본문으로 건너뛰기
9.4 SecurityContext와 Pod Security Standards

9.4 SecurityContext와 Pod Security Standards

인증과 인가를 모두 통과한 Pod라도, 노드 위에서 어떤 권한으로 실행되는지는 별개의 문제다. 컨테이너는 커널을 노드와 공유하는 프로세스 격리이지 가상 머신 수준의 보안 경계가 아니다. root로 실행되는 컨테이너에서 탈출 취약점이 악용되면 그대로 노드의 root가 된다. SecurityContext는 개별 Pod의 실행 권한을 낮추는 스펙이고, Pod Security Standards는 그 낮춤을 네임스페이스 정책으로 강제하는 표준이다.

Pod 수준과 컨테이너 수준 SecurityContext

SecurityContext는 두 위치에 적는다. spec.securityContext(Pod 수준)는 모든 컨테이너에 공통 적용되고, spec.containers[].securityContext(컨테이너 수준)는 해당 컨테이너에만 적용된다. 같은 필드가 양쪽에 있으면 컨테이너 쪽 값이 우선한다. 다만 필드마다 위치 제약이 있어, 볼륨 파일의 그룹 소유권을 지정하는 fsGroup은 Pod 수준 전용이고 capabilities와 privileged는 컨테이너 수준 전용이다.

주요 필드를 모두 적용한 예시다.

apiVersion: v1
kind: Pod
metadata:
  name: hardened
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    fsGroup: 2000
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: busybox:1.36
    command: ["sleep", "3600"]
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]

runAsUser는 컨테이너 프로세스의 UID를 지정하고, runAsNonRoot: true는 이미지가 UID 0으로 실행되려 하면 기동 자체를 거부하는 검증 필드다. 이미지의 USER 지시자를 신뢰하는 대신 클러스터 쪽에서 강제하는 수단이다. capabilities.drop: ["ALL"]은 리눅스 capability를 전부 제거한다. 컨테이너 런타임은 기본으로 CHOWN, NET_BIND_SERVICE 같은 capability 묶음을 부여하는데, 대부분의 애플리케이션은 하나도 필요로 하지 않으므로 전부 제거하고 필요한 것만 add로 되살린다.

allowPrivilegeEscalation: false는 setuid 바이너리 등을 통해 프로세스가 부모보다 높은 권한을 얻는 경로를 차단한다. readOnlyRootFilesystem: true는 루트 파일시스템을 읽기 전용으로 만들어 침입자가 바이너리를 심거나 설정을 바꾸는 것을 막는다. 애플리케이션이 임시 파일을 쓴다면 emptyDir 볼륨을 쓰기 경로에 mount해 해결한다. seccompProfile.type: RuntimeDefault는 컨테이너 런타임의 기본 seccomp 필터를 적용해 위험한 syscall 호출을 차단한다. 지정하지 않으면 seccomp이 적용되지 않는 Unconfined로 실행되므로, RuntimeDefault를 명시하는 것이 출발선이다.

privileged가 여는 것

privileged: true는 위 필드들과 차원이 다르다. 모든 capability가 부여되고, 노드의 장치 파일(/dev)에 접근하며, 커널 보안 장치의 제약 대부분이 해제된다. 이 컨테이너를 장악한 공격자는 사실상 노드의 root다. 노드의 디스크를 직접 mount해 다른 Pod의 데이터와 kubelet 자격 증명을 읽는 데까지 몇 단계 걸리지 않는다.

privileged가 정당한 곳은 CNI 플러그인, 스토리지 드라이버, 노드 모니터링 에이전트처럼 커널과 직접 상호작용해야 하는 시스템 컴포넌트다. 일반 애플리케이션 워크로드에 privileged가 등장하면 대부분 특정 capability 몇 개로 대체 가능한 과잉 요구다. allowPrivilegeEscalation과 혼동하기 쉬운데, privileged는 시작부터 전권을 주는 것이고 allowPrivilegeEscalation은 실행 중에 권한이 높아지는 것을 허용할지의 문제다.

PodSecurityPolicy는 제거됐다

오래된 자료에는 이런 제한을 클러스터 정책으로 강제하는 수단으로 PodSecurityPolicy(PSP)가 등장한다. PSP는 1.21에서 deprecated 됐고 1.25에서 완전히 제거됐다. “폐기 예정” 상태가 아니라 API 자체가 사라진 상태이므로, PSP 기반 가이드를 지금 클러스터에 적용할 방법은 없다. 대체 수단이 Pod Security Admission(PSA)이다. 1.25에서 GA 된 내장 어드미션 컨트롤러로, 별도 설치 없이 네임스페이스 라벨만으로 동작한다.

PSA가 강제하는 기준이 Pod Security Standards이며, 세 프로파일로 정의된다.

프로파일성격대표 제약
privileged무제한제약 없음
baseline알려진 권한 상승 차단privileged, hostNetwork, hostPath 금지
restricted강한 제한baseline에 더해 runAsNonRoot, capabilities 전부 drop, seccomp 필수

라벨은 프로파일과 함께 모드를 지정한다. enforce는 위반 Pod의 생성을 거부하고, audit는 감사 로그에 기록만 하며, warn은 kubectl 사용자에게 경고를 출력한다. 세 모드는 독립이라 조합해서 쓴다. 운영 중인 네임스페이스라면 enforce는 baseline으로 두고 warn을 restricted로 설정해, 지금 막지는 않되 다음 단계에서 위반이 될 지점을 미리 드러내는 식이다.

kubectl label namespace dev \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/warn=restricted

기존 네임스페이스에 enforce를 적용하기 전에는 서버 dry-run으로 영향을 미리 확인한다. 위반하게 될 기존 Pod가 경고로 나열된다.

kubectl label --dry-run=server --overwrite namespace dev \
  pod-security.kubernetes.io/enforce=restricted
enforce 모드는 Pod 오브젝트에만 작동한다. restricted를 위반하는 Deployment의 생성은 성공하고, ReplicaSet이 Pod를 만들려는 시점에 거부된다. Deployment는 정상으로 보이는데 Pod가 없는 상태가 되므로, kubectl describe replicaset의 이벤트에서 FailedCreate 사유를 확인해야 원인이 보인다.

정리

SecurityContext는 개별 Pod의 실행 권한을 낮추는 스펙이고, 그 기준을 네임스페이스 단위로 강제하는 것이 Pod Security Admission이다. PSP는 1.25에서 제거됐으므로 신규 정책은 PSA의 세 프로파일과 세 모드 조합으로 설계한다. 새 워크로드를 처음부터 restricted 프로파일을 통과하는 형태로 작성해 두면 이후 정책을 강화할 때의 비용이 줄어든다.