K8s Secret 本质上只是 base64 编码,etcd 中明文存储,RBAC 权限一泄漏全部凭据暴露。本文用 External Secrets Operator(ESO)将凭据管理从 K8s 中剥离,实现数据库密码、API Key、TLS证书的自动注入与透明轮转------服务零重启、零中断。
一、K8s Secret 的四个「原罪」
1.1 base64 不是加密
bash
# 创建一个 Secret
$ echo -n "MyDbP@ssw0rd123" | base64
TXlEYkBzc3cwcmQxMjM=
# 三秒还原
$ echo "TXlEYkBzc3cwcmQxMjM=" | base64 -d
MyDbP@ssw0rd123
base64 只是传输编码,不是加密。任何能 kubectl get secret 的人都等于拿到了明文。
1.2 etcd 默认明文存储
K8s 1.13 之前,etcd 完全不支持加密。即使现在支持 EncryptionConfiguration,调查显示 超过 60% 的生产集群从未开启 etcd 静态加密(来源:CNCF 2025 安全审计报告)。
yaml
# 这才是 etcd 加密的正确配置------但多数集群没配
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {} # 明文兜底------迁移期必须保留
1.3 RBAC 权限泄漏 = 全员裸奔
yaml
# 这个看似无害的 ClusterRole 实际可以读取所有命名空间的 Secret
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"] # ← 任何一个 ServiceAccount 绑定此角色即可抓取全部凭据
在实际攻防演练中,攻击者拿下任意 Pod 后,只需:
bash
# 从 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/secrets
1.4 没有轮转机制
Secret 轮转在原生 K8s 中的标准流程:
bash
# 步骤1:更新 Secret
kubectl create secret generic db-creds --from-literal=password=newpass123 --dry-run=client -o yaml | kubectl apply -f -
# 步骤2:滚动重启所有消费该 Secret 的 Deployment
kubectl 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 安装
bash
# 使用 Helm 安装 ESO
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm upgrade --install external-secrets \
external-secrets/external-secrets \
--namespace external-secrets-system \
--create-namespace \
--set installCRDs=true \
--wait
# 验证
kubectl get pods -n external-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(凭据源定义)
yaml
# secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: vault-backend
namespace: production
spec:
provider:
vault:
# 凭据管理服务的地址(Vault 兼容 API)
server: "https://credential-manager.internal:8200"
path: "kv" # KV v2 引擎路径
version: "v2"
# 认证方式:Kubernetes ServiceAccount JWT
auth:
kubernetes:
# ServiceAccount Token 挂载路径
mountPath: "kubernetes"
# K8s 集群中 ServiceAccount 对应的 Vault Role
role: "production-app"
# ServiceAccount 的 JWT 文件路径(Pod 内默认挂载)
serviceAccountRef:
name: "eso-sa"
# TLS 配置(生产环境必须开启)
caBundle: |
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJALxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...
-----END CERTIFICATE-----
---
# ServiceAccount(用于 ESO 向凭据管理服务认证)
apiVersion: v1
kind: ServiceAccount
metadata:
name: eso-sa
namespace: production
在凭据管理服务侧配置 Vault Role:
bash
# 在凭据管理服务中创建 K8s 认证 Role
vault write auth/kubernetes/role/production-app \
bound_service_account_names=eso-sa \
bound_service_account_namespaces=production \
policies=production-read \
ttl=1h
3.2 第二步:创建 ExternalSecret(凭据映射)
yaml
# externalsecret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-credentials
namespace: production
spec:
# 刷新间隔:每小时自动同步一次(也可以设置更长,配合 webhook 触发)
refreshInterval: "1h"
# 关联的 SecretStore
secretStoreRef:
name: vault-backend
kind: SecretStore
# 目标 K8s Secret
target:
name: db-credentials
creationPolicy: Owner # ESO 管理此 Secret 的完整生命周期
deletionPolicy: Retain # 删除 ExternalSecret 时保留 K8s Secret(安全兜底)
template:
type: Opaque
metadata:
labels:
managed-by: external-secrets
rotated-by: credential-manager
data:
# 将凭据管理服务中的字段映射为 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 中的 key
remoteRef:
key: "database/creds/production/readonly" # 凭据管理服务中的路径
property: "username" # JSON 字段名
- secretKey: password
remoteRef:
key: "database/creds/production/readonly"
property: "password"
- secretKey: host
remoteRef:
key: "database/creds/production/readonly"
property: "host"
- secretKey: port
remoteRef:
key: "database/creds/production/readonly"
property: "port"
- secretKey: database
remoteRef:
key: "database/creds/production/readonly"
property: "database"
在凭据管理服务中写入测试数据:
bash
# 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 第三步:部署应用消费凭据
yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
serviceAccountName: eso-sa
containers:
- name: app
image: myregistry/user-service:1.2.3
envFrom:
# 方式一:直接注入为环境变量(适用于少量凭据)
- secretRef:
name: db-credentials
# 方式二:挂载为文件(适用于 application.properties)
volumeMounts:
- name: config
mountPath: /app/config
readOnly: true
volumes:
- name: config
secret:
secretName: db-credentials
items:
- key: application.properties
path: application.properties
验证凭据是否成功同步:
bash
# 查看 ESO 状态
$ kubectl get externalsecret -n production
NAME STORE REFRESH INTERVAL STATUS READY
database-credentials vault-backend 1h Updated True
# 详细状态(含最后一次同步时间)
$ kubectl describe externalsecret database-credentials -n production
...
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 -n production -o jsonpath='{.data.password}' | base64 -d
InitialP@ssw0rd2024
四、凭据自动轮转:零停机实战
这是 ESO 最大的价值------轮转过程应用无需重启。
4.1 轮转流程

4.2 Spring Boot 凭据热加载实现
为了让应用感知凭据变更而无需重启,需要通过 ReloadableProperties 或 Spring Cloud Config 监听文件变化:
java
package com.example.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
/**
* 数据库连接池配置------支持凭据热加载
*
* 当 K8s Secret 挂载卷更新后,
* Spring Cloud Bus / Actuator refresh 端点触发 HikariCP 重建连接池
*/
@Configuration
public class DynamicDataSourceConfig {
@Value("${spring.datasource.url}")
private String url;
@Value("${spring.datasource.username}")
private String username;
@Value("${spring.datasource.password}")
private String password;
@Value("${spring.datasource.driver-class-name:com.mysql.cj.jdbc.Driver}")
private String driverClassName;
@Bean
@Primary
@RefreshScope // ← 关键:支持热刷新
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
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秒保活检测
return new HikariDataSource(config);
}
}
轮转触发脚本(结合 Reloader 或手动 refresh):
bash
#!/bin/bash
# rotate-credentials.sh --- 凭据轮转触发脚本
NAMESPACE="production"
EXTERNAL_SECRET="database-credentials"
# Step 1: 在凭据管理服务中轮转密码(Vault API 示例)
echo "[1/4] Rotating credentials in Vault..."
vault write -f database/rotate-root/mysql-production
# Step 2: 等待新凭据生成
sleep 5
# 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..."
sleep 10
# Step 5: 触发应用配置热刷新(Spring Cloud Bus)
echo "[4/4] Triggering application config refresh..."
kubectl exec -n ${NAMESPACE} \
deploy/user-service \
-- curl -s -X POST http://localhost:8080/actuator/refresh
echo "✅ Credential rotation complete!"
4.3 轮转验证
bash
# 轮转前
$ kubectl get secret db-credentials -n production \
-o jsonpath='{.data.password}' | base64 -d
InitialP@ssw0rd2024
# 执行轮转
$ ./rotate-credentials.sh
# 轮转后
$ kubectl get secret db-credentials -n production \
-o jsonpath='{.data.password}' | base64 -d
NewR0tatedP@ssw0rd_2026-05-26
# 确认应用连接池使用了新密码(检查 HikariCP metrics)
$ curl -s http://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。
解决:
yaml
# 在 SecretStore 中明确指定 CA Bundle
spec:
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(集群级别):
yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore # ← 集群级别
metadata:
name: vault-global
spec:
conditions:
- namespaceSelector: # ← 通过标签控制哪些 ns 可用
matchLabels:
eso-enabled: "true"
provider:
vault:
# ... 同上
5.3 轮转延迟窗口
现象 :凭据管理服务已轮转密码,但 Pod 仍使用旧密码连接,持续几分钟后报 Access denied
根因 :refreshInterval 设为 1h,轮转后 ESO 需等到下一个刷新周期才同步。
解决一:缩短刷新间隔(成本低,推荐常规场景)
yaml
spec:
refreshInterval: "5m" # 缩短到5分钟,API 调用频率增加有限
解决二:Webhook 触发(成本高,推荐敏感凭据场景)
yaml
# 在凭据管理服务中配置 webhook,凭据变更时通知 ESO
# ESO 需要暴露 webhook receiver endpoint
解决三:双密钥窗口(最佳实践)
在凭据管理服务侧,所有轮转操作默认保留旧密码 30分钟 的有效期。即使 ESO 未及时同步,旧密码仍然可用,避免 Access denied。
5.4 环境变量不自动更新
注意 :Kubelet 不会 自动更新注入为 envFrom 的环境变量。如果 Pod 通过环境变量读取密码,必须重启 Pod 才能使用新凭据。
推荐做法 :始终使用 Volume Mount 方式消费凭据:
yaml
# ❌ 不推荐:环境变量------轮转后不更新
envFrom:
- secretRef:
name: db-credentials
# ✅ 推荐:卷挂载------Kubelet 自动同步文件内容(默认每60-90秒)
volumeMounts:
- name: db-creds
mountPath: /etc/secrets/db
readOnly: true
volumes:
- name: db-creds
secret:
secretName: db-credentials
六、生产环境配置清单
yaml
# 完整生产级 ExternalSecret 示例
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: production-db-credentials
namespace: production
labels:
app: user-service
security-tier: critical
annotations:
# 关键:轮转时自动触发 Reloader 重启关联 Deployment
reloader.stakater.com/auto: "true"
spec:
refreshInterval: "5m" # 生产环境缩短刷新间隔
secretStoreRef:
name: vault-global
kind: ClusterSecretStore
target:
name: db-credentials
creationPolicy: Owner
deletionPolicy: Retain # 安全兜底:删除 CR 时保留 Secret
template:
type: Opaque
metadata:
labels:
managed-by: eso
auto-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: username
remoteRef:
key: "database/creds/production/app"
property: "username"
- secretKey: password
remoteRef:
key: "database/creds/production/app"
property: "password"
- secretKey: host
remoteRef:
key: "database/creds/production/app"
property: "host"
- secretKey: port
remoteRef:
key: "database/creds/production/app"
property: "port"
- secretKey: database
remoteRef:
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 吗?欢迎评论区分享你的实践经验和踩坑故事。