Step 20 — 보안: SecurityContext와 Pod Security Standards

학습 목표

  • SecurityContext의 핵심 필드(runAsNonRoot / runAsUser / readOnlyRootFilesystem / allowPrivilegeEscalation / capabilities / seccomp)를 이해하고 적용한다.
  • **Pod Security Standards(PSS)**와 **Pod Security Admission(PSA)**로 네임스페이스 단위 정책을 강제한다. enforce=restricted 네임스페이스가 위반 파드 생성을 거부하는 것을 실증한다.
  • privileged 파드가 왜 위험한지 이해한다.
  • 이미지 보안(latest 금지, 취약점 스캔)과 시크릿 관리 원칙을 잡는다.

선행 지식

  • Step 03(Pod), Step 07(Secret), Step 16(RBAC). RBAC가 "누가 API를 호출할 수 있나"라면, 이 스텝은 "파드가 노드에서 무슨 짓을 할 수 있나"입니다.

소요 시간

  • 35분

1. 왜 컨테이너 보안인가

컨테이너는 격리되어 있다고 하지만, 커널은 호스트와 공유합니다. 컨테이너가 root로 돌고 권한이 넓으면, 커널 취약점이나 마운트 실수 하나로 호스트 전체를 장악당할 수 있습니다. 보안의 원칙은 하나입니다: 최소 권한. 파드에 꼭 필요한 권한만 주고 나머지는 전부 뺍니다.

방어선은 두 층입니다.

  • SecurityContext — 파드/컨테이너 스펙에 직접 쓰는 권한 설정 (작성자가 지킴)
  • Pod Security Admission — 네임스페이스 정책으로 위반을 강제 차단 (클러스터가 지킴)

2. SecurityContext — 최소 권한으로 파드 가두기

핵심 필드:

필드권장
runAsNonRoot: trueroot(uid 0) 실행 금지필수
runAsUser: 10001지정한 비특권 UID로 실행권장
readOnlyRootFilesystem: true루트FS 읽기 전용(침해 후 변조 방지)권장
allowPrivilegeEscalation: falsesetuid 등으로 권한 상승 금지필수
capabilities.drop: ["ALL"]모든 리눅스 capability 제거필수
seccompProfile.type: RuntimeDefault위험한 syscall 차단필수
privileged: true호스트 커널 완전 접근 — 절대 금지금지

이 모두를 만족하는 파드(manifests/good-pod.yaml, 전문은 아래 실습 파일 섹션에 있습니다):

spec:
  securityContext:              # 파드 레벨 (모든 컨테이너에 적용)
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: busybox:1.36
      securityContext:          # 컨테이너 레벨 (더 좁게)
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: scratch         # 루트FS 가 읽기 전용이므로 쓰기는 emptyDir 로
          mountPath: /data
  volumes:
    - name: scratch
      emptyDir: {}

실제 실행 결과:

kubectl apply -f manifests/good-pod.yaml
kubectl logs good -n step20 --tail=1
running as uid=10001, rootfs is read-only

readOnlyRootFilesystem을 실증 — 루트에 쓰기를 시도하면 실패합니다:

kubectl exec good -n step20 -- sh -c 'echo x > /root_test.txt; id'
sh: can't create /root_test.txt: Read-only file system
uid=10001 gid=10001 groups=10001

함정 — 포트 80 바인딩: runAsNonRoot로 돌리면 nginx가 80 포트를 못 엽니다(1024 미만은 특권 포트). 그래서 보안 이미지는 8080 같은 상위 포트를 쓰거나 NET_BIND_SERVICE capability만 예외로 add합니다. 이 실습이 busybox를 쓴 이유입니다.


3. Pod Security Standards — 3단계 정책

작성자가 SecurityContext를 빠뜨릴 수 있으니, 클러스터가 강제합니다. 쿠버네티스는 세 단계 표준을 내장합니다.

표준
privileged제한 없음 (아무거나 허용)
baseline알려진 위험(privileged, hostNetwork 등)만 차단
restricted최소 권한 강제 (위 2장의 필드를 모두 요구)

이걸 네임스페이스 레이블로 적용합니다(PSA). 각 표준을 세 가지 모드로 걸 수 있습니다:

  • enforce — 위반 파드 생성 거부
  • warn — 생성은 허용하되 경고 메시지 출력 (사용자에게)
  • audit — 감사 로그에 기록

manifests/namespace.yaml:

metadata:
  name: step20
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

4. 위반 파드 생성 거부 실증

일반 nginx 파드(manifests/bad-pod.yaml) — SecurityContext가 하나도 없습니다. restricted 네임스페이스에 넣으면:

kubectl apply -f manifests/bad-pod.yaml

실제 출력 (거부됨):

Error from server (Forbidden): error when creating "manifests/bad-pod.yaml":
pods "bad" is forbidden: violates PodSecurity "restricted:latest":
  allowPrivilegeEscalation != false (container "nginx" must set securityContext.allowPrivilegeEscalation=false),
  unrestricted capabilities (container "nginx" must set securityContext.capabilities.drop=["ALL"]),
  runAsNonRoot != true (pod or container "nginx" must set securityContext.runAsNonRoot=true),
  seccompProfile (pod or container "nginx" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

에러 메시지가 정확히 무엇을 고쳐야 하는지 알려줍니다. 이게 PSA의 큰 장점입니다.

privileged 파드(manifests/privileged-pod.yaml)는 더 강하게 거부됩니다:

violates PodSecurity "restricted:latest": privileged (container "nginx" must not set securityContext.privileged=true), ...

반면 good 파드는 통과합니다:

pod/good created
NAME   READY   STATUS    RESTARTS   AGE
good   1/1     Running   0          8s

5. 중요한 함정 — Deployment는 만들어지고, 파드만 거부된다

PSA는 파드 생성 시점에 검사합니다. Deployment를 apply하면 Deployment 자체는 만들어지고(경고만 출력), 그 아래 ReplicaSet이 파드를 만들려다 거부됩니다. 결과는 READY 0/1인 유령 Deployment입니다.

kubectl apply -f bad-deploy.yaml
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false ...
deployment.apps/bad-deploy created         # ← 생성은 됨!
kubectl get deploy bad-deploy -n step20
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
bad-deploy   0/1     0            0           4s

거부 사유는 ReplicaSet 이벤트에 있습니다:

kubectl get events -n step20 --field-selector reason=FailedCreate
Warning  FailedCreate  replicaset/bad-deploy-84988d55c9  Error creating: pods "..." is forbidden:
  violates PodSecurity "restricted:latest": ...

교훈: kubectl apply가 성공(created)해도 파드가 안 뜰 수 있습니다. Deployment의 READY 0/1을 보면 반드시 ReplicaSet 이벤트를 확인하세요. apply가 통과한 것과 파드가 뜬 것은 다릅니다(이 코스가 반복해서 강조하는 지점).


6. privileged 파드가 왜 위험한가

privileged: true는 컨테이너에 다음을 줍니다:

  • 호스트의 모든 장치(/dev) 접근
  • 모든 capability 보유 (CAP_SYS_ADMIN 등)
  • seccomp/AppArmor 필터 우회

즉 컨테이너 탈옥으로 호스트 파일시스템 마운트 → 노드 장악 → 클러스터 전체 장악의 발판이 됩니다. CNI/스토리지 플러그인 같은 극소수 시스템 컴포넌트 외에는 절대 쓰지 마세요. baseline 표준도 이미 privileged를 금지합니다.


7. 이미지 보안

  • latest 태그 금지: nginx:latest는 배포마다 내용이 바뀔 수 있어 재현 불가능하고, 롤백 대상이 모호합니다. 불변 태그(nginx:1.27-alpine)나 다이제스트(nginx@sha256:...)를 쓰세요. 이 코스가 모든 이미지에 버전 태그를 붙인 이유입니다.
  • 취약점 스캔: Trivy, Grype, Clair 등으로 이미지의 CVE를 CI에서 스캔합니다. trivy image nginx:1.27-alpine 한 줄이면 알려진 취약점 목록이 나옵니다.
  • 최소 베이스 이미지: alpine, distroless처럼 셸·패키지 매니저가 없는 이미지는 공격 표면이 작습니다.
  • imagePullPolicy와 서명: 프로덕션에서는 이미지 서명 검증(cosign/Sigstore)과 신뢰된 레지스트리만 허용하는 admission(예: Kyverno)을 겁니다.

8. 시크릿 관리 원칙

Secret은 암호화가 아니라 base64 인코딩일 뿐입니다(manifests/secret-demo.yaml로 파드에 주입 실습). 원칙:

  • Secret YAML을 git에 커밋하지 말 것. base64는 누구나 디코딩합니다.
  • etcd 저장 암호화(EncryptionConfiguration)를 켜서 저장 시점 암호화.
  • RBAC로 Secret 접근 제한(Step 16). Secret을 읽을 수 있는 SA를 최소화.
  • 외부 시크릿 매니저 연동: External Secrets Operator + Vault/AWS Secrets Manager로 실제 비밀은 클러스터 밖에 두고 동기화.
  • 환경변수보다 볼륨 마운트가 나은 경우가 많습니다(env는 /proc/<pid>/environ, 로그, describe로 새기 쉬움).
kubectl apply -f manifests/secret-demo.yaml
kubectl logs secret-consumer -n step20 --tail=1     # secret len=20

파드는 값을 쓰지만, 소스 YAML에는 평문이 보입니다 — 그래서 커밋하면 안 됩니다.


팁과 함정

  • 함정 1 — apply 성공 ≠ 파드 실행: enforce 네임스페이스에서 Deployment는 만들어지고 파드만 거부됩니다. READY 0/N이면 ReplicaSet 이벤트를 보세요.
  • 함정 2 — restricted에서 nginx가 안 뜸: 포트 80 바인딩·/var/cache/nginx 쓰기 때문입니다. 상위 포트 + emptyDir 마운트 + (필요시) NET_BIND_SERVICE만 add로 해결.
  • 함정 3 — 파드/컨테이너 레벨 혼동: runAsNonRoot·seccompProfile·fsGroup파드 securityContext, readOnlyRootFilesystem·capabilities·allowPrivilegeEscalation컨테이너 securityContext. 위치를 틀리면 무시됩니다.
  • 팁 — 먼저 warn으로 시작: 기존 네임스페이스에 갑자기 enforce=restricted를 걸면 배포가 멈춥니다. warn/audit로 먼저 위반을 파악하고, 워크로드를 고친 뒤 enforce로 승격하세요.
  • 팁 — 클러스터 기본값: --admission-control-config-file로 레이블 없는 네임스페이스의 기본 정책을 정할 수 있습니다.

정리표

항목값/명령
root 금지securityContext.runAsNonRoot: true (파드 레벨)
권한 상승 금지allowPrivilegeEscalation: false (컨테이너 레벨)
capability 제거capabilities.drop: ["ALL"]
syscall 필터seccompProfile.type: RuntimeDefault
루트FS 잠금readOnlyRootFilesystem: true + 쓰기용 emptyDir
네임스페이스 정책pod-security.kubernetes.io/enforce: restricted
정책 모드enforce(거부) / warn(경고) / audit(감사)
이미지불변 태그, 취약점 스캔, distroless

연습 과제

challenge.md의 4개 과제.


다음 단계

Step 21 — 가용성과 중단 관리: PodDisruptionBudget과 PriorityClass, cordon/drain으로 노드 유지보수 중에도 서비스를 지킵니다.


실습 파일

이 스텝의 실습은 먼저 manifests/namespace.yamlenforce=restricted 네임스페이스 step20을 만드는 것에서 시작합니다. 그다음 정책을 위반하는 bad-pod.yaml·privileged-pod.yaml을 일부러 적용해 거부되는 것을 관찰하고, 정책을 만족하는 good-pod.yaml이 정상 기동하는 것을 확인합니다. 마지막으로 secret-demo.yaml로 시크릿 주입까지 본 뒤, 이 모든 흐름을 한 번에 재현하는 commands.sh로 전체를 검증합니다.

manifests/namespace.yaml

  • 이 스텝의 모든 실습이 벌어지는 무대인 step20 네임스페이스를 만듭니다. 가장 먼저 적용해야 하는 파일입니다. 이 네임스페이스가 없으면 나머지 매니페스트는 namespace: step20을 찾지 못해 전부 실패합니다.
  • 3장에서 설명한 PSA 레이블 세 개를 모두 겁니다. pod-security.kubernetes.io/enforce: restricted위반 파드의 생성을 실제로 거부하는 유일한 레이블이고, warn은 사용자 터미널에 경고를, audit은 API 서버 감사 로그에 기록을 남깁니다(둘 다 생성 자체는 막지 않습니다).
  • pod-security.kubernetes.io/enforce-version: latest는 "현재 클러스터가 아는 최신 버전의 restricted 정의"를 쓰겠다는 뜻입니다. 4장의 에러 메시지에 나오는 violates PodSecurity "restricted:latest"latest가 바로 이 값입니다. 운영에서는 클러스터 업그레이드 때 정책이 조용히 엄격해지는 것을 막기 위해 v1.29처럼 버전을 고정하기도 합니다.
  • course: k8s-learn, step: "20" 레이블은 정책과 무관한 정리용 태그입니다.
# step20 네임스페이스에 Pod Security Admission(PSA) 레이블을 건다.
# enforce=restricted : 가장 엄격한 표준을 위반하는 파드는 "생성 자체가 거부"된다.
# warn/audit=restricted : 위반 시 경고 메시지/감사 로그를 남긴다(생성은 허용).
apiVersion: v1
kind: Namespace
metadata:
  name: step20
  labels:
    course: k8s-learn
    step: "20"
    # Pod Security Standards: privileged < baseline < restricted
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

manifests/good-pod.yaml

  • 2장의 표에 나온 필드를 전부 만족하도록 만든 모범 파드입니다. restricted 네임스페이스에서 유일하게 통과하는 파드이므로, 위반 파드들을 본 뒤 "그럼 어떻게 써야 하나"의 정답으로 적용합니다.
  • 파드 레벨 securityContext에는 runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile.type: RuntimeDefault를, 컨테이너 레벨에는 allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities.drop: ["ALL"]을 둡니다. 함정 3에서 강조한 "어느 필드가 어느 레벨인가"의 정답 예시가 바로 이 파일입니다.
  • 이미지가 nginx가 아니라 busybox:1.36인 이유가 핵심입니다. runAsNonRoot로 uid 10001이 되면 1024 미만 특권 포트(80)를 열 수 없기 때문에, 포트 바인딩이 필요 없는 busybox를 골랐습니다.
  • readOnlyRootFilesystem: true이므로 컨테이너는 /에 한 글자도 쓸 수 없습니다. 그래서 루프가 로그를 쌓는 경로 /dataemptyDir 볼륨 scratch를 마운트했습니다. while true; do echo "$(date +%T) tick" >> /data/log.txt; sleep 5; done 부분이 **"쓰기가 필요한 곳만 볼륨으로 뚫는다"**는 원칙의 실물입니다.
  • 시작 시 출력하는 running as uid=$(id -u), rootfs is read-only 한 줄이 2장의 kubectl logs good -n step20 --tail=1 결과와 대응합니다.
# restricted 표준을 "만족"하는 파드. enforce=restricted 네임스페이스에서 정상 생성된다.
# 핵심: 포트 80 바인딩이 필요 없는 busybox 로 두고, 루트FS 는 읽기 전용,
#       쓰기가 필요한 곳은 emptyDir 로 마운트한다.
apiVersion: v1
kind: Pod
metadata:
  name: good
  namespace: step20
spec:
  securityContext:
    runAsNonRoot: true          # root(uid 0) 실행 금지
    runAsUser: 10001            # 비특권 UID
    runAsGroup: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault      # 커널 시스템콜 필터(위험한 syscall 차단)
  containers:
    - name: app
      image: busybox:1.36
      command: ["/bin/sh", "-c"]
      args:
        - |
          echo "running as uid=$(id -u), rootfs is read-only"
          # 루트FS 는 읽기 전용이므로 여기(마운트된 쓰기 볼륨)에만 쓸 수 있다
          while true; do echo "$(date +%T) tick" >> /data/log.txt; sleep 5; done
      securityContext:
        allowPrivilegeEscalation: false   # setuid 등으로 권한 상승 금지
        readOnlyRootFilesystem: true      # 루트 파일시스템 읽기 전용
        capabilities:
          drop: ["ALL"]                   # 모든 리눅스 capability 제거
      volumeMounts:
        - name: scratch
          mountPath: /data
  volumes:
    - name: scratch
      emptyDir: {}

manifests/bad-pod.yaml

  • 일부러 정책을 위반하도록 만든 파일입니다. 4장에서 "거부되는 모습"을 보기 위해 적용하며, Error from server (Forbidden)이 뜨는 것이 정상 결과입니다.
  • 위반 요소는 사실상 "아무것도 안 쓴 것" 그 자체입니다. securityContext 블록이 통째로 없어서 ① root(uid 0)로 실행되고(runAsNonRoot != true), ② allowPrivilegeEscalation이 기본값 true이며, ③ capability가 하나도 드롭되지 않았고, ④ seccompProfile이 지정되지 않았습니다. PSA는 이 네 가지를 한 번에 모두 에러 메시지로 열거해 줍니다.
  • containerPort: 80은 위반 사유는 아니지만, runAsNonRoot를 지키는 순간 이 nginx가 왜 80을 못 여는지(함정 2) 생각해 보게 하는 장치입니다.
  • 흔한 오해: 이 파일이 "문법적으로 틀린" 게 아닙니다. 정책 없는 네임스페이스에서는 아무 문제 없이 뜹니다. 같은 YAML의 운명이 네임스페이스 레이블 하나로 갈린다는 점이 학습 포인트입니다(과제 2에서 enforce를 떼면 실제로 생성됩니다).
# restricted 표준을 위반하는 파드. enforce=restricted 네임스페이스에서는
# apply 시점에 admission 이 "거부"한다(파드가 아예 안 만들어진다).
# 위반 요소: securityContext 없음(root 로 실행), allowPrivilegeEscalation 기본 true,
#            capabilities 미드롭, seccompProfile 미지정.
apiVersion: v1
kind: Pod
metadata:
  name: bad
  namespace: step20
spec:
  containers:
    - name: nginx
      image: nginx:1.27-alpine
      ports:
        - containerPort: 80

manifests/privileged-pod.yaml

  • 6장의 "privileged가 왜 위험한가"를 실증하는 의도적 위험 파일입니다. bad-pod보다 한 단계 더 나쁜 사례로, 역시 거부되는 것이 정상입니다.
  • securityContext.privileged: true 한 줄이 컨테이너에 호스트의 모든 장치(/dev) 접근, 모든 capability(CAP_SYS_ADMIN 포함), seccomp/AppArmor 우회를 한꺼번에 줍니다. 여기에 allowPrivilegeEscalation: true까지 명시해 권한 상승도 열어 둔 상태입니다.
  • 거부 메시지가 bad-pod과 다릅니다. privileged (container "nginx" must not set securityContext.privileged=true)가 맨 앞에 추가로 붙습니다. restricted뿐 아니라 한 단계 아래인 baseline 표준도 이미 privileged를 금지하므로, 어떤 정책을 걸든 막히는 유형입니다.
  • 주의: 정책이 없는 네임스페이스(예: default)에서는 이 파드가 실제로 뜹니다. 호스트 커널 접근 권한을 가진 컨테이너가 생기는 것이므로, 실습이라도 step20 밖에서 적용하지 마세요.
# privileged 파드: 호스트 커널에 거의 완전한 접근 권한. 컨테이너 격리를 사실상 무력화.
# restricted 네임스페이스에서는 당연히 거부된다. (위험성 실증용)
apiVersion: v1
kind: Pod
metadata:
  name: privileged
  namespace: step20
spec:
  containers:
    - name: nginx
      image: nginx:1.27-alpine
      securityContext:
        privileged: true          # ★ 호스트 장치/커널 접근. 탈옥의 지름길.
        allowPrivilegeEscalation: true

manifests/secret-demo.yaml

  • 8장의 시크릿 관리 원칙을 눈으로 확인하는 파일입니다. Secret과 그것을 소비하는 Pod---로 이어 붙인 두 개의 오브젝트로 되어 있습니다.
  • stringDataDB_PASSWORD: "s3cr3t-do-not-commit"평문으로 적었습니다. API 서버가 이를 base64로 인코딩해 저장할 뿐, 암호화하지는 않습니다. 값 자체가 "커밋하지 마라"라고 말하고 있는 이유입니다 — 이 파일은 git에 올리면 안 되는 예시의 표본입니다.
  • secret-consumer 파드는 env.valueFrom.secretKeyRef로 시크릿을 환경변수에 주입한 뒤, echo secret len=${#DB_PASSWORD}값이 아니라 길이만 출력합니다(secret len=20). 로그에 비밀을 흘리지 않으면서 주입이 성공했음을 보여 주는 안전한 확인법입니다.
  • 이 파드 역시 runAsNonRoot/seccompProfile(파드 레벨)과 allowPrivilegeEscalation: false/readOnlyRootFilesystem: true/capabilities.drop: ["ALL"](컨테이너 레벨)을 모두 갖추고 있습니다. restricted 네임스페이스에서 돌려야 하므로 시크릿 실습 파드도 예외 없이 정책을 지켜야 한다는 점을 보여 줍니다.
  • 8장이 지적하듯 env 주입은 kubectl describe/proc/<pid>/environ으로 새기 쉽습니다. 실무에서는 볼륨 마운트 방식을 우선 검토하세요.
# 시크릿 관리 원칙 데모용. 실제 운영에서는 이 YAML 을 git 에 커밋하면 안 된다
# (base64 는 암호화가 아니라 인코딩일 뿐이다). 원칙은 README 8장 참고.
apiVersion: v1
kind: Secret
metadata:
  name: app-secret
  namespace: step20
type: Opaque
stringData:                 # stringData: 평문으로 적으면 API 서버가 base64 로 저장
  DB_PASSWORD: "s3cr3t-do-not-commit"
---
apiVersion: v1
kind: Pod
metadata:
  name: secret-consumer
  namespace: step20
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: busybox:1.36
      command: ["/bin/sh", "-c", "echo secret len=${#DB_PASSWORD}; sleep 3600"]
      env:
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: app-secret
              key: DB_PASSWORD
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]

commands.sh

  • 위 매니페스트 다섯 개(namespacebad-podprivileged-podgood-podsecret-demo)를 강의 순서 그대로 실행해 이 스텝 전체를 한 번에 재현하는 스크립트입니다. 본문에 인용된 "실제 출력"들이 모두 이 스크립트의 결과입니다. 경로는 HERE="$(cd "$(dirname "$0")" && pwd)"로 스크립트 위치를 기준 삼으므로 어느 디렉터리에서 실행해도 동작합니다.
  • 4번째 줄 set -uo pipefail에서 -e를 일부러 뺀 것이 이 파일의 백미입니다. bad-pod/privileged-podkubectl apply는 admission 거부로 **비정상 종료(exit code ≠ 0)**하는데, -e가 켜져 있으면 스크립트가 거기서 죽어 버립니다. 여기서는 거부되는 것이 관찰 대상이므로 계속 진행해야 합니다.
  • 3번 단계에서 히어독으로 /tmp/bad-deploy.yaml을 즉석 생성해 5장의 함정을 재현합니다. kubectl applydeployment.apps/bad-deploy created성공하지만, kubectl get deployREADY 0/1이고 진짜 사유는 kubectl get events -n step20 --field-selector reason=FailedCreate에만 나옵니다. sleep 4는 ReplicaSet이 파드 생성을 시도해 이벤트가 찍힐 시간을 벌어 줍니다.
  • kubectl exec good -n step20 -- sh -c 'echo x > /root_test.txt; id' || truereadOnlyRootFilesystem을 실증합니다. Read-only file system 에러가 나는 게 성공이므로 || true로 실패를 삼킵니다.
  • 주의 — 마지막 줄이 파괴적입니다: kubectl delete namespace step20이 실습 리소스를 전부 지웁니다. 결과를 천천히 살펴보려면 이 줄을 빼고 실행하고, 나중에 수동으로 지우세요. 또한 sleep 8/sleep 6은 로컬 kind/minikube 기준이라 이미지 풀이 느린 환경에서는 kubectl logs가 아직 준비되지 않았다는 에러를 낼 수 있습니다.
  • 3번째 줄 export PATH="/opt/homebrew/bin:$PATH"는 macOS(Apple Silicon) + Homebrew 환경 전제입니다. 다른 환경이라면 kubectl 경로에 맞게 조정하세요.
#!/usr/bin/env bash
# Step 20 — 보안 (SecurityContext + Pod Security Admission)
export PATH="/opt/homebrew/bin:$PATH"
set -uo pipefail   # -e 는 빼둔다: 거부(비정상 종료)를 일부러 관찰하기 때문
HERE="$(cd "$(dirname "$0")" && pwd)"

# enforce=restricted 네임스페이스
kubectl apply -f "$HERE/manifests/namespace.yaml"

# 1) 위반 파드 -> 거부됨 (에러가 정상 결과)
echo "=== bad-pod (거부 예상) ==="
kubectl apply -f "$HERE/manifests/bad-pod.yaml"
echo "=== privileged-pod (거부 예상) ==="
kubectl apply -f "$HERE/manifests/privileged-pod.yaml"

# 2) 준수 파드 -> 생성됨
echo "=== good-pod (허용 예상) ==="
kubectl apply -f "$HERE/manifests/good-pod.yaml"
sleep 8
kubectl get pod good -n step20
kubectl logs good -n step20 --tail=1
# readOnlyRootFilesystem 실증
kubectl exec good -n step20 -- sh -c 'echo x > /root_test.txt; id' || true

# 3) Deployment 는 만들어지고 파드만 거부되는 함정
cat > /tmp/bad-deploy.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: bad-deploy, namespace: step20}
spec:
  replicas: 1
  selector: {matchLabels: {app: bad-deploy}}
  template:
    metadata: {labels: {app: bad-deploy}}
    spec:
      containers: [{name: nginx, image: nginx:1.27-alpine}]
EOF
kubectl apply -f /tmp/bad-deploy.yaml
sleep 4
kubectl get deploy bad-deploy -n step20                 # READY 0/1
kubectl get events -n step20 --field-selector reason=FailedCreate --sort-by='.lastTimestamp' | tail -2
kubectl delete deploy bad-deploy -n step20

# 4) 시크릿 주입
kubectl apply -f "$HERE/manifests/secret-demo.yaml"
sleep 6
kubectl logs secret-consumer -n step20 --tail=1

# 정리
kubectl delete namespace step20