Homelab — 홈서버 + OCI IaC
인바운드 포트 0과 VPN-only 관리, 코드로 정의된 인프라를 동시에 만족시키도록 설계한 1인 인프라
기술 스택
개요
집에서 운용 중인 Proxmox VE 하이퍼바이저 위에 pfSense(라우터·방화벽)와 포트폴리오·블로그를 비롯한 서비스 컨테이너를 함께 올리고, OCI 위에는 Terraform으로 코드화한 보조 노드(Ansible Semaphore가 도는 control plane)를 두어 홈서버의 형상을 원격에서 관리하는 1인 인프라입니다. 홈서버는 외부 공개 서비스를 담고, OCI 노드는 그 서버를 코드로 관리합니다.
이 구성을 운영하면서 인바운드 포트 0 / VPN-only 관리 / 코드로 정의된 인프라 세 축을 동시에 만족시키는 것을 목표로 잡았고, 보안을 위해 자동화를 희생하거나 자동화를 위해 결정성을 무르는 흔한 트레이드오프 대신, 세 축이 서로 양보하지 않게끔 설계 결정마다 근거를 두고 쌓아 올렸습니다.
기술 스택
- 홈서버: Proxmox VE, pfSense, Cloudflare Tunnel(
cloudflared) - 클라우드: OCI(VM.Standard.A1.Flex 4 OCPU/24GB ARM), Terraform 6.x(
oracle/oci), S3-호환 state 백엔드 - 자동화·연결: Ansible Semaphore, WireGuard, Docker, GitHub Actions, Watchtower
역할
설계·운영 모두 1인. 보안 모델 결정, OCI Terraform 모듈 구성, Semaphore–WireGuard 통합, GitHub Actions 파이프라인, race·ordering 같은 운영 이슈 대응까지 직접 코드와 워크플로우에 반영했습니다.
주요 기여
-
인바운드 포트 0을 단일 운영 원칙으로 고정.
- 외부 공개 서비스는 Cloudflare Tunnel 아웃바운드만으로, OCI 서브넷은 ingress 룰을 비워서, CI/CD는 홈서버가 GHCR을 폴링하는 방식으로 — 노출·관리·배포 세 갈래가 모두 같은 원칙에서 파생되게 정렬했습니다. 외부 회선 의존이 커지는 트레이드오프는 받아들이는 쪽으로 결정했고, 회고는 블로그에 정리해 두었습니다.
-
OCI 접근은 stateful 방화벽 + WireGuard 단일 UDP 세션으로 제한.
- inbound 룰이 비어 있어도 stateful 추적이 핸드셰이크 이후의 응답 트래픽을 통과시키고, 25초 keep-alive로 NAT 세션이 끊기지 않게 유지합니다. Tailscale 같은 외부 mesh SaaS 대신 WireGuard를 직접 운영한 이유는 “제어 평면이 외부 SaaS에 있지 않을 것”이라는 원칙 때문입니다.
-
재생성 가능한 정의가 source of truth — 부팅 스크립트 안에 ordering 가드까지 코드로 박은 결정성.
- SSH는 열지 않고 장애 시 Terraform으로 VM을 재생성하며, 응급 진입은 OCI Serial Console + 첫 부팅 시 설정한 비밀번호로 의도적으로 백업합니다. 이 모델이 “재생성할 때마다 같은 결과”를 전제하기 때문에 첫 부팅의 비결정성을 흡수하는 ordering 가드를 부팅 스크립트 안에 명시했습니다 — (1) 시스템 자동 업데이트가 잡고 있는 패키지 매니저 락을 흡수하는 재시도 헬퍼(60회·10초 간격), (2) WireGuard가 IP를 할당하기 전 Docker가 바인딩하려다 실패하는 race를 막는 60초 polling 루프. 단발성 패치가 아니라 코드 흔적으로 남겨, 다음 재구축에서도 같은 결과로 부팅되도록 결정성을 정의 안에 묶어 두었습니다.
트러블슈팅
- WireGuard IP가 할당되기 전에 Docker가 바인딩에 실패하던 ordering race.
- 문제: WireGuard 서비스를 시작하는 명령은 곧바로 리턴되지만 커널이 가상 인터페이스에 IP를 실제로 부여하는 데는 밀리초가 더 걸려, 곧바로 이어지는 Docker가 그 IP에 바인딩을 시도하다 “cannot assign requested address” 오류로 실패하는 race가 있었습니다. 단순히
sleep을 끼우는 방식은 호스트마다 IP 할당 시간이 들쭉날쭉해 신뢰할 수 없었고, 결정성을 코드에 명시할 필요가 있었습니다. - 해결: WireGuard 시작 후 가상 인터페이스에 IP가 실제로 잡혔는지 30회·2초 간격으로 확인한 뒤에야 Docker로 넘어가도록 ordering 가드를 추가해 — 단발 패치가 아니라 코드 흔적으로 남겨 다음 재구축에서도 같은 결과로 부팅되게 했습니다.
- 문제: WireGuard 서비스를 시작하는 명령은 곧바로 리턴되지만 커널이 가상 인터페이스에 IP를 실제로 부여하는 데는 밀리초가 더 걸려, 곧바로 이어지는 Docker가 그 IP에 바인딩을 시도하다 “cannot assign requested address” 오류로 실패하는 race가 있었습니다. 단순히
의의
보안·자동화·결정성이 한 저장소 안에서 서로 깎아먹지 않게 균형을 잡은 사례입니다. 운영 중 마주친 race나 ordering 같은 사건도 단발성 패치로 끝내지 않고 user_data.sh와 워크플로우에 다시 녹여, 같은 의사결정을 두 번 내릴 필요가 없게 만든 것이 가장 만족스러웠습니다.