PAYATHON 2026

Azure Java SDK 访问 CSP 成本时出现 Netty 异常

支付小周

结论

Azure Java SDK 可以查询 CSP 成本。它底层调用的仍是 Azure Cost Management REST API,无需因为使用 CSP 身份就改为手工发送 HTTP GET/POST 请求。

当前代码的问题更可能出在以下几处:

  1. 使用了示例中的占位 scope。xxxxxxxx 和 5678 必须替换为真实的 billingAccountName 和 customerName。
  2. 服务主体虽然拥有某个“Billing Administrator”角色,但没有获得目标计费范围或订阅范围的成本读取权限。
  3. 目标 CSP 订阅使用的是 Cost Management 不支持的旧 CSP offer,而非 Azure plan。
  4. Netty 日志只显示了外层异常,真正的原因被代码中的 System.out.println(e) 隐藏了。

如果只需查询每个 subscription 的成本,优先使用:

/subscriptions/{subscriptionId}

只有需要以 CSP 合作伙伴身份汇总某个客户的成本时,才使用:

/providers/Microsoft.Billing/billingAccounts/{billingAccountName}/customers/{customerName}

Java SDK 与 REST API 的关系

CostManagementManager 是 Cost Management REST API 的 Java 封装。下面两种调用最终访问的是同一套服务:

costManagementManager.queries().usageWithResponse(...)
POST https://management.azure.com/{scope}/providers/Microsoft.CostManagement/query

因此,直接调用 REST API 无法绕过以下限制:

  • CSP offer 不受支持;
  • scope 不正确;
  • 服务主体没有计费范围权限;
  • 合作伙伴未获准查看客户成本;
  • 客户没有符合条件的 Azure plan。

REST 调用便于排查具体的请求和响应,但它并不是一条权限更宽松的访问通道。

先确认应使用哪一种 scope

按单个订阅查询

如果应用已经知道 subscription ID,而且服务主体拥有该订阅的成本读取权限,可以直接查询订阅范围:

/subscriptions/{subscriptionId}

示例:

String scope = "/subscriptions/" + subscriptionId;

costManagementManager.queries().usageWithResponse(
    scope,
    new QueryDefinition()
        .withType(ExportType.USAGE)
        .withTimeframe(TimeframeType.THE_LAST_MONTH)
        .withDataset(
            new QueryDataset()
                .withGranularity(GranularityType.NONE)
                .withAggregation(
                    mapOf(
                        "totalCost",
                        new QueryAggregation()
                            .withName("PreTaxCost")
                            .withFunction(FunctionType.SUM)))
                .withGrouping(
                    Arrays.asList(
                        new QueryGrouping()
                            .withType(QueryColumnType.DIMENSION)
                            .withName("ResourceGroup")))),
    Context.NONE);

对于“逐个展示 subscription 成本”的需求,这通常是最直接的方式,也不需要 customer ID。

按 CSP 客户查询

如果应用运行在 CSP 合作伙伴一侧,需要查询某个客户名下的成本,scope 应为:

/providers/Microsoft.Billing/billingAccounts/{billingAccountName}/customers/{customerName}

这些值是 Azure Billing API 返回的资源名称,不能根据 Partner Center 中显示的公司名称、域名或订阅 ID 来猜测。

完整资源 ID 通常类似:

/providers/Microsoft.Billing/billingAccounts/12345678/customers/87654321

传给 Cost Management SDK 的 scope 需要包含开头的 /:

String scope =
    "/providers/Microsoft.Billing/billingAccounts/"
        + billingAccountName
        + "/customers/"
        + customerName;

如何获得 billingAccount 和 customer ID

先使用同一套凭据枚举当前身份可见的 Billing 资源。对应的 REST API 是:

GET https://management.azure.com/providers/Microsoft.Billing/billingAccounts?api-version={supported-api-version}

获得 billing account 后,再查询该账户下的客户:

GET https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{billingAccountName}/customers?api-version={supported-api-version}

从响应中读取资源的 name,也可以从 id 中解析对应的路径段。例如:

{
  "id": "/providers/Microsoft.Billing/billingAccounts/12345678/customers/87654321",
  "name": "87654321",
  "type": "Microsoft.Billing/billingAccounts/customers"
}

对应关系为:

billingAccountName = 12345678
customerName       = 87654321

也可以使用 Java Billing SDK 枚举这些资源。不同版本的 azure-resourcemanager-billing 在返回类型和分页方法上可能有所不同,基本形式如下:

BillingManager billingManager = BillingManager.authenticate(credential, profile);

billingManager.billingAccounts().list().forEach(account -> {
    System.out.println("Billing account name: " + account.name());
    System.out.println("Billing account id:   " + account.id());
});

取得 billingAccountName 后,再使用当前 SDK 版本提供的 customers list/listByBillingAccount 操作列出客户。具体写法应以项目实际依赖版本生成的接口为准。如果 SDK 没有提供所需的枚举方法,可以通过 Azure Billing REST API 发现资源,再把得到的 scope 交给 CostManagementManager。

如果枚举 billing accounts 时返回空结果或 403,问题通常不是“没有 customer ID”,而是当前服务主体根本看不到合作伙伴的计费账户。

权限为什么可能仍然不足

拥有“Billing Administrator”并不等于 Cost Management API 可以访问目标数据。还要确认它具体是哪一种角色,以及该角色被授予在哪个范围。

Azure 中有几类容易混淆的权限:

  • Microsoft Entra 管理员角色;
  • Azure RBAC 角色;
  • Azure Billing 计费角色;
  • Partner Center 合作伙伴角色;
  • CSP 客户租户中的委派管理关系。

Cost Management 检查的是目标 scope 上的授权。查询订阅成本时,服务主体通常需要在对应订阅或其上级管理组中获得可读取成本数据的 Azure RBAC 权限,例如 Cost Management Reader。某些场景下,Reader 也能读取成本,但还要看计费策略是否允许显示费用。

查询 CSP customer scope 时,身份必须属于合作伙伴租户,并能访问对应的 billing account/customer。合作伙伴关系、客户使用的 Azure plan、计费账户角色和成本可见性策略都可能影响结果。只在应用注册所在租户中授予目录级 Billing 角色,不会自动获得所有 CSP 客户的成本访问权。

CSP 并非一概不能使用 Cost Management

“CSP account 无法访问成本”这个说法范围过大。是否支持,要看具体的商业协议和 offer。

需要区分的情况包括:

  • 使用 Azure plan 和新商业体验的 CSP 客户;
  • 旧版或经典 CSP offer;
  • 直接合作伙伴、间接提供商和间接经销商;
  • 由合作伙伴查看成本,还是由客户在自己的订阅范围内查看成本。

部分旧 CSP offer 的成本数据确实无法通过 Cost Management 获取。遇到这种情况,Java SDK 和直接发送 REST 请求都会受到相同限制,可能需要改用 Partner Center 提供的对账单、用量或计费数据接口。

接口也不一定总会返回下面这段固定文本:

Cost management data is unavailable for subscription

实际响应可能随 scope、API 版本、权限检查顺序和商业协议而变化。因此,没有看到这句话,并不能证明当前 CSP offer 一定受支持。

修正身份配置

以下环境变量应指向同一个服务主体和租户:

AZURE_CLIENT_ID
AZURE_TENANT_ID
AZURE_CLIENT_SECRET

可以显式指定租户,降低 DefaultAzureCredential 选中其他本机身份的可能性:

TokenCredential credential = new DefaultAzureCredentialBuilder()
    .tenantId(System.getenv("AZURE_TENANT_ID"))
    .authorityHost(AzureEnvironment.AZURE.getActiveDirectoryEndpoint())
    .build();

AzureProfile profile = new AzureProfile(
    System.getenv("AZURE_TENANT_ID"),
    null,
    AzureEnvironment.AZURE);

CostManagementManager costManagementManager =
    CostManagementManager.authenticate(credential, profile);

如果应用只允许使用环境变量中的凭据,也可以改用 ClientSecretCredentialBuilder,避免意外选中开发机上的 Azure CLI、IDE 或托管身份:

TokenCredential credential = new ClientSecretCredentialBuilder()
    .clientId(System.getenv("AZURE_CLIENT_ID"))
    .tenantId(System.getenv("AZURE_TENANT_ID"))
    .clientSecret(System.getenv("AZURE_CLIENT_SECRET"))
    .authorityHost(AzureEnvironment.AZURE.getActiveDirectoryEndpoint())
    .build();

不要输出客户端密钥。原代码中的这一行应删除:

System.out.println(System.getenv("AZURE_CLIENT_SECRET"));

如果密钥已经写入控制台日志、CI 日志或应用日志,应立即轮换。

查出 Netty 异常背后的真实错误

下面的捕获方式只会调用 toString():

catch (final Exception e) {
    System.out.println(e);
}

这样往往看不到嵌套的 HTTP 响应或根本原因。至少应输出完整的异常链:

catch (Exception e) {
    e.printStackTrace();

    Throwable cause = e.getCause();
    while (cause != null) {
        System.err.println(
            cause.getClass().getName() + ": " + cause.getMessage());
        cause = cause.getCause();
    }
}

检查异常链中是否包含以下内容:

  • HTTP 400:scope 格式、查询字段或 aggregation/grouping 不合法;
  • HTTP 401:令牌、租户、client secret 或 token audience 有问题;
  • HTTP 403:身份已通过认证,但没有目标 scope 的访问权限;
  • HTTP 404:billing account/customer 不存在,或者当前身份无法看到它;
  • 明确的数据不可用错误:offer 或计费范围不受支持;
  • SSLHandshakeException、ConnectException、UnknownHostException:代理、证书、DNS 或网络问题;
  • NoSuchMethodError、ClassNotFoundException:Azure SDK、Reactor Netty 或 Netty 依赖存在版本冲突。

Azure 权限错误通常会产生 HTTP 响应。如果完整异常链中完全没有 HTTP 状态码,错误发生在 ChannelInitializer、TLS 或类加载阶段,应先检查网络和依赖冲突,而不是继续调整 Azure RBAC。

推荐排查顺序

  1. 删除输出 AZURE_CLIENT_SECRET 的代码。
  2. 输出完整异常堆栈和所有嵌套异常。
  3. 先使用 /subscriptions/{subscriptionId} 查询一个已明确授权的订阅。
  4. 确认服务主体在该订阅上拥有成本读取权限。
  5. 如果必须按 CSP 客户查询,先枚举 billing accounts,再枚举 customers,不要继续使用示例中的占位 ID。
  6. 确认应用身份属于正确的合作伙伴租户,并且能够看到对应的 billing account。
  7. 确认客户使用的是 Cost Management 支持的 CSP Azure plan。
  8. 如果同一 scope 的 REST 请求也失败,应根据实际 HTTP 状态码处理,不要只凭 Netty 的外层日志判断是否存在权限问题。

仅凭 io.netty.channel.ChannelInitializer exceptionCaught,还不能把当前异常归因于 CSP 限制。代码中的 customer scope 尚未使用真实的资源标识,权限类型和授权范围也没有确认。先用订阅 scope 验证身份和查询本身,再通过 Billing API 找到真实的 billing account/customer scope,可以更快判断问题究竟来自权限、offer 限制,还是客户端传输。

备注:内容仅供参考。