PID
프로세스 ID 번호 공간을 분리한다. 컨테이너 안 메인 프로세스가 PID 1이 된다.
컨테이너 내부 `ps`가 host 전체 프로세스를 보지 않아야 할 때분류: Layer 4 - CS 기초 — 운영체제 & Linux
Linux Namespace는 프로세스가 볼 수 있는 시스템 세계를 나누는 커널 기능이고, cgroups(Control Groups)는 프로세스 그룹이 얼마나 많은 자원을 쓸 수 있는지 제한하고 관측하는 커널 기능이다.
컨테이너는 보통 이 둘을 함께 쓴다. Namespace로 PID, 네트워크, 마운트, 사용자 같은 시야를 분리하고, cgroups로 CPU, 메모리, I/O 같은 자원 사용량에 상한을 둔다. 그래서 컨테이너는 별도 OS를 부팅한 VM이 아니라, 호스트 Linux 커널 위에서 격리된 프로세스 묶음이다.
이 문서의 핵심 질문은 세 가지다.
docker run --memory=512m --cpus=0.5 같은 설정이 커널에서 어떤 제한으로 바뀌는가?OOMKilled, CPU throttle, host PID 노출 같은 장애가 보이면 격리 문제인지, 자원 제한 문제인지, 이미지/런타임 문제인지 어떻게 구분하는가?일반 Linux 프로세스도 이미 독립 주소 공간을 가진다. process-thread.mdx에서 본 것처럼 한 프로세스의 Heap을 다른 프로세스가 마음대로 덮어쓸 수 없고, system-call-interrupt.mdx에서 본 것처럼 파일과 네트워크 접근은 syscall을 통해 커널이 검증한다.
하지만 서버 운영에서는 “메모리를 서로 덮어쓰지 못한다”만으로 부족하다. 같은 호스트에서 여러 서비스를 돌리려면 다음 질문이 생긴다.
/와 앱 B의 루트 디렉터리 /가 다르게 보여야 하지 않는가?root가 host의 진짜 root와 같으면 위험하지 않은가?chroot()는 이 문제 중 아주 작은 부분만 풀었다. chroot()는 호출 프로세스와 자식 프로세스가 보는 루트 디렉터리만 바꾼다. 파일 경로의 시작점을 바꾸는 데는 유용하지만, 보안 샌드박스나 컨테이너 격리로는 부족하다.
| chroot로 남는 문제 | 왜 위험한가 | Namespace/cgroups가 보완하는 축 |
|---|---|---|
| PID 가시성 | /proc가 host 기준이면 다른 프로세스 PID를 볼 수 있다 | PID Namespace |
| 네트워크 공유 | 같은 host 포트, 라우팅 테이블, 소켓 공간을 공유한다 | Network Namespace |
| 마운트 경계 부족 | rootfs를 바꿔도 mount view와 열린 fd가 새어나갈 수 있다 | Mount Namespace |
| 사용자 권한 공유 | 컨테이너 안 root가 host root에 가까우면 탈출 피해가 커진다 | User Namespace, capability |
| 자원 상한 부재 | fork bomb, 메모리 누수, I/O 폭주가 host 전체를 압박한다 | cgroups v2 |
여기서 중요한 분리는 격리와 제한이다.
둘은 서로 대체 관계가 아니다. PID Namespace가 있어도 메모리 사용량은 제한하지 못하고, memory.max가 있어도 host 프로세스 목록을 숨기지는 못한다. 컨테이너 런타임은 이 두 축을 같이 조립한다.
Namespace와 cgroups는 “여러 프로세스를 한 host에서 같이 돌리되, 서로의 세계와 자원 사용량을 분리해야 한다”는 문제에서 등장했다.
chroot 시대의 핵심 실패는 “프로세스를 별도 공간에 넣었다고 생각했지만, 실제로는 파일시스템 경로 일부만 바뀌었다”는 점이다. 한 프로세스가 다른 프로세스의 PID를 볼 수 있고, 같은 네트워크 스택을 쓰며, 메모리와 CPU를 무제한으로 쓸 수 있다면 멀티테넌트 실행 환경은 성립하지 않는다.
Linux는 이 문제를 하나의 거대한 컨테이너 기능으로 풀지 않았다. 대신 메인라인 커널에 작은 기능들을 단계적으로 넣고, Docker, containerd, runc 같은 런타임이 이를 조합하게 했다.
파일시스템 마운트 뷰를 분리해 chroot보다 안전한 rootfs 경계를 만들 수 있게 했다.
프로세스 번호 공간, 네트워크 스택, 자원 제한을 분리해 컨테이너의 뼈대가 갖춰졌다.
컨테이너 안 UID/GID를 host의 다른 UID/GID로 매핑해 root 권한의 폭발 반경을 줄였다.
컨테이너가 자기 cgroup 경로만 보게 하고, 컨트롤러별 다중 계층을 단일 트리로 정리했다.
이 토픽이 사라지면 Docker의 핵심 설명이 빈다. Dockerfile과 이미지는 L5에서 다루지만, 실행된 컨테이너가 “왜 host와 다르게 보이는지”와 “왜 512MiB를 넘으면 죽는지”는 L4의 커널 기능으로 설명해야 한다.
아래 용어는 뒤에서 반복된다. 처음에는 내부 구현보다 “무엇을 구분하기 위한 말인가”를 잡으면 된다.
| 용어 | 첫 이해 | 왜 필요한가 |
|---|---|---|
| Namespace | 프로세스가 보는 특정 시스템 자원의 이름 공간 또는 시야 | 같은 host에서도 PID, 네트워크, 마운트가 다르게 보이게 한다 |
| cgroup | 자원 사용량을 제한·집계하는 프로세스 그룹 | 컨테이너가 CPU, 메모리, I/O를 무제한 쓰지 못하게 한다 |
| cgroups v2 | cgroup 컨트롤러를 단일 트리(/sys/fs/cgroup)로 통합한 현재 모델 | CPU, memory, io 제한 파일을 같은 cgroup 경로에서 읽고 쓴다 |
| PID Namespace | 프로세스 ID 번호 공간을 분리한다 | 컨테이너 안에서 자기 프로세스가 PID 1처럼 보인다 |
| Network Namespace | 네트워크 인터페이스, 라우팅 테이블, 소켓 공간을 분리한다 | 컨테이너마다 IP와 포트 공간을 따로 갖게 한다 |
| Mount Namespace | 마운트된 파일시스템 목록과 rootfs view를 분리한다 | 컨테이너가 이미지 rootfs와 volume만 보게 한다 |
| User Namespace | 컨테이너 안 UID/GID를 host의 다른 UID/GID로 매핑한다 | 컨테이너 안 root가 host root와 같지 않게 만들 수 있다 |
cpu.max | cgroups v2에서 CPU quota와 period를 나타내는 파일 | 50000 100000이면 100ms 중 50ms만 CPU를 쓸 수 있다 |
memory.max | cgroups v2에서 메모리 하드 제한을 나타내는 파일 | 초과하면 cgroup 내부 OOM kill이 발생할 수 있다 |
memory.high | 메모리 소프트 제한이다 | 초과하면 reclaim과 throttling으로 느려질 수 있지만 바로 죽지는 않는다 |
| OOMKilled | cgroup 또는 노드 메모리 압박으로 프로세스가 강제 종료된 상태 | 앱 예외가 아니라 커널의 메모리 방어일 수 있다 |
| throttling | 자원 한도 때문에 프로세스 실행을 일부러 늦추는 것 | CPU나 memory.high 초과에서 p99 latency가 튈 수 있다 |
| container runtime | 컨테이너 실행을 실제 커널 기능 호출로 바꾸는 계층 | Docker CLI의 요청이 커널 설정으로 내려가는 길이다 |
| containerd | 이미지 관리와 컨테이너 생명주기를 다루는 고수준 런타임 | Docker와 Kubernetes가 컨테이너 실행을 위임하는 중간 계층이다 |
| runc | OCI(Open Container Initiative) 스펙을 받아 실제 Namespace, cgroup, mount를 설정하는 저수준 런타임 | clone(), cgroup 파일 쓰기, rootfs 준비가 이 층에서 일어난다 |
시스템 콜 관점에서는 clone(), unshare(), setns()를 함께 기억하면 좋다.
clone()은 새 실행 흐름을 만들 때 어떤 자원을 공유하고 어떤 Namespace를 새로 만들지 플래그로 고르는 Linux 입구다.unshare()는 이미 실행 중인 프로세스가 일부 자원을 새 Namespace로 분리할 때 쓴다.setns()는 프로세스가 기존 Namespace에 들어갈 때 쓴다. docker exec나 디버깅 도구의 내부 모델을 이해할 때 도움이 된다.Namespace는 커널 객체 자체를 복사하지 않는다. 같은 host 커널을 쓰되, 프로세스가 특정 자원을 조회할 때 어느 이름 공간을 기준으로 볼지 바꾼다.
예를 들어 host에서 nginx 프로세스의 실제 PID가 12847일 수 있다. 그런데 그 프로세스가 새 PID Namespace 안에서 실행되면 컨테이너 내부에서는 PID 1로 보인다. 숫자가 “거짓”인 것이 아니라, 서로 다른 PID 번호 공간에서 같은 task를 다르게 부르는 것이다.
프로세스 ID 번호 공간을 분리한다. 컨테이너 안 메인 프로세스가 PID 1이 된다.
컨테이너 내부 `ps`가 host 전체 프로세스를 보지 않아야 할 때네트워크 인터페이스, 라우팅 테이블, 포트 바인딩 공간을 분리한다.
컨테이너마다 독립 IP와 포트 공간을 가져야 할 때마운트 목록과 rootfs view를 분리한다. chroot보다 넓은 파일시스템 경계다.
이미지 레이어, volume, `/proc` mount를 컨테이너별로 나눌 때컨테이너 안 UID/GID를 host의 다른 UID/GID로 매핑한다.
컨테이너 안 root 권한이 host root 권한으로 이어지지 않게 할 때hostname/domainname, System V IPC, POSIX message queue 같은 이름 공간을 분리한다.
컨테이너별 hostname이나 공유 메모리 충돌을 막을 때프로세스가 보는 cgroup 경로를 자기 범위로 좁힌다.
컨테이너 내부에서 host 전체 cgroup 트리를 노출하고 싶지 않을 때가장 작은 사고 실험은 PID 1이다.
| 관찰 위치 | 같은 nginx master가 어떻게 보이는가 | 의미 |
|---|---|---|
| host | PID 12847 | host PID Namespace의 실제 task 번호 |
| 컨테이너 내부 | PID 1 | 컨테이너 PID Namespace에서 첫 프로세스 |
/proc/12847/ns/pid | pid:[4026532253] 같은 inode | 같은 inode면 같은 PID Namespace |
여기서 PID 1은 특별하다. 컨테이너 안에서 PID 1이 된 프로세스는 일반 앱 프로세스처럼 보이지만, 고아 자식 프로세스를 reap하고 종료 신호를 처리해야 하는 init 역할도 일부 떠안는다. Node.js 앱을 바로 PID 1로 실행하면 SIGTERM이 기대대로 처리되지 않거나 zombie가 쌓이는 문제가 생길 수 있다.
반례: “PID Namespace가 있으니 컨테이너 안 프로세스가 host에 전혀 존재하지 않는다”는 말은 틀렸다. host 커널 입장에서는 여전히 task가 있고 host PID도 있다. Namespace는 존재를 지우는 것이 아니라, 특정 프로세스가 보는 번호 공간을 바꾼다.
Network Namespace는 컨테이너마다 독립된 네트워크 스택을 제공한다. Docker 기본 bridge 네트워크에서는 보통 다음 구조가 생긴다.
eth0와 loopback(lo)이 보인다.eth0와 이어지는 veth pair의 반대쪽이 보인다.그래서 -p 8080:3000은 host의 8080 포트를 컨테이너 Network Namespace 안의 3000 포트로 연결하는 설정이다. 컨테이너 안에서 localhost:3000은 자기 Namespace 안의 loopback이고, host의 localhost:3000과 같지 않다.
반례: “컨테이너마다 포트 공간이 독립이면 host 포트 충돌이 없어야 한다”는 말은 반만 맞다. 컨테이너 내부 포트는 독립적이지만, 외부에서 접근하려고 host 포트에 publish하면 host 포트는 여전히 전역 자원이다. 두 컨테이너가 모두 -p 8080:3000을 요구하면 host 8080에서 충돌한다.
Mount Namespace는 프로세스가 보는 마운트 목록을 분리한다. 컨테이너가 /를 보면 host의 /가 아니라 이미지 레이어와 volume이 조합된 rootfs를 본다. Docker 문서에서 말하는 OverlayFS, bind mount, volume은 이 Mount Namespace의 view 위에서 보인다.
User Namespace는 권한 경계와 연결된다. 컨테이너 안에서 UID 0(root)인 프로세스를 host의 일반 UID로 매핑할 수 있다. 이 기능이 없으면 컨테이너 내부 root가 host 자원에 접근했을 때 피해 범위가 커진다. 다만 User Namespace만으로 모든 보안 문제가 해결되는 것은 아니다. capability, seccomp, AppArmor/SELinux, read-only filesystem, volume 정책이 함께 필요하다.
작은 비유로는 브라우저의 iframe sandbox를 떠올릴 수 있다. 같은 브라우저 프로세스 안에서도 DOM, origin, storage 접근 범위를 제한한다는 점은 비슷하다. 다만 Linux Namespace는 브라우저 API가 아니라 커널이 프로세스의 PID, 네트워크, 마운트 같은 OS 자원 view를 바꾸는 기능이다.
Namespace는 “보이는 세계”를 바꾼다. 그렇기 때문에 다음 일을 직접 하지는 않는다.
| Namespace가 하지 않는 일 | 대신 봐야 할 축 |
|---|---|
| CPU를 0.5개까지만 쓰게 하기 | cgroups cpu.max, cpu.weight |
| 메모리 512MiB 초과 시 죽이기 | cgroups memory.max, OOM killer |
| 이미지 파일을 만들고 배포하기 | Docker image, registry, OverlayFS |
| syscall을 허용/차단하기 | seccomp, capability, LSM(Linux Security Modules) |
| 커널 취약점으로부터 완전히 분리하기 | VM, microVM, gVisor/Kata 같은 sandboxed runtime |
이 경계를 알아야 장애를 잘못 분류하지 않는다. ps에 host 프로세스가 보이면 Namespace 문제일 수 있지만, exit code 137은 보통 cgroup 메모리 제한 문제다.
cgroups(Control Groups)는 프로세스를 그룹으로 묶고 그 그룹의 자원 사용량을 제한, 집계, 관측하는 커널 기능이다. 현재 학습 기준은 cgroups v2다. cgroups v2는 여러 컨트롤러를 단일 계층 구조(/sys/fs/cgroup)로 통합한다.
| 컨트롤러 | 제어 대상 | 대표 파일 | 첫 이해 |
|---|---|---|---|
cpu | CPU quota와 가중치 | cpu.max, cpu.weight, cpu.stat | 얼마나 오래 CPU를 쓸 수 있는가 |
memory | 메모리 한도와 압박 | memory.max, memory.high, memory.current, memory.events | 얼마까지 메모리를 쓰고, 넘으면 어떻게 되는가 |
io | 블록 I/O 대역폭과 가중치 | io.max, io.weight, io.pressure | 디스크 읽기/쓰기 사용량을 어떻게 나눌 것인가 |
pids | 생성 가능한 task 수 | pids.max, pids.current | fork bomb이나 과도한 child process 생성을 막을 수 있는가 |
cgroups v1은 컨트롤러별로 별도 계층을 만들 수 있었다. 같은 프로세스가 memory 계층에서는 A 경로에, cpu 계층에서는 B 경로에 속할 수 있어 운영자가 실제 제한을 추적하기 어려웠다.
cgroups v2는 단일 트리를 쓴다.
/sys/fs/cgroup/ system.slice/ docker-<id>.scope/ cgroup.procs cpu.max cpu.stat memory.current memory.events memory.high memory.max io.max이 단일 트리 덕분에 “이 프로세스 묶음의 CPU 제한, 메모리 제한, I/O 제한이 무엇인가”를 같은 경로에서 읽을 수 있다. Docker, containerd, kubelet, systemd는 이 트리의 하위 디렉터리를 만들어 프로세스를 배치한다.
| 비교 항목 | cgroups v1 | cgroups v2 |
|---|---|---|
| 계층 구조 | 컨트롤러별 다중 계층 | 단일 통합 트리 |
| 메모리 하드 제한 | memory.limit_in_bytes | memory.max |
| CPU 쿼터 | cpu.cfs_quota_us + cpu.cfs_period_us | cpu.max |
| I/O 컨트롤러 이름 | blkio | io |
| rootless/위임 모델 | 제한적이고 복잡 | v2에서 더 일관적 |
첫 회독에서는 파일 이름이 바뀐다는 점보다 v2에서는 한 프로세스 묶음이 하나의 cgroup 경로에 속하고, 그 경로 아래 파일들이 제한과 관측 지표가 된다는 점을 잡으면 된다.
cpu.max — CPU를 몇 ms 쓸 수 있는가cpu.max는 quota period 형식이다. 기본 단위는 마이크로초다.
cpu.max 값 | 학습용 해석 | Docker/K8s 감각 |
|---|---|---|
max 100000 | 제한 없음 | CPU limit 없음 |
50000 100000 | 100ms 주기마다 50ms 사용 가능 | 0.5 CPU |
25000 100000 | 100ms 주기마다 25ms 사용 가능 | 0.25 CPU |
200000 100000 | 100ms 주기마다 200ms 사용 가능 | 2 CPU |
이 제한은 평균 CPU 사용률만 낮추는 것이 아니다. 특정 100ms 구간에서 quota를 다 쓰면 다음 period가 올 때까지 task가 멈출 수 있다. 그래서 평균 CPU 사용률이 50% 이하로 보여도 요청 burst, JSON 직렬화, GC, 암호화 작업이 한 구간에 몰리면 p99 latency가 튄다.
수치 예시: cpu.max = 50000 100000인 컨테이너가 100ms 동안 요청을 처리한다. 처음 40ms는 JS 실행, 다음 10ms는 GC, 그 뒤 20ms가 더 필요하다고 하자. 앞의 50ms에서 quota를 모두 썼기 때문에 나머지 20ms 작업은 다음 period까지 기다린다. 애플리케이션 로그에는 “CPU 사용률 50%“처럼 보일 수 있지만, 사용자 요청은 period 경계에서 지연된다.
cpu.weight는 quota와 다르다. quota는 상한이고, weight는 CPU가 부족할 때 나눠 갖는 비율이다. limit 없이 weight만 있으면 idle CPU를 더 쓸 수 있다. 반대로 quota가 낮으면 host에 CPU가 남아 있어도 해당 cgroup은 period 안에서 더 못 쓸 수 있다.
memory.max와 memory.high — 죽일 것인가, 느리게 할 것인가memory.max는 하드 제한이다. cgroup의 메모리 사용량이 이 값을 넘고 reclaim으로도 해결되지 않으면 cgroup 내부에서 OOM kill이 발생할 수 있다. Docker의 --memory=512m, Kubernetes의 limits.memory: 512Mi는 이 축으로 이해하면 된다.
memory.high는 소프트 제한이다. 이 값을 넘으면 커널이 해당 cgroup의 메모리 회수를 강하게 시도하고, 프로세스가 느려질 수 있다. 하지만 memory.high 초과만으로 바로 kill되지는 않는다.
| 파일 | 제한 성격 | 초과 시 현상 | 운영에서 보이는 신호 |
|---|---|---|---|
memory.high | 소프트 제한 | reclaim, throttling, latency 증가 | memory.events의 high 증가, memory.pressure 상승 |
memory.max | 하드 제한 | cgroup OOM, 프로세스 kill | OOMKilled, exit code 137, memory.events의 oom_kill 증가 |
수치 예시: memory.max = 536870912라면 512MiB다. 같은 컨테이너에서 memory.high = 483183820이라면 대략 461MiB, 즉 max의 약 90%다. 이 설계는 512MiB에 닿기 전에 reclaim 압박을 보게 하려는 의도다. 다만 너무 낮게 잡으면 정상 워크로드도 계속 reclaim에 걸려 p99 latency가 커진다.
선택 심화: 일부 Kubernetes/커널 조합은 memory.high를 Memory QoS 같은 기능과 함께 다루거나, 오래된 환경에서는 기대한 방식으로 노출하지 않을 수 있다. 그래서 memory.high는 “항상 kill 전에 자동 완충해주는 장치”가 아니라, 실제 노드와 런타임에서 memory.events의 high 증가와 latency 변화를 같이 확인해야 하는 압박 신호로 읽는다.
반례: free -m에서 host 메모리가 충분하다고 해서 컨테이너 OOM이 불가능한 것은 아니다. cgroup은 host 전체 RAM과 별도의 상한을 둔다. host에 32GiB가 남아 있어도 어떤 컨테이너의 memory.max가 512MiB이고 그 컨테이너가 530MiB를 쓰면 OOMKilled가 될 수 있다.
CPU와 메모리가 가장 자주 보이지만, 컨테이너 운영에서는 I/O와 프로세스 수 제한도 중요하다.
io.max는 특정 block device에 대해 읽기/쓰기 대역폭이나 IOPS 상한을 걸 수 있다.io.pressure는 I/O 때문에 task가 얼마나 기다렸는지 보여준다.pids.max는 cgroup 안에서 만들 수 있는 task 수를 제한한다. fork bomb이나 과도한 child_process.spawn()으로 host가 마비되는 일을 줄인다.작은 반례: CPU와 메모리가 충분한데도 API가 느릴 수 있다. 로그를 동기 파일로 많이 쓰거나, 컨테이너 내부 임시 디스크에 대량 파일을 쓰면 I/O wait가 병목이 된다. 이때 CPU limit을 올려도 해결되지 않는다. cgroup의 io.pressure, host의 disk latency, 애플리케이션의 logging 방식을 같이 봐야 한다.
cgroup 파일은 모두 같은 성격이 아니다. 어떤 파일은 현재 설정값이고, 어떤 파일은 누적 이벤트이며, 어떤 파일은 최근 압박 정도를 보여준다. 이 구분을 못 하면 “제한이 있다”와 “실제로 제한에 걸렸다”를 섞게 된다.
| 파일 유형 | 예시 | 질문 | 해석 |
|---|---|---|---|
| 설정값 | cpu.max, memory.max, memory.high, pids.max | 어떤 상한이 걸려 있는가? | 배포 스펙과 런타임 설정의 결과 |
| 현재값 | memory.current, pids.current | 지금 얼마나 쓰고 있는가? | 순간 사용량. peak를 놓칠 수 있다 |
| 누적 이벤트 | memory.events, cpu.stat | 제한에 실제로 걸린 적이 있는가? | oom_kill, high, nr_throttled 같은 실패 흔적 |
| 압박 지표 | memory.pressure, io.pressure, cpu.pressure | task가 자원 부족 때문에 얼마나 기다렸는가? | 죽지 않는 성능 저하를 찾는 단서 |
worked example: 컨테이너가 느려졌는데 memory.current가 410MiB이고 memory.max가 512MiB라면 겉으로는 여유가 있어 보인다. 하지만 memory.events의 high가 계속 증가하고 memory.pressure some avg10이 20%를 넘으면, 최근 10초 동안 많은 task가 reclaim 때문에 지연됐다는 뜻이다. 이 문제는 “아직 max에 안 닿았으니 메모리 문제가 아니다”가 아니라 “하드 OOM 전의 소프트 압박”으로 읽어야 한다.
또 다른 반례: cpu.stat의 usage_usec가 낮다고 해서 CPU 문제가 없다는 뜻은 아니다. nr_throttled가 높으면 quota 때문에 실행 기회를 잃고 있을 수 있다. 반대로 nr_throttled가 거의 0인데 CPU 사용률이 높다면 quota보다 앱의 CPU-bound 작업이나 worker 수를 먼저 본다.
컨테이너 메모리 limit을 정할 때 memory.max 숫자만 외우면 부족하다. Node.js 서버 하나가 512MiB 컨테이너에서 돈다고 해보자.
| 영역 | 학습용 예시 | 왜 남겨야 하는가 |
|---|---|---|
| V8 old space | 350~400MiB 시작점 | JS 객체가 오래 살아남는 공간. limit과 같게 잡으면 여유가 없다 |
| native/Buffer/external | 40~80MiB | 이미지 처리, HTTP body, crypto, addon이 JS heap 밖 메모리를 쓴다 |
| 스레드 stack/런타임/라이브러리 | 30~60MiB | Node 런타임, libuv thread pool, shared library가 RSS에 포함된다 |
| 파일 캐시와 순간 peak | 상황별 | upload, JSON stringify, 압축에서 순간적으로 RSS가 뛴다 |
이 숫자는 정답이 아니라 사고 모델이다. memory.max가 512MiB인데 V8 heap을 512MiB로 잡으면, JS heap 밖 메모리가 들어갈 공간이 없다. 그래서 컨테이너 OOM은 heapUsed가 512MiB에 닿기 전에 발생할 수 있다.
결정 기준:
heapUsed만 증가하면 JS 객체 누수를 의심한다.heapUsed는 안정적인데 rss가 증가하면 Buffer, native addon, mmap, 이미지/압축 작업을 의심한다.memory.high와 pressure 지표를 함께 본다. OOM은 없지만 지연이 누적될 수 있다.컨테이너는 독립 OS가 아니다. 컨테이너는 다음 세 가지를 조합한 실행 단위다.
flowchart TD Kernel["Host Linux 커널"] --> NS["Namespace: 보이는 세계 분리"] Kernel --> CG["cgroups v2: 자원 제한과 관측"] Kernel --> MNT["Mount/rootfs: 이미지와 volume view"] NS --> PID["PID: 컨테이너 안 PID 1"] NS --> NET["NET: 전용 IP·포트·라우팅"] NS --> USER["USER: UID/GID 매핑"] CG --> CPU["cpu.max: CPU quota"] CG --> MEM["memory.max/high: OOM 또는 reclaim"] CG --> IO["io.max/pressure: I/O 제한과 압박"] MNT --> ROOTFS["overlayfs rootfs + volume"]
VM과 비교하면 경계가 더 선명해진다.
| 비교 축 | VM | 컨테이너 |
|---|---|---|
| 커널 | VM마다 게스트 커널 | host 커널 공유 |
| 시작 비용 | OS 부팅이 필요해 보통 더 느림 | 프로세스 시작에 가까워 빠름 |
| 격리 강도 | 하이퍼바이저와 게스트 커널 경계 | Namespace/cgroups와 커널 보안 기능 경계 |
| 적합한 경우 | 커널 버전, 강한 테넌트 격리, 디바이스 요구가 핵심 | 같은 커널 위에서 앱 단위 배포와 자원 효율이 핵심 |
컨테이너 not-a-VM 오해: 컨테이너 안에서 Ubuntu 파일들이 보인다고 해서 Ubuntu 커널이 따로 부팅된 것은 아니다. uname -r은 컨테이너 이미지의 배포판 버전이 아니라 host Linux 커널 버전을 보여준다. Docker Desktop처럼 macOS/Windows에서 Docker를 쓰는 경우에는 중간에 Linux VM이 있으므로, 컨테이너는 그 VM의 Linux 커널을 공유한다.
docker run 요청은 어떻게 커널 설정으로 내려가는가docker run --memory=512m --cpus=0.5 nginx를 실행하면 사용자는 Docker CLI만 보지만, 내부에서는 여러 계층이 움직인다.
학습용으로 단순화하면 다음처럼 볼 수 있다.
docker run --memory=512m --cpus=0.5 nginx -> runc -> clone(CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | ...) -> cgroup 디렉터리 생성 -> memory.max = 536870912 -> cpu.max = 50000 100000 -> overlayfs rootfs mount -> nginx를 새 Namespace 안에서 PID 1로 실행Kubernetes도 본질은 같다. Pod spec의 resources.requests와 resources.limits는 kubelet, containerd, runc를 거쳐 cgroup 파일과 스케줄링 정책으로 변환된다.
| K8s 스펙 | cgroups v2 감각 | 의미 |
|---|---|---|
limits.memory: 512Mi | memory.max = 536870912 | 넘으면 OOMKilled 가능 |
requests.memory: 256Mi | 환경에 따라 memory.low 같은 보호/스케줄링 힌트와 연결될 수 있음 | 이 정도 메모리는 보호받아야 한다는 의도 |
limits.cpu: 500m | cpu.max = 50000 100000 | 0.5 CPU quota |
requests.cpu: 250m | cpu.weight나 스케줄링 힌트와 연결 | CPU 경합 시 비율과 배치 판단에 영향 |
정확한 파일 매핑은 Kubernetes 버전, cgroup driver, QoS class, 노드 OS에 따라 달라질 수 있다. 하지만 첫 학습에서는 “요청/제한 값이 결국 커널의 cgroup 제한과 관측 지표로 내려간다”는 방향을 잡으면 충분하다.
컨테이너 장애를 볼 때 가장 먼저 할 일은 문제의 축을 나누는 것이다.
보이면 안 되는 프로세스, 네트워크, hostname, 파일 경로가 보인다.
`--pid=host`, `hostNetwork: true`, 잘못된 volume mount, user namespace 미사용을 의심한다.메모리, CPU, I/O, task 수 제한 때문에 느려지거나 죽는다.
`OOMKilled`, `memory.events`, `cpu.stat`, `io.pressure`, `pids.current`를 본다.프로세스가 시작하지 못하거나, 파일이 없거나, entrypoint/CMD가 잘못됐다.
`exec format error`, `command not found`, missing env, wrong image tag, bad healthcheck를 본다.실무에서 자주 쓰는 1차 분류는 다음과 같다.
| 관찰 신호 | 먼저 의심할 축 | 이유 |
|---|---|---|
OOMKilled: true, exit code 137 | Resource limit | memory.max 초과 또는 노드 메모리 압박으로 kill |
| 낮은 평균 CPU인데 p99만 급등 | Resource limit | cpu.max period 안에서 throttle 가능 |
컨테이너 안 ps에 host 프로세스가 보임 | Isolation | PID Namespace 공유 또는 hostPID |
| 컨테이너 안 hostname이 host와 같음 | Isolation | UTS Namespace 공유 |
| host 포트가 이미 사용 중 | Isolation + runtime config | 내부 포트가 아니라 host publish 포트 충돌 |
/app/server.js가 없어서 시작 실패 | Image/runtime | 이미지 빌드, workdir, COPY, CMD 문제 |
| 컨테이너 안 root가 host 파일을 수정 가능 | Isolation/security | volume mount, user namespace, capability 문제 |
fork: Resource temporarily unavailable | Resource limit 또는 PID 1 문제 | pids.max 도달, zombie 누적 가능 |
이 구분이 중요한 이유는 해결 방향이 다르기 때문이다. OOMKilled를 이미지 재빌드로 고칠 수 없고, 잘못된 CMD를 memory limit 상향으로 고칠 수 없다.
컨테이너 장애는 모두 “컨테이너가 안 된다”로 묶여 보이지만, 원인은 서로 다르다.
| 겉으로 보이는 현상 | 잘못된 빠른 결론 | 더 나은 분류 |
|---|---|---|
| 컨테이너가 계속 재시작됨 | Kubernetes가 불안정하다 | OOMKilled, healthcheck 실패, process crash, bad CMD를 분리 |
| 앱이 느림 | CPU가 부족하다 | CPU throttle, memory pressure, I/O wait, DB latency를 분리 |
| 컨테이너 안 파일이 없음 | volume 문제다 | 이미지 빌드 누락, working directory, mount 덮어쓰기, entrypoint를 분리 |
| host에서 포트 접속 안 됨 | 앱 서버가 죽었다 | app bind address, container port, host publish port, security group을 분리 |
| root 권한 문제가 남 | User Namespace가 꺼졌다 | user namespace, container user, capability, volume permission을 함께 확인 |
학습할 때는 “한 번에 고치는 명령”보다 이 분류가 더 중요하다. 분류가 맞으면 필요한 명령은 짧아지고, 분류가 틀리면 많은 명령을 실행해도 원인에서 멀어진다.
Docker Basics로 넘어가기 전에 이미지 문제와 런타임 문제를 구분해 둔다.
| 질문 | 이미지 쪽이면 | 런타임 쪽이면 |
|---|---|---|
| 파일이 없다 | Dockerfile COPY, build context, .dockerignore 문제 | volume mount가 이미지 파일을 덮어쓴 문제 |
| 명령이 없다 | base image에 binary가 없음, multi-stage copy 누락 | entrypoint 인자, shell form/exec form 문제 |
| 권한이 없다 | 이미지 안 파일 owner/mode가 잘못됨 | container user, user namespace, bind mount 권한 문제 |
| 실행 중 죽는다 | 앱 코드나 dependency 문제 | cgroup limit, signal, healthcheck, runtime policy 문제 |
| 네트워크가 안 된다 | 앱이 포트를 열지 않음 | Network Namespace, port publish, Service/Ingress 문제 |
이 표는 Docker 문서의 상세 범위를 빼앗기 위한 것이 아니다. 이 문서에서는 “커널 격리와 제한 문제인지, 이미지 패키징 문제인지”를 먼저 나누는 기준만 가져간다.
이 절은 긴 복구 런북이 아니라 실패 원리를 배우기 위한 지도다. 명령어는 관찰의 최소 단서로만 둔다.
증상: ECS task나 Kubernetes Pod가 갑자기 재시작된다. 상태에 OOMKilled: true가 보이거나 exit code 137로 종료된다.
원리: 프로세스가 속한 cgroup의 memory.max를 넘었고 reclaim으로 해결되지 않으면 커널이 cgroup 내부 프로세스를 kill한다. 컨테이너의 메인 프로세스가 죽으면 컨테이너도 종료된다.
작은 관찰 포인트:
cat /sys/fs/cgroup/<scope>/memory.events# high 0# max 3# oom 1# oom_kill 1oom_kill이 증가했다면 앱 예외보다 cgroup 메모리 제한을 먼저 본다. Node.js라면 heapUsed만 보지 말고 rss, native buffer, image 처리 버퍼, thread stack, V8 old space 한도를 함께 봐야 한다.
판단 기준:
heapUsed나 rss가 계속 증가하면 누수 가능성이 크다.heapUsed는 낮은데 rss만 높으면 native memory, Buffer, shared library, mmap을 의심한다.memory.high throttling — 죽지 않았는데 느려지는 경우증상: 컨테이너가 재시작되지는 않지만 응답이 점점 느려진다. 커널 로그에는 특별한 OOM 메시지가 없다.
원리: memory.high는 kill이 아니라 reclaim과 throttling을 유도한다. 이 과정은 별도 에러 로그 없이 memory.events의 high 카운터와 memory.pressure에만 보일 수 있다.
cat /sys/fs/cgroup/<scope>/memory.events# high 14# max 0# oom 0# oom_kill 0
cat /sys/fs/cgroup/<scope>/memory.pressure# some avg10=23.45 avg60=18.32 avg300=9.11 total=...# full avg10=5.12 avg60=3.40 avg300=1.21 total=...반례: “OOMKilled가 아니니 메모리 문제는 아니다”는 틀렸다. OOM은 하드 실패이고, memory.high는 성능 저하형 실패다. 재시작이 없어서 더 늦게 발견될 수 있다.
증상: CPU 사용률은 50% 아래로 보이는데 p99 latency가 급등한다. 특히 요청 burst, JSON 직렬화, GC, 압축, 암호화 시점에 튄다.
원리: cpu.max = 50000 100000이면 100ms period마다 50ms quota를 다 쓰는 순간 다음 period까지 실행이 밀린다. 평균 CPU는 낮아 보여도 period 단위로는 자주 막힐 수 있다.
cat /sys/fs/cgroup/<scope>/cpu.stat# nr_periods 1200# nr_throttled 348# throttled_usec 34800000nr_throttled / nr_periods가 348/1200이면 약 29%의 period에서 throttle이 발생했다. 이 경우 CPU limit을 올리거나, limit 제거가 가능한 서비스인지 보거나, CPU가 긴 작업을 worker/queue로 분리한다.
증상: 컨테이너 안에서 ps aux를 실행했는데 host의 프로세스가 보인다. 컨테이너 메인 프로세스가 PID 1이 아니라 host의 높은 PID처럼 보인다.
원리: PID Namespace가 분리되지 않았거나 --pid=host, Kubernetes hostPID: true 같은 설정으로 host PID Namespace를 공유한다.
판단 기준:
증상: docker stop이 timeout까지 기다린 뒤 SIGKILL로 끝난다. <defunct> 프로세스가 누적된다. 앱 로그에는 명확한 에러가 없다.
원리: PID Namespace 안의 PID 1은 일반 프로세스와 다르게 신호 처리와 자식 reap 책임을 갖는다. Node.js, Python, Java 앱은 보통 자신이 init 프로세스가 될 것을 가정하지 않는다.
학습용 판단 기준:
tini, Docker --init)나 명시적 reap 전략이 필요하다.증상: 컨테이너 안에서는 서버가 3000 포트에서 뜬 것 같은데 host에서 접근이 안 된다. 또는 host port가 이미 사용 중이라는 에러가 난다.
원리: 컨테이너 내부 포트와 host publish 포트는 다르다. Network Namespace 안의 localhost:3000은 host의 localhost:3000이 아니다. 외부 접근에는 bridge, veth, NAT, port publish 설정이 필요하다.
판단 기준:
127.0.0.1에만 bind하면 같은 컨테이너 안에서는 접근되지만 외부 publish가 기대대로 동작하지 않을 수 있다.-p 8080:3000에서 충돌하는 것은 컨테이너 내부 3000이 아니라 host 8080이다.증상: 컨테이너가 root로 실행되고, 민감한 host path가 volume으로 붙어 있다. 컨테이너 탈취 시 host 파일 변경 위험이 있다.
원리: Namespace는 중요한 경계지만 완전한 보안 샌드박스는 아니다. User Namespace, capability drop, seccomp, read-only rootfs, volume 최소화, non-root user가 함께 필요하다.
반례: “컨테이너니까 root로 실행해도 host에는 영향이 없다”는 위험한 직관이다. host 커널을 공유하고, 잘못된 volume mount나 capability가 있으면 피해 범위가 커진다.
증상: 컨테이너 안에서 hostname을 봤을 때 host 서버명과 같거나, 서비스가 hostname 기반으로 자기 정체성을 잘못 판단한다.
원리: UTS Namespace는 hostname과 domainname view를 분리한다. 표준 컨테이너는 보통 컨테이너 ID나 Pod 이름에 가까운 hostname을 갖는다. --uts=host 같은 설정을 쓰면 host의 UTS Namespace를 공유한다.
판단 기준:
fork: Resource temporarily unavailable — pids 제한과 zombie를 함께 본다증상: 새 child process를 만들지 못하고 Resource temporarily unavailable 류의 에러가 보인다. 동시에 <defunct>가 누적되어 있을 수 있다.
원리: cgroup의 pids.max에 닿았거나, 부모가 자식 종료 상태를 reap하지 않아 zombie가 process table entry를 계속 잡고 있을 수 있다. PID Namespace 안에서는 컨테이너 PID 1이 이 문제를 더 크게 만든다.
작은 수치 감각: pids.max = 256인 컨테이너에서 앱이 요청마다 외부 명령을 spawn하고, 종료 상태를 회수하지 못해 zombie가 쌓이면 실제 실행 중인 작업이 적어도 새 process 생성이 막힌다. CPU와 메모리가 충분해도 프로세스 수 제한에서 멈춘다.
결정 기준:
pids.current가 pids.max에 가까우면 자식 프로세스 생성 패턴과 zombie를 본다.pids.max를 크게 올리면 증상은 늦춰지지만, zombie 누적 원인은 남는다.표준 컨테이너는 빠르고 효율적이지만 host 커널을 공유한다. 신뢰할 수 없는 코드를 실행하거나 강한 테넌트 격리가 필요한 환경에서는 더 두꺼운 경계가 필요할 수 있다.
| 격리 방식 | 커널 공유 | 시작 속도/오버헤드 감각 | 적합한 경우 |
|---|---|---|---|
| Namespace+cgroups 표준 컨테이너 | host 커널 공유 | 가장 빠르고 가벼움 | 내부 서비스, 신뢰 가능한 워크로드, 높은 밀도 |
| gVisor 같은 sandboxed runtime | syscall 경계를 사용자 공간 커널이 중개 | 표준 컨테이너보다 느릴 수 있음 | 외부 코드 실행, syscall 노출을 줄이고 싶을 때 |
| Kata Containers/MicroVM | 전용 게스트 커널 | VM에 가까운 오버헤드 | 강한 멀티테넌시, 규정 준수, 커널 경계가 필요한 워크로드 |
| 전통 VM | 게스트 OS와 커널 분리 | 가장 무거운 편 | 커널 버전, 드라이버, 보안 경계가 핵심 요구사항일 때 |
오버헤드 숫자는 런타임 버전, syscall 패턴, storage/network 경로에 따라 크게 달라진다. 처음에는 “경계가 두꺼워질수록 보안 격리는 강해지지만 시작 시간과 syscall 비용을 측정해야 한다”는 trade-off로 읽고, 실제 선택은 자신의 workload에서 측정한다.
결정 질문은 단순하다.
내부 마이크로서비스를 ECS나 Kubernetes에 배포하는 일반 상황에서는 표준 컨테이너가 보통 충분하다. 사용자가 업로드한 코드를 실행하는 함수 빌더, 채점 서버, AI agent sandbox처럼 신뢰 경계가 강한 워크로드는 표준 컨테이너만으로 충분한지 의심해야 한다.
NestJS, ECS, Kubernetes 관점에서는 이 문서를 다음처럼 연결하면 된다.
| 실무 설정/현상 | 커널 개념 | 해석 |
|---|---|---|
ECS task definition memory: 512 | cgroup memory.max | task/container가 쓸 수 있는 메모리 상한 |
ECS task definition cpu: 256 | CPU share/quota 계열 | CPU 경합과 제한 정책에 영향 |
Kubernetes resources.limits.memory | memory.max | 초과 시 OOMKilled 가능 |
Kubernetes resources.limits.cpu | cpu.max | period 단위 throttle 가능 |
docker exec | setns() 모델 | 실행 중인 컨테이너 Namespace 안으로 새 프로세스를 넣는 행위 |
| CloudWatch Container Insights | cgroup 지표 수집 | memory.current, CPU 사용량, throttle 같은 커널 지표를 서비스 지표로 보여줌 |
| Fargate | 컨테이너 + microVM 계층 | AWS가 task 단위로 더 두꺼운 격리 경계를 제공 |
이 문서를 읽은 뒤 Docker Basics로 넘어가면 역할이 나뉜다. 이 문서는 “컨테이너가 커널에서 어떻게 격리되고 제한되는가”를 맡고, Docker 문서는 “그 실행 환경을 이미지, Dockerfile, registry, Compose로 어떻게 패키징하고 배포하는가”를 맡는다.
아래 명령은 개념 확인용이다. 운영 절차로 외우기보다 출력이 어느 경계를 보여주는지 본다.
# cgroups v2인지 확인stat -fc %T /sys/fs/cgroup/# cgroup2fs 이면 v2
# 현재 셸이 속한 cgroupcat /proc/$$/cgroup
# cgroup 경로 안에서 주요 파일 확인ls /sys/fs/cgroup/<scope> | grep -E "cpu.max|cpu.stat|memory.max|memory.events|memory.pressure"관찰 포인트: cgroups v2에서는 한 cgroup 경로 아래에서 CPU, memory, io 관련 파일을 함께 본다.
# host init의 PID namespacereadlink /proc/1/ns/pid
# 특정 프로세스의 namespace 목록ls -l /proc/<PID>/ns
# 두 프로세스의 pid namespace inode가 같으면 같은 PID Namespace를 공유한다.readlink /proc/<PID_A>/ns/pidreadlink /proc/<PID_B>/ns/pid관찰 포인트: Namespace는 이름 공간이고, /proc/<PID>/ns/*의 inode가 같은지로 공유 여부를 볼 수 있다.
unshare로 PID Namespace 감각 잡기# root 권한 또는 user namespace 설정이 필요할 수 있다.sudo unshare --pid --fork --mount-proc bash
# 새 shell 안에서ps aux# bash가 PID 1처럼 보인다.관찰 포인트: host에서 보이는 PID와 새 PID Namespace 안에서 보이는 PID가 다르다. 같은 프로세스라도 어느 Namespace에서 보느냐에 따라 번호가 달라진다.
# 메모리 OOM 단서cat /sys/fs/cgroup/<scope>/memory.events# oom_kill 값이 증가했는지 본다.
# CPU throttle 단서cat /sys/fs/cgroup/<scope>/cpu.stat# nr_throttled / nr_periods 비율을 본다.관찰 포인트: 컨테이너가 죽거나 느려졌을 때 애플리케이션 로그만 보지 말고 cgroup 이벤트와 throttle 지표를 함께 본다.
| 주제 | 왜 필요한가 | 관련 레이어 |
|---|---|---|
| Docker 내부 동작 | 이미지, Dockerfile, OverlayFS, registry를 컨테이너 실행과 연결한다 | L5 |
| Kubernetes Pod와 Node | kubelet, containerd, Pod sandbox, requests/limits가 cgroups로 내려가는 흐름을 본다 | L6 |
| Linux 네트워킹 | veth pair, bridge, NAT, CNI를 더 자세히 이해한다 | L4/L6 보충 |
| seccomp & AppArmor | Namespace/cgroups 외에 syscall과 권한을 어떻게 줄이는지 본다 | L7+ |
| 메모리 관리로 돌아가기 | OOMKilled, RSS, V8 heap, native memory 차이가 헷갈리면 먼저 복습한다 | L4 선행/복습 |
clone(), unshare(), setns()의 공식 설명.cpu.max, memory.high, memory.max, pressure 지표의 기준 문서.docker run --memory=512m --cpus=0.5는 커널 제한으로 내려간다. 학습용으로는 memory.max = 536870912, cpu.max = 50000 100000으로 이해할 수 있다.OOMKilled와 throttle은 resource limit issue, command not found나 잘못된 CMD는 image/runtime issue에 가깝다.