멀티플레이어 게임 사이드 프로젝트를 위한 홈 K8s 클러스터 부트스트랩
멀티플레이어 체스 게임이 살 곳이 필요했고, 그래서 집에 있던 k3s 박스가 클러스터가 되었습니다. 4주 뒤, 같은 박스에는 FluxCD 위에서 동작하는 14개의 Knative function, dashboard.wintersalmon.com의 Grafana, 그리고 1초 미만의 콜드 스타트가 자리잡았습니다. 핵심은 레이어링이었습니다: 먼저 도달 가능하게 만들고, 그 다음 모니터링하고, 나머지는 전부 미루는 것입니다.
- NGINX ingress + cert-manager DNS-01이 도달 가능 베이스라인이었습니다. Traefik 템플릿은 배포 전에 전부 다시 키잉해야 했습니다.
- FluxCD가 14개 Knative function을 reconcile한 것은 워크플로우 정규식을
dir_names_max_depth: 2에서 4로 올린 뒤였습니다. kube-prometheus-stackHelmRelease는 앱들보다 일주일 늦게 들어왔고,k8s-sidecar:2.5.0이 회귀해서 PR #85에서2.3.0으로 핀했습니다.- 모든 Knative service에
min-scale: "1"을 걸어 700m CPU와 1초 미만 응답을 맞바꿨습니다 (PR #82). - 전부
infra/home-cluster/에 선언적으로 들어 있어, 재구축 스토리는flux reconcile한 번 거리입니다.
홈 클러스터가 재구축 스토리를 정직하게 만든다
이 모든 것보다 먼저 정해 둔 마이그레이션 규칙이 있습니다: 머신을 프로비저닝하고, Kubernetes를 설치하고, 레포를 클론하고, infra를 apply하면, 서비스가 올라온다. 매니지드 컨트롤 플레인은 이 규칙을 깨뜨립니다 — 클라우드 콘솔 클릭은 레포 안에 없으니까요. 홈 네트워크 위 AMD64 박스 한 대면 체스 게임과 친구 셋에게 충분합니다. 대가는 ingress-nginx, local-path 스토리지, letsencrypt-dns issuer, 셀프호스티드 러너를 직접 소유한다는 것입니다. 표면적은 한나절 읽으면 들어오는 크기입니다.
Traefik 템플릿이 NGINX 클러스터와 충돌했다
첫 PR은 일반적인 Traefik k3s 용으로 템플릿화된 매니페스트를 실어 보냈습니다. 그런데 실제 클러스터는 NGINX로 바꿔둔 상태였습니다. 모든 ingress가 다음을 요구했습니다.
kubernetes.io/ingress.class: traefik->spec.ingressClassName: nginxtraefik.ingress.kubernetes.io/router.middlewaresannotation 제거cert-manager.io/cluster-issuer: letsencrypt-prod->letsencrypt-dns(HTTP-01은 홈 박스에 도달 못 함)
두 번째 지뢰는 공유 myapps 네임스페이스였습니다. auth-service와 multiplayer-chess-service가 둘 다 일반적인 이름의 mongodb StatefulSet과 일반적인 Service를 실어 보냈습니다 — 충돌이 보장된 구성이었죠. auth-mongodb와 chess-mongodb로 이름을 바꿨습니다. 배포 스크립트에서는 namespace.yaml도 빼냈습니다 — kubectl apply는 idempotent이지만, 매니페스트의 라벨이 손으로 추가한 라벨을 덮어쓸 수 있었기 때문입니다.
auth.wintersalmon.com과 chess-api.wintersalmon.com의 DNS는 MetalLB VIP 192.168.10.240을 가리키게 했습니다. 그 다음은 kubectl rollout status와 curl -k https://auth.wintersalmon.com/health였습니다.
중첩된 function 경로가 CI 디텍터를 망가뜨렸다
2주 차에 서비스를 깊은 디렉토리 레이아웃의 Knative function으로 분리했습니다.
functions/auth/auth/login
functions/chess/lobby/create-game
functions/chess/game/submit-action
.github/workflows/functions.yml은 dir_names_max_depth: 2를 쓰고 있었고, functions/auth까지만 감지하고 그 이상은 들여다보지 않았습니다. depth를 4로 올리고, awk 기반 path_to_name() 헬퍼를 추가해서 경로를 이미지 이름으로 평탄화했습니다.
functions/auth/auth/login -> func-auth-service-login
functions/chess/lobby/create-game -> func-chess-service-lobby-create-game
더 깊은 함정은 따로 있었습니다. 각 Dockerfile이 COPY functions/_shared/를 하는데, 이는 레포 루트에서만 resolve됩니다. CI는 function 디렉토리에서 docker build를 돌리고 있어서 _shared를 조용히 놓치고 있었습니다. 수정은 37cfd7f — 명시적인 file: 파라미터, context는 레포 루트로 설정했습니다.
_shared는 여전히 디렉토리일 뿐 진짜 workspace 패키지가 아니어서 bun typecheck와 bun test가 @cloudnest/functions-shared를 resolve하지 못합니다. 둘 다 continue-on-error: true로 표시했습니다 (3f3c3a8, ce16e4a). 진짜 검증은 Docker 빌드입니다. 미래의 나의 문제로 미뤘습니다.
모니터링은 일주일 미뤘고, 그 뒤 단일 image tag에 물렸다
패턴은 명확합니다: 일단 프로덕션에 도달하고, 그 다음 기능 추가 전에 observability를 쌓는다. 14개의 Knative function이 라이브가 된 뒤 kube-prometheus-stack(차트 82.x)이 FluxCD HelmRelease로 배포되었고, Grafana는 NGINX basic auth 뒤에서 dashboard.wintersalmon.com에 떴습니다. Prometheus는 30Gi, Grafana는 10Gi를 local-path로, admin 비밀번호는 SOPS로 암호화했습니다. 빌트인 대시보드만으로 클러스터 헬스가 공짜로 커버됐습니다. blackbox probe와 Telegram alert 라우팅은 — 미뤘습니다.
그러던 중 차트가 Grafana의 동적 ConfigMap 기반 대시보드용으로 k8s-sidecar:2.5.0을 끌어왔습니다. 사이드카가 CrashLoopBackOff로 들어갔습니다.
couldn't get current server API group list: Get "http://localhost:8080/api?timeout=32s":
dial tcp [::1]:8080: connect: connection refused
2.5.0 이미지는 in-cluster config 감지를 Go-client 내부 구현으로 바꿨고, service account 토큰 대신 localhost:8080으로 조용히 폴백했습니다. Grafana 자체는 정상이었습니다. RBAC(configmap, secret에 대한 get/watch/list)도 맞았습니다. PR #85에서 tag: "2.3.0"으로 핀했습니다. 다음 sync에서 사이드카가 정상이 됐습니다.
후속 함정 하나: init-chown-data init container가 /var/lib/grafana/csv|pdf|png에서 Permission denied로 죽었습니다. Grafana는 그 디렉토리들을 mode 2740으로 만드는데, init container는 CHOWN을 제외한 모든 cap을 떨어뜨립니다. CAP_DAC_READ_SEARCH 없이는 other 권한이 없는 디렉토리를 traverse할 수 없습니다. 돌아가는 pod 안에서 세 디렉토리에 chmod 755를 걸었더니, 다음 init은 깨끗하게 재시도됐습니다.
min-scale=0이 첫 수를 10초 걸리게 했다
Knative의 기본값은 min-scale: 0 — 5분 idle 뒤 pod가 0으로 스케일됩니다. 잠잠해진 뒤 첫 요청은 컨테이너 시작과 MongoDB 커넥션 셋업 비용을 모두 지불했습니다. 측정값은 10초 이상. 체스 한 수 두는 데에는 못 쓸 수치입니다.
PR #82에서 14개 서비스 전부에 autoscaling.knative.dev/min-scale: "1"을 걸었습니다. 트레이드오프는 교과서적입니다.
| 항목 | 값 |
|---|---|
| 항상 떠 있는 pod | 14 |
| 추가 CPU request | 700m (14 x 50m) |
| 추가 메모리 request | 896Mi (14 x 64Mi) |
| 15개 서비스 정상 상태 | 약 750m CPU, 약 928Mi RAM |
여유가 있는 노드 위 인터랙티브 게임에는 맞는 결정입니다. 배치 잡이라면 틀린 결정이고요.
FluxCD reconciliation은 func-chess-service-lobby-games를 in-place로 업데이트할 때 immutable한 serving.knative.dev/creator annotation에 한 번 걸렸습니다. Knative service를 지우고 FluxCD가 재생성하게 하니 annotation이 풀렸습니다.
이게 가능하게 한 것
4주: GitOps, ingress-nginx, cert-manager DNS-01, myapps의 MongoDB, 14개 Knative function, Prometheus + Grafana, 1초 미만 콜드 스타트. 별난 것은 하나도 없습니다. 전부 infra/home-cluster/에 들어 있습니다. 다음 챕터들 — 스테이징 환경, Bun에서 Go로의 auth API 재작성, LLM 게이트웨이, 이 블로그 — 은 이 베이스를 바꾸지 않고 그 위에 얹힙니다.
AI 워크플로우 메모
이 기간 대부분 동안 키보드 앞에는 Claude가 있었습니다. 제대로 통한 패턴은: 먼저 단계별 플랜을 요청하고, 각 단계마다 task-log 항목을 남기며 한 단계씩 실행하는 것이었습니다. 모니터링 롤아웃은 YAML 한 줄을 쓰기 전에 6단계로 쪼개졌고, 덕분에 blackbox probe 미루기를 한 줄짜리 결정으로 만들 수 있었습니다. architect 에이전트는 letsencrypt-prod vs letsencrypt-dns, min-scale: 0 vs 1 같은 트레이드오프에 유용했습니다 — 한쪽을 그냥 고르는 게 아니라 두 옵션과 각각의 비용을 함께 명명해 줬습니다. Claude가 통째 재작성을 제안했을 때(예: “내가 _shared를 workspace 패키지로 리팩터할게”), 그런 것들은 현재 diff를 키우는 대신 후속 태스크로 미뤘습니다.
