Claude Media
WIFをKubernetesと連携する — Podの署名トークンでAPIキー不要に

WIFをKubernetesと連携する — Podの署名トークンでAPIキー不要に

自己管理Kubernetesクラスターのprojected service account tokenでClaude APIを呼ぶ、Workload Identity Federationの設定手順を追います。

KubernetesのPodからAPIキーなしでClaude APIを呼ぶ仕組み

自己管理のKubernetesクラスター(kubeadm・k3s・OpenShift・オンプレミス各種)は、projected service account tokenの仕組みで、すべてのPodにOIDCのJSON Web Token(JWT)を発行できます。クラスターのAPIサーバー自身がOIDC発行者になり、各トークンのsubクレームはsystem:serviceaccount:<namespace>:<service-account>という形式です。

Workload Identity Federation(WIF)は、このトークンをPOST /v1/oauth/tokenで短命のAnthropicアクセストークンに交換する仕組みです。長期間有効なAPIキーをPodのSecretに置く必要がなくなります。

クラスターの発行者URLは、discoveryドキュメントを読めば分かります。

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

projected service account tokenとAPIサーバーによる発行は、Kubernetes自体の機能です。あらゆるディストリビューションの土台になっています。

マネージドKubernetesサービスなら、クラウドごとのガイドにプロバイダー管理の発行者URLの探し方があります。対象はAWS EKS、Google Cloud GKE、Azure AKSです。

SPIREを使っているクラスターでは、APIサーバーではなくSPIRE OIDC Discovery Providerが発行者になります。それ以外のディストリビューションやガイドに載っていないマネージドプロバイダーは、この記事の手順をそのまま使い、クラスターが報告する発行者URLを当てはめます。

WIFの基本用語(サービスアカウント・連合発行者・連合ルール)はWIFリファレンスにまとめています。

前提条件

  • クラスターのAPIサーバーに--service-account-issuerフラグが設定されていること。ほとんどのディストリビューションではデフォルトで設定済みで、kubeadmクラスターは通常 https://kubernetes.default.svc.cluster.local を使う
  • 以下のいずれかで、Anthropicがトークンの署名を検証できること
    • 発行者のJWKSエンドポイントが443番ポートでインターネットから到達可能(Anthropicが取りに行くURL、つまりdiscoveryの発行者URLとJWKS URL、explicit_urlのJWKS URLは、https・443番ポート・公開DNSのホスト名に限られ、IPリテラルは使えない)
    • クラスター内部からJWKSを取得し、inlineモードで登録できる(手順は後述の「Anthropic側の設定」)
  • Claude ConsoleでAnthropicの組織にサービスアカウント・連合発行者・連合ルールを作成できる権限

Kubernetes側の設定

Podに、連合ルールが期待するaudienceと有効期限でサービスアカウントトークンを投影します。serviceAccountTokenのprojectionは、マウントパスに新しい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  # ルールが複数のワークスペースで有効なときは必須
          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"]を持ちます。

expirationSeconds: 3600は、連合発行者が許す最大寿命の既定値(1時間)ちょうどです。JWTのexpとiatの差が発行者の設定した上限を超えると交換は拒否されます。上限はClaude Consoleで発行者ごとに変えられるので、投影の寿命を延ばすときは発行者側の上限も見直します。

Anthropic側の設定

Claude ConsoleのSettings → Workload identityを開き、Connect workloadからKubernetesタイルを選ぶと、発行者の登録・サービスアカウントの作成・連合ルールの作成をウィザードが順に案内します。

ウィザードが作成するリソースは、ウィザードで直接入力する場合も、Admin APIへ送る場合も値は共通です。

連合発行者のJWKSをどう渡すか

発行者を登録するとき、署名鍵(JWKS)の渡し方をjwks.typeで選びます。クラスターの状況ごとの選び分けは次のとおりです。

選び分け

JWKSの3つの渡し方

  • discovery(既定)

    Anthropicが発行者URLの/.well-known/openid-configurationを取りに行き、そこに載るjwks_uriから鍵を取得します。発行者がインターネットから443番ポートで見えるクラスター向けです。

  • explicit_url

    JWKSのURLを直接指定します。discoveryドキュメントは内部専用で、JWKSだけが公開されている場合に使います。discoveryとexplicit_urlは、私設CAで署名したTLSの発行者向けにca_cert_pemも受け付けます。

  • inline

    鍵の配列を登録時に貼り付けます。Anthropicからの外向き通信は発生しません。kubeadmの既定のように発行者URLが外に出ていないクラスターでは、これが標準的な選択です。

inlineで登録するときは、クラスター内部から鍵を取得します。

kubectl get --raw /openid/v1/jwks

返ってきたkeys配列の中身({"keys": [...]}という外側のラッパーではなく中身)を使って発行者を設定します。

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

inlineモードではissuer_urlはJWTのissクレームとの文字列比較にだけ使われ、AnthropicがこのURLにアクセスすることはありません。発行者がインターネットから到達可能な場合は、"jwks": {"type": "discovery"}を使います。

鍵のローテーションで交換が止まる条件

クラスターがサービスアカウントの署名鍵を入れ替えたとき、inlineの鍵を使っているなら発行者の更新は利用者の仕事です。ローテーションは頻度が低く、通常はクラスターのアップグレード時だけです。ただし新しいJWKSを反映するまで、トークン交換は署名エラーで失敗し続けます。

discoveryとexplicit_urlでは、Anthropic側がJWKSをキャッシュします。数字で並べると次のとおりです。

数字

鍵と時計にまつわる数値

  • キャッシュ更新待ち

    最大1分

    新しい鍵で署名した直後は署名エラーになりうる

  • 新しい鍵の事前公開

    15分以上前

    署名に使い始める前にJWKSへ載せる

  • 時計のずれの許容

    30秒

    exp・nbf・iatに適用される

  • assertionの上限

    16 KiB

    交換に渡すJWTの大きさ

いずれもWIFリファレンスの検証ルールと鍵ローテーションの項に載る値

自己管理クラスターは、この事前公開の運用を自分で守る側にいます。新しい鍵を先にJWKSへ載せ、古い鍵は、それで署名したトークンの期限が切れるまで残します。

連合ルール

サービスアカウントのsubクレームと、投影したトークンに設定したaudienceをマッチ条件にします。

{
  "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
}

subject_prefixをsystem:serviceaccount:inference:*まで緩めるのは、そのnamespace内のすべてのサービスアカウントを同じAnthropicサービスアカウントにマッピングしたい場合だけにします。作成したルールのfdrl_...IDは、PodのANTHROPIC_FEDERATION_RULE_ID環境変数に設定します。

oauth_scopeは、交換で得たトークンが呼べるエンドポイントの上限です。workspace:developerはFilesやSkillsなど非管理系のAPI全般に届きます。Messagesを呼ぶだけのPodならworkspace:inferenceで足り、範囲外のエンドポイントはHTTP 403になります。

トークンを取得してClaude APIを呼ぶ

「Kubernetes側の設定」のPod定義は、ANTHROPIC_IDENTITY_TOKEN_FILEを投影されたマウントパスに、ANTHROPIC_FEDERATION_RULE_ID・ANTHROPIC_ORGANIZATION_ID・ANTHROPIC_SERVICE_ACCOUNT_ID・ANTHROPIC_WORKSPACE_IDとあわせて設定済みです。SDKは交換のたびにディスクからトークンを読み、Anthropicアクセストークンを自動で更新します。

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-opus-5-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello, Claude"}],
)

curlで直接交換する場合は、マウントされたトークンファイルをそのままassertionに使います。

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 -sS 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-opus-5-5","max_tokens":1024,"messages":[{"role":"user","content":"Hello, Claude"}]}'

交換で得るトークンの寿命は、ルールのtoken_lifetime_secondsと、提示したJWTの残り寿命の2倍のうち短いほうです。ただし60秒を下回ることはありません。上の例のルール(600秒)に、残り200秒のJWTを出すと、Anthropicのトークンは400秒です。残りが20秒なら下限の60秒になります。

token_lifetime_secondsに指定できる範囲は60〜86400秒で、既定は3600秒です。

環境変数の代わりに、プロファイルファイルでも同じ設定を渡せます。<設定ディレクトリ>/configs/<プロファイル名>.jsonは秘密情報を含まないため、コンテナイメージに焼き込んだりConfigMapから配ったりできます。

{
  "version": "1.0",
  "authentication": {
    "type": "oidc_federation",
    "federation_rule_id": "fdrl_...",
    "service_account_id": "svac_...",
    "identity_token": {
      "source": "file",
      "path": "/var/run/secrets/anthropic.com/token"
    }
  },
  "organization_id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "wrkspc_...",
  "base_url": "https://api.anthropic.com"
}

設定ディレクトリは$ANTHROPIC_CONFIG_DIR、なければLinuxとmacOSでは~/.config/anthropicです。使うプロファイルはANTHROPIC_PROFILEで選びます。

設定を検証する

交換に成功すると、sk-ant-oat01-で始まるaccess_tokenとexpires_inが返ります。

401のauthentication_error(メッセージはAuthentication failedという不透明な固定文言)で失敗する場合は、認証履歴ページで拒否理由を見ます。拒否の種類を呼び出し側が嗅ぎ分けて、ルールの設定を探れないようにするため、どの検査で落ちても応答は同じです。理由は履歴にだけ残り、たとえばsubが合わなければmatch_subject_prefixと出ます。

Kubernetes側で最も多い原因は、JWKSの鍵の不一致です。inlineモードなら、kubectl get --raw /openid/v1/jwksで鍵を取り直し、発行者の登録を更新します。

交換が失敗するときの調べ方

認証履歴ページに履歴が出ないまま401が返るときは、federation_rule_idそのものが認識されていない可能性があります。履歴が出るなら、その理由を手がかりに、次の順でトークンの中身とルールを突き合わせます。

手順

トークンとルールを突き合わせる順序

  1. 1

    トークンをデコードする

    Podの中でマウントされたトークンを読み、ペイロードを表示します。JWTのペイロードはBase64URLなので、-と_を戻してからデコードします。

    kubectl exec <pod> -- cat /var/run/secrets/anthropic.com/token \
      | jq -rR 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'
  2. 2

    issが発行者URLと一致するか

    issは、登録したissuer_urlと1文字単位で一致する必要があります。スキームやポート、末尾のスラッシュの違いも不一致です。

  3. 3

    audがルールのaudienceを含むか

    audが配列のときは、要素のどれかがルールのaudienceと完全に一致すれば通ります。PodのserviceAccountToken投影に書いた値と食い違っていれば、ここで落ちます。

  4. 4

    subがsubject_prefixに合うか

    subject_prefixは大文字小文字を区別します。末尾に*がなければ完全一致で、あれば前方一致です。namespaceやサービスアカウント名のタイプミスは、ここで見つかります。

  5. 5

    exp・iatとノードの時計を見る

    expは未来、iatは過去でなければならず、30秒の許容が付きます。ノードの時計が大きくずれていると、有効なトークンでも拒否されます。

  6. 6

    JWKSが現在の署名鍵と合うか

    inlineなら、kubectl get --raw /openid/v1/jwksの出力と、登録済みの鍵を突き合わせます。クラスターの鍵ローテーションの後だけ失敗するなら、ここが原因です。トークン自体はexpirationSecondsで自動更新されても、inlineの登録は追従しないためです。

同じJWTの再送にも注意が必要です。jtiクレームを持つassertionは、発行者ごとに1回しか交換できません。リトライのループや、ローテーション前のトークンを再読込する更新処理が同じJWTを2度送ると、2度目は拒否されます。認証履歴にはjti_reusedと出るので、交換のたびに新しいassertionを作ります。

複数のワークスペースで有効なルールを使うPodがANTHROPIC_WORKSPACE_IDを設定し忘れると、履歴にはworkspace_id_requiredという理由が残ります。

APIキーからの移行でSDK側につまずくとき

ルールが正しくても、SDKが連合に進まなかったり、呼び出しが途中で止まったりすることがあります。Pod内で起きやすい原因は次の4つです。

症状別

SDK側でつまずく4つの原因

  • APIキーが先に勝つ

    ANTHROPIC_API_KEYやANTHROPIC_AUTH_TOKENが環境にあると、連合の環境変数より優先されます。SecretからAPIキーを注入していたPodを移行するときは、その変数を外します。

  • 空文字でも勝つ

    ANTHROPIC_API_KEY=""のように空で定義しただけでも、その変数は優先順位の枠を占めます。空のAPIキーで認証しようとするので、Pod定義から項目ごと消します。

  • トークンファイルがない

    初回のリクエストでFileNotFoundErrorが出るときは、ANTHROPIC_IDENTITY_TOKEN_FILEのパスが存在しません。SDKはファイルを交換の直前に開くので、projectedボリュームのマウント漏れや、パスの打ち間違いが原因です。

  • 403で止まる

    交換は成功するのに、FilesやSkillsなどの呼び出しだけが403になるなら、ルールがworkspace:inferenceのままになっていないかを見ます。

直接の環境変数で連合が働くのは、ANTHROPIC_FEDERATION_RULE_ID・ANTHROPIC_ORGANIZATION_ID・ANTHROPIC_SERVICE_ACCOUNT_IDと、ANTHROPIC_IDENTITY_TOKEN_FILE(またはANTHROPIC_IDENTITY_TOKEN)がすべてそろったときです。ANTHROPIC_WORKSPACE_IDは一緒に読まれるだけで、有効化の条件には入りません。

ルールを絞り込むときの注意点

system:serviceaccount:*だけのsubject_prefixは、クラスター内のすべてのサービスアカウントにマッチしてしまい、どのPodでも連合済みのAnthropicトークンを取得できてしまいます。audienceのマッチ条件がなければ、すべてのPodがすでに持っているデフォルトaudienceのトークンにもルールがマッチします。

マッチ条件は、書いたものがすべてAND条件になります。ルールにはsubject_prefix・claims・conditionのうち少なくとも1つが必要で、audienceだけのルールは作成時に拒否されます。audienceは、subject_prefixを絞った上で重ねる条件です。

ルールのmatchブロックは、用途に合う最も狭いスコープに絞ります。

絞り込みやり方
namespaceとサービスアカウント名を固定やり方末尾に*を付けず、完全なsystem:serviceaccount:<namespace>:<name>を使う
audienceを必ず設定するやり方ルールにaudienceを必須にし、PodのserviceAccountToken投影にも同じ値を設定してデフォルトaudienceのトークンを拒否する
namespaceごとに別ルールを用意するやり方1つのルールを広げるのではなく、namespaceごとに専用のルールとAnthropicサービスアカウントを作る
inline JWKS発行者をクラスター単位に絞るやり方複数クラスターが同じ発行者URLを共有していても、クラスターごとにJWKSを別の連合発行者として登録し、ルールをその発行者だけに紐づける

GCPやGitHub Actionsとの違い

同じWIFでも、Kubernetesの素の発行者と、Google Cloud・GitHub Actionsではトークンの発行元とクレームの形式が異なります。GKEでWorkload Identityを使う場合の手順はWIFをGoogle Cloudと連携する、CI/CDパイプラインからGitHub Actions経由で呼ぶ手順はWIFをGitHub Actionsと連携するにまとめています。

いずれの構成でも、トークン交換のリクエスト・レスポンス仕様と連合ルールの粒度設計は共通です。CEL条件式の書き方やエラーコードの逆引きはWIFリファレンスを参照してください。

まとめ

発行者URLが外に出ていないクラスターなら、inlineのJWKSで始め、鍵のローテーションを自分の運用に組み込めるかを先に決めます。ルールの条件はnamespaceとサービスアカウント名の完全一致から始めます。

この記事を共有:XはてブLinkedIn