비유로 시작하기

이사할 때 짐을 어떻게 포장하나요? 물건마다 따로 싸면 이사업체마다 다르게 취급할 수 있습니다. 하지만 규격화된 컨테이너 박스에 모든 짐을 넣으면, 어떤 이사업체든, 어떤 트럭이든 동일하게 처리할 수 있습니다.

Docker는 소프트웨어를 컨테이너라는 표준 단위로 포장하는 기술입니다. “내 로컬에서는 되는데 서버에서 안 돼요” 문제를 근본적으로 해결합니다.


컨테이너 vs 가상머신

graph TD subgraph 가상머신 HW1[물리 하드웨어] OS1[Host OS] HV[Hypervisor] VM1[VM1
Guest OS + App] VM2[VM2
Guest OS + App] HW1 --> OS1 --> HV HV --> VM1 HV --> VM2 end subgraph 컨테이너 HW2[물리 하드웨어] OS2[Host OS] DE[Docker Engine] C1[Container 1
App + Libs] C2[Container 2
App + Libs] HW2 --> OS2 --> DE DE --> C1 DE --> C2 end
항목 VM Container
크기 GB 단위 MB 단위
시작 시간 수분 수초
OS 별도 Guest OS 필요 Host OS 커널 공유
격리 수준 강함 (하이퍼바이저) 보통 (네임스페이스/cgroup)
사용 목적 완전한 OS 격리 애플리케이션 배포

핵심 개념

Image (이미지)

컨테이너를 만들기 위한 읽기 전용 템플릿입니다. 레이어(Layer) 구조로 이루어져 있습니다.

graph TD L1[Layer 1: Ubuntu base OS] L2[Layer 2: Java 17 설치] L3[Layer 3: 의존성 JAR 복사] L4[Layer 4: 앱 JAR 복사] L1 --> L2 --> L3 --> L4

레이어가 캐시되기 때문에, 앱 코드만 변경되면 Layer 4만 다시 빌드됩니다. 이것이 Docker 빌드가 빠른 이유입니다.

Container (컨테이너)

이미지의 실행 인스턴스입니다. 이미지 위에 읽기/쓰기 레이어가 추가됩니다. 이미지 하나로 컨테이너를 수십 개 생성 가능합니다.

Registry

이미지를 저장하고 배포하는 저장소입니다. Docker Hub가 대표적이며, AWS ECR, GCR, Harbor 등 사설 레지스트리도 있습니다.


Dockerfile

이미지를 만드는 설계도(스크립트)입니다.

기본 Spring Boot 애플리케이션

# 베이스 이미지
FROM eclipse-temurin:17-jre-alpine

# 작업 디렉토리 설정
WORKDIR /app

# JAR 파일 복사
COPY build/libs/app.jar app.jar

# 포트 노출 (문서화 목적, 실제 바인딩은 -p 옵션)
EXPOSE 8080

# 컨테이너 시작 명령
ENTRYPOINT ["java", "-jar", "app.jar"]

멀티스테이지 빌드 (Multi-stage Build)

빌드 환경과 실행 환경을 분리하여 최종 이미지 크기를 최소화합니다.

# Stage 1: 빌드
FROM gradle:8-jdk17-alpine AS builder
WORKDIR /build
COPY . .
RUN gradle bootJar --no-daemon

# Stage 2: 실행
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# 보안: 비 root 사용자로 실행
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=builder /build/build/libs/app.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-jar", "app.jar"]

빌드 스테이지(JDK, Gradle, 소스코드)는 최종 이미지에 포함되지 않습니다. 결과 이미지 크기: ~150MB vs 단일 스테이지 ~500MB.

.dockerignore

.git
build/
*.log
.gradle
node_modules

빌드 컨텍스트에서 불필요한 파일 제외로 빌드 속도 향상.


주요 명령어

# 이미지 빌드
docker build -t myapp:1.0 .
docker build -t myapp:1.0 --build-arg PROFILE=prod .

# 컨테이너 실행
docker run -d \
  --name myapp \
  -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -v /host/logs:/app/logs \
  myapp:1.0

# 컨테이너 관리
docker ps                          # 실행 중인 컨테이너
docker ps -a                       # 전체 컨테이너
docker logs -f myapp               # 로그 스트리밍
docker exec -it myapp /bin/sh      # 컨테이너 내부 접속
docker stats                       # CPU/메모리 사용량

# 이미지 관리
docker images
docker rmi myapp:1.0
docker image prune -a              # 미사용 이미지 정리

# 레지스트리
docker tag myapp:1.0 registry.example.com/myapp:1.0
docker push registry.example.com/myapp:1.0
docker pull registry.example.com/myapp:1.0

네트워킹

# 네트워크 생성
docker network create mynetwork

# 같은 네트워크의 컨테이너는 이름으로 통신 가능
docker run -d --name mysql --network mynetwork mysql:8
docker run -d --name myapp --network mynetwork \
  -e DB_HOST=mysql \   # 컨테이너 이름으로 접근
  myapp:1.0

네트워크 드라이버:

  • bridge (기본): 단일 호스트 내 컨테이너 간 통신
  • host: 컨테이너가 호스트 네트워크 직접 사용 (성능 최대)
  • overlay: 멀티 호스트 (Swarm, Kubernetes)
  • none: 네트워크 완전 격리

볼륨 (Volume)

컨테이너는 기본적으로 상태를 유지하지 않습니다(Stateless). 데이터 영속성을 위해 볼륨이 필요합니다.

# Named Volume (권장)
docker volume create mydata
docker run -v mydata:/var/lib/mysql mysql:8

# Bind Mount (개발 시 편리)
docker run -v /host/path:/container/path myapp

# tmpfs (임시, 메모리에만)
docker run --tmpfs /tmp myapp

docker-compose

여러 컨테이너를 선언적으로 정의하고 한 번에 관리합니다.

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - DB_HOST=mysql
      - REDIS_HOST=redis
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - backend
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512m
          cpus: '0.5'

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: mydb
      MYSQL_USER: appuser
      MYSQL_PASSWORD: apppass
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 256mb
    volumes:
      - redis_data:/data
    networks:
      - backend

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - app
    networks:
      - backend

volumes:
  mysql_data:
  redis_data:

networks:
  backend:
    driver: bridge
# 전체 스택 시작
docker-compose up -d

# 로그 확인
docker-compose logs -f app

# 특정 서비스만 재시작
docker-compose restart app

# 스케일 아웃
docker-compose up -d --scale app=3

# 전체 종료 및 볼륨 삭제
docker-compose down -v

실무 베스트 프랙티스

1. 레이어 캐시 최적화

# 나쁜 예: 소스 변경 시 의존성도 재다운로드
COPY . .
RUN gradle bootJar

# 좋은 예: 의존성 먼저 복사 → 소스 복사
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
RUN gradle dependencies --no-daemon  # 의존성 레이어 캐시

COPY src ./src
RUN gradle bootJar --no-daemon       # 앱만 재빌드

2. 컨테이너 리소스 제한

docker run -d \
  --memory="512m" \
  --cpus="0.5" \
  --pids-limit=100 \
  myapp:1.0

3. 헬스체크

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1

극한 시나리오

시나리오: 프로덕션에서 컨테이너 메모리 OOM

증상: 컨테이너가 갑자기 죽고 재시작을 반복합니다.

# 컨테이너 상태 확인
docker inspect myapp | grep -A5 OOMKilled
# "OOMKilled": true

# 원인: JVM이 컨테이너 메모리 제한을 인식 못하고 Host 메모리 기준으로 Heap 설정

해결:

# Java 8u191+ / Java 11+는 컨테이너 메모리 자동 인식
ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \  # 컨테이너 메모리의 75% 사용
    "-jar", "app.jar"]

시나리오: 이미지 용량 폭발

docker images
# myapp  latest  2.1GB  ← 문제

# 원인 진단
docker history myapp:latest

# 해결: 멀티스테이지 빌드 + Alpine 베이스
# Before: openjdk:17 (600MB) + 빌드 산출물 = 2.1GB
# After: eclipse-temurin:17-jre-alpine + 멀티스테이지 = 150MB