saip.learning notes

Docker로 포장하기: 이미지, 볼륨, compose, Caddy

배포 · 마지막 수정 2026-10-24

Spring Boot 블로그를 Docker 이미지로 만들고, 운영과 똑같은 구성(앱 + PostgreSQL + Caddy)을 내 PC에서 먼저 띄워 본 기록이다. 서버에 올리기 전에 로컬에서 리허설하면, 서버에서 생길 문제의 대부분을 미리 만난다.

한눈에 보기

개념 비유 이 프로젝트에서
이미지 설치가 끝난 프로그램의 스냅숏(읽기 전용) Java 21 JRE + 앱 jar
컨테이너 그 스냅숏으로 실행한 프로세스(일회용) 서버에서 돌아가는 블로그
레지스트리 이미지 창고 GitHub Container Registry(ghcr.io)
볼륨 컨테이너 밖에 있는 데이터 보관함 DB 파일, 업로드 이미지, HTTPS 인증서
compose 여러 컨테이너를 한 파일로 띄우는 도구 앱, DB, Caddy

Tip

"내 PC에선 되는데 서버에선 안 돼요"가 사라지는 이유: PC에서 확인한 바로 그 이미지가 서버에서 돈다. 서버에는 Java도 Gradle도 설치하지 않는다.

멀티스테이지 Dockerfile

공구(JDK, Gradle, 소스)는 집에 두고 가구(jar)만 배송한다.

# 1단계: JDK와 Gradle로 jar를 만든다
FROM eclipse-temurin:21.0.12.1_1-jdk-noble AS build
WORKDIR /src
COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
RUN ./gradlew --no-daemon -q dependencies > /dev/null   # 의존성 층
COPY src src
RUN ./gradlew --no-daemon -q bootJar

# 2단계: JRE와 jar만
FROM eclipse-temurin:21.0.12.1_1-jre-noble
RUN useradd --system --uid 10001 app \
 && mkdir -p /data/images && chown app /data/images
COPY --from=build /src/build/libs/blog-0.0.1-SNAPSHOT.jar /app/app.jar
USER app
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

레이어 캐시

Dockerfile은 명령 한 줄마다 층(레이어)을 쌓고, 앞 층이 그대로면 다시 만들지 않는다. 그래서 자주 안 바뀌는 것을 먼저 둔다.

flowchart TD
  a["`**build.gradle 복사**
  거의 안 바뀜`"] --> b["`**의존성 다운로드**
  수백 MB, 캐시됨`"]
  b --> c["`**src 복사**
  자주 바뀜`"] --> d[bootJar]

코드만 고쳤으면 src 복사부터 다시 하므로 의존성을 다시 받지 않는다.

그 밖의 결정

  • root가 아닌 사용자로 실행: 앱에 구멍이 나도 컨테이너 안에서 root 권한을 얻지 못한다.
  • 이미지 태그를 정확히 고정(21.0.12.1_1, postgres:17.11, caddy:2.11.6): latest나 21은 어느 날 내용이 바뀌어 같은 Dockerfile이 다른 결과를 낸다.
  • 빌드할 때 테스트를 건너뜀: 테스트는 CI가 따로 돌린다. Dockerfile 안에서 Testcontainers를 돌리려면 Docker 안의 Docker가 필요해서 복잡해진다.
  • .dockerignore로 .git, build, data, .env를 빌드 재료에서 뺀다. 비밀값이 이미지에 들어가는 사고를 막는다.

볼륨: 컨테이너는 버려도 데이터는 남는다

새 버전을 배포하면 옛 컨테이너를 지우고 새로 만든다. 컨테이너 안에 쓴 파일은 같이 사라진다. 그래서 남아야 하는 것은 볼륨에 둔다.

볼륨 내용 없으면
pgdata PostgreSQL 데이터 배포할 때마다 노트가 전부 사라짐
images 업로드 이미지 본문의 그림이 깨짐
caddy_data HTTPS 인증서 매번 새로 발급받다가 발급 횟수 제한에 걸림

운영용 compose

flowchart LR
  internet((인터넷)) -- "80 / 443" --> caddy
  subgraph compose 내부 네트워크
    caddy -- "app:8080" --> app
    app -- "db:5432" --> db@{ shape: cyl, label: "db" }
  end
  • compose 안에서는 서비스 이름이 곧 주소다. 앱은 DB를 jdbc:postgresql://db:5432/blog로 찾는다.
  • 바깥에 여는 포트는 Caddy의 80, 443뿐이다. 앱(8080)과 DB(5432)는 내부 네트워크에만 있다.
name: saip-prod

services:
  db:
    image: postgres:17.11
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}   # .env에 없으면 시작 거부
    command: postgres -c shared_buffers=64MB
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "blog", "-d", "blog"]
    mem_limit: 256m

  app:
    image: ghcr.io/<github-user>/saip:${APP_TAG:-latest}
    depends_on:
      db: { condition: service_healthy }   # DB가 준비된 뒤에 시작
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/blog
      JAVA_TOOL_OPTIONS: -Xmx320m -XX:+UseSerialGC
    volumes: [images:/data/images]
    mem_limit: 512m

  caddy:
    image: caddy:2.11.6
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
  • ${변수:?}: .env에 값이 없으면 compose가 바로 멈춘다. 비밀번호가 빈 채로 DB가 뜨는 사고를 막는다.
  • depends_on + healthcheck: DB가 "접속 받을 준비 완료"가 된 뒤에 앱을 띄운다. 그냥 depends_on만 쓰면 컨테이너가 시작만 하면 넘어가서, 앱이 DB 연결에 실패할 수 있다.
  • 로그도 max-size: 10m, max-file: 3으로 제한한다. 안 그러면 몇 달 뒤 디스크가 로그로 가득 찬다.

Caddy: 리버스 프록시와 자동 HTTPS

Caddy는 Go로 만든 웹 서버다. Spring 개발 경험으로 비유하면 Tomcat 앞에 두는 Apache httpd·Nginx 자리다.

  • HTTPS를 받아 암호화를 풀고 앱에는 http로 넘긴다.
  • Let's Encrypt에서 인증서를 자동으로 받고, 만료 전에 자동으로 갱신한다.
  • http로 오면 https로 보내고, 응답을 gzip으로 압축한다.
{$DOMAIN} {
	encode gzip
	reverse_proxy app:8080
}

Nginx로 같은 일을 하려면 server 블록 두 개, 인증서 경로, proxy_set_header 여러 줄을 쓰고, certbot을 따로 설치해 갱신까지 걸어야 한다. 대규모 트래픽에는 Nginx가 업계 표준이지만, 개인 블로그에서는 설정 실수가 곧 보안 사고라서 실수할 곳이 적은 Caddy를 골랐다.

인증서는 어떻게 자동으로 받나

sequenceDiagram
  participant C as Caddy (내 서버)
  participant L as Let's Encrypt
  C->>L: 이 도메인 인증서 주세요
  L->>C: 주인이 맞다면 이 주소에 이 글자를 올려 두세요
  L->>C: (인터넷에서 http://도메인/.well-known/acme-challenge/... 접속)
  L->>C: 확인 완료, 인증서 발급(90일)
  Note over C: 만료 30일 전쯤 자동으로 반복

이 확인에 80 포트가 필요해서 서버 방화벽에서 80과 443을 연다. DOMAIN=localhost면 Caddy가 자체 인증서를 만들어 쓴다(브라우저 경고는 정상, curl은 -k).

앱이 자기 주소를 아는 법

앱은 요청을 Caddy에게서 http로 받으므로 자기 주소를 http://로 착각한다. 그러면 Google 로그인의 redirect_uri가 http://...가 되어 Google이 거절한다. Caddy가 붙여 주는 X-Forwarded-Proto: https 헤더를 Spring이 읽게 한다.

server:
  forward-headers-strategy: framework

Warning

이 헤더는 누구나 위조할 수 있다. 앱 포트를 외부에 열지 않고 반드시 Caddy를 거치게 해야 믿을 수 있다(Caddy가 손님이 보낸 값을 덮어쓴다).

1GB 서버에 맞추기

컨테이너 상한 리허설 실측(요청 90번 뒤)
app (힙 -Xmx320m) 512MB 319MB
db (shared_buffers=64MB) 256MB 36MB
caddy 64MB 17MB

JVM은 힙 말고도 메타스페이스, 스레드 스택, 코드 캐시를 쓰므로 컨테이너 상한을 힙보다 넉넉히 잡는다. 서버에는 스왑도 2GB 설정할 예정이다.

로컬 리허설에서 확인한 것

  • https://localhost/ 200, http://는 308로 https 이동
  • API: 토큰 있으면 200, 없으면 401
  • Caddy 뒤에서 Google 로그인 redirect_uri=https://localhost/...
  • CSP 헤더, gzip 압축
  • up -d --force-recreate로 앱·DB 컨테이너를 새로 만들어도 노트와 이미지가 그대로
  • down 후 up해도 그대로, down -v를 해야 지워짐
docker build -t ghcr.io/<github-user>/saip:latest .
docker compose -f deploy/compose.yaml --env-file deploy/.env up -d
docker compose -f deploy/compose.yaml --env-file deploy/.env down -v   # 리허설 데이터까지 삭제

겪은 문제와 해결

Caution

compose 프로젝트 이름 충돌. 개발용 compose.yaml(저장소 루트, DB만)과 운영용 deploy/compose.yaml이 둘 다 폴더 이름에서 온 프로젝트 이름 saip을 써서, ps에 개발용 DB가 같이 나왔다. 이대로 정리 명령을 쓰면 개발 DB까지 건드릴 수 있다. 운영용 파일에 name: saip-prod를 적어 분리했다.

  • ghcr 이미지 이름은 소문자만: GitHub 아이디에 대문자가 있어도 ghcr.io/ 뒤는 소문자로 쓴다. GitHub Actions의 github.repository_owner는 대문자를 그대로 주므로 변환이 필요하다.
  • 이미지 크기가 522MB로 보임: Docker Desktop이 압축본과 푼 것을 합쳐 보여 준 값이었다. docker history로 층별 크기를 보면 JRE 166MB + jar 63MB + 우분투 기본층이고, 압축 크기는 149MB다.
  • 빈 값과 기본값: GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}처럼 빈 문자열을 넘기면 Spring이 "client id가 비었다"며 시작을 거부한다. :-unset으로 기본값을 주어, 값이 없어도 앱은 뜨고 로그인만 실패하게 했다(:-는 변수가 없을 때뿐 아니라 비어 있을 때도 기본값을 쓴다).
  • API 테스트가 400: bash의 printf로 JSON을 만들었더니 \n이 진짜 줄바꿈이 되어 JSON 문자열이 깨졌다. JSON은 Python의 json.dumps처럼 제대로 만드는 도구로 만든다.
  • Windows 줄 끝(CRLF): 리눅스 컨테이너에서 실행할 셸 스크립트가 CRLF면 /bin/sh^M 오류가 난다. .gitattributes에 *.sh text eol=lf, deploy/** text eol=lf를 넣었다.

댓글 0

로그인하고 댓글 달기