port-forward 3단계를 URL 한 줄로 — Hermes 대시보드에 Cloudflare Tunnel 붙이기

TL;DR

  • Hermes Agent 대시보드를 kubectl exec + port-forward 없이 hermes.jaypy.dev URL 하나로 접속 가능하게 변경
  • 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에서 처리했다.

  1. ClusterIP Service: hermes namespace에 hermes-dashboard 서비스 생성 (selector: app.kubernetes.io/name=hermes, port 9119)
  2. Cloudflare Tunnel: hermes.jaypy.devhermes-dashboard.hermes.svc.cluster.local:9119 라우트 추가
  3. 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

BeforeAfter
기동수동 (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 하나로 끝난다.