Kubernetes Secret不安全?External Secrets Operator接入凭据管理服务实战,自动轮转零停机

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 移交给外部的专业凭据管理服务

核心流程:

  1. ESO Controller 根据 ExternalSecret CRD 的定义,定期从外部凭据管理服务拉取最新凭据
  2. 拉取后对比本地 K8s Secret ------ 如果一致则跳过
  3. 如果不一致(凭据已轮转),更新 K8s Secret
  4. 如果配置了 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 跨命名空间访问

现象SecretStoreproduction 命名空间,但 ExternalSecretstaging 命名空间报告 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 的接入流程:

  1. 不再有硬编码:所有凭据从外部专业凭据管理服务获取,代码和配置文件零凭据
  2. 自动轮转:密码定期变更,应用通过 Volume Mount 自动感知,无需重启
  3. 双密钥窗口:新旧密码共存过渡期,彻底消除轮转引发的连接中断
  4. 完整审计:凭据管理服务记录每一次读取、每一次轮转,满足等保合规要求
  5. 统一管控:多个 K8s 集群、多个命名空间的凭据在一个平台上集中管理

相较于自建 HashiCorp Vault 的运维复杂度(HA 集群、存储后端、升级维护),国产商用凭据管理服务提供了开箱即用的 Vault 兼容 API、双密钥窗口、国密算法支持、以及中文管理界面------对于没有专职 SRE 团队的中型企业,是落地 DevSecOps 凭据管理的务实选择。


💬 话题讨论:你所在团队的 K8s 集群凭据是怎么管理的?还在用 base64 Secret 吗?欢迎评论区分享你的实践经验和踩坑故事。

相关推荐
网宿安全演武实验室1 小时前
当AI跑进容器:全链路容器安全检测与智能运营实
人工智能·安全·容器·k8s
老赵聊算法、大模型备案5 小时前
《人工智能应用伦理安全指引1.0》发布
人工智能·安全
Geometry Fu5 小时前
《智能终端与边缘计算》第六章 边缘计算安全平台
人工智能·安全·边缘计算·智能终端
一点事6 小时前
docker:安装oracle 19c
docker·oracle·容器
实在智能RPA7 小时前
实在Agent针对金融行业Agent灾备与高可用是如何进行设计的?深度拆解金融级智能体的架构安全与连续性保障
人工智能·安全·ai·金融·架构
宋浮檀s8 小时前
春秋云镜——CVE-2022-22965
网络·安全·web安全·网络安全
Geometry Fu8 小时前
《智能终端与边缘计算》第四章 边缘计算安全
人工智能·安全·边缘计算·智能终端
2601_959477918 小时前
Vatee:信息披露与运营规范性的评测参考
大数据·人工智能·安全