개요
기본편이 “컨테이너를 어떻게 만들고 실행하는가"였다면, 이 글은 “프로덕션에서 어떻게 안전하고 안정적으로 운영하는가"에 초점을 둡니다.
각 주제를 정의와 기본 동작, 관련 옵션 순으로 정리합니다.
1. 컨테이너 보안
정리 보기
컨테이너 실행 사용자(USER)
컨테이너 프로세스는 기본적으로 root(UID 0)로 실행됩니다. USER 명령은 그 뒤에 오는 명령과 컨테이너 실행 시점의 사용자를 지정합니다.
FROM node:20-alpine
# 앱 전용 유저 생성
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev
# 이후 명령과 컨테이너 실행 사용자를 app으로 지정
USER app
CMD ["node", "server.js"]- root로 실행된 컨테이너 프로세스는 마운트된 볼륨과 네트워크에 root 권한으로 접근합니다
- K8s에서는
securityContext.runAsNonRoot: true로 강제할 수 있음
빌드 시 시크릿 전달 방식
이미지 레이어는 삭제해도 히스토리에 남습니다.
# COPY/ENV/RUN 인자로 넣은 값은 레이어에 남아 docker history에 노출됨
COPY .env .
ENV API_KEY=abcd1234
RUN some-command --token=secret# BuildKit secret mount: 이미지 레이어에 남지 않음
# docker build --secret id=npmrc,src=$HOME/.npmrc .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm citarget을 생략하면 시크릿은/run/secrets/<id>에 파일로 마운트됩니다. 파일 모드 기본값은 0400, uid/gid 기본값은 0입니다required기본값은 false라서, 시크릿이 없어도 빌드가 실패하지 않습니다. 없으면 실패시키려면required=true를 줘야 합니다- 시크릿 내용은 빌드 캐시에 포함되지 않습니다. 값만 바꿔도 캐시는 무효화되지 않습니다
- BuildKit이 Docker Desktop/Docker Engine의 기본 빌더이므로
DOCKER_BUILDKIT=1을 따로 붙이지 않아도 됩니다 docker history는 이미지가 어떤 명령으로 만들어졌는지 보여줍니다
취약점 스캔과 digest 참조
# 취약점 스캔
trivy image myapp:1.0
# 이미지가 어떤 레이어로 만들어졌는지 (시크릿이 새는지) 확인
docker history myapp:1.0 --no-trunc# digest로 이미지를 참조하는 형식
FROM node:20-alpine@sha256:<64자리 hex digest>FROM에서 태그와 digest는 둘 다 생략 가능하고, 생략하면latest태그로 간주됩니다. 태그는 언제든 다른 이미지를 가리킬 수 있으니 digest로 고정하면 같은 이미지가 보장됩니다- digest는
sha256:뒤에 64자리 16진수가 오는 형식입니다. 실제 값은docker pull/docker push출력이나docker buildx imagetools inspect <image>에서 확인해서 붙여야 합니다
추가 하드닝 옵션
# --read-only: 컨테이너 루트 파일시스템을 읽기 전용으로 마운트
# --cap-drop=ALL: 기본으로 주어지는 리눅스 capability 전부 제거
# --security-opt=no-new-privileges: 실행 중 추가 권한 획득 차단
docker run \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges \
myapp컨테이너 격리 3축
컨테이너가 VM 없이 격리되는 원리는 리눅스 커널의 세 가지 기능입니다:
| 기능 | 역할 | 예시 |
|---|---|---|
| Namespace | 뭘 볼 수 있는가 (자원 격리) | PID, 네트워크, 파일시스템, 유저를 컨테이너마다 분리 |
| Cgroup | 얼마나 쓸 수 있는가 (자원 제한) | CPU, 메모리 상한 설정 (--memory, --cpus) |
| Capabilities | 뭘 할 수 있는가 (권한 제어) | 포트 바인딩, 커널 모듈 로드, 네트워크 설정 등 |
컨테이너는 기본적으로 “unprivileged” 상태지만, 기본 capability 목록(CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, NET_RAW, SETUID/SETGID, SYS_CHROOT 등)은 유지된 채로 시작합니다. --cap-drop=ALL로 전부 제거하고 필요한 것만 남기면 컨테이너 프로세스가 수행할 수 있는 특권 작업의 범위가 줄어듭니다.
# 전부 제거하고 필요한 것만 다시 추가
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
# NET_BIND_SERVICE = 1024 미만의 특권 포트에 소켓을 바인딩하는 권한--cap-add/--cap-drop은 CAP_ 접두사를 붙여도 같게 동작하고, seccomp 기본 프로파일이 선택된 capability에 맞춰 조정되므로 따로 손댈 필요는 없다고 문서에 적혀 있습니다.
2. PID 1과 Graceful Shutdown
정리 보기
컨테이너 안에서 첫 프로세스는 PID 1로 실행됩니다. PID 1은 일반 프로세스와 다르게 동작해서 두 가지 문제가 생깁니다.
문제 1: 신호(Signal)가 앱에 안 전달됨 — shell form vs exec form
# shell form: /bin/sh -c "node server.js"로 실행됨
# PID 1은 sh, node는 그 자식 프로세스
CMD node server.js
# exec form: node가 PID 1로 직접 실행되어 SIGTERM을 수신
CMD ["node", "server.js"]- shell form에서는 SIGTERM이 sh에 전달되고, 타임아웃 뒤 SIGKILL로 종료됩니다(종료 코드 137 = 128 + 9)
docker stop의 신호 순서와 기본값: 먼저 컨테이너의 StopSignal(설정이 없으면 기본값 SIGTERM)을 보내고, 타임아웃이 지나면 SIGKILL로 종료합니다. 타임아웃 기본값은 데몬이 정하며 Linux 컨테이너 10초, Windows 컨테이너 30초입니다.docker run --stop-timeout이나docker stop -t로 바꿀 수 있고,-t -1이면 무한히 기다립니다- 신호 자체는 Dockerfile
STOPSIGNAL또는docker run --stop-signal로 바꿀 수 있습니다
문제 2: 좀비 프로세스 — init 필요
PID 1은 종료된 자식 프로세스를 회수해야 합니다. 애플리케이션이 이 처리를 하지 않으면 좀비 프로세스가 남을 수 있습니다.
# init 프로세스를 PID 1로 넣기 (좀비 수거 + 신호 전달 대행)
docker run --init myapp--init이 쓰는 기본 init은 Docker 데몬의 PATH에서 처음 찾은 docker-init 실행 파일이고, 기본 설치본에 포함된 이 docker-init은 tini 기반이라고 문서에 명시돼 있습니다. PID 1이 특별한 이유도 문서에 있습니다. 리눅스는 PID 1로 실행되는 프로세스에서 기본 동작(default action)만 걸린 신호를 무시하기 때문에, 앱이 직접 핸들러를 등록하지 않으면 SIGTERM으로 종료되지 않습니다.
# 또는 이미지에 tini 내장
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]앱 레벨 graceful shutdown
// SIGTERM 받으면 새 요청 안 받고, 진행 중인 요청 마무리 후 종료
process.on("SIGTERM", async () => {
server.close(() => process.exit(0));
// DB 커넥션 정리 등
});3. 리소스 제한과 cgroup
정리 보기
컨테이너는 기본적으로 호스트 자원 사용에 제한이 없고, CPU 사이클 접근도 unlimited입니다. 리눅스 호스트에서 커널이 메모리 부족을 감지하면 OOME를 발생시키고 프로세스를 종료하기 시작하는데, 이때 Docker 데몬을 포함한 어떤 프로세스든 대상이 될 수 있어 잘못된 프로세스가 종료되면 시스템 전체가 영향을 받을 수 있습니다(noisy neighbor).
메모리/CPU 제한 플래그
# --memory: 메모리 상한 (설정 시 최소 허용값 6m)
# --memory-swap: 메모리 + swap 총합 상한, --memory와 같은 값을 주면 swap 사용 불가
# --cpus=1.5: --cpu-period=100000 --cpu-quota=150000 과 동일
docker run \
--memory=512m \
--memory-swap=512m \
--cpus=1.5 \
myapp--memory-swap은--memory가 함께 설정돼 있을 때만 의미가 있는 수정자(modifier) 플래그입니다.--memory-swap을 생략하면 컨테이너는--memory와 같은 크기의 swap을 추가로 쓸 수 있고(호스트에 swap이 있는 경우),-1이면 호스트가 가진 만큼 무제한으로 씁니다- 컨테이너 안에서
free같은 도구는 호스트의 swap을 보여주므로, 컨테이너의 swap 상태를 판단하는 근거로 쓰지 말라고 문서가 명시합니다
OOM Kill 이해
- 컨테이너가 메모리 한도를 넘으면 커널이 프로세스를 종료합니다. SIGKILL로 죽었으므로 종료 코드는 137(128 + 9)
- OOM 여부는
docker inspect의State.OOMKilled가true인지로 확인합니다 - 137만 보고 OOM이라고 단정할 수는 없습니다.
docker stop타임아웃 후의 SIGKILL, 외부에서 보낸docker kill도 같은 코드가 됩니다
런타임의 cgroup 인식
컨테이너에 한도를 걸어도, 런타임이 그 한도를 못 보면 호스트 메모리를 기준으로 힙을 잡습니다.
# Java: 컨테이너 인지 기능은 UseContainerSupport (Linux 전용, 기본값 true)
# 컨테이너에 보이는 메모리 중 힙에 쓸 비율을 지정
-XX:MaxRAMPercentage=75.0
# Node: --max-old-space-size로 힙 상한 지정
node --max-old-space-size=384 server.js- Oracle JDK 문서 기준으로
-XX:-UseContainerSupport는 Linux 전용이고 기본값이 true입니다. 즉 컨테이너에 할당된 메모리와 프로세서 수를 JVM이 감지합니다. 필요하면-Xlog:os+container=trace로 무엇을 감지했는지 볼 수 있습니다 - cgroup은 리눅스 커널 기능으로, Docker가 리소스 제한 플래그를 걸면 호스트에서 해당 컨테이너의 cgroup 설정을 바꾸는 방식으로 동작합니다
- K8s의
resources.requests/limits도 cgroup으로 구현됩니다
4. BuildKit 심화
정리 보기
BuildKit은 Docker Desktop과 Docker Engine의 기본 빌더입니다(Windows 컨테이너를 빌드할 때는 레거시 빌더가 쓰입니다). 캐시 마운트, 병렬 실행, 빌드 시크릿 기능을 제공합니다.
캐시 마운트 — 의존성 캐시 재사용
캐시 마운트로 지정한 디렉터리의 내용은 빌드 호출 사이에 유지되고, 이 유지 자체가 명령어 캐시를 무효화하지 않습니다. 그래서 레이어 캐시가 깨져도 패키지 캐시는 남아 재다운로드를 줄일 수 있습니다.
# npm
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Go
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o app캐시는 성능 목적으로만 써야 합니다. 다른 빌드가 같은 캐시에 쓰거나 GC가 정리할 수 있으므로, 캐시 디렉터리가 비어 있어도 빌드가 성공해야 한다고 문서가 못 박아 둡니다. apt처럼 데이터에 배타적 접근이 필요한 경우에는 sharing=locked를 씁니다(sharing 기본값은 shared).
멀티 아키텍처 빌드
컨테이너는 호스트 커널을 공유하므로, 컨테이너 안 코드가 호스트 아키텍처와 맞아야 합니다. 그래서 arm64 맥에서 만든 단일 아키텍처 이미지는 에뮬레이션 없이 amd64 호스트에서 실행할 수 없습니다.
# 여러 아키텍처를 하나의 manifest list로 빌드 & 푸시
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myrepo/myapp:1.0 --push .- 멀티 플랫폼 이미지는 manifest list를 지원하는 이미지 스토어가 필요합니다. Docker Desktop과 Docker Engine 29.0 이상(새로 설치한 경우)은 containerd 이미지 스토어를 기본으로 사용합니다. 이전 버전에서 업그레이드한 경우에는 수동으로 활성화해야 합니다. 그보다 낮은 버전이라면 containerd 이미지 스토어를 켜거나,
docker-container드라이버 빌더를 따로 만들어야 합니다 docker-container드라이버로 빌드한 결과는 로컬 이미지 스토어에 자동으로 올라가지 않습니다. 그래서 위 예시처럼--push로 레지스트리에 바로 올리는 형태를 씁니다--platform에 값을 안 주면 기본값은 빌드가 실행되는 BuildKit 데몬의 플랫폼입니다
빌드 시크릿 (1번 참고)
docker build --secret id=aws,src=$HOME/.aws/credentials .- 캐시 마운트와 레이어 순서(기본편 5장)는 다시 실행되는 단계 수에 함께 영향을 줍니다
5. HEALTHCHECK와 로깅
정리 보기
HEALTHCHECK
컨테이너가 “떠 있는 것"과 “정상 동작하는 것"은 다릅니다. 프로세스는 살아있는데 응답을 못 하는 경우가 있습니다.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1옵션의 공식 기본값은 이렇습니다.
| 옵션 | 기본값 | 의미 |
|---|---|---|
--interval |
30s | 검사 간격 (컨테이너 시작 후 첫 검사도 이 간격 뒤) |
--timeout |
30s | 한 번의 검사 제한 시간. 넘기면 실패로 보고 검사 프로세스는 SIGKILL로 중단 |
--start-period |
0s | 초기화 유예 시간. 이 기간의 실패는 재시도 횟수에 포함하지 않음 |
--start-interval |
5s | start period 동안의 검사 간격 (Docker Engine 25.0 이상 필요) |
--retries |
3 | 연속 실패 횟수. 이만큼 실패하면 unhealthy |
- 검사 명령의 종료 코드로 판정합니다. 0은 healthy, 1은 unhealthy이고, 2는 예약값이라 쓰지 말라고 문서에 적혀 있습니다
- 상태는 처음
starting이고, 검사가 통과하면healthy, 연속 실패가--retries에 도달하면unhealthy가 됩니다.docker ps의 STATUS에도 표시됩니다 - Dockerfile에
HEALTHCHECK는 하나만 유효합니다(여러 개면 마지막 것). 베이스 이미지가 넣어둔 healthcheck를 끄려면HEALTHCHECK NONE - K8s의 liveness/readiness probe와 같은 개념
json-file 로그 로테이션 기본값
json-file 로그 드라이버의 max-size 기본값은 -1(무제한)이고 max-file 기본값은 1입니다. 즉 따로 설정하지 않으면 로테이션이 걸리지 않고 파일이 계속 커집니다.
// /etc/docker/daemon.json — 로그 로테이션 설정
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}max-file은max-size가 함께 설정돼 있을 때만 유효합니다daemon.json의log-opts값은 문자열로 적어야 합니다. 숫자인max-file도 따옴표로 감싸는 이유가 이것입니다- 데몬 설정을 바꾸고 Docker를 재시작해도 새로 만드는 컨테이너에만 적용됩니다. 이미 있는 컨테이너는 기존 로깅 설정을 그대로 씁니다
6. 이미지 태깅과 운영 전략
정리 보기
latest 태그의 동작
docker build -t myapp:latest .
# semver 태그와 git SHA 태그를 함께 붙이는 형태
docker build -t myapp:1.4.2 -t myapp:$(git rev-parse --short HEAD) .latest는 시점마다 다른 이미지를 가리킬 수 있습니다- git SHA 태그는 이미지가 어느 커밋에서 빌드됐는지를 태그로 나타냅니다
이미지 프로모션
같은 이미지에 환경별 태그를 추가하는 방식
build once → myapp:1.4.2
→ dev 에서 검증
→ 같은 이미지에 stg 태그 → 검증
→ 같은 이미지에 prod 태그 → 배포7. 컨테이너 런타임: Docker / containerd / OCI
정리 보기
계층 구조
Docker (CLI/빌드/UX)
└── containerd (고수준 런타임: 이미지 관리, 컨테이너 라이프사이클)
└── runc (저수준 런타임: 실제로 namespace/cgroup 만들어 프로세스 실행)- OCI (Open Container Initiative): 컨테이너 이미지 패키징 규격(OCI image-spec)과 컨테이너 실행 규격(OCI runtime-spec)을 관리하는 곳. runtime-spec의 구현체가 runc이고, containerd와 CRI-O 모두 기본 저수준 런타임으로 runc를 씁니다. 그래서 Docker로 만든 이미지를 다른 런타임에서도 실행할 수 있습니다
- containerd: Docker에서 분리된 핵심 런타임. 지금은 독립 프로젝트(CNCF)
“K8s가 왜 dockershim을 걷어냈나”
- K8s는 CRI(Container Runtime Interface)로 런타임과 통신함. CRI는 OCI의 저수준 규격 위에 얹혀 컨테이너 관리 전체를 표준화한 인터페이스입니다
- Docker Engine은 CRI를 구현하지 않아서, 전환을 돕기 위한 dockershim 코드를 K8s 프로젝트가 직접 들고 있었음. 이름 그대로 임시 방편이었고 유지 부담이 커졌다는 게 공식 설명
- dockershim 제거는 1.20에서 예고됐고, 실제로 제거된 건 Kubernetes 1.24입니다. 1.23까지는 kubelet 시작 시 경고 로그만 나왔습니다
- Docker Engine을 kubelet에 계속 붙이고 싶다면 Mirantis와 Docker가 유지하는 별도 어댑터 cri-dockerd를 쓰는 길이 남아 있습니다
docker build로 만든 이미지는 OCI image-spec을 따르므로 CRI 런타임에서 실행됩니다
8. Docker에서 Kubernetes로
정리 보기
왜 오케스트레이션이 필요한가
Compose는 단일 호스트를 대상으로 합니다. 여러 호스트에서는:
- 여러 서버에 컨테이너를 분산 배치(스케줄링)
- 죽으면 자동 재시작(self-healing)
- 무중단 롤링 업데이트 / 롤백
- 부하에 따라 자동 스케일(HPA)
- 서비스 디스커버리, 로드밸런싱
Docker/Compose와 Kubernetes 오브젝트 대응
| Docker / Compose | Kubernetes | 역할 |
|---|---|---|
docker run |
Pod | 실행 최소 단위 |
| compose service | Deployment | 원하는 상태 유지(replica, 롤링) |
-p 8080:80 |
Service | 네트워크 노출/로드밸런싱 |
depends_on + 네트워크 |
Service DNS | 서비스 간 통신 |
| named volume | PV / PVC | 영구 스토리지 |
ENV / .env |
ConfigMap / Secret | 설정 주입 |
| HEALTHCHECK | liveness / readiness probe | 상태 점검 |
--memory / --cpus |
resources.limits/requests | 리소스 제한(cgroup) |
docker-compose.yml |
매니페스트 YAML들 | 선언적 정의 |
Pod 개념
Pod는 “네트워크와 볼륨을 공유하며 함께 뜨는 한 개 이상의 컨테이너 묶음"입니다. 예를 들어 앱 컨테이너 옆에 프록시/로그수집 컨테이너를 붙이는 사이드카 패턴이 여기서 나옵니다.
이 글에서 다룬 non-root 실행, graceful shutdown, 리소스 제한, HEALTHCHECK, 이미지 태깅은 Kubernetes에서도 각각 대응하는 설정으로 존재합니다.