在 Kubernetes 中使用 WIF

使用投影的服务账号令牌从自管理 Kubernetes 集群认证到 Claude API。


自管理 Kubernetes 集群(kubeadm、k3s、OpenShift 和本地部署发行版)通过投影的服务账号令牌为每个 Pod 签名 OIDC JSON Web Token (JWT)。集群的 API 服务器充当 OIDC 签发方,每个令牌的 sub 声明遵循格式 system:serviceaccount:<namespace>:<service-account>。你可以通过读取发现文档来查找集群的签发方 URL:

kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
Note

本页的机制(投影的服务账号令牌、集群 API 服务器作为 OIDC 签发方)是 Kubernetes 本身的原生机制,因此它适用于所有 Kubernetes 发行版。如果你在托管 Kubernetes 服务上运行,云提供者指南将引导你找到提供者管理的签发方 URL:AWS (EKS)Google Cloud (GKE)Azure (AKS)。如果你的集群运行 SPIRE,SPIRE OIDC Discovery Provider 是签发方而非集群 API 服务器;请参阅 SPIFFE。对于未列出的其他发行版或托管提供者,请按照本指南操作并使用集群报告的签发方 URL。

前置条件

  • 熟悉 WIF 概念:服务账号、联合签发方和联合规则。
  • 一个在 API 服务器上配置了 --service-account-issuer 标志的 Kubernetes 集群。大多数发行版默认设置此标志;kubeadm 集群通常使用 https://kubernetes.default.svc.cluster.local。如果你没有直接访问 API 服务器配置的权限,你的平台团队可以确认该值。
  • 以下之一,以便 Anthropic 可以验证令牌签名:
    • 签发方的 JWKS 端点可通过公共互联网在 443 端口通过 HTTPS 访问,或者
    • 你可以从集群内部获取 JWKS 并以 inline 模式注册(在配置 Anthropic 中介绍)。
  • 拥有在 Claude Console 中为你的 Anthropic 组织创建服务账号、联合签发方和联合规则的权限。

配置 Kubernetes

将服务账号令牌投影到你的 Pod 中,使用你的联合规则期望的受众和生命周期。serviceAccountToken 投影将新的 JWT 写入挂载路径,并在 expirationSeconds 到期前轮换它。

apiVersion: v1
kind: Pod
metadata:
  name: inference-worker
  namespace: inference
spec:
  serviceAccountName: inference-worker
  volumes:
    - name: anthropic-token
      projected:
        sources:
          - serviceAccountToken:
              audience: https://api.anthropic.com
              expirationSeconds: 3600
              path: token
  containers:
    - name: app
      image: your-registry/inference-worker:latest
      env:
        - name: ANTHROPIC_IDENTITY_TOKEN_FILE
          value: /var/run/secrets/anthropic.com/token
        - name: ANTHROPIC_FEDERATION_RULE_ID
          value: fdrl_...
        - name: ANTHROPIC_ORGANIZATION_ID
          value: 00000000-0000-0000-0000-000000000000
        - name: ANTHROPIC_SERVICE_ACCOUNT_ID
          value: svac_...
        - name: ANTHROPIC_WORKSPACE_ID  # required when the rule covers multiple workspaces
          value: wrkspc_...
      volumeMounts:
        - name: anthropic-token
          mountPath: /var/run/secrets/anthropic.com
          readOnly: true

为此 Pod 签发的令牌携带 sub: "system:serviceaccount:inference:inference-worker"aud: ["https://api.anthropic.com"]

配置 Anthropic

按照设置指南在 Claude Console 中注册联合签发方、创建 Anthropic 服务账号并创建联合规则。使用以下 Kubernetes 特定的值。

联合签发方: 许多自管理集群使用的签发方 URL(如 https://kubernetes.default.svc.cluster.local)无法从公共互联网访问。如果你的集群属于这种情况,请选择 inline JWKS 源并粘贴集群的密钥。从集群内部获取它们:

kubectl get --raw /openid/v1/jwks

然后使用返回的 keys 数组的内容(不是外层的 {"keys": [...]} 包装器)配置签发方:

{
  "name": "onprem-k8s",
  "issuer_url": "https://kubernetes.default.svc.cluster.local",
  "jwks_source": "inline",
  "jwks_keys": [{ "kty": "RSA", "kid": "...", "n": "...", "e": "AQAB" }]
}

inline 模式下,issuer_url 仅与 JWT 的 iss 声明进行比较;Anthropic 永远不会尝试访问它。如果你的签发方可公开访问,请改用 "jwks_source": "discovery" 并省略 jwks_keys

Warning

使用 inline 密钥时,你有责任在集群轮换服务账号签名密钥时更新签发方。轮换很少发生(通常仅在集群升级期间),但在你推送新的 JWKS 之前,令牌交换会因签名错误而失败。

联合规则: 匹配服务账号的 sub 声明和你在投影令牌上设置的受众。

{
  "name": "onprem-inference",
  "issuer_id": "fdis_...",
  "match": {
    "subject_prefix": "system:serviceaccount:inference:inference-worker",
    "audience": "https://api.anthropic.com"
  },
  "target": {
    "type": "service_account",
    "service_account_id": "svac_..."
  },
  "workspace_id": "wrkspc_...",
  "oauth_scope": "workspace:developer",
  "token_lifetime_seconds": 600
}

尽可能精确地匹配工作负载。只有当命名空间中的每个服务账号都需要映射到同一个 Anthropic 服务账号时,才将 subject_prefix 放宽为 system:serviceaccount:inference:*(末尾的 * 使其成为前缀匹配)。将规则的 fdrl_... ID 添加到 Pod 的 ANTHROPIC_FEDERATION_RULE_ID 环境变量中。

获取并使用令牌

配置 Kubernetes 中的 Pod 规范将 ANTHROPIC_IDENTITY_TOKEN_FILE 设置为投影的挂载路径,同时设置了 ANTHROPIC_FEDERATION_RULE_IDANTHROPIC_ORGANIZATION_IDANTHROPIC_SERVICE_ACCOUNT_IDANTHROPIC_WORKSPACE_ID。有了这些,SDK 会在每次交换时从磁盘读取令牌,并自动刷新 Anthropic 访问令牌。

JWT=$(cat "$ANTHROPIC_IDENTITY_TOKEN_FILE")

ACCESS_TOKEN=$(curl -sS https://api.anthropic.com/v1/oauth/token \
  -H "content-type: application/json" \
  --data @- <<JSON | jq -r .access_token
{
  "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
  "assertion": "$JWT",
  "federation_rule_id": "$ANTHROPIC_FEDERATION_RULE_ID",
  "organization_id": "$ANTHROPIC_ORGANIZATION_ID",
  "service_account_id": "$ANTHROPIC_SERVICE_ACCOUNT_ID",
  "workspace_id": "$ANTHROPIC_WORKSPACE_ID"
}
JSON
)

curl https://api.anthropic.com/v1/messages \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-4-6",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Hello, Claude"}]
  }' | jq -r '.content[0].text'
import anthropic

# 从 Pod 环境中读取 ANTHROPIC_IDENTITY_TOKEN_FILE、ANTHROPIC_FEDERATION_RULE_ID、
# ANTHROPIC_ORGANIZATION_ID、ANTHROPIC_SERVICE_ACCOUNT_ID 和 ANTHROPIC_WORKSPACE_ID。
client = anthropic.Anthropic()

message = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello, Claude"}],
)
print(message.content[0].text)
import Anthropic from "@anthropic-ai/sdk";

// 从 Pod 环境中读取 ANTHROPIC_IDENTITY_TOKEN_FILE、ANTHROPIC_FEDERATION_RULE_ID、
// ANTHROPIC_ORGANIZATION_ID、ANTHROPIC_SERVICE_ACCOUNT_ID 和 ANTHROPIC_WORKSPACE_ID。
const client = new Anthropic();

const message = await client.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Hello, Claude" }]
});
for (const block of message.content) {
  if (block.type === "text") {
    console.log(block.text);
  }
}
package main

import (
	"context"
	"fmt"

	"github.com/anthropics/anthropic-sdk-go"
)

func main() {
	// 从 Pod 环境中读取 ANTHROPIC_IDENTITY_TOKEN_FILE、ANTHROPIC_FEDERATION_RULE_ID、
	// ANTHROPIC_ORGANIZATION_ID、ANTHROPIC_SERVICE_ACCOUNT_ID 和 ANTHROPIC_WORKSPACE_ID。
	client := anthropic.NewClient()

	message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
		Model:     anthropic.ModelClaudeSonnet4_6,
		MaxTokens: 1024,
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock("Hello, Claude")),
		},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(message.Content[0].Text)
}
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.messages.MessageCreateParams;
import com.anthropic.models.messages.Model;

void main() {
    AnthropicClient client = AnthropicOkHttpClient.fromEnv();

    var message = client.messages().create(MessageCreateParams.builder()
            .model(Model.CLAUDE_SONNET_4_6)
            .maxTokens(1024)
            .addUserMessage("Hello, Claude")
            .build());

    IO.println(message.content());
}
using Anthropic.Models.Messages;
using Anthropic.Oidc;

var result = AnthropicCredentials.Resolve()
    ?? throw new InvalidOperationException("No federation credentials found in environment");
using var client = new AnthropicOidcClient(result);

var message = await client.Messages.Create(new()
{
    Model = Model.ClaudeSonnet4_6,
    MaxTokens = 1024,
    Messages = [new() { Role = Role.User, Content = "Hello, Claude" }],
});
foreach (var block in message.Content)
{
    if (block.Value is TextBlock textBlock)
    {
        Console.WriteLine(textBlock.Text);
    }
}
# 从 Pod 环境中读取 ANTHROPIC_FEDERATION_RULE_ID、ANTHROPIC_ORGANIZATION_ID、
# ANTHROPIC_SERVICE_ACCOUNT_ID、ANTHROPIC_WORKSPACE_ID 和 ANTHROPIC_IDENTITY_TOKEN_FILE。
ant messages create \
  --model claude-sonnet-4-6 \
  --max-tokens 1024 \
  --message '{role: user, content: "Hello, Claude"}'
<?php
require 'vendor/autoload.php';

use Anthropic\Client;

// 从 Pod 环境中读取 ANTHROPIC_FEDERATION_RULE_ID、ANTHROPIC_ORGANIZATION_ID、
// ANTHROPIC_SERVICE_ACCOUNT_ID、ANTHROPIC_WORKSPACE_ID 和 ANTHROPIC_IDENTITY_TOKEN_FILE。
$client = new Client();

$message = $client->messages->create(
    model: 'claude-sonnet-4-6',
    maxTokens: 1024,
    messages: [['role' => 'user', 'content' => 'Hello, Claude']],
);
echo $message->content[0]->text, PHP_EOL;
require "anthropic"

# 从 Pod 环境中读取 ANTHROPIC_FEDERATION_RULE_ID、ANTHROPIC_ORGANIZATION_ID、
# ANTHROPIC_SERVICE_ACCOUNT_ID、ANTHROPIC_WORKSPACE_ID 和 ANTHROPIC_IDENTITY_TOKEN_FILE。
client = Anthropic::Client.new

message = client.messages.create(
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  messages: [{role: "user", content: "Hello, Claude"}]
)
puts message.content.first.text

验证设置

成功交换会返回一个以 sk-ant-oat01- 开头的 access_token 和一个以秒为单位的 expires_in 值。如果出现 400 invalid_grant,请参阅排查交换失败;最常见的 Kubernetes 侧原因是 JWKS 密钥不匹配(对于 inline 模式,使用 kubectl get --raw /openid/v1/jwks 重新获取并更新签发方)。

限定规则范围

Warning

subject_prefixsystem:serviceaccount:* 会匹配集群中的每个服务账号,因此任何 Pod 都可以获取联合 Anthropic 令牌。如果没有 audience 匹配器,规则还会匹配集群的默认受众令牌,而每个 Pod 已经投影了该令牌。

将规则的 match 块锁定到适合你用例的最窄范围:

  • 固定命名空间和服务账号名称: 使用完整的 system:serviceaccount:<namespace>:<name> 值,不带末尾的 *
  • 始终设置受众: 在规则上要求 audience,并在 Pod 的 serviceAccountToken 投影上设置相同的值,这样默认受众令牌会被拒绝。
  • 为每个命名空间使用单独的规则: 为每个命名空间创建不同的规则和 Anthropic 服务账号,而不是放宽一个规则。
  • 将 inline-JWKS 签发方限定到一个集群: 当多个集群共享一个签发方 URL 时,将每个集群的 JWKS 注册为自己的联合签发方,并将规则仅绑定到该签发方。

后续步骤