이 프로젝트를 시작한 게 벌써 두 달 전이네요. 만들 때는 노트를 따로 남기지 않았던 터라, 이제 와서 한 번쯤은 정리해 두는 편이 좋겠다 싶어 이 글을 씁니다.
LLM 덕분에 생산성이 올라가고 만들어보고 싶은 게 많아졌습니다. 하지만 사람들에게 공개하기 위해 클라우드 비용을 매번 지불하는 건 쉬운 일이 아니었습니다. 그런 생각으로 Mac Mini M4를 구매했는데요, 최근 OpenClaw 열풍과도 겹쳐 많은 사람들이 Mac Mini를 구매한 터라 이 프로젝트가 다른 사람들에게도 수요가 있을 것이라는 생각이 듭니다.
프로젝트의 목적은 Mac Mini를 "개인 클라우드"로 만들어보자는 겁니다. 클러스터, 게이트웨이, 관측성, 관리를 모두 UI에서 다루고, 깃 URL 하나로 프론트와 서버를 함께 배포해 지정한 URL에서 서비스가 응답하는 - 그런 상상으로 시작했습니다.

만들면서 풀어야 했던 문제는 크게 여섯 가지였습니다.
- macOS 위에서 어떻게 Kubernetes를 가볍게 띄울 것인가
- 공인 IP, 포트포워딩 없이 어떻게 외부에서 접근하게 만들 것인가
- 모든 트래픽 앞단의 보안, 제어를 어떻게 둘 것인가
- "깃 URL을 붙여 넣었더니 배포되더라" 를 어떻게 진짜로 만들 것인가
- 무엇이 돌아가고 무엇이 죽었는지 어떻게 한눈에 볼 것인가
- 이 모든 걸 터미널 없이 어떻게 관리하게 만들 것인가
1. macOS 위에서 Kubernetes를 띄우기
Mac에서 Kubernetes를 돌리는 표준 경로는 Docker Desktop의 내장 클러스터입니다. 다만 Docker Desktop은 그 자체로 메모리를 꽤 먹습니다. 그렇다고 minikube나 kubeadm으로 풀 K8s를 띄우는 건 단일 노드 홈서버 용도로는 과합니다 - etcd, kube-apiserver, kube-controller-manager, kube-scheduler가 각각 별도 프로세스로 자리를 차지합니다.
결정: Colima + k3s
Colima는 macOS 위에 Lima 기반 Linux VM을 띄워주는 도구입니다. Docker Desktop의 대안이지만 훨씬 가볍고, Kubernetes 런타임으로 k3s를 옵션으로 선택할 수 있습니다.
k3s는 Rancher Labs가 만든 경량 Kubernetes 배포판입니다. 핵심 컨트롤 플레인을 단일 바이너리로 묶고, etcd 대신 SQLite를 기본 저장소로 사용해 단일 노드 환경에서 메모리, 디스크 풋프린트를 크게 줄였습니다. 단일 노드 홈서버라는 제약에 정확히 맞는 선택이었습니다.
colima start --cpu 4 --memory 8 --disk 60 --kubernetes --runtime docker
VM에 4 CPU / 8GB / 60GB를 할당하고 그 위에서 k3s가 돌아갑니다. M4의 14코어, 36GB 메모리에서 절반 이상이 호스트 macOS 작업용으로 남고, 나머지로 클러스터가 충분히 돕니다.
2. 공인 IP 없이 외부로 노출하기
집에서 서비스를 외부로 노출시키려면 두 가지 벽을 넘어야 합니다. 하나는 ISP가 주는 IP가 보통 동적이라는 점, 다른 하나는 공유기 뒤에 있어 포트포워딩 설정이 필요하다는 점입니다. DDNS와 포트포워딩으로 풀 수도 있지만, ISP가 80/443 포트를 막아두는 경우가 많고, 무엇보다 가정용 회선의 공인 IP를 인터넷에 직접 노출하는 건 보안적으로 좋은 선택이 아닙니다.
결정: Cloudflare Tunnel
Cloudflare Tunnel(cloudflared)은 클러스터 안쪽에서 Cloudflare의 엣지로 아웃바운드 연결을 먼저 만들어두고, 그 터널 위로 트래픽을 역방향으로 흘려보내는 방식입니다. 인바운드 포트를 단 하나도 열지 않고도 외부 트래픽을 받을 수 있습니다.
mermaid 로드 중…
cloudflared를 K8s Pod로 띄워 두면, 도메인 한 개만 Cloudflare에 등록해 둔 채로 서브도메인이 모두 이 터널로 흘러들어옵니다. 공유기 설정은 건드리지 않습니다.
세팅 과정에서 사용자 친화성을 위해 마지막에 한 단계를 추가했습니다. 브라우저 위저드. 처음 ./setup.sh를 돌리면 로컬에 작은 웹 서버가 떠서 브라우저로 띄워줍니다. 거기서 도메인을 입력하고 Cloudflare에 로그인하면 터널이 자동으로 생성되고 DNS 레코드까지 박힙니다. 터미널에서 cloudflared tunnel route dns ... 같은 명령을 외울 필요가 없도록 만들고 싶었습니다.
3. 게이트웨이
레이트 리미트, CORS, IP 차단, 봇 필터, 인증 등 외부 트래픽을 받는 서비스라면 반드시 필요한 기능들입니다. 모든 트래픽이 공통으로 지나가는 단일 지점에 게이트웨이를 구성하기로 했습니다.
결정: Kong Gateway (DB-less)
Kong은 OpenResty(nginx + LuaJIT) 위에 만들어진 API 게이트웨이입니다. DB-less 모드로 띄우면 외부 DB 없이 K8s ConfigMap에 선언적으로 정책을 정의할 수 있고, 그 정책을 GitOps 흐름에 자연스럽게 태울 수 있습니다.
기본적으로 켜둔 글로벌 플러그인은 다음과 같습니다.
| 플러그인 | 정책 |
|---|---|
| Rate Limiting | 60 req/min, 1000 req/hour (IP 기준) |
| CORS | 도메인별 허용 도메인 화이트리스트 |
| Request Size Limiting | 10MB |
| Bot Detection | 알려진 봇 UA 차단 |
이 위에 서비스별로 추가 정책을 얹을 수 있도록 설계했습니다. 예를 들어 어떤 서비스는 API Key 인증을, 다른 서비스는 JWT 인증을 요구하도록 - 정책은 모두 K8s 매니페스트로 표현되고, Admin UI에서는 그것을 사람이 읽을 수 있는 토글로 노출합니다.

서비스 상세 페이지. 라우트, Pod 상태, API 문서, 정책 토글이 한 화면에 모인다.
4. 깃 URL 하나로 배포되게 만들기
문제: "그냥 코드만 있으면 돌게" 가 생각보다 어렵다
가장 욕심을 부린 부분이 이 영역입니다. Heroku나 Vercel처럼 "깃 URL 하나면 끝" 이라는 경험을, 본인 Mac 위에서 동일하게 만들고 싶었습니다. 그러려면 다음을 모두 처리해야 합니다.
- Dockerfile이 있으면 그걸 쓰고
- 없으면
docker-compose.yaml을 보고 - 그것도 없으면 언어를 자동으로 감지해 빌드하고 (Nixpacks)
- 모노레포라면 프론트엔드와 백엔드를 각각 빌드해 연결하고
- 같은 커밋이면 캐시로 즉시 다시 띄우고
- 실패하면 이전 버전으로 자동 롤백한다
결정: 계층적 자동 감지 + Nixpacks fallback + 모노레포 빌더
배포 트리거는 단순합니다. Admin UI에서 깃 URL을 붙여 넣으면 백엔드가 클론, 분석, 빌드, 배포, 라우트 등록을 순서대로 수행합니다.
mermaid 로드 중…
이 흐름에서 가장 손이 많이 갔던 곳은 모노레포 처리 였습니다. 프론트엔드 디렉토리와 백엔드 디렉토리가 한 레포에 함께 있는 경우, 둘을 별도의 서비스로 띄우되 환경 변수로 서로의 URL을 자동 주입해야 합니다. 예를 들어 프론트엔드 코드는 빌드 타임에 NEXT_PUBLIC_API_URL이 필요하고, 그 값은 같은 배포 안에서 함께 띄우는 백엔드의 라우트여야 합니다.
여기에 더해, 라이브러리 디렉토리(packages/utils 같이 자체 서비스가 아닌 공유 코드)는 빌드 대상에서 제외해야 하고, Django 같이 진입점이 명시적이지 않은 프레임워크는 자동 감지가 필요하며, 형제 디렉토리에 흩어진 의존성은 적절히 병합해야 합니다. 이 로직만 모은 빌더가 admin-ui/main.go 안의 모노레포 핸들러로 자리 잡고 있고, 실제 배포가 늘어날 때마다 케이스가 하나씩 늘어나는 영역이기도 합니다.

Deploy 탭. 깃 URL을 붙여 넣으면 자동 감지된 빌드 전략이 미리 표시되고, 모노레포라면 하위 서비스들이 함께 나열된다.
캐시는 단순한 룰입니다. 동일한 커밋 SHA로 다시 배포 요청이 들어오면 빌드를 건너뛰고 기존 이미지를 가리키는 새 Deployment로 즉시 롤아웃 합니다. 푸시-투-배포 흐름에서, 같은 커밋을 두 번 트리거하는 일은 의외로 흔하고, 그때마다 빌드 전체를 다시 도는 건 낭비입니다.
5. Observability
서비스가 떠 있다는 것과 "건강하게 떠 있다"는 다른 이야기입니다. CPU, 메모리, 디스크가 한도 안에 있는지, Pod이 CrashLoopBackOff 상태에 빠지지 않았는지, 어떤 서비스가 실제로 트래픽을 받고 있는지, 누가 어디서 접속하고 있는지. 이 모든 게 한 화면에서 보여야 합니다.
결정: Prometheus + Grafana + Loki + 자체 분석
표준 스택을 그대로 갖다 썼습니다 - kube-prometheus-stack Helm 차트로 Prometheus, Grafana, Alertmanager를 한 번에 설치하고, 그 위에 Loki + Promtail로 컨테이너 로그를 집계합니다.
| 컴포넌트 | 역할 |
|---|---|
| Prometheus | 메트릭 수집 (Node Exporter, kube-state-metrics, Kong, 서비스별 커스텀) |
| Grafana | 시각화 (Node Exporter, K8s Cluster, Kong 대시보드 자동 프로비저닝) |
| Alertmanager | 알림 (Discord / Email - CPU 80%, 메모리 85%, 디스크 80%, CrashLoop) |
| Loki | 로그 집계 |
| Gatus | 외부 시점의 상태 페이지 (status.domain.com) |
여기에 한 가지를 더 얹었습니다. 서비스별 트래픽 분석. Kong이 흘려보내는 모든 요청을 가로채 IP, 국가, 브라우저, 경로, 응답 시간을 집계합니다. 쿠키나 추적 스크립트 없이, 게이트웨이 레이어에서 서버 사이드로만 수집됩니다.

Analytics 탭. 서비스별 RPS, 응답 시간, 에러율과 함께 방문자 메타(국가, 브라우저, 경로)가 시각화된다. 쿠키 없이 게이트웨이 로그를 서버 사이드에서 집계한다.
이 화면에서 의심스러운 IP나 국가를 발견하면, 같은 화면에서 한 번의 클릭으로 IP/CIDR 또는 국가 코드를 차단할 수 있도록 만들었습니다. "이상 트래픽 발견 → 차단" 까지의 거리가 마우스 한 번이라는 점이 핵심이었습니다.

방문자 한 명을 펼친 상세 뷰. IP 차단, 국가 차단, 요청 기록 조회가 같은 화면에서 가능하다.
6. CLI 없이 모든 것을 관리하기
홈서버를 운영하면서 가장 자주 마주치는 작업은 의외로 작은 것들입니다. 서비스 재시작, 로그 한 줄 확인, 레이트 리밋 조정, 도메인 추가, 새 환경 변수. 이런 일들마다 터미널을 열고 kubectl edit ...을 하는 건 너무 번거롭습니다.
결정: Go 단일 바이너리 + 임베드된 SPA - admin-ui
Admin UI를 처음 설계할 때 두 가지 제약을 두었습니다. (1) 단일 바이너리로 떨어질 것, (2) 클러스터 안에서 돌 것. 첫 번째는 배포, 업데이트의 단순함을 위한 결정이고, 두 번째는 클러스터 권한을 쓰기 위해 in-cluster 서비스 어카운트를 그대로 활용하기 위한 결정이었습니다.
구현은 Go로, 정적 자산(HTML/JS/CSS)은 embed.FS로 바이너리에 함께 묶었습니다. K8s API는 client-go로, Kong Admin API는 표준 라이브러리의 HTTP 클라이언트로 직접 호출하고, 빌드는 Colima 안의 Docker 데몬에 위임합니다. 페이지는 SPA지만 프레임워크 없이 작성되어 있어, 한 파일 안에서 모든 화면 전이가 일어납니다.

Service Map. 배포된 서비스들의 상호 호출 관계와 헬스 상태가 한 화면에 그려진다.
기능적으로는 다음을 한 화면에서 모두 다룹니다.
- Deploy: 깃 URL → 빌드 전략 자동 감지 → 배포
- Services: 시작/정지/스케일, Pod 상태, 라우트, 환경 변수
- Analytics: 트래픽, 방문자, 에러율, 국가별 분포
- Protect: 인증, IP/국가 차단, 레이트 리밋, 유지보수 모드
- Monitor: 리소스, 알림, 헬스체크
- Settings: 도메인, VM 리소스, 언어 (en/ko/ja/zh/es)

Settings 탭. 도메인 연결, Colima VM 리소스 변경, UI 언어 전환이 한 곳에서 이루어진다.
부록: 기술 스택과 역할
인프라
| 컴포넌트 | 역할 |
|---|---|
| Colima | macOS 위에 Lima 기반 Linux VM. 4 CPU / 8GB / 60GB. |
| k3s | 경량 Kubernetes. 단일 노드, SQLite 백엔드. |
| Cloudflare Tunnel | 아웃바운드 터널로 공유기 설정 없이 외부 노출. |
| cert-manager | Let's Encrypt 기반 TLS 인증서 자동 발급. |
| Nginx Ingress Controller | 도메인 기반 라우팅, TLS 종료. |
게이트웨이
| 컴포넌트 | 역할 |
|---|---|
| Kong (DB-less) | API 게이트웨이. ConfigMap 기반 선언적 정책. |
| Rate Limiting / CORS / Bot Detection / Request Size | 글로벌 플러그인. |
| JWT / API Key / IP Restriction | 서비스별 적용 플러그인. |
| Consumer 그룹별 레이트 리밋 | 익명 vs 인증 사용자 우대 정책. |
빌드, 배포
| 컴포넌트 | 역할 |
|---|---|
| Docker | 이미지 빌드 런타임 (Colima 내). |
| Nixpacks | Dockerfile 없는 레포 대상 자동 빌드 (20+ 언어). |
| Monorepo 빌더 (자체) | 라이브러리 필터링, Django 자동 감지, 형제 의존성 병합. |
| ArgoCD | GitOps 동기화 (선택). |
| GitHub Actions 템플릿 | GHCR 빌드/푸시 파이프라인. |
관측성
| 컴포넌트 | 역할 |
|---|---|
| Prometheus | 메트릭 수집 (Node/Pod/Kong). |
| Grafana | 시각화 + Alerting (Discord/Email). |
| Loki + Promtail | 컨테이너 로그 집계. |
| Gatus | 외부 시점 상태 페이지. |
| 자체 분석 모듈 | Kong 로그 기반 트래픽, 방문자 분석 (서버 사이드, 쿠키 없음). |
관리 UI
| 컴포넌트 | 역할 |
|---|---|
Go + client-go | 단일 바이너리 백엔드. K8s API in-cluster 호출. |
| 임베드된 SPA (HTML/JS) | 프레임워크 없는 단일 파일 SPA. |
| i18n | 5개 언어 (en / ko / ja / zh / es). |
| 자체 업데이트 | 이미지 태그 교체 → 응답 후 Pod 재시작. |