콘텐츠로 이동
Study Note온프렘 쿠버네티스

Sealed Secrets의 동작 원리

결론부터
Sealed Secrets는 공개키를 클러스터 밖에, 개인키를 클러스터 안에 두는 한 가지 배치가 전부다 — 그래서 봉인은 누구나 어디서나 할 수 있고 푸는 것은 클러스터 안 컨트롤러만 할 수 있다. Git에 올라가는 것은 금고를 가리키는 참조가 아니라 암호화된 값 그 자체다
이 장에서 처음 나오는 말6개
Secret
쿠버네티스의 비밀 저장 오브젝트. data 필드는 암호문이 아니라 base64 인코딩이라 읽을 권한만 있으면 원문을 본다. 그래서 그대로는 Git에 올릴 수 없다.
공개키 · 개인키Public Key · Private Key
공개키로 잠그고 개인키로만 푸는 한 쌍. 공개키는 나눠 줘도 되지만 개인키는 복호화 권한 그 자체라 숨기고 백업해야 한다.
kubeseal
내 PC나 CI에서 실행하는 명령줄 도구. 평문 Secret을 받아 공개키로 잠근 SealedSecret을 뱉는다. 클러스터 안에 상주하는 것이 아니라 필요할 때 부르는 CLI다.
Sealed Secrets 컨트롤러
클러스터 안에 상주하는 Deployment. 개인키를 쥐고 SealedSecret을 감시하다가 복호화해 평범한 Secret을 만든다. kubeseal과 짝을 이루는 반대편이다.
SealedSecret
CRD(Custom Resource Definition, 새 오브젝트 종류 등록 방법)로 등록된 커스텀 오브젝트. Git에 올라가며 암호문을 직접 담고 있다.
sealing key 세트봉인 키 세트
컨트롤러가 쥔 개인키들의 묶음. 키가 주기적으로 추가되므로 하나가 아니라 세트다.

Sealed Secrets는 평문을 Git에 올리지 않으면서 시크릿까지 GitOps로 관리하는 가장 단순한 방법이다. 이 페이지는 그 전체 동작을 처음부터 훑는다 — 무엇을 설치하고, 무엇이 Git에 올라가며, 값이 어떻게 잠기고 풀리고, 무엇을 잃으면 안 되는지다.

시크릿 관리 전체의 선택지 비교, 네임스페이스 경계, 값이 새는 곳 같은 더 넓은 맥락은 시크릿에 있다. 이 페이지만 읽어도 Sealed Secrets 자체는 이해된다.

출발점은 오해 하나를 걷는 것이다. 쿠버네티스 Secret의 data는 암호화가 아니라 base64 인코딩이다.

터미널 창
kubectl -n database get secret pg-backup-s3 -o jsonpath='{.data.SECRET_ACCESS_KEY}' | base64 -d
# → 그냥 원문이 나온다. 암호가 아니라 인코딩이다

되돌리는 데 키가 필요 없으니 Secret YAML을 Git에 올리면 저장소를 읽는 모두가 값을 본다. GitOps는 Git을 단일 소스로 삼는데(GitOps) 시크릿만 Git에 둘 수 없는 모순이 여기서 생긴다. Sealed Secrets는 Git에 올려도 되는 형태로 값을 바꿔서 이 모순을 푼다.

구성 요소 셋 — 무엇이 어디에 있나

섹션 제목: “구성 요소 셋 — 무엇이 어디에 있나”

설치되고 동작하는 조각은 셋뿐이다.

조각무엇인가어디에 있나
kubeseal필요할 때 실행하는 CLI클러스터 밖 — 내 PC나 CI
컨트롤러상주하는 Deployment클러스터 안 — 개인키를 쥐고 있다
SealedSecretCRD로 등록된 오브젝트Git, 그리고 적용되면 클러스터 안

kubeseal — 클러스터 밖에서 도는 CLI

섹션 제목: “kubeseal — 클러스터 밖에서 도는 CLI”

kubeseal은 서버가 아니라 내가 실행하는 명령줄 도구다. 패키지 매니저나 릴리스 바이너리로 설치하고, 쓸 때만 부른다. 하는 일은 하나 — 평문 Secret YAML을 입력받아 공개키로 잠근 SealedSecret YAML을 출력하는 것이다. 상주하지 않고, 상태를 갖지 않는다. 꼭 내 PC에 있을 필요도 없다 — CI에 두고 봉인 결과만 커밋하는 구성이 흔하다.

봉인하는 자리에 실제로 필요한 것은 kubeseal 바이너리와 공개키, 둘뿐이다. kubectl은 필요 없고(아래 전체 흐름에서 쓰는 kubectl은 평문 YAML을 만드는 편의 도구일 뿐이다), 공개키를 --cert 파일로 미리 받아 두면 클러스터 접속도 필요 없다. 인터넷도, 사내 프록시 예외도, kubeconfig도 없이 망 분리된 자리나 CI 러너에서 봉인할 수 있다 — 온프렘에서 특히 반가운 성질이다. 클러스터에 닿아야 하는 순간은 공개키를 처음 받아 올 때 (--fetch-cert) 한 번뿐이다.

컨트롤러 — 클러스터 안에 떠 있는 Deployment

섹션 제목: “컨트롤러 — 클러스터 안에 떠 있는 Deployment”

맞다, 별도의 컨트롤러가 클러스터 안에 상주한다. 보통 sealed-secrets 네임스페이스에 Deployment 하나로 설치되고, 이 파드가 개인키를 쥔 유일한 주체다. 쿠버네티스의 흔한 컨트롤러 패턴 그대로 SealedSecret 오브젝트를 감시하다가, 새로 생기거나 바뀌면 복호화해 평범한 Secret을 만든다.

터미널 창
kubectl -n sealed-secrets get deploy,pod # 상주하는 컨트롤러
kubectl get crd sealedsecrets.bitnami.com # 등록된 오브젝트 종류

개인키는 컨트롤러 네임스페이스 안에 Secret 형태로 etcd에 저장된다. 이 시스템에서 진짜로 지켜야 할 상태는 이것 하나뿐이다.

공개키는 클러스터 밖 어디에나 나눠 주고 개인키는 컨트롤러만 가지므로, 봉인은 누구나 하지만 복호화는 클러스터 안에서만 일어나는 배치

공개키는 비밀이 아니다. 저장소에 커밋해도 되고 CI에 나눠 줘도 된다. 공개키로는 잠글 수만 있지 풀 수 없기 때문이다. 점선(공개키 받기)이 클러스터에 닿는 유일한 순간이고, 나머지 봉인은 전부 밖에서 끝난다.

Git에 올라가는 것은 참조가 아니라 값 그 자체다

섹션 제목: “Git에 올라가는 것은 참조가 아니라 값 그 자체다”

여기서 헷갈리기 쉽다. SealedSecret의 encryptedData에 들어 있는 긴 문자열은 어딘가를 가리키는 참조나 ID가 아니라, 암호화된 값 그 자체다. 비밀의 실물이 잠긴 채로 Git에 들어 있는 것이다.

apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: loki-s3
namespace: observability
spec:
encryptedData:
# 이 긴 문자열이 곧 암호화된 값이다 — 포인터가 아니다
AWS_ACCESS_KEY_ID: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq0i8Sd1xO...
AWS_SECRET_ACCESS_KEY: AgCtr8s2K1n9xQ0m4FbP7vZ1dHhLeJ3wYu6N...
template:
metadata:
name: loki-s3
namespace: observability

여기서 따라 나오는 성질이 둘이다.

  • Git과 sealing key만 있으면 복원이 끝난다. 별도로 조회할 금고가 없으니, 값이 어디 따로 살아 있는지 신경 쓸 필요가 없다. 클러스터를 통째로 잃어도 Git의 암호문 + 개인키 세트면 모든 시크릿이 되살아난다.
  • 거꾸로 sealing key를 잃으면 Git의 암호문은 영영 못 푼다. 값이 다른 데 남아 있지 않기 때문이다. 이게 아래 백업 절이 중요한 이유다.

template 블록은 암호화되지 않은 평문 스펙이다. 만들어질 Secret에 라벨·annotation· type(예: kubernetes.io/dockerconfigjson)을 지정하고 싶을 때 여기에 적고, Git에서 그대로 읽고 고칠 수 있다.

세 조각이 실제로 어떻게 맞물리는지 값 하나를 끝까지 따라가 본다.

  1. 평문 Secret 매니페스트를 만든다 — 클러스터에 적용하지 않고 파일로만.

    터미널 창
    kubectl create secret generic loki-s3 --namespace observability \
    --from-file=AWS_SECRET_ACCESS_KEY=/dev/stdin \
    --dry-run=client -o yaml > /tmp/secret.yaml
    # 값을 입력하고 Ctrl-D — 실제로는 앞에 파이프로 넣는다

    --dry-run=client라 전부 클라이언트 쪽에서 끝나고 클러스터에 접속조차 하지 않는다. 값을 표준 입력으로 넣는 것은 --from-literal에 비밀번호를 그대로 쓰면 셸 히스토리에 남기 때문이다.

  2. kubeseal로 봉인한다. 평문이 암호문으로 바뀐 SealedSecret YAML이 나온다.

    터미널 창
    kubeseal --controller-namespace sealed-secrets --format yaml \
    < /tmp/secret.yaml > platform/loki/sealedsecret-s3.yaml
    rm /tmp/secret.yaml
  3. 암호문을 커밋한다. 이제 저장소를 읽는 사람이 늘어도 값은 새지 않는다.

  4. Argo CD가 SealedSecret을 클러스터에 적용한다. 이 시점까지 값은 여전히 잠겨 있다.

  5. 컨트롤러가 복호화해 Secret을 만든다. 개인키 세트를 차례로 시도해 풀고, 같은 이름·네임스페이스에 평범한 Secret을 생성한다.

  6. 파드가 그 Secret을 평소처럼 읽는다. 앱 입장에서는 보통 Secret과 구별되지 않는다.

값을 바꿀 때도 같은 길을 다시 돈다 — 평문은 로컬에서만 잠깐 존재하고, Git에는 암호문만 쌓인다. 암호문은 직접 편집할 수 없고 로컬에 개인키가 없어 열어 볼 수도 없으므로, 바꿀 키만 다시 봉인해 갈아끼운다. 그 조작(--merge-into, 값 하나만 잠그는 --raw, CI에서 봉인)은 시크릿의 “Sealed Secrets 실무”에 있다.

봉인 — kubeseal이 실제로 만드는 것

섹션 제목: “봉인 — kubeseal이 실제로 만드는 것”
이 절에서 쓰는 말5개
하이브리드 암호화Hybrid Encryption
데이터는 빠른 대칭 키로 잠그고, 그 대칭 키만 공개키로 잠그는 2단 구성. 공개키 암호는 느리고 한 번에 잠글 수 있는 길이가 짧아서 이렇게 나눈다.
세션 키Session Key
값 하나를 잠그려고 그때그때 새로 만드는 일회용 대칭 키. 매번 무작위라 같은 값을 두 번 봉인해도 암호문이 다르다.
AES-GCMAdvanced Encryption Standard - Galois/Counter Mode
빠르고 길이 제한이 없는 대칭 암호. 암호문이 조작되면 복호화가 실패하는 위조 감지가 내장돼 있다.
RSA-OAEPRSA - Optimal Asymmetric Encryption Padding
공개키 암호화 방식. 한 번에 잠글 수 있는 길이가 키 크기보다도 짧아(몇백 바이트) 세션 키만 잠그는 용도로 쓴다.
OAEP label
RSA-OAEP에 끼워 넣는 부가 문자열. 암호문에 저장되지 않고 암호화 때와 복호화 때 똑같이 줘야만 풀린다. Sealed Secrets가 스코프를 강제하는 장치다.

위 흐름의 봉인 단계에서는 두 겹의 암호화가 돈다. RSA 하나로 값을 직접 잠그지 않는 이유는 RSA-OAEP가 한 번에 몇백 바이트밖에 못 잠그고 느리기 때문이다 — 인증서나 긴 설정 파일이 들어간 Secret은 RSA만으로는 못 다룬다. 그래서 하이브리드다.

평문 값은 일회용 세션 키의 AES-GCM으로 잠그고 세션 키는 공개키와 스코프 label의 RSA-OAEP로 잠가서, 두 암호문을 이어 붙인 것이 encryptedData 항목 하나가 되는 구조

순서대로 보면 이렇다.

  1. data의 값마다 일회용 세션 키를 무작위로 만든다.
  2. 값을 그 세션 키의 AES-GCM으로 잠근다 — 길이 제한이 없고, 나중에 암호문이 조작됐으면 복호화가 실패한다.
  3. 세션 키를 컨트롤러의 공개키 + 스코프 label의 RSA-OAEP로 잠근다.
  4. 둘을 이어 붙인 것이 SealedSecret의 encryptedData 항목 하나가 된다.

값마다 독립적으로 잠기기 때문에 한 값만 갈아끼우는 조작(--merge-into, 값 하나만 암호화하는 --raw)이 가능하다. 형식의 정확한 정의는 Sealed Secrets 암호화 설계 문서에 있다.

kubeseal은 기본으로 클러스터의 컨트롤러에게서 현재 인증서(공개키)를 받아 온다. 클러스터 접속 없이 쓰려면 인증서를 한 번 받아 파일로 두고 --cert로 준다.

터미널 창
# 현재 공개키를 파일로 받아 둔다 → 이후 봉인은 클러스터 접속 없이 가능
kubeseal --controller-namespace sealed-secrets --fetch-cert > sealed-secrets-public.pem
kubeseal --cert sealed-secrets-public.pem --format yaml < secret.yaml

--cert 파일은 키 갱신(아래) 뒤에도 계속 봉인에 쓸 수 있지만, 갱신 후에는 새 인증서로 바꿔 두는 편이 낫다 — 옛 공개키로 봉인한 것도 풀리긴 하나, 키를 오래 쓸수록 그 키 하나에 걸린 암호문이 늘어난다.

스코프 — 규칙이 아니라 수학이다

섹션 제목: “스코프 — 규칙이 아니라 수학이다”

SealedSecret은 기본적으로 이름 + 네임스페이스에 고정된다(strict). 네임스페이스를 바꾸거나 리소스 이름을 정리하면 no key could decrypt secret이 뜨는데, 내부에서 보면 이건 컨트롤러가 검사해서 거부하는 게 아니라 복호화 자체가 성립하지 않는 것이다.

위 그림의 RSA-OAEP에 들어간 스코프 label이 그 장치다. label은 암호문에 저장되지 않고, 복호화하는 쪽이 똑같은 문자열을 다시 만들어 줘야만 세션 키가 풀린다.

스코프봉인 때 label에 들어가는 것컨트롤러가 복호화 때 다시 만드는 재료
strict (기본)네임스페이스 + 이름눈앞의 SealedSecret의 metadata.namespace + metadata.name
namespace-wide네임스페이스만metadata.namespace
cluster-wide빈 문자열빈 문자열 — 그래서 어디서든 풀린다

스코프를 지정하는 annotation과 실무 함정은 시크릿의 “함정 1”에 있다.

컨트롤러는 자기 눈앞에 있는 SealedSecret의 메타데이터로 label을 다시 만든다. 그래서 암호문을 다른 네임스페이스로 복사하면 컨트롤러가 만드는 label이 봉인 때와 달라지고, RSA-OAEP 복호화가 수학적으로 실패한다. 훔친 암호문을 자기가 관리하는 네임스페이스에 붙여 넣고 풀어 보는 공격이 이 한 줄로 막힌다. 이름·네임스페이스를 바꿔야 하면 그 위치로 다시 봉인하는 게 정석이다.

같은 값을 다시 봉인하면 암호문이 매번 다르다

섹션 제목: “같은 값을 다시 봉인하면 암호문이 매번 다르다”

세션 키가 매번 무작위로 새로 만들어지므로, 같은 값을 같은 위치로 다시 봉인해도 암호문은 완전히 다르다. 알아 두면 헷갈리지 않는 결과가 둘 있다.

  • Git diff로는 “값이 실제로 바뀌었는지” 알 수 없다. 재봉인한 커밋은 값이 그대로여도 diff가 전부 바뀐 것처럼 보인다. 값 변경 여부는 커밋 메시지로 남기는 수밖에 없다.
  • 거꾸로 보안에는 이롭다 — 두 SealedSecret의 암호문을 비교해서 “두 곳이 같은 비밀번호를 쓰는구나”를 알아낼 수 없다.

컨트롤러가 만든 Secret은 누구 것인가

섹션 제목: “컨트롤러가 만든 Secret은 누구 것인가”

컨트롤러가 만든 Secret에는 원본 SealedSecret을 가리키는 ownerReference(오브젝트 사이의 소유 관계 표시)가 붙는다. 여기서 운영 성질 두 가지가 나온다.

  • SealedSecret을 지우면 Secret도 같이 지워진다. Git에서 파일을 지워 Argo CD가 SealedSecret을 정리하면 런타임 Secret까지 정리되는 것 — GitOps에서 원하는 동작이다.
  • Secret을 손으로 고쳐도 소스는 여전히 SealedSecret이다. 컨트롤러가 다시 처리할 때 암호문 기준으로 덮어쓰므로, 값을 바꾸려면 반드시 재봉인·커밋 경로로 가야 한다.

보호가 끝나는 지점 — 런타임 Secret은 평범한 Secret이다

섹션 제목: “보호가 끝나는 지점 — 런타임 Secret은 평범한 Secret이다”

Sealed Secrets가 지키는 구간은 Git까지다. 컨트롤러가 복호화해 Secret을 만든 순간부터 그것은 다른 Secret과 아무 차이가 없다 — data는 다시 base64일 뿐이라, 읽을 권한이 있는 사람은 원문을 그대로 본다.

터미널 창
kubectl -n observability get secret loki-s3 -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d
# → 봉인해서 커밋한 값이라도 클러스터 안에서는 평문이 나온다

단 클러스터에 kubectl로 붙을 수 있다는 것이 곧 Secret을 읽을 수 있다는 뜻은 아니다. 그 네임스페이스에 get secrets 권한이 있어야 하고, 그것을 정하는 것은 RBAC(Role-Based Access Control, 역할 기반 접근 제어)이다. 누가 읽을 수 있는지는 짐작하지 말고 확인한다.

터미널 창
kubectl auth can-i get secrets -n observability
kubectl auth can-i --list -n observability --as=system:serviceaccount:apps:web | grep secret

그래서 구간마다 지키는 것이 따로 있다. 이 페이지가 다룬 것은 첫 줄 하나뿐이다.

구간무엇이 지키나
Git 저장소Sealed Secrets — 봉인해서 평문을 남기지 않는다
클러스터 안 접근RBAC — 네임스페이스별로 get secrets를 좁힌다
etcd 디스크·스냅샷저장 시 암호화(EncryptionConfiguration)
Argo CD 화면Secret 표시를 끄고 UI 접근을 그룹으로 제한한다

값이 새는 경로의 전체 목록과 대응은 시크릿의 “평문이 새는 곳”에 있다.

sealing key 백업 — 이 시스템의 급소

섹션 제목: “sealing key 백업 — 이 시스템의 급소”

개인키 세트가 사라지면 Git에 있는 모든 암호문이 영원히 안 풀린다. 값이 다른 곳에 남아 있지 않으므로, 클러스터를 다시 세울 때 DB 비밀번호부터 S3 키까지 전부 새로 발급해야 한다. 이 경로를 고른 대가가 정확히 이 지점이다.

터미널 창
# 현재 키 세트 전체를 뜬다 — 이 파일에는 개인키가 평문으로 들어 있다
kubectl -n sealed-secrets get secret \
-l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > sealed-secrets-keys.yaml

키 갱신 — “회전”이라는 말에 속지 않기

섹션 제목: “키 갱신 — “회전”이라는 말에 속지 않기”

컨트롤러는 주기적으로(기본 30일) 새 키 쌍을 만들어 세트에 추가한다. 이때 일어나는 일과 일어나지 않는 일을 가르는 게 중요하다.

실제 동작
새 봉인최신 공개키로 잠근다 (--cert 파일을 쓰면 그 파일의 키)
옛 암호문옛 개인키가 세트에 남아 있으므로 계속 풀린다. 재암호화는 자동으로 일어나지 않는다

그래서 키 갱신은 유출 대응이 아니다. 옛 개인키가 유출됐다면, 그 키로 봉인된 Git 이력의 암호문은 여전히 그 키로 풀린다. 이때 필요한 조치는 키 갱신이 아니라 —

  1. 비밀 값 자체를 원천에서 회전하고 (DB 비밀번호·S3 키 재발급),
  2. 새 키로 재봉인해 커밋하고,
  3. 유출된 옛 키를 세트에서 제거한다 — 그 키의 Secret을 지우고 컨트롤러를 재시작한다.

유출이 아니라 단순히 “옛 키에 걸린 암호문을 줄이고 싶다”면 kubeseal --re-encrypt가 평문을 클러스터 밖으로 꺼내지 않고 컨트롤러 쪽에서 최신 키로 다시 잠근 결과를 돌려준다 — 결과를 커밋해야 반영되는 건 마찬가지다. 갱신 주기와 절차의 정본은 Sealed Secrets의 키 갱신 문서다.

  • 조각은 셋이다 — kubeseal(밖에서 실행하는 CLI), 컨트롤러(안에 상주하는 Deployment), SealedSecret(Git에 올라가는 오브젝트)
  • 설계의 전부는 공개키는 밖·개인키는 안이다. 봉인은 누구나, 복호화는 컨트롤러만
  • Git에 올라가는 것은 참조가 아니라 암호화된 값 그 자체다 — Git + sealing key면 복원이 끝나지만, sealing key를 잃으면 값이 어디에도 남지 않는다
  • 봉인은 하이브리드다 — 값은 일회용 세션 키의 AES-GCM으로, 세션 키는 RSA-OAEP로
  • 스코프는 OAEP label로 강제된다. 이름·네임스페이스가 다르면 검사에 걸리는 게 아니라 복호화가 수학적으로 실패한다
  • 세션 키가 매번 새로 생기므로 재봉인하면 암호문이 항상 바뀐다 — diff로 값 변경을 알 수 없다
  • 컨트롤러가 만든 Secret은 SealedSecret이 소유한다 — 같이 지워지고, 손으로 고쳐도 덮어써진다
  • 보호는 Git까지다 — 복호화된 런타임 Secret은 평범한 Secret이라 그 뒤는 RBAC과 저장 시 암호화가 맡는다. 금고로 바꿔도 이 부분은 같다
  • 키 갱신은 추가지 교체가 아니다. 옛 암호문은 계속 풀리고, 백업은 세트 전체를 갱신마다, 유출 대응은 키가 아니라 값의 회전이다