生产环境的 AccessKey (AK/SK) 密钥管理始终是个难题。硬编码在代码里是灾难,存放在配置文件或环境变量中也只是五十步笑百步,一旦容器镜像或主机环境被攻破,长期有效的凭证就会彻底暴露。密钥轮换更是一项繁重且容易出错的运维任务。我们的目标是彻底消灭应用代码或容器环境中的任何长期阿里云凭证。容器启动时应能自动获取一个临时的、具有最小权限的凭证,并在凭证过期前自动刷新。
初步的构想是利用阿里云的 RAM 角色和 STS (Security Token Service)。容器内的应用通过扮演一个预设的 RAM 角色,来获取临时安全令牌(包含临时 AK/SK 和 SecurityToken)。这种方式早已成熟,但关键在于“扮演角色”这个动作本身需要身份验证。传统的 AssumeRole
API 调用需要一个更高权限的 AK/SK 来发起,这便陷入了“鸡生蛋,蛋生鸡”的困境。
真正的突破口在于 OpenID Connect (OIDC)。阿里云 RAM 现已支持将外部 OIDC Provider 作为可信实体。这意味着,我们可以配置一个 RAM 角色,使其信任某个 OIDC Provider 颁发的身份令牌 (JWT)。任何持有有效 JWT 的实体,都可以调用 STS 的 AssumeRoleWithOIDC
接口来扮演该角色,从而获取临时凭证。这个过程不再需要任何预先存在的阿里云 AK/SK。
本文将完整复盘一套可用于生产环境的方案:为运行在阿里云 ECS 上的 Docker 容器,通过 ECS 元数据服务提供的 OIDC Token,自动完成 RAM 角色的扮演,实现对阿里云资源的无凭证化访问。
第一步:基础设施定义 (Infrastructure as Code)
在真实项目中,手动在控制台创建资源是不可靠且难以追溯的。我们使用 Terraform 来定义所有需要的云资源,确保整个流程的可重复性与自动化。
1. 创建 OIDC 提供商
首先,我们需要在 RAM 中创建一个 OIDC 提供商。对于运行在 ECS 上的应用,我们可以利用阿里云为 ECS 实例内置的 OIDC 能力。每个 ECS 实例都可以通过其元数据服务获取一个代表该实例身份的 OIDC Token。这个内置 OIDC Provider 的 IssuerUrl
是固定的。
# terraform/oidc-provider.tf
# 在 RAM 中创建一个 OIDC 提供商,用于信任来自 ECS 实例元数据服务的 OIDC Token。
# 这是整个信任链的起点。
# IssuerUrl 是阿里云为 ECS 元数据服务提供的固定地址。
# ClientIDs 字段是可选的,这里我们不作限制。
resource "alicloud_ram_oidc_provider" "ecs_oidc_provider" {
provider_name = "ecs-instance-provider"
issuer_url = "https://oidc.alibabacloud.com"
description = "OIDC provider for ECS instances."
issuance_time = "2" # OIDC Token 的最长有效期(小时)
fingerprints = ["902ef2deeb3c5b13ea4c3d5193629309935284f4"] # 这是阿里云 OIDC 服务端证书的指纹,用于安全校验
}
这里的 fingerprints
是一个关键的安全配置,它确保了我们信任的 OIDC Provider 是真正的阿里云官方服务,而非伪造的。这个指纹需要通过 OpenSSL 命令从 oidc.alibabacloud.com
的 TLS 证书中获取。
2. 创建 RAM 角色并建立信任策略
接下来,创建应用在容器中需要扮演的 RAM 角色。这个角色的核心是其信任策略(Trust Policy),它声明了“谁”可以扮演这个角色。在这里,“谁”就是通过了上一步创建的 OIDC 提供商认证的实体。
# terraform/ram-role.tf
# 创建一个 RAM 角色,该角色将由容器内的应用程序扮演。
resource "alicloud_ram_role" "app_oss_reader_role" {
name = "AppOSSReaderRole"
document = data.alicloud_ram_policy_document.oidc_trust_policy.document
description = "Role for application container to read OSS buckets, assumed via OIDC."
force = true # 如果角色已存在,则强制更新
}
# 定义角色的信任策略。这是安全模型的核心。
data "alicloud_ram_policy_document" "oidc_trust_policy" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithOIDC"] # 明确允许的操作
principals {
type = "Federated"
# 指定信任的 OIDC 提供商的 ARN
identifiers = [alicloud_ram_oidc_provider.ecs_oidc_provider.arn]
}
# Condition 块是实现最小权限的关键。
# 它通过校验 OIDC Token (JWT) 中的声明 (claims) 来施加额外的限制。
condition {
operator = "StringEquals"
variable = "oidc:sub" # 'sub' (subject) claim in JWT
# 要求 JWT 的 subject 必须是指定的 ECS 实例 ID。
# 在生产中,可以将其替换为变量,例如 `acs:ecs:cn-hangzhou:${data.alicloud_caller_identity.current.account_id}:instance/i-bp1xxxxxxxxxxxx`
# 这确保了只有运行在特定 ECS 实例上的容器才能扮演此角色。
values = ["acs:ecs:cn-hangzhou:1234567890123456:instance/i-xxxxxxxxx"]
}
condition {
operator = "StringEquals"
variable = "oidc:aud" # 'aud' (audience) claim in JWT
# 要求 JWT 的 audience 必须是 "sts.aliyuncs.com"
values = ["sts.aliyuncs.com"]
}
}
}
# 为角色授予具体的权限,例如只读访问 OSS。
# 遵循最小权限原则,只授予应用真正需要的权限。
resource "alicloud_ram_policy" "oss_readonly_policy" {
name = "PolicyForAppOSSReader"
document = <<EOF
{
"Version": "1.0",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:ListBuckets",
"oss:GetBucketInfo"
],
"Resource": "*"
}
]
}
EOF
}
# 将权限策略附加到 RAM 角色上。
resource "alicloud_ram_role_policy_attachment" "attach_oss_policy" {
role_name = alicloud_ram_role.app_oss_reader_role.name
policy_name = alicloud_ram_policy.oss_readonly_policy.name
policy_type = "Custom"
}
这里的 Condition
块至关重要。它将信任关系从宽泛的“任何来自该 OIDC Provider 的 Token”收紧到“只有来自特定 ECS 实例的 Token”。oidc:sub
声明的值是 ECS 实例的 ARN,这是由阿里云元数据服务颁发的 OIDC Token 中固定包含的,无法被伪造。这建立了一个强绑定:角色只能被运行在指定实例上的容器扮演。
第二步:实现容器内的凭证获取逻辑
现在,基础设施已经就绪。我们需要在应用程序代码中实现获取 OIDC Token 并用它来交换 STS 临时凭证的逻辑。我们使用 Go 语言作为示例。
// main.go
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/aliyun/alibaba-cloud-sdk-go/services/oss"
"github.com/aliyun/alibaba-cloud-sdk-go/services/sts"
)
const (
// 阿里云 ECS 元数据服务获取 OIDC Token 的 Endpoint
oidcTokenURITemplate = "http://100.100.100.200/latest/oidc-token?audience=%s"
// STS API 的 Audience 固定为此值
stsAudience = "sts.aliyuncs.com"
)
// OIDCProviderToken 代表从 ECS 元数据服务获取的 Token
type OIDCProviderToken struct {
Token string `json:"token"`
}
// fetchOIDCToken 从 ECS 元数据服务获取 OIDC Token
// 这是一个高度可靠的服务,但在生产代码中仍需考虑重试和超时。
func fetchOIDCToken(ctx context.Context, audience string) (string, error) {
client := &http.Client{Timeout: 5 * time.Second}
url := fmt.Sprintf(oidcTokenURITemplate, audience)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create http request: %w", err)
}
// 必须添加此 Header
req.Header.Set("Metadata-Flavor", "v1")
log.Println("Fetching OIDC token from ECS metadata service...")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch OIDC token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to fetch OIDC token, status: %s, body: %s", resp.Status, string(body))
}
var token OIDCProviderToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return "", fmt.Errorf("failed to decode OIDC token response: %w", err)
}
if token.Token == "" {
return "", fmt.Errorf("received empty OIDC token")
}
log.Println("Successfully fetched OIDC token.")
return token.Token, nil
}
// getTemporaryCredentials 使用 OIDC Token 扮演 RAM 角色,获取临时凭证
func getTemporaryCredentials(ctx context.Context, oidcToken, roleArn, oidcProviderArn string) (*sts.Credentials, error) {
// 注意:创建 sts.Client 时,不需要传入任何 AK/SK。
// SDK 知道对于 AssumeRoleWithOIDC 操作,认证是通过 OIDC Token 完成的。
client, err := sts.NewClientWithAccessKey("cn-hangzhou", "", "")
if err != nil {
return nil, fmt.Errorf("failed to create STS client: %w", err)
}
request := sts.CreateAssumeRoleWithOIDCRequest()
request.Scheme = "https"
request.RoleArn = roleArn
request.OIDCProviderArn = oidcProviderArn
request.OIDCToken = oidcToken
request.RoleSessionName = "ecs-container-session" // 自定义会话名称,会出现在操作审计日志中
request.DurationSeconds = "3600" // 凭证有效期,单位秒
log.Printf("Assuming role %s with OIDC token...", roleArn)
response, err := client.AssumeRoleWithOIDC(request)
if err != nil {
return nil, fmt.Errorf("failed to assume role with OIDC: %w", err)
}
log.Println("Successfully assumed role and got temporary credentials.")
return &response.Credentials, nil
}
// listOSSBuckets 使用临时凭证访问 OSS 服务
func listOSSBuckets(ctx context.Context, creds *sts.Credentials) error {
ossClient, err := oss.New("cn-hangzhou", creds.AccessKeyId, creds.AccessKeySecret, oss.SecurityToken(creds.SecurityToken))
if err != nil {
return fmt.Errorf("failed to create OSS client: %w", err)
}
log.Println("Listing OSS buckets with temporary credentials...")
lsRes, err := ossClient.ListBuckets()
if err != nil {
return fmt.Errorf("failed to list OSS buckets: %w", err)
}
fmt.Println("----------------------------------------")
fmt.Println("Successfully listed OSS Buckets:")
for _, bucket := range lsRes.Buckets {
fmt.Printf(" - %s\n", bucket.Name)
}
fmt.Println("----------------------------------------")
return nil
}
func main() {
roleArn := os.Getenv("ALIBABA_CLOUD_ROLE_ARN")
oidcProviderArn := os.Getenv("OIDC_PROVIDER_ARN")
if roleArn == "" || oidcProviderArn == "" {
log.Fatal("Environment variables ALIBABA_CLOUD_ROLE_ARN and OIDC_PROVIDER_ARN must be set.")
}
ctx := context.Background()
// 1. 从 ECS 元数据服务获取 OIDC Token
oidcToken, err := fetchOIDCToken(ctx, stsAudience)
if err != nil {
log.Fatalf("Error fetching OIDC token: %v", err)
}
// 2. 使用 OIDC Token 扮演角色,获取临时凭证
creds, err := getTemporaryCredentials(ctx, oidcToken, roleArn, oidcProviderArn)
if err != nil {
log.Fatalf("Error getting temporary credentials: %v", err)
}
// 3. 使用临时凭证访问云服务
if err := listOSSBuckets(ctx, creds); err != nil {
log.Fatalf("Error using temporary credentials: %v", err)
}
log.Println("Demonstration successful. The container accessed OSS without any pre-configured credentials.")
}
这段代码的单元测试思路:
-
fetchOIDCToken
: 可以使用httptest
包来 mock ECS 元数据服务的 HTTP 接口,测试各种响应情况,如成功返回 Token、返回 404、返回格式错误的 JSON、网络超时等。 -
getTemporaryCredentials
: 这一层对阿里云 SDK 的封装较薄,可以 mock STS 的 API 接口,或者更多地依赖集成测试。 -
listOSSBuckets
: 同样,mock OSS 的 API 接口,验证在给定凭证下是否能正确构造并调用 SDK。
第三步:容器化与部署
最后,我们将应用打包成一个轻量级的 Docker 镜像。使用多阶段构建是一个好习惯,可以显著减小最终镜像的体积并减少安全攻击面。
# Dockerfile
# ---- Builder Stage ----
# 使用官方的 golang 镜像作为构建环境
FROM golang:1.21-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制 Go 模块文件并下载依赖
COPY go.mod go.sum ./
RUN go mod download
# 复制源代码
COPY . .
# 构建静态链接的 Go 应用
# CGO_ENABLED=0 确保了静态链接,使其不依赖于目标镜像中的 C 库
# -ldflags="-s -w" 去除调试信息,减小二进制文件大小
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o main .
# ---- Final Stage ----
# 使用一个极简的基础镜像
FROM alpine:latest
WORKDIR /root/
# 从 builder 阶段复制编译好的二进制文件
COPY /app/main .
# 容器启动时执行的命令
CMD ["./main"]
构建和运行容器:
# 构建镜像
docker build -t oidc-credential-demo .
# 部署到配置了 RAM 角色信任的 ECS 实例上运行
# 假设 Terraform apply 后,我们获取到了 Role ARN 和 OIDC Provider ARN
export ROLE_ARN="acs:ram::1234567890123456:role/appossreaderrole"
export OIDC_PROVIDER_ARN="acs:ram::1234567890123456:oidc-provider/ecs-instance-provider"
# 运行容器,并通过环境变量传入必要的 ARN
docker run --rm \
-e ALIBABA_CLOUD_ROLE_ARN=$ROLE_ARN \
-e OIDC_PROVIDER_ARN=$OIDC_PROVIDER_ARN \
oidc-credential-demo
当容器在符合信任策略中 Condition
限制的 ECS 实例上启动时,你将看到类似以下的日志输出:
2023/10/27 10:45:01 Fetching OIDC token from ECS metadata service...
2023/10/27 10:45:01 Successfully fetched OIDC token.
2023/10/27 10:45:01 Assuming role acs:ram::1234567890123456:role/appossreaderrole with OIDC token...
2023/10/27 10:45:02 Successfully assumed role and got temporary credentials.
2023/10/27 10:45:02 Listing OSS buckets with temporary credentials...
----------------------------------------
Successfully listed OSS Buckets:
- my-production-bucket-1
- my-log-archive-bucket
----------------------------------------
2023/10/27 10:45:02 Demonstration successful. The container accessed OSS without any pre-configured credentials.
架构流程的可视化
整个认证和授权流程可以用 Mermaid 图清晰地表示出来:
sequenceDiagram participant Container as Docker Container (App) participant ECSMetadata as ECS Metadata Service (100.100.100.200) participant STS as Alibaba Cloud STS participant RAM as Alibaba Cloud RAM participant OSS as Alibaba Cloud OSS Container->>ECSMetadata: GET /latest/oidc-token activate ECSMetadata ECSMetadata-->>Container: OIDC Token (JWT) deactivate ECSMetadata Container->>STS: AssumeRoleWithOIDC(RoleARN, OIDCToken) activate STS STS->>RAM: Verify OIDC Token Signature & Claims against OIDC Provider & Role Trust Policy activate RAM RAM-->>STS: Verification OK deactivate RAM STS-->>Container: Temporary Credentials (AK, SK, SecurityToken) deactivate STS Container->>OSS: ListBuckets(using Temporary Credentials) activate OSS OSS-->>Container: Bucket List deactivate OSS
生产环境中的考量与陷阱
凭证缓存与刷新: 在实际应用中,不能每次 API 调用都去请求一次临时凭证。这不仅效率低下,还会触发 STS 的 API 限流。一个健壮的实现应该在内存中缓存获取到的临时凭证,并在其过期前(例如,过期时间剩余 10% 时)自动发起刷新请求。阿里云的官方 SDK 通常内置了对
ECSRamRole
凭证提供者的支持,可以自动处理这个过程,但理解其底层机制依然重要。网络策略: 容器必须能够访问 ECS 元数据服务地址
100.100.100.200
。如果你的 ECS 实例或容器网络配置了严格的网络策略(Security Group Rules 或 Network ACLs),需要确保该地址的访问是开放的。审计与监控: 所有通过
AssumeRoleWithOIDC
的角色扮演事件都会被记录在阿里云操作审计(ActionTrail)中。务必开启并定期审计这些日志,监控任何异常的角色扮演行为。日志中会包含roleSessionName
,这就是为什么我们在代码中设置它的原因,便于追溯。跨环境适用性: 此方案强依赖于阿里云 ECS 的元数据服务。如果你的 Docker 容器运行在自建的 Kubernetes 集群(即使节点是 ECS)、其他云厂商或本地数据中心,这个特定的 OIDC Token 获取方式将不再适用。在这些场景下,需要引入其他机制来为 Pod/容器提供身份,例如使用 SPIFFE/SPIRE 这类通用的工作负载身份框架,或者利用 CI/CD 平台(如 GitLab CI, GitHub Actions)提供的 OIDC 能力来获取 Token。
当前方案的局限性在于其与特定云环境的耦合。虽然它为阿里云 ECS 上的容器化工作负载提供了一个极其安全和便捷的凭证管理模型,但将其推广到混合云或多云架构中则需要引入更上层的身份抽象。未来的演进方向可能是利用服务网格(Service Mesh)的 Sidecar 模式,将 OIDC Token 获取和凭证交换的逻辑从业务应用中剥离出来,由 Sidecar 透明地完成,并注入到应用的环境变量或本地文件中,让业务代码彻底无需关心凭证管理的任何细节。