Docker를 활용한 애플리케이션 배포와 운영

Docker란?

Docker는 애플리케이션과 실행 환경을 컨테이너로 패키징하고 실행하는 플랫폼이다. 애플리케이션 코드뿐만 아니라 런타임, 라이브러리, 시스템 의존성 등을 함께 구성해 개발 환경과 운영 환경 사이의 차이를 줄일 수 있다.

일반적인 개발 환경에서는 로컬에서는 정상적으로 실행되던 애플리케이션이 서버에서는 다른 라이브러리 버전이나 설정 차이로 인해 실패하는 문제가 발생할 수 있다. Docker는 이러한 환경 의존성을 이미지로 정의하고 동일한 이미지를 여러 환경에서 실행할 수 있도록 한다.

가상 머신과 달리 컨테이너는 일반적으로 호스트 운영체제의 커널을 공유한다. 따라서 별도의 운영체제를 부팅하는 가상 머신보다 가볍고 빠르게 실행할 수 있다.

Docker를 사용할 때 핵심적으로 이해해야 하는 개념은 이미지, 컨테이너, 네트워크, 볼륨, 환경 설정이다. 이 요소들을 적절하게 분리하면 애플리케이션을 일관성 있게 배포하고 운영할 수 있다.

Dockerfile

Dockerfile은 Docker 이미지를 생성하기 위한 빌드 명세서다. 어떤 베이스 이미지를 사용할지, 어떤 파일을 복사할지, 어떤 의존성을 설치할지, 컨테이너가 시작될 때 어떤 명령을 실행할지 정의한다.

Node.js 애플리케이션을 위한 Dockerfile은 다음과 같이 작성할 수 있다.

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY . .

ENV NODE_ENV=production

EXPOSE 3000

CMD ["node", "server.js"]

각 명령어는 서로 다른 역할을 수행한다.

  • FROM: 이미지의 기반이 되는 베이스 이미지를 지정한다.
  • WORKDIR: 컨테이너 내부의 작업 디렉터리를 설정한다.
  • COPY: 호스트의 파일을 이미지 내부로 복사한다.
  • RUN: 이미지 빌드 과정에서 명령어를 실행한다.
  • ENV: 컨테이너의 기본 환경변수를 설정한다.
  • EXPOSE: 애플리케이션이 사용할 포트를 문서화한다.
  • CMD: 컨테이너가 시작될 때 기본적으로 실행할 명령어를 지정한다.

RUN과 CMD는 실행 시점이 다르다. RUN은 이미지를 빌드하는 동안 실행되며, CMD는 해당 이미지로 컨테이너를 시작할 때 실행된다.

의존성 설치 전에 package.json과 잠금 파일을 먼저 복사하는 이유는 Docker의 빌드 캐시를 활용하기 위해서다. 애플리케이션 코드만 변경되었다면 의존성 설치 단계를 재사용할 수 있어 빌드 시간을 줄일 수 있다.

다만 위 예시는 이해를 위한 기본 구성이다. 실제 운영 환경에서는 개발 의존성을 제외하고, 실행에 필요한 파일만 포함하는 멀티 스테이지 빌드를 고려할 수 있다.

또한 프로젝트의 .dockerignore 파일에 node_modules, .git, 로컬 환경 파일 등 불필요한 항목을 지정하면 빌드 컨텍스트의 크기를 줄이고 민감한 파일이 이미지에 포함되는 위험을 낮출 수 있다.

Image와 Container

Docker Image는 컨테이너를 생성하기 위한 읽기 전용 템플릿이다. 애플리케이션 코드, 라이브러리, 런타임 및 파일 시스템 구성이 이미지에 포함된다.

Docker Container는 이미지를 기반으로 생성되어 실제로 실행되는 격리된 프로세스 환경이다. 컨테이너는 이미지의 파일 시스템 위에 쓰기 가능한 계층을 추가해 실행 중 발생하는 변경 사항을 처리한다.

이미지 하나를 기반으로 여러 컨테이너를 생성할 수 있다. 예를 들어 동일한 게임 서버 이미지를 사용해 개발용 서버와 테스트용 서버를 각각 실행할 수 있다.

이미지와 컨테이너의 관계는 클래스와 인스턴스의 관계에 비유할 수 있다. 이미지는 실행 환경의 정의이고, 컨테이너는 그 정의를 기반으로 만들어진 실행 단위다.

기본적인 명령어는 다음과 같다.

# 이미지 빌드
docker build -t game-server .

# 컨테이너 실행
docker run -d --name game-server -p 3000:3000 game-server

# 실행 중인 컨테이너 확인
docker ps

# 컨테이너 로그 확인
docker logs game-server

# 컨테이너 중지
docker stop game-server

# 컨테이너 제거
docker rm game-server

컨테이너를 제거하면 컨테이너의 쓰기 가능 계층에 저장된 데이터도 일반적으로 함께 사라진다. 따라서 데이터베이스 파일이나 반드시 보존해야 하는 사용자 데이터는 컨테이너 내부에만 저장해서는 안 된다.

이미지를 버전별로 관리할 때는 명확한 태그를 사용하는 것이 좋다. 운영 환경에서는 변경 가능한 latest 태그에만 의존하기보다 명시적인 버전이나 이미지 다이제스트를 사용해 배포 대상을 고정하는 편이 안전하다.

Docker Compose

Docker Compose는 여러 컨테이너로 구성된 애플리케이션을 하나의 설정 파일로 정의하고 관리하는 도구다.

실제 게임 플랫폼은 게임 서버만으로 구성되지 않는다. API 서버, 데이터베이스, Redis, 메시지 브로커 등 여러 구성 요소가 함께 동작할 수 있다. 이러한 서비스를 개별 명령어로 실행하면 실행 순서와 네트워크 설정, 환경변수 관리가 복잡해진다.

Docker Compose를 사용하면 각 서비스의 이미지, 포트, 네트워크, 볼륨, 환경 설정을 compose.yaml 파일에 선언할 수 있다.

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      REDIS_HOST: redis
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  redis-data:

위 설정은 API 서버와 Redis를 함께 실행한다. API 서버는 redis라는 서비스 이름을 사용해 Redis에 접근할 수 있으며, Redis 데이터는 별도의 볼륨에 저장된다.

주요 명령어는 다음과 같다.

# 서비스 빌드 및 실행
docker compose up -d --build

# 서비스 상태 확인
docker compose ps

# 로그 확인
docker compose logs -f

# 서비스 중지 및 컨테이너 제거
docker compose down

docker compose down은 기본적으로 선언된 named volume까지 제거하지 않는다. 볼륨까지 삭제하는 docker compose down -v는 영속 데이터가 사라질 수 있으므로 주의해야 한다.

depends_on은 서비스 간 의존 관계를 표현하지만, 기본적인 설정만으로 애플리케이션이 실제 요청을 처리할 준비가 되었는지까지 보장하지는 않는다. Redis가 실행 중이어도 초기화가 완료되지 않았거나 연결이 준비되지 않았을 수 있다.

따라서 Health Check와 애플리케이션의 재연결 로직을 함께 설계해야 한다.

Docker Compose는 개발 환경과 소규모 배포 환경에서 특히 유용하다. 다만 대규모 분산 시스템에서는 오케스트레이션, 자동 복구, 스케일링, 배포 전략 등을 고려해 Kubernetes와 같은 별도의 플랫폼이 필요할 수 있다.

컨테이너 네트워크

컨테이너 네트워크는 컨테이너 간 통신과 외부 시스템과의 연결을 관리한다.

Docker는 브리지 네트워크, 호스트 네트워크, 오버레이 네트워크 등 다양한 네트워크 구성을 제공한다. 일반적인 단일 호스트의 Compose 환경에서는 브리지 네트워크를 활용하는 경우가 많다.

Compose는 기본적으로 프로젝트 전용 네트워크를 생성하고 같은 네트워크에 연결된 서비스들이 서비스 이름으로 서로를 찾을 수 있도록 한다.

예를 들어 API 서버가 Redis에 연결할 때는 다음과 같이 설정한다.

const redisConfig = {
  host: process.env.REDIS_HOST ?? "redis",
  port: 6379
};

여기서 redis는 Compose 설정에 선언된 서비스 이름이다. 컨테이너 내부에서 localhost는 해당 컨테이너 자신을 의미하므로 API 서버에서 localhost:6379로 접근하면 다른 Redis 컨테이너에 연결되지 않는다.

포트 설정도 구분해야 한다.

ports:
  - "3000:3000"

위 설정에서 왼쪽은 호스트 포트이고 오른쪽은 컨테이너 포트다. 호스트의 포트로 들어온 요청을 컨테이너의 포트로 전달한다.

외부에서 접근할 필요가 없는 Redis나 데이터베이스는 호스트 포트를 공개하지 않고 내부 네트워크에서만 접근하도록 구성할 수 있다. 이는 공격 표면을 줄이고 서비스 접근 경계를 명확하게 만드는 데 도움이 된다.

여러 컨테이너로 구성된 게임 서버에서는 API 서버, 매칭 서버, 데이터베이스, Redis 사이의 통신 경로를 명확하게 설계해야 한다. 외부에 공개해야 하는 서비스와 내부 통신 전용 서비스를 분리하는 것이 기본적인 보안 원칙이다.

볼륨과 영속성

컨테이너는 교체와 재생성을 전제로 운영하는 것이 일반적이다. 따라서 컨테이너의 파일 시스템에 저장된 데이터를 영구적인 저장 공간으로 취급해서는 안 된다.

Docker 볼륨은 컨테이너의 생명주기와 데이터의 생명주기를 분리하기 위한 기능이다. 컨테이너를 삭제하거나 새 이미지로 교체하더라도 볼륨이 유지되면 데이터를 계속 사용할 수 있다.

Docker에서 자주 사용하는 저장 방식은 다음과 같다.

  • Named Volume: Docker가 관리하는 저장 공간이다. 데이터베이스 파일이나 캐시 데이터처럼 컨테이너 교체 후에도 유지해야 하는 데이터에 적합하다.
  • Bind Mount: 호스트의 특정 디렉터리나 파일을 컨테이너에 연결한다. 개발 중 소스 코드를 공유하거나 설정 파일을 주입할 때 유용하다.
  • tmpfs Mount: 호스트 메모리를 사용하는 임시 저장 공간이다. 컨테이너가 중지되거나 제거되면 데이터가 유지되지 않으며, 민감한 임시 데이터를 다룰 때 활용할 수 있다.

Compose에서는 다음과 같이 Named Volume을 설정할 수 있다.

services:
  database:
    image: postgres:17-alpine
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

이 구성에서는 PostgreSQL의 데이터 디렉터리가 postgres-data 볼륨에 저장된다.

볼륨은 데이터 보존 수단이지 백업 자체는 아니다. 디스크 장애, 실수로 인한 삭제, 데이터 손상에 대비하려면 별도의 백업 정책과 복구 절차가 필요하다.

또한 여러 컨테이너가 같은 볼륨을 사용한다고 해서 데이터베이스의 동시성 제어 문제가 해결되는 것은 아니다. 데이터 무결성과 동시 접근은 애플리케이션과 데이터 저장 시스템의 책임이다.

게임 플랫폼에서는 계정 정보, 구매 내역, 포인트 원장 등 중요한 데이터를 컨테이너의 임시 파일 시스템에만 저장해서는 안 된다. 일반적으로 데이터베이스와 외부 저장소를 활용하고, 컨테이너는 교체 가능한 실행 환경으로 유지하는 것이 바람직하다.

환경변수 및 Secret

환경변수는 애플리케이션의 실행 환경에 따라 달라지는 설정을 전달하는 수단이다.

개발 환경과 운영 환경에서는 데이터베이스 주소, 로그 수준, 외부 API 주소 등 서로 다른 설정이 필요하다. 이러한 값을 소스 코드에 직접 작성하면 환경별 변경이 어려워지고 비밀 정보가 코드 저장소에 노출될 위험이 커진다.

따라서 애플리케이션 코드와 환경 설정을 분리해야 한다.

const config = {
  port: Number(process.env.PORT ?? 3000),
  databaseUrl: process.env.DATABASE_URL,
  redisHost: process.env.REDIS_HOST ?? "redis",
  nodeEnv: process.env.NODE_ENV ?? "development"
};

환경변수는 컨테이너 실행 시 전달하거나 Compose 설정을 통해 주입할 수 있다. 로컬 개발 환경에서는 .env 파일을 사용할 수도 있다.

다만 .env 파일을 사용한다고 해서 자동으로 안전해지는 것은 아니다. 파일에 비밀번호나 API 키가 포함되어 있다면 Git 저장소에 커밋하지 않도록 해야 하며, 파일 접근 권한도 제한해야 한다.

운영 환경에서는 Secret 관리 기능이나 외부 비밀 관리 서비스를 사용해 민감한 정보를 별도로 관리하는 것이 좋다.

여기서 중요한 점은 환경변수가 비밀 정보를 안전하게 암호화해 주는 기능은 아니라는 것이다. 컨테이너 설정이나 프로세스 환경, 로그, 진단 도구 등을 통해 값이 노출될 수 있다.

Docker Compose의 secrets 기능도 사용 환경과 구성 방식에 따라 실제 비밀 정보의 전달 및 저장 보호 수준이 달라진다. 단순히 secrets 항목을 선언했다고 해서 모든 환경에서 자동으로 암호화된 비밀 관리 시스템이 제공되는 것은 아니다.

실무에서는 다음 원칙을 적용할 수 있다.

  • 비밀번호와 API 키를 이미지에 포함하지 않는다.
  • .env와 비밀 정보 파일을 버전 관리 대상에서 제외한다.
  • 서비스마다 필요한 최소한의 비밀 정보만 전달한다.
  • 로그에 인증 토큰, 비밀번호, 개인정보를 기록하지 않는다.
  • 비밀 정보가 유출되었다면 폐기 및 교체 절차를 수행한다.

이미지에 비밀 정보가 포함되면 이후 컨테이너에서 해당 값을 삭제하더라도 이미지 레이어나 빌드 기록에 흔적이 남을 수 있다. 따라서 빌드 시점과 실행 시점의 비밀 정보 관리를 구분해야 한다.

Health Check

Health Check는 컨테이너 내부의 애플리케이션이 정상적으로 동작하는지 확인하기 위한 기능이다.

컨테이너 프로세스가 실행 중이라는 사실만으로 서비스가 정상이라고 판단할 수는 없다. 서버 프로세스가 살아 있어도 데이터베이스 연결이 끊겼거나 초기화에 실패했거나 요청을 처리할 수 없는 상태일 수 있기 때문이다.

Docker는 HEALTHCHECK 명령어를 통해 컨테이너의 상태를 주기적으로 검사할 수 있다.

Node.js 애플리케이션에 HTTP 상태 확인 엔드포인트가 있다면 다음과 같이 설정할 수 있다.

HEALTHCHECK \
  --interval=30s \
  --timeout=5s \
  --start-period=10s \
  --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

이 설정은 주기적으로 /health 엔드포인트에 요청을 보내고, 정상 응답을 받지 못하면 검사 실패로 판단한다. 예시 코드는 fetch를 지원하는 Node.js 버전을 전제로 한다.

주요 옵션은 다음과 같다.

  • --interval: 상태 검사를 수행하는 주기다.
  • --timeout: 검사 요청이 완료되어야 하는 제한 시간이다.
  • --start-period: 애플리케이션이 시작되는 동안 검사 실패를 유예하는 기간이다.
  • --retries: 연속 실패를 비정상 상태로 판정하기 위한 실패 횟수다.

애플리케이션의 /health 엔드포인트는 목적에 맞게 설계해야 한다.

Liveness Check는 애플리케이션 프로세스가 계속 실행될 수 있는 상태인지 확인한다. 서비스가 자체적으로 복구할 수 없는 상태라면 재시작 판단에 활용할 수 있다.

Readiness Check는 애플리케이션이 실제 요청을 처리할 준비가 되었는지 확인한다. 데이터베이스 연결이나 필수 초기화 작업이 완료되지 않았다면 준비되지 않은 상태로 응답할 수 있다.

두 검사는 목적이 다르다. 데이터베이스 장애가 발생했다는 이유만으로 애플리케이션을 반복해서 재시작하면 오히려 장애가 확대될 수 있다. 어떤 상태를 비정상으로 판단하고 어떤 조치를 취할지 명확하게 정의해야 한다.

또한 Docker의 Health Check는 컨테이너 상태를 표시하는 기능이다. 상태가 unhealthy로 바뀌었다고 해서 일반적인 Docker 실행 환경에서 컨테이너가 자동으로 재시작되는 것은 아니다. 재시작 정책, 외부 모니터링, 오케스트레이션 시스템의 동작을 별도로 이해해야 한다.

Docker 운영 시 고려할 사항

Docker를 도입하는 목적은 단순히 애플리케이션을 컨테이너 안에서 실행하는 데 있지 않다. 배포 환경을 재현 가능하게 만들고, 구성 요소를 분리하며, 장애를 격리하고, 운영 과정을 표준화하는 데 있다.

실제 운영 환경에서는 다음 사항을 고려해야 한다.

  • 이미지 최적화: 불필요한 파일과 의존성을 제거하고 멀티 스테이지 빌드를 활용한다.
  • 보안 강화: 신뢰할 수 있는 베이스 이미지를 사용하고, 가능하면 비루트 사용자로 실행하며, 이미지 취약점을 점검한다.
  • 로그 관리: 표준 출력과 표준 오류를 활용하고 로그 수집, 보존 기간, 용량 제한을 설계한다.
  • 리소스 관리: CPU와 메모리 사용량을 관찰하고 서비스별 자원 제한을 검토한다.
  • 장애 대응: 컨테이너 재시작만으로 해결되지 않는 장애를 구분하고 데이터 복구와 재연결 절차를 준비한다.
  • 배포 관리: 이미지 버전을 명시하고 롤백 가능한 배포 절차를 구성한다.

특히 컨테이너를 재시작하면 모든 문제가 해결된다고 가정해서는 안 된다. 데이터베이스 연결 고갈, 메모리 누수, 외부 서비스 장애, 잘못된 환경변수 등은 원인을 분석하고 별도로 해결해야 한다.

게임 서버에서의 Docker 활용

게임 플랫폼에서는 API 서버, 실시간 통신 서버, 매칭 서버, Redis, 데이터베이스 등을 각각 별도의 서비스로 구성할 수 있다.

각 서비스를 컨테이너로 분리하면 독립적인 빌드와 배포가 가능하고, 특정 서비스의 장애가 다른 서비스에 미치는 영향을 줄이는 데 도움이 된다.

예를 들어 API 서버의 요청량이 증가하면 API 서버의 컨테이너 수를 늘리는 방향을 검토할 수 있다. 다만 컨테이너를 여러 개 실행한다고 해서 자동으로 부하가 분산되는 것은 아니다. 로드 밸런서, 세션 관리, 서비스 디스커버리, 데이터 일관성 등을 함께 설계해야 한다.

실시간 게임 서버에서는 연결 상태와 세션 유지 방식도 중요하다. 서버 인스턴스가 교체되거나 장애가 발생할 때 클라이언트 재접속을 어떻게 처리할지, 세션 데이터는 어디에 보관할지, 진행 중인 게임 상태를 복구할 수 있는지 고려해야 한다.

또한 Redis와 데이터베이스는 컨테이너로 실행할 수 있지만, 운영 환경에서는 저장 데이터의 영속성, 백업, 복제, 모니터링, 장애 복구가 충분히 확보되어야 한다. 관리형 데이터베이스나 별도의 인프라 서비스를 이용하는 것도 합리적인 선택이다.

Docker는 애플리케이션 실행 환경을 표준화하는 도구이지, 분산 시스템의 모든 문제를 해결하는 도구는 아니다. 네트워크 장애, 동시성, 데이터 정합성, 서비스 간 의존성은 별도의 설계가 필요하다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다