본문 바로가기

Docker를 이용한 컨테이너화

NestJS 실행 환경을 다단계 Docker 이미지로 만들고 환경 변수와 데이터베이스를 Compose로 함께 구동합니다.

11장에서는 NestJS 애플리케이션의 배포와 운영을 다룹니다. 첫 글인 이 글에서는 Docker를 이용한 컨테이너화를 설명합니다.

개발 환경과 운영 환경의 차이는 흔히 “내 컴퓨터에서는 잘 되는데” 하는 문제로 드러납니다. Docker는 애플리케이션과 그 실행에 필요한 사용자 공간의 의존성을 하나의 이미지로 묶어 이 차이를 줄입니다.


컨테이너란

컨테이너는 애플리케이션 코드, 런타임, 시스템 도구와 라이브러리를 묶어 실행하는 단위입니다. Linux 컨테이너는 네임스페이스와 cgroup 같은 커널 기능으로 프로세스와 자원을 어느 정도 격리하지만, VM처럼 별도의 게스트 커널을 넣지 않고 호스트(또는 Docker Desktop VM)의 커널을 함께 씁니다. 네임스페이스와 cgroup이 각각 무엇을 나누고 제한하는지는 컨테이너와 Docker에서 자세히 볼 수 있습니다.

  • 격리성: 프로세스, 파일 시스템, 네트워크를 나눌 수 있습니다. 다만 권한, 마운트, 커널 취약점에 따라 격리의 강도가 달라지므로 절대적인 보안 장벽으로 여기지 않습니다.
  • 이식성: 대상 OS, CPU 아키텍처, 런타임 조건이 같다면 같은 이미지를 여러 환경에 일관되게 배포할 수 있습니다. 다른 아키텍처까지 지원하려면 멀티 플랫폼 이미지를 빌드해야 합니다.
  • 경량성: 게스트 OS 전체를 담는 VM보다 대체로 시작이 빠르고 이미지가 작습니다.
  • 일관성: 이미지로 고정한 사용자 공간의 의존성 덕분에 개발, 테스트, 운영 환경의 차이가 줄어듭니다.

컨테이너와 가상 머신(VM)은 다음과 같이 비교할 수 있습니다.

특징컨테이너가상 머신(VM)
격리 방식OS 수준 격리, 호스트/VM 커널 공유하드웨어 가상화, 게스트 OS 포함
오버헤드대체로 낮음대체로 높음
배포 단위애플리케이션과 사용자 공간 의존성 이미지게스트 OS를 포함한 VM 이미지
호환 조건대상 OS·아키텍처와 이미지 플랫폼이 맞아야 함하이퍼바이저와 게스트 OS 지원이 필요함
예시Docker, containerdVMware, VirtualBox, KVM, Hyper-V

Docker란

Docker는 컨테이너 이미지를 빌드하고, 배포하고, 실행하고, 관리하는 도구와 서비스를 제공합니다.

  • Dockerfile: 이미지를 빌드하는 명령을 적은 텍스트 파일입니다.
  • 이미지(image): 컨테이너를 만드는 읽기 전용 이미지입니다.
  • 컨테이너(container): 이미지를 바탕으로 실행되는 프로세스 집합입니다.
  • Docker Daemon: 이미지와 컨테이너를 관리하는 백그라운드 서비스입니다.
  • Docker CLI: Docker Daemon과 상호작용하는 명령줄 도구입니다.
  • 레지스트리(registry): 이미지를 저장하고 배포하는 저장소이며, Docker Hub가 대표적인 공개 레지스트리입니다.

NestJS 컨테이너의 Secure paved road

차단된 입력을 제외한 build 결과가 runtime image·registry digest·동일 digest 배포·실행 관측으로 이어진다.

작업 트리의 입력을 dockerignore로 거르고, builder와 production dependencies 단계에서 런타임 이미지를 만든 뒤 Registry 게시 결과의 digest를 기록하고 검증·운영 승인한 동일 digest로 컨테이너를 배포하며, 설정과 비밀 입력, 헬스와 재시도, 표준 출력 로그를 이미지 바깥의 운영 경계로 분리한 NestJS 컨테이너 아키텍처

Nest · Container trust boundaries

빌드 입력에서 운영 컨테이너까지

빌드 입력·이미지 내용·배포 digest·런타임 책임을 분리합니다. .dockerignore와 다단계 빌드 예제에, 검증한 digest 승인과 외부 설정·관측을 결합하는 운영 설계를 보여줍니다.

NestJS 컨테이너 이미지의 Secure paved road 아키텍처Workspace, Build and Release, Runtime 세 영역을 위에서 아래로 읽는다. dockerignore가 환경 파일과 호스트 산출물처럼 나열한 패턴을 차단하고, 제외되지 않은 입력을 builder와 production dependencies 단계로 보낸다. 두 단계의 결과는 최소 runtime image로 합쳐져 Registry에 게시된다. 게시 결과의 manifest digest를 기록하고 검증·운영 승인한 동일 digest를 배포 입력으로 사용한다. 운영 플랫폼은 설정과 비밀을 컨테이너 실행 시 주입하고, 컨테이너는 헬스 상태와 표준 출력 로그를 내보내며 애플리케이션은 재시도와 종료 훅을 책임진다.1 · WORKSPACE2 · BUILD / RELEASE3 · RUNTIME.dockerignore 입력 필터src · lockfile · Dockerfile 허용 / .env · 호스트 산출물 제외buildernpm ci빌드 → distdepsnpm ci --omit=dev운영 의존성runtime imagenode:24-alpinedist · 운영 의존성검증한 digestRegistry · 배포 기록플랫폼 설정config · secrets실행 시 주입NestJS 컨테이너exec CMD · port 3000승인한 digest운영 상태health · stdout 로그앱 retry · shutdown
NestJS 컨테이너 이미지의 Secure paved road 아키텍처입력은 builder와 deps로 별도 전달되고 두 결과가 runtime image에 합쳐집니다. 검증한 digest와 플랫폼 설정은 각각 컨테이너로 들어갑니다. 설정 노드가 digest 배포의 다음 실행 단계라는 뜻은 아닙니다. 컨테이너의 상태와 로그는 운영 경계로 나갑니다..dockerignore입력 필터src · lockfile 허용.env 제외호스트 산출물 제외buildernpm ci빌드 → distdepsnpm ci--omit=devruntime imagenode:24-alpinedist · 운영 의존성검증한 digestRegistry배포 기록플랫폼 설정config · secrets실행 시 주입NestJS 실행exec CMDport 3000운영 상태health · 로그retry · shutdown

01 · build context gate

.dockerignore가 Builder 입력을 줄인다

이 예시에서는 소스, lockfile과 Dockerfile은 컨텍스트에 남기고, .env, 호스트 node_modules, dist, Git 메타데이터와 로그처럼 .dockerignore에 나열한 패턴만 전송 전에 제외합니다.

입력 필터는 우발적 복사를 줄일 뿐 비밀 저장소가 아닙니다. 빌드 비밀은 BuildKit secret mount로 별도 전달합니다.

02 · build / deps

두 빌드 경로가 작은 runtime image로 합쳐진다

builder는 개발 의존성으로 dist를 만들고, deps는 운영 의존성만 설치합니다. 최종 이미지는 Node 런타임에 dist·운영 의존성·package 메타데이터를 복사하고 비루트 사용자로 실행합니다.

03 · release gate

Registry 게시 결과의 digest가 운영 승인과 배포 기록으로 이어진다

메이저 태그는 읽기 쉬운 이름이고 변경될 수 있습니다. Registry 게시 결과의 manifest digest를 기록하고 검증·운영 승인의 단위로 고정합니다. 승인 뒤에는 동일한 digest를 배포하며 업데이트는 새 검증을 거칩니다.

04 · runtime boundary

이미지 바깥에서 설정하고 상태를 관측한다

플랫폼은 일반 설정과 secret을 실행 시 주입하고, 컨테이너는 헬스 상태와 stdout/stderr 로그를 내보냅니다.

실행 중 연결 복구는 앱의 retry·timeout 책임이고, 정상 종료는 NestJS shutdown hook을 명시적으로 활성화해야 합니다.

  • 핵심 통제
  • 런타임 주입
  • 빌드 컨텍스트에서 차단

보안 경계의 핵심은 한 도구가 모든 것을 해결하는 것이 아니라, 입력 필터·최소 이미지·불변 digest·외부 설정·상태와 관측 책임을 이어 붙이는 데 있습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Workspace, Build and Release, Runtime 세 영역을 위에서 아래로 읽는다. dockerignore가 환경 파일과 호스트 산출물처럼 나열한 패턴을 차단하고, 제외되지 않은 입력을 builder와 production dependencies 단계로 보낸다. 두 단계의 결과는 최소 runtime image로 합쳐져 Registry에 게시된다. 게시 결과의 manifest digest를 기록하고 검증·운영 승인한 동일 digest를 배포 입력으로 사용한다. 운영 플랫폼은 설정과 비밀을 컨테이너 실행 시 주입하고, 컨테이너는 헬스 상태와 표준 출력 로그를 내보내며 애플리케이션은 재시도와 종료 훅을 책임진다.
  • 입력은 builder와 deps로 별도 전달되고 두 결과가 runtime image에 합쳐집니다. 검증한 digest와 플랫폼 설정은 각각 컨테이너로 들어갑니다. 설정 노드가 digest 배포의 다음 실행 단계라는 뜻은 아닙니다. 컨테이너의 상태와 로그는 운영 경계로 나갑니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS 컨테이너의 Secure paved road
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
빌드 입력 경계

dockerignore가 환경 파일·호스트 산출물 등 나열한 패턴을 제외합니다.

입력: 제외되지 않은 workspace · 비밀 파일: 패턴으로 제외빌드에 넘길 입력 범위가 결정됩니다.
두 빌드 산출물 합치기

builder와 production dependencies의 결과를 최소 runtime image로 합칩니다.

builder: 빌드 산출물 · dependencies: 운영 의존성 · image: 최소 runtime독립 빌드 결과가 실행 이미지로 합쳐집니다.
digest 고정·배포

registry의 manifest digest를 기록하고 검증·승인된 같은 digest를 배포 입력으로 씁니다.

registry: manifest digest · 배포: 동일 승인 digest승인 대상과 실행 대상의 identity를 고정합니다.
runtime 설정과 관찰

운영 플랫폼이 설정·비밀을 실행 시 주입하고 컨테이너가 health·표준 출력 로그를 제공합니다.

비밀: runtime 주입 · 관찰: health·stdout · 앱 책임: retry·종료 훅이미지가 실제 운영 프로세스로 실행됩니다.

NestJS 컨테이너의 Secure paved road

차단된 입력을 제외한 build 결과가 runtime image·registry digest·동일 digest 배포·실행 관측으로 이어진다.

작업 트리의 입력을 dockerignore로 거르고, builder와 production dependencies 단계에서 런타임 이미지를 만든 뒤 Registry 게시 결과의 digest를 기록하고 검증·운영 승인한 동일 digest로 컨테이너를 배포하며, 설정과 비밀 입력, 헬스와 재시도, 표준 출력 로그를 이미지 바깥의 운영 경계로 분리한 NestJS 컨테이너 아키텍처

Nest · Container trust boundaries

빌드 입력에서 운영 컨테이너까지

빌드 입력·이미지 내용·배포 digest·런타임 책임을 분리합니다. .dockerignore와 다단계 빌드 예제에, 검증한 digest 승인과 외부 설정·관측을 결합하는 운영 설계를 보여줍니다.

NestJS 컨테이너 이미지의 Secure paved road 아키텍처Workspace, Build and Release, Runtime 세 영역을 위에서 아래로 읽는다. dockerignore가 환경 파일과 호스트 산출물처럼 나열한 패턴을 차단하고, 제외되지 않은 입력을 builder와 production dependencies 단계로 보낸다. 두 단계의 결과는 최소 runtime image로 합쳐져 Registry에 게시된다. 게시 결과의 manifest digest를 기록하고 검증·운영 승인한 동일 digest를 배포 입력으로 사용한다. 운영 플랫폼은 설정과 비밀을 컨테이너 실행 시 주입하고, 컨테이너는 헬스 상태와 표준 출력 로그를 내보내며 애플리케이션은 재시도와 종료 훅을 책임진다.1 · WORKSPACE2 · BUILD / RELEASE3 · RUNTIME.dockerignore 입력 필터src · lockfile · Dockerfile 허용 / .env · 호스트 산출물 제외buildernpm ci빌드 → distdepsnpm ci --omit=dev운영 의존성runtime imagenode:24-alpinedist · 운영 의존성검증한 digestRegistry · 배포 기록플랫폼 설정config · secrets실행 시 주입NestJS 컨테이너exec CMD · port 3000승인한 digest운영 상태health · stdout 로그앱 retry · shutdown
NestJS 컨테이너 이미지의 Secure paved road 아키텍처입력은 builder와 deps로 별도 전달되고 두 결과가 runtime image에 합쳐집니다. 검증한 digest와 플랫폼 설정은 각각 컨테이너로 들어갑니다. 설정 노드가 digest 배포의 다음 실행 단계라는 뜻은 아닙니다. 컨테이너의 상태와 로그는 운영 경계로 나갑니다..dockerignore입력 필터src · lockfile 허용.env 제외호스트 산출물 제외buildernpm ci빌드 → distdepsnpm ci--omit=devruntime imagenode:24-alpinedist · 운영 의존성검증한 digestRegistry배포 기록플랫폼 설정config · secrets실행 시 주입NestJS 실행exec CMDport 3000운영 상태health · 로그retry · shutdown

01 · build context gate

.dockerignore가 Builder 입력을 줄인다

이 예시에서는 소스, lockfile과 Dockerfile은 컨텍스트에 남기고, .env, 호스트 node_modules, dist, Git 메타데이터와 로그처럼 .dockerignore에 나열한 패턴만 전송 전에 제외합니다.

입력 필터는 우발적 복사를 줄일 뿐 비밀 저장소가 아닙니다. 빌드 비밀은 BuildKit secret mount로 별도 전달합니다.

02 · build / deps

두 빌드 경로가 작은 runtime image로 합쳐진다

builder는 개발 의존성으로 dist를 만들고, deps는 운영 의존성만 설치합니다. 최종 이미지는 Node 런타임에 dist·운영 의존성·package 메타데이터를 복사하고 비루트 사용자로 실행합니다.

03 · release gate

Registry 게시 결과의 digest가 운영 승인과 배포 기록으로 이어진다

메이저 태그는 읽기 쉬운 이름이고 변경될 수 있습니다. Registry 게시 결과의 manifest digest를 기록하고 검증·운영 승인의 단위로 고정합니다. 승인 뒤에는 동일한 digest를 배포하며 업데이트는 새 검증을 거칩니다.

04 · runtime boundary

이미지 바깥에서 설정하고 상태를 관측한다

플랫폼은 일반 설정과 secret을 실행 시 주입하고, 컨테이너는 헬스 상태와 stdout/stderr 로그를 내보냅니다.

실행 중 연결 복구는 앱의 retry·timeout 책임이고, 정상 종료는 NestJS shutdown hook을 명시적으로 활성화해야 합니다.

  • 핵심 통제
  • 런타임 주입
  • 빌드 컨텍스트에서 차단

보안 경계의 핵심은 한 도구가 모든 것을 해결하는 것이 아니라, 입력 필터·최소 이미지·불변 digest·외부 설정·상태와 관측 책임을 이어 붙이는 데 있습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Workspace, Build and Release, Runtime 세 영역을 위에서 아래로 읽는다. dockerignore가 환경 파일과 호스트 산출물처럼 나열한 패턴을 차단하고, 제외되지 않은 입력을 builder와 production dependencies 단계로 보낸다. 두 단계의 결과는 최소 runtime image로 합쳐져 Registry에 게시된다. 게시 결과의 manifest digest를 기록하고 검증·운영 승인한 동일 digest를 배포 입력으로 사용한다. 운영 플랫폼은 설정과 비밀을 컨테이너 실행 시 주입하고, 컨테이너는 헬스 상태와 표준 출력 로그를 내보내며 애플리케이션은 재시도와 종료 훅을 책임진다.
  • 입력은 builder와 deps로 별도 전달되고 두 결과가 runtime image에 합쳐집니다. 검증한 digest와 플랫폼 설정은 각각 컨테이너로 들어갑니다. 설정 노드가 digest 배포의 다음 실행 단계라는 뜻은 아닙니다. 컨테이너의 상태와 로그는 운영 경계로 나갑니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS 컨테이너의 Secure paved road
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
빌드 입력 경계

dockerignore가 환경 파일·호스트 산출물 등 나열한 패턴을 제외합니다.

입력: 제외되지 않은 workspace · 비밀 파일: 패턴으로 제외빌드에 넘길 입력 범위가 결정됩니다.
두 빌드 산출물 합치기

builder와 production dependencies의 결과를 최소 runtime image로 합칩니다.

builder: 빌드 산출물 · dependencies: 운영 의존성 · image: 최소 runtime독립 빌드 결과가 실행 이미지로 합쳐집니다.
digest 고정·배포

registry의 manifest digest를 기록하고 검증·승인된 같은 digest를 배포 입력으로 씁니다.

registry: manifest digest · 배포: 동일 승인 digest승인 대상과 실행 대상의 identity를 고정합니다.
runtime 설정과 관찰

운영 플랫폼이 설정·비밀을 실행 시 주입하고 컨테이너가 health·표준 출력 로그를 제공합니다.

비밀: runtime 주입 · 관찰: health·stdout · 앱 책임: retry·종료 훅이미지가 실제 운영 프로세스로 실행됩니다.

위 그림은 소스에서 운영까지 이어지는 안전한 빌드·배포 경로를 보여 줍니다. 작업 폴더의 입력을 .dockerignore로 걸러 빌드에 넘기고, builder 단계와 프로덕션 의존성 단계를 거쳐 실행용 이미지를 만듭니다. 레지스트리에 올린 이미지의 digest를 기록하고, 검증과 운영 승인을 받은 바로 그 digest로 컨테이너를 배포합니다. 실행 시점의 설정과 비밀, 헬스 확인과 재시도, 표준 출력 로그는 이미지 바깥의 운영 영역으로 분리되어 있습니다.


NestJS 애플리케이션 컨테이너화

Dockerfile 작성

프로젝트 루트에 Dockerfile을 만듭니다. 다음 예시는 이 교재의 기준인 Node.js 24 LTS의 공식 Alpine 이미지를 쓰며, 빌드, 프로덕션 의존성, 실행 단계를 나눕니다.

먼저 같은 프로젝트에서 npm ci와 npm run build가 성공하고 실행 파일이 dist/main.js에 생기는지 확인합니다. 모노레포나 다른 출력 폴더를 쓴다면 아래 COPY와 CMD가 그 결과물을 가리켜야 합니다. Docker가 연결되지 않은 Nest 모듈이나 빠진 환경 설정을 대신 만들어 주지는 않습니다. 데이터베이스가 꼭 필요한 프로젝트는 뒤의 Compose 예제로 실행하고, 단일 컨테이너 예제는 필요한 외부 연결과 설정이 준비된 경우에 씁니다.

Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
  • npm ci는 package-lock.json 같은 lockfile을 기준으로 의존성을 똑같이 설치합니다.
  • builder 단계에는 TypeScript와 빌드 도구가 있지만, runtime 단계에는 프로덕션 의존성과 dist만 복사합니다.
  • USER node는 공식 Node 이미지에 있는 root가 아닌 사용자를 씁니다. 애플리케이션이 쓰기 폴더를 필요로 한다면 그 경로의 소유권도 명시적으로 줍니다.
  • EXPOSE 3000은 이미지의 메타데이터일 뿐이며, 호스트 포트를 열려면 docker run -p나 Compose의 ports가 필요합니다.
  • CMD의 exec 형식은 Node 프로세스가 종료 신호를 직접 받게 합니다.

node:24-alpine 같은 주 버전 태그는 다시 빌드할 때 가리키는 이미지가 바뀔 수 있습니다. 실제 릴리스 파이프라인에서는 검증한 @sha256:... digest로 고정하고, 보안 업데이트가 나오면 새 digest를 검증해 의도적으로 갱신합니다.

.dockerignore 작성

.dockerignore는 빌드 컨텍스트가 빌더로 전송되기 전에 필요 없는 입력을 뺍니다. 빌드 컨텍스트(build context)는 docker build 명령의 마지막 인자 .가 가리키는 폴더이며, Dockerfile의 COPY . .는 이 파일로 걸러지고 남은 것만 복사합니다. 아래 목록은 다시 만들 수 있는 결과물, 비밀이 담길 수 있는 파일, 저장소 기록을 빼는 최소한의 예입니다.

.dockerignore
node_modules
dist
.env
.git
*.log

node_modules와 dist는 컨테이너 안에서 다시 설치하거나 만듭니다. .env를 빼 두면 실수로 복사되는 일을 줄일 수 있지만, .dockerignore가 비밀 저장소나 완전한 보안 장치는 아닙니다. 빌드 중에 비밀이 필요하다면 ARG나 ENV에 넣지 말고 BuildKit의 secret mount를 씁니다.

이미지 빌드

docker build --pull -t nestjs-app:1.0.0 .

--pull은 캐시에 같은 태그가 있어도 최신 기반 이미지를 확인합니다. 태그는 사람이 읽는 릴리스 이름일 뿐이므로, 실제 배포 후보는 테스트한 이미지의 digest까지 함께 기록해야 같은 이미지를 다시 배포하고 감사할 수 있습니다.

컨테이너 실행

docker run -d --name my-nestjs-container \
  -p 3000:3000 \
  nestjs-app:1.0.0

-p 3000:3000은 호스트의 3000번 포트를 컨테이너의 3000번 포트에 연결하고, -d는 백그라운드에서 실행합니다.

실행 시점 설정과 비밀 주입

이미지에는 환경별 설정을 넣지 않고, 실행할 때 주입합니다. 일반 설정은 다음처럼 환경 변수로 넘길 수 있습니다.

다음 명령은 앞의 docker run에 설정을 더한 대안입니다. 두 명령을 차례로 실행하면 같은 이름 my-nestjs-container로 컨테이너를 두 번 만들 수 없으므로, 아직 컨테이너를 만들지 않았다면 필요한 명령 하나만 고르고, 이미 실행했다면 기존 컨테이너를 정리할지 다른 이름으로 시험할지 먼저 정합니다. db.internal도 예시 주소이므로 실제로 연결할 수 있는 호스트가 있어야 합니다.

docker run -d --name my-nestjs-container \
  -p 3000:3000 \
  -e DATABASE_HOST=db.internal \
  -e DATABASE_PORT=5432 \
  -e NODE_ENV=production \
  nestjs-app:1.0.0

환경 변수와 평문 .env 파일은 비밀 저장소가 아닙니다. 비밀번호와 토큰은 운영 플랫폼의 secret 기능이나 전용 비밀 관리자로 최소 권한만 주어 주입하고, 애플리케이션이 그 값을 로그에 남기지 않게 합니다.

헬스 확인, 재시도, 종료, 로그

헬스 엔드포인트는 프로세스가 살아 있는지와 트래픽을 받을 준비가 되었는지를 구분해서 알려야 합니다. 데이터베이스나 Redis 연결은 실행 중에도 끊길 수 있으므로, 애플리케이션 안에서 횟수가 제한된 재시도, 지수 백오프, 타임아웃을 설계합니다.

NestJS의 종료 hook은 기본으로 켜져 있지 않습니다. SIGTERM을 받아 연결과 진행 중인 작업을 정리하려면 bootstrap에서 명시적으로 켭니다.

src/main.ts
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);

애플리케이션 로그는 컨테이너 안의 파일보다 stdout과 stderr로 내보내 수집기가 가져가게 하고, 요청 ID 같은 상관 필드와 구조화된 형식을 씁니다.

더 깊이: PID 1로 실행된 Node가 SIGTERM을 무시하는 경우

컨테이너에서 CMD로 시작한 프로세스는 그 컨테이너의 PID 네임스페이스에서 PID 1, 즉 init 프로세스가 됩니다. Linux의 pid_namespaces 문서에 따르면 init 프로세스에는 그 프로세스가 처리기(handler)를 등록한 신호만 전달되며, 바깥 네임스페이스에서 보낸 신호도 마찬가지입니다. 예외는 바깥에서 보낸 SIGKILL과 SIGSTOP뿐입니다. 그래서 SIGTERM 처리기가 없는 Node 프로세스가 PID 1이면 docker stop이 보낸 SIGTERM은 아무 효과가 없고, Docker는 Linux 컨테이너의 기본 유예 시간인 10초를 기다린 뒤 SIGKILL로 강제 종료합니다.

이 글의 enableShutdownHooks()는 Nest가 종료 신호의 리스너를 등록하게 하므로, 켜 두면 PID 1이어도 SIGTERM을 받아 종료 hook을 실행합니다. 공식 Node 이미지의 모범 사례 문서도 Node.js가 PID 1로 실행되도록 설계되지 않았다고 설명하며, 그 예로 PID 1인 Node 프로세스가 SIGINT(Ctrl+C)에 반응하지 않는 경우를 듭니다. 직접 처리기를 두기 어렵다면 docker run --init으로 작은 init 프로세스를 PID 1에 두는 방법도 있습니다. Docker 문서에 따르면 이 init은 tini 기반의 docker-init이며, 신호를 자식 프로세스에 전달하고 좀비 프로세스를 거두는 일을 맡습니다.


Docker Compose로 여러 컨테이너 관리하기

Docker Compose는 앱, 데이터베이스, 캐시처럼 여러 컨테이너로 이루어진 애플리케이션을 하나의 모델로 정의하고 실행합니다.

Docker Compose 기본 네트워크에 app, db, redis 서비스를 배치하고, 외부에는 앱의 3000번 포트만 게시하며, 앱이 서비스 DNS 이름과 내부 포트로 데이터 서비스에 연결하는 구조. 데이터베이스와 Redis의 healthcheck는 앱 생성 전 초기 게이트를 제공하고 이름 있는 볼륨은 컨테이너와 독립적으로 데이터를 유지한다.

Nest · Compose deployment

Compose 서비스와 초기 준비 게이트

외부에는 app의 3000번 포트만 게시하고 app은 db:5432·redis:6379로 연결합니다. health 조건은 초기 생성 게이트이며 실행 중 재접속·재시도·종료는 앱의 책임입니다.

NestJS, PostgreSQL, Redis의 Docker Compose 배치도호스트 클라이언트는 게시된 3000번 포트로 app 서비스에 접근한다. Compose 기본 네트워크에서 app은 db라는 DNS 이름의 5432번 포트와 redis라는 DNS 이름의 6379번 포트로 연결한다. 두 데이터 서비스의 healthcheck 결과가 depends_on 초기 생성 게이트로 들어가며, 실행 후 장애에는 app의 재시도와 재연결이 필요하다. PostgreSQL과 Redis는 각 이름 있는 볼륨에 데이터를 저장한다.호스트 클라이언트localhost:3000appNestJS · :3000db / redis 클라이언트재시도 · 재접속dbPostgreSQL 18:5432 · pg_isreadyredisRedis 8:6379 · ping초기 준비 게이트db · redis healthyapp 생성 전 조건db_data/var/lib/postgresqlredis_data/data:3000
NestJS, PostgreSQL, Redis의 Docker Compose 배치도위 구역은 호스트에서 app으로 들어오는 경로, app의 db와 redis 연결, 각 데이터 서비스의 볼륨 마운트입니다. 아래 구역은 같은 db와 redis의 health 결과가 초기 게이트로 합쳐져 같은 app의 생성 조건이 되는 관계를 다시 표시합니다. 위아래가 새로운 서비스나 전체 실행 순서가 아닙니다. 두 관계 구역의 노드 반복은 같은 서비스의 다른 연결을 읽기 위한 것입니다.서비스·볼륨 관계초기 준비 조건호스트localhost:3000appNestJS · :3000db / redis재시도 · 재접속dbPostgreSQL 18:5432db_data/var/lib/postgresqlredisRedis 8:6379redis_data/datadb healthpg_isreadyredis healthredis-cli ping초기 준비 게이트둘 다 healthy생성 전 확인app 생성실행 중 복구는앱의 별도 책임

01 · ingress

호스트는 app의 3000번 포트에만 들어온다

3000:3000을 게시하고 PostgreSQL과 Redis의 포트는 Compose 네트워크 안에 둡니다. 개발 도구가 꼭 필요할 때만 데이터 포트를 별도로 엽니다.

02 · service dns

app은 IP 대신 서비스 이름과 내부 포트를 사용한다

데이터베이스는 db:5432, 캐시는 redis:6379로 연결합니다. 서비스가 재생성되면 IP가 바뀔 수 있으므로 연결 종료를 감지하고 이름을 다시 확인해 재접속합니다.

03 · initial gate

health 조건은 app 생성 전 준비 상태만 확인한다

pg_isready와 redis-cli ping이 healthy가 되면 app을 만듭니다.

이 게이트는 실행 중 가용성이나 자동 연결 복구를 보장하지 않습니다. app의 timeout, backoff와 retry가 별도로 필요합니다.

04 · persistence / lifecycle

이름 있는 볼륨과 앱 수명 주기는 별도 책임이다

db_data는 PostgreSQL 18의 /var/lib/postgresql에, redis_data는 /data에 연결합니다. 볼륨은 컨테이너 삭제 뒤에도 유지되지만 백업을 대신하지 않습니다.

NestJS는 enableShutdownHooks()를 켜야 종료 신호에 맞춰 연결과 진행 중인 작업을 정리할 수 있습니다.

  • 외부 게시 경로
  • 서비스 DNS 트래픽·볼륨 연결
  • health 기반 초기 생성 게이트

Compose는 로컬 다중 컨테이너 모델과 초기 시작 순서를 제공하지만, 지속 가용성·애플리케이션 재시도·비밀 관리·백업·정상 종료를 대신하지 않습니다.

위 그림은 Compose의 기본 네트워크에 app, db, redis 서비스를 두고, 외부에는 앱의 3000번 포트만 여는 구조입니다. 앱은 서비스 이름을 DNS 이름으로 삼아 db:5432, redis:6379처럼 내부 포트로 데이터 서비스에 연결합니다. 데이터베이스와 Redis의 healthcheck는 앱을 만들기 전의 초기 확인만 제공하며, 이름 있는 볼륨은 컨테이너의 수명과 상관없이 데이터를 유지한다는 점이 함께 표시되어 있습니다.

compose.yaml 작성

프로젝트 루트에 compose.yaml을 만듭니다. 이 예시는 공식 이미지의 Node 24 LTS, PostgreSQL 18, Redis 8 주 버전 태그를 씁니다. PostgreSQL 18부터 공식 이미지의 볼륨 대상이 /var/lib/postgresql로 바뀌었으므로, 예전 /var/lib/postgresql/data 경로를 그대로 쓰지 않습니다.

compose.yaml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      DATABASE_HOST: db
      DATABASE_PORT: 5432
      DATABASE_USER: ${DB_USER:?set DB_USER}
      DATABASE_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
      DATABASE_NAME: ${DB_NAME:?set DB_NAME}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET}
      NODE_ENV: production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: ${DB_USER:?set DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME:?set DB_NAME}
    volumes:
      - db_data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:8-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  db_data:
  redis_data:
  • Compose의 기본 네트워크에서 app은 db:5432, redis:6379처럼 서비스 이름과 컨테이너 포트로 연결합니다. IP 주소는 다시 만들 때 바뀔 수 있으므로 고정 IP에 기대지 않습니다.
  • db와 redis에는 호스트 ports를 열지 않았습니다. 호스트의 도구가 직접 접근해야 하는 개발 상황에서만 명시적으로 엽니다.
  • condition: service_healthy는 앱 컨테이너를 처음 만들기 전의 준비 확인입니다. 실행 중에 의존 서비스가 계속 살아 있음을 보장하거나 연결을 자동으로 복구하지 않으므로, 앱의 재연결과 재시도 정책이 따로 필요합니다.
  • 이름 있는 볼륨은 컨테이너와 상관없이 데이터를 유지하지만 백업과 복구를 대신하지 않습니다. docker compose down -v처럼 볼륨을 명시적으로 지우면 데이터도 사라집니다.
  • ${VAR} 치환은 값을 넘기는 방법일 뿐 비밀 저장소가 아닙니다. 위 예시는 로컬 학습용이며, 운영에서는 Compose secrets나 배포 플랫폼의 secret 기능을 씁니다.

Docker Compose 실행

필수 변수를 안전한 실행 환경에서 넣은 뒤, 현재의 docker compose CLI로 실행합니다. 앞 파일의 ${DB_USER:?set DB_USER}는 셸 환경이나 프로젝트 폴더의 .env에 DB_USER가 없거나 비어 있으면 set DB_USER라는 오류 메시지를 내고 명령을 멈추게 합니다. 그래서 변수를 넣지 않고 실행하면 컨테이너가 만들어지기 전에 실패합니다. 아래 명령은 설정 확인, 빌드와 시작, 상태 확인, 로그 따라가기, 정리의 순서입니다.

docker compose config
docker compose up --build -d
docker compose ps
docker compose logs -f app
docker compose down

docker compose config로 변수 치환과 최종 모델을 먼저 확인하고, ps와 로그로 상태를 지켜봅니다. 비밀 값이 들어 있는 출력이나 로그를 공유하지 않도록 주의합니다.

브라우저에서 http://localhost:3000으로 보낸 요청은 호스트의 3000번 포트를 거쳐 app의 3000번 포트로 들어갑니다. 그 앱이 PostgreSQL에 연결할 때는 db:5432를 씁니다. 앱 컨테이너 안의 localhost는 앱 자신이므로 이 Compose의 데이터베이스가 아닙니다. db와 redis가 healthy가 된 뒤 앱이 시작하더라도, 필수 변수가 비었거나 Nest 초기화가 실패하면 앱 로그를 따로 확인해야 합니다.

docker compose logs -f app은 로그를 계속 따라가는 명령이므로, 다 본 뒤에는 따라가기를 끝내고 다음 명령을 실행합니다. 일반 docker compose down과, 이름 있는 볼륨까지 지우는 명령은 구분해서 씁니다.

Docker 기반 컨테이너화의 핵심은 이미지를 만드는 데서 끝나지 않습니다. 빌드 입력을 제한하고, 빌드 도구를 실행 이미지에서 빼며, 승인된 이미지 digest를 배포하고, 설정·비밀·헬스·재시도·종료·로그를 이미지 바깥에서 분명하게 관리해야 반복할 수 있는 운영 단위가 됩니다.


정리

  • 컨테이너는 호스트 커널을 함께 쓰며 프로세스와 자원을 격리하는 실행 단위이고, Docker는 그 이미지를 빌드·배포·실행하는 도구입니다.
  • 다단계 Dockerfile로 빌드 도구는 builder 단계에만 두고, 실행 이미지에는 프로덕션 의존성과 dist만 복사하며 USER node로 실행합니다.
  • 이미지 태그는 바뀔 수 있으므로, 배포할 때는 검증한 digest를 기록하고 고정합니다.
  • 설정과 비밀은 이미지에 넣지 않고 실행 시점에 주입하며, 빌드 중 비밀이 필요하면 BuildKit secret mount를 씁니다.
  • Compose의 서비스는 이름으로 서로 연결하고, depends_on의 healthcheck 조건은 시작 전 확인일 뿐 실행 중 재연결을 대신하지 않습니다.

연습 문제

1. 게시 포트만 바꾸면

별도의 시험 환경에서 호스트 게시 포트만 8080:3000으로 바꾸고 데이터 서비스 설정은 그대로 두었습니다. 브라우저의 접속 주소와 앱이 쓰는 데이터베이스 주소는 각각 무엇일까요? 앱이 시작된 뒤 데이터베이스 연결이 끊기면 depends_on이 연결을 복구해 줄까요?

해설 보기

브라우저는 http://localhost:8080으로 접속하고, 앱은 여전히 db:5432를 씁니다. 바뀐 것은 호스트에서 앱으로 들어오는 포트일 뿐, 컨테이너 안의 서비스 이름과 포트는 그대로입니다.

depends_on의 health 조건은 처음 시작할 때의 확인일 뿐 실행 중의 연결 복구 기능이 아니므로, 앱의 타임아웃, 재연결, 횟수가 제한된 재시도를 확인해야 합니다. 볼륨에 데이터가 남아 있는 것과 데이터베이스가 지금 요청을 처리할 수 있는 것도 서로 다른 조건입니다.

2. 정리하다 지운 데이터

개발 중인 동료가 Compose 환경을 깨끗이 다시 시작하려고 docker compose down -v를 실행한 뒤 docker compose up -d로 다시 띄웠습니다. 데이터베이스에 넣어 둔 테스트 데이터는 어떻게 되었을까요? -v 없이 down만 실행했다면 어땠을까요?

해설 보기

-v는 Compose 파일에 선언된 이름 있는 볼륨까지 지우므로, db_data에 있던 데이터베이스 파일이 모두 사라집니다. 다시 띄운 PostgreSQL은 빈 데이터베이스로 새로 초기화됩니다.

-v 없이 down만 실행하면 컨테이너와 네트워크는 지워지지만 이름 있는 볼륨은 남으므로, 다시 띄운 데이터베이스가 기존 데이터를 그대로 씁니다. 다만 볼륨이 남는다고 백업이 되는 것은 아니므로, 지켜야 할 데이터는 따로 백업하고 복구를 연습해 둡니다.

3. 빌드할 때 필요한 토큰

사설 npm 레지스트리의 패키지를 설치하려고 Dockerfile에 ARG NPM_TOKEN을 두고 RUN echo "//registry.example.com/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci로 설치했습니다. 최종 이미지는 다단계 빌드의 runtime 단계라 .npmrc가 복사되지 않았습니다. 이 토큰은 안전할까요?

해설 보기

안전하다고 보기 어렵습니다. ARG 값은 빌드 기록이나 중간 단계의 레이어에 남을 수 있고, builder 단계의 이미지나 캐시가 레지스트리에 올라가거나 다른 사람과 공유되면 토큰이 드러날 수 있습니다. .npmrc를 만든 RUN 레이어도 builder 단계 안에는 그대로 남습니다.

빌드 중에 필요한 비밀은 BuildKit의 secret mount로 넘깁니다. 예를 들어 RUN --mount=type=secret,id=npmrc,target=/app/.npmrc npm ci처럼 쓰면, 비밀이 그 명령이 실행되는 동안에만 파일로 보이고 이미지 레이어에는 남지 않습니다. 빌드할 때는 docker build --secret id=npmrc,src=$HOME/.npmrc .처럼 비밀 파일을 따로 넘깁니다.

다음 글 CI/CD 파이프라인 구축에서는 이 이미지를 자동으로 빌드하고 검증해 배포하는 파이프라인을 만듭니다.