使用Terraform与Vault为Milvus和PubSub构建动态凭证安全架构


项目初期,我们面临一个棘手的问题:如何在一个高度自动化的CI/CD环境中,为一套实时安全日志分析流管道提供凭证,同时彻底根除长期有效的静态密钥。这套管道的核心组件包括用于事件流的Google Cloud Pub/Sub,以及用于向量化日志分析的Milvus数据库。静态凭证,无论是存储在Kubernetes Secrets中还是注入到环境变量里,都存在泄露风险,且轮换管理复杂,是安全审计的噩梦。

我们的目标是实现一种“零信任”的凭证管理模式:服务在启动时证明自己的身份,然后按需获取仅在短时间内有效的、权限最小化的动态凭证。整个基础设施和安全策略必须通过代码(IaC)进行管理,以保证可重复性和审计能力。这直接将我们的技术栈指向了Terraform和HashiCorp Vault的组合。

架构决策与技术权衡

最初的构想很简单:一个Go语言编写的处理器服务,从Pub/Sub订阅安全日志(如VPC流日志、防火墙日志),利用模型将其转换为向量嵌入,然后存入Milvus进行相似性分析,以发现潜在的攻击模式。挑战在于这个处理器服务如何安全地获取访问Pub/Sub和Milvus的权限。

方案一:使用GCP IAM服务账号密钥。这是最直接的方式,通过Terraform创建服务账号,生成JSON密钥,然后通过某种方式(如Kubernetes Secrets)分发给应用。这种方式的弊端显而易见:密钥是长期的,一旦泄露,影响巨大。轮换这些密钥需要复杂的自动化流程,且难以追踪谁在何时使用了哪个密钥。

方案二:利用Workload Identity。对于GCP内的服务,Workload Identity允许GKE Pod模拟GCP服务账号,从而无需JSON密钥即可访问GCP资源(如Pub/Sub)。这解决了访问Pub/Sub的问题,但Milvus是我们自建在GKE上的,它不直接支持GCP IAM认证。我们仍然需要为Milvus管理一个数据库用户名和密码。

最终我们选择了方案三,一个以Vault为中心的动态凭证架构。

graph TD
    subgraph "Google Cloud Platform (Managed by Terraform)"
        GKE[GKE Cluster]
        PubSub[Pub/Sub Topic/Subscription]
        IAM_SA[IAM Service Account for Processor]
    end

    subgraph "GKE Cluster"
        subgraph "Vault Pod"
            VAULT[Vault Server]
            GCPAuth[GCP Auth Method]
            DBSecrets[Database Secrets Engine for Milvus]
        end
        subgraph "Processor Pod"
            APP[Processor Service]
        end
        subgraph "Milvus Pods"
            MILVUS[Milvus Database]
        end
    end

    TERRAFORM[Terraform] -- Provisions & Configures --> GKE
    TERRAFORM -- Provisions & Configures --> PubSub
    TERRAFORM -- Provisions & Configures --> IAM_SA
    TERRAFORM -- Configures Vault Policies & Roles --> VAULT

    APP -- 1. Authenticates using GCP Service Account --> GCPAuth
    GCPAuth -- 2. Validates against GCP IAM --> IAM_SA
    GCPAuth -- 3. Returns Vault Token --> APP
    APP -- 4. Requests Milvus Creds with Token --> DBSecrets
    DBSecrets -- 5. Creates temporary user in Milvus --> MILVUS
    DBSecrets -- 6. Returns temporary user/pass --> APP
    APP -- 7. Connects to Milvus with temp creds --> MILVUS
    APP -- Uses Workload Identity to access --> PubSub

这个架构的核心优势在于:

  1. 统一凭证管理:无论是云原生服务(Pub/Sub)还是自建服务(Milvus),凭证获取都通过Vault这一统一入口。
  2. 动态与短暂:应用获取的Milvus凭证是动态生成的,具有较短的TTL(Time-To-Live),过期自动失效。这极大地缩小了凭证泄露的风险窗口。
  3. 身份认证:应用通过GCP的IAM身份(Workload Identity)向Vault证明自己是谁,而不是通过一个可被窃取的密钥。
  4. 基础设施即代码:从GCP资源到Vault的安全策略,所有内容都由Terraform管理,实现了端到端的DevSecOps。

Phase 1: 使用Terraform构建基础设施骨架

第一步是用Terraform定义所有必需的GCP资源。在一个真实项目中,我们会将这些代码组织成独立的模块,但为了清晰起见,这里将它们放在一起。

main.tf - GCP 资源定义

# main.tf

provider "google" {
  project = var.gcp_project_id
  region  = var.gcp_region
}

# 为GKE集群创建网络
resource "google_compute_network" "main" {
  name                    = "milvus-sec-net"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "main" {
  name          = "milvus-sec-subnet"
  ip_cidr_range = "10.10.0.0/24"
  network       = google_compute_network.main.id
  region        = var.gcp_region
}

# 创建GKE集群,并启用Workload Identity
resource "google_container_cluster" "primary" {
  name               = "milvus-cluster"
  location           = var.gcp_region
  initial_node_count = 1
  network            = google_compute_network.main.id
  subnetwork         = google_compute_subnetwork.main.id

  # 启用Workload Identity,这是应用向Vault认证身份的关键
  workload_identity_config {
    workload_pool = "${var.gcp_project_id}.svc.id.goog"
  }

  # ... 其他GKE配置
}

# 为日志处理器应用创建专用的GCP服务账号
resource "google_service_account" "processor_sa" {
  account_id   = "log-processor-sa"
  display_name = "Service Account for Log Processor"
}

# 授权GCP服务账号访问Pub/Sub
resource "google_project_iam_member" "pubsub_subscriber" {
  project = var.gcp_project_id
  role    = "roles/pubsub.subscriber"
  member  = "serviceAccount:${google_service_account.processor_sa.email}"
}

# 关键一步:将KSA(Kubernetes Service Account)与GSA(GCP Service Account)绑定
# 这允许名为 `processor-ksa` 的KSA模拟 `processor_sa` GSA的身份
resource "google_service_account_iam_member" "workload_identity_user" {
  service_account_id = google_service_account.processor_sa.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "serviceAccount:${var.gcp_project_id}.svc.id.goog[default/processor-ksa]" # 假设部署在default命名空间
}

# 创建Pub/Sub主题和订阅
resource "google_pubsub_topic" "security_logs" {
  name = "security-logs-topic"
}

resource "google_pubsub_subscription" "processor_sub" {
  name  = "processor-subscription"
  topic = google_pubsub_topic.security_logs.name
  ack_deadline_seconds = 20

  # 确保只有我们的处理器服务账号可以消费消息
  push_config {} # 使用拉取模式
}

resource "google_pubsub_subscription_iam_member" "subscriber" {
  subscription = google_pubsub_subscription.processor_sub.name
  role         = "roles/pubsub.subscriber"
  member       = "serviceAccount:${google_service_account.processor_sa.email}"
}

这段Terraform代码完成了基础工作:创建了网络、一个启用了Workload Identity的GKE集群、一个专用于处理器应用的GCP服务账号(GSA),并授予了它访问Pub/Sub的权限。最核心的配置是google_service_account_iam_member,它建立了Kubernetes命名空间内的服务账号(KSA)与GCP服务账号(GSA)之间的信任关系。

Phase 2: Terraform与Vault的深度集成

现在,我们需要配置Vault。这同样通过Terraform完成,利用Vault Provider。这里的挑战在于,我们需要在Terraform apply 的过程中动态地配置Vault,包括启用GCP认证方法和为Milvus设置动态凭证引擎。

vault.tf - Vault配置即代码

# vault.tf

# 假设Vault已经部署在GKE中,并且我们已经通过某种方式获取了管理员Token和地址
# 在生产环境中,这通常通过安全的CI/CD变量来提供
provider "vault" {
  address = var.vault_addr
  token   = var.vault_token
}

# 1. 启用GCP认证方法
# 这允许实体(如GCE实例或GKE Pod)使用其GCP身份向Vault进行认证
resource "vault_auth_backend" "gcp" {
  type = "gcp"
  path = "gcp"
}

# 2. 创建一个Vault角色,将GCP服务账号与Vault策略关联起来
# 只有绑定到 `log-processor-sa` GSA的实体才能使用此角色登录
resource "vault_gcp_auth_backend_role" "processor_role" {
  backend             = vault_auth_backend.gcp.path
  role_name           = "log-processor"
  type                = "gcp_iam"
  service_account_email = google_service_account.processor_sa.email
  
  # 绑定到这个角色的实体将获得名为 `log-processor-policy` 的Vault策略
  token_policies      = [vault_policy.processor_policy.name]
  token_ttl           = 3600 # Vault令牌的TTL为1小时
}

# 3. 定义Vault策略,授予读取Milvus动态凭证的权限
resource "vault_policy" "processor_policy" {
  name = "log-processor-policy"

  policy = <<EOT
path "database/creds/milvus-dynamic-role" {
  capabilities = ["read"]
}
EOT
}

# 4. 启用Database Secrets Engine
resource "vault_database_secrets_engine" "milvus_db" {
  path = "database"

  # 在生产环境中,这里应该配置一个高可用的数据库连接池
  # 注意:这里的用户名密码是Vault用来管理Milvus用户的,拥有较高权限
  # 它本身也应该通过Vault动态管理,这里为了简化示例而硬编码
  connection_url = "root:Milvus@tcp(milvus-service.default.svc.cluster.local:19530)/"
  allowed_roles  = ["milvus-dynamic-role"]

  # 这里需要Milvus的Vault插件
  plugin_name = "milvus-database-plugin"
}

# 5. 为Milvus创建动态角色
# Vault将使用此定义来动态创建Milvus用户
resource "vault_database_secrets_engine_role" "milvus_role" {
  backend       = vault_database_secrets_engine.milvus_db.path
  name          = "milvus-dynamic-role"
  db_name       = vault_database_secrets_engine.milvus_db.name
  
  # 当应用请求凭证时,Vault执行这些SQL语句来创建用户
  # Milvus的权限管理比较特殊,这里是示意性的创建语句
  creation_statements = [
    "CREATE USER '{{name}}' IDENTIFIED BY '{{password}}';",
    "GRANT ALL ON *.* TO '{{name}}';", // 生产环境中应使用最小权限
  ]

  default_ttl   = "1h"  # 凭证默认有效期1小时
  max_ttl       = "24h" # 最长有效期24小时
}

这里的Terraform代码是整个安全架构的核心。它做了几件关键的事情:

  • 启用并配置GCP认证方法:让Vault信任来自GCP的身份断言。
  • **创建vault_gcp_auth_backend_role**:这是一个关键的绑定。它声明:“任何能够证明自己是GCP服务账号log-processor-sa的实体,在登录Vault后,都将被授予log-processor-policy策略”。
  • **定义vault_policy**:这是一个权限声明,允许持有者从database/creds/milvus-dynamic-role路径读取凭证。
  • 配置Database Secrets Engine:这是Vault的魔力所在。我们告诉Vault如何连接到Milvus,并提供了一个“模板”(vault_database_secrets_engine_role)来创建临时的数据库用户。creation_statements是实际执行的SQL,{{name}}{{password}}是Vault动态生成的占位符。

一个常见的错误是,在creation_statements中赋予了过高的权限。在真实项目中,应该根据应用需求,创建权限严格受限的角色,比如只允许对特定的collection进行读写操作。

Phase 3: 应用侧的凭证获取逻辑

现在基础设施和安全策略都已就绪,最后一步是修改我们的处理器应用,让它能够与Vault交互来获取凭证。我们将使用Go语言和Vault官方的SDK来演示这个流程。

processor/main.go - Go服务获取动态凭证

// main.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"cloud.google.com/go/pubsub"
	vault "github.com/hashicorp/vault/api"
)

const (
	// 这些值应该通过环境变量或配置文件传入
	gcpProjectID          = "your-gcp-project-id"
	pubsubSubscriptionID  = "processor-subscription"
	vaultAddr             = "http://vault.default.svc.cluster.local:8200"
	vaultGCPAuthRole      = "log-processor" // Terraform中定义的Vault角色
	milvusCredsPath       = "database/creds/milvus-dynamic-role" // Terraform中定义的动态角色路径
)

func main() {
	ctx := context.Background()

	// 1. 初始化Vault客户端
	vaultConfig := vault.DefaultConfig()
	vaultConfig.Address = vaultAddr
	client, err := vault.NewClient(vaultConfig)
	if err != nil {
		log.Fatalf("failed to create vault client: %v", err)
	}

	// 2. 使用GCP IAM进行Vault登录,获取Vault Token
	// 这是整个流程最关键的一步。应用无需任何预置密钥,
	// 它会从GKE元数据服务获取一个签名的JWT,代表其KSA身份,
	// Vault的GCP Auth Backend会验证这个JWT的有效性。
	loginData := map[string]interface{}{
		"role": vaultGCPAuthRole,
		"jwt":  getGCPIdentityToken(), // 这是一个模拟函数,实际应从元数据服务获取
	}
	secret, err := client.Logical().Write("auth/gcp/login", loginData)
	if err != nil {
		log.Fatalf("failed to login to vault via gcp auth: %v", err)
	}
	if secret.Auth == nil {
		log.Fatalf("gcp auth failed: no auth info returned")
	}
	client.SetToken(secret.Auth.ClientToken)
	log.Println("Successfully authenticated to Vault")

	// 3. 使用获取到的Vault Token,从Database Secrets Engine请求Milvus的动态凭证
	milvusSecret, err := client.Logical().Read(milvusCredsPath)
	if err != nil {
		log.Fatalf("failed to read milvus credentials from vault: %v", err)
	}
	if milvusSecret == nil || milvusSecret.Data == nil {
		log.Fatalf("no data received for milvus credentials")
	}

	milvusUser, okUser := milvusSecret.Data["username"].(string)
	milvusPassword, okPass := milvusSecret.Data["password"].(string)
	if !okUser || !okPass {
		log.Fatalf("invalid milvus credentials format received from vault")
	}
	log.Printf("Successfully obtained dynamic credentials for Milvus user: %s", milvusUser)

	// 4. 使用动态凭证连接Milvus
	// connectToMilvus(milvusUser, milvusPassword)
	// 在真实应用中,凭证是有租期的(Lease),需要一个后台goroutine在租期快到期时进行续期或重新获取
	go manageCredentialLease(client, milvusSecret)

	// 5. 使用Workload Identity访问Pub/Sub
	// 这里不需要任何显式凭证,GCP Go SDK会自动从环境中发现Workload Identity配置
	pubsubClient, err := pubsub.NewClient(ctx, gcpProjectID)
	if err != nil {
		log.Fatalf("failed to create pubsub client: %v", err)
	}
	defer pubsubClient.Close()
	sub := pubsubClient.Subscription(pubsubSubscriptionID)

	log.Println("Starting to listen for messages on Pub/Sub...")
	// ... 开始处理消息循环 ...
	// cctx, cancel := context.WithCancel(ctx)
	// err = sub.Receive(cctx, func(ctx context.Context, msg *pubsub.Message) {
	//    log.Printf("Got message: %s", msg.Data)
	//    // Process message and store vector in Milvus
	//    msg.Ack()
	// })
	// if err != nil {
	//    log.Fatalf("pubsub receive error: %v", err)
	// }
}

// 模拟从GKE元数据服务获取身份令牌的函数
// 在实际的Pod环境中,这个令牌通常位于一个预定义的文件路径下
func getGCPIdentityToken() string {
	// In a real GKE pod with Workload Identity, you'd read this from a file:
	// token, err := ioutil.ReadFile("/var/run/secrets/google.com/sa/token")
	// For this example, we return a placeholder.
	log.Println("Fetching GCP identity token from metadata service (simulated)...")
	return "placeholder-gcp-signed-jwt"
}

// 管理凭证租约,在到期前续订
func manageCredentialLease(client *vault.Client, secret *vault.Secret) {
    if !secret.Renewable {
        log.Println("Milvus credentials are not renewable.")
        // 如果不可续订,则需要在租期结束前重新请求
        // ttl := time.Duration(secret.LeaseDuration) * time.Second
        // ...
        return
    }

    renewer, err := client.NewRenewer(&vault.RenewerInput{
        Secret: secret,
    })
    if err != nil {
        log.Fatalf("failed to create renewer: %v", err)
    }

    log.Printf("Starting to renew Milvus credentials lease (ID: %s)", secret.LeaseID)
    go renewer.Renew()
    defer renewer.Stop()

    for {
        select {
        case err := <-renewer.DoneCh():
            if err != nil {
                log.Fatalf("failed to renew secret, application must restart or re-authenticate: %v", err)
            }
            log.Println("Lease expired, no more renewals.")
            // 在这里触发应用重连或优雅关闭
            return
        case renewal := <-renewer.RenewCh():
            log.Printf("Successfully renewed Milvus credentials at: %s", renewal.RenewedAt)
        }
    }
}

应用代码清晰地展示了整个流程:

  1. 它首先使用其GCP环境身份(通过getGCPIdentityToken模拟获取)登录Vault。这一步是无密钥的。
  2. 登录成功后,Vault返回一个有时效性的Vault Token。
  3. 应用使用这个Vault Token向Vault请求访问Milvus的凭证。
  4. Vault的Database Secrets Engine在后台实时地在Milvus中创建一个临时用户,并将用户名和密码返回给应用。
  5. 应用使用这些临时凭证连接Milvus。
  6. 一个关键的生产实践是manageCredentialLease。Vault返回的每个动态凭证都有一个租约(Lease)。应用必须在租约到期前向Vault续订(renew),否则凭证会失效,Vault会自动删除Milvus中的临时用户。这是一个强大的安全特性,确保即使应用崩溃,过期的凭证也会被自动清理。

当前方案的局限性与未来展望

这套架构虽然健壮,但并非没有权衡。首先,Vault成为了系统的关键依赖和潜在单点故障。在生产环境中,必须部署一个高可用的Vault集群(通常使用Consul或Etcd作为后端存储),并建立完善的监控和告警。

其次,数据库插件的可用性是一个考量。虽然Vault为PostgreSQL、MySQL等主流数据库提供了内置插件,但对于Milvus这样的新兴数据库,可能需要社区提供或自行开发插件。本文假设milvus-database-plugin存在,在现实中,如果不存在,就需要投入研发资源。

未来的优化路径可以包括:

  1. 策略即代码的深化:使用Terraform Sentinel或Open Policy Agent (OPA)对Vault策略进行更细粒度的控制和测试,例如,限制只有在工作日的特定时间段才能生成凭证。
  2. 证书管理:将动态凭证的思想扩展到TLS证书管理。应用可以通过Vault的PKI Secrets Engine动态获取用于mTLS通信的短时效证书,进一步增强服务间的通信安全。
  3. 可观测性集成:将Vault的审计日志接入集中式日志平台,与应用日志、GCP审计日志进行关联分析,可以构建一个完整的、可追溯的安全事件视图,清晰地看到哪个服务、在何时、出于什么目的请求了哪些资源的访问权限。

  目录