soobook
INFRA

HashiCorp Vault

secret을 저장만 하지 않고 요청 시점에 발급한다

Intro

secret은 맨날 털린다.

하드코딩된 API Key, .env File, CI 로그에 찍힌 토큰 등등…

심지어 secret이 여러 곳에 흩어지면 누가 무엇에 접근하는지 아무도 모르고, 유출됐을 때 무엇을 갈아야 하는지도 모른다.

HashiCorp Vault는 이 문제를 하나의 Layer로 모아서 처리한다.

모든 secret을 한 곳에 두고, 그 앞에 인증(authentication), 인가(authorization), 감사(audit)를 세운다.

누가(인증) 무엇에(인가) 접근했는지 전부 기록(감사)하고, 저장된 데이터는 암호화 경계 뒤에 숨긴다.

여기까지는 통상적인 금고의 역할이고, Vault를 금고 이상으로 만드는 것은 동적 secret(dynamic secrets)이다.

정적 비밀번호를 보관하는 대신, 요청이 올 때마다 수명이 짧은 자격증명을 즉석에서 만들어 주고 만료되면 스스로 폐기한다.

The Barrier and Sealing

Vault의 저장 구조를 이해하는 열쇠는 barrier다.

barrier란 Vault가 데이터를 storage backend(디스크, S3, Raft 등)에 쓰기 전에 통과시키는 암호화 경계를 말한다.

barrier 안쪽은 신뢰 영역이고 바깥쪽(storage backend)은 신뢰하지 않는 영역이다.

그래서 storage backend에 실제로 남는 것은 언제나 암호문뿐이고, 스토리지를 통째로 훔쳐도 평문 secret은 나오지 않는다.

  • 실제 데이터는 keyring의 encryption key로 암호화된다.
  • 그 encryption key는 root key(마스터 키)로 암호화된다.
  • 그 root key는 다시 unseal key로 암호화된다.

저장되는 것은 언제나 암호문이다.

Vault 프로세스는 시작할 때 sealed(봉인) 상태다. 이때 Vault는 암호문이 어디 있는지는 알지만 root key를 몰라 아무것도 복호화하지 못한다.

unseal(봉인 해제)이란 unseal key를 제공해 root key를 평문으로 복원하는 과정을 말한다. 봉인이 풀려야 비로소 요청을 처리한다.

문제는 이 unseal key를 한 사람이 통째로 쥐면 그 사람이 곧 단일 실패점이자 단일 공격 대상이 된다는 것이다.

Vault의 기본 답은 Shamir’s Secret Sharing이다.

근데 보통은 auto unseal을 쓴다.

auto unseal은 root key의 복호화를 클라우드 KMS나 HSM 같은 외부 신뢰 장치에 위임해, Vault가 재시작할 때 사람 개입 없이 자동으로 봉인을 푼다. 오토스케일링이나 잦은 재시작이 있는 환경에서 사실상 필수다.

Vault 봉인 해제 두 방식 비교. Manual, Shamir 경로는 5조각 중 3개를 사람이 직접 입력해 root key를 복원하고, Auto Unseal 경로는 재시작할 때 Vault가 클라우드 KMS나 HSM을 호출해 root key를 자동으로 복호화한다. 복원된 root key와 unsealed 결과는 두 경로가 같고, 차이는 여는 주체뿐이다

Auth, Policy, Secrets Engines

Unseal된 Vault에 요청이 들어오면 네 단계를 밟는다. 인증, 토큰 발급, secret 작업, 그리고 그 전부를 관통하는 감사다.

먼저 인증(authentication). 클라이언트는 자신이 누구인지 auth method로 증명한다. auth method란 신원 증명 방식을 꽂아 쓰는 Plugin으로, 토큰, AppRole, LDAP, 그리고 쿠버네티스 서비스 어카운트, 클라우드 IAM 등이 있다. 증명에 성공하면 Vault는 그 신원에 정책을 묶은 토큰을 발급한다.

여기서 인가(authorization) 모델은 default-deny다. 정책(policy)이 경로에 대해 명시적으로 허용하지 않는 한 모든 접근은 거부된다. 정책은 “이 경로에 read/create를 허용한다” 식으로 경로 기반으로 쓰고, 토큰에 붙은 정책들의 합집합이 그 토큰의 권한이 된다.

인가를 통과하면 secrets engine에 대고 실제 작업을 한다. secrets engine이란 특정 경로에 마운트돼 secret을 저장하거나 생성하는 컴포넌트로, 두 부류로 갈린다.

  • 정적 secret: kv(key-value) 엔진처럼 우리가 넣어둔 값을 그대로 돌려준다. 기존 비밀번호 금고에 가깝다.
  • 동적 secret: database, aws, pki 엔진처럼 요청이 올 때마다 자격증명을 새로 만든다. 예컨대 database 엔진은 앱이 요청하면 PostgreSQL에 수명 1시간짜리 계정을 만들어 건네고, 1시간 뒤 그 계정을 DB에서 삭제한다.

동적 secret과 서비스 토큰에는 모두 lease가 붙는다. lease란 secret의 임대 계약으로, TTL(time-to-live)이 지나면 Vault가 그 자격증명을 자동으로 회수(revoke)한다. 토큰을 revoke하면 그 토큰으로 만든 모든 lease가 함께 회수된다. 유출된 자격증명의 유효 기간이 짧을수록 피해 반경이 줄어든다는 것이 이 설계의 핵심이다.

마지막으로 감사(audit). 인증 성공 여부, 인가 통과 여부와 무관하게 모든 요청과 응답이 audit device에 기록된다. 누가 언제 무엇을 요청했고 결과가 무엇이었는지가 남으므로, 사고가 났을 때 추적이 된다.

Vault 요청 흐름. 클라이언트가 auth method로 인증하면 정책이 붙은 토큰을 발급받고, 그 토큰으로 secrets engine 경로에 접근한다. 정책이 명시적으로 허용하지 않으면 거부되는 default-deny 모델이고, 인증과 인가의 성공 여부와 무관하게 모든 요청이 audit device에 기록된다

Storage Backends

barrier 바깥에서 암호문을 실제로 보관하는 곳이 storage backend다. 선택지는 크게 넷이다.

  • Integrated Storage (Raft): Vault 자신이 Raft 합의 알고리즘으로 노드끼리 데이터를 복제한다. 외부 의존성이 없고 고가용성(HA)을 지원해, HashiCorp가 대부분의 배포에 권장하는 기본값이다.
  • File system: 단일 노드 로컬 디스크. HA 불가. 개발이나 단일 인스턴스용이다.
  • External: Consul, 클라우드 스토리지, RDBMS 등 외부 시스템에 저장. HA 지원 여부는 백엔드마다 다르다.
  • In-memory: 프로세스 메모리에만 두고 재시작하면 사라진다. 개발/테스트 전용이다.

HA가 필요하면 답은 Integrated Storage(Raft)다. 과거에는 HA를 위해 Consul을 별도로 운영해야 했지만, Raft가 그 외부 의존성을 없앴다. Raft 클러스터는 리더 하나와 팔로워 여럿으로 구성되고, 리더가 죽으면 남은 노드 중 하나가 선출돼 승계한다. 3노드라면 최소 2노드(과반)가 살아 있어야 정족수(quorum)를 유지한다. 노드 간 통신은 API 포트 8200과 별개로 클러스터 포트 8201을 쓴다.

Vault in Kubernetes

쿠버네티스에서는 앞의 “Vault Agent”가 훨씬 매끄럽게 녹아든다. 그 출발점은 파드가 Vault에 어떻게 자신을 증명하느냐다.

Kubernetes Auth Method

파드에 Vault 토큰을 미리 심어 두면, 그 토큰 자체가 또 다른 secret이 되어 “secret을 안전하게 나눠주려다 secret을 하나 더 만드는” 순환에 빠진다.

Kubernetes auth method를 써서 해결할 수 있다.

파드에는 이미 쿠버네티스가 자동으로 마운트해 주는 서비스 어카운트 토큰(ServiceAccount token)이 있다.

파드는 이 토큰을 그대로 Vault에 내밀고, Vault는 쿠버네티스의 TokenReview API에 “이 토큰이 진짜 이 서비스 어카운트의 것이 맞냐”고 물어 검증한다.

검증되면 그 서비스 어카운트에 매핑된 정책이 붙은 Vault 토큰을 발급한다.

즉 파드가 원래부터 갖고 있던 신원(서비스 어카운트)을 “첫 번째 secret”(Secret Zero)으로 삼아, 별도의 Vault 자격증명을 심을 필요를 없앤다. 이것이 뒤따르는 세 가지 연동 방식의 공통 토대다.

세 가지 연동 경로

파드에 실제 secret을 전달하는 방법은 셋이고, 전달 매체가 서로 다르다.

  • Agent Injector (사이드카): mutating admission webhook이 파드 스펙을 가로채, vault.hashicorp.com/agent-inject 같은 어노테이션이 붙은 파드에 Vault Agent 사이드카를 자동으로 끼워 넣는다. 사이드카가 secret을 받아 /vault/secrets/ 아래 메모리 볼륨(tmpfs)에 렌더링하면, Vault를 전혀 모르는 앱 컨테이너가 그냥 그 파일을 읽는다. Vault의 모든 auto-auth 방식을 지원하고, 사이드카가 살아 있으므로 secret 갱신과 회전을 자동으로 해준다. 대신 파드마다 에이전트가 하나씩 붙는 오버헤드가 있다.
  • CSI Provider (볼륨 마운트): Secrets Store CSI Driver를 통해 SecretProviderClass 오브젝트가 정의한 secret을 볼륨으로 마운트한다. secret은 컨테이너 생성(ContainerCreation) 단계에 채워지고, 그때까지 파드는 시작이 막힌다. 노드마다 CSI 드라이버 파드가 하나씩 도는 구조라 파드별 사이드카가 없어 가볍고, Istio 같은 사이드카 충돌에서 자유롭다. 단, Vault Agent를 쓰지 않으므로 자동 회전을 지원하지 않고, auth method도 쿠버네티스 방식만 된다.
  • External Secrets Operator (ESO): SecretStore/ExternalSecret CRD로 Vault의 값을 가져와 네이티브 쿠버네티스 Secret 오브젝트로 실체화한다. 앱은 평범한 Secret을 마운트하므로 기존 매니페스트를 거의 안 바꿔도 된다. 대신 secret이 결국 etcd에 일반 Secret으로 내려앉는다는 점(Vault 밖으로 복제된다)과, Vault provider가 KV 엔진만 지원한다는 제약을 감안해야 한다. PKI 같은 동적 엔진은 이 경로로 못 가져온다.

쿠버네티스에서 Vault secret을 파드로 전달하는 세 경로. Agent Injector는 사이드카가 메모리 볼륨 /vault/secrets에 렌더링해 자동 회전을 지원하고, CSI Provider는 SecretProviderClass로 컨테이너 생성 시 볼륨을 채우며, ESO는 값을 네이티브 쿠버네티스 Secret으로 실체화해 etcd에 내려앉는다. 셋 다 파드의 서비스 어카운트 토큰을 Secret Zero로 삼는 Kubernetes auth method 위에서 인증한다

셋의 선택은 이렇게 갈린다. secret을 Vault 밖으로 내보내지 않고 자동 Rotate까지 원하면 Agent Injector, 사이드카 없이 가볍게 볼륨으로만 받고 싶으면 CSI Provider, 기존 Secret 기반 워크로드를 최소 변경으로 붙이고 싶으면 ESO다.

경로전달 매체자동 회전auth methodsecret이 etcd에?
Agent Injector메모리 볼륨 /vault/secretsO모든 auto-auth아니오
CSI ProviderCSI 볼륨 마운트X쿠버네티스만아니오
ESO네이티브 SecretO(재동기화)다양

PKI Secrets Engine

pki 엔진은 동적 secret 개념을 인증서로 확장한다. PKI secrets engine이란 요청 시점에 X.509 인증서를 즉석에서 발급하는 secrets engine으로, 개인키 생성 → CSR 작성 → CA 제출 → 검증 → 서명이라는 전통적 수작업을 한 번의 API 호출로 대체한다. 서비스 간 mTLS나 내부 TLS 인증서를 사람 손 없이 뽑아낼 수 있다.

핵심 운영 원칙은 root CA를 Vault에 두지 않는 것이다. 권장 구성은 root CA를 오프라인(에어갭)에 두고, Vault에는 root가 서명한 intermediate CA만 올려 실제 leaf 인증서 발급을 맡기는 것이다. 이렇게 하면 Vault가 뚫려도 최상위 신뢰 앵커는 안전하다. 개념을 보이기 위해 아래 예는 root를 Vault 안에서 생성하지만, 실제로는 root를 밖에서 만들고 CSR을 서명해 들여오는 흐름을 쓴다.

# 1. PKI 엔진 활성화, 최대 lease TTL 10년으로 튜닝
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki

# 2. root CA 생성 (개념 시연용. 실전은 오프라인 root 권장)
vault write pki/root/generate/internal \
  common_name="example.com" ttl=87600h

# 3. 발급 URL과 CRL 배포 지점 설정
vault write pki/config/urls \
  issuing_certificates="http://vault.vault:8200/v1/pki/ca" \
  crl_distribution_points="http://vault.vault:8200/v1/pki/crl"

# 4. 역할(role) 정의 — 이 도메인만, 이 최대 수명으로
vault write pki/roles/example-dot-com \
  allowed_domains="example.com" \
  allow_subdomains=true max_ttl="72h"

# 5. 인증서 발급
vault write pki/issue/example-dot-com common_name="api.example.com"

role이 정책의 역할을 한다. allowed_domains로 발급 가능한 도메인을 못 박고 max_ttl로 수명을 제한한다. 여기서 TTL을 짧게 유지하는 것이 PKI 운영의 핵심 철학이다. 인증서 수명이 짧으면 폐기(revocation) 자체가 거의 필요 없어지고, CRL(인증서 폐기 목록)이 짧게 유지되며, 인스턴스마다 메모리에만 사는 일회성 인증서를 쓸 수 있다. 긴 수명 인증서를 발급하고 유출 시 폐기에 매달리는 대신, 애초에 금방 만료되게 만드는 쪽이다.

cert-manager 연동

쿠버네티스라면 인증서 발급을 cert-manager에 맡기고 Vault를 그 뒤의 CA로 세우는 조합이 자연스럽다. cert-manager의 Vault Issuer는 Vault의 PKI 엔진을 발급 백엔드로 삼아, Certificate 리소스가 생기면 Vault로 서명 요청을 보내 X.509 인증서를 자동 발급하고 갱신한다.

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: vault-issuer
  namespace: default
spec:
  vault:
    server: http://vault.vault:8200
    path: pki/sign/example-dot-com     # 반드시 sign 엔드포인트
    auth:
      kubernetes:
        role: issuer                    # Vault 쪽 role
        mountPath: /v1/auth/kubernetes
        secretRef:
          name: issuer-token
          key: token

두 가지 디테일이 자주 발목을 잡는다. 하나, spec.vault.path는 반드시 sign 엔드포인트(pki/sign/...)를 가리켜야 한다. 둘, 쿠버네티스 1.24부터는 서비스 어카운트 토큰 Secret이 자동 생성되지 않으므로, Issuer가 참조할 kubernetes.io/service-account-token 타입 Secret을 명시적으로 만들어 줘야 한다. Vault 서버가 클러스터 안에 있으면 위처럼 Kubernetes auth를, 클러스터 밖에 있으면 JWT/OIDC auth를 쓰는 것이 권장된다.

이렇게 붙이면 애플리케이션은 Certificate 하나만 선언하면 되고, 실제 인증서의 발급과 만료 전 갱신은 cert-manager와 Vault가 알아서 처리한다.

References