将 Kubernetes 用作工作负载身份提供商,通过交换投影的 Kubernetes 服务账号令牌来获取短期 OpenAI 访问令牌。
本指南假设已启用 Kubernetes 服务账号令牌投影,这在现代 Kubernetes 版本中默认可用。OpenAI 工作负载身份联合需要兼容 OIDC 的投影服务账号令牌。不支持存储在 Secrets 中的旧版 Kubernetes 服务账号令牌。
为需要调用 OpenAI API 的 EKS 工作负载使用一个 Kubernetes ServiceAccount 针对需要调用 OpenAI API 的工作负载。如果尚未拥有,请创建一个:
kubectl create serviceaccount openai-wif --namespace default
获取 Kubernetes 集群的 OIDC 签发者:
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
即使您上传了 JWKS,并且 OpenAI 不会针对 OIDC 签发者执行 JWKS 发现,该签发者也必须与工作负载身份提供商中配置的签发者相匹配。
获取集群 JWKS 并保存返回的密钥集。配置工作负载身份提供商时将需要用到它:
kubectl get --raw /openid/v1/jwks
使用 OpenAI 预期的受众和适合您工作负载的过期时间来配置投影服务账号令牌。OpenAI 会验证令牌的颁发者、签名、受众和过期时间。在此示例中,令牌文件挂载于 /var/run/secrets/tokens/token, 使用的受众为 https://api.openai.com/v1, 并在 3600 秒后过期。如果投影令牌的受众与 OpenAI 工作负载身份提供商的受众相匹配,您可以使用不同的受众:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: v1
kind: Pod
metadata:
name: openai-wif-app
namespace: default
spec:
serviceAccountName: openai-wif
containers:
- name: app
image: my-image
volumeMounts:
- name: ksa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: ksa-token
projected:
sources:
- serviceAccountToken:
path: token
audience: "https://api.openai.com/v1"
expirationSeconds: 3600
在配置工作负载身份联合之前,请在本地解码一个示例投影服务账号令牌并检查其声明。在挂载了投影令牌的运行中的 Pod 上执行:
1
2
3
4
5
6
7
8
9
10
11
TOKEN=$(kubectl exec -n default openai-wif-app -- cat /var/run/secrets/tokens/token)
TOKEN="$TOKEN" python3 - <<'PY'
import base64
import json
import os
payload = os.environ["TOKEN"].split(".")[1]
payload += "=" * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY
解码后的 AWS 颁发 OIDC 令牌类似于:
解码后的 Kubernetes 投影服务账号令牌类似于:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"iss": "https://kubernetes.example.com",
"aud": ["https://api.openai.com/v1"],
"sub": "system:serviceaccount:default:openai-wif",
"iat": 1716235422,
"exp": 1716239022,
"kubernetes.io": {
"namespace": "default",
"serviceaccount": {
"name": "openai-wif",
"uid": "11111111-2222-3333-4444-555555555555"
}
}
}
声明中显现。 iss, aud,且 sub 设置工作负载身份联合
在 OpenAI 中为 Kubernetes 签发者创建工作负载身份提供商,然后添加与投影令牌中的属性相匹配的服务账号映射。
设置工作负载身份提供商
-
为一个唯一的值,例如 设置 名称 设置颁发者和受众。 kubernetes-prod值。使用 描述,例如 Production Kubernetes cluster, 以帮助管理员识别集群。
-
OIDC 颁发者 URL 设置 设置为启用出站身份联合时返回的 AWS 特定于账户的颁发者 URL。此值必须与令牌的 指向由 kubectl get --raw /.well-known/openid-configuration | jq -r .issuer返回的颁发者。此值必须与 iss 投影令牌中的声明。设置 为传递给 为在投影服务账号令牌卷上配置的相同不透明受众(audience)字符串。在此示例中,该值为 https://api.openai.com/v1.
-
上传 Kubernetes JWKS。 启用 使用上传的 JWKS 进行令牌验证,然后设置 JWKS JSON to the output from kubectl get --raw /openid/v1/jwks。OpenAI 使用此公钥集来验证投影的 Kubernetes 服务账号令牌。请上传包含周围 keys.
Note: 对于自托管 Kubernetes 集群,OpenAI 仅支持本地 JWKS 模式。请上传集群返回的 JWKS;OpenAI 不会针对配置的签发者执行 OIDC 发现。OpenAI 仍会将配置的签发者与令牌中的 iss 字段进行比较。
如果您的集群轮换服务账号签名密钥,请更新工作负载身份提供商配置中上传的 JWKS。由不在已配置 JWKS 中的密钥签名的令牌将被拒绝。如果 JWKS 包含多个活跃公钥,请包含完整的 keys array.
-
仅在需要派生映射属性时才添加属性转换。 诸如以下原始令牌声明 sub, aud,且 iss 可直接用于映射断言中。如果您计划基于转换后的属性而非原始令牌声明进行匹配,控制台将应用 openai. 前缀;例如,输入 workload_subject with expression assertion.sub to create openai.workload_subject。对于已经以以下内容开头的原始令牌声明 openai. 映射键,已以 openai. 开头的原始令牌声明将被忽略,除非配置了匹配的转换。
-
创建服务账户映射。 设置 名称 为 Workload Identity Provider 中的唯一值,例如 openai-mapping-kubernetes值。使用 描述,例如 Workload Identity Provider Mapping for Kubernetes Workloads,以说明哪个工作负载可以使用此映射。
-
匹配 Kubernetes 服务账号主体。 设置 键 to sub and 值 to system:serviceaccount:default:openai-wif。对于 Kubernetes 服务账号,主体格式为 system:serviceaccount:<namespace>:<service-account-name>.
-
选择 OpenAI 目标。 设置 项目 指向拥有目标服务账户的 OpenAI 项目。将 服务账户 为 Kubernetes 工作负载可以使用的 OpenAI 服务账号,例如 kubernetes-prod-openai-wif如果您希望为此映射创建新的服务账号而不是复用现有账号,请选中 Create a new service account in this project 如果您希望为此映射创建新的服务账号,而不是复用现有账号。
-
根据需要缩小 API 权限范围。 选择适当的 权限 例如 api.model.request and api.vector_store.read 以进一步限制从此映射生成的访问令牌。将权限留空可避免添加特定于 WIF 的范围限制;该令牌仍会以映射的服务账户身份进行授权。
配置你的 OpenAI SDK 客户端,以读取投射的 Kubernetes 令牌并将其换取 OpenAI 颁发的访问令牌。
使用已挂载的令牌路径(例如 /var/run/secrets/tokens/token,作为 SDK 工作负载身份联合提供者的主体令牌源。SDK 会将该 Kubernetes 令牌换取为 OpenAI 颁发的访问令牌,并使用该 OpenAI 令牌来验证 API 请求。
以下示例使用自定义主体令牌提供者初始化 OpenAI 客户端。该提供者会从挂载的文件路径中读取投射的 Kubernetes 服务账号令牌,并将其用作工作负载身份联合的主体令牌。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { readFile } from "node:fs/promises";
import OpenAI from "openai";
import type { SubjectTokenProvider } from "openai/auth";
const tokenPath = "/var/run/secrets/tokens/token";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;
if (!identityProviderId || !serviceAccountId) {
throw new Error("Set OPENAI_IDENTITY_PROVIDER_ID and OPENAI_SERVICE_ACCOUNT_ID");
}
function mountedServiceAccountTokenProvider(path: string): SubjectTokenProvider {
return {
tokenType: "jwt",
getToken: async () => {
const token = (await readFile(path, "utf8")).trim();
if (!token) {
throw new Error("The mounted service account token file is empty.");
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: mountedServiceAccountTokenProvider(tokenPath),
},
});
const response = await client.responses.create({
model: "gpt-4.1-mini",
input: "Say hello from Kubernetes workload identity federation.",
});
console.log(response.output_text);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import os
from pathlib import Path
from openai import OpenAI
from openai.auth import SubjectTokenProvider
TOKEN_PATH = "/var/run/secrets/tokens/token"
def mounted_service_account_token_provider(token_path: str) -> SubjectTokenProvider:
def get_token() -> str:
token = Path(token_path).read_text().strip()
if not token:
raise RuntimeError("The mounted service account token file is empty.")
return token
return {"token_type": "jwt", "get_token": get_token}
client = OpenAI(
workload_identity={
"identity_provider_id": os.environ["OPENAI_IDENTITY_PROVIDER_ID"],
"service_account_id": os.environ["OPENAI_SERVICE_ACCOUNT_ID"],
"provider": mounted_service_account_token_provider(TOKEN_PATH),
},
)
response = client.responses.create(
model="gpt-4.1-mini",
input="Say hello from Kubernetes workload identity federation.",
)
print(response.output_text)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/auth"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
)
const tokenPath = "/var/run/secrets/tokens/token"
type mountedServiceAccountTokenProvider struct {
path string
}
func (p mountedServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p mountedServiceAccountTokenProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {
data, err := os.ReadFile(p.path)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "kubernetes",
Message: "failed to read mounted service account token",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "kubernetes",
Message: "mounted service account token is empty",
}
}
return token, nil
}
func main() {
client := openai.NewClient(
option.WithWorkloadIdentity(auth.WorkloadIdentity{
IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),
ServiceAccountID: os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),
Provider: mountedServiceAccountTokenProvider{
path: tokenPath,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from Kubernetes workload identity federation."),
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(response.OutputText())
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.openai.auth.SubjectTokenProvider;
import com.openai.auth.SubjectTokenType;
import com.openai.auth.WorkloadIdentity;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.core.http.HttpClient;
import com.openai.errors.SubjectTokenProviderException;
import com.openai.models.ChatModel;
import com.openai.models.responses.ResponseCreateParams;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
public final class KubernetesWorkloadIdentityExample {
private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";
private KubernetesWorkloadIdentityExample() {}
static final class MountedServiceAccountTokenProvider implements SubjectTokenProvider {
private final Path tokenPath;
MountedServiceAccountTokenProvider(String tokenPath) {
this.tokenPath = Path.of(tokenPath);
}
@Override
public SubjectTokenType tokenType() {
return SubjectTokenType.JWT;
}
@Override
public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {
String token;
try {
token = Files.readString(tokenPath).trim();
} catch (Exception e) {
throw new SubjectTokenProviderException(
"kubernetes",
"failed to read mounted service account token",
e);
}
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"kubernetes",
"mounted service account token is empty",
null);
}
return token;
}
@Override
public CompletableFuture<String> getTokenAsync(
HttpClient httpClient, JsonMapper jsonMapper) {
return CompletableFuture.supplyAsync(() -> getToken(httpClient, jsonMapper));
}
}
public static void main(String[] args) {
WorkloadIdentity workloadIdentity = WorkloadIdentity.builder()
.identityProviderId(System.getenv("OPENAI_IDENTITY_PROVIDER_ID"))
.serviceAccountId(System.getenv("OPENAI_SERVICE_ACCOUNT_ID"))
.provider(new MountedServiceAccountTokenProvider(TOKEN_PATH))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder()
.workloadIdentity(workloadIdentity)
.build();
ResponseCreateParams params = ResponseCreateParams.builder()
.model(ChatModel.GPT_4_1_MINI)
.input("Say hello from Kubernetes workload identity federation.")
.build();
client.responses().create(params).output().stream()
.flatMap(item -> item.message().stream())
.flatMap(message -> message.content().stream())
.flatMap(content -> content.outputText().stream())
.forEach(outputText -> System.out.println(outputText.text()));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
require "openai"
TOKEN_PATH = "/var/run/secrets/tokens/token"
class MountedServiceAccountTokenProvider
include OpenAI::Auth::SubjectTokenProvider
def initialize(token_path:)
@token_path = token_path
end
def token_type
OpenAI::Auth::TokenType::JWT
end
def get_token
token = File.read(@token_path).strip
if token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Mounted service account token is empty",
provider: "kubernetes"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to read mounted service account token: #{e.message}",
provider: "kubernetes",
cause: e
)
end
end
provider = MountedServiceAccountTokenProvider.new(token_path: TOKEN_PATH)
workload_identity = OpenAI::Auth::WorkloadIdentity.new(
identity_provider_id: ENV.fetch("OPENAI_IDENTITY_PROVIDER_ID"),
service_account_id: ENV.fetch("OPENAI_SERVICE_ACCOUNT_ID"),
provider: provider
)
client = OpenAI::Client.new(workload_identity: workload_identity)
response = client.responses.create(
model: "gpt-4.1-mini",
input: "Say hello from Kubernetes workload identity federation."
)
puts(response.output_text)
- 使用稳定的 OIDC 颁发者。颁发者 URL 必须与投射的服务账号令牌
iss 声明相匹配,并且在集群升级和维护操作期间应保持稳定。
- 妥善保管签名密钥。任何拥有集群服务账号签名密钥访问权限的人都可以伪造令牌,而这些令牌可能会被 OpenAI 接受。
- 为 OpenAI 集成使用专门的服务账号。避免复用同时也用于不相关基础设施或应用访问的服务账号。
- 保持上传的 JWKS 处于最新状态。OpenAI 使用配置的 JWKS 在本地 JWKS 模式下验证工作负载身份令牌,因此在轮换到新的签名密钥之前,请先更新工作负载身份提供者。
- 尽量减少自定义声明的复杂性。建议优先匹配标准声明,例如
sub and aud,或直接从这些声明派生的转换属性。
- 将命名空间的归属管理视为安全模型的一部分。如果命名空间管理员可以创建服务账号,请确保映射范围已进行适当的限定,以防止意外的权限提升。
- 监控颁发者和签名密钥的变更。在未更新工作负载身份提供者 JWKS 的情况下轮换签名密钥可能会导致令牌交换失败。