본문으로 건너뛰기

안동민 개발노트

본문 시작

Docker를 이용한 컨테이너화

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

11장에서는 NestJS 애플리케이션 배포와 운영을 다루며, 먼저 Docker를 이용한 컨테이너화를 설명합니다.

개발 환경과 운영 환경의 차이는 흔히 내 컴퓨터에서는 잘 돌아가는데...라는 문제로 드러납니다. Docker는 애플리케이션과 사용자 공간 의존성을 이미지로 묶어 이 차이를 줄입니다.


컨테이너(Container)란 무엇인가?

컨테이너는 애플리케이션 코드, 런타임, 시스템 도구와 라이브러리를 묶어 실행하는 단위입니다. Linux 컨테이너는 네임스페이스와 cgroup 같은 커널 기능으로 프로세스와 자원을 상대적으로 격리하지만, VM처럼 별도 게스트 커널을 포함하지 않고 호스트 또는 Docker Desktop VM의 커널을 공유합니다.

컨테이너의 주요 특징
  • 격리성: 프로세스·파일 시스템·네트워크 경계를 분리할 수 있습니다. 다만 권한, 마운트, 커널 취약점에 따라 경계 강도가 달라지므로 절대적인 보안 경계로 간주하지 않습니다.
  • 이식성: 동일한 대상 OS, CPU 아키텍처와 런타임 조건에서는 같은 이미지를 여러 환경에 일관되게 배포할 수 있습니다. 다른 아키텍처까지 지원하려면 멀티 플랫폼 이미지를 빌드해야 합니다.
  • 경량성: 게스트 OS 전체를 포함하는 VM보다 일반적으로 시작이 빠르고 이미지가 작습니다.
  • 일관성: 이미지로 고정한 사용자 공간 의존성 덕분에 개발·테스트·운영 환경 차이를 줄입니다.
컨테이너와 가상 머신(VM)의 비교
특징컨테이너가상 머신(VM)
격리 방식OS 수준 격리, 호스트/VM 커널 공유하드웨어 가상화, 게스트 OS 포함
오버헤드일반적으로 낮음일반적으로 높음
배포 단위애플리케이션과 사용자 공간 의존성 이미지게스트 OS를 포함한 VM 이미지
호환 조건대상 OS·아키텍처와 이미지 플랫폼이 맞아야 함하이퍼바이저와 게스트 OS 지원이 필요함
예시Docker, containerdVMware, VirtualBox, KVM, Hyper-V

Docker란?

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

Docker의 주요 구성 요소
  • Dockerfile: 이미지를 빌드하는 명령을 정의한 텍스트 파일입니다.
  • Docker Image: 컨테이너를 생성하는 읽기 전용 이미지입니다.
  • Docker Container: 이미지를 기반으로 실행되는 프로세스 집합입니다.
  • Docker Daemon: 이미지와 컨테이너를 관리하는 백그라운드 서비스입니다.
  • Docker CLI: Docker Daemon과 상호작용하는 명령줄 인터페이스입니다.
  • Registry: 이미지를 저장하고 배포하는 저장소입니다. Docker Hub는 대표적인 공개 Registry입니다.

아래 구조는 소스 입력부터 운영 경계까지 이어지는 Secure paved road입니다. .dockerignore로 빌드 입력을 줄이고, 빌드 전용 의존성을 최종 이미지에서 제외한 뒤, 승인한 digest를 Registry와 배포 기록에 남깁니다. 실행 시점의 설정·비밀, 헬스, 재시도, 로그는 이미지 바깥의 운영 경계로 분리합니다.

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

NestJS · secure paved road · architecture

작은 런타임 이미지만 승인된 길을 따라 운영에 도착한다

빌드 입력, 이미지 내용, 배포 digest, 런타임 운영 책임을 서로 다른 경계로 나눕니다. .dockerignore는 컨텍스트를 줄이고, 다단계 빌드는 도구를 제거하며, Registry와 운영 플랫폼은 검증한 이미지와 외부 설정만 결합합니다.

NestJS 컨테이너 이미지의 Secure paved road 아키텍처 Workspace, Build and Release, Runtime 세 영역을 왼쪽에서 오른쪽으로 읽는다. dockerignore가 환경 파일과 호스트 산출물처럼 나열한 패턴을 차단하고, 제외되지 않은 입력을 builder와 production dependencies 단계로 보낸다. 두 단계의 결과는 최소 runtime image로 합쳐지고 digest 승인 후 Registry에 저장된다. 운영 플랫폼은 설정과 비밀을 컨테이너 실행 시 주입하고, 컨테이너는 헬스 상태와 표준 출력 로그를 내보내며 애플리케이션은 재시도와 종료 훅을 책임진다. 01 · WORKSPACE 02 · BUILD / RELEASE 03 · RUNTIME ALLOW digest inject BUILD CONTEXT GATE .dockerignore 전송 전 입력 필터 ALLOW src · package-lock Dockerfile · schema BLOCK .env · node_modules dist · .git · logs 비밀 저장소는 아님 BUILDER 빌드 산출물 npm ci · nest build DEPS 운영 의존성 npm ci --omit=dev MINIMAL RUNTIME IMAGE node:24-alpine dist + prod deps · USER node RELEASE GATE 검증된 이미지 digest → Registry PLATFORM INPUT config · secrets 실행 시 최소 권한 주입 CONTAINER NestJS · port 3000 exec CMD · immutable image OPERATIONS OUTPUT health · logs retry · shutdown CONTROL POINTS 입력 최소화 빌드 도구 제거 digest 승인 외부 설정 상태·복구·관측

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는 운영 의존성만 설치합니다. 최종 이미지는 두 결과와 비루트 사용자만 포함합니다.

03 · release gate

검증한 digest만 Registry와 배포 기록으로 이동한다

메이저 태그는 읽기 쉬운 이름이고 변경될 수 있습니다. 테스트한 manifest digest를 승인 단위로 고정하고 업데이트는 새 검증을 거칩니다.

04 · runtime boundary

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

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

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

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

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


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

Dockerfile 작성

프로젝트 루트에 Dockerfile을 생성합니다. 다음 예시는 현재 LTS 라인인 Node.js 24의 공식 Alpine 이미지를 사용하며, 빌드·프로덕션 의존성·실행 단계를 분리합니다.

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 cipackage-lock.json과 같은 lockfile을 전제로 의존성을 재현 가능하게 설치합니다.
  • builder에는 TypeScript와 빌드 도구가 있지만, runtime에는 프로덕션 의존성과 dist만 복사합니다.
  • USER node는 공식 Node 이미지의 비루트 사용자를 이용합니다. 애플리케이션이 쓰기 디렉터리를 필요로 한다면 해당 경로의 소유권도 명시적으로 부여해야 합니다.
  • EXPOSE 3000은 이미지 메타데이터이며, 호스트 포트를 게시하려면 docker run -p 또는 Compose의 ports가 필요합니다.
  • CMD의 exec 형식은 Node 프로세스가 종료 신호를 직접 받도록 합니다.

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

.dockerignore 파일 작성

.dockerignore는 빌드 컨텍스트가 Builder로 전송되기 전에 불필요한 입력을 제외합니다.

.dockerignore
node_modules
dist
.env
.git
*.log

node_modulesdist는 컨테이너 안에서 다시 설치하거나 생성합니다. .env를 제외하면 우발적인 복사를 줄일 수 있지만, .dockerignore는 비밀 저장소나 완전한 보안 경계가 아닙니다. 빌드 중 비밀이 필요하면 ARGENV에 넣지 말고 BuildKit secret mount를 사용합니다.

Docker 이미지 빌드

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

--pull은 캐시에 같은 태그가 있어도 최신 기반 이미지를 확인합니다. 태그는 사람이 읽는 릴리스 이름이고, 실제 배포 후보는 테스트한 이미지 digest까지 함께 기록해야 재현성과 감사 가능성이 높아집니다.

Docker 컨테이너 실행

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

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

런타임 설정과 비밀 주입

이미지에는 환경별 설정을 굽지 않고 실행 시점에 주입합니다. 다음처럼 일반 설정은 환경 변수로 전달할 수 있습니다.

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의 종료 훅은 기본으로 활성화되지 않습니다. SIGTERM을 받아 연결과 진행 중인 작업을 정리하려면 bootstrap에서 명시적으로 활성화합니다.

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

애플리케이션 로그는 컨테이너 내부 파일보다 stdoutstderr로 내보내 수집기가 가져가게 하고, 요청 ID 같은 상관관계 필드와 구조화된 로그 형식을 사용합니다.


Docker Compose를 이용한 다중 컨테이너 관리

Docker Compose는 앱, 데이터베이스, 캐시처럼 여러 컨테이너로 구성된 애플리케이션을 하나의 모델로 정의하고 실행합니다. 아래 배치도에서 app, db, redis는 Compose 기본 네트워크의 서비스 이름이며 내부 DNS 이름으로도 사용됩니다. 클라이언트에는 앱 포트만 게시하고, 데이터 서비스는 내부 네트워크에 둡니다.

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

Docker Compose · deployment

외부에는 app만 열고, 내부 서비스는 이름으로 연결한다

Compose 기본 네트워크가 app, db, redis를 서비스 DNS로 묶습니다. health 조건은 앱을 처음 만드는 시점만 제어하고, 실행 중 연결 복구와 정상 종료는 NestJS 애플리케이션의 별도 책임입니다.

NestJS, PostgreSQL, Redis의 Docker Compose 배치도 호스트 클라이언트는 게시된 3000번 포트로 app 서비스에 접근한다. Compose 기본 네트워크에서 app은 db라는 DNS 이름의 5432번 포트와 redis라는 DNS 이름의 6379번 포트로 연결한다. 두 데이터 서비스의 healthcheck 결과가 depends_on 초기 생성 게이트로 들어가며, 실행 후 장애에는 app의 재시도와 재연결이 필요하다. PostgreSQL과 Redis는 각 이름 있는 볼륨에 데이터를 저장한다. HOST COMPOSE DEFAULT NETWORK · SERVICE DNS :3000 db:5432 · DNS redis:6379 · DNS CLIENT localhost SERVICE · app NestJS container :3000 · 유일한 published port DNS clients · db · redis retry · re-resolve CREATE GATE depends_on db · healthy redis · healthy INITIAL ONLY SERVICE · db postgres:18-alpine :5432 · network internal health · pg_isready SERVICE · redis redis:8-alpine :6379 · network internal health · redis-cli ping 실행 중 장애: app의 timeout · backoff · reconnect가 담당 DOCKER-MANAGED STORAGE db_data /var/lib/ postgresql redis_data /data

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_isreadyredis-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.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의 기본 네트워크에서 appdb:5432, redis:6379처럼 서비스 이름과 컨테이너 포트로 연결합니다. IP 주소는 재생성될 수 있으므로 고정 IP에 의존하지 않습니다.
  • dbredis에는 호스트 ports를 열지 않았습니다. 호스트 도구가 직접 접근해야 하는 개발 상황에서만 명시적으로 게시합니다.
  • condition: service_healthy앱 컨테이너를 처음 생성하기 전의 준비 게이트입니다. 실행 중 의존 서비스의 지속 가용성을 보장하거나 연결을 자동 복구하지 않으므로 앱의 재연결·재시도 정책이 별도로 필요합니다.
  • 이름 있는 볼륨은 컨테이너 수명과 독립적으로 데이터를 유지하지만 백업과 복구를 대신하지 않습니다. docker compose down -v처럼 볼륨을 명시적으로 제거하면 데이터도 삭제됩니다.
  • ${VAR} 보간은 값을 전달하는 방법일 뿐 비밀 저장소가 아닙니다. 위 예시는 로컬 학습용이며, 운영에서는 Compose secrets 또는 배포 플랫폼의 secret 기능을 사용합니다.

Docker Compose 실행

필수 변수를 안전한 실행 환경에서 주입한 뒤 현재 Compose CLI를 사용합니다.

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

docker compose config로 보간과 최종 모델을 먼저 확인하고, ps와 로그로 상태를 관찰합니다. 비밀 값이 포함된 렌더링 결과나 로그를 공유하지 않도록 주의합니다.


Docker 기반 컨테이너화의 핵심은 단순히 이미지를 만드는 데 있지 않습니다. 빌드 입력을 제한하고, 빌드 도구를 런타임에서 제거하며, 승인된 이미지 digest를 배포하고, 설정·비밀·헬스·재시도·종료·로그 경계를 명확히 해야 반복 가능한 운영 단위가 됩니다.