집 미니PC로 서비스 올리기: Dockerfile부터 Tunnel, 무중단 배포까지

조회 7 #31

기능 개발을 잠시 멈추고, 이 사이트를 집에 있는 미니PC에 직접 올렸습니다. 인프라 지식이 깊지 않아서 개념부터 하나씩 확인하며 진행했고, 그 과정과 실제로 겪은 문제를 정리합니다.

최종 구성

브라우저 → Cloudflare → Tunnel → cloudflared → nginx → 앱 3대 → DB·Redis

서버는 N100 미니PC 한 대이고, Windows의 WSL2 Ubuntu 안에서 Docker로 돌립니다. 앱을 3대 띄워도 CPU는 그대로라 처리량이 3배가 되지는 않습니다. 이 구성의 목적은 앱 한 대가 죽거나 배포 중일 때도 서비스를 유지하는 것입니다.

1. 프로젝트를 이미지로 만들기

순서는 Dockerfile(설계도) → 이미지 → 컨테이너(실행) 입니다. 빌드 단계와 실행 단계를 나눠서 최종 이미지에는 JRE와 jar만 남겼습니다.

FROM eclipse-temurin:17.0.21_9-jdk AS builder
WORKDIR /app
COPY gradlew build.gradle settings.gradle gradle.properties ./
COPY gradle ./gradle
COPY src/main ./src/main
RUN chmod +x ./gradlew
RUN ./gradlew bootJar -x test

FROM eclipse-temurin:17.0.21_9-jre
WORKDIR /app
COPY --from=builder /app/build/libs/project0-*.jar app.jar
ENV SPRING_PROFILES_ACTIVE=prod
ENV SERVER_SHUTDOWN=graceful
ENTRYPOINT ["java", "-jar", "app.jar"]
  • ENTRYPOINT를 exec 형식으로: 쉘을 거치지 않아 java가 PID 1이 되고, 종료 신호를 바로 받습니다.
  • SERVER_SHUTDOWN=graceful: 종료 신호를 받으면 처리 중인 요청을 마친 뒤 내려갑니다.
  • .dockerignore: .env, .git, build 등이 빌드에 섞이지 않게 막습니다. 특히 비밀 값이 이미지에 들어가지 않게 하는 역할이 큽니다.

2. nginx로 로드밸런싱

nginx는 upstream에 서버를 나열하면 기본으로 라운드 로빈으로 동작합니다. 컨테이너끼리는 IP 대신 Compose의 서비스 이름으로 찾습니다.

upstream everyboard_app {
    server app1:8082;
    server app2:8082;
    server app3:8082;
}

server {
    listen 80;
    location / {
        proxy_pass http://everyboard_app;
        proxy_set_header Host              $http_host;
        proxy_set_header X-Real-IP         $http_cf_connecting_ip;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    }
}

Compose에서는 공통 설정을 YAML 앵커로 묶으면 앱 3대를 짧게 쓸 수 있습니다.

x-app: &app
  image: project0:local
  build: /project/project0
  restart: unless-stopped
  env_file: .env
  stop_grace_period: 35s

services:
  app1: *app
  app2: *app
  app3: *app

어느 앱이 응답했는지는 실습 때 add_header X-Upstream $upstream_addr always;를 넣어 확인했습니다. 내부 주소가 노출되는 헤더라 공개 전에 지웠습니다.

3. Cloudflare Tunnel로 공개하기

도메인은 Cloudflare에서 샀습니다. 처음에는 Cloudflare에서 서버까지 빌릴 수 있는 줄 알았는데, 도메인과 DNS, 프록시를 맡는 서비스이고 서버는 제가 준비해야 했습니다.

Tunnel은 미니PC가 Cloudflare로 먼저 연결을 맺어 두는 방식입니다. 그래서 공유기 포트를 열지 않아도 HTTPS로 접속할 수 있습니다.

  1. Cloudflare 대시보드에서 Tunnel을 만들고 토큰을 복사합니다.
  2. 미니PC에서 cloudflared를 실행합니다.
  3. Routes에 도메인과 서비스 주소를 등록합니다. DNS 레코드는 자동으로 만들어집니다.

4. 실제로 겪은 문제

로그인이 안 된다

로컬에서는 되는데 nginx를 거치면 로그인이 안 됐습니다. prod 프로필의 JWT 쿠키에 Secure가 걸려 있어서 HTTP로는 쿠키가 전달되지 않았습니다. HTTPS를 붙이기 전까지는 local 프로필로 실습하고, Tunnel을 연결한 뒤 prod로 되돌렸습니다.

프로필을 바꿨더니 502

프로필마다 포트가 달랐습니다. 앱은 8080으로 떴는데 nginx는 여전히 8082로 보내고 있었습니다. 502가 나면 nginx와 앱의 포트부터 맞춰 보는 습관이 생겼습니다.

토큰 오류와 530, 1033

Provided Tunnel token is not valid는 토큰이 잘못 복사된 경우였습니다. 530과 Error 1033은 Service 주소가 아니라 Cloudflare에 연결된 cloudflared가 없다는 신호였습니다.

Tunnel이 15초마다 끊긴다

가장 오래 걸린 문제입니다. 컨테이너로 띄운 cloudflared가 연결됐다가 4개가 동시에 끊기기를 반복했습니다. 이렇게 하나씩 확인했습니다.

  • QUIC(UDP) 대신 --protocol http2로 바꿔 봐도 계속 끊김
  • Ubuntu와 컨테이너에서 ping을 해 보니 둘 다 손실 0%
  • 컨테이너에서 7844 포트 연결 테스트는 10번 모두 성공
  • Docker 밖에서 cloudflared를 3분간 실행하니 한 번도 끊기지 않음

그래서 cloudflared만 Docker 밖으로 옮겨 systemd 서비스로 돌리고, Service 주소를 http://nginx:80에서 http://localhost:4444로 바꿔 해결했습니다. Docker 네트워크 쪽 문제로 보이지만 정확한 원인까지는 확인하지 못했습니다.

[Service]
EnvironmentFile=/Docker/nginx/tunnel.env
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --protocol http2 run
Restart=always
RestartSec=5

5. 무중단 배포 스크립트

docker compose up -d --build는 앱을 한꺼번에 교체할 수 있어서, 한 대씩 교체하는 스크립트를 만들었습니다.

docker compose build app1

for app in app1 app2 app3; do
  docker compose up -d --no-deps --no-build --force-recreate "$app"

  # 새 앱이 응답할 때까지 기다린다 (실패 처리는 생략)
  until docker compose exec -T nginx curl -sf -o /dev/null "http://$app:8082/"; do
    sleep 3
  done

  # 새 컨테이너의 IP를 nginx에 반영한다
  docker compose exec -T nginx nginx -s reload
done

nginx는 시작할 때 앱의 IP를 읽어 두기 때문에, 컨테이너를 다시 만들면 reload로 반영해야 합니다. 다만 무중단에도 한계가 있습니다.

  • 배포 중에는 이전 버전과 새 버전이 함께 돌아서, 새 HTML이 요청한 JS가 이전 앱으로 가면 잠깐 404가 날 수 있습니다.
  • 컬럼 삭제 같은 DB 변경은 이전 버전 앱을 깨뜨릴 수 있고, 이미지를 롤백해도 DB는 돌아오지 않습니다.
  • 서버가 한 대라 재부팅이나 정전까지 막아 주지는 못합니다.

다음은 GitLab Runner를 붙여서 푸시하면 이 스크립트가 자동으로 돌게 할 계획입니다.

정리

  • 컨테이너 안과 밖의 주소는 다릅니다. 컨테이너끼리는 서비스 이름과 안쪽 포트, 밖에서는 바깥쪽 포트를 씁니다.
  • 오류 코드가 구간을 알려 줍니다. 530·1033은 Tunnel, 502는 nginx 뒤쪽을 먼저 봅니다.
  • 추측보다 비교 실험이 빠릅니다. Docker 안과 밖에서 같은 명령을 돌려 본 것이 결정적이었습니다.
  • DB·Redis 포트는 인터넷에 열지 않습니다. 외부에서 접속해야 하면 Tailscale 같은 사설망을 씁니다.

깊게 아는 분야는 아니지만, 각 조각의 역할을 넓게 알고 있으니 막히는 곳을 하나씩 좁혀 갈 수 있었습니다. 이 사이트도 이렇게 돌아가고 있습니다.

밍밍밍