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이 앱에 직접 전달되고, 앱은 현재 처리 중인 요청을 마무리하고 우아하게 종료할 수 있습니다.