fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code 메모리: CLAUDE.md와 .claude/rules를 규칙으로 쓰는 법
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
    • 스킬 문서를 신경망처럼 학습시킨다 — Microsoft SkillOpt 분석
  • ai 페이지로 이동
    • agent 페이지로 이동
    • [초안] AI 제품 백엔드 안정성 — 지연·비용·권한·관측·도구 실패·폴백/재시도/사람 에스컬레이션
    • [초안] LLM 평가 프레임워크: 골든셋, 회귀 테스트, LLM-as-a-judge, 사람 피드백 루프
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 디지털 채널 면접: 슬롯 도메인 경험을 커머스 도메인 설계 능력으로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS는 어떻게 안전한가 — TLS, 인증서, 그리고 termination
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] 커머스/F&B 면접 답변집 — 슬롯 도메인 경험을 주문·결제·쿠폰·매장 설계로 매핑하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/devops/Docker에서 좀비 프로세스가 쌓이는 이유…
devops

Docker에서 좀비 프로세스가 쌓이는 이유 — PID 1 문제와 tini

운영 중인 문서 파싱 서비스 인스턴스에 들어가서 ps를 쳤다가, soffice.bin 가 화면을 가득 채우는 걸 봤다. 세어보니 420개였다. 컨테이너가 뜬 지 일주일밖에 안 됐는데 좀비가 420마리. 처음엔 "좀비는 메모리도 거의 안 먹는다는데 그냥 둬도 되나?" 싶었다. 그런데 좀비는 PID 슬롯을 하나씩 점유한다. 계속 쌓이면 결국 PID가 고갈되고,...

2026.06.08·7 min read·8 views

운영 중인 문서 파싱 서비스 인스턴스에 들어가서 ps를 쳤다가, soffice.bin <defunct> 가 화면을 가득 채우는 걸 봤다. 세어보니 420개였다. 컨테이너가 뜬 지 일주일밖에 안 됐는데 좀비가 420마리.

처음엔 "좀비는 메모리도 거의 안 먹는다는데 그냥 둬도 되나?" 싶었다. 그런데 좀비는 PID 슬롯을 하나씩 점유한다. 계속 쌓이면 결국 PID가 고갈되고, 그러면 새 프로세스를 fork 하지 못해 서비스 전체가 멈춘다. 무시할 문제가 아니었다.

이 글은 내가 그 420개의 정체를 추적하면서 다시 정리한 리눅스 프로세스의 기초 — 좀비와 고아의 차이, PID 1이 왜 특별한지, 그리고 tini가 무슨 일을 하는지에 대한 기록이다.

좀비가 뭔지부터 — 종료했는데 안 사라지는 프로세스

리눅스에서 자식 프로세스가 exit() 로 종료해도 곧바로 사라지지 않는다. 커널은 그 프로세스의 최소 정보(PID, 종료 코드, CPU 사용 시간)를 프로세스 테이블에 남겨둔다. 부모가 자식의 종료 상태를 거둬갈(reap) 때까지 기다리는 것이다.

이 "종료했지만 아직 거둬지지 않은" 상태가 바로 좀비(zombie, Z 또는 <defunct>)다.

부모가 자식을 거두는 방법은 wait() / waitpid() 시스템 콜이다. 흐름은 이렇다:

  1. 자식이 종료한다.
  2. 커널이 부모에게 SIGCHLD 시그널을 보낸다 — "네 자식 하나가 죽었어."
  3. 부모가 wait() 를 호출해 종료 상태를 읽어간다.
  4. 그 순간 커널은 프로세스 테이블에서 자식의 엔트리를 지운다. 좀비 소멸.

문제는 부모가 wait() 를 안 부를 때다. 부모에 버그가 있거나, SIGCHLD 를 무시하도록 짜여 있으면 자식은 영원히 좀비로 남는다. 좀비는 자기 스스로 사라질 수 없다. 거두는 건 전적으로 부모(또는 부모를 대신할 누군가)의 책임이다.

c
// 부모가 SIGCHLD 핸들러에서 죽은 자식을 모두 거두는 전형적 패턴
// (개념 설명용)
void reap(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;  // 거둘 자식이 없을 때까지 반복
}

고아는 좀비와 다르다 — 부모가 먼저 죽은 경우

좀비랑 헷갈리기 쉬운 게 고아(orphan)다. 둘은 방향이 반대다.

  • 좀비: 자식이 먼저 죽었는데 부모가 안 거둬감 (자식은 이미 죽은 상태)
  • 고아: 부모가 먼저 죽었는데 자식은 아직 살아서 돌아감 (자식은 멀쩡히 실행 중)

부모가 죽으면 그 밑의 살아있는 자식들은 고아가 된다. 그런데 리눅스에서 고아는 떠돌게 두지 않는다. PID 1 프로세스(init)가 고아를 입양한다. 고아의 부모 PID가 1로 바뀐다.

여기서 두 개념이 만난다. 고아가 나중에 종료하면, 입양한 PID 1이 그 고아를 거둬야 한다. PID 1이 제대로 wait() 를 부르는 init이면 고아는 종료 즉시 거둬져 깨끗이 사라진다. 그런데 PID 1이 거두는 일을 안 하면? 고아가 죽은 자리에 좀비가 남고, 그게 계속 쌓인다.

내가 본 420개가 정확히 이 경우였다.

PID 1은 왜 특별한가

컨테이너든 일반 리눅스든, PID 1 프로세스에는 일반 프로세스에 없는 두 가지 의무가 따라붙는다.

첫째, 고아 reaping. 위에서 말한 대로 모든 고아는 PID 1이 입양하고, 그 고아들이 종료하면 PID 1이 거둬야 한다. init 계열 프로그램(systemd, tini 등)은 이걸 위해 주기적으로 wait() 를 돈다.

둘째, 시그널 처리. PID 1은 커널로부터 시그널에 대한 기본 핸들러를 받지 못한다. 일반 프로세스라면 SIGTERM 을 받으면 기본 동작으로 종료하지만, PID 1은 명시적인 핸들러를 등록하지 않는 한 SIGTERM 을 그냥 무시한다. docker stop 을 쳤을 때 컨테이너가 10초를 버티다 강제 종료(SIGKILL)되는 흔한 현상이 바로 이것이다 — PID 1이 된 앱이 SIGTERM 을 못 받아서 graceful하게 안 죽는 것이다. (이 시그널 쪽 이야기는 Graceful Shutdown 에서 더 다뤘다.)

문제는, 우리가 컨테이너에 띄우는 앱 대부분은 init이 아니라는 점이다. uvicorn, gunicorn, node, java… 이들은 웹 서버나 애플리케이션 런타임이지, "버려진 자식 뒷정리"를 하라고 만들어진 프로그램이 아니다. 그런데 docker run으로 앱을 직접 띄우면 그 앱이 PID 1이 된다. init이 할 일을 떠안지만 할 줄은 모르는 상태가 된다.

PID 1은 직책이지 능력이 아니다. uvicorn을 PID 1 자리에 앉혀도, uvicorn이 고아를 거두는 법을 배우는 건 아니다.

내 케이스 — LibreOffice가 좀비를 만든 경로

420개의 정체를 추적한 과정은 단순했다.

bash
# 좀비만 세기 — STAT가 Z로 시작하는 것
ps -eo stat,ppid,pid,comm | awk '$1 ~ /^Z/' | wc -l
# → 420
 
# 부모 PID별로 묶어보기
ps -eo stat,ppid,pid,comm | awk '$1 ~ /^Z/ {print $2}' | sort | uniq -c | sort -rn
# → 420개 전부 같은 부모 PID 하나
 
# 그 부모가 누구인가
ps -p <부모PID> -o comm,args
# → uvicorn (컨테이너의 PID 1)
 
# 좀비들의 정체
ps -eo stat,ppid,pid,comm | awk '$1 ~ /^Z/' | head
# → 전부 soffice.bin <defunct>

좀비 420개가 전부 soffice.bin 이고, 부모는 PID 1인 uvicorn이었다. 그림이 그려졌다.

이 서비스는 .doc, .ppt 같은 구버전 오피스 파일을 받으면 LibreOffice를 headless 모드로 띄워 PDF로 변환한다. 코드는 이렇게 생겼다.

python
# 개념 설명용 의사코드
proc = subprocess.Popen(
    ["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", out, src]
)
proc.communicate(timeout=180)  # libreoffice 프로세스가 끝나길 기다림

communicate() 가 wait 를 포함하니까 거두는 것 같은데, 왜 좀비가 쌓일까. 함정은 libreoffice 명령이 실제 작업 프로세스가 아니라 런처(wrapper)라는 점이었다.

libreoffice 를 실행하면 그건 내부적으로 soffice.bin 을 띄우고 변환 작업을 넘긴 뒤 런처 자신은 먼저 종료한다. 그래서 일어나는 일:

  1. 워커가 libreoffice 런처를 Popen 으로 띄운다.
  2. 런처가 soffice.bin 을 spawn 한다.
  3. 런처가 먼저 끝난다 → communicate() 는 런처만 거둔다.
  4. soffice.bin 은 부모(런처)가 죽었으니 고아가 된다 → PID 1(uvicorn)이 입양.
  5. soffice.bin 이 변환을 마치고 종료한다 → 입양한 PID 1이 거둬야 하는데…
  6. uvicorn은 reaper가 아니다 → soffice.bin 이 좀비로 남는다.

.doc/.ppt 변환 요청이 한 번 들어올 때마다 좀비가 하나씩 쌓였다. 일주일에 420개. 코드의 communicate() 나 finally 정리 블록은 런처를 다룰 뿐이고, 고아가 된 손주 soffice.bin 은 어느 경로로도 거둬지지 않았다.

핵심은 코드의 버그라기보다 구조의 문제였다. 손주 프로세스가 고아가 되는 건 LibreOffice 런처의 동작 방식이라 코드로 일일이 막기 까다롭다. 진짜 빈자리는 PID 1에 reaper가 없다는 것이었다.

tini — PID 1 자리에 앉히는 최소 init

이 문제의 정석 해법이 tini다. 수십 KB짜리 단일 바이너리로, 오직 컨테이너의 PID 1 노릇을 하려고 만들어진 미니 init이다. 하는 일은 딱 두 가지다.

고아 reaping. tini는 PID 1로 앉아서, 입양한 고아들이 종료하면 주기적으로 wait 를 돌며 거둬간다. soffice.bin이 고아가 되어 tini에게 입양되면, 종료하는 즉시 tini가 거둬서 좀비가 안 쌓인다.

시그널 forwarding. tini는 SIGTERM, SIGINT 같은 시그널을 받아 자식(실제 앱)에게 그대로 전달한다. 덕분에 docker stop 이나 Ctrl+C 가 앱까지 도달해서 graceful shutdown이 정상 동작한다. PID 1이 시그널을 무시하던 문제가 풀린다.

비유하자면, uvicorn은 "웹 서버 일"만 하는 전문가다. 집안에 버려진 아이들 뒷정리(reaping)와 현관 초인종 응대(시그널)는 관리인의 일인데, uvicorn은 관리인이 아니다. tini는 그 관리인 역할만 가볍게 맡는다. uvicorn은 자기 일에 집중하고, tini가 PID 1의 잡일을 처리한다.

적용 — Dockerfile 한 곳 vs --init 매번

tini를 넣는 방법은 크게 둘이다.

docker run --init. Docker가 내장 init(tini와 같은 메커니즘)을 PID 1에 자동으로 끼워준다. 가장 간단하지만, 컨테이너를 띄우는 모든 경로에 매번 --init 을 붙여야 한다. 수동 docker run, 배포 스크립트, 배포 도구 콘솔 설정… 한 곳이라도 빠지면 그 컨테이너는 좀비가 다시 쌓인다. 앞으로 새 실행 경로가 생길 때마다 또 챙겨야 하는 영구적인 누락 위험이 있다.

Dockerfile ENTRYPOINT를 tini로 감싸기. 이미지 자체에 init을 박는 방법이다.

dockerfile
# ubuntu 계열이면 apt 한 줄로 설치
RUN apt-get install -y tini
 
# 기존
# ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# 변경 — tini를 PID 1로
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]

이러면 tini가 PID 1이 되고, 그 아래에서 엔트리포인트 스크립트와 앱이 돈다. 누가 어떤 방법으로 띄우든(수동 run이든 배포 도구든) 이미지에 init이 들어있으니 항상 적용된다. 새 실행 경로가 생겨도 자동으로 커버된다.

직관적으로는 "이미지 안 건드리는 --init 이 간단하지 않나" 싶지만, 실제로는 반대다. --init 은 손댈 곳이 여러 군데인데다 미래 누락 위험이 영구적이고, Dockerfile tini는 한 곳을 고치면 끝이다. 나는 단일 지점이라는 이유로 Dockerfile 방식을 택했다.

한 가지 더 — 엔트리포인트 스크립트가 내부에서 분기를 타거나(예: GPU MPS 모드 on/off) 앱을 백그라운드로 띄우는 경우가 있다. 이때 tini를 스크립트 안의 exec 줄에만 넣으면 한쪽 분기만 커버될 수 있다. ENTRYPOINT 최상위를 tini로 감싸면 스크립트 내부 동작과 무관하게 tini가 항상 최상위 PID 1이라, 모든 분기의 고아를 거둔다. 그래서 스크립트를 건드리는 것보다 ENTRYPOINT를 감싸는 쪽이 안전했다.

지금 보면

좀비를 처음 봤을 때 "메모리 안 먹으니 괜찮겠지"라고 넘겼다면, PID 고갈로 서비스가 멈춘 뒤에야 원인을 찾았을 거다. 좀비는 양성이지만 누적은 양성이 아니다. 증상이 가벼워 보여도 누적되는 종류의 문제는, 발견 시점에 한가하더라도 끝까지 따라가 보는 게 맞다.

그리고 이번 일로 다시 새긴 건, 컨테이너에서 PID 1이 누구인지는 항상 의식해야 할 질문이라는 점이다. docker run app 처럼 무심코 앱을 직접 띄우면 그 앱이 init의 의무를 떠안는다. 평소엔 자식 프로세스를 안 만드니 티가 안 나다가, LibreOffice처럼 손주 프로세스를 고아로 흘리는 코드가 끼는 순간 좀비가 새어나온다. 베이스 이미지에 init을 깔아두는 건 "지금 당장 필요해서"가 아니라 "언제 필요해질지 모르니까" 미리 해두는 보험에 가깝다.

마지막으로, 이건 우리 코드의 LibreOffice 호출 방식이 만든 특수한 사례지만 — 자식·손주 프로세스를 띄우는 컨테이너라면 어디서든 같은 함정에 빠질 수 있다. PID namespace 안에서 PID 1이 갖는 역할은 리눅스에서 프로세스를 격리시키는 방법 과도 이어진다.

참고

  • tini — A tiny but valid init for containers (GitHub)
  • Zombie process (Wikipedia)
  • Docker and the PID 1 zombie reaping problem (Hacker News 토론)
  • Why Your Docker Containers Refuse to Die: The PID 1 Problem (DEV)
  • Container Init Process (DevOps Directive)
on this page
  • 01좀비가 뭔지부터 — 종료했는데 안 사라지는 프로세스
  • 02고아는 좀비와 다르다 — 부모가 먼저 죽은 경우
  • 03PID 1은 왜 특별한가
  • 04내 케이스 — LibreOffice가 좀비를 만든 경로
  • 05tini — PID 1 자리에 앉히는 최소 init
  • 06적용 — Dockerfile 한 곳 vs `--init` 매번
  • 07지금 보면
  • 08참고

이런 글도

  • 쿠버네티스 핵심 객체 4종 — Pod, Service, Ingress, Namespace의 관계
    쿠버네티스에서 외부 노출 작업을 하다가, Pod니 Service니 Ingress니 하는 단어들이 머릿속에서 자꾸 섞였다. 각각 뉘앙스는 알겠는데 "그래서 이것들이 서로 어떤 관계냐"가 안 잡혔다. 그래서 이 네 가지를 한 번에 정리하기로 했다. 이 네 개의 관계만 잡으면 쿠버네티스의 절반은 이해한 거라고 봐도 된다. 한 문장으로 시작하면 빠르다 — Pod는...
    🚀 devops
    devops
    2026.06.09
  • ingress-nginx 운영에서 부딪힌 디테일들 — webhook, whitelist, affinity, 리소스 사양
    ingress controller를 하나 추가하는 작업은 "차트 만들고 배포하면 끝"일 줄 알았다. 그런데 실제로는 그 과정에서 처음 보는 개념들에 계속 걸렸다. annotation으로 설정을 관리하는 방식, admission webhook이 만드는 self-lock 위험, whitelist, Pod 분산 배치, 그리고 리소스 사양까지. 하나하나는 작지만,...
    🚀 devops
    devops
    2026.06.09
  • Helm과 ArgoCD로 GitOps 하기 — chart, Application, 그리고 새 컴포넌트 추가 흐름
    쿠버네티스에 새 컴포넌트(ingress controller 하나)를 추가하는 작업을 맡고 나서야, 그동안 "어딘가에서 알아서 배포되던" 그 과정의 구조를 처음 들여다봤다. Helm 차트가 뭐고, ArgoCD가 뭘 하고, Application이라는 게 왜 또 따로 있는지. 막상 정리해보니 큰 그림은 단순했다. 그 구조와, 실제로 새 컴포넌트를 추가하려면 어디...
    🚀 devops
    devops
    2026.06.09
  • 외부 트래픽은 어떻게 Pod까지 닿는가 — LoadBalancer, Ingress Controller, 내부와 외부 분리
    회사에서 "API Gateway를 걷어내고, 쿠버네티스 앞에 LoadBalancer를 직접 붙여서 외부로 노출하자"는 작업을 맡게 됐다. 그런데 막상 들여다보니 나는 Ingress가 뭔지도 제대로 몰랐다. "외부 요청이 들어와서 서버가 응답한다" 정도로만 알고 있었지, 그 사이에 LoadBalancer니 Ingress Controller니 하는 것들이 몇...
    🚀 devops
    devops
    2026.06.09

댓글 (0)