port-forward 3단계를 URL 한 줄로 — Hermes 대시보드에 Cloudflare Tunnel 붙이기
TL;DR
- Hermes Agent 대시보드를 kubectl exec + port-forward 없이
hermes.jaypy.devURL 하나로 접속 가능하게 변경- s6 서비스로 자동 기동 — 파드 재시작 시에도 대시보드가 함께 올라옴
- Cloudflare Tunnel(outbound, 포트 노출 없음) + Access(이메일 인증)로 Ingress 없이 안전하게 노출
- 삽질: K8s Service env var가 대시보드 포트를 오염시킨 문제를
enableServiceLinks: false로 해결
이전 글: Hermes Agent를 처음 배포한 이야기는 Discord 봇 하나 올리는데 스토리지부터 다시 짰다, 두 번째 봇을 추가한 이야기는 Hermes Agent에 두 번째 봇을 올리다에서 다뤘다.
들어가며
Hermes Agent에는 웹 대시보드가 내장되어 있다. config, API 키, 세션 모니터링을 브라우저에서 할 수 있는 유용한 도구인데, 접속 방법이 번거로웠다.
# 1. 파드 안에서 대시보드 데몬을 수동으로 기동
kubectl exec -n hermes deploy/hermes -- hermes dashboard --no-open
# 2. 로컬에서 port-forward 연결
kubectl port-forward -n hermes deploy/hermes 9119:9119
# 3. 브라우저에서 http://127.0.0.1:9119 접속
문제점:
- 매번 2단계 수동 작업 — 대시보드 데몬 기동 + port-forward. 둘 다 포그라운드 프로세스라 터미널을 잡아먹는다
- 파드 재시작 시 대시보드 소멸 — 수동 실행이라 rollout restart나 OOM kill 후 다시 띄워야 한다
- 외부 접속 불가 — port-forward는 로컬에서만. 카페나 이동 중에는 Cloudflare WARP로 K8s API 서버에 연결한 뒤 다시 port-forward를 걸어야 한다
- API 키를 다루는 민감 UI — 그래서 Ingress로 노출하기엔 찜찜하고, port-forward가 “보안적으로 맞는” 방법이었다
이상적으로는 hermes.jaypy.dev를 브라우저에 치면 끝이어야 한다. Cloudflare Access가 인증을 처리하면 Ingress 없이도 안전하게 노출할 수 있다.
구성 설계
브라우저 → hermes.jaypy.dev
→ Cloudflare Access (이메일 OTP 인증)
→ Cloudflare Tunnel (outbound, 포트 노출 없음)
→ K8s Service (hermes-dashboard:9119)
→ Pod 0.0.0.0:9119 (s6 자동 기동)
보안 포인트:
- Ingress 없음 — Tunnel은 클러스터에서 Cloudflare로 outbound 연결. 외부에서 클러스터로 인바운드 포트를 열지 않는다
- Cloudflare Access — 허용된 이메일만 접근 가능. 세션 만료 후 재인증
- NetworkPolicy — Cloudflare Tunnel 파드 → hermes 파드의 9119 포트만 허용, 나머지 ingress 차단
1단계 — 인프라 설정
K8s Service + Cloudflare 설정은 infra-copilot에서 처리했다.
- ClusterIP Service: hermes namespace에
hermes-dashboard서비스 생성 (selector:app.kubernetes.io/name=hermes, port 9119) - Cloudflare Tunnel:
hermes.jaypy.dev→hermes-dashboard.hermes.svc.cluster.local:9119라우트 추가 - Cloudflare Access:
hermes.jaypy.dev에 이메일 인증 정책 추가
2단계 — 대시보드 자동 기동
ConfigMap으로 s6 서비스 활성화
대시보드의 s6 run 스크립트가 HERMES_DASHBOARD 환경변수를 체크한다. 값이 truthy가 아니면 서비스가 즉시 종료된다.
# manifests/configmap.yaml
HERMES_DASHBOARD: "1"
HERMES_DASHBOARD_INSECURE: "1"
INSECURE는 non-localhost 바인딩을 허용하는 플래그다. 이름이 무섭지만, Cloudflare Access가 앞단에서 인증을 처리하므로 실질적 보안 영향은 없다.
K8s Service env var 충돌
적용 후 대시보드가 기동은 됐는데 포트가 열리지 않았다. 로그를 보니:
hermes dashboard --host 0.0.0.0 --port tcp://10.43.166.157:9119 --no-open --insecure
--port에 TCP URL이 들어갔다. 원인은 K8s의 Service discovery env var 자동 주입이었다.
K8s는 같은 namespace에 hermes-dashboard라는 이름의 Service가 있으면, 컨테이너에 HERMES_DASHBOARD_PORT=tcp://10.43.166.157:9119를 자동으로 주입한다. s6 스크립트의 ${HERMES_DASHBOARD_PORT:-9119}가 이 값을 읽어서, 포트 번호(9119) 대신 TCP URL 전체를 --port에 전달한 것이다.
Service 이름이 앱의 환경변수 네이밍과 우연히 겹치면서 생긴 충돌이다. Service 이름을 바꿔도 되지만, 근본적으로 이 앱은 Service discovery를 env var로 하지 않는다.
# manifests/deployment.yaml
spec:
template:
spec:
enableServiceLinks: false
enableServiceLinks: false는 K8s가 같은 namespace의 Service 정보를 env var로 주입하는 것을 막는다. DNS 기반 Service discovery는 영향 없다.
NetworkPolicy ingress 허용
적용 후 hermes.jaypy.dev에 접속하니 “Bad Gateway”가 떴다. NetworkPolicy가 모든 ingress를 차단(ingress: [])하고 있었기 때문이다.
Cloudflare Tunnel 파드에서 대시보드 포트로의 트래픽만 허용하도록 수정했다.
# manifests/networkpolicy.yaml
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: cloudflare-tunnel
podSelector:
matchLabels:
app: cloudflared
ports:
- protocol: TCP
port: 9119
cloudflare-tunnel namespace의 app=cloudflared 파드에서 오는 9119 트래픽만 통과하고, 나머지 ingress는 여전히 차단된다.
삽질 포인트 정리
| 문제 | 원인 | 해결 |
|---|---|---|
--port tcp://... 오염 | K8s Service 이름이 앱 env var와 충돌 | enableServiceLinks: false |
| Cloudflare “Bad Gateway” | NetworkPolicy ingress: []로 전체 차단 | Tunnel 파드 → 9119 ingress 허용 |
| 파드 재시작 시 대시보드 소멸 | 수동 실행은 일회성 | ConfigMap으로 s6 서비스 활성화 |
Before / After
| Before | After | |
|---|---|---|
| 기동 | 수동 (kubectl exec ... hermes dashboard) | 자동 (s6, 파드와 함께 시작) |
| 로컬 접속 | port-forward 필수 (터미널 점유) | port-forward 또는 hermes.jaypy.dev |
| 외부 접속 | WARP → port-forward (3단계) | hermes.jaypy.dev (1단계) |
| 보안 | port-forward만 (인증 없음) | Cloudflare Access (이메일 인증) + NetworkPolicy |
| 파드 재시작 | 대시보드 소멸, 재기동 필요 | 자동 복구 |
port-forward를 없앤 게 아니라, port-forward 없이도 되게 만든 것이다. 로컬에서 빠르게 확인하고 싶을 때는 여전히 just dashboard로 port-forward를 쓸 수 있고, 외부에서는 URL 하나로 끝난다.