Certified Kubernetes Security Specialist (CKS) #18 Container immutability, forensics
#17 Falco 행동 분석, audit logs에서는 런타임에서 이상 행동을 탐지하고 감사 로그를 남기는 법을 다뤘습니다. 탐지는 “무언가 잘못되고 있다"를 알려 줄 뿐입니다. 이번 글은 같은 Runtime Security도메인에서 그 앞과 뒤, 즉 **애초에 컨테이너를 바꾸지 못하게 굳히는 불변성(immutability)**과 **이미 뚫린 다음의 사고 대응(forensics)**을 정리하겠습니다.
불변성과 사고 대응은 한 쌍입니다. 컨테이너를 불변으로 굳혀 두면 공격자가 안에서 바이너리를 심거나 설정을 바꾸기가 어려워지고, 만약 침해가 일어나더라도 “원래 무엇이 있었는가"라는 기준선이 또렷해 조사가 쉬워집니다. 시험에서도 이 두 가지가 묶여 나옵니다. 불변 설정을 적용하는 작업과, 침해된 Pod를 격리하고 증거를 보존하는 절차입니다.
불변 컨테이너란 무엇인가 #
불변 컨테이너(immutable container)는 실행이 시작된 뒤로는 그 내용이 바뀌지 않는 컨테이너입니다. 코드를 고치거나 설정을 바꾸려면 컨테이너 안으로 들어가 파일을 수정하는 것이 아니라, 새 이미지를 빌드해 재배포합니다. 살아 있는 컨테이너를 손보는 일은 없습니다.
이 사고방식은 보안과 직결됩니다. 컨테이너가 침해되었을 때 공격자가 가장 먼저 하려는 일이 안에 무언가를 심는 것입니다. 코인 채굴 바이너리를 내려받아 실행하고, 백도어 스크립트를 /tmp나 시스템 경로에 쓰고, 기존 바이너리를 악성으로 덮어씌웁니다. 파일시스템이 읽기 전용이면 이 모든 쓰기 시도가 실패합니다.
쿠버네티스에서 불변성을 강제하는 핵심 설정은 컨테이너의 securityContext.readOnlyRootFilesystem: true입니다. #8 kernel hardening에서 권한을 깎는 securityContext 필드를 함께 다뤘는데, 그 가운데 이 필드가 불변성의 출발점입니다.
readOnlyRootFilesystem이 막는 것 #
이 필드를 켜면 컨테이너의 루트 파일시스템이 읽기 전용으로 마운트됩니다. 다음 같은 공격 패턴이 그대로 막힙니다.
curl ... -o /usr/local/bin/miner같은 악성 바이너리 내려받기/etc/cron.d나 시작 스크립트에 백도어 심기- 기존 시스템 바이너리를 악성으로 덮어쓰기
- 셸 히스토리,임시 파일을 통한 흔적 남기기
읽기 전용 파일시스템은 침해 후 조사에서도 가치가 큽니다. 루트 파일시스템이 부팅 시점의 이미지와 동일하다는 것이 보장되므로, 변조 여부를 따질 필요가 없는 깨끗한 기준선이 됩니다.
readOnlyRootFilesystem 적용 #
핵심은 한 줄입니다. 컨테이너의 securityContext에 readOnlyRootFilesystem: true를 넣습니다.
apiVersion: v1
kind: Pod
metadata:
name: immutable-web
spec:
containers:
- name: web
image: nginx:1.27
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]이 매니페스트는 루트 파일시스템을 읽기 전용으로 굳히고, 비root로 돌리며, 권한 상승을 막고, 모든 capabilities를 떨굽니다. 불변성과 최소 권한을 한 묶음으로 적용한 형태입니다.
쓰기가 필요한 경로는 emptyDir로 #
문제는 많은 애플리케이션이 동작에 쓰기를 필요로 한다는 점입니다. nginx는 /var/cache/nginx와 /var/run에 써야 하고, 어떤 애플리케이션은 /tmp에 임시 파일을 만듭니다. 루트 파일시스템을 통째로 읽기 전용으로 만들면 이런 정상 쓰기까지 막혀 컨테이너가 뜨지 못합니다.
해결은 쓰기가 필요한 디렉터리만 골라 쓰기 가능한 볼륨을 덮어씌우는 것입니다. 보통 emptyDir 볼륨을 그 경로에 마운트합니다. emptyDir는 Pod 수명 동안만 존재하고 Pod가 사라지면 함께 비워지므로, 공격자가 거기에 무언가를 심더라도 재배포 한 번이면 흔적 없이 사라집니다.
apiVersion: v1
kind: Pod
metadata:
name: immutable-nginx
spec:
containers:
- name: web
image: nginx:1.27
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /var/run
- name: tmp
mountPath: /tmp
volumes:
- name: cache
emptyDir: {}
- name: run
emptyDir: {}
- name: tmp
emptyDir: {}루트 파일시스템 전체는 읽기 전용이지만, 캐시,런,임시 디렉터리 세 곳만 쓰기 가능한 emptyDir로 열어 두었습니다. 읽기 전용을 기본값으로 두고 필요한 곳만 예외로 여는 것이 불변 운영의 표준 형태입니다.
어느 경로를 열어야 하는지 찾는 법 #
어느 디렉터리에 쓰기가 필요한지 미리 모를 때가 많습니다. 가장 단순한 방법은 일단 readOnlyRootFilesystem: true만 적용해 띄워 본 뒤, 로그에서 어디에 쓰기를 시도하다 실패했는지 확인하는 것입니다.
kubectl logs immutable-nginx
# 예: "Read-only file system" 또는 "Permission denied" 가 가리키는 경로를 확인실패가 가리키는 경로만 emptyDir로 하나씩 열어 가며 컨테이너가 정상으로 뜰 때까지 좁혀 갑니다. 시험에서는 nginx처럼 잘 알려진 이미지가 나오는 경우가 많아, /var/cache/nginx,/var/run,/tmp 정도를 기억해 두면 빠르게 통과합니다.
실행 중 변경 금지와 드리프트 방지 #
불변성은 설정만이 아니라 운영 원칙이기도 합니다. 살아 있는 컨테이너에 kubectl exec로 들어가 파일을 고치는 일은 하지 않습니다. 이런 실행 중 변경은 드리프트(drift), 즉 매니페스트가 선언한 상태와 실제 컨테이너 상태가 어긋나는 문제를 낳습니다. 드리프트가 쌓이면 “지금 도는 것이 정확히 무엇인가"를 아무도 단언하지 못하게 되고, 그 자체가 보안 공백입니다.
원칙은 단순합니다.
- 코드,설정 변경은 새 이미지 빌드 → 재배포로만 한다
- 살아 있는 컨테이너에 들어가 파일을 고치지 않는다
kubectl exec는 조사,디버깅에만 쓰고, 변경에는 쓰지 않는다
readOnlyRootFilesystem은 이 원칙을 기술적으로 강제하는 장치입니다. 사람이 실수로 들어가 파일을 고치려 해도 파일시스템이 거부하므로, 드리프트가 일어날 여지를 구조적으로 없앱니다.
startupProbe로 시작을 안정화 #
불변 컨테이너는 뜰 때 모든 쓰기 경로가 제대로 열려 있어야 정상 동작합니다. 시작이 느리거나 초기화에 시간이 걸리는 애플리케이션이라면 startupProbe로 시작이 끝났는지를 먼저 판정하고, 그 다음에 livenessProbe,readinessProbe가 동작하도록 두는 것이 안전합니다. startupProbe가 성공하기 전까지는 다른 프로브가 컨테이너를 죽이지 않으므로, 읽기 전용 환경에서 초기화가 끝날 시간을 벌어 줍니다.
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 5이 설정은 최대 150초(30 × 5초)까지 시작을 기다리며, 그 사이에는 liveness 실패로 인한 재시작이 일어나지 않습니다.
Forensics: 침해된 Pod 다루기 #
탐지와 예방을 거쳤는데도 침해가 일어났다면, 이제는 **사고 대응(incident response)**의 영역입니다. CKS에서 요구하는 forensics의 핵심은 화려한 분석 도구가 아니라 절차입니다. 순서를 틀리면 증거가 사라지거나 공격이 번집니다. 원칙은 두 가지입니다. 번지지 않게 가두고, 증거를 보존한 뒤 조사한다.
1) 격리: 번지지 않게 가둔다 #
가장 먼저 할 일은 침해된 Pod가 다른 Pod나 외부와 통신하지 못하게 끊는 것입니다. 그런데 Pod를 곧바로 지우면 안 됩니다. 지우는 순간 메모리,프로세스,임시 파일 같은 휘발성 증거가 함께 사라지기 때문입니다.
네트워크부터 끊습니다. 침해된 Pod에 붙은 라벨을 골라 모든 ingress,egress를 막는 NetworkPolicy를 적용합니다. #2 NetworkPolicy에서 다룬 default deny 패턴을 단일 Pod에 겨냥해 쓰는 형태입니다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: quarantine-compromised
namespace: prod
spec:
podSelector:
matchLabels:
quarantine: "true"
policyTypes:
- Ingress
- EgresspodSelector로 격리 라벨이 붙은 Pod를 고르고, policyTypes에 Ingress,Egress를 넣되 허용 규칙을 하나도 두지 않아 모든 통신을 차단합니다. 침해된 Pod에 kubectl label pod <name> quarantine=true로 라벨을 붙이면 즉시 격리됩니다. 이렇게 하면 공격자의 명령,제어(C2) 통신과 횡적 이동이 끊깁니다.
라벨을 바꾸는 또 다른 효과가 있습니다. Deployment가 관리하는 Pod라면, Pod의 라벨을 바꿔 Service,ReplicaSet의 셀렉터에서 빼내면 트래픽이 더는 그 Pod로 가지 않고, 컨트롤러는 정상 Pod를 새로 띄웁니다. 침해된 Pod는 조사용으로 떼어 두고 서비스는 계속 돌아가게 하는 방법입니다.
그다음 노드를 격리합니다. 침해가 노드 수준으로 번졌을 가능성이 있다면 kubectl cordon으로 해당 노드에 새 Pod가 스케줄되지 못하게 막습니다.
kubectl cordon node-3 # 새 Pod 스케줄 차단 (기존 Pod는 유지)cordon은 기존 Pod를 쫓아내지 않고 새 스케줄만 막으므로, 노드 위의 증거를 보존하면서 피해 확산을 제한합니다. drain은 Pod를 쫓아내 증거를 흩뜨릴 수 있으니, 조사 전 단계에서는 cordon만 거는 것이 안전합니다.
2) 증거 보존: 지우기 전에 남긴다 #
격리가 끝나면 Pod를 손대기 전에 증거를 확보합니다. 휘발성이 큰 것부터 챙깁니다.
kubectl logs <pod> -n prod --all-containers --previous > evidence-logs.txt
kubectl describe pod <pod> -n prod > evidence-describe.txt
kubectl get pod <pod> -n prod -o yaml > evidence-spec.yaml- 로그: 컨테이너 표준 출력.
--previous로 재시작 전 로그까지 확보 - 메모리,프로세스: 노드에서 컨테이너 런타임으로 프로세스 목록과 메모리 상태 확인
- 파일: 컨테이너 안에서 변조,추가된 파일. readOnlyRootFilesystem을 켜 두었다면 emptyDir 영역만 보면 됩니다
컨테이너 런타임이 지원한다면 중지 전 스냅샷을 떠 둡니다. 컨테이너를 멈추거나 지우는 순간 메모리와 실행 중 상태가 사라지므로, 가능한 한 살아 있는 상태 그대로의 이미지를 남기는 것이 forensics의 기본입니다.
# 노드에서 컨테이너 런타임으로 현재 상태를 이미지로 떠 둔다 (예: containerd/Docker)
crictl ps # 침해 컨테이너 ID 확인
docker commit <container-id> evidence:incident-0001 # 중지 전 스냅샷3) 조사: kubectl debug로 들여다본다 #
증거를 보존했으면 이제 조사합니다. readOnlyRootFilesystem이나 distroless(#13) 이미지라 셸조차 없는 경우가 많은데, 이때 쓰는 도구가 kubectl debug입니다. 침해된 Pod에 영향을 주지 않고 임시 디버그 컨테이너를 같은 프로세스 네임스페이스에 붙여 조사합니다.
kubectl debug -it <pod> -n prod \
--image=busybox \
--target=web \
--share-processes--target으로 조사 대상 컨테이너의 프로세스 네임스페이스를 공유하고, --share-processes로 그 컨테이너의 프로세스를 디버그 컨테이너에서 들여다봅니다. 침해된 컨테이너 자체에는 새 바이너리를 넣지 않으므로 증거를 오염시키지 않고 조사할 수 있습니다. 노드 자체를 조사해야 한다면 kubectl debug node/<node>로 노드 파일시스템을 /host에 마운트한 디버그 Pod를 띄웁니다.
조사가 끝나고 모든 증거를 확보한 다음에야 침해된 Pod를 삭제하고, 깨끗한 이미지로 재배포합니다.
시험 포인트 #
- 불변성의 핵심 한 줄: 컨테이너
securityContext.readOnlyRootFilesystem: true. 컨테이너 단위 필드이지 Pod 단위가 아님 - 쓰기 경로: 읽기 전용으로 컨테이너가 안 뜨면, 실패한 경로를 emptyDir로 마운트해 연다. nginx는
/var/cache/nginx,/var/run,/tmp - 불변 운영: 변경은 재배포로만. 살아 있는 컨테이너를 고치지 않음. exec는 조사용
- 격리 순서: 지우지 말고 먼저 격리한다. 격리 라벨 + default deny NetworkPolicy로 통신 차단, 노드는
cordon(drain 아님) - 증거 보존: 로그(
--previous),메모리,파일을 지우기 전에 확보. 중지 전 스냅샷 - 조사 도구:
kubectl debug로 디버그 컨테이너 부착(--target,--share-processes). 노드는kubectl debug node/<node> - 순서가 점수: 격리 → 증거 보존 → 조사 → 삭제,재배포. 이 순서가 어긋나면 증거가 사라진다
정리 #
이번 글에서 잡은 것:
- 불변 컨테이너는 실행 후 내용이 바뀌지 않는 컨테이너. 공격자가 바이너리를 심거나 파일을 변조하기 어려워지고, 깨끗한 기준선이 보장됨
- readOnlyRootFilesystem: true가 불변성의 출발점. 쓰기가 필요한 경로만 emptyDir로 예외를 열어 둠
- 드리프트 방지: 변경은 재배포로만. startupProbe로 읽기 전용 환경의 느린 시작을 안정화
- forensics 절차: 침해된 Pod를 지우지 말고 격리(NetworkPolicy,노드 cordon) → 증거 보존(로그,메모리,파일, 중지 전 스냅샷) →
kubectl debug로 조사 → 삭제,재배포 - 시험에서는 불변 설정 적용과 침해 Pod 격리 절차가 묶여 나오며, 순서를 지키는 것이 점수
이로써 6개 도메인의 마지막인 Monitoring,Logging,Runtime Security까지 모든 기술 도메인을 다뤘습니다.
다음: 시험 팁 #
내용은 다 잡았습니다. 이제 남은 것은 그 내용을 2시간 안에 67%로 끌어내는 운영입니다.
#19 시험 팁과 시간 관리, 자주 틀리는 패턴에서는 시험 시작 직후 단축키,별칭(alias) 세팅, 어려운 작업을 건너뛰고 돌아오는 시간 관리, 도구별 문서를 빠르게 찾는 법, 그리고 readOnlyRootFilesystem을 적용했는데 컨테이너가 안 뜨는 함정처럼 자주 틀리는 패턴을 모아 정리하겠습니다.