CI/CD 中的密钥注入方案SOPS、Sealed Secrets 与 Vault一、你的数据库密码躺在 Git 仓库里而安全意识开会还要等三周CI/CD 流水线里最让人头疼的不是构建失败不是测试超时而是密钥管理。数据库密码、API Key、证书私钥——这些敏感信息怎么安全地进入流水线的运行环境大多数团队的做法极其原始把密钥 base64 编码后放在 CI 变量里或者更糟——直接提交到配置文件里然后在 code review 中被提醒删掉……但历史记录里永远留下了。三种主流方案各有优劣没有银弹。Mozilla SOPS 适合小团队的低成本起步Sealed Secrets 适合纯 Kubernetes 环境HashiCorp Vault 适合有统一密钥管理需求的较大组织。这三种方案不是互斥的——一套成熟的 CI/CD 系统往往会组合使用。关键在于理解每种方案的信任链。密钥注入的安全水位不是由你用了什么工具决定的而是由从密钥存储到运行时环境的这整条链路有多少点可能泄露决定的。二、底层机制与原理剖析三种方案的核心差异SOPS密钥加密后直接存储在 Git 仓库中解密依赖云 KMSAWS KMS / GCP KMS / Azure Key Vault。信任链IAM 角色 → KMS 密钥 → 明文。优点是部署简单——Git 即唯一状态源缺点是密钥加密态仍在仓库中历史泄露风险存在。Sealed Secrets操作者用kubeseal命令行工具加密密钥生成 SealedSecret CRD 提交到 Git。集群内运行的 Sealed Secrets Controller 用自己的私钥解密生成 K8s Secret。信任链Controller 私钥 → SealedSecret → K8s Secret。优点是紧密集成 K8s缺点是与 K8s 强绑定非 K8s 环境无法使用。Vault密钥存储在独立的 Vault 服务中CI Runner 通过 JWT / AppRole 认证后动态获取密钥。信任链Vault Auth → Policy → 密钥。优点是集中管理、审计日志、动态密钥轮换缺点是运维 Vault 本身就是一个不小的工程。三、生产级代码实现# .sops.yaml — SOPS 配置文件 # 为什么用 age 而不是 GPGage 密钥管理更简洁不涉及 GPG keyring creation_rules: - path_regex: .*secrets.*\.ya?ml$ # 使用 age 公钥加密生产环境建议替换为云 KMS ARN age: - age1qg8p7x9f2v...你的 age 公钥 encrypted_regex: ^(data|stringData)$# secrets.enc.yaml — 加密后的 SOPS 文件示例结构 apiVersion: v1 kind: Secret metadata: name: app-secrets namespace: production type: Opaque stringData: DATABASE_URL: postgres://user:ENC[AES256_GCM,data:...]db.internal:5432/mydb REDIS_PASSWORD: ENC[AES256_GCM,data:...] OPENAI_API_KEY: ENC[AES256_GCM,data:...] sops: kms: [] age: - recipient: age1qg8p7x9f2v... enc: | -----BEGIN AGE ENCRYPTED FILE----- ... -----END AGE ENCRYPTED FILE----- lastmodified: 2026-07-22T10:00:00Z# sealed-secret.yaml — Sealed Secrets 示例 apiVersion: bitnami.com/v1alpha1 kind: SealedSecret metadata: name: app-sealed-secrets namespace: production spec: encryptedData: DATABASE_URL: AgCj1x...kubeseal 加密后的密文省略 REDIS_PASSWORD: AgDX9k... OPENAI_API_KEY: AgFk3m...# ci_secrets_injector.py CI 环境下的密钥统一注入器 支持三种后端SOPS、Sealed Secrets、Vault 设计原则优先使用 Vault动态密钥降级到 Sealed Secrets / SOPS import os import subprocess import json import logging from dataclasses import dataclass from typing import Dict, Optional from enum import Enum logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) class SecretBackend(Enum): VAULT vault SOPS sops SEALED_SECRETS sealed_secrets dataclass class InjectorConfig: 密钥注入配置 cascade_order 定义了降级顺序 如果 Vault 不可用降级到 Sealed Secrets 如果 Sealed Secrets 也不可用降级到 SOPS vault_addr: Optional[str] None vault_token_path: Optional[str] /var/run/secrets/vault-token sops_config: Optional[str] .sops.yaml sealed_secret_ns: Optional[str] production # 降级链试着按这个顺序获取密钥第一个成功的就返回 cascade_order: list None def __post_init__(self): if self.cascade_order is None: self.cascade_order [ SecretBackend.VAULT, SecretBackend.SEALED_SECRETS, SecretBackend.SOPS, ] def inject_secrets(config: InjectorConfig, secret_keys: list) - Dict[str, str]: 依次尝试 cascade_order 中配置的后端注入所有需要的密钥 返回{key: value} 字典无值则为空字符串 为什么不是抛异常而是返回空值 CI 环境可能有密钥缺失但允许运行的场景如本地开发。 调用方负责判断哪些密钥缺失是可接受的。 result {} errors [] for backend in config.cascade_order: try: if backend SecretBackend.VAULT: secrets _from_vault(config, secret_keys) elif backend SecretBackend.SEALED_SECRETS: secrets _from_sealed_secrets(config, secret_keys) elif backend SecretBackend.SOPS: secrets _from_sops(config, secret_keys) else: continue # 合并结果新键覆盖已有键 for k, v in secrets.items(): if k not in result or not result[k]: result[k] v except Exception as e: errors.append(f{backend.value}: {e}) logger.warning(Backend %s failed: %s, backend.value, e) continue # 缺失的密钥补充空值 for key in secret_keys: if key not in result: result[key] logger.error(Secret %s not found in any backend, key) if errors and len(errors) len(config.cascade_order): logger.error(All secret backends failed: %s, ; .join(errors)) return result def _from_sops(config: InjectorConfig, keys: list) - Dict[str, str]: SOPS 解密逻辑 前提条件CI Runner 上有解密所需的 KMS/age 权限 # 查找加密文件 enc_file None for f in os.listdir(.): if f.endswith(.enc.yaml) or f.endswith(.enc.yml): enc_file f break if not enc_file: raise FileNotFoundError(No encrypted SOPS file found) # 执行 sops 解密 cmd [sops, --decrypt, enc_file] result subprocess.run(cmd, capture_outputTrue, textTrue, timeout30) if result.returncode ! 0: raise RuntimeError(fsops decrypt failed: {result.stderr}) # 解析解密后的 YAML提取需要的键 # 这里简化处理——实际生产环境建议用 yaml.safe_load parsed {} for key in keys: value os.environ.get(key, ) if not value: # 从解密结果中搜索简化实现 for line in result.stdout.split(\n): if line.strip().startswith(f{key}:): value line.split(:, 1)[1].strip().strip() break parsed[key] value return parsed def _from_vault(config: InjectorConfig, keys: list) - Dict[str, str]: 从 Vault 获取密钥 使用 Vault Agent Sidecar 挂载的 token 进行认证 if not config.vault_addr: raise ValueError(Vault address not configured) token if os.path.exists(config.vault_token_path): with open(config.vault_token_path, r) as f: token f.read().strip() if not token: raise RuntimeError(Vault token not found) secrets {} vault_path secret/data/ci # 使用 curl 而非 hvac 库避免额外 Python 依赖 cmd [ curl, -s, -H, fX-Vault-Token: {token}, f{config.vault_addr}/v1/{vault_path}, --connect-timeout, 5, --max-time, 10 ] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: raise RuntimeError(fVault request failed: {result.stderr}) data json.loads(result.stdout) kv_data data.get(data, {}).get(data, {}) for key in keys: if key in kv_data: secrets[key] str(kv_data[key]) return secrets def _from_sealed_secrets(config: InjectorConfig, keys: list) - Dict[str, str]: 从 Sealed Secrets 获取密钥 依赖 kubectl 访问集群所以只在 K8s CI Runner 场景有效 secrets {} for key in keys: # 转换为 K8s Secret 的 key 格式小写 下划线 k8s_key key.lower() cmd [ kubectl, get, secret, app-sealed-secrets, -n, config.sealed_secret_ns, -o, fjsonpath{{.data.{k8s_key}}}, --ignore-not-found ] result subprocess.run(cmd, capture_outputTrue, textTrue, timeout10) if result.returncode 0 and result.stdout.strip(): import base64 secrets[key] base64.b64decode(result.stdout.strip()).decode() return secrets # --------------------------------------------------------------------------- # CI 入口在流水线脚本中调用 # --------------------------------------------------------------------------- if __name__ __main__: import sys # 从环境变量读取配置 config InjectorConfig( vault_addros.environ.get(VAULT_ADDR), sops_configos.environ.get(SOPS_CONFIG, .sops.yaml), sealed_secret_nsos.environ.get(K8S_NAMESPACE, production), ) # 所有需要的密钥列表 required_keys [ DATABASE_URL, REDIS_PASSWORD, OPENAI_API_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, ] try: secrets inject_secrets(config, required_keys) except Exception as e: logger.critical(Fatal: secret injection failed: %s, e) sys.exit(1) # 注入到环境变量 for k, v in secrets.items(): if v: os.environ[k] v logger.info(Injected: %s *** (len%d), k, len(v)) else: logger.warning(Secret %s is empty, k) # 验证关键密钥 mandatory [DATABASE_URL] missing [k for k in mandatory if not secrets.get(k)] if missing: logger.error(Mandatory secrets missing: %s, missing) sys.exit(2) logger.info(All secrets injected successfully)四、边界分析与架构权衡SOPS 的局限密钥加密后仍在 Git 仓库历史版本中的旧密钥不会自动失效依赖云 KMS 的可用性——KMS 挂了CI 就瘫了没有自动轮换能力轮换需要手动重新加密、提交Sealed Secrets 的局限强依赖 Kubernetes如果不是 K8s 环境完全不能用Controller 私钥丢失 所有 SealedSecret 需要重新生成没有审计日志无法知道谁用什么时间解密了哪个密钥Vault 的局限运维成本高——Vault 集群也需要监控、备份、升级高可用部署至少需要 3 节点 Consul/Integrated Storage 后端如果 Vault 成为单点瓶颈流水线性能会受大影响组合策略建议非生产环境dev/stagingSOPS 足矣稳定性 安全性K8s 生产环境Sealed Secrets 或 Vault需要动态密钥 审计Vault多集群 / 多云Vault 或 SOPS 各云 KMS五、总结密钥注入方案的选择不看谁功能多看谁运维成本低、与你现有基础设施的契合度高。SOPS 最简单但最不动态Sealed Secrets 是 K8s 原生但锁定了平台Vault 最强但运维成本不容忽视。关键是建立一条从存储到运行时的、每一步都可审计的信任链——工具只是这条链上的一环。