Sealed Secrets의 동작 원리
이 장에서 처음 나오는 말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을 그냥 커밋할 수 없나
섹션 제목: “왜 Secret을 그냥 커밋할 수 없나”출발점은 오해 하나를 걷는 것이다. 쿠버네티스 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 | 클러스터 안 — 개인키를 쥐고 있다 |
| SealedSecret | CRD로 등록된 오브젝트 | 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/v1alpha1kind: SealedSecretmetadata: name: loki-s3 namespace: observabilityspec: 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에서 그대로
읽고 고칠 수 있다.
전체 흐름 한 바퀴
섹션 제목: “전체 흐름 한 바퀴”세 조각이 실제로 어떻게 맞물리는지 값 하나를 끝까지 따라가 본다.
-
평문
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에 비밀번호를 그대로 쓰면 셸 히스토리에 남기 때문이다. -
kubeseal로 봉인한다. 평문이 암호문으로 바뀐
SealedSecretYAML이 나온다.터미널 창 kubeseal --controller-namespace sealed-secrets --format yaml \< /tmp/secret.yaml > platform/loki/sealedsecret-s3.yamlrm /tmp/secret.yaml -
암호문을 커밋한다. 이제 저장소를 읽는 사람이 늘어도 값은 새지 않는다.
-
Argo CD가
SealedSecret을 클러스터에 적용한다. 이 시점까지 값은 여전히 잠겨 있다. -
컨트롤러가 복호화해
Secret을 만든다. 개인키 세트를 차례로 시도해 풀고, 같은 이름·네임스페이스에 평범한Secret을 생성한다. -
파드가 그
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만으로는 못 다룬다. 그래서 하이브리드다.
순서대로 보면 이렇다.
data의 값마다 일회용 세션 키를 무작위로 만든다.- 값을 그 세션 키의 AES-GCM으로 잠근다 — 길이 제한이 없고, 나중에 암호문이 조작됐으면 복호화가 실패한다.
- 세션 키를 컨트롤러의 공개키 + 스코프 label의 RSA-OAEP로 잠근다.
- 둘을 이어 붙인 것이
SealedSecret의encryptedData항목 하나가 된다.
값마다 독립적으로 잠기기 때문에 한 값만 갈아끼우는 조작(--merge-into, 값 하나만
암호화하는 --raw)이 가능하다. 형식의 정확한 정의는
Sealed Secrets 암호화 설계 문서에 있다.
공개키는 어디서 오나
섹션 제목: “공개키는 어디서 오나”kubeseal은 기본으로 클러스터의 컨트롤러에게서 현재 인증서(공개키)를 받아 온다.
클러스터 접속 없이 쓰려면 인증서를 한 번 받아 파일로 두고 --cert로 준다.
# 현재 공개키를 파일로 받아 둔다 → 이후 봉인은 클러스터 접속 없이 가능kubeseal --controller-namespace sealed-secrets --fetch-cert > sealed-secrets-public.pemkubeseal --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 observabilitykubectl 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 이력의 암호문은 여전히 그 키로 풀린다. 이때 필요한 조치는 키 갱신이 아니라 —
- 비밀 값 자체를 원천에서 회전하고 (DB 비밀번호·S3 키 재발급),
- 새 키로 재봉인해 커밋하고,
- 유출된 옛 키를 세트에서 제거한다 — 그 키의
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과 저장 시 암호화가 맡는다. 금고로 바꿔도 이 부분은 같다 - 키 갱신은 추가지 교체가 아니다. 옛 암호문은 계속 풀리고, 백업은 세트 전체를 갱신마다, 유출 대응은 키가 아니라 값의 회전이다