PAYATHON 2026

如何通过 Azure Billing API 获取资源成本明细

支付老李

结论

可以获取,但不应从 Billing Invoices API 入手。Microsoft.Billing/invoices 返回的主要是账单周期、账单标识和下载信息,不提供逐资源费用,因此列表中没有金额很正常。

制作资源成本 dashboard 时,可以优先考虑:

  • Azure Cost Management Query API:按资源、资源组、服务、日期等维度聚合成本,适合制作图表和报表。
  • Azure Consumption Usage Details API:提供更接近原始消费记录的明细,适合核算或导出。
  • 新项目建议使用 @azure/identity、@azure/arm-costmanagement 和 @azure/arm-consumption。ms-rest-azure、azure-arm-billing、azure-arm-consumption 都是旧版 SDK,不建议在新代码中继续使用。

自动化认证应使用 Microsoft Entra ID service principal、托管身份或工作负载身份,无需通过 Postman 或 interactiveLogin() 获取令牌。

为什么 Invoices API 没有资源金额

账单、成本和消费记录是三类不同的数据:

  • Billing API:提供账单周期、发票元数据和账单文档等信息。
  • Cost Management API:提供聚合后的实际成本、摊销成本和预测成本。
  • Consumption API:提供用量和计费明细。

发票是结算文档,不是资源成本数据集。即使成功调用:

/subscriptions/{subscriptionId}/providers/Microsoft.Billing/invoices

返回结果通常也不会逐项列出虚拟机、磁盘、数据库等资源的成本。

URL 中的 api-version=2016-09-01 只是该 REST 操作使用的版本,并不表示查询的是 2016 年的数据。不过,旧示例使用的版本和 SDK 确实可能已经停止维护。通过官方 SDK 调用时,通常无需手动拼接 api-version。

推荐方案:Cost Management Query API

该 API 可以按 ResourceId 聚合成本,也能同时按资源组、资源类型、服务名称等维度分组。

对应的 REST 请求如下:

POST https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CostManagement/query?api-version={supported-api-version}
Authorization: Bearer {access-token}
Content-Type: application/json

请求体示例:

{
  "type": "ActualCost",
  "timeframe": "Custom",
  "timePeriod": {
    "from": "2026-09-01T00:00:00Z",
    "to": "2026-09-10T00:00:00Z"
  },
  "dataset": {
    "granularity": "Daily",
    "aggregation": {
      "totalCost": {
        "name": "Cost",
        "function": "Sum"
      }
    },
    "grouping": [
      {
        "type": "Dimension",
        "name": "ResourceId"
      },
      {
        "type": "Dimension",
        "name": "ResourceType"
      }
    ]
  }
}

返回结果使用 columns 加 rows 的表格结构。字段顺序不能假定为固定值,应根据 columns 建立索引。

使用现代 Node.js SDK

安装依赖:

npm install @azure/identity @azure/arm-costmanagement

使用 service principal 查询订阅成本:

const { ClientSecretCredential } = require("@azure/identity");
const { CostManagementClient } = require("@azure/arm-costmanagement");

const tenantId = process.env.AZURE_TENANT_ID;
const clientId = process.env.AZURE_CLIENT_ID;
const clientSecret = process.env.AZURE_CLIENT_SECRET;
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;

async function main() {
  const credential = new ClientSecretCredential(
    tenantId,
    clientId,
    clientSecret
  );

  const client = new CostManagementClient(credential);
  const scope = `/subscriptions/${subscriptionId}`;

  const result = await client.query.usage(scope, {
    type: "ActualCost",
    timeframe: "Custom",
    timePeriod: {
      from: new Date("2026-09-01T00:00:00Z"),
      to: new Date("2026-09-10T00:00:00Z")
    },
    dataset: {
      granularity: "Daily",
      aggregation: {
        totalCost: {
          name: "Cost",
          function: "Sum"
        }
      },
      grouping: [
        {
          type: "Dimension",
          name: "ResourceId"
        },
        {
          type: "Dimension",
          name: "ResourceType"
        }
      ]
    }
  });

  const columnNames = (result.columns || []).map(column => column.name);

  const rows = (result.rows || []).map(row =>
    Object.fromEntries(
      columnNames.map((name, index) => [name, row[index]])
    )
  );

  console.dir(rows, { depth: null });
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

如果程序运行在 Azure VM、App Service、Functions 或 Container Apps 中,建议启用托管身份,以免保存 client secret:

const { DefaultAzureCredential } = require("@azure/identity");
const { CostManagementClient } = require("@azure/arm-costmanagement");

const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;

async function main() {
  const credential = new DefaultAzureCredential();
  const client = new CostManagementClient(credential);

  const result = await client.query.usage(
    `/subscriptions/${subscriptionId}`,
    {
      type: "ActualCost",
      timeframe: "MonthToDate",
      dataset: {
        granularity: "Daily",
        aggregation: {
          totalCost: {
            name: "Cost",
            function: "Sum"
          }
        },
        grouping: [
          {
            type: "Dimension",
            name: "ResourceId"
          }
        ]
      }
    }
  );

  console.dir(result, { depth: null });
}

main().catch(console.error);

权限应该配置在哪里

所需权限不在 App registration 的 API permissions 页面,而是在目标订阅或计费范围的 Access control (IAM) 中。

调用 Azure Resource Manager 成本 API 时,需要给 service principal 分配 Azure RBAC 角色。如果只需读取成本,通常应在订阅范围分配:

Cost Management Reader

该角色包含读取 Cost Management 和 Consumption 数据所需的权限。仅在 App registration 中添加 Microsoft Graph 或其他 delegated permissions,并不会自动获得订阅成本的访问权。

可以使用 Azure CLI 分配角色:

az role assignment create \
  --assignee-object-id "<service-principal-object-id>" \
  --assignee-principal-type ServicePrincipal \
  --role "Cost Management Reader" \
  --scope "/subscriptions/<subscription-id>"

这里要填写 service principal 在 Enterprise applications 中对应的 object ID,而不是以下任一 ID:

  • Application (client) ID
  • App registration 自身的 object ID
  • Subscription ID
  • Tenant ID

如果程序还要读取普通 Azure 资源的属性,例如资源名称、标签、位置和资源类型,可以额外分配:

Reader

Reader 和 Cost Management Reader 的用途不同:

  • Reader:读取 Azure 资源配置。
  • Cost Management Reader:读取成本和消费数据。

如果 dashboard 同时展示资源属性和成本,通常需要同时使用这两个角色。

401 错误的判断方法

缺少 Azure RBAC 权限时,更常见的响应是 403 Forbidden。如果返回 401 Unauthorized,应先检查认证信息本身。

需要核对以下内容:

  1. domain 是否为订阅所属 Microsoft Entra tenant 的 tenant ID。

  2. clientId 是否为 Application (client) ID。

  3. secret 是否为创建 secret 后显示的 Value,而不是 Secret ID。

  4. service principal 是否确实存在于该 tenant 的 Enterprise applications 中。

  5. subscriptionId 是否属于同一个 tenant。

  6. access token 的 audience 是否为 Azure Resource Manager,即:

    https://management.azure.com/
    
  7. 是否混用了旧 SDK、旧认证库和不兼容的令牌类型。

使用 ClientSecretCredential 或 DefaultAzureCredential 时,SDK 会为 Azure Resource Manager 获取正确的令牌,通常不需要自行调用 getToken()。

角色分配完成后可能会有短暂的传播延迟。此时应重新获取令牌并重试,不要继续使用角色分配前缓存的令牌。

使用 Usage Details API 获取更细明细

如果需要逐条计费记录,而不是按资源聚合的数据,可以使用:

@azure/arm-consumption

安装:

npm install @azure/identity @azure/arm-consumption

示例:

const { ClientSecretCredential } = require("@azure/identity");
const {
  ConsumptionManagementClient
} = require("@azure/arm-consumption");

const tenantId = process.env.AZURE_TENANT_ID;
const clientId = process.env.AZURE_CLIENT_ID;
const clientSecret = process.env.AZURE_CLIENT_SECRET;
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;

async function main() {
  const credential = new ClientSecretCredential(
    tenantId,
    clientId,
    clientSecret
  );

  const client = new ConsumptionManagementClient(
    credential,
    subscriptionId
  );

  const scope = `/subscriptions/${subscriptionId}`;

  for await (const detail of client.usageDetails.list(scope, {
    filter:
      "properties/usageStart ge '2026-09-01' and " +
      "properties/usageEnd le '2026-09-10'"
  })) {
    console.dir(detail, { depth: null });
  }
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

不同账户协议和 SDK 版本支持的筛选字段可能不同。如果服务端拒绝某个 $filter,可以先去掉筛选条件调用,再根据实际返回模型和当前 SDK 文档调整。

Usage Details 的数据量通常很大,因此必须处理分页。使用 SDK 的异步迭代器,可以避免只读取第一页。

订阅类型和计费策略也会影响结果

即使 Azure RBAC 配置正确,以下情况仍可能造成无权访问或数据不完整:

  • Enterprise Agreement 管理员关闭了订阅用户查看费用的策略。
  • 在 Microsoft Customer Agreement 下,身份没有对应 billing profile 或 invoice section 的权限。
  • CSP 订阅的部分成本只能从合作伙伴侧查看。
  • 资源尚未产生可计费使用量。
  • 当天成本仍在处理中,尚未进入 Cost Management。
  • 某些费用只能分配到订阅、资源组或服务,无法对应具体 ResourceId。
  • 预留实例、Savings Plan、支持计划、税费、Marketplace 采购和抵扣项不一定能映射到单个资源。

如果已经在订阅范围分配 Cost Management Reader,但仍无法读取成本,需要检查订阅的计费协议,以及组织级的“允许订阅用户查看费用”等策略。

ActualCost 与 AmortizedCost

dashboard 还要明确使用哪种成本口径:

ActualCost

表示账单实际记账成本,更接近本期结算金额。

AmortizedCost

会将预留实例、Savings Plan 等预付费用按使用期间摊销,更适合分析资源每天的实际使用成本。

如果只展示其中一种,图表总额可能与发票金额不一致。生产环境中的 dashboard 最好注明成本口径、币种、查询时间范围和数据更新时间。

实际落地建议

资源成本 dashboard 可以按以下方式实现:

  1. 使用 Cost Management Query API 生成按天、资源组和 ResourceId 聚合的数据。
  2. 使用 Azure Resource Manager 查询资源名称、标签、位置等属性。
  3. 需要审计或核算时,再通过 Usage Details API 获取底层计费记录。
  4. 定期将查询结果写入数据库,避免用户每次打开 dashboard 时都实时扫描大量消费数据。
  5. 根据返回的 Currency 字段处理币种,不要假定所有记录使用同一种货币。
  6. 将没有 ResourceId 的费用单独归为共享费用、采购费用或订阅级费用。

原程序的认证思路基本正确,但不应继续使用 interactiveLogin(),也不能依靠 App registration 的 API permissions 获得成本权限。应改用现代 Azure Identity SDK,并在订阅 IAM 中将 Cost Management Reader 分配给对应的 service principal,才能获取逐资源成本数据。

备注:内容仅供参考。