在 Azure AKS 中利用 Go 和 Workload Identity 构建无凭据的容器依赖扫描任务


我们团队在 Azure AKS 上维护着数十个微服务,一个长期存在的运维痛点是CI/CD流水线和集群内安全工具如何安全地访问私有的 Azure 容器镜像仓库 (ACR)。过去的老办法是使用 kubectl create secret docker-registry,将一个具有 AcrPull 角色的服务主体的密码或管理员凭据硬编码在 Kubernetes Secret 中。这个方案的弊端显而易见:凭据需要定期轮换,手动轮换流程繁琐且容易出错;凭据泄露风险高,一旦 Secret 泄露,攻击者就能访问我们所有的核心镜像。

问题演变成了:如何让运行在 AKS Pod 中的自动化任务(比如一个定时的依赖扫描器)能够以一种原生的、无密码的方式向 ACR 进行身份验证?初步构想是利用云厂商提供的 IAM 能力,让 Pod 本身拥有一个身份。Azure 的 Workload Identity 正是为此设计的,它允许我们将 Kubernetes Service Account (KSA) 与 Azure Active Directory (AAD) 中的托管身份 (Managed Identity) 进行联邦,从而实现 Pod 内的应用程序直接获取 AAD Token,无需任何持久化的 Secret。

我们的目标是构建一个 Go 语言编写的、以 CronJob 形式运行在 AKS 内部的依赖扫描工具。它会自动拉取指定 ACR 中的最新镜像,使用 Trivy 进行漏洞扫描,并将结果输出。整个过程的核心挑战在于实现基于 Workload Identity 的无凭据 ACR 认证流程。

技术选型决策

  1. 执行环境: Azure Kubernetes Service (AKS)。这是我们现有的生产环境。
  2. 身份认证: Azure Workload Identity。相较于过时的 AAD Pod Identity,Workload Identity 是官方推荐的未来方向,它与 Kubernetes 的集成更紧密,配置也更标准化。它通过 OIDC 联邦实现,避免了对 AAD Pod Identity 所需的 NMI 和 MIC 组件的依赖。
  3. 开发语言: Go。选择 Go 是因为它能编译成一个轻量级的静态链接二进制文件,非常适合构建小体积的 Docker 镜像。Go 强大的并发模型和丰富的标准库也使得开发这类工具变得高效。更重要的是,Azure 官方提供了成熟的 Go SDK (azidentity),对 Workload Identity 提供了原生支持。
  4. 扫描引擎: Trivy。这是一个开源、全面的漏洞扫描器。我们不打算重新发明轮子去解析各种包管理器的依赖关系。务实的做法是将 Trivy 的二进制文件打包进我们的 Go 应用容器中,由 Go 程序负责认证、拉取镜像和调用 Trivy 进程,然后解析其输出。这是一种典型的组合模式,Go 负责“胶水”和“控制”逻辑,Trivy 负责核心扫描能力。

架构与认证流程

在深入代码之前,必须先理清 Workload Identity 的工作流。这并非一个简单的客户端-服务器请求,而是一系列精心设计的信任交换。

sequenceDiagram
    participant CronJob as CronJob
    participant Pod as Scanner Pod
    participant Kubelet as Kubelet
    participant OIDCProvider as AKS OIDC Issuer
    participant AzureAD as Azure AD / STS
    participant ACR as Azure Container Registry

    CronJob->>Pod: 创建 Pod 实例
    Pod->>Kubelet: 请求 Service Account Token (有特定 audience)
    Kubelet->>OIDCProvider: 签名并颁发 Projected SAT
    OIDCProvider-->>Pod: Service Account Token (SAT) 挂载到文件系统
    
    Pod->>AzureAD: 发起请求,携带 SAT 和身份信息
    AzureAD->>OIDCProvider: 验证 SAT 签名 (使用 OIDC Discovery URL)
    OIDCProvider-->>AzureAD: 验证通过
    AzureAD->>AzureAD: 检查 Federated Identity Credential
    Note right of AzureAD: 确认 SAT 的 issuer, subject 
与联邦凭据配置匹配 AzureAD-->>Pod: 颁发 AAD Access Token (for ARM) Pod->>ACR: 发起登录请求 (POST /oauth2/exchange)
携带 AAD Access Token ACR-->>Pod: 颁发 ACR Refresh Token Pod->>ACR: 使用 ACR Refresh Token 拉取镜像 ACR-->>Pod: 镜像数据

这个流程的关键在于:

  1. AKS 集群本身是一个 OIDC Provider,能够签发带有特定 audience 的 Service Account Token (SAT)。
  2. 我们在 Azure AD 中创建了一个用户分配的托管身份 (User-Assigned Managed Identity),并授予它访问目标 ACR 的 AcrPull 权限。
  3. 我们建立了一个“联邦”关系,告诉 Azure AD:“请信任来自我 AKS 集群 OIDC Provider、且 Subject (Service Account) 为 scanner-sa 的 SAT,并将其视为我创建的那个托管身份”。
  4. Pod 内的 Go 应用通过 Azure SDK 读取这个挂载的 SAT,向 Azure AD 请求一个 AAD Token。
  5. 拿到 AAD Token 后,再用它去向 ACR “交换” 一个 ACR 专用的 Refresh Token,最终完成 Docker 客户端的认证。

步骤化实现:从 IAM 配置到 Go 代码

1. 基础设施配置 (IaC / Azure CLI)

在真实项目中,这部分会用 Terraform 或 Bicep 完成,但为了清晰展示,这里使用 Azure CLI 命令。

#!/bin/bash

# --- 环境配置 (请替换为你的值) ---
RESOURCE_GROUP="my-secops-rg"
AKS_CLUSTER_NAME="my-aks-cluster"
ACR_NAME="mycorpcontainerregistry"
MANAGED_IDENTITY_NAME="aks-scanner-identity"
SERVICE_ACCOUNT_NAMESPACE="security-tools"
SERVICE_ACCOUNT_NAME="scanner-sa"
LOCATION="eastus"

# --- 准备工作:获取必要信息 ---
# 启用 AKS 的 OIDC Issuer 功能 (如果尚未启用)
az aks update -g ${RESOURCE_GROUP} -n ${AKS_CLUSTER_NAME} --enable-oidc-issuer

# 获取 AKS OIDC Issuer URL
export AKS_OIDC_ISSUER=$(az aks show -n ${AKS_CLUSTER_NAME} -g ${RESOURCE_GROUP} --query "oidcIssuerProfile.issuerUrl" -o tsv)
echo "AKS OIDC Issuer URL: ${AKS_OIDC_ISSUER}"

# 获取 ACR ID
export ACR_ID=$(az acr show --name ${ACR_NAME} --resource-group ${RESOURCE_GROUP} --query "id" --output tsv)

# --- 1. 创建用户分配的托管身份 ---
az identity create --name ${MANAGED_IDENTITY_NAME} --resource-group ${RESOURCE_GROUP} --location ${LOCATION}

export USER_ASSIGNED_CLIENT_ID=$(az identity show --name ${MANAGED_IDENTITY_NAME} --resource-group ${RESOURCE_GROUP} --query "clientId" -o tsv)
export MANAGED_IDENTITY_ID=$(az identity show --name ${MANAGED_IDENTITY_NAME} --resource-group ${RESOURCE_GROUP} --query "id" -o tsv)
echo "Managed Identity Client ID: ${USER_ASSIGNED_CLIENT_ID}"

# --- 2. 为托管身份授予访问 ACR 的权限 ---
# 最小权限原则:这里只给 AcrPull 权限
az role assignment create --assignee ${USER_ASSIGNED_CLIENT_ID} --scope ${ACR_ID} --role "AcrPull"

# --- 3. 创建联邦身份凭证 (核心步骤) ---
# 这步将 KSA 和 Managed Identity 关联起来
az identity federated-credential create \
    --name "aks-scanner-federation" \
    --identity-name ${MANAGED_IDENTITY_NAME} \
    --resource-group ${RESOURCE_GROUP} \
    --issuer "${AKS_OIDC_ISSUER}" \
    --subject "system:serviceaccount:${SERVICE_ACCOUNT_NAMESPACE}:${SERVICE_ACCOUNT_NAME}" \
    --audience "api://AzureADTokenExchange"

echo "联邦身份凭证创建完成."

这段脚本完成了所有云端的配置。最关键的是 az identity federated-credential create 命令,它精确地定义了信任关系:只有来自特定 OIDC Issuer (${AKS_OIDC_ISSUER}), 且命名空间和名称完全匹配的 Service Account (system:serviceaccount:${SERVICE_ACCOUNT_NAMESPACE}:${SERVICE_ACCOUNT_NAME}) 签发的 Token 才会被接受。

2. Go 扫描器核心逻辑

现在我们来编写 Go 程序。这个程序需要完成以下任务:

  1. 从环境变量或命令行参数接收要扫描的 ACR 名称和镜像。
  2. 使用 azidentity.NewWorkloadIdentityCredential 创建凭据对象。
  3. 使用该凭据向 ACR 请求一个认证 Token。
  4. 使用 Docker CLI 的方式,通过 docker login 命令和获取到的 Token 登录到 ACR。
  5. 调用 trivy 命令扫描指定的镜像。
  6. 解析 Trivy 的 JSON 输出,提取关键信息并打印。

main.go:

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"strings"
	"time"

	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)

// Config 存储从环境变量中读取的配置
type Config struct {
	ACRName      string
	ImageToScan  string
	ClientID     string // Managed Identity Client ID
	TenantID     string // AAD Tenant ID
	ReportFormat string
	Severity     string
}

// Vulnerability 定义了我们关心的漏洞信息子集
type Vulnerability struct {
	VulnerabilityID  string `json:"VulnerabilityID"`
	PkgName          string `json:"PkgName"`
	InstalledVersion string `json:"InstalledVersion"`
	FixedVersion     string `json:"FixedVersion"`
	Severity         string `json:"Severity"`
	Title            string `json:"Title"`
}

// TrivyReport 定义了 Trivy JSON 报告的结构,我们只关心 Results
type TrivyReport struct {
	Results []struct {
		Vulnerabilities []Vulnerability `json:"Vulnerabilities"`
	} `json:"Results"`
}

func main() {
	// 使用结构化日志,这在生产环境中至关重要
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := loadConfig()
	if err != nil {
		logger.Error("Failed to load configuration", "error", err)
		os.Exit(1)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	logger.Info("Starting vulnerability scan", "image", cfg.ImageToScan)

	// 1. 使用 Workload Identity 获取 ACR 登录凭证
	acrUsername, acrPassword, err := getACRCredentials(ctx, cfg, logger)
	if err != nil {
		logger.Error("Failed to get ACR credentials", "error", err)
		os.Exit(1)
	}

	// 2. 登录到 ACR
	if err := dockerLogin(ctx, cfg.ACRName, acrUsername, acrPassword, logger); err != nil {
		logger.Error("Failed to login to ACR", "error", err)
		os.Exit(1)
	}
	logger.Info("Successfully logged into ACR", "acr", cfg.ACRName)

	// 3. 执行 Trivy 扫描
	report, err := runTrivyScan(ctx, cfg.ImageToScan, cfg.ReportFormat, cfg.Severity, logger)
	if err != nil {
		logger.Error("Trivy scan failed", "error", err)
		os.Exit(1)
	}

	// 4. 解析并报告结果
	if len(report.Results) > 0 && len(report.Results[0].Vulnerabilities) > 0 {
		logger.Warn("Vulnerabilities found!", "count", len(report.Results[0].Vulnerabilities))
		for _, v := range report.Results[0].Vulnerabilities {
			// 在真实世界中,这里可能会推送到监控系统、安全中心或创建Jira Ticket
			fmt.Printf("ID: %s, Package: %s, Severity: %s, Installed: %s, Fix: %s, Title: %s\n",
				v.VulnerabilityID, v.PkgName, v.Severity, v.InstalledVersion, v.FixedVersion, v.Title)
		}
		// 根据策略决定是否退出非0状态码,以在CI/CD中中断流程
		os.Exit(10) // Custom exit code for vulnerabilities found
	} else {
		logger.Info("No vulnerabilities found meeting the criteria.")
	}
}

// loadConfig 从环境变量加载配置
func loadConfig() (*Config, error) {
	cfg := &Config{
		ACRName:      os.Getenv("ACR_NAME"),
		ImageToScan:  os.Getenv("IMAGE_TO_SCAN"),
		ClientID:     os.Getenv("AZURE_CLIENT_ID"),
		TenantID:     os.Getenv("AZURE_TENANT_ID"),
		ReportFormat: getEnv("REPORT_FORMAT", "json"),
		Severity:     getEnv("SEVERITY", "HIGH,CRITICAL"),
	}
	if cfg.ACRName == "" || cfg.ImageToScan == "" || cfg.ClientID == "" || cfg.TenantID == "" {
		return nil, fmt.Errorf("missing required environment variables: ACR_NAME, IMAGE_TO_SCAN, AZURE_CLIENT_ID, AZURE_TENANT_ID")
	}
	// 完整的镜像地址
	cfg.ImageToScan = fmt.Sprintf("%s.azurecr.io/%s", cfg.ACRName, cfg.ImageToScan)
	return cfg, nil
}

// getACRCredentials 是本应用的核心,演示了如何使用 Workload Identity
func getACRCredentials(ctx context.Context, cfg *Config, logger *slog.Logger) (string, string, error) {
	// 这些环境变量由 Azure Workload Identity Webhook 自动注入到 Pod 中
	// AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE, AZURE_AUTHORITY_HOST
	credOpts := &azidentity.WorkloadIdentityCredentialOptions{
		ClientID: cfg.ClientID,
		TenantID: cfg.TenantID,
	}

	cred, err := azidentity.NewWorkloadIdentityCredential(credOpts)
	if err != nil {
		return "", "", fmt.Errorf("failed to create workload identity credential: %w", err)
	}
	logger.Info("Workload identity credential created successfully")

	// 我们需要一个用于 ACR 的 Token。ACR 的 scope 是 `https://management.azure.com/.default`
	// 然后用这个 token 去交换一个 ACR 的 refresh token
	// 这里的 username 是一个特殊的 GUID,密码就是我们获取的 AAD Token
	token, err := cred.GetToken(ctx, policy.TokenRequestOptions{
		Scopes: []string{"https://management.azure.com/.default"},
	})
	if err != nil {
		return "", "", fmt.Errorf("failed to get AAD token for ACR: %w", err)
	}
	logger.Info("Successfully acquired AAD token")

	// ACR 的 OAuth2 登录协议要求 username 为 '00000000-0000-0000-0000-000000000000'
	// 密码就是我们刚才获取的 AAD Access Token
	return "00000000-0000-0000-0000-000000000000", token.Token, nil
}

func dockerLogin(ctx context.Context, acrName, username, password string, logger *slog.Logger) error {
	serverURL := fmt.Sprintf("%s.azurecr.io", acrName)
	cmd := exec.CommandContext(ctx, "docker", "login", serverURL, "--username", username, "--password-stdin")

	// 将密码通过 stdin 传递,避免在进程列表中暴露
	cmd.Stdin = strings.NewReader(password)

	var stderr bytes.Buffer
	cmd.Stderr = &stderr

	logger.Info("Attempting to log in to Docker registry", "server", serverURL)
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("docker login failed: %w. Stderr: %s", err, stderr.String())
	}
	return nil
}

func runTrivyScan(ctx context.Context, image, format, severity string, logger *slog.Logger) (*TrivyReport, error) {
	// trivy image --format json --severity HIGH,CRITICAL --ignore-unfixed <image>
	args := []string{
		"image",
		"--format", format,
		"--severity", severity,
		"--ignore-unfixed", // 在生产环境中,通常只关心已有修复方案的漏洞
		image,
	}

	cmd := exec.CommandContext(ctx, "trivy", args...)
	
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	logger.Info("Running trivy scan", "args", strings.Join(args, " "))
	if err := cmd.Run(); err != nil {
		// Trivy 在找到漏洞时会返回非零退出码,这不一定是执行错误
		// 我们需要检查 stderr 来判断是否是真正的执行失败
		if strings.Contains(stderr.String(), "vulnerabilities found") {
			// This is expected, not an execution error.
		} else if stderr.String() != "" {
			return nil, fmt.Errorf("trivy execution failed: %w. Stderr: %s", err, stderr.String())
		}
	}

	var report TrivyReport
	if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
		return nil, fmt.Errorf("failed to parse trivy json output: %w", err)
	}
	
	logger.Info("Trivy scan completed")
	return &report, nil
}

func getEnv(key, fallback string) string {
	if value, ok := os.LookupEnv(key); ok {
		return value
	}
	return fallback
}

代码中的一个常见陷阱是错误处理。例如,trivy 在发现漏洞时默认会以非零状态码退出,如果简单地检查 cmd.Run()err,会误判为执行失败。因此,必须检查 stderr 的内容来区分是“发现漏洞”还是“程序执行错误”。

3. 容器化

为了让 Go 程序和 Trivy 在同一个环境中运行,我们使用多阶段 Dockerfile 构建一个包含两者的镜像。

Dockerfile:

# --- Build Stage ---
FROM golang:1.21-alpine AS builder

WORKDIR /app

# 预先下载依赖,利用 Docker 缓存
COPY go.mod go.sum ./
RUN go mod download

COPY main.go ./

# 构建静态链接的二进制文件
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o image-scanner .

# --- Release Stage ---
FROM alpine:3.18

# 安装 Trivy 和 Docker client
# 在生产镜像中包含 Docker client 并不理想,但对于这个工具是必要的
# 更好的长期方案是使用 Go 的容器库(如 go-containerregistry)直接与 registry 交互
# 但对于一个内部工具,使用 docker client 是一个务实且快速的启动方式
RUN apk add --no-cache ca-certificates trivy docker-cli

WORKDIR /app

# 从构建阶段拷贝编译好的 Go 程序
COPY --from=builder /app/image-scanner .

# 设置环境变量,虽然我们会在 Kubernetes manifest 中覆盖它们
ENV AZURE_TENANT_ID="" \
    AZURE_CLIENT_ID="" \
    ACR_NAME="" \
    IMAGE_TO_SCAN="" \
    SEVERITY="HIGH,CRITICAL"

# 设置容器的入口点
ENTRYPOINT ["/app/image-scanner"]

4. Kubernetes 部署

最后,我们将所有组件通过 Kubernetes manifest 部署到 AKS 中。

k8s-manifest.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: security-tools
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: scanner-sa
  namespace: security-tools
  annotations:
    # 这是 Workload Identity 的关键!
    # 它告诉 webhook 注入器这个 Service Account 需要关联哪个 Managed Identity
    azure.workload.identity/client-id: "YOUR_MANAGED_IDENTITY_CLIENT_ID" # <-- 必须替换为之前创建的 Client ID
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: scanner-config
  namespace: security-tools
data:
  # 在这里配置要扫描的镜像
  ACR_NAME: "mycorpcontainerregistry" # <-- 替换
  IMAGE_TO_SCAN: "backend/api-service:latest" # <-- 替换
  SEVERITY: "HIGH,CRITICAL"
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: acr-vulnerability-scanner
  namespace: security-tools
spec:
  # 每天凌晨2点执行
  schedule: "0 2 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: scanner-sa
          # 明确指定该 Pod 使用 Workload Identity
          # 这样 webhook 会注入必要的环境变量和 Projected Volume
          labels:
            azure.workload.identity/use: "true"
          containers:
          - name: scanner
            image: your-repo/image-scanner:v1.0.0 # <-- 替换为你的镜像
            envFrom:
            - configMapRef:
                name: scanner-config
            env:
            # 这些变量由 webhook 自动注入,但最佳实践是显式声明,并从环境中获取
            # Go 代码中 AZURE_CLIENT_ID 和 AZURE_TENANT_ID 变量就是从这里和 webhook 注入中获取的
            - name: AZURE_CLIENT_ID
              valueFrom:
                secretKeyRef:
                  # webhook 会创建一个 secret,但这里我们直接使用 annotation 的值
                  # 这只是为了让Go代码中的os.Getenv("AZURE_CLIENT_ID")能工作
                  name: azure-identity-secret # This secret is created by the webhook
                  key: AZURE_CLIENT_ID
            - name: AZURE_TENANT_ID
              valueFrom:
                secretKeyRef:
                  name: azure-identity-secret
                  key: AZURE_TENANT_ID
            resources:
              requests:
                cpu: "250m"
                memory: "512Mi"
              limits:
                cpu: "1"
                memory: "1Gi"
          restartPolicy: OnFailure
      backoffLimit: 3

在部署这个 CronJob 之前,需要将 YOUR_MANAGED_IDENTITY_CLIENT_ID 和镜像地址替换成真实值。部署后,AKS 的 Workload Identity Webhook 会自动拦截这个 Pod 的创建请求,因为它有 azure.workload.identity/use: "true" 这个标签。Webhook 会向 Pod 中注入 AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE, AZURE_AUTHORITY_HOST 这几个环境变量,并挂载一个包含签名的 Service Account Token 的 Projected Volume。我们的 Go 程序中的 azidentity.NewWorkloadIdentityCredential() 正是依赖这些注入的信息来完成认证流程。

方案的局限性与未来迭代

当前方案实现了一个全自动、无凭据的定时扫描任务,极大地提升了安全性和运维效率。但它并非完美,还存在一些局限和可优化的方向:

  1. 即时性不足: CronJob 是定时触发,无法在镜像推送到 ACR 的瞬间就进行扫描。一个更高级的架构是利用 ACR Webhook 和 Azure Event Grid,当新镜像推送时触发一个 Azure Function 或一个 KEDA-scaled Job,实现事件驱动的实时扫描。
  2. 结果处理单一: 目前扫描结果仅打印到 Pod 日志中。生产级的解决方案需要将结构化的结果推送到一个中心化的平台,例如 Azure Security Center、DefectDojo,或者至少是一个可供查询的数据库或 Blob Storage,以便进行趋势分析和告警。
  3. 对 Docker Daemon 的依赖: 为了快速实现,我们的工具依赖于容器内的 Docker 客户端。这意味着 Pod 需要以某种方式访问 Docker socket 或者使用 DinD (Docker-in-Docker)。一个更云原生的做法是完全抛弃 Docker CLI,使用 google/go-containerregistry 这样的 Go 库来直接与 ACR API 交互,拉取镜像层并进行分析。这会使工具更安全、更轻量。
  4. 配置管理: 将扫描目标硬编码在 ConfigMap 中,对于大量服务的场景维护起来很麻烦。可以设计一个自定义资源 (CRD),比如 ScanTarget,然后让我们的扫描控制器 (Operator)去监听这些资源的变化,动态地生成扫描任务。

尽管存在这些可改进之处,但这个基于 Go 和 Workload Identity 的扫描器已经解决了一类核心问题:如何在 Kubernetes 集群内部以最安全、最云原生的方式与云平台上的其他服务进行交互。


  目录