使用 Pulumi 与 Vault 为 SQL Server 自动化部署动态 mTLS 认证


数据库的凭证管理是生产环境中一个持续存在的痛点。静态的用户名密码、长生命周期的证书,或者共享的密钥,一旦泄露,后果不堪设想。在真实项目中,我们追求的目标是零信任网络和身份驱动的访问控制,即服务间的每次通信都必须经过严格的双向认证,并且凭证应该是动态生成、短生命周期的。

这套体系的核心构想是,将访问权限与一个短暂的、可验证的身份绑定,而不是一个静态的秘密。我们将使用 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 一键部署的环境。这个环境包括:

  1. 一个配置了 mTLS 的 SQL Server Docker 容器。
  2. 一个代表客户端应用的 Docker 容器,它在启动时动态获取证书。
  3. 连接两者所需的网络。

我们将使用 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;

代码深度解析与陷阱

  1. 动态证书生成: vault.pkiSecret.Secret 是核心资源。每次 pulumi up,如果证书即将过期或不存在,它会向 Vault 申请新的证书。这是实现动态性的基础。
  2. command.local.Command 的妙用: Pulumi 本身不直接操作本地文件系统。我们使用 @pulumi/command 包,将证书内容从 Pulumi 的内存状态写入到本地临时文件。这里的坑在于依赖管理,必须用 dependsOn 明确告诉 Pulumi,必须在 sqlServerCert 资源创建成功后,才能执行 write-server-certs 命令。
  3. SQL Server 配置 (mssql.conf): forceencryption = 1 是强制所有连接必须加密。tlscerttlskey 指向我们挂载的证书。但最关键的一点被隐藏在 SQL Server 的默认行为里:当提供了 CA 证书时,它会自动开启客户端证书验证。在更精细的控制中,可能需要在数据库内部通过 T-SQL 设置 ALTER ENDPOINT
  4. Docker 网络与别名: networksAdvanced 中的 aliases: ["sql-server.local"] 至关重要。它确保了在 Docker 的内部 DNS 中,sql-server.local 这个主机名能被解析到正确的容器 IP。这与我们为服务器证书签发的 commonName 是一致的,否则 TLS 握手会因为主机名不匹配而失败。
  5. 客户端连接字符串: sqlcmd 的参数 -N "true" 强制加密,-C 信任服务器证书(因为我们使用内部CA,客户端默认不信任),而 -K-P (注意这里是大写P)分别指定了客户端证书和私钥的路径。这是 mTLS 的客户端实现细节。
  6. 生命周期管理: 当你执行 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 部署,但在生产环境中还有几个需要考量的点:

  1. 证书轮换与服务重启: Pulumi 的模型是在基础设施变更时运行。对于生命周期只有1小时的证书,我们不能每小时都运行 pulumi up。一个更成熟的方案是,应用本身(客户端和服务端)应该集成 Vault Agent 或 SDK。Vault Agent 可以自动从 Vault 获取证书,并在证书即将过期时自动续期,然后向应用进程发送一个信号(如 SIGHUP)来重新加载证书,从而避免服务中断。Pulumi 的角色是部署应用和 Vault Agent 的 sidecar,并完成初始的引导配置。

  2. 引导信任问题 (Secret Zero): 我们的 Pulumi 脚本中硬编码了 Vault 的地址和令牌。这是一个典型的“第一个秘密”问题。在生产中,Pulumi 运行的环境(如 CI/CD Runner)应该通过一个安全的机制(如云平台的 IAM 角色)来向 Vault 进行认证,获取一个有时效性的令牌,然后用这个令牌来执行后续操作。

  3. 客户端身份验证: 目前,任何能从 Vault 的 db-services 角色申请到证书的进程都能连接数据库。更安全的做法是为不同的客户端或服务创建不同的角色,并结合 Vault 的 AppRole 或其他认证方法,确保只有经过授权的应用才能申请到证书。

  4. 数据库内的用户映射: mTLS 解决了网络层的认证问题,但没有解决授权问题。连接到 SQL Server 后,用户是 sa。高级用法是将证书的某个字段(如 CN 或 SAN)映射到数据库内的特定用户或角色,实现更细粒度的权限控制。这通常需要 SQL Server 的额外配置,比如通过 CREATE USER [client.local] FROM CERTIFICATE [cert_name]。这个步骤也可以通过 Pulumi 的 SQL Provider 来自动化。

尽管存在这些可迭代的点,但通过 Pulumi 将动态 PKI 的理念与传统数据库基础设施相结合,已经为我们构建一个更安全、更自动化的数据平台奠定了坚实的基础。它将安全配置从手动、易错的文档变成了可审查、可测试、可重复执行的代码。


  目录