이 글의 위치: 기술 블로그 → 인프라 · 로컬 서버 운영. 작성·검증 방식은 Editorial Policy 참조.
집 PC 한 대로 굴리는 로컬 서버 운영기 4편입니다. 터널로 공개하고 Keycloak으로 로그인을 합친 스택 전체를, 이번엔 실행 기반을 K3s로 옮깁니다. 이관 뒤에 조용히 깨진 운영 작업은 5편에서 다룹니다.
"Compose로 잘 도는데 왜 K3s?"
솔직히 단일 노드에서 Docker Compose는 충분히 잘 돕니다. 그런데 서비스가 열 개를 넘어가니 몇 가지가 아쉬웠습니다 — 컨테이너가 죽으면 스스로 되살아나면 좋겠고(셀프힐링), 배포를 명령형(up/restart)이 아니라 선언형(원하는 상태를 파일로)으로 관리하고 싶었습니다. 그래서 경량 쿠버네티스 K3s로 옮기기로 했습니다. 전면 클라우드가 아니라 집 PC 한 대에 얹는 single-node입니다.
목표는 하나였습니다 — 무손실, 그리고 되돌릴 수 있게.

Compose와 K3s를 나란히 매핑
이관의 첫 단계는 개념을 1:1로 대응시키는 것입니다. Compose의 구성요소는 대부분 K8s 리소스로 자연스럽게 매핑됩니다.
| Docker Compose | Kubernetes(K3s) | 메모 |
|---|---|---|
service |
Deployment + Service | 실행 + 네트워크 노출 |
container_name |
Service 이름 | 여기를 똑같이 두는 게 핵심 |
image(로컬 빌드) |
로컬 이미지 import | 레지스트리 없으면 ctr images import |
env_file |
Secret(--from-env-file) |
값은 그대로 재사용 |
ports |
Service(ClusterIP/NodePort) | 외부 노출은 NodePort |
bind mount(./data) |
hostPath 볼륨 | 기존 데이터 dir 그대로 |
| Compose 네트워크 | 네임스페이스 내 DNS | 짧은 이름 → FQDN 주의(아래) |
가장 이득이 컸던 결정은 "K8s Service 이름을 Compose 컨테이너 이름과 똑같이 짓는다"였습니다. 서비스들이 서로를 http://user-management-service:8082처럼 이름으로 부르는데, 이름을 그대로 두니 기존 환경변수·게이트웨이 설정을 거의 손대지 않아도 됐습니다. 이관 표면적이 확 줄었습니다.
또 하나, 데이터베이스는 기존 bind mount 디렉터리를 hostPath로 직결했습니다. 데이터를 복사하지 않고 그 자리에서 재사용 → 무손실 이관입니다. (이 hostPath를 나중에 표준 PVC로 바꾸는 계획은 마지막 편의 향후 계획에서 다룹니다.)
삽질 3선 (이 글의 진짜 값어치)
매핑은 깔끔했지만, 실제로 올리자 세 곳에서 걸렸습니다. 전부 "경계"에서 터진 문제였습니다.
① 이미지가 사라진다 — 저장소 위치
로컬 빌드 이미지 11개를 K3s에 넣었는데, 잠시 뒤 대부분이 ErrImageNeverPull로 죽었습니다. 원인은 K3s 데이터 디렉터리가 9GB짜리 작은 파티션(/var)에 있었던 것입니다. 이미지들이 공간에 못 버티고 사라졌습니다.
→ K3s를 큰 디스크로 재설치하니 해결됐습니다.
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--data-dir /data/k3s ..." sh -
교훈: 컨테이너 런타임 저장소가 어느 파티션에 있는지부터 확인해야 합니다.
② nginx가 서비스 이름을 못 푼다 — DNS
게이트웨이(nginx)가 502를 냈습니다. 로그엔 web-front could not be resolved. Docker의 내장 DNS는 짧은 이름(web-front)을 풀어주지만, 쿠버네티스의 CoreDNS는 nginx resolver 지시자에서 FQDN을 요구합니다.
# Before (Docker): proxy_pass http://admin-front:3000;
# After (K8s): proxy_pass http://admin-front.<namespace>.svc.cluster.local:3000;
교훈: Docker DNS와 K8s DNS의 이름 해석 규칙이 다릅니다. 짧은 이름에 기대면 안 됩니다.
③ 환경변수에 붙은 주석 — 파싱 차이
한 서비스가 gin mode unknown: debug로 크래시했습니다. debug는 분명 유효한 값인데 말이죠. .env 파일에 이렇게 적혀 있었습니다.
GIN_MODE=debug # debug, release, test
Docker Compose는 이 인라인 주석을 떼지만, kubectl create secret --from-env-file은 통째로 저장합니다. 값이 debug # ...가 돼 버려 크래시가 났습니다. 시크릿 생성 때 주석을 제거하도록 고쳤습니다.
(+ 게이트웨이 헬스체크가 특정 Host 헤더에서만 200을 주도록 돼 있어, 쿠버네티스 readiness 프로브에 Host 헤더를 붙여야 했습니다. 네 번째 경계였습니다.)
컷오버와 검증
병행 구동은 두 스택이 서로 다른 네트워크라 까다로워서, 다운타임을 감수하고 한 번에 컷오버했습니다 — Compose 중지 → K3s 적용 → 마지막에 터널을 K3s 게이트웨이(NodePort)로 재지정. Postgres는 hostPath로 기존 데이터를 그대로 붙였으니 데이터는 그대로였습니다(사용자 수·DB 그대로 확인). 문제가 생기면 Compose를 다시 올리는 즉시 롤백 경로도 열어뒀습니다.
결과는 응답 코드로 확인했습니다. 전체 Pod Ready, 외부 도메인 전부 200, 로그인→토큰→관리 API까지 통과했습니다.

정리
- 단일 노드라도 K3s의 선언형 배포·셀프힐링은 값어치가 있습니다.
- Service 이름을 컨테이너 이름과 동일하게 두면 기존 env·게이트웨이를 재사용해 이관 표면적이 줄어듭니다. DB는 hostPath로 데이터 무손실입니다.
- 쿠버네티스의 함정은 대부분 저장소·DNS·환경변수 파싱 같은 경계에서 나옵니다.
- 병행이 어려우면 다운타임 컷오버 + 롤백 경로 확보가 현실적입니다.
다음 편에서는 이관이 끝난 뒤의 이야기를 다룹니다. 인프라를 바꾸면 거기 매달린 운영 작업도 조용히 깨집니다 — docker exec로 돌던 백업 cron이 멈춘 사건입니다: 5편.
참고
- K3s 공식 문서(Installation /
--data-dir) · docs.k3s.io - Kubernetes DNS for Services(FQDN 규칙) · kubernetes.io/docs/concepts/services-networking/dns-pod-service
- 같은 시리즈 — 집 PC를 공개 서버로 · 이관 뒤가 진짜다: 백업 cron
답글 남기기