이 글의 위치: 기술 블로그 → Backend/Infra · 채팅 백엔드 설계 시리즈의 보안·운영 편. dumb pipe 게이트웨이와 fan-out·멀티테넌시에서 만든 구조를, 이번엔 "어떻게 지키고 어떻게 들여다보나" 관점으로 다시 본다. 학습·무료 티어 데모라 남은 부채도 숨기지 않고 적는다.
1. WebSocket은 무엇으로 지키나
핸드셰이크에서 JWT로 인증한다. 게이트웨이는 WS로 upgrade 하기 전에 토큰을 검증하고, 실패하면 401로 끊는다.
// server.go — upgrade 전에 인증
claims, appErr := middleware.ParseToken(authHeader(r), s.secret)
if appErr != nil { http.Error(w, appErr.Message, http.StatusUnauthorized); return }
토큰은 Authorization 헤더를 먼저 보고, 없으면 ?token= 쿼리스트링으로 폴백한다. 브라우저 WebSocket API가 커스텀 헤더를 붙이지 못하기 때문에 쿼리스트링 경로가 필요하다(Bibleana 클라이언트가 정확히 이 방식으로 붙는다).
검증은 HS256이고, 여기 중요한 방어가 하나 있다 — 검증 시 HMAC 계열 알고리즘만 허용한다.
// jwt.go — alg confusion 공격 방지
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
이게 없으면 공격자가 alg: none이나 RS256 공개키 트릭으로 서명을 우회할 수 있다. claim에는 UserID / AppID / Role이 실린다.
전송 계층(wss://)은 앱이 아니라 인그레스에서 종단한다. Traefik + cert-manager 편에서 만든 Let’s Encrypt 인증서로 https/wss가 종단되고, 클러스터 안 pod 간 트래픽은 평문(HTTP/AMQP)이다.
격리는 테넌트 네임스페이싱으로. 게이트웨이는 방 키를 roomKey(appID, roomID) = appID + ":" + roomID로 만들어(멀티테넌시 편 참고) 테넌트 간 방을 분리한다.
⚠️ 정직하게 남길 것: WS 릴레이 경로에는 방 멤버십 검증이 없다. 유효한 JWT만 있으면 같은 AppID 안의 임의 방에 join/send 할 수 있다(멤버십 검사는 영속화 서비스인 chat-management에만 있다). 그리고 upgrader의 CheckOrigin은 개발 편의로 항상 true다 — 코드 주석도 이걸 인정한다: "Permissive for dev; tighten in production via an env-driven allowlist." 프로덕션에선 origin allowlist와 WS 멤버십 검사를 채워야 한다.
2. 메시지 큐 — RabbitMQ를 고르고 PV에 얹다
pod 간 fan-out을 위해 메시지 큐로 RabbitMQ topic exchange를 골랐다. 이 규모(단일 노드)에선 "topic exchange 하나면 크로스 인스턴스 방 fan-out에 충분"하다는 판단이다. 이미지는 공식 이미지를 fully-qualified로 쓴다.
image: docker.io/library/rabbitmq:3.13-management-alpine
상태를 잃으면 안 되는 것은 PersistentVolume(PVC) 로 받친다. OCI Block Volume(Always Free 200GB 한도 내)이다.
| 스토어 | 배포 형태 | 영속 | 크기 |
|---|---|---|---|
| RabbitMQ | StatefulSet + volumeClaimTemplates | PVC /var/lib/rabbitmq |
2Gi (RWO) |
| Postgres | StatefulSet + PVC | PVC | 10Gi (RWO) |
| Redis | Deployment | 무영속(캐시) | — (--maxmemory 128mb --maxmemory-policy allkeys-lru) |
Redis는 일부러 영속을 끈 캐시라 PVC가 없다. 스토어별로 "잃으면 안 되는가"를 나눠 PV를 붙인 셈이다.
3. PV에 얹은 큐를 어떻게 막았나 — "바깥에 안 연다"
여기가 접근 통제의 핵심이다. RabbitMQ Service는 headless ClusterIP다. LoadBalancer도 NodePort도 아니다.
kind: Service
metadata: { name: rabbitmq, namespace: chat-platform }
spec:
clusterIP: None # headless — 외부 IP 없음
ports:
- { name: amqp, port: 5672 }
- { name: mgmt, port: 15672 } # 관리 UI
clusterIP: None이라 클러스터 외부에서 도달할 IP가 없다. amqp(5672)도, 관리 UI(15672)도 클러스터 안에서만 보인다. 게다가 인그레스에는 /api/ums·cms·pms·rgs·/만 있고 15672로 가는 경로가 없다. 즉 관리 UI는 어떤 공개 URL로도 열리지 않는다.
클러스터 바깥의 공격 표면은 OKE 프로비저닝 단계에서 Security List가 80/443(LB)과 6443(API 서버)만 열어 둔 것으로 제한된다. 데이터스토어 포트(5672/5432/6379)는 애초에 노출 대상이 아니다.
⚠️ 정직하게 남길 것 — 안쪽은 무방비다. infra/k8s 전체에 NetworkPolicy가 하나도 없다. 즉 같은 클러스터 안의 어떤 pod든 rabbitmq:5672, postgres:5432, redis:6379에 자유롭게 접근할 수 있다. 게다가 RabbitMQ 자격증명은 Secret이 아니라 매니페스트에 guest/guest 평문으로 박혀 있고, JWT 기본 시크릿도 default-secret-change-me 같은 placeholder다. "바깥엔 안 열었지만 안쪽 격리는 아직 없다" — 프로덕션 전환의 1순위 하드닝 목록이다(NetworkPolicy deny-all + Secret/SealedSecret/OCI Vault).
4. 그래서 누가 모니터링할 수 있나 — kubeconfig를 쥔 PC뿐
관리 UI(15672)도 Postgres(5432)도 바깥에 안 열려 있으니, 운영자는 어떻게 들여다볼까? 답은 하나다 — 클러스터 kubeconfig(토큰)를 가진 머신에서 kubectl port-forward로 터널을 뚫는다.
# kubeconfig(context-c3gpmysidsq)를 가진 PC에서만 동작
kubectl -n chat-platform port-forward svc/rabbitmq 15672:15672
# → 로컬 http://localhost:15672 로 관리 UI 접속 (guest/guest)
즉 접근 통제가 IP 방화벽이 아니라 "노출을 안 하는 것 + kubeconfig 소지" 로 이뤄진다. ~/.kube/config에 그 클러스터 토큰이 있는 PC만 터널을 열 수 있고, 그 외 어떤 IP도 관리 UI에 닿지 못한다. (참고로 이 port-forward 절차는 아직 README에 문서화돼 있지 않아, 운영 런북에 추가할 항목이다.)
애플리케이션 지표는 어떨까. 게이트웨이는 외부 Prometheus/Grafana 없이 의존성 없는 원자 카운터를 자체 노출한다.
// handler.go — 경량 JSON 스냅샷 (외부 메트릭 백엔드 없음)
router.GET("/metrics", ...) // WriteDropped 등 카운터 JSON
이 /metrics는 /api/rgs prefix 밖이라 인그레스로 라우팅되지 않는다 → 역시 클러스터 안에서만 접근된다. 다만 이 엔드포인트엔 인증이 없어, 노출 표면이 in-cluster로 한정된다는 사실에 기대고 있다(이것도 하드닝 대상).
정리
- WS 인증: 핸드셰이크 JWT(HS256, HMAC-only로 alg confusion 방지) + 쿼리스트링 폴백 + 인그레스 TLS(wss).
- 격리: 테넌트 네임스페이싱(
appID:roomID). 단 WS 경로 멤버십 검증·origin allowlist는 미구현(부채). - MQ 선택: RabbitMQ topic exchange, 공식 이미지, 2Gi PVC(OCI Block Volume). Redis는 무영속 캐시.
- 접근 통제: headless ClusterIP + 인그레스 미노출 → 바깥에 안 연다. OCI Security List는 80/443/6443만.
- 모니터링: 노출이 없으니 kubeconfig를 쥔 PC의
kubectl port-forward가 유일한 창. IP 방화벽이 아니라 "비노출 + 자격증명 소지"로 막는다. - 남은 부채: NetworkPolicy 전무, guest/guest·placeholder 시크릿, WS 멤버십 미검증, 무인증
/metrics.
이렇게 "바깥에 안 열고 kubeconfig로만 들여다보는" 셀프호스팅은 라이선스 피가 0이지만 관리 포인트를 떠안는 선택이다. 그 대가를 어떻게 계산해야 하는지 — Bitnami 라이선스 vs 개발자 시간 편에서 이어간다.
답글 남기기