컨테이너를 지탱하는 커널 기능
지원 버전: Linux 6.1 / 6.12 / 6.18 (Amazon Linux 2023), Kubernetes 1.25+ (cgroup v2) 마지막 업데이트: 2026년 9월 13일
이 문서에서 다루는 것
- 컨테이너는 커널의 어떤 기능들을 조합해 만들어지는가 — 그리고 "컨테이너"라는 커널 객체는 없다는 점
- cgroup v1에서 v2로 바뀌면서 운영에서 실제로 달라진 것 (특히 OOM 진단)
- netfilter와 conntrack이 Kubernetes 네트워킹의 어디에 끼어 있는가
먼저: 커널에 "컨테이너"는 없습니다
이것이 컨테이너를 이해하는 출발점입니다. 커널에는 struct container 같은 것이 없고, 컨테이너를 만드는 단일 시스템 콜도 없습니다.
컨테이너는 여러 독립적인 커널 기능을 조합해 만든 관례입니다. 런타임(containerd, runc)이 프로세스를 하나 띄우면서 다음을 함께 적용합니다.
| 목적 | 커널 기능 |
|---|---|
| 무엇을 볼 수 있는가 (격리) | namespace |
| 얼마나 쓸 수 있는가 (제한) | cgroup |
| 무엇을 할 수 있는가 (권한) | capabilities, seccomp, LSM (AppArmor/SELinux) |
| 파일시스템을 어떻게 합치는가 | overlayfs (union mount) |
| 트래픽을 어떻게 흘리는가 | veth, bridge/route, netfilter |
이 조합이라는 점에서 두 가지 실무적 결론이 나옵니다.
첫째, 격리는 전부 또는 전무가 아닙니다. 어떤 namespace는 공유하고 어떤 것은 격리할 수 있습니다. Kubernetes Pod가 정확히 그 예입니다 — 같은 Pod의 컨테이너들은 network·IPC namespace를 공유하고 mount·PID namespace는 대개 분리합니다. 그래서 같은 Pod 안에서는 localhost로 서로를 부를 수 있고(network 공유), 파일시스템은 서로 안 보입니다(mount 분리).
둘째, 빠뜨린 격리는 조용히 구멍이 됩니다. 커널이 "컨테이너를 만들어라"를 모르므로, 런타임이 seccomp 프로필을 적용하지 않으면 그냥 적용되지 않은 상태로 돕니다. 컨테이너 보안이 런타임과 정책 설정의 문제인 이유입니다.
Namespace — 무엇을 볼 수 있는가
namespace는 커널 자원의 "이름 공간"을 분리합니다. 같은 이름이나 번호가 namespace마다 다른 것을 가리키게 만드는 장치입니다.
| Namespace | 격리 대상 | Pod에서 |
|---|---|---|
| mnt | 마운트 지점 | 컨테이너별 분리 |
| pid | 프로세스 ID | 컨테이너별 분리 (shareProcessNamespace: true로 Pod 내 공유 가능) |
| net | 네트워크 인터페이스, 라우팅 테이블, netfilter 규칙, 소켓, 포트 | Pod 단위로 공유 |
| ipc | System V IPC, POSIX 메시지 큐 | Pod 단위로 공유 |
| uts | hostname, domainname | Pod 단위로 공유 |
| user | UID/GID 매핑 | 기본 미사용 (아래 참고) |
| cgroup | cgroup 루트 경로 | 컨테이너별 분리 |
| time | 부팅 시각, 단조 시계 (5.6+) | 미사용 |
net namespace가 Pod의 경계인 이유
Pod의 정체가 여기서 정해집니다. Kubernetes는 Pod마다 net namespace를 하나 만들고(pause 컨테이너가 보유), 그 Pod의 모든 컨테이너를 같은 net namespace에 넣습니다.
결과로 따라오는 것들:
- Pod 안의 컨테이너들은 같은 IP, 같은 포트 공간을 공유합니다 → 같은 Pod에서 두 컨테이너가 8080을 동시에 열 수 없습니다
localhost통신이 됩니다 → 사이드카 패턴의 기반- netfilter 규칙도 net namespace별입니다 → 사이드카 메시의 init container가 Pod의 net namespace 안에서 iptables를 심을 수 있는 이유이고, 그 규칙이 노드 전체에 영향을 주지 않는 이유입니다 (VPC Lattice 커널 데이터패스 참고)
- 라우팅 테이블도 분리됩니다 → Pod 안에서
ip route를 보면 노드의 것이 아닙니다
user namespace — 왜 오래 기본이 아니었는가
User namespace는 컨테이너 UID/GID를 다른 host 범위로 매핑합니다. 여러 탈출 동작의 권한을 줄이지만 kernel 취약점이나 추가 권한 상승 경로까지 봉쇄한다고 보장하지는 않습니다.
그런데 오래 기본이 아니었습니다. 이유는 파일 소유권입니다. 볼륨의 파일이 호스트 UID로 기록되어 있는데 컨테이너가 다른 UID로 보면 권한이 맞지 않습니다. 이를 해결하려면 마운트 시점에 UID를 변환해야 하고(idmapped mounts, 커널 5.12+), 스토리지 드라이버와 CSI도 이를 지원해야 합니다.
Kubernetes의 user namespace 지원 현황
KEP-127 기준으로 성숙 단계는 다음과 같습니다.
| 단계 | 버전 |
|---|---|
| alpha | v1.25 |
| beta | v1.35 |
| stable (GA) | v1.36 |
feature gate는 UserNamespacesSupport이며 kubelet과 kube-apiserver에 적용됩니다. 1.36부터는 GA이므로 feature gate를 켜지 않아도 hostUsers: false를 쓸 수 있습니다.
확인 필요
위 성숙도는 Kubernetes 업스트림 기준입니다. EKS가 해당 버전을 제공하는지, 그리고 사용 중인 컨테이너 런타임과 CSI 드라이버가 idmapped mounts를 지원하는지는 별개입니다. 도입 전에 EKS 지원 버전과 런타임·스토리지 조합을 확인하십시오.
cgroup — 얼마나 쓸 수 있는가
cgroup(control group)은 프로세스 그룹의 자원 사용을 측정하고 제한합니다. Kubernetes의 requests/limits가 최종적으로 도달하는 곳입니다.
v1과 v2의 구조적 차이
| 항목 | cgroup v1 | cgroup v2 |
|---|---|---|
| 계층 구조 | 컨트롤러(cpu, memory, blkio…)마다 별개의 트리 | 단일 통합 트리 |
| 프로세스 소속 | 컨트롤러별로 다른 그룹에 속할 수 있음 | 하나의 그룹에만 속함 |
| 메모리+IO 협조 | 어려움 (별도 트리라 연계 불가) | 가능 (같은 트리) |
| 압력 정보 | 없음 | PSI (cpu.pressure, memory.pressure, io.pressure) |
| CPU 제한 표기 | cpu.cfs_quota_us / cpu.cfs_period_us | cpu.max (한 파일에 "quota period") |
| 메모리 제한 표기 | memory.limit_in_bytes | memory.max, 여기에 memory.high(소프트 압력) 추가 |
| AL2023 EKS AMI | — | 기본값 |
v1의 "컨트롤러마다 별개 트리"가 실제로 문제였던 지점은 메모리 회수와 IO의 연계입니다. 메모리가 부족해 page cache를 비워야 할 때, 그 회수 작업 자체가 디스크 IO를 유발하는데 v1에서는 두 컨트롤러가 서로를 몰랐습니다. v2의 통합 트리는 이를 같은 계층에서 다룹니다.
운영에서 가장 크게 달라진 것 — OOM 진단
cgroup v2에서 반드시 알아야 할 사실입니다.
memory.current는 page cache를 포함합니다.
즉 애플리케이션이 실제로 붙잡고 있는 메모리(anon/RSS)가 limit보다 훨씬 낮은데도, 파일을 많이 읽어 page cache가 쌓이면 memory.current가 limit에 닿습니다.
여기서 중요한 구분이 있습니다. page cache는 회수 가능(reclaimable)합니다. 그래서 정상적인 경우 커널은 limit에 닿으면 page cache를 버려서 공간을 만들고, OOM은 나지 않습니다. 문제가 되는 것은 회수 속도가 할당 속도를 못 따라갈 때이고, 이때 OOM killer가 동작합니다.
실무적 함의:
| 오해 | 실제 |
|---|---|
"memory.current가 limit 근처이면 OOM 직전" | 회수 가능한 file cache가 포함될 수 있으므로 구성을 가정하지 말고 anon/file/kernel 사용량·압력 확인 |
| "RSS만 보면 된다" | RSS가 낮아도 OOM이 날 수 있습니다 (회수 못 따라가는 경우) |
| "limit을 올리면 해결" | 원인이 회수 지연이면 올려도 재발합니다 |
진단에 봐야 하는 값들:
| 파일/값 | 의미 |
|---|---|
memory.current | 현재 사용량 (page cache 포함) |
memory.stat의 anon | 익명 메모리 — 애플리케이션이 실제 붙잡은 양 |
memory.stat의 file | page cache |
memory.events의 oom / oom_kill | OOM 발생·킬 횟수 |
memory.events의 high / max | 소프트/하드 한계에 닿은 횟수 |
memory.pressure (PSI) | 메모리 압박으로 지연된 시간의 비율 |
PSI가 특히 유용합니다. 사용량(얼마나 쓰는가)이 아니라 **압박(그래서 얼마나 기다렸는가)**을 알려주기 때문입니다. memory.pressure의 some avg10이 올라가고 있으면 회수에 시간을 쓰고 있다는 뜻이고, 이는 사용량 그래프만으로는 보이지 않습니다.
CPU limit과 throttling — 사용률이 낮은데 느린 이유
CPU limit은 대역폭 제한입니다. cpu.max가 20000 100000이면 "100ms 주기마다 20ms까지"를 뜻합니다.
여기서 직관에 반하는 일이 벌어집니다. 애플리케이션이 짧은 시간에 여러 스레드로 일하면, 주기 초반에 할당량을 다 쓰고 주기가 끝날 때까지 강제로 멈춥니다. 평균 사용률은 20%로 낮게 보이는데 지연은 튑니다.
멀티스레드에서 더 심합니다. 4개 스레드가 동시에 돌면 20ms 할당량은 실제 시간 5ms에 소진됩니다. 나머지 95ms는 대기입니다.
진단: cpu.stat의 nr_throttled(throttling 당한 주기 수)와 throttled_usec(총 throttling 시간). nr_periods에 대한 nr_throttled 비율이 유의미하게 높으면 limit이 원인입니다.
대응 방향 (자세한 request/limit 설계는 리소스 최적화):
- limit을 올리거나 제거 (단 노드 안정성과 트레이드오프)
- 애플리케이션의 스레드 수를 limit에 맞게 조정 (JVM의
-XX:ActiveProcessorCount, Go의GOMAXPROCS등) — 컨테이너가 인식하는 CPU 수와 실제 할당량이 다른 것이 근본 원인인 경우가 많습니다 cpu.pressurePSI로 실제 대기 시간 확인
권한 — 무엇을 할 수 있는가
격리(namespace)와 제한(cgroup)이 되어 있어도, 프로세스가 할 수 있는 동작 자체를 줄이는 것은 별개 계층입니다.
| 기능 | 무엇을 하는가 | Kubernetes에서 |
|---|---|---|
| capabilities | root 권한을 잘게 쪼갠 단위로 부여·제거 (CAP_NET_ADMIN, CAP_SYS_ADMIN 등) | securityContext.capabilities.add/drop |
| seccomp | 허용할 시스템 콜 목록 제한 | securityContext.seccompProfile (RuntimeDefault 권장) |
| LSM (AppArmor/SELinux) | 파일·네트워크 접근을 정책으로 통제 | securityContext.appArmorProfile 등 |
| no_new_privs | setuid 바이너리로 권한 상승 차단 | allowPrivilegeEscalation: false |
세 계층이 다른 질문에 답합니다 — capabilities는 "이 권한을 가졌는가", seccomp는 "이 시스템 콜을 부를 수 있는가", LSM은 "이 객체에 접근할 수 있는가". 그래서 하나만으로는 부족하고 겹쳐 쓰는 것이 정석입니다.
CAP_NET_ADMIN은 특별히 언급할 가치가 있습니다. 사이드카 메시의 init container가 iptables를 심으려면 이 권한이 필요하고, 그래서 메시 도입이 "왜 이 Pod가 NET_ADMIN을 갖고 있나"라는 보안 심의 질문을 만듭니다.
netfilter와 conntrack — Kubernetes 네트워킹의 실체
netfilter
netfilter는 커널 네트워크 스택의 정해진 지점에 훅을 제공하는 프레임워크입니다. iptables, nftables, ipvs는 모두 이 훅을 쓰는 사용자 공간 도구이거나 그 위의 구현입니다.
주요 훅 지점:
| 훅 | 언제 |
|---|---|
PREROUTING | 패킷이 들어와 라우팅 결정 전 — DNAT 지점 |
INPUT | 로컬 프로세스로 향하는 패킷 |
FORWARD | 통과하는 패킷 |
OUTPUT | 로컬에서 나가는 패킷 |
POSTROUTING | 라우팅 결정 후 나가기 직전 — SNAT/MASQUERADE 지점 |
Kubernetes에서 이 훅들이 쓰이는 곳:
- Service의 ClusterIP → Pod IP 변환:
PREROUTING/OUTPUT에서 DNAT - Pod → 외부 통신의 출발지 변환:
POSTROUTING에서 MASQUERADE - NetworkPolicy: CNI가
FORWARD등에 규칙 삽입 (Calico의 iptables 데이터플레인) - 사이드카 메시의 트래픽 인터셉트: Pod net namespace 안의
OUTPUT/PREROUTINGREDIRECT
kube-proxy 모드 — iptables, IPVS, nftables
Service 구현 방식이 세 갈래이고, 2025~2026년에 지형이 바뀌었습니다.
| 모드 | 룰 평가 | 상태 |
|---|---|---|
| iptables | Rule-chain 조회 비용은 배치에 따라 다르며 현재 kube-proxy는 갱신을 최적화 | 명시 변경하지 않은 환경의 기본값. 설치 구현 확인 |
| IPVS | 커널 L4 로드밸런서, 해시 기반 O(1) | Kubernetes 1.35(2025년 12월)에서 deprecated; 1.40 기본 비활성화·1.43 제거 계획 |
| nftables | O(1) 조회 + 증분 규칙 갱신 | Kubernetes 1.33에서 GA (1.29 alpha → 1.31 beta). 워커 노드에 커널 5.13+ 필요 |
읽는 방법:
- 대규모 클러스터에서 iptables 모드의 병목은 룰 수와 갱신 비용입니다. Service·Endpoint가 많을수록 kube-proxy의 동기화 시간이 늘고, 그 동안 규칙이 최신이 아닙니다.
- IPVS를 쓰고 있다면 이전 계획이 필요합니다. Upstream은 1.40 기본 비활성화와 1.43 제거를 계획하므로 이전 계획 시 최신 KEP-5495 일정을 확인합니다. 권장 대체는 nftables 모드입니다.
- AL2023 노드는 커널 6.x라 nftables 모드의 커널 요건을 충족합니다.
- nftables가 GA여도 기본값은 iptables이므로 명시적으로 전환해야 합니다.
conntrack — 가장 자주 사고를 내는 지점
netfilter가 NAT를 하려면 연결을 기억해야 합니다. 나갈 때 주소를 바꿨으면 돌아오는 패킷을 원래대로 되돌려야 하니까요. 이 기억을 담는 커널 테이블이 nf_conntrack입니다.
Kube-proxy netfilter mode의 Service NAT는 connection tracking에 의존하지만 NAT 없는 트래픽도 추적될 수 있습니다. Headless Service·외부 endpoint·eBPF 구현의 경로는 다르므로 모든 Kubernetes Service가 같은 DNAT 경로를 반드시 거친다고 보면 안 됩니다.
포화되면 어떻게 되는가가 문제의 핵심입니다. 에러 로그가 요란하게 나지 않습니다. 새 연결이 조용히 드롭되고, 애플리케이션은 연결 타임아웃이나 refused를 봅니다. 무엇이 원인인지 애플리케이션 쪽에서는 알 수 없습니다.
| 관측 지점 | 의미 |
|---|---|
/proc/sys/net/netfilter/nf_conntrack_count | 현재 항목 수 |
/proc/sys/net/netfilter/nf_conntrack_max | 상한 |
conntrack -S → insert_failed | 삽입 실패. 단독으로 포화를 확정하지 말고 count/max·drop·kernel log와 함께 확인 |
conntrack -S의 drop | 드롭된 패킷 |
dmesg의 nf_conntrack: table full, dropping packet | 커널 경고 |
EKS에서 주의할 점이 하나 있습니다. kube-proxy도 conntrack 값을 관리하는데, EKS에는 kube-proxy-config ConfigMap이 기본으로 존재하고 이것이 커맨드라인 인자보다 우선합니다. 따라서 노드에서 sysctl만 올려놓고 kube-proxy가 다시 낮추는 상황이 생길 수 있습니다. 값을 바꾸려면 ConfigMap의 conntrack.maxPerCore·conntrack.min을 조정하고 kube-proxy DaemonSet을 재시작하는 것이 올바른 경로입니다.
nf_conntrack_max를 올리면 노드 메모리 사용이 늘어납니다. 항목당 메모리를 쓰므로 무한정 올릴 수 없고, 노드 크기에 맞춰야 합니다. 구체적 설정은 EKS 노드 커널 튜닝에서 다룹니다.
실제 kube-proxy 설정 확인
과거 Bottlerocket 보고에는 kube-proxy가 node sysctl을 덮어쓴 사례가 있습니다. --config 사용 시 덮어써지는 CLI flag가 아니라 활성 설정의 conntrack.maxPerCore·conntrack.min을 수정합니다. 둘 다 0으로 설정하는 것은 node sysctl에 상한 관리를 맡기는 의도적 선택이므로 설치한 add-on/version의 동작과 실제 node 값을 검증하고 메모리 예산을 유지합니다.
과거 issue만으로 모든 현재 Bottlerocket release가 같은 동작이라고 단정할 수 없습니다. Rollout 후 유효 설정과 실제 sysctl을 확인합니다.
conntrack을 피하는 방향
conntrack 부하 자체를 줄이는 접근도 있습니다.
- Headless Service는 Service VIP DNAT를 피하지만 conntrack을 본질적으로 우회하지는 않습니다.
- Cilium은 kube-proxy/netfilter 기능을 eBPF map으로 대체할 수 있으며 자체 tracking/map 압력과 남은 netfilter 경로를 측정합니다.
- 연결 재사용은 churn을 줄이며 established 용량과 timeout 동작도 검증합니다.
overlayfs — 이미지 계층이 합쳐지는 방식
컨테이너 이미지가 계층으로 되어 있고 그 계층들이 하나의 파일시스템으로 보이는 것은 union mount, 구체적으로는 overlayfs입니다.
구조는 세 부분입니다.
| 계층 | 역할 |
|---|---|
| lowerdir | 읽기 전용 — 이미지 계층들 (여러 개 겹칠 수 있음) |
| upperdir | 쓰기 가능 — 컨테이너의 변경분 |
| merged | 컨테이너가 보는 합쳐진 뷰 |
여기서 운영상 중요한 성질이 copy-up입니다. lowerdir의 파일을 수정하면 파일 전체가 upperdir로 복사된 뒤 수정됩니다. 그래서:
- 큰 파일을 조금 수정하는 것도 전체 복사 비용을 냅니다. 1GB 파일의 1바이트 수정에 1GB 복사가 일어납니다
- 컨테이너 안에서 대용량 쓰기를 하면 노드 디스크를 먹습니다 (ephemeral storage)
- 쓰기가 많은 경로는 볼륨으로 빼는 것이 정석입니다 — emptyDir, PVC 등
정리
- 커널에 "컨테이너"는 없습니다. namespace(격리) + cgroup(제한) + capabilities/seccomp/LSM(권한) + overlayfs(파일시스템) + netfilter(네트워크)의 조합입니다. 그래서 격리는 선택적이고, 빠뜨린 격리는 조용한 구멍이 됩니다.
- net namespace가 Pod의 경계입니다. 같은 IP·포트 공간,
localhost통신, Pod 범위의 netfilter 규칙이 모두 여기서 나옵니다. - cgroup v2에서
memory.current는 page cache를 포함합니다. OOM 진단은memory.stat의anon과memory.events, 그리고 **PSI(memory.pressure)**를 함께 봐야 합니다. - CPU limit은 대역폭 제한이라 사용률이 낮아도 throttling으로 지연이 튑니다.
cpu.stat의nr_throttled가 증거입니다. - kube-proxy는 nftables가 1.33에서 GA, IPVS는 1.35에서 deprecated이며 upstream은 IPVS의 1.40 기본 비활성화와 1.43 제거를 계획합니다. 기본값은 여전히 iptables입니다.
- Conntrack 포화는 신규 연결을 드롭할 수 있습니다. Count/max·drop/insert counter·log로 진단하고 상한 변경 전 유효 kube-proxy 설정을 확인합니다.
다음: 커널 네트워킹 스택에서 패킷이 지나는 전체 경로를 봅니다.
참고 자료
- Control Group v2 — Linux kernel documentation
- PSI - Pressure Stall Information
- namespaces(7) — Linux manual
- KEP-127: Support User Namespaces
- bottlerocket-os/bottlerocket#4221 — conntrack limit not applied
- NFTables mode for kube-proxy (Kubernetes Blog)
- KEP-5495: Deprecate IPVS mode in kube-proxy
- Running kube-proxy in nftables Mode — EKS Best Practices
- Increase nf_conntrack_max limit on EKS nodes
- Amazon EKS-Optimized Amazon Linux 2023 AMIs