컨테이너 학습 노트 목차

실행과 운영 — PID 1·볼륨·네트워크·로그

이미지를 만들었으면 이제 띄우는 쪽이다. 이 편에서 다루는 네 가지가 컨테이너 사고의 대부분을 차지한다.


1. PID 1 문제 — 시그널이 안 가는 이유

컨테이너 안에서 우리 프로세스는 PID 1 이 된다. 리눅스에서 PID 1 은 특별하다.

  • 일반 프로세스 — 시그널 핸들러를 등록하지 않으면 기본 동작이 적용된다

    • SIGTERM → 종료
  • PID 1 — 기본 동작이 적용되지 않는다

    • 핸들러를 등록하지 않으면 SIGTERM 을 '무시' 한다
    • docker stop 이 안 먹고 10초 뒤 SIGKILL 로 강제 종료된다
$ docker stop web
# 10초를 기다렸다가 강제 종료된다 — 로그에 종료 처리가 안 남는다

셋 중 하나로 푼다

# ① exec form 을 쓴다 — 애플리케이션이 직접 PID 1 이 된다
ENTRYPOINT ["java", "-jar", "/app.jar"]

shell form(CMD java -jar app.jar)이면 PID 1 이 /bin/sh 가 된다 sh 는 SIGTERM 을 자식에게 전달하지 않는다 → 애플리케이션이 모르고 죽는다

# ② init 프로세스를 넣는다
ENTRYPOINT ["/usr/bin/tini", "--", "java", "-jar", "/app.jar"]
# ③ 런타임 옵션으로 init 을 붙인다
docker run --init myapp

좀비 프로세스도 PID 1 의 일이다

자식 프로세스가 끝나면 부모가 wait 으로 거둬야 한다 거두지 않으면 좀비로 남는다

원래는 PID 1(init)이 고아가 된 프로세스를 입양해 거둬 준다 그런데 컨테이너의 PID 1 은 우리 애플리케이션이고, 대개 남의 자식을 거두는 코드가 없다

  • 좀비가 쌓인다 → PID 한도(cgroup pids.max)에 도달 → fork 실패
  • 증상: "resource temporarily unavailable" · 스레드 생성 실패

자식 프로세스를 만드는 컨테이너(셸 스크립트 · 감시 프로세스)는 tini 같은 최소 init 을 반드시 넣는다

2. 종료 절차와 유예 시간

docker stop

  • SIGTERM 을 PID 1 에게 보낸다
  • ② 유예 시간을 기다린다 (기본 10초, -t 로 조정)
  • ③ 여전히 살아 있으면 SIGKILL

쿠버네티스도 같다 — terminationGracePeriodSeconds (기본 30초)

유예 시간이 처리 시간보다 짧으면 결국 잘린다

  • 가장 긴 요청의 처리 시간 + 여유로 잡는다

STOPSIGNAL 로 시그널 자체를 바꿀 수도 있다 nginx 는 SIGTERM 이 '즉시 종료' 이므로 SIGQUIT 로 바꾼다 STOPSIGNAL SIGQUIT

3. 종료 코드가 원인을 말해 준다

$ docker inspect web --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
코드흔한 원인
0정상 종료
1애플리케이션 오류예외로 죽었다. 로그를 본다
125도커 데몬 오류docker run 옵션이 잘못됐다
126실행 권한 없음스크립트에 실행 비트가 없다
127명령을 못 찾음경로 오류. 멀티스테이지에서 바이너리 누락
137SIGKILL (128+9)OOMKilled 또는 유예 시간 초과 강제 종료
139SIGSEGV (128+11)세그폴트
143SIGTERM (128+15)정상 정지 요청으로 종료

128 + 시그널 번호 규칙이다 137 을 보면 먼저 OOMKilled 인지 확인한다

  • true — → 메모리 제한을 넘겼다 false → 유예 시간 안에 안 죽어서 강제 종료됐다 (PID 1 문제 의심)

127 은 알파인에서 특히 자주 난다

멀티스테이지로 만든 정적 바이너리가 아닌데 alpine 에 올리면

  • alpine 은 musl libc 를 쓴다. glibc 로 빌드한 바이너리가 안 돈다
  • "not found" 인데 파일은 분명히 있다 (동적 링커를 못 찾는 것)

확인: ldd ./app 로 필요한 라이브러리를 본다 대응: 정적 빌드 · glibc 기반 슬림 이미지 · alpine 에서 빌드

4. 데이터는 어디에 두나

  • 쓰기 레이어컨테이너를 지우면 사라진다. union fs 를 거쳐 느리다
  • 볼륨 — 도커가 관리하는 저장소. 권장 방식
  • 바인드 마운트 — 호스트 경로를 그대로 연결. 개발에 편하고 운영엔 위험
  • tmpfs — 메모리. 디스크에 안 남는다 (비밀·임시 파일)
docker volume create appdata
docker run -v appdata:/var/lib/app myapp          # 볼륨
docker run -v $(pwd)/src:/src:ro myapp            # 바인드 (읽기 전용)
docker run --tmpfs /tmp:rw,size=64m myapp         # tmpfs

마운트가 기존 내용을 가린다

이미지 안에 /app/config 에 파일이 있는데 그 경로에 빈 볼륨을 마운트하면?

  • 마운트가 위에 덮인다 → 이미지의 파일이 안 보인다
  • (볼륨이 '처음 비어 있을 때' 는 이미지 내용을 복사해 넣는 동작이 있지만
    • 바인드 마운트는 그렇지 않다 — 그냥 가린다)

"설정 파일이 사라졌다" 의 전형이다

권한 문제

바인드 마운트한 호스트 디렉터리의 소유자와 컨테이너 안 프로세스의 UID 가 다르면 쓰기가 안 된다

  • Permission denied

대응

  • 컨테이너 UID 를 호스트 디렉터리 소유자와 맞춘다 (--user 1000:1000)
  • 또는 호스트 디렉터리 권한을 조정한다
  • 볼륨을 쓰면 도커가 초기 권한을 맞춰 주므로 덜 겪는다

5. 네트워크

  • bridge — 기본. 컨테이너마다 가상 IP. -p 로 호스트 포트에 연결
  • host — 호스트 네트워크를 그대로 쓴다. 포트 매핑이 없다. 격리도 없다
  • none — 네트워크 없음
  • 사용자 정의 브리지컨테이너 이름으로 서로를 찾을 수 있다 (내장 DNS)
docker network create appnet
docker run -d --name db  --network appnet postgres
docker run -d --name api --network appnet myapi   # DB 주소는 'db:5432'

기본 bridge 에서는 컨테이너 이름으로 서로를 못 찾는다 사용자 정의 네트워크를 만들면 내장 DNS 가 이름을 풀어 준다

  • compose 가 자동으로 하는 일이 이것이다

localhost 가 다르다

컨테이너 안의 localhost 는 '그 컨테이너 자신' 이다 호스트나 다른 컨테이너가 아니다

  • 다른 컨테이너 → 컨테이너 이름 (같은 사용자 정의 네트워크)
  • 호스트 → host.docker.internal (Docker Desktop)
    • 리눅스에서는 --add-host=host.docker.internal:host-gateway

"DB 에 연결이 안 된다" 의 절반이 localhost 오해다

포트 매핑

docker run -p 8080:80 nginx        # 호스트 8080 → 컨테이너 80
docker run -p 127.0.0.1:8080:80 nginx   # 로컬만 (외부 노출 방지)

-p 8080:80 은 모든 인터페이스(0.0.0.0)에 연다 개발 머신에서 무심코 열면 같은 네트워크의 누구나 접근할 수 있다

  • 로컬 전용이면 IP 를 명시한다

  • 그리고 도커의 포트 게시는 iptables 를 직접 만진다

  • 호스트 방화벽(ufw) 규칙보다 앞서 적용돼 '막았는데 열려 있는' 일이 생긴다

6. 로그

docker logs -f --tail 100 web
  • 컨테이너의 stdout/stderr 를 도커가 수집한다
  • 애플리케이션은 파일이 아니라 표준 출력으로 로그를 낸다
    • (파일에 쓰면 컨테이너가 지워질 때 함께 사라진다)

로그가 디스크를 채운다

  • 기본 드라이버 json-file 은 제한이 없다
  • 장애로 로그가 폭주하면 호스트 디스크를 통째로 채운다
  • 도커 데몬 자체가 멎는다
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

daemon.json 에 이 네 줄이 없는 호스트를 아직도 자주 본다 컨테이너 단위로도 지정할 수 있다 (--log-opt max-size=10m)

운영에서는 대개 수집기로 보낸다

  • fluentd · awslogs · gelf · journald 드라이버
  • 또는 노드 에이전트가 파일을 읽어 간다

주의 — 로그 드라이버에 따라 docker logs 가 동작하지 않는다

7. 리소스 제한은 기본값이다

docker run -d \
  --memory=512m --memory-swap=512m \
  --cpus=1.5 \
  --pids-limit=200 \
  --restart=on-failure:3 \
  myapp

--memory-swap 을 --memory 와 같게 두면 스왑을 안 쓴다

  • 안 그러면 메모리를 넘겨도 스왑으로 버티며 극도로 느려진다
  • (죽는 것보다 나쁜 상태가 될 수 있다)

--pids-limit fork 폭탄·좀비 누적 방어

--restart
  no              기본
  on-failure:N    0 이 아닌 종료 코드일 때만, N 번까지
  always          항상 (데몬 재시작 시에도)
  unless-stopped  수동으로 멈춘 것은 다시 안 띄운다

제한이 없는 컨테이너 하나가 호스트 전체를 잠식한다. 운영에서 limit 은 선택이 아니라 기본이고, 값을 모르겠으면 넉넉하게라도 반드시 건다.

8. 안으로 들어가 보기

docker exec -it web sh                 # 셸이 있는 이미지
docker exec -it web ps -ef
docker inspect web | less              # 전체 설정
docker stats                           # 실시간 자원 사용량
docker top web                         # 안에서 도는 프로세스
docker diff web                        # 이미지 대비 바뀐 파일

"운영 이미지에 셸이 없어야 안전한데 디버깅이 어렵다" 는 트레이드오프가 여기서 생긴다 (05편)


한눈에 정리

  • PID 1 — 핸들러가 없으면 SIGTERM 을 무시한다 → exec form · tini · --init
  • 좀비 — 자식을 만드는 컨테이너는 init 이 필요하다 → pids 고갈
  • 종료 코드 — 137 SIGKILL(OOM 또는 강제) · 143 SIGTERM · 127 명령 없음(musl 주의)
  • 데이터볼륨 권장. 마운트는 기존 내용을 가린다. UID 불일치로 권한 오류
  • 네트워크 — 사용자 정의 브리지 + 컨테이너 이름. localhost 는 자기 자신이다
  • 포트 — -p 는 0.0.0.0 에 연다. iptables 를 직접 만져 방화벽보다 앞선다
  • 로그 — stdout 으로. json-file 은 기본 무제한 → max-size/max-file 필수
  • 제한 — memory-swap 을 memory 와 같게. pids-limit. restart 정책
  • 디버깅 — exec · inspect · stats · top · diff. 셸 없는 이미지는 임시 컨테이너

출처 — Docker Docs: "Container runtime" · "Manage data in Docker" · "Networking overview" · "Configure logging drivers" · "Runtime options with Memory, CPUs, and GPUs" · Linux man pages signal(7) · pid_namespaces(7)

Dockerfile — 명령어와 그 함정이미지 크기와 보안 — 같은 문제의 두 얼굴