Puppet驱动的在役密钥轮换中两阶段提交模式的实现


一个看似简单的安全需求摆在了桌面上:对一个核心应用集群的数据库凭证进行90天周期的强制轮换。这个集群横跨数百个节点,承载着关键业务流量,任何形式的服务中断都是不可接受的。当前,这个凭证被静态地存储在Hiera数据中,由Puppet分发到各个节点的配置文件里。这是一个典型的、脆弱的配置管理模式,轮换凭证将直接触发一场运维灾难。

定义复杂技术问题:零停机与原子性的双重挑战

问题的核心在于“在役轮换”(In-Service Rotation, ISR)。简单地更新Hiera中的密码,然后等待Puppet agent下一轮执行并重启服务,这个方案从一开始就被否决了。

  1. 中断窗口不可接受:即使是滚动重启,对于一个拥有500个节点的集群,假设每个节点重启耗时30秒,总的中断窗口也将长达数小时。在此期间,集群的整体处理能力会持续下降。
  2. 非原子性操作的风险:凭证轮换涉及两个分离的动作——数据库侧修改密码和应用侧更新配置。这两个操作无法在一个事务中完成。如果在某个时间点,一部分应用节点使用了新密码,而另一部分仍在使用旧密码,数据库却只认其中一个,那么必然导致大规模的连接失败。
  3. Puppet运行的不确定性:在一个大规模集群中,总有少数节点因为各种原因(网络抖动、资源耗尽)导致Puppet agent执行失败或延迟。依赖Puppet的会合(convergence)来同步一个有时序要求的操作,本身就充满了风险。

我们需要一个机制,它必须保证在整个轮换窗口期内,新旧凭证同时有效,并且能够优雅地引导所有应用实例从旧凭证迁移到新凭证,最后再原子性地让旧凭证失效。

方案A:基于服务依赖的简单通知模型

最直接的想法是尝试编排重启顺序。

  • 实现思路

    1. 在Puppet中定义一个新的数据库用户和密码。
    2. 创建一个Puppet任务(Task)或Orchestrator计划,首先在数据库服务器上执行SQL命令,将旧用户的密码更新为新密码。
    3. 紧接着,触发所有应用节点的Puppet agent运行,它们会拉取到包含新密码的Hiera数据,更新配置文件。
    4. 配置文件资源通过notify关系,通知并重启应用服务。
  • 优劣分析

    • 优点:逻辑相对简单,对现有Puppet代码的改动最小。
    • 缺点:这完全没有解决核心问题。在第2步数据库密码被修改的瞬间,到所有应用节点完成重启并加载新密码之前,存在一个致命的“死亡窗口”。所有正在运行的、持有旧密码连接池的应用实例都会在下一次数据库交互时失败。这个方案的本质依然是“停机更新”。在真实项目中,这种方法仅适用于允许停机维护的非核心系统。

方案B:引入两阶段状态的ISR架构

既然原子性无法在基础设施层实现,那么就必须将这种能力构建到应用层和配置管理层。我们最终选择的方案,其核心思想源于两阶段提交(2PC),通过引入中间状态,将一次性的原子操作分解为两个更安全、可控的阶段。

  • 核心设计

    1. 应用改造:应用必须被改造以支持同时从两个数据源读取和验证凭证。例如,配置文件中可以同时存在primary_db_passwordsecondary_db_password。应用在建立数据库连接时,会首先尝试使用primary凭证,如果失败,则立即尝试secondary凭证。
    2. 数据库侧准备:在轮换开始前,我们不再是修改旧用户的密码,而是创建一个全新的数据库用户,拥有与旧用户完全相同的权限,并赋予其新密码。此时,新旧两个用户(及密码)在数据库中是共存的。
    3. 配置管理的两阶段
      • 阶段一(Prepare):Puppet将新用户的凭证配置为primary_db_password,同时将旧用户的凭证降级为secondary_db_password。此配置被推送到所有节点,并触发服务的平滑重载(reload)而非重启,让应用加载新的配置并开始优先使用新凭证。
      • 阶段二(Commit):当监控系统确认所有应用实例均已成功使用新凭证(例如,通过日志或自定义的健康检查端点暴露的指标),我们进入第二阶段。Puppet再次运行,这次它会彻底移除配置文件中的secondary_db_password字段。同时,在数据库中删除旧的用户。
  • 优劣分析

    • 优点
      • 真正的零停机:在整个过程中,总有一个有效的凭证可供应用使用。
      • 容错性高:如果在第一阶段部分节点更新失败,系统依然可以靠secondary_d_password正常工作,为排错提供了充足的时间。
      • 过程可控:两个阶段之间的转换由运维人员(或自动化脚本)显式触发,而不是被动依赖于Puppet的执行时序。
    • 缺点
      • 应用侵入性:要求对应用进行改造,这对于无法修改源码的第三方商业软件或老旧的遗留系统来说是主要障碍。
      • 配置复杂度增加:Puppet和Hiera的逻辑变得更加复杂,需要管理轮换的状态。

最终选择与理由

在稳定性和业务连续性面前,增加的复杂度是值得付出的代价。我们选择了方案B。一次性的应用改造投入,换来的是未来每一次凭证轮换的安全、自动化和零感知。这是典型的用架构设计解决运维难题的思路,一个常见的错误是试图用脚本和技巧去绕过应用层能力的缺失。

核心实现概览

以下是此架构在Puppet中的核心实现细节。

1. 架构流程的可视化

我们使用Mermaid.js来清晰地展示整个轮换过程的状态机。

graph TD
    subgraph "凭证版本 v1"
        A[稳定状态: v1] -->|1. 准备轮换至v2| B(准备阶段: v1 + v2)
    end

    subgraph "轮换窗口"
        B -->|2. Puppet分发v2为主, v1为辅| C{所有节点是否完成重载?}
        C -->|是| D(提交阶段: 移除v1)
        C -->|否, 部分节点失败| E[告警: 检查失败节点]
        E --> B
    end

    subgraph "凭证版本 v2"
        D -->|3. Puppet分发仅含v2的配置| F[稳定状态: v2]
    end

    subgraph "数据库操作"
        subgraph "轮换开始前"
            DB1(创建用户v2)
        end
        subgraph "轮换结束后"
            DB2(删除用户v1)
        end
    end

    A -- 依赖 --> DB1
    F -- 触发 --> DB2

2. Hiera数据结构设计

我们需要在Hiera中不仅定义凭证本身,还要定义当前的轮换状态。这使得整个过程由数据驱动。

data/common.yaml:

# data/common.yaml

# 应用程序的配置profile
app::core::config::manage: true

# 凭证配置
# 使用 eyaml 或 vault lookup 进行加密
# puppet lookup('app::db::password_v1', { 'value_type' => 'sensitive' })
app::db::user_v1: 'app_user_v1'
app::db::password_v1: Sensitive("ENC[PKCS7,...old_password...]")

# 在轮换期间,新凭证会在这里定义
app::db::user_v2: 'app_user_v2'
app::db::password_v2: Sensitive("ENC[PKCS7,...new_password...]")

# 核心状态控制
# 可选值: 'stable', 'prepare', 'commit'
app::db::rotation_stage: 'stable' 

当需要发起轮换时,运维人员或CI/CD流水线会修改Hiera:

  1. 首先,添加user_v2password_v2的定义。
  2. 然后,将rotation_stage修改为prepare
  3. 在所有节点完成prepare阶段后,再将rotation_stage修改为commit
  4. 最后,在下一次轮换前,将状态改回stable,并清理掉过时的v1凭证数据。

3. Puppet Profile核心代码

这是实现两阶段逻辑的核心profile。它根据rotation_stage的值来决定生成什么样的配置文件。

manifests/app/core/config.pp:

# modules/profiles/manifests/app/core/config.pp

class profiles::app::core::config (
  # 控制当前凭证轮换所处的阶段
  String $rotation_stage,
  
  # 旧的(或当前稳定的)凭证信息
  String $db_user_v1,
  Sensitive[String] $db_password_v1,
  
  # 新的凭证信息(仅在轮换时需要)
  Optional[String] $db_user_v2,
  Optional[Sensitive[String]] $db_password_v2,
  
  # 其他应用配置...
  String $config_path = '/etc/app/config.json',
  String $service_name = 'my-app',
) {

  # 根据轮换阶段决定主、次凭证
  # 这里的逻辑是整个方案的核心
  case $rotation_stage {
    'stable': {
      $primary_user     = $db_user_v1
      $primary_password = $db_password_v1
      $secondary_user     = undef
      $secondary_password = undef
    }
    'prepare': {
      # 在准备阶段,新凭证(v2)成为主,旧凭证(v1)成为备用
      if !$db_user_v2 or !$db_password_v2 {
        fail('rotation_stage is "prepare", but v2 credentials are not defined in Hiera.')
      }
      $primary_user     = $db_user_v2
      $primary_password = $db_password_v2
      $secondary_user     = $db_user_v1
      $secondary_password = $db_password_v1
    }
    'commit': {
      # 在提交阶段,彻底切换到新凭证,不再保留备用
      if !$db_user_v2 or !$db_password_v2 {
        fail('rotation_stage is "commit", but v2 credentials are not defined in Hiera.')
      }
      $primary_user     = $db_user_v2
      $primary_password = $db_password_v2
      $secondary_user     = undef
      $secondary_password = undef
    }
    default: {
      fail("Invalid rotation_stage: '${rotation_stage}'. Must be one of 'stable', 'prepare', 'commit'.")
    }
  }

  # 使用template生成配置文件
  # 传递解析后的主、次凭证
  file { $config_path:
    ensure  => file,
    owner   => 'app-user',
    group   => 'app-group',
    mode    => '0600',
    content => template('profiles/app/config.json.erb'),
    # 关键点:当配置文件内容变化时,触发服务的reload操作
    # 服务的 exec-reload 命令必须被正确实现,以保证平滑加载配置
    notify  => Service[$service_name],
  }

  service { $service_name:
    ensure    => running,
    enable    => true,
    # 应用程序必须支持 reload 操作,否则只能退化为 restart
    hasstatus => true,
    hasrestart => true,
  }
}

4. ERB模板文件

模板文件负责将Puppet变量动态渲染成最终的JSON配置文件。

templates/app/config.json.erb:

{
    "database": {
        "primary": {
            "user": "<%= @primary_user %>",
            "password": "<%= @primary_password.unwrap %>"
        }<% if @secondary_user %>
        ,
        "secondary": {
            "user": "<%= @secondary_user %>",
            "password": "<%= @secondary_password.unwrap %>"
        }
        <% end %>
    },
    "logging": {
        "level": "INFO"
    }
}

注意 .unwrap 的使用,这是处理 Sensitive 数据类型的标准做法,确保密码内容只在最终生成的文件中出现,而不会在Puppet的报告和日志中泄露。

5. 验证与监控

prepare阶段,如何确认所有节点都已经成功切换?这是一个关键的闭环。在真实项目中,我们会依赖一个自定义fact来上报应用当前使用的凭证版本。

lib/facter/active_credential.rb:

# modules/utils/lib/facter/active_credential.rb
require 'json'

Facter.add(:active_credential_user) do
  setcode do
    config_file = '/var/run/app/status.json'
    if File.exist?(config_file)
      begin
        status_data = JSON.parse(File.read(config_file))
        status_data.dig('database', 'active_user')
      rescue JSON::ParserError
        nil
      end
    end
  end
end

这个fact会读取一个由应用自身生成的状态文件,其中包含了它当前正在积极使用的数据库用户名。然后,我们可以通过PuppetDB查询来验证集群状态:
puppet query 'inventory[certname] { facts.active_credential_user = "app_user_v2" }'
当查询结果数量与集群总节点数相等时,就标志着prepare阶段完成,可以安全地进入commit阶段。

架构的扩展性与局限性

这个基于Puppet和两阶段状态的ISR方案,虽然解决了眼前零停机轮换的难题,但它并非银弹。

它的主要局限在于对应用的侵入式要求。对于那些我们无法控制其源代码的系统,此方案无法实施。此外,整个流程虽然大部分是自动化的,但阶段切换的决策点(prepare -> commit)仍然需要人工干预或额外的自动化脚本来触发,这意味着它还不是一个完全自愈的系统。

未来的迭代方向是明确的:将凭证管理本身从Hiera中剥离,转向专用的密钥管理系统,如HashiCorp Vault。通过为Puppet集成Vault后端,Puppet可以直接从Vault中动态获取短期有效的凭证。应用本身也可以通过集成Vault Agent来自动获取和续期凭证。这将彻底消除静态长效凭证的存在,将“轮换”这一概念本身变为一个持续、自动且无需人工介入的后台进程,从而在架构层面根除此类问题。


  目录