Docker 2026.09.06 7 min read

main 변경분을 운영 이미지로 배포하기까지

프로젝트 막바지에는 main이 여러 번 바뀌었다. 나는 “최신 코드를 받았으니 바로 build”하지 않았다. 소스 동기화, 일회용 DB 테스트, image 추적, registry 배포, stack update, 외부 readiness를 하나의 절차로 묶었다.

CI/CD는 이번 프로젝트 범위에 포함하지 않았다. 대신 수동 배포에서 사람이 빠뜨리기 쉬운 지점을 명령과 검증값으로 고정했다.

원격 main을 받기 전에 작업 tree부터 확인했다

Build directory에 로컬 변경이 남아 있으면 git pull이 예상치 못한 merge를 만들 수 있다. 먼저 git status --porcelain과 현재 branch, 원격 commit을 확인했다.

한 번은 로컬에서 .env.example이 삭제된 상태라 fast-forward가 막혔다. 이때 전체 작업 tree를 강제로 초기화하지 않고, 내가 확인한 그 파일의 삭제만 원복한 뒤 origin/main으로 fast-forward했다.

운영 shell에서 사용하는 검증 script가 connection을 닫는 문제도 겪었다. 원인은 script 안의 exit 1이 source 방식으로 실행될 경우 현재 shell까지 종료할 수 있다는 점이었다. 이후에는 script를 별도 process로 실행하거나 실패 시 returnexit의 경계를 분명히 했다.

일회용 MySQL로 schema까지 검증했다

애플리케이션 단위 테스트만 통과해도 실제 MySQL version과 Flyway migration에서 실패할 수 있다. 나는 운영과 같은 major version의 일회용 MySQL container를 만들고 Maven test와 migration을 확인했다.

최종 결과는 다음과 같았다.

Maven tests: 18
Failures: 0
Errors: 0
Skipped: 0
Build: SUCCESS
Flyway migrations: 2
Schema tables: 18

검증이 끝난 일회용 DB는 삭제했다. 운영 DB 데이터를 복사해 시험한 것이 아니라 schema와 애플리케이션 기동 조건을 확인하기 위한 독립 환경이었다.

latest 대신 commit 기반 tag를 사용했다

Image tag는 source commit을 기준으로 만들었다. 배포 후 어떤 코드가 실행 중인지 docker service inspect만으로 추적하기 위해서다.

Push 결과의 digest와 배포된 service image digest도 비교했다. 처음에는 예상 tag와 push 출력의 digest를 혼동했다. Tag는 사람이 붙이는 버전 이름이고 digest는 registry content의 불변 hash다. 둘은 다른 값이며, 같은 tag라도 content가 바뀌면 digest가 달라질 수 있다.

운영에서는 commit tag와 digest를 함께 기록했다.

Worker의 registry 인증도 전달해야 했다

Manager에서는 image를 pull할 수 있었지만 Worker task가 pull access denied로 거부된 적이 있다. Private registry 인증은 manager shell에 로그인했다고 자동으로 모든 Worker에 적용되지 않는다.

나는 stack 배포 시 registry 인증을 전달했다.

docker stack deploy \
  --with-registry-auth \
  -c compose.yaml \
  meetback

그 뒤 docker service ps --no-trunc에서 새 task가 Worker 1과 Worker 2 모두에서 image를 받아 Running이 되는지 확인했다.

.env를 그대로 배포하지 않고 Secret으로 옮겼다

처음 Compose는 .envenv_file로 읽었다. 운영에서는 DB password, OAuth secret, JWT key 같은 값을 평문 environment 목록에 남기고 싶지 않았다.

나는 애플리케이션 설정을 Docker Secret 파일로 주입하고 Spring Boot가 그 파일을 읽도록 구성했다. App secret은 애플리케이션 UID가 읽을 수 있는 mode로, Redis password와 ACL은 별도 Secret으로 나눴다.

최종 service environment에는 이름에 PASSWORD, SECRET, TOKEN, API_KEY가 포함된 평문 변수가 남지 않도록 감사했다. Secret의 이름과 mount target만 확인하고 본문은 출력하지 않았다.

Swarm manager나 host root 권한을 가진 운영자는 Secret을 사용할 수 있다. Docker Secret을 적용한 목적은 source, Compose, docker inspect environment와 terminal history에 민감값이 불필요하게 남는 범위를 줄이는 것이었다.

NFS volume은 container 시작 전부터 준비돼야 했다

App은 /app/uploads/feed를 NFS volume에 mount한다. Worker에 NFS client package가 없거나 export 권한과 UID가 맞지 않으면 Spring Boot가 뜨기 전에 task가 Rejected 된다.

두 Worker에 NFS mount helper를 준비하고 NFSv4.2, TCP, hard,timeo=600,retrans=2 옵션을 사용했다. 애플리케이션은 비-root 10001:10001로 실행하고 NFS directory owner도 같은 ID로 맞췄다.

최종 검증에서는 Worker 1의 app container가 marker를 쓰고 Worker 2의 container가 같은 내용을 읽은 뒤 삭제했다. “volume 설정이 보인다”가 아니라 실제 두 replica가 같은 데이터를 보는지 확인했다.

readiness와 Docker Healthcheck는 같은 것이 아니었다

코드에는 /readyz endpoint가 추가됐고, 기본 Cloudflare Tunnel 경로와 예비 NPM 경로에서 모두 HTTP 200과 {"status":"UP"}을 확인했다. DB 연결과 애플리케이션 기동 상태를 외부에서 확인할 수 있게 된 것이다.

다만 최종 Service 명세를 다시 확인했을 때 Docker Healthcheck 항목은 null이었다. 즉 endpoint가 존재한다는 사실과 Swarm scheduler가 그 endpoint를 health 판단에 사용한다는 사실은 별개다.

최종 배포 상태는 다음과 같다.

  • 애플리케이션 readiness endpoint: 구현·외부 검증 완료
  • Docker image 또는 Compose healthcheck: 최종 Service에는 미연결
  • rolling update의 readiness 기반 자동 판단: 보완 필요

후속 배포에서는 image에 curl 또는 경량 probe가 있는지 확인한 뒤 127.0.0.1:8080/readyz를 healthcheck로 연결해야 한다. 단순히 TCP port가 열렸는지가 아니라 실제 요청을 받을 준비가 됐는지 확인해야 한다.

배포 완료의 기준

나는 다음 조건을 모두 만족해야 배포가 끝났다고 판단했다.

  • 테스트와 Flyway migration 통과
  • commit tag와 registry digest 기록
  • App 2/2 Running, Worker별 1개
  • 두 task의 image가 같은 tag·digest
  • DB 연결과 schema 18개 확인
  • 두 Worker의 NFS 교차 읽기·삭제 성공
  • 기본·예비 외부 경로 /readyz 200
  • 최근 service log에 반복 재시작이나 fatal error 없음

자동화가 없을수록 체크리스트는 더 엄격해야 했다. 다음 글에서는 이 애플리케이션의 데이터 계층을 VyOS 뒤 전용망으로 분리하고 MySQL 복제를 구성한 과정을 다룬다.

실제 검증 화면

  1. Secret 값은 출력하지 않고 이름과 주입 대상만 확인했다. App은 10001:10001 비-root 계정으로 실행된다.
  2. 먼저 Service 관점에서 meetback_app_data/app/uploads/feed에 연결된 것을 짧게 확인했다.
  3. 배포된 Service가 NFSv4.2 volume을 /app/uploads/feed에 연결한 실제 명세다.
  4. 실제 Service를 조회한 결과다. Secret 이름과 비-root UID는 확인됐고, Healthcheck는 null로 표시됐다.
  5. 코드에 추가된 /readyz는 Cloudflare Tunnel과 NPM 경로 모두에서 HTTP 200을 반환했다.
    26-secret-mapping.png

11-app-volume-mount-summary.png

12-app-volume-mount-detail.png

24-secret-user-healthcheck.png

27-dual-readyz-clean.png