网络服务配置文件里的加密密钥如果长期不更新,就相当于把保险柜密码写在门口,还一直不改。运维团队通常会定期修改登录密码,却对服务端加密密钥视而不见。密钥轮换并不是简单地把新密钥替换进配置文件,因为线上已经存在用旧密钥加密的密文,直接替换会导致解密失败。要安全地完成轮换,需要同时解决密钥版本管理、配置原子写入、服务热加载和失败回滚四个问题。Ruby 的语法简洁,适合编写这类运维自动化脚本。

一、先做密钥版本化,保留旧密钥解密存量数据
密钥轮换最容易犯的错误就是把旧密钥直接删除。假设配置中只有一个 encryption_key 字段,服务每次启动时读取它来解密数据库密码。如果轮换脚本直接覆盖这个字段并触发热加载,那么所有此前加密过的密文都会因为找不到旧密钥而无法解密。即使服务不立即崩溃,也会在读取某些配置项时抛出异常。
稳妥的方案是让配置文件支持多版本密钥。新密钥负责加密新写入的数据,旧密钥只用于解密历史数据。可以把当前活跃密钥和上一代密钥同时保留,超过两代的密钥再逐步清理。这样在过渡期内,无论请求命中新密文还是旧密文,服务都有对应的解密入口。
下面是一个典型的密钥配置文件结构,使用 YAML 格式,active_key 表示当前加密密钥,previous_key 表示上一代仍可用于解密的密钥。
version: 3
active_key: key_202502
previous_key: key_202501
keys:
key_202501:
material: "旧密钥材料"
created_at: "2025-01-05T03:00:00Z"
key_202502:
material: "新密钥材料"
created_at: "2025-02-02T03:00:00Z"
服务端在解密时不应该只依赖 active_key,而是按照 active_key 到 previous_key 的顺序依次尝试。Ruby 的 ActiveSupport::MessageEncryptor 提供对称加密和签名能力,解密失败时会抛出 InvalidMessage。利用这个特性可以实现自动回退。
require 'yaml'
require 'active_support/message_encryptor'
CONFIG_PATH = '/etc/myapp/secrets.yml'
def load_crypto_keys(path)
config = YAML.load_file(path)
keys = config.fetch('keys')
primary = keys.fetch(config['active_key']).fetch('material')
fallback = keys.fetch(config['previous_key']).fetch('material')
{
config: config,
primary_key: primary,
fallback_key: fallback
}
end
def decrypt_with_fallback(ciphertext, primary_key, fallback_key)
[primary_key, fallback_key].each do |key|
encryptor = ActiveSupport::MessageEncryptor.new(key)
begin
return encryptor.decrypt_and_verify(ciphertext)
rescue ActiveSupport::MessageEncryptor::InvalidMessage
next
end
end
raise '无法使用任何可用密钥解密'
end
state = load_crypto_keys(CONFIG_PATH)
plain = decrypt_with_fallback(
ENV.fetch('ENCRYPTED_DB_PASSWORD'),
state[:primary_key],
state[:fallback_key]
)
puts "解密成功:#{plain}"
二、用Ruby生成新密钥并原子替换配置文件
轮换脚本的首要任务是生成高质量随机密钥。不要使用时间戳或简单的随机数,应该使用 Ruby 标准库中的 SecureRandom。SecureRandom.hex(32) 会返回 64 个十六进制字符,足够作为对称加密密钥。生成后需要把新密钥写入配置文件,同时把原活跃密钥降级为 previous_key。
更新配置文件时一定要避免直接打开原文件写入。如果服务正好在读取配置,可能会读到不完整的 YAML,导致启动失败或热加载异常。正确做法是先写临时文件,调用 fsync 把内容刷到磁盘,再用 File.rename 原子替换原文件。这样读取方只会看到旧文件或新文件,不会看到半截状态。
require 'securerandom'
require 'yaml'
require 'fileutils'
require 'time'
CONFIG_PATH = '/etc/myapp/secrets.yml'
config = YAML.load_file(CONFIG_PATH)
old_active = config.fetch('active_key')
new_key_id = "key_#{Time.now.strftime('%Y%m%d%H%M%S')}"
new_material = SecureRandom.hex(32)
config['previous_key'] = old_active
config['active_key'] = new_key_id
config['keys'][new_key_id] = {
'material' => new_material,
'created_at' => Time.now.utc.iso8601
}
# 只保留当前和上一代密钥,避免配置文件无限变大
retired = config['keys'].keys.reject do |key|
[config['active_key'], config['previous_key']].include?(key)
end
retired.each { |key| config['keys'].delete(key) }
tmp_path = "#{CONFIG_PATH}.tmp"
File.open(tmp_path, 'w') do |file|
file.write(YAML.dump(config))
file.flush
file.fsync
end
File.chmod(0600, tmp_path)
File.rename(tmp_path, CONFIG_PATH)
puts "轮换完成:#{old_active} -> #{new_key_id}"
上面这段脚本还有一个容易忽略的细节:File.chmod 必须放在 File.rename 之前执行。因为有些系统在重命名后文件的权限位不会改变,但如果在临时文件阶段就设置好权限,替换后配置文件不会意外暴露给其他用户。0600 表示只有文件属主可以读写,适合存放密钥。
清理旧密钥的策略也要谨慎。previous_key 绝不能立刻删除,否则在轮换后的过渡期内,服务仍可能收到旧密文。通常至少保留一个轮换周期,例如每周轮换一次,就保留上周的密钥。更保守的做法是保留最近三代,给历史数据解密留足缓冲。
三、触发配置热加载并处理加载失败
配置文件替换成功后,如果服务不会自动重新读取,轮换就没有真正完成。常见的热加载方式有三种:向进程发送 HUP 信号、通过 Unix Socket 下发重载指令、由服务定期检查配置文件的 mtime。信号方式实现简单,适合大多数网络服务。
Ruby 的 Signal.trap 可以注册 HUP 信号处理函数。在信号处理函数中重新读取配置,并替换全局状态。注意不要在信号处理函数里做太重的操作,尤其是加解密测试。最好把新配置读取到局部变量,验证通过后再替换当前的加密器对象。
require 'yaml'
require 'active_support/message_encryptor'
CONFIG_PATH = '/etc/myapp/secrets.yml'
def load_encryptors(path)
config = YAML.load_file(path)
active_material = config['keys'][config['active_key']]['material']
previous_material = config['keys'][config['previous_key']]['material']
{
active_encryptor: ActiveSupport::MessageEncryptor.new(active_material),
fallback_encryptor: ActiveSupport::MessageEncryptor.new(previous_material),
config: config
}
end
CURRENT = load_encryptors(CONFIG_PATH)
Signal.trap('HUP') do
begin
new_state = load_encryptors(CONFIG_PATH)
CURRENT.replace(new_state)
puts 'HUP received, config reloaded'
rescue StandardError => e
warn "reload failed: #{e.message}"
end
end
轮换脚本最后一步可以向主服务发送信号,例如 Process.kill('HUP', pid)。如果主服务由 systemd 管理,也可以使用 systemctl reload myapp。无论哪种方式,都需要在发送信号后检查服务日志,确认重载成功。如果服务没有输出预期日志,应该立即回滚配置文件。
回滚的关键同样在于原子替换。轮换前先把旧配置备份到带时间戳的文件,一旦发现新密钥有问题,就用 File.rename 把备份文件替换回去,再次发送 HUP 信号。不要用 cp 覆盖,因为 cp 不是原子操作,可能让服务读到损坏内容。
四、生产环境中的安全加固与监控
密钥文件的权限和归属是第一道防线。配置文件应设置为 0600,属主为运行服务的账号,禁止放在 Web 可访问目录或源码仓库中。如果使用 Docker 部署,可以通过挂载只读配置文件或者使用 secret 管理工具下发,避免镜像层里残留旧密钥。
轮换任务本身也需要定时执行。可以使用 cron 或 systemd timer 在业务低峰期触发,例如每周二凌晨三点。调度器要记录每次轮换的脚本输出,方便审计。轮换失败时不要继续尝试覆盖,先保留现场并告警。
监控指标应至少包括:轮换是否按时执行、每次轮换是否成功写盘、服务是否成功热加载、旧密钥解密命中次数。旧密钥解密命中数尤其重要,如果轮换后很久仍然有大量旧密文被解密,说明存量数据需要尽快重新加密。可以给解密回退分支添加指标上报代码。
def decrypt_with_fallback(ciphertext, primary_key, fallback_key)
[primary_key, fallback_key].each_with_index do |key, index|
encryptor = ActiveSupport::MessageEncryptor.new(key)
begin
plain = encryptor.decrypt_and_verify(ciphertext)
# index 0 表示新密钥,index 1 表示旧密钥
puts "decrypt_using_legacy_key=#{index}" if index == 1
return plain
rescue ActiveSupport::MessageEncryptor::InvalidMessage
next
end
end
raise '无法使用任何可用密钥解密'
end
最后还要定期演练密钥泄露后的应急流程。假设线上密钥真的泄露,轮换工具必须能在几分钟内完成生成、替换、热加载和旧密文清理。平时不演练,真正出事时手忙脚乱,反而可能因为操作失误扩大影响。