K8s Secret 本质上只是 base64 编码,etcd 中明文存储,RBAC 权限一泄漏全部凭据暴露。本文用 External Secrets Operator(ESO)将凭据管理从 K8s 中剥离,实现数据库密码、API Key、TLS证书的自动注入与透明轮转——服务零重启、零中断。
一、K8s Secret 的四个「原罪」
1.1 base64 不是加密
# 创建一个 Secret$echo-n"MyDbP@ssw0rd123"|base64TXlEYkBzc3cwcmQxMjM=# 三秒还原$echo"TXlEYkBzc3cwcmQxMjM="|base64-dMyDbP@ssw0rd123base64 只是传输编码,不是加密。任何能kubectl get secret的人都等于拿到了明文。
1.2 etcd 默认明文存储
K8s 1.13 之前,etcd 完全不支持加密。即使现在支持EncryptionConfiguration,调查显示超过 60% 的生产集群从未开启 etcd 静态加密(来源:CNCF 2025 安全审计报告)。
# 这才是 etcd 加密的正确配置——但多数集群没配apiVersion:apiserver.config.k8s.io/v1kind:EncryptionConfigurationresources:-resources:-secretsproviders:-aescbc:keys:-name:key1secret:<base64-encoded-32-byte-key>-identity:{}# 明文兜底——迁移期必须保留1.3 RBAC 权限泄漏 = 全员裸奔
# 这个看似无害的 ClusterRole 实际可以读取所有命名空间的 SecretapiVersion:rbac.authorization.k8s.io/v1kind:ClusterRolemetadata:name:secret-readerrules:-apiGroups:[""]resources:["secrets"]verbs:["get","list","watch"]# ← 任何一个 ServiceAccount 绑定此角色即可抓取全部凭据在实际攻防演练中,攻击者拿下任意 Pod 后,只需:
# 从 Pod 内部利用挂载的 ServiceAccount Token 拉取 Secret$TOKEN=$(cat/var/run/secrets/kubernetes.io/serviceaccount/token)$curl-k-H"Authorization: Bearer$TOKEN"\https://kubernetes.default.svc/api/v1/secrets1.4 没有轮转机制
Secret 轮转在原生 K8s 中的标准流程:
# 步骤1:更新 Secretkubectl create secret generic db-creds --from-literal=password=newpass123 --dry-run=client-oyaml|kubectl apply-f-# 步骤2:滚动重启所有消费该 Secret 的 Deploymentkubectl rollout restart deployment/user-service deployment/order-service deployment/payment-service# 步骤3:祈祷没有 Pod 在重启过程中用旧密码连接数据库失败... 🤞这套流程的问题:
- 需要人工编排,无法自动化
- 重启期间服务有瞬断风险
- 旧密码何时失效?没有"双密钥窗口"机制
- 审计日志缺失——谁在什么时候轮转了哪个凭据?
二、External Secrets Operator:正确的姿势
2.1 ESO 是什么?
External Secrets Operator(ESO)是 CNCF Sandbox 项目,它做的事很简单:把凭据的管理权从 K8s 移交给外部的专业凭据管理服务。
核心流程:
- ESO Controller 根据
ExternalSecretCRD 的定义,定期从外部凭据管理服务拉取最新凭据 - 拉取后对比本地 K8s Secret —— 如果一致则跳过
- 如果不一致(凭据已轮转),更新 K8s Secret
- 如果配置了
spec.target.creationPolicy: Owner,还支持 Deployment 自动滚动更新
2.2 ESO 安装
# 使用 Helm 安装 ESOhelm repoaddexternal-secrets https://charts.external-secrets.io helm repo update helm upgrade--installexternal-secrets\external-secrets/external-secrets\--namespaceexternal-secrets-system\--create-namespace\--setinstallCRDs=true\--wait# 验证kubectl get pods-nexternal-secrets-system# NAME READY STATUS RESTARTS AGE# external-secrets-6d8f9b7c4f-xxxxx 1/1 Running 0 30s# external-secrets-cert-controller-5b7c9d8f6-xxxxx 1/1 Running 0 30s# external-secrets-webhook-7d8f6b5c4f-xxxxx 1/1 Running 0 30s三、实战:三步接入凭据管理服务
本节以国产商用凭据管理服务(支持 HashiCorp Vault 兼容 API)为例,演示完整接入流程。
3.1 第一步:创建 SecretStore(凭据源定义)
# secretstore.yamlapiVersion:external-secrets.io/v1beta1kind:SecretStoremetadata:name:vault-backendnamespace:productionspec:provider:vault:# 凭据管理服务的地址(Vault 兼容 API)server:"https://credential-manager.internal:8200"path:"kv"# KV v2 引擎路径version:"v2"# 认证方式:Kubernetes ServiceAccount JWTauth:kubernetes:# ServiceAccount Token 挂载路径mountPath:"kubernetes"# K8s 集群中 ServiceAccount 对应的 Vault Rolerole:"production-app"# ServiceAccount 的 JWT 文件路径(Pod 内默认挂载)serviceAccountRef:name:"eso-sa"# TLS 配置(生产环境必须开启)caBundle:|-----BEGIN CERTIFICATE----- MIIDXTCCAkWgAwIBAgIJALxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ... -----END CERTIFICATE--------# ServiceAccount(用于 ESO 向凭据管理服务认证)apiVersion:v1kind:ServiceAccountmetadata:name:eso-sanamespace:production在凭据管理服务侧配置 Vault Role:
# 在凭据管理服务中创建 K8s 认证 Rolevaultwriteauth/kubernetes/role/production-app\bound_service_account_names=eso-sa\bound_service_account_namespaces=production\policies=production-read\ttl=1h3.2 第二步:创建 ExternalSecret(凭据映射)
# externalsecret.yamlapiVersion:external-secrets.io/v1beta1kind:ExternalSecretmetadata:name:database-credentialsnamespace:productionspec:# 刷新间隔:每小时自动同步一次(也可以设置更长,配合 webhook 触发)refreshInterval:"1h"# 关联的 SecretStoresecretStoreRef:name:vault-backendkind:SecretStore# 目标 K8s Secrettarget:name:db-credentialscreationPolicy:Owner# ESO 管理此 Secret 的完整生命周期deletionPolicy:Retain# 删除 ExternalSecret 时保留 K8s Secret(安全兜底)template:type:Opaquemetadata:labels:managed-by:external-secretsrotated-by:credential-managerdata:# 将凭据管理服务中的字段映射为 application.properties 格式application.properties:|spring.datasource.url=jdbc:mysql://{{ .host }}:{{ .port }}/{{ .database }} spring.datasource.username={{ .username }} spring.datasource.password={{ .password }}# 从凭据管理服务中拉取的具体路径data:-secretKey:username# K8s Secret 中的 keyremoteRef:key:"database/creds/production/readonly"# 凭据管理服务中的路径property:"username"# JSON 字段名-secretKey:passwordremoteRef:key:"database/creds/production/readonly"property:"password"-secretKey:hostremoteRef:key:"database/creds/production/readonly"property:"host"-secretKey:portremoteRef:key:"database/creds/production/readonly"property:"port"-secretKey:databaseremoteRef:key:"database/creds/production/readonly"property:"database"在凭据管理服务中写入测试数据:
# Vault CLI 操作vault kv put kv/database/creds/production/readonly\username="app_readonly"\password="InitialP@ssw0rd2024"\host="mysql-primary.production.svc.cluster.local"\port="3306"\database="ecommerce"3.3 第三步:部署应用消费凭据
# deployment.yamlapiVersion:apps/v1kind:Deploymentmetadata:name:user-servicenamespace:productionspec:replicas:3selector:matchLabels:app:user-servicetemplate:metadata:labels:app:user-servicespec:serviceAccountName:eso-sacontainers:-name:appimage:myregistry/user-service:1.2.3envFrom:# 方式一:直接注入为环境变量(适用于少量凭据)-secretRef:name:db-credentials# 方式二:挂载为文件(适用于 application.properties)volumeMounts:-name:configmountPath:/app/configreadOnly:truevolumes:-name:configsecret:secretName:db-credentialsitems:-key:application.propertiespath:application.properties验证凭据是否成功同步:
# 查看 ESO 状态$ kubectl get externalsecret-nproduction NAME STORE REFRESH INTERVAL STATUS READY database-credentials vault-backend 1h Updated True# 详细状态(含最后一次同步时间)$ kubectl describe externalsecret database-credentials-nproduction... Status: Conditions: Last Transition Time:2026-05-26T08:30:00Z Message: Secret synced successfully Reason: SecretSynced Status: True Type: Ready Refresh Time:2026-05-26T08:30:00Z# 查看生成的 K8s Secret$ kubectl get secret db-credentials-nproduction-ojsonpath='{.data.password}'|base64-dInitialP@ssw0rd2024四、凭据自动轮转:零停机实战
这是 ESO 最大的价值——轮转过程应用无需重启。
4.1 轮转流程
4.2 Spring Boot 凭据热加载实现
为了让应用感知凭据变更而无需重启,需要通过 ReloadableProperties 或 Spring Cloud Config 监听文件变化:
packagecom.example.config;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.cloud.context.config.annotation.RefreshScope;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.context.annotation.Primary;importcom.zaxxer.hikari.HikariConfig;importcom.zaxxer.hikari.HikariDataSource;importjavax.sql.DataSource;/** * 数据库连接池配置——支持凭据热加载 * * 当 K8s Secret 挂载卷更新后, * Spring Cloud Bus / Actuator refresh 端点触发 HikariCP 重建连接池 */@ConfigurationpublicclassDynamicDataSourceConfig{@Value("${spring.datasource.url}")privateStringurl;@Value("${spring.datasource.username}")privateStringusername;@Value("${spring.datasource.password}")privateStringpassword;@Value("${spring.datasource.driver-class-name:com.mysql.cj.jdbc.Driver}")privateStringdriverClassName;@Bean@Primary@RefreshScope// ← 关键:支持热刷新publicDataSourcedataSource(){HikariConfigconfig=newHikariConfig();config.setJdbcUrl(url);config.setUsername(username);config.setPassword(password);config.setDriverClassName(driverClassName);// 连接池优化配置config.setMaximumPoolSize(20);config.setMinimumIdle(5);config.setIdleTimeout(300000);// 5分钟空闲超时config.setConnectionTimeout(10000);// 10秒连接超时config.setMaxLifetime(1200000);// 20分钟最大生命周期(短于轮转间隔)// 启用连接存活检测——凭据变更后可主动淘汰旧连接config.setKeepaliveTime(60000);// 每60秒保活检测returnnewHikariDataSource(config);}}轮转触发脚本(结合 Reloader 或手动 refresh):
#!/bin/bash# rotate-credentials.sh — 凭据轮转触发脚本NAMESPACE="production"EXTERNAL_SECRET="database-credentials"# Step 1: 在凭据管理服务中轮转密码(Vault API 示例)echo"[1/4] Rotating credentials in Vault..."vaultwrite-fdatabase/rotate-root/mysql-production# Step 2: 等待新凭据生成sleep5# Step 3: 强制 ESO 立即同步(通过 annotation trigger)echo"[2/4] Triggering ESO refresh..."kubectl annotate externalsecret${EXTERNAL_SECRET}\-n${NAMESPACE}\force-sync="$(date+%s)"\--overwrite# Step 4: 等待 K8s Secret 更新echo"[3/4] Waiting for K8s Secret propagation..."sleep10# Step 5: 触发应用配置热刷新(Spring Cloud Bus)echo"[4/4] Triggering application config refresh..."kubectlexec-n${NAMESPACE}\deploy/user-service\--curl-s-XPOST http://localhost:8080/actuator/refreshecho"✅ Credential rotation complete!"4.3 轮转验证
# 轮转前$ kubectl get secret db-credentials-nproduction\-ojsonpath='{.data.password}'|base64-dInitialP@ssw0rd2024# 执行轮转$ ./rotate-credentials.sh# 轮转后$ kubectl get secret db-credentials-nproduction\-ojsonpath='{.data.password}'|base64-dNewR0tatedP@ssw0rd_2026-05-26# 确认应用连接池使用了新密码(检查 HikariCP metrics)$curl-shttp://user-service:8080/actuator/metrics/hikaricp.connections.active{"name":"hikaricp.connections.active","measurements":[{"statistic":"VALUE","value":8.0}], // 有活跃连接=连接正常"availableTags":[{"tag":"pool","values":["HikariPool-1"]}]}# 查看凭据管理服务的审计日志$ vault audit list-detailed# 2026-05-26T08:30:00Z database/rotate-root/mysql-production success# 2026-05-26T08:30:02Z database/creds/production/readonly rotated五、踩坑记录
5.1 TLS 证书信任链问题
现象:ESO 日志报x509: certificate signed by unknown authority
根因:凭据管理服务使用内部 CA 签发的证书,ESO Pod 不信任该 CA。
解决:
# 在 SecretStore 中明确指定 CA Bundlespec:provider:vault:server:"https://credential-manager.internal:8200"caBundle:|-----BEGIN CERTIFICATE----- # 粘贴内部 CA 的根证书 -----END CERTIFICATE-----# 或者将 CA 证书注入 ESO Pod(推荐方式)caProvider:type:"ConfigMap"name:"internal-ca-bundle"key:"ca.crt"5.2 跨命名空间访问
现象:SecretStore在production命名空间,但ExternalSecret在staging命名空间报告SecretStore not found
根因:SecretStore默认仅在自身命名空间内可用。
解决:使用ClusterSecretStore代替SecretStore(集群级别):
apiVersion:external-secrets.io/v1beta1kind:ClusterSecretStore# ← 集群级别metadata:name:vault-globalspec:conditions:-namespaceSelector:# ← 通过标签控制哪些 ns 可用matchLabels:eso-enabled:"true"provider:vault:# ... 同上5.3 轮转延迟窗口
现象:凭据管理服务已轮转密码,但 Pod 仍使用旧密码连接,持续几分钟后报Access denied
根因:refreshInterval设为1h,轮转后 ESO 需等到下一个刷新周期才同步。
解决一:缩短刷新间隔(成本低,推荐常规场景)
spec:refreshInterval:"5m"# 缩短到5分钟,API 调用频率增加有限解决二:Webhook 触发(成本高,推荐敏感凭据场景)
# 在凭据管理服务中配置 webhook,凭据变更时通知 ESO# ESO 需要暴露 webhook receiver endpoint解决三:双密钥窗口(最佳实践)
在凭据管理服务侧,所有轮转操作默认保留旧密码30分钟的有效期。即使 ESO 未及时同步,旧密码仍然可用,避免Access denied。
5.4 环境变量不自动更新
注意:Kubelet不会自动更新注入为envFrom的环境变量。如果 Pod 通过环境变量读取密码,必须重启 Pod 才能使用新凭据。
推荐做法:始终使用Volume Mount方式消费凭据:
# ❌ 不推荐:环境变量——轮转后不更新envFrom:-secretRef:name:db-credentials# ✅ 推荐:卷挂载——Kubelet 自动同步文件内容(默认每60-90秒)volumeMounts:-name:db-credsmountPath:/etc/secrets/dbreadOnly:truevolumes:-name:db-credssecret:secretName:db-credentials六、生产环境配置清单
# 完整生产级 ExternalSecret 示例apiVersion:external-secrets.io/v1beta1kind:ExternalSecretmetadata:name:production-db-credentialsnamespace:productionlabels:app:user-servicesecurity-tier:criticalannotations:# 关键:轮转时自动触发 Reloader 重启关联 Deploymentreloader.stakater.com/auto:"true"spec:refreshInterval:"5m"# 生产环境缩短刷新间隔secretStoreRef:name:vault-globalkind:ClusterSecretStoretarget:name:db-credentialscreationPolicy:OwnerdeletionPolicy:Retain# 安全兜底:删除 CR 时保留 Secrettemplate:type:Opaquemetadata:labels:managed-by:esoauto-rotated:"true"rotation-schedule:"weekly"annotations:last-rotated-at:""# 由自动化流程注入时间戳data:application.properties:|spring.datasource.url=jdbc:mysql://{{ .host }}:{{ .port }}/{{ .database }}?useSSL=true&requireSSL=true spring.datasource.username={{ .username }} spring.datasource.password={{ .password }} spring.datasource.hikari.maximumPoolSize=20 spring.datasource.hikari.maxLifetime=600000 # 10min < 双密钥窗口(30min)data:-secretKey:usernameremoteRef:key:"database/creds/production/app"property:"username"-secretKey:passwordremoteRef:key:"database/creds/production/app"property:"password"-secretKey:hostremoteRef:key:"database/creds/production/app"property:"host"-secretKey:portremoteRef:key:"database/creds/production/app"property:"port"-secretKey:databaseremoteRef:key:"database/creds/production/app"property:"database"七、总结
本文从 K8s Secret 的四个安全短板出发,完整演示了 External Secrets Operator 的接入流程:
- 不再有硬编码:所有凭据从外部专业凭据管理服务获取,代码和配置文件零凭据
- 自动轮转:密码定期变更,应用通过 Volume Mount 自动感知,无需重启
- 双密钥窗口:新旧密码共存过渡期,彻底消除轮转引发的连接中断
- 完整审计:凭据管理服务记录每一次读取、每一次轮转,满足等保合规要求
- 统一管控:多个 K8s 集群、多个命名空间的凭据在一个平台上集中管理
相较于自建 HashiCorp Vault 的运维复杂度(HA 集群、存储后端、升级维护),国产商用凭据管理服务提供了开箱即用的 Vault 兼容 API、双密钥窗口、国密算法支持、以及中文管理界面——对于没有专职 SRE 团队的中型企业,是落地 DevSecOps 凭据管理的务实选择。
💬 话题讨论:你所在团队的 K8s 集群凭据是怎么管理的?还在用 base64 Secret 吗?欢迎评论区分享你的实践经验和踩坑故事。