컨테이너는 무엇인가 — 결국 프로세스다
"가벼운 가상 머신" 이라는 설명은 틀렸다. 이 편은 컨테이너가 실제로 무엇인지, 그리고 그 사실이 운영에서 무엇을 바꾸는지를 본다.
1. 격리된 프로세스일 뿐이다
# 호스트에서 본다
$ docker run -d --name web nginx
$ ps -ef | grep nginx
root 48231 48210 nginx: master process nginx -g daemon off;
호스트의 ps 에 그대로 보인다. 가상 머신이라면 절대 안 보일 것이다.
컨테이너 = 리눅스 프로세스 + 세 가지 제약
셋 다 원래 있던 리눅스 기능이다 컨테이너 런타임은 이것을 조합해 주는 도구일 뿐이다
그래서 컨테이너를 "만드는" 특별한 기술은 없다.
docker run이 하는 일은 네임스페이스를 만들고 cgroup 을 걸고 루트를 바꿔 프로세스를 실행하는 것이다.
2. namespace — 무엇이 보이는가
- PID — 프로세스 목록. 컨테이너 안에서 자기가 PID 1 이 된다
- Network — 네트워크 인터페이스·라우팅·포트. 컨테이너마다 자기 IP
- Mount — 마운트 지점. 자기 루트 파일시스템만 본다
- UTS — 호스트명·도메인명
- IPC — 공유 메모리·세마포어
- User — UID/GID 매핑. 안에서는 root, 밖에서는 일반 사용자
- Cgroup — cgroup 계층 자체를 가린다
$ docker run --rm alpine ps -ef
PID USER COMMAND
1 root ps -ef ← 컨테이너 안에서는 자기가 1번이다
$ docker run --rm --pid=host alpine ps -ef | head -3
1 root /sbin/init ← 호스트 네임스페이스를 쓰면 전부 보인다
격리는 선택적으로 풀 수 있다. --pid=host, --net=host 처럼. 이것이 편리하면서 동시에 위험한 지점이다.
3. cgroup — 얼마나 쓰는가
$ docker run -d --memory=512m --cpus=1.5 myapp
- memory — 상한을 넘으면 커널이 프로세스를 죽인다 (OOM Killer)
- cpu — cpu.max 로 주기당 실행 시간을 제한한다
- --cpus=1.5 → 100ms 주기에 150ms 사용 허용
- pids — 프로세스·스레드 수 제한 (fork 폭탄 방어)
- io — 디스크 대역폭 제한
메모리 제한을 넘기면 무슨 일이 생기나
컨테이너 안 프로세스가 512MB 를 넘겨 쓰려 한다
- 커널이 그 cgroup 안에서 프로세스를 하나 죽인다 (OOM Killer)
- 보통 가장 많이 쓰는 프로세스, 즉 우리 애플리케이션
- 컨테이너 상태가 OOMKilled, 종료 코드 137
중요 — 애플리케이션 로그에 아무것도 안 남는다
- 커널이 SIGKILL 로 죽인 것이라 정리 코드가 돌지 않는다
- docker inspect 나 이벤트 로그에서만 확인된다
$ docker inspect web --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
true 137
CPU 제한은 죽이지 않는다 — 느려진다
- 메모리 — 초과하면 죽인다 (하드 리밋)
- CPU — 초과하면 다음 주기까지 재운다 (스로틀링)
증상이 완전히 다르다
- 메모리 → 갑자기 죽는다. 재시작된다
- CPU → 안 죽는데 응답이 튄다. p99 만 나빠진다
CPU 스로틀링은 지표를 봐야 보인다
- cgroup 의 throttled 시간·횟수를 확인한다
- "CPU 사용률은 60% 인데 왜 느리지" 의 정체가 이것인 경우가 많다
4. 가상 머신과 정말로 다른 점
| 가상 머신 | 컨테이너 | |
|---|---|---|
| 커널 | 게스트마다 하나씩 | 호스트 커널을 공유 |
| 기동 | 수십 초 | 밀리초 |
| 이미지 | GB 단위 | MB 단위 |
| 격리 강도 | 강하다 (커널까지 분리) | 약하다 (커널 취약점이 곧 탈출 경로) |
| 다른 OS | 가능 (리눅스 위 윈도우) | 불가 (같은 커널 계열만) |
커널을 공유한다는 사실이 만드는 제약
-
호스트 커널 버전이 곧 컨테이너의 커널 버전이다
- 이미지에 든 배포판은 유저 공간(라이브러리·바이너리)일 뿐이다
- "우분투 이미지" 를 써도 커널은 호스트 것이다
-
커널 파라미터(sysctl)는 대부분 호스트와 공유된다
- 일부만 네임스페이스화돼 있다
-
리눅스 컨테이너는 리눅스에서만 네이티브로 돈다
- macOS·윈도우의 Docker Desktop 은 내부에 리눅스 VM 을 띄운다
- 그래서 파일 마운트 성능이 느리고, 그 위에서 도는 것이다
5. 이미지와 컨테이너는 다른 것이다
클래스와 인스턴스의 관계와 같다 이미지 하나로 컨테이너를 100개 만들어도 이미지는 한 벌만 저장된다
$ docker images # 이미지 목록
$ docker ps -a # 컨테이너 목록 (종료된 것 포함)
컨테이너를 지워도 이미지는 남는다 이미지를 지우려면 그것을 쓰는 컨테이너가 먼저 없어야 한다 "디스크가 찼는데 뭘 지워야 하지" 의 출발점이다
6. 무엇이 표준화됐나
OCI (Open Container Initiative) 표준
이미지 스펙 이미지가 어떤 구조인지
런타임 스펙 컨테이너를 어떻게 실행하는지
배포 스펙 레지스트리와 어떻게 주고받는지
덕분에 도구를 갈아 끼울 수 있다
빌드 Docker Buildx · BuildKit · Buildah · Kaniko
런타임 containerd · CRI-O (그 아래 runc · crun)
레지스트리 Docker Hub · ECR · GHCR · Harbor
계층을 정리하면
docker CLI
→ containerd (고수준 런타임 — 이미지 관리 · 생명주기)
→ runc (저수준 런타임 — 실제로 네임스페이스·cgroup 을 만든다)
→ 리눅스 커널
쿠버네티스는 CRI 로 containerd·CRI-O 를 직접 부른다 그래서 "쿠버네티스가 Docker 를 뺐다" 는 말이 나온 것이다 이미지는 그대로 쓴다 — OCI 표준이기 때문이다
7. 그래서 운영에서 무엇이 달라지나
-
이미지는 불변이다 → 컨테이너 안에서 고친 것은 사라진다
- "들어가서 설정을 바꿨는데 재배포하니 원복됐다" 는 당연한 결과다
- 바꿀 것은 이미지나 환경변수·볼륨에 둔다
-
컨테이너는 언제든 죽고 다시 뜬다고 가정한다
- 상태를 안에 두지 않는다 (로그·업로드 파일·세션)
-
프로세스가 하나 죽으면 컨테이너가 끝난다
- PID 1 이 무엇인지, 시그널을 어떻게 받는지가 중요해진다 (04편)
-
리소스 제한이 없으면 한 컨테이너가 호스트를 잠식한다
- limit 은 선택이 아니라 기본이다
한눈에 정리
- 컨테이너 — 격리된 리눅스 프로세스. namespace + cgroup + 루트 변경
- namespace — 무엇이 보이는가 (PID · Network · Mount · UTS · IPC · User)
- cgroup — 얼마나 쓰는가. 메모리는 죽이고(137) CPU 는 스로틀한다
- VM 과의 차이 — 커널 공유 여부. 그래서 가볍고, 그래서 격리가 약하다
- 이미지/컨테이너 — 읽기 전용 템플릿 vs 실행 인스턴스 + 쓰기 레이어
- OCI — 이미지·런타임·배포 스펙. 도구를 갈아 끼울 수 있는 근거
- 계층 — docker → containerd → runc → 커널
- 운영 전제 — 이미지 불변 · 언제든 죽는다 · limit 은 기본
출처 — OCI Image/Runtime Specification · Docker Docs "Container runtime" · Linux man pages:
namespaces(7)·cgroups(7)· Kubernetes Docs "Container Runtimes"