Certified Kubernetes Security Specialist (CKS) #10 Secrets 관리: etcd 암호화, External Secrets

#9 Pod Security Admission에서 위험한 Pod를 입구에서 거부하는 정책을 잡았다면, 이번 글은 그 Pod가 다루는 비밀 데이터 자체를 어떻게 지키는가입니다. Minimize Microservice Vulnerabilities도메인의 한 축인 Secret 보안은, 쿠버네티스 Secret이 기본 상태로는 안전하지 않다는 불편한 사실에서 시작합니다. 이번 글에서는 etcd at rest 암호화, 기존 Secret 재암호화, 평문 여부 확인, 그리고 External Secrets와 KMS 연동까지 정리하겠습니다.

Secret은 기본적으로 암호화가 아니다 #

가장 먼저 바로잡아야 할 오해가 있습니다. 쿠버네티스 Secret은 암호화된 저장소가 아닙니다. Secret 객체의 데이터는 etcd에 base64로 인코딩되어 들어갈 뿐이며, base64는 누구나 한 줄 명령으로 되돌릴 수 있는 단순 인코딩입니다.

이 차이를 직접 확인해 보겠습니다. Secret을 하나 만들고 그 값을 그대로 디코딩하면, 평문이 그대로 나옵니다.

# Secret 생성
k create secret generic db-cred \
  --from-literal=password=SuperSecret123

# 저장된 값 확인 (base64)
k get secret db-cred -o jsonpath='{.data.password}'
# U3VwZXJTZWNyZXQxMjM=

# base64 디코딩: 평문이 그대로 드러남
k get secret db-cred -o jsonpath='{.data.password}' | base64 -d
# SuperSecret123

base64는 보안 장치가 아니라 바이너리 안전 전송을 위한 인코딩입니다. 즉 etcd 데이터에 접근할 수 있는 사람은 Secret을 평문으로 읽을 수 있습니다. etcd는 디스크에 저장되므로, etcd 파일을 탈취하거나 etcd 백업을 손에 넣은 공격자도 마찬가지입니다.

그래서 Secret 보안은 두 방향으로 접근합니다. 첫째, **저장 시점 암호화(encryption at rest)**로 etcd에 들어가는 데이터 자체를 암호문으로 만듭니다. 둘째, 접근 통제로 Secret을 읽을 수 있는 주체를 RBAC로 최소화합니다. 이번 글은 첫째에 무게를 두고, 둘째는 #4 RBAC 최소 권한과 묶어 마무리하겠습니다.

etcd at rest 암호화의 구조 #

쿠버네티스는 apiserver가 etcd에 데이터를 쓰기 직전에 암호화하고, 읽어 올 때 복호화하는 encryption at rest 기능을 제공합니다. 핵심은 apiserver에 EncryptionConfiguration이라는 설정 파일을 물려 주는 것입니다.

흐름은 단순합니다.

  1. 어떤 리소스(보통 secrets)를 어떤 provider로 암호화할지 EncryptionConfiguration에 기술한다
  2. apiserver에 --encryption-provider-config 플래그로 이 파일 경로를 넘긴다
  3. apiserver를 재시작한다
  4. 설정 이후 새로 쓰이는 Secret은 암호문으로 etcd에 들어간다
  5. 이미 들어 있던 Secret은 한 번 다시 써 줘야 암호화된다(재암호화)

여기서 자주 놓치는 지점이 5번입니다. 암호화 설정은 그 이후의 쓰기에만 적용되므로, 기존 Secret을 다시 저장하지 않으면 etcd에는 여전히 평문이 남아 있습니다.

provider 종류 #

EncryptionConfiguration의 providers순서가 있는 목록입니다. 쓰기에는 목록의 첫 번째 provider가 쓰이고, 읽기에는 일치하는 provider를 위에서부터 찾아 씁니다. 시험에서 외워 둘 provider는 다음과 같습니다.

provider성격비고
identity암호화 안 함(평문)기본값. 목록 첫째면 사실상 비활성화
aescbcAES-CBC 대칭키32바이트 키. 널리 쓰이는 기본 선택
secretboxXSalsa20+Poly130532바이트 키. aescbc 대안
aesgcmAES-GCM키 회전을 자주 해야 하는 점에 주의
kms외부 KMS 연동권장 방식. 키를 클러스터 밖에서 관리

identity는 “암호화하지 않음"을 뜻하는 특수 provider입니다. 목록의 첫 번째identity가 오면 새 쓰기는 평문이 되고, 마지막에 두면 아직 암호화되지 않은 기존 데이터를 읽기 위한 폴백으로 동작합니다. 이 순서의 의미가 시험 함정으로 자주 나옵니다.

EncryptionConfiguration 작성 #

실제 파일을 작성해 보겠습니다. aescbcsecrets를 암호화하는 가장 전형적인 형태입니다.

# /etc/kubernetes/enc/enc.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <base64로 인코딩한 32바이트 키>
      - identity: {}

읽는 순서를 다시 짚겠습니다. resources 아래에 암호화 대상 리소스(secrets)를 적고, providers에 쓰기용 provider(aescbc)를 맨 위에 둡니다. 맨 아래의 identity: {}암호화 설정 이전에 평문으로 저장된 기존 Secret을 읽기 위한 폴백입니다. 이 폴백이 없으면 재암호화 전의 기존 Secret을 apiserver가 읽지 못해 장애가 납니다.

키는 무작위 32바이트를 base64로 인코딩해 만듭니다.

# 32바이트 무작위 키를 base64로
head -c 32 /dev/urandom | base64
# 이 출력값을 위 YAML의 secret 자리에 채움

apiserver에 연결 #

apiserver는 static Pod이므로, 매니페스트를 직접 편집해 플래그와 볼륨을 추가합니다. kubeadm 기준 경로는 /etc/kubernetes/manifests/kube-apiserver.yaml입니다.

# /etc/kubernetes/manifests/kube-apiserver.yaml (발췌)
spec:
  containers:
    - command:
        - kube-apiserver
        - --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
        # ...기존 플래그...
      volumeMounts:
        - name: enc
          mountPath: /etc/kubernetes/enc
          readOnly: true
  volumes:
    - name: enc
      hostPath:
        path: /etc/kubernetes/enc
        type: DirectoryOrCreate

세 가지를 함께 손봐야 합니다. 첫째 --encryption-provider-config 플래그로 설정 파일 경로를 지정하고, 둘째 그 파일이 든 디렉터리를 volumes로 노드에서 끌어오고, 셋째 컨테이너 안에 volumeMounts로 마운트합니다. 플래그만 추가하고 볼륨을 빠뜨리면 apiserver가 설정 파일을 찾지 못해 기동에 실패합니다. static Pod이므로 매니페스트를 저장하면 kubelet이 apiserver를 자동으로 재시작합니다.

apiserver가 다시 떴는지 확인합니다.

# apiserver Pod 재기동 확인
k -n kube-system get pod -l component=kube-apiserver

# 기동에 실패하면 kubelet 로그로 원인 추적
journalctl -u kubelet -f

기존 Secret 재암호화 #

앞서 강조한 5번 단계로, 암호화 설정은 이후의 쓰기에만 적용되므로 이미 etcd에 들어 있던 Secret은 한 번 다시 저장해야 암호문으로 바뀝니다. 모든 네임스페이스의 Secret을 읽어 그대로 다시 쓰는 한 줄이 정석입니다.

# 전 네임스페이스 Secret을 읽어 그대로 replace: 재암호화
k get secrets -A -o json | k replace -f -

get ... -o json으로 현재 Secret 전체를 끌어와 replace로 그대로 덮어쓰면, apiserver가 쓰기 시점에 새 provider로 암호화해 etcd에 저장합니다. 데이터 내용은 바뀌지 않고 etcd에 저장되는 표현만 평문에서 암호문으로 바뀝니다.

규모가 큰 클러스터라면 특정 네임스페이스만 골라 돌리거나 리소스 종류를 좁혀 부담을 줄일 수도 있습니다. 다만 시험에서는 위 한 줄로 충분합니다.

평문 여부 확인 #

암호화가 실제로 걸렸는지는 etcd를 직접 읽어 확인합니다. apiserver를 거치면 복호화된 값이 나오므로 의미가 없고, etcd에 저장된 raw 바이트를 봐야 합니다. etcdctl로 Secret 키를 직접 조회합니다.

# etcd에 직접 접속해 Secret raw 값을 hexdump
ETCDCTL_API=3 etcdctl \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/default/db-cred | hexdump -C | head

판별 기준은 간단합니다.

  • 출력 앞부분에 k8s:enc:aescbc:v1:key1 같은 prefix가 보이면 암호화 성공입니다. 뒤따르는 바이트는 사람이 읽을 수 없는 암호문입니다
  • 반대로 SuperSecret123 같은 평문이 그대로 보이면 암호화가 안 된 상태입니다. 재암호화를 빠뜨렸거나 provider 순서가 잘못된 것입니다

이 prefix 확인이 시험에서 “암호화가 적용되었는지 검증하라"는 작업의 정답 근거가 됩니다.

External Secrets와 KMS #

etcd 암호화는 클러스터 안에서 데이터를 보호하지만, 키와 비밀의 생애주기 자체를 클러스터 밖에서 관리하고 싶을 때가 있습니다. 여기서 등장하는 것이 KMS provider와 External Secrets Operator입니다.

KMS provider #

EncryptionConfiguration의 provider로 kms를 쓰면, 암호화 키를 etcd나 노드 디스크가 아니라 외부 KMS(예: 클라우드 KMS, HashiCorp Vault의 transit)에서 관리합니다. apiserver는 데이터 암호화 키(DEK)로 Secret을 암호화하고, 그 DEK를 다시 KMS의 키 암호화 키(KEK)로 감쌉니다. KEK는 KMS 밖으로 나오지 않으므로, etcd나 노드만 탈취해서는 복호화할 수 없습니다.

# KMS provider 예시 (발췌)
resources:
  - resources:
      - secrets
    providers:
      - kms:
          apiVersion: v2
          name: myKmsPlugin
          endpoint: unix:///var/run/kmsplugin/socket.sock
      - identity: {}

KMS provider는 별도의 KMS 플러그인 프로세스를 노드에서 소켓으로 연결해 동작합니다. 운영 환경에서 권장되는 방식이며, aescbc처럼 키를 평문으로 설정 파일에 적어 두지 않는다는 점이 핵심 이점입니다.

External Secrets Operator #

방향이 다른 접근도 있습니다. **External Secrets Operator(ESO)**는 비밀을 쿠버네티스가 아니라 외부 비밀 저장소(AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, HashiCorp Vault 등)에 두고, 그 값을 동기화해 클러스터 안의 Secret 객체로 만들어 주는 컨트롤러입니다.

운영 흐름은 다음과 같습니다.

  1. 비밀의 원본은 외부 저장소에만 둔다(클러스터에는 원본이 없음)
  2. SecretStore 또는 ClusterSecretStore로 외부 저장소 접속 정보를 정의한다
  3. ExternalSecret 리소스로 “외부의 어떤 키를 어떤 Secret으로 가져올지” 매핑한다
  4. ESO가 주기적으로 외부 값을 읽어 쿠버네티스 Secret을 생성,갱신한다

이렇게 하면 비밀의 단일 출처가 외부 저장소가 되어, 회전,감사,접근 통제를 그 저장소에서 일원화할 수 있습니다. 시험에서 ESO 설치를 직접 시키는 일은 드물지만, “비밀을 클러스터 밖에서 관리한다"는 개념과 KMS와의 차이는 알아 두는 편이 좋습니다. 정리하면 KMS는 etcd에 들어가는 데이터를 외부 키로 암호화하는 방식이고, ESO는 비밀의 원본 자체를 외부에 두고 동기화하는 방식입니다.

Secret 접근은 RBAC로 최소화 #

암호화를 아무리 단단히 걸어도, Secret을 읽을 수 있는 주체가 넓으면 의미가 줄어듭니다. etcd at rest 암호화는 저장 매체를 탈취당했을 때를 막고, RBAC는 정상 경로로 Secret을 읽는 주체를 막습니다. 두 방어선은 서로를 대체하지 못하고 함께 가야 합니다.

#4 RBAC 최소 권한에서 다룬 원칙을 Secret에 그대로 적용합니다.

  • Secret에 대한 get,list,watch 권한을 꼭 필요한 ServiceAccount와 사용자에게만 부여한다
  • 와일드카드(resources: ["*"], verbs: ["*"])로 Secret 접근을 흘리지 않는다
  • 특정 Secret만 필요한 경우 resourceNames로 대상을 그 Secret으로 좁힌다
# 특정 Secret만 읽게 하는 Role
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: app
  name: read-db-cred
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["db-cred"]
    verbs: ["get"]

Secret을 Pod에 넘기는 방법도 보안에 영향을 줍니다. #12 ConfigMap과 Secret 깊이에서 본 것처럼, 환경 변수보다 볼륨 마운트가 더 안전한 선택입니다. 환경 변수는 자식 프로세스로 상속되고 일부 도구에서 노출되기 쉬운 반면, 볼륨 마운트는 파일 권한으로 통제하기 수월하고 회전된 값이 자동 반영되는 이점도 있습니다.

시험 포인트 #

CKS 시험에서 Secret 보안은 etcd at rest 암호화 활성화가 압도적인 단골 작업입니다. 다음 순서를 손에 익혀 두겠습니다.

  • Secret은 base64일 뿐 암호화가 아니다. etcd에 평문 수준으로 저장된다는 전제를 기억합니다
  • EncryptionConfiguration을 작성합니다. resources: [secrets], providersaescbc(또는 secretbox/kms)를 맨 위, identity를 맨 아래에 둡니다
  • apiserver static Pod에 --encryption-provider-config 플래그 + volume + volumeMount 세 가지를 함께 추가합니다. 볼륨을 빠뜨려 기동 실패하는 실수를 피합니다
  • 기존 Secret 재암호화를 잊지 않습니다. k get secrets -A -o json | k replace -f -로 한 번 다시 씁니다
  • 검증은 etcdctl로 직접 합니다. raw 출력에 k8s:enc:aescbc:v1: prefix가 보이면 성공, 평문이 보이면 실패입니다
  • provider 순서의 의미(첫째=쓰기, 마지막 identity=기존 평문 읽기 폴백)를 정확히 답할 수 있어야 합니다
  • 개념 문항 대비로 KMS provider와 External Secrets Operator의 차이를 한 줄로 설명할 수 있게 해 둡니다

정리 #

이번 글에서 잡은 것:

  • Secret은 기본적으로 etcd에 base64로만 저장됩니다. base64는 인코딩일 뿐 암호화가 아니므로 etcd에 접근하면 평문이 드러납니다
  • EncryptionConfiguration + --encryption-provider-config 플래그로 secrets를 at rest 암호화합니다. provider는 aescbc,secretbox,kms 중 선택하고, identity를 폴백으로 맨 아래에 둡니다
  • 설정은 이후 쓰기에만 적용되므로 기존 Secret을 k get secrets -A -o json | k replace -f -로 재암호화해야 합니다
  • 검증은 etcdctl로 raw 값을 읽어 prefix 존재 여부로 판별합니다
  • KMS provider는 키를 외부에서 관리하고, External Secrets Operator는 비밀 원본을 외부 저장소에 두고 동기화합니다
  • 암호화와 별개로 Secret 접근을 RBAC로 최소화해야 두 방어선이 함께 작동합니다

다음: 격리(gVisor) #

비밀 데이터는 etcd 수준에서 지켰습니다. 이제 워크로드 자체를 호스트 커널에서 떼어 내는 격리로 넘어갑니다.

#11 격리: gVisor, Kata Containers, RuntimeClass에서는 컨테이너가 호스트 커널을 직접 호출하는 구조의 위험, gVisor가 시스템 콜을 사용자 공간에서 가로채 격리하는 원리, Kata Containers가 경량 VM으로 커널을 분리하는 방식, 그리고 RuntimeClass로 특정 Pod만 샌드박스 런타임에 올리는 패턴을 직접 만들어 보며 정리하겠습니다.

X