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 Compose | k3s (VM 3대), kustomize base/overlays |
| 인그레스 | Nginx Proxy Manager | Envoy Gateway (Gateway API HTTPRoute) |
| TLS | NPM 이 발급 | cert-manager, Cloudflare DNS01 |
| 배포 | CI 러너가 서버에서 compose up | ArgoCD + image-updater 가 GHCR 태그를 감시 |
| 시크릿 | 서버의 .env | SealedSecret (클러스터별 봉인키) |
| 오브젝트 스토리지 | MinIO | SeaweedFS (S3 호환) |
| 스키마 | Hibernate ddl-auto=update | Flyway baseline + validate |
| 네트워크 | 없음 | NetworkPolicy ingress·egress default-deny |
| 관측 | Prometheus/Grafana LXC | Alloy → 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 마이그레이션 때와 같은 원칙이었다. 바깥부터 멈추고, 데이터를 옮기고, 안쪽부터 켠다.
- 앱 컨테이너 정지 → MySQL 덤프 → k3s 의 MySQL StatefulSet 에 복원. 수십 개 테이블이 행 수까지 일치하는지 세어 확인했다.
- MinIO 버킷의 객체를 SeaweedFS 로 복사. 개수가 적어(수십 개) 단순 복사로 끝났다.
- Envoy Gateway 에 HTTPRoute 를 올리고 방화벽의 443 포워딩을 클러스터로 바꿨다.
- 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전체가 아니라 그룹 경로에 붙인다.