iBetter Books
수정

Docker와 쉘 스크립트

Docker 컨테이너를 사용하면 "내 컴퓨터에서는 됩니다"라는 변명이 사라집니다. 개발 환경과 프로덕션 환경이 완전히 동일합니다. 그리고 그 컨테이너가 시작할 때 가장 먼저 실행되는 것이 쉘 스크립트입니다.

ENTRYPOINT와 CMD

Dockerfile에서 컨테이너 시작 시 실행할 명령을 지정합니다.

# CMD: 기본 명령 (docker run 시 덮어쓸 수 있음)CMD ["node", "server.js"]# ENTRYPOINT: 항상 실행되는 진입점 (덮어쓰기 어려움)ENTRYPOINT ["node", "server.js"]# 조합 패턴: ENTRYPOINT는 준비 스크립트, CMD는 실제 앱ENTRYPOINT ["/entrypoint.sh"]CMD ["node", "server.js"]

ENTRYPOINT에 쉘 스크립트를 지정하면 앱 실행 전 환경 변수 검증, 설정 파일 생성, 데이터베이스 연결 대기 같은 준비 작업을 할 수 있습니다.

entrypoint.sh 패턴

표준적인 entrypoint.sh 구조입니다. 준비 작업을 모두 마친 뒤 마지막에 exec "$@"로 CMD를 실행합니다. exec를 쓰면 쉘 스크립트 프로세스가 앱 프로세스로 교체되어 시그널이 앱에 직접 전달됩니다.

#!/bin/bash# 새 파일: docker-entrypoint.sh# docker-entrypoint.sh: Docker 컨테이너 시작 스크립트set -euo pipefailRED='\033[0;31m'GREEN='\033[0;32m'YELLOW='\033[1;33m'NC='\033[0m'log()  { echo -e "${GREEN}[entrypoint]${NC} $*"; }warn() { echo -e "${YELLOW}[entrypoint]${NC} $*"; }err()  { echo -e "${RED}[entrypoint]${NC} $*" >&2; }# ─── 1. 환경 변수 기본값 설정 ─────────────────────────────────────────────────log "환경 변수 기본값 설정 중..."export APP_ENV="${APP_ENV:-production}"export APP_PORT="${APP_PORT:-3000}"export DB_PORT="${DB_PORT:-5432}"export REDIS_PORT="${REDIS_PORT:-6379}"export LOG_LEVEL="${LOG_LEVEL:-info}"log "  환경: $APP_ENV"log "  포트: $APP_PORT"# ─── 2. 필수 환경 변수 검증 ───────────────────────────────────────────────────log "필수 환경 변수 검증 중..."REQUIRED_VARS=(    "DB_HOST"    "DB_NAME"    "DB_USER"    "DB_PASSWORD")MISSING=0for var in "${REQUIRED_VARS[@]}"; do    if [ -z "${!var:-}" ]; then        err "  누락된 환경 변수: $var"        MISSING=$((MISSING + 1))    fidoneif [ $MISSING -gt 0 ]; then    err "$MISSING개 필수 환경 변수가 없습니다. 컨테이너를 종료합니다."    exit 1filog "  환경 변수 검증 완료"# ─── 3. 설정 파일 생성 ────────────────────────────────────────────────────────log "설정 파일 생성 중..."# nginx 설정 생성 (템플릿이 있을 때)if [ -f "/etc/nginx/nginx.conf.template" ]; then    envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf    log "  nginx 설정 생성 완료"fi# 앱 설정 파일 생성if [ -f "/app/config.template.json" ]; then    envsubst < /app/config.template.json > /app/config.json    log "  앱 설정 생성 완료"fi# ─── 4. 데이터베이스 연결 대기 ────────────────────────────────────────────────wait_for_db() {    local host=$1    local port=$2    local max_wait=${3:-60}   # 최대 대기 시간(초)    local elapsed=0    log "데이터베이스 연결 대기 중 ($host:$port)..."    while ! nc -z "$host" "$port" > /dev/null 2>&1; do        if [ $elapsed -ge $max_wait ]; then            err "데이터베이스에 ${max_wait}초 내에 연결할 수 없습니다."            exit 1        fi        warn "  DB 응답 없음, 재시도 중... (${elapsed}s / ${max_wait}s)"        sleep 2        elapsed=$((elapsed + 2))    done    log "  데이터베이스 연결 성공 (${elapsed}s 소요)"}if [ -n "${DB_HOST:-}" ]; then    wait_for_db "$DB_HOST" "${DB_PORT:-5432}" 60fiif [ -n "${REDIS_HOST:-}" ]; then    wait_for_db "$REDIS_HOST" "${REDIS_PORT:-6379}" 30fi# ─── 5. 데이터베이스 마이그레이션 (선택) ──────────────────────────────────────if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then    log "데이터베이스 마이그레이션 실행 중..."    # 프레임워크에 맞게 수정    # Node.js: npx prisma migrate deploy    # Python:  alembic upgrade head    # Rails:   rails db:migrate    log "  마이그레이션 완료"fi# ─── 6. CMD 실행 ──────────────────────────────────────────────────────────────log "애플리케이션 시작: $*"echo ""# exec로 교체 — 이후 쉘 스크립트 프로세스가 앱 프로세스로 대체됨# SIGTERM, SIGINT 등 시그널이 앱에 직접 전달됨exec "$@"

Dockerfile 전체 코드

# 새 파일: DockerfileFROM node:20-alpine# 작업 디렉토리 설정WORKDIR /app# netcat 설치 (wait_for_db에서 nc 명령 사용)RUN apk add --no-cache netcat-openbsd gettext# 의존성 먼저 복사 (레이어 캐시 최적화)COPY package*.json ./RUN npm install --production# 앱 소스 복사COPY . .# entrypoint 스크립트 복사 및 실행 권한 부여COPY docker-entrypoint.sh /entrypoint.shRUN chmod +x /entrypoint.sh# 비루트 사용자로 실행 (보안)RUN addgroup -S appgroup && adduser -S appuser -G appgroupUSER appuserEXPOSE 3000ENTRYPOINT ["/entrypoint.sh"]CMD ["node", "server.js"]

Docker Compose와 쉘 스크립트

# 새 파일: docker-compose.ymlservices:  app:    build: .    ports:      - "3000:3000"    environment:      - APP_ENV=development      - DB_HOST=db      - DB_PORT=5432      - DB_NAME=myapp      - DB_USER=postgres      - DB_PASSWORD=devpassword      - REDIS_HOST=redis      - RUN_MIGRATIONS=true    depends_on:      - db      - redis  db:    image: postgres:16-alpine    environment:      POSTGRES_DB: myapp      POSTGRES_USER: postgres      POSTGRES_PASSWORD: devpassword    volumes:      - postgres_data:/var/lib/postgresql/data  redis:    image: redis:7-alpine    volumes:      - redis_data:/datavolumes:  postgres_data:  redis_data:

실습: 컨테이너 실행 테스트

# 빌드docker build -t myapp:latest .# 실행 (환경 변수 전달)docker run --rm \    -e DB_HOST=localhost \    -e DB_NAME=myapp \    -e DB_USER=postgres \    -e DB_PASSWORD=devpassword \    -p 3000:3000 \    myapp:latest
[entrypoint] 환경 변수 기본값 설정 중...
[entrypoint]   환경: production
[entrypoint]   포트: 3000
[entrypoint] 필수 환경 변수 검증 중...
[entrypoint]   환경 변수 검증 완료
[entrypoint] 설정 파일 생성 중...
[entrypoint] 데이터베이스 연결 대기 중 (localhost:5432)...
[entrypoint]   데이터베이스 연결 성공 (2s 소요)
[entrypoint] 애플리케이션 시작: node server.js

Server running on port 3000

필수 환경 변수가 없으면 컨테이너가 즉시 종료합니다.

# DB_PASSWORD 없이 실행docker run --rm \    -e DB_HOST=localhost \    -e DB_NAME=myapp \    -e DB_USER=postgres \    myapp:latest
[entrypoint] 환경 변수 기본값 설정 중...
[entrypoint] 필수 환경 변수 검증 중...
[entrypoint]   누락된 환경 변수: DB_PASSWORD
[entrypoint] 1개 필수 환경 변수가 없습니다. 컨테이너를 종료합니다.

exec "$@"의 의미

exec "$@"는 현재 쉘 프로세스를 CMD로 지정한 프로세스로 교체합니다. 이것이 중요한 이유가 있습니다.

exec 없이 "$@"를 실행하면 쉘 스크립트가 PID 1이고, 앱이 자식 프로세스로 실행됩니다. Docker가 컨테이너에 SIGTERM을 보내면 PID 1(쉘)이 받는데, 기본 쉘은 시그널을 자식에게 전달하지 않습니다. 앱이 종료 신호를 받지 못해 강제 종료됩니다.

exec "$@"를 쓰면 앱이 PID 1이 됩니다. Docker의 SIGTERM이 앱에 직접 전달되고, 앱은 현재 처리 중인 요청을 마무리하고 우아하게 종료할 수 있습니다.