我们团队在 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 认证流程。
技术选型决策
- 执行环境: Azure Kubernetes Service (AKS)。这是我们现有的生产环境。
- 身份认证: Azure Workload Identity。相较于过时的 AAD Pod Identity,Workload Identity 是官方推荐的未来方向,它与 Kubernetes 的集成更紧密,配置也更标准化。它通过 OIDC 联邦实现,避免了对 AAD Pod Identity 所需的 NMI 和 MIC 组件的依赖。
- 开发语言: Go。选择 Go 是因为它能编译成一个轻量级的静态链接二进制文件,非常适合构建小体积的 Docker 镜像。Go 强大的并发模型和丰富的标准库也使得开发这类工具变得高效。更重要的是,Azure 官方提供了成熟的 Go SDK (
azidentity
),对 Workload Identity 提供了原生支持。 - 扫描引擎: 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: 镜像数据
这个流程的关键在于:
- AKS 集群本身是一个 OIDC Provider,能够签发带有特定
audience
的 Service Account Token (SAT)。 - 我们在 Azure AD 中创建了一个用户分配的托管身份 (User-Assigned Managed Identity),并授予它访问目标 ACR 的
AcrPull
权限。 - 我们建立了一个“联邦”关系,告诉 Azure AD:“请信任来自我 AKS 集群 OIDC Provider、且 Subject (Service Account) 为
scanner-sa
的 SAT,并将其视为我创建的那个托管身份”。 - Pod 内的 Go 应用通过 Azure SDK 读取这个挂载的 SAT,向 Azure AD 请求一个 AAD Token。
- 拿到 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 程序。这个程序需要完成以下任务:
- 从环境变量或命令行参数接收要扫描的 ACR 名称和镜像。
- 使用
azidentity.NewWorkloadIdentityCredential
创建凭据对象。 - 使用该凭据向 ACR 请求一个认证 Token。
- 使用 Docker CLI 的方式,通过
docker login
命令和获取到的 Token 登录到 ACR。 - 调用
trivy
命令扫描指定的镜像。 - 解析 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 /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()
正是依赖这些注入的信息来完成认证流程。
方案的局限性与未来迭代
当前方案实现了一个全自动、无凭据的定时扫描任务,极大地提升了安全性和运维效率。但它并非完美,还存在一些局限和可优化的方向:
- 即时性不足:
CronJob
是定时触发,无法在镜像推送到 ACR 的瞬间就进行扫描。一个更高级的架构是利用 ACR Webhook 和 Azure Event Grid,当新镜像推送时触发一个 Azure Function 或一个 KEDA-scaled Job,实现事件驱动的实时扫描。 - 结果处理单一: 目前扫描结果仅打印到 Pod 日志中。生产级的解决方案需要将结构化的结果推送到一个中心化的平台,例如 Azure Security Center、DefectDojo,或者至少是一个可供查询的数据库或 Blob Storage,以便进行趋势分析和告警。
- 对 Docker Daemon 的依赖: 为了快速实现,我们的工具依赖于容器内的 Docker 客户端。这意味着 Pod 需要以某种方式访问 Docker socket 或者使用 DinD (Docker-in-Docker)。一个更云原生的做法是完全抛弃 Docker CLI,使用
google/go-containerregistry
这样的 Go 库来直接与 ACR API 交互,拉取镜像层并进行分析。这会使工具更安全、更轻量。 - 配置管理: 将扫描目标硬编码在
ConfigMap
中,对于大量服务的场景维护起来很麻烦。可以设计一个自定义资源 (CRD),比如ScanTarget
,然后让我们的扫描控制器 (Operator)去监听这些资源的变化,动态地生成扫描任务。
尽管存在这些可改进之处,但这个基于 Go 和 Workload Identity 的扫描器已经解决了一类核心问题:如何在 Kubernetes 集群内部以最安全、最云原生的方式与云平台上的其他服务进行交互。