去年,就有一位客户打电话向我咨询过 exactly 这个问题。有人出于直觉运行了 git log -p 命令,结果发现两年前有一份 .env 文件被提交到了代码库中,但此前这一错误根本没有被发现。数据库密码、Stripe 的密钥以及 JWT 签名密钥——所有这些敏感信息仍然处于活跃使用状态,而且仍在生产环境中被使用。

根据 IBM 在 2024 年发布的报告,平均来说,一次数据泄露造成的损失为 488万美元 —— 这只是平均值,并非最严重的情况。

导致数据泄露的根本原因往往与敏感信息的泄露有关。仅在 2023 年一年,GitHub 就发现有超过一百万条敏感信息在公共代码库中被泄露了;更何况那些从未被任何人发现的私有敏感信息更是数不胜数。

这并不是开发人员的问题。与我合作过的那些开发者们都非常谨慎,但现有的系统架构本身就容易导致他们犯错:有时候 .env 文件会不小心被提交到代码库中;有时人们会将敏感信息复制并粘贴到 Slack 消息中,以便帮助同事解除账号锁定;有时在发布 Docker 镜像时,这些敏感信息也会被包含在其中;还有时候服务器被关闭后,却没有人去更换它之前保存的敏感信息。

Azure Key Vault 的解决方案截然不同。你的应用程序可以在运行时从中央化的、加密存储的服务中获取所需的敏感信息,因此 .env 文件就不再会成为安全隐患了,因为其中已经不再包含任何值得被窃取的信息。

你所要构建的是一个 Node.js Express API,它在程序启动时会从 Azure Key Vault 中获取所有必要的敏感信息。代码中根本不会包含任何密码。当有员工离职时,代码库中也不存在需要更换的敏感信息;最终 .env 文件里只会留下一行内容——也就是 Key Vault 的名称而已。

先决条件

  • Node.js 18 及更高版本

  • 一个 Azure 账户(免费账户即可)

  • 已安装并登录了 Azure CLI (az login)

  • 具备 Express.js 的基础知识

  • Docker(可选——仅用于本地数据库测试环节)

我们要构建什么

我们将构建一个 Node.js Express API,该 API会:

  1. 在程序启动时,从 Azure Key Vault 中获取所需的认证信息,然后使用这些信息连接到 PostgreSQL 数据库

  2. 采用“托管身份”机制进行身份验证——因此代码中根本不需要任何客户端密钥或密码

  3. 将敏感信息缓存到内存中,这样每次请求时就不需要再次从 Key Vault 中获取这些信息了

  4. 可以通过 Azure CLI 在本地进行测试,而在生产环境中则可以使用“托管身份”机制——代码完全相同,无需做任何修改

目录结构

  1. 架构原理

  2. 什么是 Azure Key Vault?

  3. 配置 Key Vault

  4. 创建 Node.js 项目

  5. 使用“托管身份”机制连接 Key Vault

  6. 在程序启动时缓存敏感信息

  7. 在 Express API 中使用敏感信息

  8. 进行本地测试

  9. 部署到 Azure App Service

  10. 为应用程序授予 Key Vault 访问权限

  11. 无需重新部署即可更换敏感信息

  12. 故障排除

  13. 总结

架构的工作原理

在编写任何代码之前,先了解整个系统的运作流程会很有帮助:

 本地开发环境
.-------------------------------------------------------.
|                                                        |
|   [Node.js应用程序]                                        |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---> 访问Azure登录会话       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  --->> 提供加密密钥              |
|        |                                               |
|        v                                               |
|   [内存缓存]          --->> 应用在运行时使用这些密钥  |
'-------------------------------------------------------'

生产环境(Azure)
.-------------------------------------------------------.
|                                                        |
|   [Azure应用服务]                                  |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] --->> 使用托管身份认证       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  --->> 提供加密密钥              |
|        |                                               |
|        v                                               |
|   [内存缓存]          --->> 应用在运行时使用这些密钥  |
'-------------------------------------------------------'

这两种环境运行的代码是完全相同的。DefaultAzureCredential会自动确定应该从哪里获取加密信息:在本地环境中,它会使用你的Azure登录会话;而在Azure生产环境中,则会使用托管身份认证机制。你无需手动切换配置文件或管理凭证信息,系统就能正常运行。

什么是Azure Key Vault?

Azure Key Vault是微软提供的加密密钥管理系统,它可以用来存储各种敏感信息,如数据库密码、API密钥以及JWT签名密钥等。在本教程中,我们主要关注它的加密密钥存储功能——那些应用程序运行所必需的信息,但又不适合被保存在Git代码库中的数据。

与传统的`.env`文件相比,在实际开发过程中,使用Azure Key Vault确实能带来很多便利。因此,在开始编写代码之前,了解这两者之间的区别是非常重要的。

在真实项目中,我注意到密钥轮换机制非常实用。当你在Azure Key Vault中更新了某个加密密钥后,所有应用程序在下次重启时都会自动使用新版本的密钥,这样就无需在测试环境和生产环境中分别查找不同的配置文件了。

访问控制也是另一个非常重要的功能。每个应用程序只能被允许读取它真正需要的加密信息。这样一来,即使某个服务遭到攻击,也无法获取其他服务的敏感数据。

每一次读取操作都会被记录下来。当出现问题时——而最终总会出现问题的——你可以清楚地看到是哪个应用程序在什么时间访问了哪条保密信息。审计人员恰恰想要看到的就是这样的记录。

我参加过很多安全审查,因此知道“我们使用.env文件,并要求员工不要将这些文件提交到代码库中”这样的做法是无法让审核人员满意的。SOC 2、HIPAA、GDPR这些标准都要求提供可证明的有效控制措施,而一个带有访问日志的密钥保管库恰恰能够满足这一要求。

设置密钥保管库

请运行以下命令。密钥保管库的名称必须在整个Azure平台上保持唯一性——而不能只是在你自己的订阅账户中唯一,因此请选择一个具体的名称。名称应由字母、数字和连字符组成,长度为3到24个字符。

# 创建一个资源组(如果你已经有了这个资源组,就可以跳过这一步)
az group create \
  --name keyvault-demo-rg \
  --location eastus

# 创建密钥保管库(默认情况下会启用RBAC权限控制——后续的角色分配步骤需要这一设置)
az keyvault create \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --location eastus

# 授予自己管理这些秘密的权限(在启用RBAC的情况下这是必需的——创建密钥保管库的用户并不会自动被赋予这些权限)
az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee-object-id $(az ad signed-in-user show --query id -o tsv) \
  --scope $(az keyvault show \
    --name your-vault-name \
    --resource-group keyvault-demo-rg \
    --query id -o tsv)

# 添加你需要保护的秘密信息
az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-HOST" \
  --value "your-db-host.postgres.database.azure.com"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "your-super-secret-password"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "JWT-SECRET" \
  --value "your-jwt-signing-secret"

请验证这些秘密信息是否已经被成功存储到密钥保管库中:

az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv

你应该会看到如下结果:

DB-HOST
DB-PASSWORD
JWT-SECRET

创建Node.js项目

首先设置项目的结构:

mkdir nodejs-azure-keyvault
cd nodejs-azure-keyvault
npm init -y
npm install express pg jsonwebtoken @azure/keyvault-secrets @azure/identity dotenv

其中两个Azure相关的包会完成所有的核心功能:

  • @azure/keyvault-secrets——用于连接你的密钥保管库并提取其中的秘密信息。

  • @azure/identity——负责处理身份验证相关的工作。在本地开发环境中,它会使用你的az login会话进行认证;而在生产环境中,则会自动切换到“托管身份识别”服务。

package.json文件中添加一个启动脚本:

npm pkg set scripts.start="node server.js"

最后创建如下文件结构:

nodejs-azure-keyvault/
|-- src/
|   |-- config/
|   |   `-- secrets.js   # 用于处理与密钥保管库相关的逻辑
|   |-- db/
|   |   `-- index.js     # 使用从密钥保管库中获取的秘密信息来操作PostgreSQL数据库
|   `-- routes/
|       `-- users.js     # 示例路由处理函数
|-- app.js               # Express应用程序的主文件
`-- server.js            # 程序的入口点文件——会首先加载秘密信息

使用“管理身份”功能连接Key Vault

创建密钥配置文件:


// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require '@azure/identity';

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

async function getSecret(name) {
  const secret = await client.getSecret(name);
  return secret.value;
}

module.exports = { getSecret };

DefaultAzureCredential是此配置中最重要的部分。它会按顺序尝试一系列认证方法:

  1. 环境变量(用于CI/CD管道)

  2. Azure CLI凭据(用于本地开发——使用az login命令)

  3. “管理身份”功能(用于在Azure上部署的应用程序)

这意味着,无论是在本地环境还是生产环境中,使用完全相同的代码即可正常运行,且无需进行任何修改。在本地环境中,系统会使用你的az login会话信息;而在生产环境中,则会使用应用程序的“管理身份”功能。你根本不需要手动处理任何凭据相关事宜。

在程序启动时缓存密钥

每次请求时都调用Key Vault会导致延迟并产生费用。因此,可以在程序启动时一次性加载所有密钥,并将它们缓存在内存中。请用以下完整代码替换src/config/secrets.js文件:


// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require '@azure/identity';

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

// 内存缓存
const cache = {};

async function getSecret(name) {
  if (cache[name]) return cache[name];
  const secret = await client.getSecret(name);
  cache[name] = secret.value;
  return secret.value;
}

async function loadAllSecrets() {
  console.log('正在从Azure Key Vault加载密钥...
  const secretNames = ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'];

  await Promise.all(
    secretNames.map(async (name) => {
      cache[name] = await getSecret(name);
      console.log(`  ✓ 已成功加载${name}`);
    })
  );

  console.log('所有密钥均已成功加载。');
}

function getFromCache(name) {
  if (!cache[name]) throw new Error(`未加载密钥“${name}”。是否执行过loadAllSecrets()函数?`);
  return cache[name];
}

module.exports = { loadAllSecrets, getFromCache };

loadAllSecrets函数会在应用程序启动时被调用一次。之后,所有密钥都会从内存缓存中直接提供出来,从而完全避免再次调用Key Vault接口,进而降低延迟并节省成本。

在Express API中使用密钥

使用缓存的密钥来建立数据库连接:

// src/db/index.js
const { Pool } = require('pg');
const { getFromCache } = require('../config/secrets');

let pool;

function getPool() {
if (!pool) {
pool = new Pool({
host: getFromCache('DB-HOST'),
database: process.env.DB_NAME || 'myapp',
user: process.env.DB_USER || 'dbadmin',
password: getFromCache('DB-PASSWORD'),
port: parseInt(process.env.DB_PORT || '5432'),
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: false }
: false,
});

pool.on('error', (err) => {
console.error('数据库池出现异常错误:', err.message);
});
}

return pool;
}

module.exports = { getPool };

请注意其中的区别:DB-HOSTDB-PASSWORD这些信息属于敏感数据,因此是从Key Vault中获取的;而数据库名称、用户名以及端口号则不属于敏感信息,它们不需要受到保护,因此使用环境变量,并设置了合理的默认值。Key Vault主要用于存储凭证信息,而不是所有的配置参数。

SSL相关的设置会根据运行环境进行自动调整:在生产环境中会强制启用SSL加密;而在本地开发环境中则会关闭SSL加密,这样Docker连接就可以在不使用证书的情况下正常工作。rejectUnauthorized: false这个设置允许Azure Database for PostgreSQL使用其自带的证书,而无需验证证书链——这对于由Azure管理的数据库来说是常见的做法。在要求更严格的安全环境下,你可以下载Azure的根CA证书,并通过pool配置中的ca选项来指定该证书。

下面是一个示例代码,展示了如何使用从Key Vault中获取的密钥来进行JWT验证:

// src/routes/users.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('../config/secrets');
const { getPool } = require('../db');

const router = express.Router();

// 认证中间件——JWT密钥来自Key Vault,而不是环境变量process.env
function authMiddleware(req, res, next) {
const authHeader = req.headersauthorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization header缺失或格式不正确' });
}

const token = authHeader.split(' ')[1];

try {
req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
next();
} catch (err) {
return res.status(401).json({ error: '令牌无效或已过期' });
}
}

// GET /api/users — 显示所有用户信息(需要认证)
router.get('/', authMiddleware, async (req, res) => {
try {
const result = await getPool().query(
'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
);
res.json(result.rows);
} catch (err) {
console.error('数据库出现错误:', err.message);
res.status(500).json({ error: '内部服务器错误' });
}
});

// GET /api/users/:id — 显示指定用户的详细信息(需要认证)
router.get('/:id', authMiddleware, async (req, res) => {
try {
const result = await getPool().query(
'SELECT id, email, created_at FROM users WHERE id = $1',
[req.params.id]
);
if (!result.rows[0]) return res.status(404).json({ error: '用户未找到' });
res.json(result.rows[0]);
} catch (err) {
console.error('数据库出现错误:', err.message);
res.status(500).json({ error: '内部服务器错误' });
}
});

module.exports = router;

请注意,错误处理程序返回的是 `'内部服务器错误'`,而不是 `err.message`。数据库错误会泄露很多敏感信息——如果让这些错误通过的话,攻击者就能获取到你的表名、列名以及查询结构。

配置 Express 应用程序时,这两个文件中都定义了 `authMiddleware` 中间件——是的,这个中间件被重复定义了。在生产环境中,我会将这个中间件放在一个共享的中间件文件中。但对于本教程来说,将其保留在本地文件中意味着你可以直接阅读其中任何一个文件,而无需再去查找其他三个文件。

“`javascript
// app.js
const express = require(‘express’);
const jwt = require(‘jsonwebtoken’);
const { getFromCache } = require(‘./src/config/secrets’);
const usersRouter = require(‘./src/routes/users’);

const app = express();
app.use(express.json());

// 认证中间件——JWT 密钥来自 Key Vault,而不是 process.env
function authMiddleware(req, res, next) {
const authHeader = req.headersauthorization;
if (!authHeader || !authHeader.startsWith(‘Bearer ‘)) {
return res.status(401).json({ error: ‘缺少或格式错误的 Authorization 头部’ });
}
const token = authHeader.split(‘ ‘)[1];
try {
req.user = jwt.verify(token, getFromCache(‘JWT-SECRET’));
next();
} catch (err) {
return res.status(401).json({ error: ‘无效或过期的令牌’ });
}
}

// 健康检查——不需要进行认证
app.get(‘/health’, (req, res) => {
res.json({ status: ‘healthy’, timestamp: new Date().toISOString() });
});

// 状态端点——无需数据库即可验证 Key Vault 的集成情况
app.get(‘/api/status’, authMiddleware, (req, res) => {
res.json({
message: ‘所有密钥已从 Azure Key Vault 中加载完毕’,
vault: process.env.KEY_VAULT_NAME,
secrets_loaded: [‘DB-HOST’, ‘DB-PASSWORD’, ‘JWT-SECRET’],
authenticated_as: req.user.email,
timestamp: new Date().toISOString()
});
});

app.use(‘/api/users’, usersRouter);

app.use((req, res) => res.status(404).json({ error: ‘未找到路由’ }));
app.use((err, req, res, next) => {
console.error(‘发生未知错误:’, err.message);
res.status(500).json({ error: ‘内部服务器错误’ });
});

module.exports = app;
“`

在启动服务器之前,入口脚本会先加载所有的密钥。只有当所有密钥都成功加载后,服务器才会真正开始运行。

“`javascript
// server.js
require(‘dotenv’).config();
const app = require(‘./app’);
const { loadAllSecrets } = require(‘./src/config/secrets’);

const PORT = process.env.PORT || 3000;

async function start() {
try {
await loadAllSecrets();
app.listen(PORT, () => {
console.log(`服务器正在端口 ${PORT} 上运行`);
});
} catch (err) {
console.error(‘无法启动服务器:’, err.message);
console.error(‘提示:在本地开发时请运行 “az login” 命令;在 Azure 环境中请使用 Managed Identity 功能。’);
process.exit(1);
}
}

start();
“`

之所以使用 `process.exit(1)`,是因为我更愿意让应用程序在启动时就直接崩溃,而不是带着缺失的凭据继续运行,然后在两小时后收到第一个请求时才出现故障。

在本地进行测试

为本地开发创建一个`.env`文件。该文件中仅包含Key Vault的名称,不包含任何敏感信息:

# .env
KEY_VAULT_NAME=你的-vault-name
PORT=3000

将`.env`文件以及部署用的zip文件添加到`.gitignore`中:

echo ".env" >> .gitignore
echo "app.zip" >> .gitignore

确保你已经登录到了Azure CLI:

az login

启动应用程序:

npm start

你应该会看到如下输出:

正在从Azure Key Vault中加载密钥信息…
  ✓ JWT密钥已成功加载
  ✓ 数据库密码已成功加载
  ✓ 数据库主机地址已成功加载
所有密钥信息均已成功加载。
服务器正在3000端口运行

密钥信息的加载顺序可能会有所不同——`Promise.all`会并行获取这些信息,并在每个操作完成后再进行合并处理。关键的是,在服务器启动之前,必须确保这三个密钥信息都已被正确加载。

测试健康检查端点:

curl http://localhost:3000/health
# 返回结果:{"status":"healthy","timestamp":"2026-07-14T19:38:11.659Z"}

现在,我们来验证整个集成流程是否正常工作。取出你之前存储为`JWT-SECRET`的密钥值,用它来生成一个测试令牌——请将这个令牌值替换为`YOUR-JWT-SECRET-VALUE`。然后使用这个令牌访问 `/api/status` 端点:

node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"

在Linux/macOS系统中:

curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/status

在Windows PowerShell中:

Invoke-RestMethod -Uri "http://localhost:3000/api/status" -Headers @{Authorization = "Bearer YOUR_TOKEN"}

你应该会看到如下响应:

{
  "message": "所有密钥信息均已从Azure Key Vault中成功加载",
  "vault": "your-vault-name",
  "secrets_loaded": ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"],
  "authenticated_as": "test@test.com",
  "timestamp": "2026-07-14T19:50:08.687Z"
}

如果收到了这样的响应,那就说明整个集成流程是正常的。JWT令牌的生成和验证过程所使用的密钥信息仅存储在Key Vault中,既不在你的代码中,也不在`.env`文件里,更不会出现在代码仓库中的任何地方。你的`az login`会话负责处理本地的身份认证逻辑;而在生产环境中,系统会使用“托管身份”功能来替代这一过程。代码本身是不变的。

使用Docker测试完整的数据库流程

该应用程序会从Key Vault中读取`DB-HOST`和`DB-PASSWORD`这些密钥信息,因此这些值需要与你在本地Docker容器中设置的值保持一致。现在请更新这些值:

az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "localhost"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "demopassword123"

Docker会创建一个Postgres容器。密码必须与你在Key Vault中设置的值〈code>demopassword123

docker run --name pg-demo \
  -e POSTGRES_USER=dbadmin \
  -e POSTGRES_PASSWORD=demopassword123 \
  -e POSTGRES_DB=myapp \
  -p 5432:5432 \
  -d postgres:15

创建相应的表并插入一些测试数据:

docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());"

docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "INSERT INTO users (email) VALUES ('alice@example.com'), ('bob@example.com'), ('carol@example.com');"

关闭服务器后再重新启动它——因为配置信息是在服务器启动时加载的,所以需要重新运行一次才能应用你在Key Vault中所做的更改:
npm start

使用有效的JWT调用用户信息接口:
# 生成一个token(使用你在Key Vault中存储的JWT-SECRET值)
node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"

在Linux/macOS系统中:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/users

在Windows PowerShell中:
Invoke-RestMethod -Uri "http://localhost:3000/api/users" -Headers @{Authorization = "Bearer YOUR_TOKEN"}

你应该会看到如下结果:
[
{ "id": 1, "email": "alice@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
{ "id": 2, "email": "bob@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
{ "id": 3, "email": "carol@example.com", "created_at": "2026-07-14T19:59:21.064Z" }
]

这个查询是使用直接从Key Vault中获取的密码来执行的。该密码既不在你的〈code>.env文件中,也没有被硬编码在任何地方,更不会存储在本地变量中。因此,这个代码仓库本身并没有任何值得窃取的信息。

在部署之前,请将真正的生产环境配置信息重新保存到Key Vault中:
az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "your-db-host.postgres.database.azure.com"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "your-super-secret-password"

如果跳过这一步,部署后的应用程序会尝试连接到〈code>localhost,但会立即失败——因为在App Service环境中并不存在〈code>localhost
这个地址。

部署到Azure App Service

注意:这一部分主要是用于创建App Service所需的基础设施。实际的代码部署(即将压缩文件上传到服务器)会在下一节中进行。在应用程序首次启动之前,必须确保它已经配置好了对Key Vault的访问权限,否则它会立即失败并退出运行。

创建应用服务:

# 创建一个应用服务计划(B1是最便宜的付费等级)
az appservice plan create \
  --name keyvault-demo-plan \
  --resource-group keyvault-demo-rg \
  --sku B1 \
  --is-linux

# 创建Web应用
az webapp create \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --plan keyvault-demo-plan \
  --runtime "NODE:18-lts"

# 设置应用配置——KEY_VAULT_NAME用于指定应用应使用哪个密钥库
# NODE_ENV=production可启用数据库连接的SSL加密
az webapp config appsettings set \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --settings KEY_VAULT_NAME=your-vault-name NODE_ENV=production

为应用授予Key Vault访问权限

为该应用启用“托管身份”功能。这样,该应用就能在Microsoft Entra ID系统中获得一个Key Vault可以信任的身份:

# 启用系统分配的托管身份
az webapp identity assign \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg

以下命令会自动获取principalId,并使用它来为应用授予相应的权限:

# 获取principal ID
PRINCIPAL_ID=$(az webapp identity show \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --query principalId \
  --output tsv)

# 获取Key Vault的资源ID
KV_ID=$(az keyvault show \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --query id \
  --output tsv)

# 为应用授予“Key Vault Secrets User”角色
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $PRINCIPAL_ID \
  --scope $KV_ID

“Key Vault Secrets User”角色仅允许应用读取密钥信息,而不允许其创建、更新或删除这些密钥。这体现了最小权限原则——应用程序只能执行它真正需要的操作。

现在可以部署该应用了。Linux/macOS用户可以直接运行相关命令;Windows用户则需要打开Git Bash(Git for Windows自带此工具):

zip -r app.zip . -x "node_modules/*" ".git/*" ".env" "app.zip"

然后进行部署:

az webapp deployment source config-zip \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --src app.zip

部署完成后,该应用会自动使用其“托管身份”功能与Key Vault进行交互。在整个部署过程中,既不需要密码,也不需要任何客户端密钥或认证信息。

可以通过检查健康检查端点来确认应用是否正常运行:

curl https://my-keyvault-node-app.azurewebsites.net/health
# 返回结果类似 {"status":"healthy","timestamp":"..."}

如果应用无法启动,可以查看日志信息:

az webapp log tail --name my-keyvault-node-app --resource-group keyvault-demo-rg

在绝大多数情况下,问题都是由于Key Vault角色的授权尚未生效。请等待2–3分钟后再尝试重启应用。

az webapp restart --name my-keyvault-node-app --resource-group keyvault-demo-rg

无需重新部署即可更换密钥

Key Vault最大的实际优势之一就是能够实现密钥的及时更新。当需要更改数据库密码时,你只需在Key Vault中更新该密码,而无需修改应用程序代码:

az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "new-rotated-password"

系统会在启动时自动加载这些密钥,因此无需重新部署应用程序,只需重启即可:

az webapp restart \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg

整个过程中不需要修改任何代码,也不需要进行新的部署。密钥更新后,应用程序会在几秒钟内立即使用新密码。

如果你需要实现无中断的密钥更新功能,可以在管理员身份认证之后添加一个/refresh-secrets端点,该端点会清除缓存,然后调用loadAllSecrets()函数。顺序非常重要——loadAllSecrets()函数会使用getSecret()函数来获取密钥值,而如果缓存中存在这些值,getSecret()函数就会直接返回缓存中的内容。因此,你必须先清除缓存,这样才能确保新密码能够被正确加载。这个功能是可选的,但对于那些无法承受应用程序重启的长周期运行进程来说,它非常有用。

故障排除

CredentialUnavailableError: DefaultAzureCredential无法获取令牌

这是因为你还没有登录到Azure CLI。请运行az login后再试一次。在Azure App Service中,需要确认“管理身份”功能已启用,并且角色分配操作已经正确完成。

RestError: 禁止访问——该用户没有获取密钥的权限

这可能是因为“管理身份”功能尚未与Key Vault连接成功。请重新运行az role assignment create命令。如果你已经执行过这个操作,那么可能需要等待一段时间才能看到效果。Azure系统通常需要2到3分钟的时间来完成角色分配的同步,因此请稍等片刻再继续检查。

如果loadAllSecrets()函数在完成执行之前就先调用了getFromCache()函数,那么说明启动顺序出现了问题。请打开server.js文件,确认await loadAllSecrets()语句确实位于app.listen()语句之前。如果顺序没有问题,那么可能只是因为密钥尚未被加载到Key Vault中。你可以运行az keyvault secret list --vault-name YOUR_VAULT命令来再次检查。

应用程序在本地可以正常启动,但在Azure App Service上却无法运行

几乎在这种情况下,问题都出在应用程序的配置设置上。要么是KEY_VAULT_NAME这个配置项根本不存在于App Service的配置文件中,要么就是 vault名称中存在拼写错误。请运行az webapp log tail命令来查看具体的启动错误信息,这样就能找出问题所在了。

AuthorizationFailed 这个错误通常会在执行az role assignment create命令时出现

您当前是Azure租户中的普通用户,没有分配角色的权限。因此,需要将现有的密钥库切换为访问策略模式——这样既无需重新创建密钥库,也不会导致您的机密信息丢失:

az keyvault update \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --enable-rbac-authorization false

如果这种情况发生在“设置密钥库”阶段(即为自己分配访问权限时),请运行以下命令:

az keyvault set-policy \
  --name your-vault-name \
  --object-id $(az ad signed-in-user show --query id -o tsv) \
  --secret-permissions get set list delete

如果这种情况发生在“为应用程序授予密钥库访问权限”阶段(即为应用程序分配管理身份访问权限时),请运行以下命令:

az keyvault set-policy \
  --name your-vault-name \
  --object-id $PRINCIPAL_ID \
  --secret-permissions get list

如果密钥库返回“SecretNotFound”错误,说明该机密信息从未被添加到密钥库中,或者已经被删除,又或者其名称与您的代码中请求的名称不完全匹配——需要注意的是,密钥库中的机密名称是区分大小写的。“db-password”和“DB-PASSWORD”是两个不同的名称。请运行`az keyvault secret list --vault-name YOUR_VAULT`命令,查看密钥库中实际存在的机密信息,然后与`src/config/secrets.js`文件中`loadAllSecrets()`函数请求的名称进行对比。通常,问题出在大小写差异或多余的连字符上。

总结

这个项目中的`.env`文件只包含一个值:密钥库的名称。这个信息并不属于敏感数据。所有的机密信息——如数据库密码、API密钥、签名密钥等——都存储在密钥库中,而永远不会影响到您的代码或部署流程。

这就是我现在在Azure项目中采用的方案。我发现,启动检查功能在实际应用中非常有用:如果密钥库无法访问或某个机密信息缺失,服务器会立即退出并显示明确的错误信息,而不会在开始运行后很快就出现故障。这样,您就能立刻发现问题,而不会等到两小时后才收到难以理解的数据库连接错误提示。

如果要添加新的机密信息,只需将其放入密钥库中,然后将其名称添加到`secretNames`数组中即可。其余的所有配置都会自动随之调整。

完整的代码示例可以在GitHub上找到:nodejs-azure-keyvault

Comments are closed.