Docker로 포장하기: 이미지, 볼륨, compose, Caddy
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
로그인하고 댓글 달기