1. 개요

단일 서버 시절 시크릿은 서버의 .env 파일이었다. 백업에도 들어갔고, 어떤 값이 어디에 쓰이는지 git 은 몰랐다. k3s 로 오면서 두 가지를 정했다. 시크릿은 git 에 암호화된 채로 두고, 네임스페이스 안팎의 통신은 기본 차단 으로 시작한다. 이 글은 그 둘과, 그 사이에서 생긴 “클라이언트 IP 를 누가 믿을 것인가” 문제를 다룬다.


2. 핵심 내용

2-1. SealedSecret: 시크릿을 git 에 두는 법

sealed-secrets 컨트롤러는 클러스터마다 키 쌍을 갖는다. 공개키로 봉인한 SealedSecret 리소스를 git 에 커밋하면, 컨트롤러가 클러스터 안에서 복호화해 일반 Secret 을 만든다. prod 와 staging 은 봉인키가 다르므로 한쪽 파일을 다른 쪽에 넣어도 풀리지 않는다.

DoEatFit 은 overlay 마다 8종의 SealedSecret 을 둔다(백엔드·프론트 설정, DB, Redis, 검색, 스토리지, 푸시, 레지스트리 pull). 앱 설정은 이미지에 굽지 않고 전부 env 나 Secret 으로 외부화했다.

봉인하면서 밟은 함정이 여럿이다.

  • 빈 값이 봉인된다. zsh 의 read 는 bash 와 옵션이 달라, 스크립트에서 값을 읽지 못한 채 빈 문자열을 봉인해도 파일은 정상적으로 생긴다. kubeseal 은 빈 값을 거부하지 않는다. 검증은 암호문 base64 길이로 평문 길이를 역산 해서 한다. 값을 꺼내 보지 않고도 “이게 빈 값인지, 예상 길이인지” 를 알 수 있다.
  • 이름이 암호화 스코프다. 기본 스코프에서 SealedSecret 은 네임스페이스와 이름에 바인딩된다. 이름을 바꾸면 재봉인이다.
  • 검증할 때도 값을 꺼내지 않는다. kubectl get secret -o jsonpath 로 값을 터미널에 찍으면 그 순간 셸 히스토리와 세션 로그에 남는다. 개수와 키 이름, 길이, 지문만 본다.

2-2. 비밀번호를 바꾸는데 서비스가 죽는 순서

DB 비밀번호 로테이션은 순서 문제처럼 보인다. “DB 먼저 바꾸면 앱이 못 붙고, 앱 먼저 바꾸면 DB 가 거부한다.” 그런데 진짜 문제는 readiness 프로브가 DB 비밀번호를 쓴다 는 것이었다. 어느 순서로 해도 프로브가 실패하는 창이 생기고, 그 창에서 파드가 Ready 에서 빠진다.

해법은 MySQL 8 의 이중 비밀번호(RETAIN CURRENT PASSWORD)다. 옛 비밀번호와 새 비밀번호가 동시에 유효한 기간을 만들고, 그 사이에 앱 시크릿을 바꾼다. 순서 문제가 아니라 “겹치는 기간” 문제로 바꾸면 풀린다.

2-3. NetworkPolicy: ingress default-deny

네임스페이스에 podSelector: {} 인 ingress default-deny 를 두면 그 순간부터 모든 파드로의 인바운드가 막힌다. 그 위에 허용 정책을 얹는다.

  • 같은 네임스페이스 안의 파드끼리
  • Envoy Gateway 네임스페이스 → 앱의 특정 포트
  • 노드의 kubelet 이 프로브를 보내는 주소 → 앱
  • 관측 네임스페이스 → actuator 포트(8081)

여기서 두 번 놀랐다. 첫째, kubelet 프로브도 막힌다. 프로브는 노드에서 오므로 노드 주소를 허용해야 한다. 둘째, ipBlock 에 파드 CIDR 을 넣으면 정책이 무력화된다. “클러스터 안은 다 허용” 을 의도한 10.42.0.0/16 같은 값은 사실상 모든 파드를 허용하는 것이다.

정책이 반영되는 데도 시간이 있다. 새 파드가 뜨고 IP 가 IPSet 에 들어가기까지 몇 초 동안 연결이 ECONNREFUSED 로 거부된다. 백업 CronJob 처럼 뜨자마자 DB 에 붙는 Job 은 이 창에서 조용히 실패한다. DB 접속 전에 대기 루프를 넣었다.

2-4. egress default-deny: 전수조사가 먼저다

egress 를 막는 건 더 무섭다. 앱이 밖으로 나가는 곳을 전부 알아야 한다. OAuth 토큰 검증, JWKS, 푸시 알림, Turnstile siteverify, 공공데이터 API, SMTP, 그리고 관측 에이전트. 하나라도 빠지면 그 기능만 조용히 죽는다.

2026년 9월 22일에 넣었다. 허용 규칙은 다섯 종류다.

  1. DNS (UDP 와 TCP 53 둘 다)
  2. 같은 네임스페이스
  3. 관측 에이전트로 OTLP
  4. 인터넷 (사설 대역 except)
  5. 노드 헬스체크 응답

인터넷 규칙이 넓은 이유는 외부 API 들이 IP 를 고정하지 않기 때문이다. 대신 사설 대역을 except 로 빼서 “인터넷은 되고 LAN 은 안 되는” 모양을 만들었다. 그리고 부분 적용은 없다. default-deny 를 넣는 순간 다섯 규칙이 전부 있어야 한다. 한 커밋으로 넣었다.

LAN 전용 정책을 검증할 때 한 번 속았다. 맥에서 curl 로 확인하니 허용돼야 할 곳도 허용됐다. 맥은 방화벽에서 SNAT 돼 허용 대역 안의 주소로 도착하기 때문이다. 거부는 파드 안에서만 보인다. kubectl run 으로 임시 파드를 띄워 확인해야 한다.

2-5. 클라이언트 IP 는 누가 믿나

레이트리밋은 IP 단위다. 그런데 Next.js 서버 액션이 백엔드를 클러스터 내부 주소로 직접 호출하면, 백엔드는 Next 파드 IP 하나만 본다. “IP 당 N 회” 가 전 사용자 합산이 된다. 실제로 로그인 시도가 모두 한 IP 로 뭉쳐 정상 사용자가 막히는 일이 있었다.

X-Forwarded-For 를 믿으면 되지 않나. 안 된다. XFF 는 누구나 붙일 수 있다. 엣지(Cloudflare)를 지나온 요청은 CF-Connecting-IP 를 믿을 수 있지만, 내부 호출은 엣지를 지나지 않는다.

그래서 공유 시크릿 헤더를 만들었다. Next 서버가 백엔드를 내부 호출할 때 시크릿 헤더와 원 클라이언트 IP 헤더를 함께 싣는다. 백엔드는 시크릿이 일치할 때만 IP 헤더를 믿고, 아니면 getRemoteAddr() 로 떨어진다. XFF 는 아예 보지 않는다.

flowchart LR
    B[브라우저] -->|CF-Connecting-IP| CF[Cloudflare] --> GW[Gateway] --> BE[백엔드]
    B -->|서버 액션| FE[Next 서버]
    FE -->|내부 주소 + 시크릿 헤더 + 원 IP 헤더| BE
    BE -->|시크릿 일치?| D{판정}
    D -->|예| IP1[헤더의 IP]
    D -->|아니오| IP2[remoteAddr]

한 가지 더. 이 헤더 이름을 CF-Connecting-IP 로 재사용하면 안 된다. 그 요청이 어떤 경로로든 Cloudflare 를 다시 지나면 엣지가 위조로 보고 403 을 낸다. 이름은 우리 것으로 따로 만든다.

2-6. 게이트웨이 백스톱

앱 레이트리밋은 정책이 붙은 엔드포인트에만 작동한다. 무인증 엔드포인트에 정책을 빠뜨리면 그 경로의 방어는 0 이다. 엣지에는 IP 단위 규칙이 없다. 그래서 게이트웨이에 BackendTrafficPolicy 로 총량 상한(분당 요청 수)과 circuit breaker 를 걸었다. IP 단위가 아니라 경로 총량이다. 앱 정책이 빠져도 상한이 0 이 되지 않게 하는 마지막 그물이다.


3. 마무리

요약

  • SealedSecret 은 값을 꺼내지 않고 검증한다. 암호문 길이 역산, 개수, 지문.
  • 로테이션은 순서가 아니라 “겹치는 기간” 문제다. 이중 비밀번호로 푼다.
  • default-deny 는 부분 적용이 없다. 허용 목록을 전수조사한 뒤 한 커밋으로.
  • 거부는 파드 안에서만 보인다. 맥에서 curl 하면 SNAT 에 속는다.
  • 내부 홉의 클라이언트 IP 는 공유 시크릿 헤더로. XFF 는 믿지 않는다.

다음은 장애 이야기다.