项目初期,我们面临一个棘手的问题:如何在一个高度自动化的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
这个架构的核心优势在于:
- 统一凭证管理:无论是云原生服务(Pub/Sub)还是自建服务(Milvus),凭证获取都通过Vault这一统一入口。
- 动态与短暂:应用获取的Milvus凭证是动态生成的,具有较短的TTL(Time-To-Live),过期自动失效。这极大地缩小了凭证泄露的风险窗口。
- 身份认证:应用通过GCP的IAM身份(Workload Identity)向Vault证明自己是谁,而不是通过一个可被窃取的密钥。
- 基础设施即代码:从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)
}
}
}
应用代码清晰地展示了整个流程:
- 它首先使用其GCP环境身份(通过
getGCPIdentityToken模拟获取)登录Vault。这一步是无密钥的。 - 登录成功后,Vault返回一个有时效性的Vault Token。
- 应用使用这个Vault Token向Vault请求访问Milvus的凭证。
- Vault的Database Secrets Engine在后台实时地在Milvus中创建一个临时用户,并将用户名和密码返回给应用。
- 应用使用这些临时凭证连接Milvus。
- 一个关键的生产实践是
manageCredentialLease。Vault返回的每个动态凭证都有一个租约(Lease)。应用必须在租约到期前向Vault续订(renew),否则凭证会失效,Vault会自动删除Milvus中的临时用户。这是一个强大的安全特性,确保即使应用崩溃,过期的凭证也会被自动清理。
当前方案的局限性与未来展望
这套架构虽然健壮,但并非没有权衡。首先,Vault成为了系统的关键依赖和潜在单点故障。在生产环境中,必须部署一个高可用的Vault集群(通常使用Consul或Etcd作为后端存储),并建立完善的监控和告警。
其次,数据库插件的可用性是一个考量。虽然Vault为PostgreSQL、MySQL等主流数据库提供了内置插件,但对于Milvus这样的新兴数据库,可能需要社区提供或自行开发插件。本文假设milvus-database-plugin存在,在现实中,如果不存在,就需要投入研发资源。
未来的优化路径可以包括:
- 策略即代码的深化:使用Terraform Sentinel或Open Policy Agent (OPA)对Vault策略进行更细粒度的控制和测试,例如,限制只有在工作日的特定时间段才能生成凭证。
- 证书管理:将动态凭证的思想扩展到TLS证书管理。应用可以通过Vault的PKI Secrets Engine动态获取用于mTLS通信的短时效证书,进一步增强服务间的通信安全。
- 可观测性集成:将Vault的审计日志接入集中式日志平台,与应用日志、GCP审计日志进行关联分析,可以构建一个完整的、可追溯的安全事件视图,清晰地看到哪个服务、在何时、出于什么目的请求了哪些资源的访问权限。