Kubernetes에서 WIF 사용하기
Kubernetes에서 WIF 사용하기
자체 관리 Kubernetes 클러스터(kubeadm, k3s, OpenShift, 온프레미스 배포판)는 프로젝티드 서비스 계정 토큰을 통해 모든 파드에 OIDC JSON Web Token(JWT)을 서명해요. 클러스터의 API 서버가 OIDC 발급자로 작동하고, 각 토큰의 sub 클레임은 system:serviceaccount:<namespace>:<service-account> 형태를 따라요. 클러스터의 발급자 URL은 discovery 문서를 읽어 찾을 수 있어요:
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
참고 이 페이지의 메커니즘(프로젝티드 서비스 계정 토큰, 클러스터 API 서버를 OIDC 발급자로)은 Kubernetes 자체에 내재하므로 모든 Kubernetes 배포판의 기반이 돼요. 관리형 Kubernetes 서비스에서 실행한다면 클라우드 공급자 가이드가 공급자 관리 발급자 URL을 어디서 찾는지 안내해요: AWS (EKS), Google Cloud (GKE), Azure (AKS). 클러스터가 SPIRE를 실행한다면, 발급자는 클러스터 API 서버가 아니라 SPIRE OIDC Discovery Provider예요. SPIFFE 참고. 그 외의 배포판이나 나열되지 않은 관리 공급자는 이 가이드를 따라가고 클러스터가 보고하는 발급자 URL을 사용하세요.
출처: 문서
본문
사전 요구사항
- WIF 개념에 익숙해야 해요. 서비스 계정, 페더레이션 발급자, 페더레이션 규칙.
- API 서버에
--service-account-issuer플래그가 구성된 Kubernetes 클러스터. 대부분의 배포판은 기본으로 설정하며, kubeadm 클러스터는 보통https://kubernetes.default.svc.cluster.local을 사용해요. API 서버 구성에 직접 접근할 수 없으면 플랫폼 팀이 값을 확인해 줄 수 있어요. - Anthropic이 토큰 서명을 검증할 수 있게 하는 다음 중 하나:
- 발급자의 JWKS 엔드포인트가 공개 인터넷에서 포트 443 HTTPS로 도달 가능하거나,
- 클러스터 안에서 JWKS를 가져와
inline모드로 등록할 수 있거나(Anthropic 구성에서 다룸).
- Anthropic 조직의 Claude Console에서 서비스 계정, 페더레이션 발급자, 페더레이션 규칙을 만들 권한.
Kubernetes 구성하기
내 페더레이션 규칙이 기대하는 audience와 수명으로 서비스 계정 토큰을 파드에 프로젝트하세요. 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
이 파드를 위해 발행된 토큰은 sub: "system:serviceaccount:inference:inference-worker"와 aud: ["https://api.anthropic.com"]를 나른다.
Anthropic 구성하기
Claude Console에서 Settings → Workload identity를 열고 Connect workload를 클릭한 뒤 Kubernetes 타일을 선택하세요. 마법사가 발급자 등록, 서비스 계정 만들기, 페더레이션 규칙 만들기를 안내해요.
마법사가 이 리소스들을 만들어 줘요. 마법사에 입력하든 Admin API로 보내든 다음 값을 사용하세요:
페더레이션 발급자: 많은 자체 관리 클러스터가 공개 인터넷에서 도달할 수 없는 https://kubernetes.default.svc.cluster.local 같은 발급자 URL을 사용해요. 그런 경우 inline JWKS 소스를 선택하고 클러스터의 키를 붙여 넣으세요. 클러스터 안에서 가져오세요:
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은 절대 도달하려 하지 않아요. 발급자가 공개적으로 도달 가능하면 대신 "jwks": {"type": "discovery"}를 사용하세요.
경고
inline키를 사용하면 클러스터가 서비스 계정 서명 키를 회전할 때 발급자를 직접 업데이트해야 해요. 회전은 드물고(보통 클러스터 업그레이드 중에만), 하지만 새 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
}
워크로드가 허용하는 만큼 구체적으로 하세요. 네임스페이스의 모든 서비스 계정이 같은 Anthropic 서비스 계정에 매핑되어야만 subject_prefix를 system:serviceaccount:inference:*(끝자리 *가 접두사 일치로 만듦)로 느슨하게 하세요. 규칙의 fdrl_... ID를 파드의 ANTHROPIC_FEDERATION_RULE_ID 환경 변수에 추가하세요.
토큰 획득과 사용
Kubernetes 구성하기의 파드 스펙은 ANTHROPIC_IDENTITY_TOKEN_FILE을 프로젝티드 마운트 경로로, 그리고 ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_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-opus-5-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello, Claude"}]
}' | jq -r '.content[] | select(.type == "text") | .text'
import anthropic
# Reads ANTHROPIC_IDENTITY_TOKEN_FILE, ANTHROPIC_FEDERATION_RULE_ID,
# ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, and ANTHROPIC_WORKSPACE_ID
# from the pod's environment.
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Claude"}],
)
print(next(block.text for block in message.content if block.type == "text"))
import Anthropic from "@anthropic-ai/sdk";
// Reads ANTHROPIC_IDENTITY_TOKEN_FILE, ANTHROPIC_FEDERATION_RULE_ID,
// ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, and ANTHROPIC_WORKSPACE_ID
// from the pod's environment.
const client = new Anthropic();
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello, Claude" }]
});
for (const block of message.content) {
if (block.type === "text") {
console.log(block.text);
}
}
// Reads ANTHROPIC_IDENTITY_TOKEN_FILE, ANTHROPIC_FEDERATION_RULE_ID,
// ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, and ANTHROPIC_WORKSPACE_ID
// from the pod's environment.
client := anthropic.NewClient()
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus5_5,
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Hello, Claude")),
},
})
if err != nil {
panic(err)
}
for _, block := range message.Content {
if textBlock, ok := block.AsAny().(anthropic.TextBlock); ok {
fmt.Println(textBlock.Text)
break
}
}
AnthropicClient client = AnthropicOkHttpClient.fromEnv();
var message = client.messages().create(MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_5_5)
.maxTokens(1024)
.addUserMessage("Hello, Claude")
.build());
IO.println(message.content());
// Reads ANTHROPIC_IDENTITY_TOKEN_FILE, ANTHROPIC_FEDERATION_RULE_ID,
// ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, and ANTHROPIC_WORKSPACE_ID
// from the pod's environment.
using var client = new AnthropicClient();
var message = await client.Messages.Create(new()
{
Model = Model.ClaudeOpus5_5,
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);
}
}
# Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
# ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
ant messages create \
--model claude-opus-5-5 \
--max-tokens 1024 \
--message '{role: user, content: "Hello, Claude"}'
use Anthropic\Client;
// Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
// ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
$client = new Client();
$message = $client->messages->create(
model: 'claude-opus-5-5',
maxTokens: 1024,
messages: [['role' => 'user', 'content' => 'Hello, Claude']],
);
$textBlock = array_find($message->content, static fn ($block): bool => $block->type === 'text');
echo $textBlock->text, PHP_EOL;
require "anthropic"
# Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
# ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
client = Anthropic::Client.new
message = client.messages.create(
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "Hello, Claude"}]
)
puts message.content.find { it.type == :text }.text
설정 검증하기
성공한 교환은 sk-로 시작하는 access_token과 초 단위의 expires_in 값을 반환해요. 불투명한 401 authentication_error 응답(메시지 Authentication failed)으로 교환이 실패하면 인증 기록 페이지에서 거부 이유를 확인하고 실패한 교환 문제 해결을 참고하세요. 가장 흔한 Kubernetes 측 원인은 JWKS 키 불일치(inline 모드에서는 kubectl get --raw /openid/v1/jwks로 다시 가져와 발급자를 업데이트)예요.
규칙 범위 지정하기
경고
system:serviceaccount:*의subject_prefix는 클러스터의 모든 서비스 계정과 일치하므로 어떤 파드든 페더레이션 Anthropic 토큰을 얻을 수 있어요.audience매처가 없으면 규칙은 모든 파드가 이미 프로젝트한 클러스터의 기본 audience 토큰에도 일치해요.
규칙의 match 블록을 사용 사례에 맞는 가장 좁은 범위로 잠그세요:
- 네임스페이스와 서비스 계정 이름 고정: 끝자리
*없이 전체system:serviceaccount:<namespace>:<name>값을 사용하세요. - 항상 audience 설정: 규칙에
audience를 요구하고 파드의serviceAccountToken프로젝션에 같은 값을 설정해 기본 audience 토큰이 거부되게 하세요. - 네임스페이스별로 별도 규칙: 하나의 규칙을 넓히기보다 각 네임스페이스마다 별개 규칙과 Anthropic 서비스 계정을 만드세요.
- inline-JWKS 발급자를 한 클러스터로 범위: 여러 클러스터가 발급자 URL을 공유할 때 각 클러스터의 JWKS를 자체 페더레이션 발급자로 등록하고 규칙을 그 발급자에만 바인딩하세요.
다음 단계
- Workload Identity Federation — 개념, 토큰 교환 흐름, SDK 구성 옵션.
- WIF reference — 환경 변수, JWKS 소스 모드, 규칙 일치 모드.