Certified Kubernetes Application Developer (CKAD) #15 SecurityContext와 Capabilities: runAsUser, fsGroup, readOnly rootfs
#14 ServiceAccount와 RBAC에서 컨테이너가 쿠버네티스 API에 무엇을 할 수 있는지를 제한했다면, 이번에는 그보다 한 층 아래인 컨테이너 프로세스 자체가 리눅스에서 어떤 권한으로 도는지를 제한합니다. 기본값을 그대로 두면 많은 이미지가 root로 실행되고 루트 파일시스템에 마음껏 쓸 수 있습니다. 공격자가 컨테이너를 장악하면 그 권한을 그대로 물려받습니다.
securityContext는 컨테이너가 어떤 UID/GID로 실행될지, 루트 파일시스템에 쓸 수 있는지, 어떤 리눅스 커널 기능(capability)을 가질지를 선언으로 통제하는 필드입니다. CKAD에서는 “이 컨테이너를 비루트로 실행하라”, “NET_ADMIN capability만 추가하라”, “루트 파일시스템을 읽기 전용으로 만들라” 같은 작업으로 직접 출제됩니다. 채점 스크립트가 id나 매니페스트 필드로 결과를 검사하므로, 필드 위치와 철자를 정확히 아는 것이 점수를 가릅니다.
securityContext는 두 층위에 붙는다 #
securityContext는 Pod 레벨과 컨테이너 레벨 두 곳에 모두 선언할 수 있습니다. 둘은 위치도 다르고 적용 범위도 다릅니다.
| 위치 | 경로 | 적용 범위 |
|---|---|---|
| Pod 레벨 | spec.securityContext | Pod 안 모든 컨테이너의 기본값 |
| 컨테이너 레벨 | spec.containers[].securityContext | 해당 컨테이너에만 적용 |
두 위치에 같은 필드가 있으면 컨테이너 레벨이 Pod 레벨을 덮어씁니다(override). 즉 Pod 레벨에서 공통 정책을 깔아 두고, 특정 컨테이너만 컨테이너 레벨에서 예외를 주는 식으로 씁니다.
apiVersion: v1
kind: Pod
metadata:
name: ctx-demo
spec:
securityContext: # Pod 레벨: 모든 컨테이너 기본값
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
securityContext: # 컨테이너 레벨: 이 컨테이너만 덮어씀
runAsUser: 2000위 예제에서 app 컨테이너의 프로세스는 컨테이너 레벨이 우선하므로 UID 2000으로 실행됩니다. 같은 Pod에 다른 컨테이너가 있었다면 그 컨테이너는 Pod 레벨 값인 UID 1000을 따릅니다. 한편 fsGroup처럼 컨테이너 레벨에 없는 필드(fsGroup은 Pod 레벨 전용)는 Pod 레벨 값이 그대로 적용됩니다.
또 하나 중요한 구분이 있습니다. runAsUser나 capabilities처럼 일부 필드는 컨테이너 레벨에만 있고, fsGroup이나 supplementalGroups처럼 일부 필드는 Pod 레벨에만 있습니다. 어느 필드가 어느 층위에 속하는지 헷갈리면 kubectl explain으로 즉시 확인합니다.
k explain pod.spec.securityContext
k explain pod.spec.containers.securityContext핵심 필드: 어떤 사용자로 실행할 것인가 #
가장 자주 출제되는 묶음은 실행 사용자 제어입니다.
| 필드 | 층위 | 의미 |
|---|---|---|
runAsUser | 둘 다 | 프로세스의 UID 지정 |
runAsGroup | 둘 다 | 프로세스의 기본 GID 지정 |
runAsNonRoot | 둘 다 | true면 root(UID 0) 실행을 거부 |
fsGroup | Pod | 마운트된 볼륨의 소유 그룹 GID |
supplementalGroups | Pod | 프로세스에 추가로 부여할 보조 GID 목록 |
runAsUser / runAsGroup / runAsNonRoot #
runAsUser는 컨테이너 안 메인 프로세스가 어떤 UID로 뜰지를 지정합니다. runAsNonRoot: true는 한 발 더 나아가, 이미지가 root로 뜨도록 만들어졌으면 컨테이너를 아예 시작하지 않고 실패시킵니다. 비루트 실행을 강제하고 싶을 때 가장 확실한 방어선입니다.
apiVersion: v1
kind: Pod
metadata:
name: nonroot-demo
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "id && sleep 3600"]
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000runAsNonRoot: true만 두고 runAsUser를 비워 두면, 이미지의 기본 USER가 root인 경우 컨테이너가 CreateContainerConfigError로 뜨지 않습니다. 비루트 UID를 함께 지정하는 편이 안전합니다.
fsGroup: 볼륨 소유 그룹 #
비루트로 실행하면 마운트된 볼륨에 쓰지 못하는 문제가 흔히 생깁니다. fsGroup을 지정하면 쿠버네티스가 마운트 시점에 볼륨의 그룹 소유권을 그 GID로 바꾸고, 프로세스에 그 GID를 보조 그룹으로 부여합니다. 그 결과 비루트 프로세스도 볼륨에 쓸 수 있게 됩니다.
apiVersion: v1
kind: Pod
metadata:
name: fsgroup-demo
spec:
securityContext:
runAsUser: 1000
fsGroup: 2000 # /data의 그룹 소유가 2000으로 바뀜
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "touch /data/test && ls -l /data && sleep 3600"]
volumeMounts:
- name: scratch
mountPath: /data
volumes:
- name: scratch
emptyDir: {}이 Pod가 뜨면 /data의 그룹이 2000으로 설정되어, UID 1000으로 도는 프로세스가 touch에 성공합니다. supplementalGroups는 비슷하지만 볼륨 소유권을 바꾸지 않고 프로세스에 보조 GID만 추가합니다.
파일시스템 권한 통제 #
readOnlyRootFilesystem #
컨테이너 루트 파일시스템을 읽기 전용으로 만들면, 공격자가 침투해도 바이너리를 심거나 설정을 변조하기 어렵습니다. readOnlyRootFilesystem: true는 컨테이너 레벨 필드입니다.
securityContext:
readOnlyRootFilesystem: true문제는 많은 앱이 /tmp나 캐시 디렉터리에 쓰기를 해야 정상 동작한다는 점입니다. 이때는 루트는 읽기 전용으로 두되, 쓰기가 필요한 경로에만 emptyDir를 마운트해 우회합니다.
apiVersion: v1
kind: Pod
metadata:
name: ro-rootfs
spec:
containers:
- name: app
image: nginx:1.27
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /var/run
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
- name: run
emptyDir: {}루트는 읽기 전용이지만 /tmp,/var/cache/nginx,/var/run은 emptyDir라 쓰기가 가능합니다. 이 패턴은 “루트 파일시스템을 읽기 전용으로 만들되 앱이 계속 동작하게 하라"는 형태로 출제됩니다.
allowPrivilegeEscalation #
allowPrivilegeEscalation: false는 프로세스가 자신보다 높은 권한을 얻는 것(예 setuid 바이너리 실행)을 막습니다. 비루트 실행과 함께 두면 권한 상승 경로를 한 번 더 닫습니다. 컨테이너 레벨 필드입니다.
securityContext:
allowPrivilegeEscalation: falseLinux capabilities #
리눅스는 root의 권한을 잘게 쪼갠 capability 단위로 관리합니다. 컨테이너 런타임은 기본적으로 일부 capability만 부여하는데, securityContext.capabilities로 개별 추가(add) 와 제거(drop) 를 할 수 있습니다. 이 필드는 컨테이너 레벨 전용입니다.
securityContext:
capabilities:
add: ["NET_ADMIN", "SYS_TIME"]
drop: ["ALL"]값은 CAP_ 접두사를 떼고 적습니다(예 CAP_NET_ADMIN이 아니라 NET_ADMIN). 보안 모범 사례는 **drop: ["ALL"]로 전부 제거한 뒤 꼭 필요한 것만 add**하는 최소 권한 방식입니다.
| capability | 용도 예시 |
|---|---|
NET_ADMIN | 네트워크 인터페이스,라우팅,iptables 조작 |
SYS_TIME | 시스템 시계 변경 |
CHOWN | 파일 소유권 변경 |
NET_BIND_SERVICE | 1024 미만 포트 바인딩 |
drop과 add를 함께 쓰면 둘이 충돌하지 않습니다. 먼저 모두 떨어뜨린 다음 명시한 것만 다시 붙는다고 이해하면 됩니다.
apiVersion: v1
kind: Pod
metadata:
name: cap-demo
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
securityContext:
capabilities:
drop: ["ALL"]
add: ["NET_ADMIN"]privileged: true의 위험 #
privileged: true는 컨테이너에 호스트의 거의 모든 권한을 부여합니다. 모든 capability를 켜고 디바이스 접근까지 열어, 사실상 컨테이너 격리를 무너뜨립니다. 시험이나 실무에서 명시적으로 요구하지 않는 한 절대 켜지 않습니다. CKAD에서는 “privileged를 끄라” 또는 “최소 권한으로 바꾸라"는 방향으로 나오지, 켜라고 나오는 경우는 드뭅니다.
securityContext:
privileged: false # 기본값이자 권장값seccompProfile #
seccompProfile은 컨테이너가 호출할 수 있는 시스템 콜을 제한합니다. CKAD에서는 RuntimeDefault 프로파일을 거는 한 줄 정도만 알면 충분하고, 커스텀 프로파일과 상세 운영은 CKS의 영역입니다.
securityContext:
seccompProfile:
type: RuntimeDefault결과 검증 #
매니페스트를 적용한 뒤에는 컨테이너 안에서 실제 실행 신원을 확인해 채점 기준과 맞는지 봅니다.
# UID/GID와 보조 그룹 확인
k exec ctx-demo -- id
# 사용자 이름 확인 (UID만 있고 이름이 없으면 숫자로 표시될 수 있음)
k exec nonroot-demo -- whoami
# 볼륨 그룹 소유권 확인
k exec fsgroup-demo -- ls -ld /data
# 읽기 전용 루트 확인 (쓰기 시도 시 실패해야 정상)
k exec ro-rootfs -c app -- touch /test 2>&1 || echo "read-only 확인"id 출력의 uid,gid,groups가 매니페스트에 적은 값과 일치하면 적용이 끝난 것입니다. runAsNonRoot: true인데 컨테이너가 CreateContainerConfigError로 뜬다면, 이미지가 root로 실행되도록 만들어졌다는 신호이므로 비루트 UID를 명시합니다.
종합 예제 #
비루트 실행, 읽기 전용 루트 파일시스템, 최소 capability를 한 번에 적용한 매니페스트입니다. 시험에서 요구하는 “보안을 강화한 Pod"의 전형적인 형태입니다.
apiVersion: v1
kind: Pod
metadata:
name: hardened
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 2000
containers:
- name: app
image: nginx:1.27
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: run
mountPath: /var/run
volumes:
- name: tmp
emptyDir: {}
- name: run
emptyDir: {}이 Pod는 비루트 UID 1000으로 뜨고, 권한 상승이 막혀 있으며, 모든 capability를 떨어뜨린 뒤 1024 미만 포트 바인딩에 필요한 NET_BIND_SERVICE만 다시 붙였습니다. 루트는 읽기 전용이지만 /tmp,/var/run에 emptyDir를 붙여 동작에 지장이 없습니다.
시험 포인트 #
securityContext는 Pod 레벨(spec.securityContext)과 컨테이너 레벨(spec.containers[].securityContext) 두 곳에 있고, 컨테이너 레벨이 Pod 레벨을 덮어쓴다.runAsUser,runAsGroup,runAsNonRoot,capabilities,readOnlyRootFilesystem,allowPrivilegeEscalation,privileged는 컨테이너 레벨,fsGroup,supplementalGroups는 Pod 레벨 전용이다.- capability 값은
CAP_접두사를 떼고 적는다. 모범 답안은drop: ["ALL"]후 필요한 것만add. readOnlyRootFilesystem: true로 쓰기가 막히면emptyDir를 쓰기 경로에 마운트해 우회한다.runAsNonRoot: true만 두고 비루트 UID를 안 주면 root 이미지가 시작에 실패할 수 있으니runAsUser를 함께 지정한다.- 검증은
k exec -- id와k exec -- whoami. 채점 전에 실제 UID/GID를 눈으로 확인한다. - 필드 위치가 헷갈리면
k explain pod.spec.securityContext와k explain pod.spec.containers.securityContext로 즉시 확인한다.
정리 #
이번 글에서 잡은 것:
- securityContext의 두 층위. Pod 레벨은 공통 기본값, 컨테이너 레벨은 예외이며 컨테이너 레벨이 우선
- 실행 사용자 제어.
runAsUser,runAsGroup,runAsNonRoot로 비루트 강제,fsGroup으로 볼륨 쓰기 권한 확보 - 파일시스템 통제.
readOnlyRootFilesystem+emptyDir우회,allowPrivilegeEscalation: false - capabilities.
drop: ["ALL"]후 최소add,privileged: true는 격리를 무너뜨리므로 회피 - 검증.
k exec -- id,whoami로 실제 신원 확인
다음: 리소스 관리 #
컨테이너의 권한을 좁혔으니, 이제 컨테이너가 얼마나 많은 자원을 쓸 수 있는지를 통제할 차례입니다.
#16 리소스 관리: requests/limits, QoS class, LimitRange에서는 CPU,메모리의 requests와 limits가 스케줄링과 OOM에 미치는 영향, 그 조합으로 결정되는 QoS class(Guaranteed,Burstable,BestEffort), 네임스페이스 단위 기본값을 거는 LimitRange와 ResourceQuota까지 직접 만들어 보며 정리하겠습니다.