数据库的凭证管理是生产环境中一个持续存在的痛点。静态的用户名密码、长生命周期的证书,或者共享的密钥,一旦泄露,后果不堪设想。在真实项目中,我们追求的目标是零信任网络和身份驱动的访问控制,即服务间的每次通信都必须经过严格的双向认证,并且凭证应该是动态生成、短生命周期的。
这套体系的核心构想是,将访问权限与一个短暂的、可验证的身份绑定,而不是一个静态的秘密。我们将使用 HashiCorp Vault 作为动态 PKI(Public Key Infrastructure)的引擎,负责颁发短周期的 mTLS 证书。SQL Server 作为我们的目标数据库,它需要被配置为强制要求客户端提供由我们内部 CA 签发的有效证书。而将这一切粘合起来,实现从基础设施到应用配置完全自动化的工具,就是 Pulumi。我们用 TypeScript 编写 Pulumi 代码,这让我们能用熟悉的编程语言来描述和编排整个复杂的安全工作流。
初始设定:构建信任的根基 Vault PKI
在用 Pulumi 自动化一切之前,我们必须先手动建立信任的根基:Vault 中的证书颁发机构(CA)。在生产环境中,这通常是一个被严格保管的离线根 CA 和一个在线的中间 CA。为了演示,我们简化这个流程,直接在 Vault 中创建。
首先,启用 PKI secrets engine:
# 假设 Vault 服务已在本地 dev 模式启动 (vault server -dev)
# 并且 VAULT_ADDR 和 VAULT_TOKEN 环境变量已设置
# 1. 启用根 CA PKI 引擎
vault secrets enable -path=pki_root pki
# 2. 设置根 CA 的存活时间 (TTL),例如10年
vault secrets tune -max-lease-ttl=87600h pki_root
# 3. 生成根证书,并保存公钥
vault write -field=certificate pki_root/root/generate/internal \
common_name="internal.my-corp.com" \
ttl=87600h > root_ca.crt
# 4. 启用中间 CA PKI 引擎
vault secrets enable -path=pki_int pki
# 5. 设置中间 CA 的 TTL,例如5年
vault secrets tune -max-lease-ttl=43800h pki_int
# 6. 为中间 CA 生成证书签名请求 (CSR)
vault write -format=json pki_int/intermediate/generate/internal \
common_name="internal.my-corp.com Intermediate Authority" | jq -r '.data.csr' > pki_intermediate.csr
# 7. 使用根 CA 签署中间 CA 的 CSR
vault write -format=json pki_root/root/sign-intermediate csr=@pki_intermediate.csr \
format=pem_bundle ttl=43800h | jq -r '.data.certificate' > intermediate.crt
# 8. 将签发好的中间证书导入到中间 CA 引擎中
vault write pki_int/intermediate/set-signed certificate=@intermediate.crt
现在,我们有了一个两级的 CA 结构。pki_root
应该被隔离,而 pki_int
将负责为我们的服务动态签发证书。接下来,定义一个角色(role),它规定了将要颁发的证书的参数,比如允许的域名、TTL 等。
# 为 SQL Server 和客户端定义一个角色
vault write pki_int/roles/db-services \
allowed_domains="sql-server.local,client.local" \
allow_subdomains=true \
max_ttl="24h" \
ttl="1h" # 证书默认有效期1小时
这个 db-services
角色是关键。之后 Pulumi 将通过这个角色为 SQL Server 和客户端申请证书。
Pulumi 编排:从零到全自动化的 mTLS 数据库基础设施
我们的目标是创建一个完全自包含、可通过 pulumi up
一键部署的环境。这个环境包括:
- 一个配置了 mTLS 的 SQL Server Docker 容器。
- 一个代表客户端应用的 Docker 容器,它在启动时动态获取证书。
- 连接两者所需的网络。
我们将使用 Pulumi 的 TypeScript SDK。
项目结构与配置
.
├── Pulumi.yaml
├── index.ts
├── package.json
└── tsconfig.json
Pulumi.yaml
定义了项目:
name: sql-mtls-automation
runtime: nodejs
description: A Pulumi program to deploy SQL Server with dynamic mTLS auth.
我们需要安装一些依赖:
npm install @pulumi/pulumi @pulumi/docker @pulumi/command @pulumi/vault
核心实现 index.ts
整个流程的核心在于 Pulumi 如何与 Vault 交互来生成证书,并将这些证书安全地注入到 Docker 容器中。这里的坑在于,证书的生成和使用存在时序依赖,必须精确控制。
// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as docker from "@pulumi/docker";
import * as vault from "@pulumi/vault";
import * as command from "@pulumi/command";
import * as fs from "fs";
import * as path from "path";
// --- 配置区 ---
// 在真实项目中,这些应该来自 Pulumi config
const config = {
sqlServerPassword: "aStrongPassword!123",
sqlServerImage: "mcr.microsoft.com/mssql/server:2019-latest",
vaultAddr: "http://127.0.0.1:8200", // 假设 Vault 在本地运行
vaultToken: "root", // 仅用于开发
};
// --- 准备工作:确保 Vault Provider 配置正确 ---
const vaultProvider = new vault.Provider("vault-provider", {
address: config.vaultAddr,
token: config.vaultToken,
});
// --- 第一步:为 SQL Server 动态生成证书 ---
// 1.1 使用 Vault Provider 从 pki_int 后端申请证书
const sqlServerCert = new vault.pkiSecret.Secret("sql-server-cert", {
backend: "pki_int",
name: "db-services", // 我们之前创建的角色
commonName: "sql-server.local", // 证书的 CN,必须与客户端连接时使用的主机名匹配
ttl: "24h",
}, { provider: vaultProvider });
// --- 第二步:准备 SQL Server 容器环境 ---
// 2.1 创建一个 Docker 网络,让客户端和服务端可以互相发现
const appNetwork = new docker.Network("app-net", {
name: "sql-mtls-net",
});
// 2.2 创建用于存放证书和配置的临时目录
// Pulumi 会在运行时创建这个目录,并将生成的证书文件放进去
const sqlServerConfigDir = "./sql-server-config";
if (!fs.existsSync(sqlServerConfigDir)) {
fs.mkdirSync(sqlServerConfigDir, { recursive: true });
}
// 2.3 将 Pulumi 从 Vault 获取的证书内容写入本地文件
// 这是 Pulumi 的一个强大之处:可以将一个资源(证书)的输出
// 作为另一个操作(本地文件创建)的输入。
const serverCertPath = path.join(sqlServerConfigDir, "mssql.crt");
const serverKeyPath = path.join(sqlServerConfigDir, "mssql.key");
const caChainPath = path.join(sqlServerConfigDir, "ca.crt");
const writeCerts = new command.local.Command("write-server-certs", {
create: pulumi.interpolate`
echo "${sqlServerCert.certificate}" > ${serverCertPath} && \
echo "${sqlServerCert.privateKey}" > ${serverKeyPath} && \
echo "${sqlServerCert.caChain}" > ${caChainPath}
`,
// 当证书资源被删除时,也清理本地文件
delete: `rm -rf ${sqlServerConfigDir}`,
}, { dependsOn: [sqlServerCert] }); // 显式声明依赖
// 2.4 准备 SQL Server 的自定义配置文件 mssql.conf
// 这个文件将告诉 SQL Server 启用 TLS 并强制客户端认证
const sqlServerConfContent = pulumi.all([sqlServerCert.certificate]).apply(() => `
[network]
tlscert = /var/opt/mssql/certs/mssql.crt
tlskey = /var/opt/mssql/certs/mssql.key
tlsprotocols = 1.2
forceencryption = 1
tlsciphers = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384
[sqlagent]
enabled = false
`);
const sqlConfPath = path.join(sqlServerConfigDir, "mssql.conf");
const writeSqlConf = new command.local.Command("write-sql-conf", {
create: pulumi.interpolate`echo "${sqlServerConfContent}" > ${sqlConfPath}`,
delete: `rm -f ${sqlConfPath}`,
}, { dependsOn: [writeCerts] });
// --- 第三步:部署配置好的 SQL Server 容器 ---
const sqlServerContainer = new docker.Container("sql-server", {
image: config.sqlServerImage,
name: "sql-server-mtls",
networksAdvanced: [{ name: appNetwork.name, aliases: ["sql-server.local"] }],
envs: [
"ACCEPT_EULA=Y",
`SA_PASSWORD=${config.sqlServerPassword}`,
// 让 SQL Server 在启动时读取我们的配置
"MSSQL_CONF=/var/opt/mssql/mssql.conf"
],
// 将包含证书和配置的目录挂载到容器中
mounts: [{
type: "bind",
source: path.resolve(sqlServerConfigDir), // 使用绝对路径
target: "/var/opt/mssql/certs",
readOnly: true,
}, {
type: "bind",
source: path.resolve(sqlConfPath),
target: "/var/opt/mssql/mssql.conf",
readOnly: true,
}],
}, { dependsOn: [writeSqlConf] });
// --- 第四步:模拟一个需要访问数据库的客户端 ---
// 为了演示,我们创建一个简单的客户端容器,它在启动时尝试连接数据库
// 生产环境中,客户端应用本身需要集成 Vault SDK 来动态获取证书
const clientCert = new vault.pkiSecret.Secret("client-cert", {
backend: "pki_int",
name: "db-services",
commonName: "client.local",
ttl: "1h",
}, { provider: vaultProvider });
const clientConfigDir = "./client-config";
if (!fs.existsSync(clientConfigDir)) {
fs.mkdirSync(clientConfigDir, { recursive: true });
}
const clientCertPath = path.join(clientConfigDir, "client.crt");
const clientKeyPath = path.join(clientConfigDir, "client.key");
const clientCaChainPath = path.join(clientConfigDir, "ca.crt");
// 注意:客户端也需要 CA 链来验证服务端的证书
const writeClientCerts = new command.local.Command("write-client-certs", {
create: pulumi.interpolate`
echo "${clientCert.certificate}" > ${clientCertPath} && \
echo "${clientCert.privateKey}" > ${clientKeyPath} && \
echo "${clientCert.caChain}" > ${clientCaChainPath}
`,
delete: `rm -rf ${clientConfigDir}`,
}, { dependsOn: [clientCert] });
// 使用一个带有 sqlcmd 的基础镜像来测试连接
const clientContainer = new docker.Container("sql-client", {
image: "mcr.microsoft.com/mssql-tools",
name: "sql-client-test",
networksAdvanced: [{ name: appNetwork.name }],
// 容器启动后执行的命令
command: [
"/bin/sh", "-c",
`
echo "Waiting for SQL Server to be ready..."
sleep 30 # 在真实场景中,应该使用更健壮的健康检查
echo "Attempting to connect to sql-server.local with mTLS..."
/opt/mssql-tools/bin/sqlcmd \
-S sql-server.local \
-U sa -P "${config.sqlServerPassword}" \
-N "true" \
-C \
-Q "SELECT @@VERSION" \
-K"/certs/client.crt" \
-P"/certs/client.key"
if [ $? -eq 0 ]; then
echo "SUCCESS: Connection established using mTLS."
else
echo "FAILURE: Could not connect."
# 保持容器运行以便调试
tail -f /dev/null
fi
`
],
mounts: [{
type: "bind",
source: path.resolve(clientConfigDir),
target: "/certs",
readOnly: true,
}],
}, { dependsOn: [sqlServerContainer, writeClientCerts] }); // 必须等待服务端和客户端证书都就绪
// --- 第五步:输出关键信息 ---
export const sqlServerName = sqlServerContainer.name;
export const clientLogs = clientContainer.logs;
代码深度解析与陷阱
- 动态证书生成:
vault.pkiSecret.Secret
是核心资源。每次pulumi up
,如果证书即将过期或不存在,它会向 Vault 申请新的证书。这是实现动态性的基础。 -
command.local.Command
的妙用: Pulumi 本身不直接操作本地文件系统。我们使用@pulumi/command
包,将证书内容从 Pulumi 的内存状态写入到本地临时文件。这里的坑在于依赖管理,必须用dependsOn
明确告诉 Pulumi,必须在sqlServerCert
资源创建成功后,才能执行write-server-certs
命令。 - SQL Server 配置 (
mssql.conf
):forceencryption = 1
是强制所有连接必须加密。tlscert
和tlskey
指向我们挂载的证书。但最关键的一点被隐藏在 SQL Server 的默认行为里:当提供了 CA 证书时,它会自动开启客户端证书验证。在更精细的控制中,可能需要在数据库内部通过 T-SQL 设置ALTER ENDPOINT
。 - Docker 网络与别名:
networksAdvanced
中的aliases: ["sql-server.local"]
至关重要。它确保了在 Docker 的内部 DNS 中,sql-server.local
这个主机名能被解析到正确的容器 IP。这与我们为服务器证书签发的commonName
是一致的,否则 TLS 握手会因为主机名不匹配而失败。 - 客户端连接字符串:
sqlcmd
的参数-N "true"
强制加密,-C
信任服务器证书(因为我们使用内部CA,客户端默认不信任),而-K
和-P
(注意这里是大写P)分别指定了客户端证书和私钥的路径。这是 mTLS 的客户端实现细节。 - 生命周期管理: 当你执行
pulumi destroy
时,command.local.Command
中的delete
脚本会被执行,清理掉本地的临时证书文件。Pulumi 会自动处理资源的销毁顺序,先删除依赖于证书的容器,再删除证书本身。
可视化架构流程
整个自动化流程可以用下面的图来描述:
sequenceDiagram participant User participant Pulumi participant Vault participant Docker as Docker Engine participant SQLServer as SQL Server Container participant ClientApp as Client Container User->>Pulumi: pulumi up Pulumi->>Vault: Request Server Certificate (CN=sql-server.local) Vault-->>Pulumi: Issue Server Cert + Key + CA Chain Pulumi->>User: Write certs to local ./sql-server-config Pulumi->>Vault: Request Client Certificate (CN=client.local) Vault-->>Pulumi: Issue Client Cert + Key + CA Chain Pulumi->>User: Write certs to local ./client-config Pulumi->>Docker: Create Docker Network 'sql-mtls-net' Docker-->>Pulumi: Network created Pulumi->>Docker: Run SQL Server container, mount ./sql-server-config Docker->>SQLServer: Start container with mTLS config SQLServer-->>Docker: SQL Server listening with mTLS enabled Pulumi->>Docker: Run Client container, mount ./client-config Docker->>ClientApp: Start container ClientApp->>SQLServer: Attempt connection to sql-server.local Note right of SQLServer: TLS Handshake begins SQLServer->>ClientApp: Present Server Certificate ClientApp->>ClientApp: Verify Server Cert against CA ClientApp->>SQLServer: Present Client Certificate SQLServer->>SQLServer: Verify Client Cert against CA Note right of SQLServer: Handshake successful, connection allowed SQLServer-->>ClientApp: Connection established ClientApp->>Docker: Log "SUCCESS" Docker-->>Pulumi: Capture logs Pulumi-->>User: Display client logs and outputs
局限性与未来迭代方向
这个方案虽然实现了完全自动化的 mTLS 部署,但在生产环境中还有几个需要考量的点:
证书轮换与服务重启: Pulumi 的模型是在基础设施变更时运行。对于生命周期只有1小时的证书,我们不能每小时都运行
pulumi up
。一个更成熟的方案是,应用本身(客户端和服务端)应该集成 Vault Agent 或 SDK。Vault Agent 可以自动从 Vault 获取证书,并在证书即将过期时自动续期,然后向应用进程发送一个信号(如SIGHUP
)来重新加载证书,从而避免服务中断。Pulumi 的角色是部署应用和 Vault Agent 的 sidecar,并完成初始的引导配置。引导信任问题 (Secret Zero): 我们的 Pulumi 脚本中硬编码了 Vault 的地址和令牌。这是一个典型的“第一个秘密”问题。在生产中,Pulumi 运行的环境(如 CI/CD Runner)应该通过一个安全的机制(如云平台的 IAM 角色)来向 Vault 进行认证,获取一个有时效性的令牌,然后用这个令牌来执行后续操作。
客户端身份验证: 目前,任何能从 Vault 的
db-services
角色申请到证书的进程都能连接数据库。更安全的做法是为不同的客户端或服务创建不同的角色,并结合 Vault 的 AppRole 或其他认证方法,确保只有经过授权的应用才能申请到证书。数据库内的用户映射: mTLS 解决了网络层的认证问题,但没有解决授权问题。连接到 SQL Server 后,用户是
sa
。高级用法是将证书的某个字段(如 CN 或 SAN)映射到数据库内的特定用户或角色,实现更细粒度的权限控制。这通常需要 SQL Server 的额外配置,比如通过CREATE USER [client.local] FROM CERTIFICATE [cert_name]
。这个步骤也可以通过 Pulumi 的 SQL Provider 来自动化。
尽管存在这些可迭代的点,但通过 Pulumi 将动态 PKI 的理念与传统数据库基础设施相结合,已经为我们构建一个更安全、更自动化的数据平台奠定了坚实的基础。它将安全配置从手动、易错的文档变成了可审查、可测试、可重复执行的代码。