1. 개요

sealed-secrets 는 시크릿을 git 에 넣을 수 있게 해 준다. 공개키로 봉인하면 컨트롤러만 풀 수 있다. 좋은 도구인데, 봉인 과정에서 실수하면 파일은 멀쩡하게 생기고 값만 틀린다. 그리고 확인하려고 값을 꺼내면 그 순간 터미널·히스토리·세션 로그에 평문이 남는다. 이 글은 세 프로젝트에서 SealedSecret 을 쓰며 정리한 “값을 보지 않고 검증하는 법” 이다.


2. 핵심 내용

2-1. 빈 값이 봉인된다

가장 흔한 사고. 스크립트에서 값을 읽어 kubeseal 에 넘기는데, 읽기가 실패해 빈 문자열이 들어간다. kubeseal 은 빈 값을 거부하지 않는다. 파일은 정상적으로 생긴다. 적용하면 Secret 도 생긴다. 앱은 빈 비밀번호로 DB 에 붙으려다 실패한다.

내가 밟은 원인은 zsh 의 read 였다. bash 의 read -s -p "prompt" VAR 은 zsh 에서 다르게 동작한다(-p 가 coprocess 옵션). 스크립트가 bash 용으로 쓰여 있고 zsh 에서 실행하면 값이 안 들어간다. 조용히.

2-2. 암호문 길이로 평문 길이를 역산한다

SealedSecret 의 encryptedData 는 base64 다. 이 길이는 평문 길이의 함수다. 하이브리드 암호화(RSA 로 세션키를 감싸고 AES-GCM 으로 본문)라 고정 오버헤드 + 평문 길이 + 태그다.

같은 클러스터 공개키로 길이가 알려진 값 몇 개를 봉인해 표를 만들면, 암호문 길이만 보고 “이 값은 0자다” 또는 “이 값은 32자 근처다” 를 알 수 있다. 값을 꺼내지 않고.

# 봉인된 값의 암호문 길이만 본다
yq '.spec.encryptedData | to_entries | .[] | .key + " " + (.value | length)' sealed-x.yaml

빈 값이 봉인됐으면 길이가 표의 “0자” 칸과 같다. 예상 길이(예: 64자 hex 토큰)와 다르면 다시 본다. 이 표는 클러스터별로 다르니 클러스터마다 한 번 만든다.

2-3. 검증할 때 값을 꺼내지 않는다

적용 뒤 확인은 이렇게만 한다.

  • kubectl get secret X -o jsonpath='{.data}' | jq 'keys' → 키 이름과 개수
  • kubectl get secret X -o jsonpath='{.data.KEY}' | base64 -d | wc -c → 길이
  • ... | sha256sum | cut -c1-8 → 지문(다른 환경과 같은 값인지 비교할 때)

값을 echo 하지 않는다. 한 번 찍으면 셸 히스토리, 터미널 스크롤백, 에이전트 세션 로그에 남는다. 실제로 세션 로그에서 평문 자격증명 수십 건을 회수해 로테이션한 경험이 있다. 확인 습관 하나가 로테이션 작업을 만든다.

2-4. 이름이 암호화 스코프다

기본 스코프(strict)에서 SealedSecret 은 네임스페이스와 이름 에 바인딩된다. 이름을 바꾸면 복호화가 실패한다. 리소스를 rename 하려면 재봉인이다. 클러스터를 옮겨도 재봉인이다(봉인키가 다르다). prod 와 staging 은 서로의 파일을 풀 수 없다. 이건 기능이다.

2-5. optional: true 로 배포 순서를 독립시킨다

시크릿이 아직 없는데 Deployment 가 secretKeyRef 로 참조하면 파드가 안 뜬다. 코드 저장소·인프라 저장소·시크릿 봉인이 따로 배포되는 GitOps 에서 이 순서를 매번 맞추는 건 피곤하다.

env:
  - name: TURNSTILE_SECRET
    valueFrom:
      secretKeyRef:
        name: app-turnstile
        key: secret
        optional: true

optional: true 면 시크릿이 없을 때 env 가 그냥 비어 있다. 앱은 “시크릿이 비면 그 기능을 끈다” 로 짠다. 시크릿이 생기면 다음 롤아웃에서 켜진다. 매니페스트, 시크릿, 이미지가 어느 순서로 나가도 서로를 막지 않는다.

단 “없으면” 과 “비어 있으면” 은 다르다. 2-1 의 빈 값 봉인은 optional 이 잡아 주지 않는다. 그래서 2-2 가 필요하다.

2-6. 로테이션의 프로브 함정

DB 비밀번호를 바꾼다. “DB 먼저? 앱 먼저?” 를 고민하게 되는데, 진짜 문제는 readiness 프로브가 DB 비밀번호를 쓴다 는 것이다. 어느 순서로 해도 프로브가 실패하는 창이 생기고, 그 창에서 파드가 Ready 에서 빠져 트래픽이 끊긴다.

순서 문제를 겹치는 기간 문제로 바꾸면 풀린다. MySQL 8 은 ALTER USER ... RETAIN CURRENT PASSWORD 로 옛·새 비밀번호를 동시에 유효하게 둘 수 있다. 그 사이에 앱 시크릿을 바꾸고 롤아웃하고, 끝나면 옛 비밀번호를 DISCARD OLD PASSWORD 로 버린다. PostgreSQL 은 이중 비밀번호가 없어 임시 사용자를 하나 더 만드는 식으로 같은 모양을 만든다.

2-7. de-bake 는 앞으로의 이미지에만 유효하다

설정을 이미지에 굽던 앱을 시크릿 주입으로 바꾸는 작업(de-bake)을 하면서 배운 것. Spring 의 relaxed binding 은 env 가 baked yml 을 이기므로, 앱 이미지를 안 고치고 매니페스트 주입만으로 효력이 난다. 이길 수 있는 것부터 하면 된다.

하지만 옛 이미지 태그에는 값이 그대로 남아 있다. 레지스트리에서 지워도 pull 한 사람이 있을 수 있다. de-bake 의 마무리는 삭제가 아니라 회전 이다.


3. 마무리

요약

  • kubeseal 은 빈 값을 거부하지 않는다. zsh read 방언이 흔한 원인.
  • 암호문 base64 길이로 평문 길이를 역산하면 값을 보지 않고 검증할 수 있다.
  • 확인은 키 개수·길이·지문만. 값을 echo 하면 로테이션 작업이 생긴다.
  • optional: true + “비면 끈다” 로 배포 순서를 독립시킨다.
  • 로테이션은 순서가 아니라 겹치는 기간. 이중 비밀번호.
  • de-bake 의 마무리는 삭제가 아니라 회전.