개인 플랫폼 — 모노레포와 k3d 위의 GitOps

플랫폼Internal2026-04 ~ 운영 중클러스터 이관까지 2일

STACKk3d(k3s) · ArgoCD · Helm · sealed-secrets · traefik · nginx · kube-prometheus-stack · Loki · Alloy · GitHub Actions · docker compose

개정 이력

  1. 출처에 본문이 참조한 오픈소스의 GitHub 저장소 링크를 추가했다.
  2. 첫 공개. 모노레포 배포 스크립트와 platform 저장소의 커밋 본문, 사용자 구술을 근거로 작성하고 아키텍처 그림을 추가했다.

시작

AI 를 쓰면서 사이드 프로젝트를 만드는 속도가 달라졌다. 하루에도 몇 번씩 만들고 부수기를 반복했고, 그중 어떤 게 끝까지 갈지는 만들어 보기 전엔 알 수 없었다. 그런 프로젝트마다 서버를 따로 잡고 인프라 비용을 내는 건 번거로웠다. 그래서 앱 코드를 모노레포 하나에 모으고, 집의 Mac Studio 한 대에 전부 올리기로 했다. 끝까지 가서 출시하고 매출이 생긴 앱만 "졸업" 시켜 따로 빼면 된다는 생각이었다.

처음 배포는 단순했다. 2026년 4월 25일, Mac 에 GitHub Actions 셀프 호스티드 러너를 붙이고 docker compose 와 배포 스크립트 하나로 시작했다. 스크립트는 점점 일을 떠안았다. 6월에는 API 를 띄우기 전에 마이그레이션을 돌리게 했고, 앱의 도메인을 읽어 nginx 가상 호스트를 만들고 인증서까지 발급하게 했다. 새 앱을 올리는 비용은 거의 없어졌다.

앱이 늘면서 문제가 터지기 전에 풀어 두고 싶은 것이 세 가지 생겼다. 첫째, 한 호스트의 컨테이너들이 자원을 나눠 쓰는 이상 한 앱이 무거워지면 옆 앱까지 흔들릴 수 있었다. 실제로 그런 일이 나기 전에 앱마다 상한을 걸어 두고 싶었다. 둘째, 운영하려면 모니터링이 필요한데, 앱을 열 때마다 Grafana 같은 모듈을 앱별로 다시 만들고 싶지 않았다. 셋째, 언젠가 AWS 로 넘어갈 때를 생각하면, 지금 한 번 제대로 설정해 두는 편이 나중에 편할 거라고 봤다.

무엇을 만들었나

저장소를 둘로 나눴다. sandbox 모노레포에는 앱 코드와 이미지 빌드가 있고, platform 저장소에는 "클러스터에 떠 있어야 하는 것 전부" 가 있다. 원칙은 README 첫 줄에 적어 둔 대로 "Git 이 원본, 클러스터는 캐시" 다. 클러스터를 지우고 다시 세워도 platform 저장소를 읽어 같은 상태로 돌아와야 한다.

새 앱을 올리는 일은 파일 하나를 추가하는 것이 됐다. apps/<앱>/<환경>.yaml 에 이미지, 포트, 헬스 체크 경로, 호스트, 설정값, 봉인한 비밀값, 자원 상한을 적어 커밋하면, ArgoCD 가 그 파일을 보고 네임스페이스와 배포를 만든다. 모니터링은 클러스터에 한 번 깔려 있어서 새 앱의 지표와 로그가 따로 설정하지 않아도 Grafana 에 모인다. 이미지는 모노레포의 GitHub Actions 가 빌드해, dev 는 자동으로 태그를 올리고 운영은 dev 에서 확인한 이미지를 승격 커밋으로 올린다.

지금은 fika, 약국통, Kaneo, 이 블로그, 홈페이지 두 개와 챗봇·주차 관리 API 가 이 경로로 돌고 있다. fika 는 dev 와 운영 환경을 따로 둔다.

스택 — 가볍게 시작하되 옮길 수 있게

클러스터는 k3d 로 세웠다. Docker Desktop 안에서 k3s 를 돌리는 도구라, 이미 같은 Mac 에서 돌고 있던 compose 앱과 정문 nginx 를 건드리지 않고 옆에 올릴 수 있었다. 80·443 은 정문 nginx 가 쥐고 있으니 클러스터는 8080 으로 받고, 노드 메모리에는 상한을 걸어 클러스터가 기존 운영 컨테이너를 밀어내지 못하게 했다. 옮기는 동안 두 방식이 한 대에서 같이 돌아야 했기 때문이다.

그러면서도 쿠버네티스라는 점이 중요했다. 배포는 Helm 차트와 ArgoCD 로, 비밀은 sealed-secrets 로, 모니터링은 kube-prometheus-stack·Loki·Alloy 로 구성했다. 전부 관리형 쿠버네티스에서도 그대로 쓰는 도구다. 저장소도 그걸 전제로 나눴다. 클러스터를 세우는 절차는 cluster/ 에만 두고, 지금은 k3d 스크립트지만 나중에는 Terraform 이 들어갈 자리다. 클라우드로 옮길 때는 이 디렉터리만 바꾸고 나머지는 그대로 가져가는 구조다.

아키텍처 — 사람이 세우는 층과 ArgoCD 가 맞추는 층

platform 저장소는 층을 나눴다. cluster/ 는 사람이 직접 실행하는 층이다. 클러스터를 만들고 ArgoCD 와 sealed-secrets 를 설치하는 스크립트, 그리고 클러스터 밖에서 도는 PostgreSQL·MinIO·Redis 가 여기 있다. system/ 부터는 ArgoCD 가 읽는다. 부트스트랩 때 한 번만 손으로 적용하면 그다음부터는 ArgoCD 가 자기 설정까지 관리한다. 그 아래 apps/ 가 앱 환경별 파일이고, charts/web-service 가 모든 앱이 쓰는 공용 차트다.

요청은 정문 nginx 에서 TLS 를 풀고, 클러스터의 traefik 이 호스트와 경로를 보고 앱으로 보낸다. 상태를 가진 것은 클러스터 밖 compose 로 두었다. 클러스터를 새로 세워도 데이터는 그대로 남아야 하고, 단일 노드에서 스토리지를 클러스터 안에 넣어 얻는 것이 없었다. PostgreSQL 은 운영과 dev 인스턴스를 물리적으로 나눴다.

개인 플랫폼 아키텍처 — 요청은 Mac Studio 의 nginx 에서 k3d 클러스터의 traefik 을 거쳐 앱으로 가고, 상태는 클러스터 밖 PostgreSQL·MinIO 에 둔다. 모노레포가 이미지를 빌드해 platform 저장소에 태그를 커밋하면 ArgoCD 가 클러스터를 맞춘다.
앱 코드는 모노레포, 떠 있어야 할 상태는 platform 저장소에 있다. ArgoCD 가 platform 저장소를 읽어 앱마다 네임스페이스를 만들고, 관측 스택은 모든 앱이 함께 쓴다.

앱 하나는 파일 하나

ArgoCD 의 ApplicationSet 을 "앱 공장" 으로 썼다. apps/ 아래 파일이 하나 생기면 같은 이름의 네임스페이스와 Application 이 생기고, 파일을 지우면 사라진다. 설정값은 세 층으로 겹친다. 전역 공용값, 환경별 공용값(dev·운영), 앱 파일 순서이고 뒤가 이긴다. DB 주소나 자원 상한처럼 환경마다 같은 값은 앱 파일에 쓰지 않는다.

공용 차트는 처음부터 크게 만들지 않았다. 헬스 체크가 있는 HTTP 서비스 하나에서 시작해, 앱이 들어오며 필요해진 것만 옵션으로 늘렸다. fika 는 웹과 API 가 한 호스트를 경로로 나눠 써서 경로 분할과 접두사 제거가 들어갔다. 약국통의 공공 데이터 수집 배치는 CronJob 이 됐고, 처음에는 배치가 성공하면 API 를 다시 띄우는 재시작 컨테이너까지 붙였다. 9월 1일에는 약가 배치의 재시작을 걷어내고, 워크로드를 바꾸기 전에 마이그레이션을 먼저 돌리는 훅을 차트에 넣었다.

비밀값은 봉인해서 Git 에

Git 이 원본이라면 비밀값도 Git 에 있어야 클러스터를 다시 세울 수 있다. 그렇다고 원문을 올릴 수는 없다. 그래서 원본과 봉인본을 나눴다. 봉인은 클러스터의 공개키로 잠그는 일이라, 봉인된 값은 클러스터 안의 sealed-secrets 만 풀 수 있고 사람은 다시 열어 볼 수 없다. 값을 하나 바꾸거나 새로 넣으려면 원본이 따로 있어야 한다. 원본은 Bitwarden 에 앱과 환경별로 저장하고, 스크립트가 그 원본을 읽어 봉인한 뒤 앱 파일의 비밀값 블록을 통째로 다시 만든다. Git 의 봉인본을 부분 수정하지 않고 매번 원본에서 새로 만드는 이유다. 봉인을 푸는 클러스터의 개인키도 설치 때 Bitwarden 에 백업해 두었다. 클러스터를 새로 세워도 같은 키를 넣으면 Git 의 봉인본이 그대로 풀린다.

옮기는 이틀

이관은 8월 16일과 17일 이틀에 끝냈다. 첫날은 저장소 골격을 세우고, fika API 의 dev 환경 하나만 "플랫폼 검증용" 으로 올렸다. 둘째 날 아침에 fika 운영 환경을 클러스터에 올렸는데, 이때는 정문이 아직 compose 를 가리키고 있어 트래픽이 없었다. 확인이 끝난 오후에 정문 nginx 의 fika 설정을 traefik 으로 돌렸다. 커밋 본문에는 롤백 방법을 한 줄로 적었다. 이 커밋을 되돌리고 nginx 를 다시 읽으면 된다.

fika 가 넘어가자 compose 에서 fika 를 뺐고, 같은 날 나머지 앱 여섯 개를 옮겼다. 배포 스크립트는 클러스터로 간 앱을 건너뛰고, 그 앱에 자동으로 만들던 가상 호스트는 정리하게 고쳤다. 마지막으로 배포 워크플로를 dev 와 운영으로 나눴다.

운영하며 겪은 일

메모리 상한에 약국통이 걸렸다. 이관 당일 약국통 API 는 뜬 직후 JavaScript 힙이 모자라 죽었고, 약가 배치는 컨테이너 메모리 제한에 걸려 강제 종료됐다. API 에 Node 힙 상한을 명시하고 컨테이너 메모리를 올렸다. 그러자 이번엔 노드 상한이 모자랐다. 배치가 최대 4GiB, 앱이 3GiB 를 같이 쓰는 순간을 계산해 노드 상한도 6GiB 에서 10GiB 로 올렸다.

옮긴 비밀값이 깨졌다. compose 시절 배포 스크립트는 값을 셸 이스케이프해서 저장했다. 이걸 그대로 봉인해 옮기니 백슬래시가 값에 섞여 앱이 형식 오류를 냈다. 이스케이프를 걷어내고 다시 봉인했다. 비슷한 일로, Grafana 관리자 비밀번호를 빈 값으로 넘겨 로그인이 막혔고, 배치 뒤 재시작에 쓴 kubectl 이미지에 셸이 없어 명령이 돌지 않았다.

운영이 멈춘 날. 9월 7일, fika API 가 없는 컬럼을 조회하며 멈췄다. compose 시절 배포 스크립트는 API 를 띄우기 전에 마이그레이션을 돌렸는데, 클러스터로 옮기며 그 단계가 빠져 있었다. 공용 차트에는 이미 마이그레이션 훅이 있었다. 9월 1일 약국통 때문에 넣은 것으로, ArgoCD 가 워크로드를 바꾸기 전에 마이그레이션 Job 을 돌리고 실패하면 배포가 넘어가지 않는다. fika 운영 환경에서 그 훅을 켜지 않았던 것이다. dev 에서 먼저 켜 확인한 뒤 운영에도 켰다.

비밀값과 마이그레이션, 두 사고는 원인이 같다. 옛 배포 스크립트가 말없이 해 주던 일, 즉 값을 어떤 형식으로 저장하는지와 마이그레이션을 언제 돌리는지를 옮길 목록에 넣지 않았다.

돌아보며

처음 세운 목표는 이뤘다. 만들고 부수는 속도에도 맞는다. 앱을 올리는 것도 파일 하나를 커밋하는 일이고, 치우는 것도 그 파일을 지우는 일이다. 이관 뒤에 들어온 Kaneo 와 이 블로그는 앱 파일 하나와 이미지 태그로 올라갔고, 모니터링은 따로 붙이지 않았다. 앱마다 자원 상한이 있어 한 앱이 무거워져도 그 앱의 상한 안에서 끝난다.

아쉬운 건 옮기기 전에 배포 스크립트가 하던 일을 목록으로 뽑지 않은 것이다. 그 목록이 있었으면 fika 운영 환경에 마이그레이션 훅을 켜는 일을 놓치지 않았을 것이다. 졸업한 앱은 아직 없다. 졸업할 때는 cluster/ 의 클러스터를 세우는 부분을 클라우드용으로 바꾸고 인스턴스를 옮기면 된다. 앱 파일과 공용 차트, 봉인 절차는 그대로 가져간다. 그때까지는 모든 앱이 Mac 한 대, 노드 하나에 있다는 점을 안고 간다. DB 를 클러스터 안에 둘지 밖에 둘지는 옮길 때 한 번에 정하기로 했다.

출처

  • sandbox 모노레포 infra/scripts/deploy.sh, .github/workflows/deploy*.yml 의 커밋 본문(2026-04-25 ~ 08-17).
  • platform 저장소 README, cluster/bootstrap/cluster.yaml, system/appset-apps.yaml, charts/web-service, platform apps/<앱>/<환경>.yaml 과 커밋 본문(2026-08-16 ~ ).
  • 옮긴 이유(자원 간섭 예방, 공용 모듈, AWS 이전 대비)와 비밀값 원본 관리 방식은 사용자 구술과 platform scripts/seal.sh.
  • 참조한 오픈소스(GitHub): k3s · k3d · Argo CD · Helm · sealed-secrets · Traefik · nginx · kube-prometheus-stack · Prometheus · Grafana · Loki · Alloy · Docker Compose · PostgreSQL · MinIO · Redis.
  • 비밀값, 계정 이름, 운영 호스트의 접속 정보와 DB 포트는 싣지 않는다.