Skip to content
ioob.dev
Go back

Docker 입문 4편 — 컨테이너 생명주기

· 9분 읽기
Docker 시리즈 (4/13)
  1. Docker 입문 1편 — Docker란
  2. Docker 입문 2편 — 이미지와 레이어
  3. Docker 입문 3편 — Dockerfile 작성법
  4. Docker 입문 4편 — 컨테이너 생명주기
  5. Docker 입문 5편 — 볼륨과 데이터 영속성
  6. Docker 입문 6편 — 네트워크
  7. Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
  8. Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트
  9. Docker 9편 — 레지스트리, 이미지는 어디에 두는가
  10. Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
  11. Docker 11편 — BuildKit과 고급 빌드
  12. Docker 12편 — 프로덕션 모범 사례
  13. Docker 13편 — 트러블슈팅과 대안
Table of contents

Table of contents

컨테이너는 프로세스다

이미지는 정지된 사진이고, 컨테이너는 움직이는 영상이다. 영상은 시작되고, 일시정지되고, 끝난다. 이 생명주기를 모르면 운영 중에 이상한 일을 많이 만난다. “컨테이너를 재시작했는데 -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 — 가장 많이 치는 명령

컨테이너를 띄우는 기본 명령이다. 옵션이 많은데, 실무에서 반복해서 쓰는 것들만 정리하면 이렇다.

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

이 한 줄에 중요한 옵션이 다 들어 있다.

짧게 테스트할 때는 이런 식으로도 자주 쓴다.

# 일회성 대화형 셀, 종료되면 바로 삭제
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은 특별한 존재다.

  1. 고아 프로세스를 입양해야 한다 (wait() 호출로 좀비를 거둬야 함)
  2. 시그널이 자동으로 forward되지 않는다. 명시적으로 받고 자식에 전달해야 함
  3. 기본 시그널 핸들러 대부분이 비활성이다. 예를 들어 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는 컨테이너를 멈출 때 이 순서로 동작한다.

  1. PID 1에 SIGTERM 보냄
  2. 기본 10초(--stop-timeout으로 조정 가능) 대기
  3. 여전히 살아 있으면 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 stopdocker 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-stoppedalways와 비슷하지만, 사용자가 명시적으로 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)을 호출했거나 예외)
125docker run 자체가 실패 (이미지 없음 등)
126컨테이너 명령이 실행 불가 (권한 없음 등)
127컨테이너 명령을 찾을 수 없음 (PATH에 없음)
137SIGKILL로 종료 (9 + 128). docker kill 또는 OOM
139SIGSEGV 세그폴트 (11 + 128)
143SIGTERM으로 종료 (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로 옮겨가게 된다. 그렇게 되어도 각 컨테이너의 생명주기는 여전히 이 규칙을 따른다.

생명주기에서 꼭 챙길 원칙 다섯

마지막으로 실전에서 잊지 말 원칙들.

  1. PID 1을 의식하라. CMD를 exec form으로 쓰고, 필요하면 --init이나 tini를 넣는다
  2. SIGTERM을 앱에서 잡아라. graceful shutdown 없이 무중단 배포는 불가능하다
  3. 재시작 정책은 unless-stopped가 안전한 기본값이다. 필요에 따라 on-failure로 조정
  4. exit code와 logs로 원인을 추적하라. 137이 나오면 OOM부터 의심
  5. Exited 컨테이너도 디스크를 먹는다. 주기적으로 docker container prune으로 정리

다음 편에서는 컨테이너가 죽어도 살아남아야 할 데이터 이야기를 한다. bind mount와 named volume의 차이, tmpfs의 역할, 볼륨 백업과 복원, 그리고 의외로 자주 터지는 UID/GID 권한 문제까지.

5편: 볼륨과 데이터 영속성


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 입문 3편 — Dockerfile 작성법
Next Post
Docker 입문 5편 — 볼륨과 데이터 영속성