실행과 운영 — 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 | 명령을 못 찾음 | 경로 오류. 멀티스테이지에서 바이너리 누락 |
| 137 | SIGKILL (128+9) | OOMKilled 또는 유예 시간 초과 강제 종료 |
| 139 | SIGSEGV (128+11) | 세그폴트 |
| 143 | SIGTERM (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 # 이미지 대비 바뀐 파일
- 셸이 없는 이미지(distroless · scratch)는 exec 로 못 들어간다
- 디버그용 사이드카를 붙이거나
- docker debug · kubectl debug 같은 임시 컨테이너를 쓴다
"운영 이미지에 셸이 없어야 안전한데 디버깅이 어렵다" 는 트레이드오프가 여기서 생긴다 (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)