1. 개요

DoEatFit 은 트레이너와 회원을 잇는 운동·식단·컨디션 기록 PWA 다. 회원은 일지를 쓰고, 트레이너는 코칭·처방·출석·수업 예약·급여 정산까지 한 앱에서 처리한다. 이 블로그에 올라간 DoEatFit 글은 대부분 v2.0 시절, 그러니까 Proxmox LXC 한 대 위에서 Docker Compose 와 Nginx Proxy Manager, MinIO 로 돌던 시절의 기록이다.

2026년 7월 말에 운영 환경을 k3s 로 옮겼다. 그 뒤 두 달 동안 배포 방식, 시크릿 관리, 네트워크 격리, 스키마 관리, 관측성이 전부 바뀌었고, 그 과정에서 운영 DB 디스크가 읽기 전용으로 굳는 장애도 겪었다. 이 시리즈는 그 두 달의 기록이다. 1편은 지도 역할이다. 무엇이 어떻게 바뀌었는지 한 장에 담고, 각 주제는 뒤 편에서 파고든다.


2. 핵심 내용

2-1. 왜 옮겼나

단일 서버는 편했다. 그런데 세 가지가 계속 걸렸다.

  • SPOF: LXC 하나가 죽으면 서비스 전체가 죽는다. 실제로 Nginx 500 장애(ISS-162)도 그 LXC 의 파일 디스크립터 한도였다.
  • 이력이 없는 운영: 컨테이너 설정을 서버에서 손으로 고치면 “지금 운영에 뭐가 떠 있는가” 를 git 이 모른다.
  • 세 서비스의 표준화: 같은 홈랩에서 다른 프로젝트 둘도 돌리기 시작했다. 인그레스·TLS·시크릿·백업을 프로젝트마다 다르게 만들고 싶지 않았다.

그래서 Proxmox VM 세 대(server 1 + worker 2)에 k3s 를 올리고, 매니페스트를 git 에 두고 ArgoCD 가 동기화하는 GitOps 구조로 갔다.

2-2. 무엇이 바뀌었나

영역v2.0 (2026-05)지금 (2026-09)
실행 환경LXC + Docker Composek3s (VM 3대), kustomize base/overlays
인그레스Nginx Proxy ManagerEnvoy Gateway (Gateway API HTTPRoute)
TLSNPM 이 발급cert-manager, Cloudflare DNS01
배포CI 러너가 서버에서 compose upArgoCD + image-updater 가 GHCR 태그를 감시
시크릿서버의 .envSealedSecret (클러스터별 봉인키)
오브젝트 스토리지MinIOSeaweedFS (S3 호환)
스키마Hibernate ddl-auto=updateFlyway baseline + validate
네트워크없음NetworkPolicy ingress·egress default-deny
관측Prometheus/Grafana LXCAlloy → WireGuard → 중앙 Grafana 스택
CI 러너고정 VM 2대GitHub Actions ARC 스케일셋

애플리케이션 코드는 크게 바뀌지 않았다. 백엔드는 Spring Boot 3.5 / Java 17, 프론트는 Next.js 16 / React 18 그대로다. 바뀐 것은 전부 “코드 바깥” 이다.

2-3. 지금의 그림

flowchart TD
    U([사용자 · 트레이너 · PWA]) --> CF[Cloudflare<br/>DNS · TLS · 봇 챌린지 · Turnstile]
    CF --> GW[Envoy Gateway<br/>HTTPRoute · SecurityPolicy · BackendTrafficPolicy]

    subgraph NS["k3s · 서비스 네임스페이스 (default-deny ingress/egress)"]
        GW -->|apex| FE[Next.js frontend<br/>BFF 프록시 · 서비스워커]
        GW -->|api| BE[Spring Boot backend<br/>RateLimit · JWT · Flyway]
        GW -->|resources| S3[SeaweedFS S3<br/>서명 URL만 private 통과]
        FE -->|내부 호출 + 내부 홉 헤더| BE
        BE --> MY[(MySQL 8.0)]
        BE --> RD[(Redis 7<br/>토큰 · 버킷 · 락 · 플래그)]
        BE --> MS[(Meilisearch)]
        BE --> S3
        BE -->|OTLP| AL[Alloy]
    end

    AL -->|WireGuard| OBS[중앙 Grafana · Prometheus · Loki · Tempo]
    GH[GitHub Actions ARC<br/>빌드만] -->|prod-sha · vX.Y.Z| GHCR[(GHCR)]
    GHCR -->|semver 감시| IU[argocd-image-updater]
    IU -->|newTag write-back| INFRA[(infra 저장소)]
    INFRA -->|폴링| ARGO[ArgoCD] --> NS

읽는 법은 간단하다. 사용자 트래픽은 위에서 아래로, 배포는 오른쪽 아래에서 네임스페이스로 들어온다. CI 러너는 이미지를 만들기만 하고 클러스터에는 손을 대지 않는다. 배포의 유일한 입구는 infra 저장소의 커밋이다.

2-4. 마이그레이션 당일

옮기는 날의 순서는 Proxmox 마이그레이션 때와 같은 원칙이었다. 바깥부터 멈추고, 데이터를 옮기고, 안쪽부터 켠다.

  1. 앱 컨테이너 정지 → MySQL 덤프 → k3s 의 MySQL StatefulSet 에 복원. 수십 개 테이블이 행 수까지 일치하는지 세어 확인했다.
  2. MinIO 버킷의 객체를 SeaweedFS 로 복사. 개수가 적어(수십 개) 단순 복사로 끝났다.
  3. Envoy Gateway 에 HTTPRoute 를 올리고 방화벽의 443 포워딩을 클러스터로 바꿨다.
  4. NPM LXC 는 바로 지우지 않고 정지 상태로 보존했다. 되돌릴 길을 하루 남겨 두는 편이 마음이 편하다.

이 과정에서 배운 것 중 가장 컸던 건 헬스 프로브 경로 였다. 처음엔 liveness 를 /actuator/health 전체 집계에 붙였는데, 메일 서버 indicator 하나가 503 을 내자 파드가 45번 재시작했다. 지금은 /actuator/health/liveness 와 /readiness 그룹 경로만 본다. DB 가 죽었다고 앱 파드를 죽이는 건 최악의 선택이다.


3. 이 시리즈에서 다룰 것

  • 2편: release-please → 재태깅 → image-updater → ArgoCD 까지, 그리고 그 사이의 함정들
  • 3편: 네이티브 ENUM 사고와 Flyway baseline·validate 전환
  • 4편: SealedSecret, NetworkPolicy default-deny, 내부 홉 클라이언트 IP
  • 5편: Longhorn 타임아웃과 ext4 읽기 전용 잠금, 12분 장애
  • 6편: 서비스워커는 /api/* 를 캐시하지 않는다, IndexedDB 사본과 outbox
  • 7편: 플래키 테스트의 진범 ShedLock, UP-TO-DATE 초록, e2e 두 계층

요약

  • 코드는 그대로, 코드 바깥이 전부 바뀌었다. 실행 환경·인그레스·배포·시크릿·스키마·네트워크·관측.
  • 배포의 유일한 입구는 infra 저장소 커밋이다. CI 러너는 이미지만 만든다.
  • liveness 는 /actuator/health 전체가 아니라 그룹 경로에 붙인다.