Si has montado Vault en Kubernetes, tarde o temprano te topas con el momento incómodo: un pod se reinicia, arranca sealed y alguien tiene que ir a mano con las claves de Shamir a desbloquearlo. Son minutos, sí, pero mientras Vault está sealed no da servicio, y todo lo que depende de él se queda esperando. Y si el reinicio viene de una actualización de nodos de GKE, el problema se repite pod a pod.
Antes de llevarlo a un entorno real, hice un spike en un proyecto de GCP aparte para tenerlo claro: Vault en HA con Raft en GKE y auto-unseal con Cloud KMS. Te cuento cómo quedó y, sobre todo, dónde está la parte que de verdad da guerra: no es el bloque seal "gcpckms", que son cuatro campos, sino conseguir que el pod pueda autenticarse contra Google sin llevar ninguna credencial encima.
Qué hace el auto-unseal
Vault cifra sus datos con una clave (la root key), y esa clave a su vez se guarda protegida. Por defecto se protege con las claves de Shamir que repartes entre personas. Con auto-unseal pasa a protegerla una clave de KMS: cuando un pod arranca, le pide a KMS que descifre la root key y se desbloquea solo. Vault nunca ve la clave de KMS, solo le pide que cifre y descifre.
Un detalle que conviene tener claro desde el principio: con auto-unseal, al inicializar ya no te dan unseal keys sino recovery keys. Sirven para operaciones sensibles (regenerar el token root, por ejemplo), pero no pueden descifrar la root key, así que si KMS deja de estar disponible no te sacan del apuro. Guárdalas igual de bien.
El problema del huevo y la gallina
Aquí hay una trampa conceptual: Vault es quien guarda los secretos de tu plataforma, pero para arrancar necesita credenciales de Google para hablar con KMS. Y esas credenciales no pueden estar... en Vault.
La salida limpia es no tener credenciales. Nada de JSON de service account montado como secret, que además sería un secreto que hay que guardar y rotar fuera de Vault. Con Workload Identity, el pod de Vault se identifica ante Google con su propia identidad de Kubernetes y no hay ninguna clave que custodiar.
Paso 1: el cluster
Lo único que cambia respecto a un cluster normal es el workload pool y las APIs que hay que habilitar. Estos son los comandos, recortados a lo importante:
PROJECT_ID="<tu-proyecto>"
WORKLOAD_POOL="${PROJECT_ID}.svc.id.goog"
gcloud services enable \
container.googleapis.com \
iam.googleapis.com \
cloudkms.googleapis.com
gcloud container clusters create vault-dev-cluster \
--zone=us-central1-a \
--num-nodes=3 \
--machine-type=e2-small \
--disk-size=50 \
--workload-pool=${WORKLOAD_POOL} \
--enable-ip-alias
--workload-pool es el que activa Workload Identity en el cluster. Sin él, lo demás no funciona aunque el Terraform esté perfecto. Tres nodos e2-small para un spike. Ojo con la cuota: con el disco de arranque por defecto (100 GB por nodo) me falló la creación por la cuota SSD_TOTAL_GB de la región (250 GB en us-central1, en mi proyecto). Añadir --disk-size=50 al comando lo resolvió.
Paso 2: la clave y la identidad, con Terraform
Tres piezas: la clave de KMS, una service account de Google (GSA) con permisos solo sobre esa clave, y el enlace entre la GSA y la ServiceAccount de Kubernetes.
resource "google_service_account" "vault_sa" {
account_id = var.vault_sa # "vault-sa"
display_name = "Vault Service Account"
}
resource "google_kms_key_ring" "vault" {
name = "vault-keyring"
location = "global"
}
resource "google_kms_crypto_key" "vault" {
name = "vault-key"
key_ring = google_kms_key_ring.vault.id
rotation_period = "86400s" # 1 día, solo para el spike
purpose = "ENCRYPT_DECRYPT"
}
# Cifrar y descifrar con la clave
resource "google_kms_crypto_key_iam_binding" "vault_encrypter_decrypter" {
crypto_key_id = google_kms_crypto_key.vault.id
role = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
members = [google_service_account.vault_sa.member]
}
# Vault también consulta los metadatos de la clave al arrancar
resource "google_kms_crypto_key_iam_binding" "vault_viewer" {
crypto_key_id = google_kms_crypto_key.vault.id
role = "roles/cloudkms.viewer"
members = [google_service_account.vault_sa.member]
}
# Workload Identity: "esta ServiceAccount de Kubernetes puede actuar como esta GSA"
resource "google_service_account_iam_binding" "vault_ksa_binding" {
service_account_id = google_service_account.vault_sa.name
role = "roles/iam.workloadIdentityUser"
members = [
"serviceAccount:${var.project_id}.svc.id.goog[${var.k8s_namespace}/${var.vault_sa}]"
]
}
Hay varias cosas que te van a ahorrar tiempo:
Tres permisos, y dos roles en mi caso. La documentación de Vault pide useToEncrypt, useToDecrypt y cryptoKeys.get sobre la clave. Los dos primeros los da cryptoKeyEncrypterDecrypter; el tercero hay que añadirlo aparte, y es un error fácil de cometer porque el nombre de ese rol parece cubrirlo todo. Yo usé cloudkms.viewer, que lo incluye pero es más amplio de lo necesario. Lo comprobé: sin ese permiso el pod no arranca (Permission 'cloudkms.cryptoKeys.get' denied), y un rol personalizado solo con cryptoKeys.get sobre la clave fue suficiente para que arrancara.
El member de Workload Identity tiene un formato muy concreto: PROYECTO.svc.id.goog[NAMESPACE/NOMBRE_SA]. El namespace y el nombre de la ServiceAccount de Kubernetes tienen que coincidir exactamente con los del pod. Aquí son vault/vault-sa, y si el chart creara la SA con otro nombre, simplemente no funcionaría y Google no te avisa de nada.
iam_binding es autoritativo. Sustituye a todos los miembros que tuvieran ese rol sobre esa clave, no añade uno. Para un spike me da igual, pero en un proyecto compartido usaría iam_member, que solo añade.
La rotación. Puse la clave a rotar cada día solo para pruebas. Un valor realista son 30 días, porque cada versión activa de la clave cuesta dinero hasta que se destruye. Y rotar tiene una consecuencia para Vault que no es obvia, la cuento más abajo.
Algo que no tiene mi spike pero pondría: un lifecycle { prevent_destroy = true } en la clave. Si alguien la borra, los datos de Vault quedan indescifrables y ningún backup de Raft te salva, porque lo que contiene está cifrado con una clave que ya no existe. Es el único sitio de todo el montaje donde un terraform destroy despistado tiene consecuencias de las de verdad.
Este Terraform lo apliqué tal cual (con el proveedor google v8.7.0) y creó los seis recursos. Una cosa a tener en cuenta si lo repites en el mismo proyecto: Cloud KMS no permite borrar un key ring, así que tendrás que cambiarle el nombre.
Si al aplicar el Terraform te aparece un error de application default credentials, es tu máquina y no el proyecto: el propio mensaje ya trae la solución, gcloud auth application-default login.
Paso 3: Vault con el Helm chart oficial
Con la identidad lista, la parte de Vault ocupa unas pocas líneas de values. Estos son los values que importan, simplificados (he quitado probes, recursos y logging, que no son relevantes aquí):
server:
image:
repository: "hashicorp/vault"
tag: "2.0.3"
# Solo para el spike; el valor por defecto del chart es OnDelete
updateStrategyType: "RollingUpdate"
ha:
enabled: true
replicas: 2
raft:
enabled: true
config: |
ui = true
listener "tcp" {
address = "[::]:8200"
cluster_address = "[::]:8201"
tls_disable = 1
}
storage "raft" {
path = "/vault/data"
retry_join {
leader_api_addr = "http://vault-0.vault-internal.vault.svc.cluster.local:8200"
}
retry_join {
leader_api_addr = "http://vault-1.vault-internal.vault.svc.cluster.local:8200"
}
}
seal "gcpckms" {
project = "<tu-proyecto>"
region = "global"
key_ring = "vault-keyring"
crypto_key = "vault-key"
}
serviceAccount:
create: true
name: vault-sa
annotations:
iam.gke.io/gcp-service-account: "vault-sa@<tu-proyecto>.iam.gserviceaccount.com"
Fíjate en dos cosas. Primero, en el bloque seal "gcpckms" no hay ninguna credencial: si Workload Identity está bien montado, Vault las obtiene del metadata server de forma transparente. Segundo, la anotación iam.gke.io/gcp-service-account es la mitad de Kubernetes del enlace que declaramos antes en Terraform. El enlace se declara en dos sitios, y si solo haces uno de los dos, falla igual.
Un detalle más: si creas el binding de Workload Identity justo antes de instalar, el primer arranque del pod puede fallar con un 403 en getAccessToken. Es la propagación de IAM: a mí se recuperó solo en unos cuatro minutos, sin tocar nada.
Un apunte sobre las dos réplicas: es lo mínimo para ver cómo un nodo se une al cluster y se desbloquea solo, pero con dos servidores el quórum es de dos y la tolerancia a fallos es cero, según la tabla de la documentación de Raft en Vault. Es decir, cada reinicio de un pod es un corte. Para algo real, esa misma documentación recomienda cinco. Y tls_disable = 1 es cosa del spike: no lo copies a un entorno con tráfico real.
Paso 4: inicializar y comprobar que funciona de verdad
Con los dos pods arrancados, inicializa Vault en el primero. El comando funciona sin más flags y, con auto-unseal, te da cinco recovery keys y el token root:
kubectl -n vault exec vault-0 -- vault operator init
Qué hacer con esas claves en un Vault de verdad (no dejarlas en la pantalla de nadie) da para otro post.
Después, dentro del pod (kubectl -n vault exec -it vault-0 -- sh) y con vault login con el token root que te dio el init, vault status y las líneas que miro son estas:
vault status
# Seal Type gcpckms
# Sealed false
# Storage Type raft
# HA Enabled true
Y esperar a que los dos pods aparezcan como votantes en el cluster de Raft:
until [ "$(vault operator raft list-peers | awk '{print $4}' | grep -c true)" -eq 2 ]; do
echo "Esperando a que los pods se unan al cluster de Raft..."
sleep 5
done
vault operator raft list-peers
vault operator raft autopilot state
Pero lo que de verdad me convence es la prueba de reiniciar todo y ver si vuelve solo, sin que nadie toque nada. Ojo con lo que mide: comprueba el auto-unseal, no que el servicio aguante un reinicio (de eso hablo más abajo). El chart de Helm de Vault usa OnDelete por defecto, que es lo correcto en un entorno real. Para pruebas rápidas lo cambio a RollingUpdate en los values (por eso aparece en el snippet del paso 3), y así reinicio todos los pods con un solo comando. Sin ese cambio, rollout restart no reinicia nada con el valor por defecto. Pero fíjate en por qué el valor por defecto es el que es: cada reinicio o actualización provoca un corte corto por la elección de líder, y con RollingUpdate no controlas el orden. Kubernetes no sabe nada de los roles de Vault y va del ordinal más alto al más bajo. Imagina un cluster de cinco nodos en el que el líder es vault-4: el liderazgo puede pasar a vault-3, que Kubernetes reinicia a continuación, luego a vault-2, y así sucesivamente, con varias elecciones y varios cortes cortos. Con OnDelete controlas tú el flujo (borras los pods de uno en uno, primero los standbys y el líder el último), a cambio de necesitar permisos de borrado en el namespace de Vault. Para esta comprobación, sin tráfico, me vale RollingUpdate:
kubectl -n vault rollout restart statefulset vault
kubectl -n vault rollout status statefulset vault --timeout=300s
rollout status espera a que cada pod esté Ready, y con auto-unseal eso significa que se ha desbloqueado solo.
Después vuelve a ejecutar las mismas comprobaciones. Si los dos pods vuelven a estar Sealed false y como votantes sin que nadie les haya dado ninguna clave, el auto-unseal funciona. Es más barato descubrir que un eslabón está roto en el spike que un sábado de madrugada.
Lo que el auto-unseal no arregla
El auto-unseal quita a la persona del camino, pero no quita el reinicio. Un pod que arranca sigue sealed hasta que KMS le descifra la root key, y mientras tanto no atiende peticiones. Lo que pasa alrededor depende de a quién reinicies, y esto es lo que dice la documentación de Raft en Vault:
- Un standby en un cluster de tres o más: el líder sigue sirviendo y no hay corte.
- El nodo activo: el cluster tiene que elegir un líder nuevo. Con los valores por defecto (
performance_multiplier = 5), los timeouts de heartbeat y de elección son del orden de cinco segundos. Son segundos y no minutos, pero no son cero. En mi spike lo medí una vez, con Vault 2.0.3: borré el pod activo de dos votantes y consulté/v1/sys/leadercada medio segundo desde otro pod. Pasaron unos 16 segundos entre la primera petición fallida y el último momento sin líder. Es una sola ejecución, tómalo como orden de magnitud. - Todos a la vez: sin una mayoría de nodos arrancados y desbloqueados no hay líder, y el cluster no está disponible. Con auto-unseal esa espera ya no depende de nadie, pero existe.
- Dos nodos, como en mi spike: cualquier reinicio es un corte, por lo del quórum.
Y un detalle más sobre cómo reinicias. La guía oficial de upgrade en Kubernetes insiste en usar OnDelete y no RollingUpdate, porque hay que actualizar los standbys antes que el activo: borrar los pods de uno en uno, sacando antes cada standby de Raft con vault operator raft remove-peer, y el activo el último. Para la prueba del spike, sin tráfico real, RollingUpdate me resulta cómodo y, por el mismo motivo, me salto lo de sacar el nodo de Raft. Con tráfico en producción lo eficiente es reducir las elecciones de líder al mínimo, como hace la guía: primero los standbys y el líder el último. Una práctica mía, que la guía no cuenta: antes de borrar el pod del líder hago vault operator step-down. Así traspaso yo el liderazgo a propósito, en vez de dejar que lo resuelva Vault al darse cuenta de que el líder ha desaparecido. Es una sola elección y controlada.
Cuando no arranca
El síntoma es siempre el mismo (Vault no puede hablar con KMS y se queda sealed) aunque la causa esté en tres sitios distintos. El orden en el que miraría, de más barato a más caro:
# 1. ¿La anotación de la ServiceAccount es la que crees?
kubectl -n vault get sa vault-sa -o yaml | grep iam.gke.io
# 2. ¿El pod dice quién es? (orientativo, no prueba nada por sí solo)
kubectl -n vault exec vault-0 -- \
wget -qO- --header "Metadata-Flavor: Google" \
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email
Cuidado: esa llamada no prueba la identidad. Con la ServiceAccount anotada pero sin el binding, /email sigue devolviendo el email de la GSA. Y con una ServiceAccount sin anotar devuelve PROYECTO.svc.id.goog. La prueba de verdad es pedir un token:
kubectl -n vault exec vault-0 -- \
wget -qO- --header "Metadata-Flavor: Google" \
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token
Si devuelve un token y el email es el de tu GSA, la identidad funciona y el problema está en los permisos sobre la clave (¿falta el viewer?) o en el nombre del key ring o la región del bloque seal. Si devuelve 403 Forbidden, la anotación está pero falta el binding o no coincide su member. Y si el email es PROYECTO.svc.id.goog, a la ServiceAccount le falta la anotación. Ese discriminador te ahorra mucho dar vueltas.
Rotar la clave de KMS: qué versión usa Vault de verdad
Si te quedas con una sola cosa de esta sección, que sea esta: no destruyas las versiones viejas de la clave de KMS, ni siquiera después de rotarla y de reiniciar los pods. Vault puede seguir necesitándolas para desbloquearse.
Imagina que Vault se inicializó cuando la primaria era la versión 1 y que luego rotas la clave hasta la 4. La root key quedó cifrada con la 1 y sigue así: según la documentación de Cloud KMS, rotar no re-cifra nada ni desactiva las versiones antiguas, y la del seal de GCP dice que no se deben deshabilitar ni borrar porque se usan para descifrar datos antiguos.
Hasta ahí, lógico. Lo que esperaba es que, al arrancar un pod después de rotar, Vault re-cifrase la root key con la versión primaria, porque en el código hay una función (UpgradeKeys, en seal_autoseal.go) que lo hace cuando el identificador de la clave no coincide con el actual. Lo probé en Vault 2.0.3 y no es tan simple.
Para saber cuándo una versión deshabilitada ha dejado de funcionar de verdad, cifré un fichero con esa versión y lo descifré con gcloud kms decrypt cada 30 segundos. Tras deshabilitarla, el primer fallo llegó a algo más de 60 segundos y tres seguidos a los 125: un descifrado a los 30 segundos todavía funcionaba, y quien se fíe de él pensará que la versión ya no hace falta. Cada prueba fue: Vault limpio (chart 0.34.0, dos votantes), rotar la clave una vez, hacer lo que dice la tabla, deshabilitar la versión con la que se inicializó, esperar a que el descifrado fallase tres veces seguidas, borrar los dos pods y mirar si arrancaban.
| Qué hice tras rotar la clave | Pruebas | Arrancaron con la versión vieja deshabilitada |
|---|---|---|
| Nada | 2 | 0 de 2 |
| Reiniciar el standby | 1 | 0 de 1 |
| Borrar el pod activo y, cuando vuelve, borrarlo otra vez | 3 | 2 de 3 |
vault operator step-down del activo y borrar el antiguo activo |
4 | 2 de 4 |
Cuando no arrancaron, el error fue cryptoKeyVersions/1 is not enabled, current state is: DISABLED, y volver a habilitar la versión los recuperó sin tocar Vault. En el log, cada arranque dice value has no key-id in common with currently healthy seals: Vault sabe que el identificador no coincide.
Lo que importa son las dos últimas filas: el mismo escenario dio resultados distintos. Ni un reinicio, ni un failover ni un step-down garantizan que la root key se re-cifre, y el log no marca cuándo ocurre. No sé qué lo dispara, y desde fuera no se puede saber. Tampoco probé si esperar horas lo hace solo. Son pocas pruebas en un único cluster de dos votantes, pero para la regla basta con que a veces no ocurra.
Lo que la documentación recoge como formas explícitas de re-cifrar la root key es un rekey de la root key (con auto-unseal, presentando recovery keys) o un cambio en el bloque seal, que dispara una migración. No lo confundas con vault operator rekey -target=recovery, que cambia las recovery keys, ni con vault operator rotate, que rota el keyring interno de Vault: ninguno toca la root key.
Si Vault sigue con la root key cifrada con la versión 1 y la destruyes, no podrá desbloquearse, y las recovery keys no te salvan, porque no pueden descifrar la root key.
Hay una forma reversible de comprobarlo en un spike: rota la clave, cifra un fichero con la versión vieja, deshabilítala (no la destruyas) y espera a que el descifrado de ese fichero falle tres veces seguidas. Entonces borra los pods. Si Vault arranca, ya no dependía de ella; si no arranca, vuelve a habilitarla y todo sigue como estaba. Repítelo, porque un resultado solo no demuestra nada, y hazlo en un entorno de pruebas, nunca en uno con tráfico.
Para cerrar
Lo curioso es que la parte "de Vault" de todo esto ocupa una docena de líneas y el resto del post trata de identidad. Con el auto-unseal pasa lo que con los certificados automáticos: en cuanto lo tienes bien montado, te olvidas de que existe, que es justo para lo que se hace. La diferencia entre acordarte de desbloquear Vault y que Vault se desbloquee solo está en una cadena de cuatro eslabones (clave, GSA, ServiceAccount, anotación) donde cualquiera que falle da el mismo síntoma.
Una última cosa: este spike fue en un cluster sin mesh. Después lo llevé a un entorno con Istio en modo ambient, con ztunnel, y ahí aparecen cuestiones propias que dejo para otro post.
Mi consejo: monta la prueba de reiniciar los pods antes de que te haga falta de verdad, y repítela cada vez que toques la configuración de identidad.