Table of contents
- 컨테이너는 프로세스다
- 상태 전이 다이어그램
- docker run — 가장 많이 치는 명령
- 컨테이너는 PID 1짜리 회사다
- SIGTERM을 앱에서 받아 처리하기
- start, stop, restart, rm — 상태를 옮기는 명령들
- docker exec — 돌아가는 컨테이너 속으로
- logs와 inspect — 상태를 들여다본다
- 재시작 정책 (restart policy)
- exit code로 “왜 죽었는지” 추적하기
- 한눈에 보는 운영 플로우
- 생명주기에서 꼭 챙길 원칙 다섯
컨테이너는 프로세스다
이미지는 정지된 사진이고, 컨테이너는 움직이는 영상이다. 영상은 시작되고, 일시정지되고, 끝난다. 이 생명주기를 모르면 운영 중에 이상한 일을 많이 만난다. “컨테이너를 재시작했는데 -it가 안 붙어서 바로 Exited로 빠진다”, “서버를 재부팅했더니 컨테이너가 안 올라왔다”, “배포 스크립트에서 SIGKILL이 자꾸 뜬다” 같은 문제가 모두 생명주기 이해 부족에서 나온다.
이 편에서는 컨테이너가 만들어지고 사라지는 전 과정을 따라간다.
상태 전이 다이어그램
가장 먼저 상태 전이부터 한 장으로 보고 가자.
stateDiagram-v2
[*] --> created : docker create
[*] --> running : docker run
created --> running : docker start
running --> paused : docker pause
paused --> running : docker unpause
running --> stopped : docker stop<br/>(SIGTERM → SIGKILL)
running --> stopped : 프로세스 정상 종료
stopped --> running : docker start / restart
stopped --> [*] : docker rm
created --> [*] : docker rm
핵심은 이렇다.
docker run=create+start를 한 번에 함stopped(Exited) 상태의 컨테이너는 아직 디스크에 남아 있다.docker rm으로 명시 삭제해야 사라진다pause는 프로세스를 얼리기만 할 뿐, 자원을 놔주는 건 아니다. 디버깅 용도로 주로 쓴다
docker run — 가장 많이 치는 명령
컨테이너를 띄우는 기본 명령이다. 옵션이 많은데, 실무에서 반복해서 쓰는 것들만 정리하면 이렇다.
docker run \
-d \
--name web \
--restart unless-stopped \
-p 8080:80 \
-e NODE_ENV=production \
-v $(pwd)/data:/data \
--memory 512m --cpus 0.5 \
nginx:1.27
이 한 줄에 중요한 옵션이 다 들어 있다.
-d(--detach): 백그라운드로 실행. 빠지지 않으면 컨테이너 로그가 터미널을 점유한다--name: 컨테이너에 사람이 읽을 수 있는 이름을 붙인다. 없으면pensive_dirac같은 랜덤 이름이 붙는다--restart: 재시작 정책 (뒤에서 자세히)-p HOST:CONTAINER: 포트 포워딩. 호스트 8080을 컨테이너 80에 연결-e KEY=VALUE: 환경변수. Dockerfile의ENV를 덮어쓴다-v HOST:CONTAINER: 볼륨 마운트 (5편)--memory,--cpus: 자원 제한. 컨테이너 하나가 호스트를 잡아먹지 않도록 제한
짧게 테스트할 때는 이런 식으로도 자주 쓴다.
# 일회성 대화형 셀, 종료되면 바로 삭제
docker run --rm -it ubuntu:24.04 bash
# 호스트 네트워크 공유 (디버깅용)
docker run --rm --network host nginx:1.27
--rm은 “끝나면 자동 삭제”다. -it는 -i(표준 입력 연결)와 -t(TTY 할당)의 조합으로, 쉘처럼 대화형으로 쓸 때 쓴다.
컨테이너는 PID 1짜리 회사다
컨테이너는 격리된 PID namespace를 갖고, 그 안에서 지정한 프로세스가 PID 1로 실행된다. 리눅스에서 PID 1은 특별한 존재다.
- 고아 프로세스를 입양해야 한다 (
wait()호출로 좀비를 거둬야 함) - 시그널이 자동으로 forward되지 않는다. 명시적으로 받고 자식에 전달해야 함
- 기본 시그널 핸들러 대부분이 비활성이다. 예를 들어 PID 1은
SIGTERM을 “기본 동작”으로 무시한다
이걸 모르고 쉘 스크립트를 PID 1로 실행하면 이상한 일이 벌어진다. 특히 docker stop이 동작 안 하고 10초 대기 후 SIGKILL로 죽는다.
CMD shell form의 함정
3편에서 잠깐 언급한 내용을 풀어보자. Dockerfile에 이렇게 쓰면 문제가 된다.
CMD node server.js # shell form
이건 내부적으로 /bin/sh -c "node server.js"로 실행된다. 그러면 프로세스 트리는 이렇게 된다.
PID 1: /bin/sh -c "node server.js"
└─ PID 7: node server.js
docker stop은 PID 1에 SIGTERM을 보낸다. sh는 그 시그널을 받고 무시한다. Node 앱은 신호를 받지 못하고, 10초 후 SIGKILL로 강제 종료된다. DB 커넥션이 끊기고, 진행 중이던 요청이 날아간다.
exec form으로 쓰면 이 문제가 사라진다.
CMD ["node", "server.js"] # exec form
PID 1: node server.js # 쉘 없이 직접 실행
Node.js는 기본 시그널 핸들러가 붙어 있어서 SIGTERM에 반응해 이벤트 루프를 빠져나온다. 깨끗한 종료가 가능하다.
tini나 dumb-init 같은 init 프로세스
복잡한 앱은 자식 프로세스를 여럿 띄우는 경우가 있다. PID 1이 좀비를 안 거두면 시간이 지나면서 좀비가 쌓인다. 이럴 때는 가벼운 init 프로세스를 PID 1로 두는 패턴이 쓰인다.
# Docker가 기본 제공하는 init
# docker run --init 옵션으로도 대체 가능
docker run --init myapp
--init 플래그는 tini를 PID 1로 넣고, 그 아래 애플리케이션을 돌린다. tini가 시그널을 받아서 자식에 forward하고, 좀비도 거둬준다. 복잡한 스크립트 앞단에 두면 안정성이 확 올라간다.
SIGTERM을 앱에서 받아 처리하기
Docker는 컨테이너를 멈출 때 이 순서로 동작한다.
- PID 1에 SIGTERM 보냄
- 기본 10초(
--stop-timeout으로 조정 가능) 대기 - 여전히 살아 있으면 SIGKILL로 강제 종료
이 “10초의 유예”가 핵심이다. 이 시간 안에 앱이 깨끗하게 정리해야 한다. Node.js에서 핸들러를 붙이는 예시다.
// Express 앱의 graceful shutdown
const server = app.listen(3000);
function shutdown(signal) {
console.log(`${signal} received, shutting down...`);
server.close(() => {
console.log('HTTP server closed');
// DB 커넥션, 큐 컨슈머 등 정리
process.exit(0);
});
// 강제 종료 대비 타임아웃
setTimeout(() => {
console.error('Forced exit');
process.exit(1);
}, 9000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.close()는 새 연결을 받지 않고, 진행 중인 요청을 다 마치면 콜백을 실행한다. 9초 타이머를 건 건 Docker의 10초 타임아웃에 쫓기기 전에 스스로 깔끔히 끝내기 위함이다.
Go나 Java 같은 다른 언어도 같은 패턴이다. 시그널 수신 → 신규 요청 차단 → 진행 중 요청 완료 → 리소스 정리 → 종료.
start, stop, restart, rm — 상태를 옮기는 명령들
한 번 만든 컨테이너를 조작할 때 쓰는 명령들이다.
# 멈추기 — SIGTERM → 10초 대기 → SIGKILL
docker stop web
docker stop --time=30 web # 30초까지 기다림
# 다시 띄우기
docker start web
# 멈췄다 다시 띄우기 (stop + start)
docker restart web
# 강제로 즉시 종료 (SIGKILL)
docker kill web
docker kill --signal=SIGUSR1 web # 임의 시그널도 가능
# 삭제 (stopped 상태여야 함)
docker rm web
# 실행 중인 컨테이너 강제 삭제
docker rm -f web
docker stop과 docker kill의 차이를 구분해두자. stop은 유예를 주고 죽인다. kill은 즉시 죽인다. 일상적인 재배포엔 stop을, 프로세스가 멈췄거나 응답 없을 때만 kill을 쓴다.
docker exec — 돌아가는 컨테이너 속으로
이미 실행 중인 컨테이너 안에 들어가 명령을 실행하고 싶을 때 쓴다. 디버깅에서 가장 자주 쓰는 명령이다.
# 셸 붙기
docker exec -it web bash
# 쉘이 없는 이미지라면
docker exec -it web sh
# 명령 한 번만 실행
docker exec web printenv NODE_ENV
# root로 들어가기 (이미지가 USER를 지정했을 때)
docker exec -u root -it web bash
컨테이너 안에서 네트워크 테스트, 로그 확인, 디스크 상태 점검을 주로 한다. 단, exec로 들어가 설치한 패키지나 변경한 파일은 컨테이너 재시작 시 날아가지 않지만, 재생성(삭제 후 새 run) 시에는 사라진다. 운영용 변경은 이미지/볼륨에 반영해야 한다.
logs와 inspect — 상태를 들여다본다
컨테이너가 왜 죽었는지, 지금 무슨 일이 벌어지는지 보려면.
# 로그 조회
docker logs web
docker logs -f web # tail -f 처럼 스트리밍
docker logs --tail 100 web # 마지막 100줄
docker logs --since 10m web # 최근 10분
docker logs -t web # 타임스탬프 포함
# 컨테이너 전체 메타데이터 (JSON)
docker inspect web
# 특정 필드만 꺼내기
docker inspect --format '{{.State.Status}}' web
docker inspect --format '{{.State.ExitCode}}' web
docker inspect --format '{{.RestartCount}}' web
docker inspect의 JSON은 너무 큼직해서 --format으로 필요한 필드만 꺼내는 게 현실적이다. State.Status, State.ExitCode, RestartCount는 운영에서 자주 본다.
컨테이너 자원 사용량을 실시간으로 보고 싶다면.
docker stats
# CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
top과 비슷한 느낌의 도구다.
재시작 정책 (restart policy)
컨테이너가 종료됐을 때 자동으로 다시 띄울지 결정하는 옵션이다. --restart 플래그로 지정한다.
| 정책 | 동작 |
|---|---|
no (기본값) | 자동 재시작 안 함 |
on-failure[:N] | 0이 아닌 종료 코드일 때만 재시작. N은 최대 재시도 횟수 |
always | 어떤 이유로든 재시작. Docker Daemon이 시작될 때도 자동 기동 |
unless-stopped | always와 비슷하지만, 사용자가 명시적으로 docker stop한 경우엔 재시작 안 함 |
docker run -d --restart unless-stopped --name web nginx:1.27
실무에서 가장 많이 쓰는 건 unless-stopped다. 비정상 종료엔 자동 복구하되, 내가 일부러 멈춘 건 내 의도대로 꺼져 있게 한다. 서버 재부팅 후에도 자동으로 올라온다.
on-failure:3을 쓰면 앱이 오류로 죽을 때 최대 3번만 재시작하고, 3번째에도 실패하면 포기한다. 크래시 루프가 무한히 돌아서 자원을 태우는 걸 막아준다.
Docker Compose나 Kubernetes를 쓴다면 재시작 정책은 오케스트레이터에 맡기는 게 보통이다. Docker Engine 수준의 재시작 정책은 주로 단일 노드 운영용.
exit code로 “왜 죽었는지” 추적하기
컨테이너가 종료되면 exit code가 남는다. 이 숫자가 원인 진단의 첫 단서다.
docker ps -a --filter "name=web" --format "{{.Names}}\t{{.Status}}"
# web Exited (137) 2 seconds ago
| Exit Code | 의미 |
|---|---|
| 0 | 정상 종료 |
| 1 | 일반적 오류 (앱에서 exit(1)을 호출했거나 예외) |
| 125 | docker run 자체가 실패 (이미지 없음 등) |
| 126 | 컨테이너 명령이 실행 불가 (권한 없음 등) |
| 127 | 컨테이너 명령을 찾을 수 없음 (PATH에 없음) |
| 137 | SIGKILL로 종료 (9 + 128). docker kill 또는 OOM |
| 139 | SIGSEGV 세그폴트 (11 + 128) |
| 143 | SIGTERM으로 종료 (15 + 128). docker stop의 정상 루트 |
137이 가장 자주 만나는 수수께끼다. 보통은 메모리 상한을 넘어 OOM Killer가 컨테이너를 죽였거나, 누가 docker kill을 때렸거나, Kubernetes가 livenessProbe 실패로 죽인 경우다. dmesg를 보거나 docker inspect --format '{{.State.OOMKilled}}' web 로 OOM 여부를 확인한다.
한눈에 보는 운영 플로우
실전에서 자주 만나는 플로우를 한 장에 모으면 이런 느낌이다.
flowchart TB
BUILD["docker build -t app:1.2.3 ."] --> PUSH["docker push registry/app:1.2.3"]
PUSH --> PULL["서버에서 docker pull"]
PULL --> STOP["docker stop old-app (graceful)"]
STOP --> RM["docker rm old-app"]
RM --> RUN["docker run -d --name app --restart unless-stopped ..."]
RUN --> HEALTH{"HEALTHCHECK OK?"}
HEALTH -->|Yes| DONE["배포 완료"]
HEALTH -->|No| LOGS["docker logs app<br/>원인 파악"]
LOGS --> ROLLBACK["롤백: 이전 태그 docker run"]
단일 서버에서는 이 플로우가 쉘 스크립트 한 장으로 끝난다. 서버가 여러 대로 늘어나면 Compose, Swarm, Kubernetes로 옮겨가게 된다. 그렇게 되어도 각 컨테이너의 생명주기는 여전히 이 규칙을 따른다.
생명주기에서 꼭 챙길 원칙 다섯
마지막으로 실전에서 잊지 말 원칙들.
- PID 1을 의식하라.
CMD를 exec form으로 쓰고, 필요하면--init이나 tini를 넣는다 - SIGTERM을 앱에서 잡아라. graceful shutdown 없이 무중단 배포는 불가능하다
- 재시작 정책은
unless-stopped가 안전한 기본값이다. 필요에 따라 on-failure로 조정 - exit code와 logs로 원인을 추적하라. 137이 나오면 OOM부터 의심
- Exited 컨테이너도 디스크를 먹는다. 주기적으로
docker container prune으로 정리
다음 편에서는 컨테이너가 죽어도 살아남아야 할 데이터 이야기를 한다. bind mount와 named volume의 차이, tmpfs의 역할, 볼륨 백업과 복원, 그리고 의외로 자주 터지는 UID/GID 권한 문제까지.

Loading comments...