Certified Kubernetes Security Specialist (CKS) #6 AppArmor 프로파일 (System Hardening)
CKA 시리즈에서 클러스터 운영을 익히고, 이 CKS 시리즈의 앞 다섯 편에서 네트워크 격리와 클러스터 하드닝을 다뤘다면, 이제 도메인이 바뀝니다. System Hardening은 쿠버네티스 위가 아니라 노드의 리눅스 커널 수준에서 컨테이너를 가두는 영역입니다. 그 첫 도구가 AppArmor입니다.
AppArmor는 컨테이너가 호출하는 파일 접근과 기능(capability)을 프로파일에 적힌 만큼만 허용하는 리눅스 보안 모듈입니다. 컨테이너가 침해되더라도 프로파일이 막아 둔 경로 밖으로는 손을 뻗지 못하게 만드는, 침해된 컨테이너가 호스트 파일에 접근하거나 추가 권한을 얻으려 할 때 커널 수준에서 차단하는 보안 모듈입니다. 이번 글에서는 프로파일을 직접 작성해 노드에 로드하고, Pod에 붙여서, 실제로 막는지까지 확인하겠습니다.
AppArmor란 무엇인가 #
리눅스의 기본 접근 제어는 파일 소유자,그룹,권한 비트에 기반한 **DAC(Discretionary Access Control)**입니다. 파일 소유자가 권한을 마음대로 바꿀 수 있어, 프로세스가 권한을 얻으면 그 권한 범위 전체를 쓸 수 있습니다. 컨테이너가 root로 돌면 이 DAC만으로는 막을 수 없는 동작이 많아집니다.
AppArmor는 그 위에 **MAC(Mandatory Access Control)**를 덧씌웁니다. MAC는 프로세스가 무엇을 할 수 있는지를 관리자가 정한 정책으로 강제하며, 프로세스 자신은 그 정책을 풀 수 없습니다. AppArmor의 정책 단위가 바로 **프로파일(profile)**입니다. 프로파일은 특정 실행 파일이 다음을 어디까지 할 수 있는지 적습니다.
- 어떤 파일 경로를 읽고(
r) 쓰고(w) 실행(x)할 수 있는가 - 어떤 리눅스 capability를 가질 수 있는가
- 네트워크,마운트,시그널 등을 쓸 수 있는가
컨테이너에 AppArmor 프로파일을 붙이면, 그 컨테이너 안 프로세스가 프로파일에 적힌 범위 밖의 파일이나 기능에 손대는 순간 커널이 거부합니다. 침해된 컨테이너가 호스트 파일을 읽거나 쓰려는 시도를 노드 커널이 차단하므로, 피해를 컨테이너 안에 가두는 효과가 있습니다.
AppArmor는 경로 기반(path-based) MAC라서 파일 경로 규칙을 쓰기 쉽습니다. 같은 MAC 계열인 SELinux는 라벨 기반이라 규칙이 더 복잡합니다. CKS는 AppArmor를 다루며, 시험 노드는 보통 AppArmor가 기본 활성화된 배포판입니다.
프로파일의 두 가지 모드 #
AppArmor 프로파일은 같은 규칙을 어떻게 적용할지에 따라 두 모드 중 하나로 동작합니다.
| 모드 | 동작 | 용도 |
|---|---|---|
enforce | 프로파일에서 허용하지 않은 동작을 실제로 차단하고 로그를 남김 | 운영. 실제 보호 |
complain | 차단하지 않고 위반 동작을 로그로만 기록(audit) | 프로파일 개발,튜닝 |
complain 모드는 애플리케이션을 정상 동작시키면서 어떤 접근이 일어나는지 로그를 모으는 단계입니다. 그 로그를 보고 필요한 규칙을 채운 뒤 enforce로 전환하는 흐름이 일반적입니다. CKS 시험에서 직접 차단까지 확인해야 하는 작업은 대부분 enforce 모드를 요구합니다.
프로파일 작성 #
프로파일은 노드의 /etc/apparmor.d/ 아래에 텍스트 파일로 둡니다. 컨테이너가 /를 제외한 거의 모든 곳에 쓰지 못하게 막는 간단한 프로파일을 예로 들겠습니다.
# /etc/apparmor.d/k8s-deny-write
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
# 모든 파일 읽기는 허용
file,
# 디스크에 쓰는 모든 시도는 거부
deny /** w,
}핵심 문법을 짚겠습니다.
- 첫 줄의
profile k8s-deny-write가 프로파일 이름입니다. Pod에 붙일 때 이 이름을 그대로 씁니다. 파일 이름과 프로파일 이름이 달라도 되지만, 헷갈리지 않게 맞추는 편이 안전합니다. flags=(attach_disconnected)는 컨테이너 환경에서 경로가 마운트 네임스페이스 때문에 끊겨 보일 때 프로파일이 정상 적용되도록 돕는 플래그입니다. 쿠버네티스 컨테이너용 프로파일에 자주 붙입니다.#include <abstractions/base>는 프로세스가 정상 동작에 필요한 공통 접근(라이브러리 로드 등)을 미리 모아 둔 묶음입니다.file,는 모든 파일 접근을 일단 허용하는 규칙입니다.deny /** w,가 이 프로파일의 핵심입니다./**는 모든 하위 경로를 뜻하고,w는 쓰기를,deny는 거부를 뜻합니다. 즉 어디든 쓰기를 금지합니다.
더 좁히고 싶다면 경로별로 권한을 명시합니다. 다음은 특정 디렉터리만 읽기를 허용하고 나머지 쓰기를 막는 형태입니다.
profile k8s-restrict flags=(attach_disconnected) {
#include <abstractions/base>
# 애플리케이션 디렉터리만 읽기,실행 허용
/app/** r,
/usr/bin/** rix,
# 임시 디렉터리만 쓰기 허용
/tmp/** rw,
# 민감 경로 접근 명시적 거부
deny /etc/shadow r,
deny /proc/sysrq-trigger rwx,
}규칙은 위에서 아래로 보지 않고 권한과 거부를 합쳐서 판단하며, deny는 다른 허용보다 우선합니다. 그래서 넓게 허용한 뒤 위험한 경로만 deny로 콕 집어 막는 방식이 자주 쓰입니다.
노드에 프로파일 로드 #
작성한 프로파일은 파일로 두는 것만으로는 동작하지 않습니다. 커널에 로드해야 합니다. AppArmor가 설치된 노드에서 apparmor_parser로 로드합니다.
# 프로파일을 커널에 로드(또는 갱신). -r은 replace
sudo apparmor_parser -r /etc/apparmor.d/k8s-deny-write-r(replace)는 이미 같은 이름의 프로파일이 로드돼 있어도 새 내용으로 덮어씁니다. 그래서 프로파일을 고친 뒤 다시 로드할 때도 같은 명령을 씁니다. 기본 모드는 enforce이며, complain으로 로드하려면 aa-complain을 씁니다.
# complain 모드로 로드(차단하지 않고 로그만)
sudo aa-complain /etc/apparmor.d/k8s-deny-write
# 다시 enforce 모드로
sudo aa-enforce /etc/apparmor.d/k8s-deny-write로드 결과는 aa-status(또는 apparmor_status)로 확인합니다.
sudo aa-status출력에서 다음을 확인합니다.
apparmor module is loaded.
42 profiles are loaded.
38 profiles are in enforce mode.
...
k8s-deny-write
4 profiles are in complain mode.
...k8s-deny-write가 enforce 목록에 보이면 노드에 정상 로드된 것입니다. 시험에서는 프로파일 이름이 이 목록에 정확히 나타나는지로 로드 성공을 판단하므로, 이름 철자를 반드시 맞춰야 합니다.
중요한 함정 하나. 프로파일은 Pod가 스케줄될 노드에 미리 로드돼 있어야 합니다. 멀티 노드 클러스터에서는 어느 노드에 로드했는지와 Pod가 어느 노드로 가는지를 맞춰야 합니다. 시험에서는 보통
nodeName을 지정하거나 단일 워커 노드를 쓰게 안내하지만, 여러 노드라면 모든 후보 노드에 로드하거나 스케줄링을 고정하는 편이 안전합니다.
Pod에 프로파일 적용 #
노드에 프로파일이 로드됐다면, 이제 Pod의 컨테이너에 그 프로파일을 붙입니다. 쿠버네티스 버전에 따라 방식이 둘로 나뉩니다.
1.30 이상: securityContext.appArmorProfile #
쿠버네티스 1.30부터 AppArmor 설정이 정식 필드로 들어왔습니다. securityContext 아래 appArmorProfile로 지정합니다. Pod 단위와 컨테이너 단위 모두에서 쓸 수 있으며, 컨테이너 단위가 Pod 단위를 덮습니다.
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
spec:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-write
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]type에 따라 의미가 달라집니다.
| type | 의미 |
|---|---|
Localhost | 노드에 미리 로드된 프로파일을 쓴다. localhostProfile에 프로파일 이름을 적음 |
RuntimeDefault | 컨테이너 런타임의 기본 AppArmor 프로파일을 적용 |
Unconfined | AppArmor 적용 없이 제한 없는 상태로 둔다 |
직접 작성한 프로파일을 붙일 때는 type: Localhost에 localhostProfile로 노드에 로드된 프로파일 이름을 적는 것이 핵심입니다. 이름이 aa-status 목록과 정확히 같아야 하며, 다르면 Pod가 생성에 실패합니다.
1.29이하: annotation #
1.30 이전에는 AppArmor를 annotation으로 지정했습니다. 시험 클러스터 버전이 낮거나 기존 매니페스트를 다룰 때를 위해 형식을 알아 둘 필요가 있습니다. 키는 컨테이너 이름까지 포함합니다.
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-deny-write
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]annotation 키는 container.apparmor.security.beta.kubernetes.io/<컨테이너이름> 형태이고, 값은 localhost/<프로파일이름> 형태입니다. 위 예에서 컨테이너 이름이 app이므로 키 끝이 /app이고, 값으로 노드에 로드된 k8s-deny-write를 localhost/ 접두로 가리킵니다. 값에 쓰는 타입도 필드 방식과 같아서 localhost/<이름>, runtime/default, unconfined를 씁니다.
두 방식의 대응을 외워 두면 버전을 가리지 않고 풉니다. 필드의
type: Localhost+localhostProfile: NAME은 annotation의 값localhost/NAME과 같고,type: RuntimeDefault는runtime/default,type: Unconfined는unconfined와 같습니다.
프로파일이 막는지 검증 #
붙였다고 끝이 아닙니다. 프로파일이 실제로 차단하는지 exec로 확인하는 습관이 시험에서 점수를 지킵니다. 위 k8s-deny-write(어디든 쓰기 금지)를 붙인 Pod로 확인하겠습니다.
# Pod 생성
kubectl apply -f hardened-pod.yaml
# 컨테이너 안에서 쓰기를 시도
kubectl exec hardened-pod -- sh -c 'echo test > /tmp/x'프로파일이 정상 적용됐다면 쓰기가 거부되어 다음과 비슷한 오류가 납니다.
sh: can't create /tmp/x: Permission denied
command terminated with exit code 1읽기는 막지 않았으므로 다음은 정상 동작해야 합니다.
# 읽기는 허용되므로 성공
kubectl exec hardened-pod -- cat /etc/hostname쓰기가 막히고 읽기가 되면 프로파일이 의도대로 동작하는 것입니다. 차단이 일어나면 노드의 커널 로그에도 기록이 남으므로, 노드에서 다음으로 확인할 수 있습니다.
# 노드에서 AppArmor 거부 로그 확인
sudo dmesg | grep -i apparmor
# 또는
sudo grep -i 'apparmor.*DENIED' /var/log/syslog로그에 DENIED와 함께 프로파일 이름,차단된 경로가 보이면, 어떤 동작이 막혔는지 정확히 짚을 수 있습니다. 반대로 프로파일이 너무 빡빡해 애플리케이션이 정상 동작까지 막혔다면, 이 로그가 어떤 규칙을 풀어야 하는지 알려줍니다.
프로파일을 붙였는데 차단이 일어나지 않는다면 거의 항상 둘 중 하나입니다. 첫째, 프로파일이 해당 노드에 로드되지 않았습니다.
aa-status로 확인합니다. 둘째, 이름이 어긋났습니다.localhostProfile값과aa-status의 프로파일 이름이 한 글자라도 다르면 적용되지 않습니다.
시험 포인트 #
CKS의 System Hardening도메인에서 AppArmor는 거의 매번 나오는 단골입니다. 다음을 손에 익혀 두면 실수 없이 풉니다.
- 흐름을 외운다. 프로파일 작성 → 노드에
apparmor_parser -r로 로드 →aa-status로 로드 확인 → Pod에type: Localhost+localhostProfile(또는 annotation)로 붙이기 → exec로 차단 검증. 이 다섯 단계가 한 작업의 전형입니다. - 프로파일이 노드에 있어야 Pod가 뜬다. 가장 흔한 실패는 노드 로드를 빠뜨리고 Pod 매니페스트만 고치는 것입니다. 로드 먼저, Pod는 그다음입니다.
- 이름을 정확히.
localhostProfile값과aa-status에 보이는 프로파일 이름이 정확히 같아야 합니다. 철자가 어긋나면 Pod가 생성 단계에서 막힙니다. - 버전별 방식을 둘 다 안다. 1.30+는
securityContext.appArmorProfile필드, 그 이전은container.apparmor.security.beta.kubernetes.io/<컨테이너>annotation입니다. 시험 클러스터 버전을 먼저 확인하겠습니다. - 세 가지 타입을 구분한다.
Localhost(직접 만든 프로파일),RuntimeDefault(런타임 기본),Unconfined(제한 없음). 문제가 무엇을 요구하는지 보고 고릅니다. - 검증까지 한다. exec로 막히는 동작과 되는 동작을 한 번씩 확인하면, 붙이기만 하고 적용이 안 된 채로 넘어가는 사고를 막습니다.
정리 #
이번 글에서 잡은 것:
- AppArmor는 리눅스 MAC. DAC 위에 덧씌워 프로세스가 접근할 파일 경로와 capability를 프로파일로 강제하며, 프로세스 스스로 풀 수 없음
- 모드는 둘.
enforce는 실제 차단,complain은 로그만. 개발은 complain, 운영,시험 검증은 enforce - 프로파일은
/etc/apparmor.d/에 작성하고file,,deny /** w,,경로별rwx규칙으로 권한을 정함.deny가 허용보다 우선 - 로드와 확인.
apparmor_parser -r로 노드에 로드하고aa-status로 프로파일 이름이 enforce 목록에 보이는지 확인 - Pod 적용. 1.30+는
securityContext.appArmorProfile(type: Localhost+localhostProfile), 이전은container.apparmor.security.beta.kubernetes.io/<컨테이너>annotation. 타입은Localhost,RuntimeDefault,Unconfined - 검증. exec로 차단,허용을 직접 확인하고, 노드
dmesg,syslog의DENIED로그로 무엇이 막혔는지 짚음
다음: seccomp 프로파일 #
AppArmor가 파일과 기능을 경로 기준으로 막는다면, 같은 System Hardening도메인의 짝꿍은 시스템 콜 자체를 막는 seccomp입니다.
#7 seccomp 프로파일에서는 컨테이너가 호출할 수 있는 시스템 콜을 화이트리스트로 좁히는 법, RuntimeDefault 프로파일의 의미, 커스텀 seccomp 프로파일(JSON)을 노드에 두고 securityContext.seccompProfile로 붙이는 절차, SCMP_ACT_ERRNO,SCMP_ACT_ALLOW 같은 액션, 그리고 차단된 시스템 콜을 찾아 프로파일을 좁히는 흐름까지 직접 만들어 보며 정리하겠습니다.