利用 etcd 构建从 GitHub Actions 到 Spinnaker 的 Milvus 可验证安全部署管道


在部署像 Milvus 这样处理敏感向量数据的核心服务时,CI/CD 管道的安全性不再是一个可选项,而是必须满足的基线。一个常见的脆弱环节在于构建(CI)与部署(CD)阶段之间的信任传递。构建产物的元数据,例如漏洞扫描结果、代码签名、软件物料清单(SBOM),通常以文件形式存储在制品库中。这种方式缺乏原子性、难以进行严格的访问控制,并且为潜在的篡改留下了攻击面。我们需要一个更坚固的机制,一个能充当“部署前最后一道防线”的、具备强一致性和可审计性的元数据存储。

方案A: 依赖制品库与隐式信任

这是最常见的做法。GitHub Actions 构建 Docker 镜像,运行 Trivy 进行漏洞扫描,并将扫描报告(一个 JSON 或 TXT 文件)与镜像一同推送到 Docker Registry,或者上传到一个 S3 Bucket。Spinnaker 管道在部署阶段被触发,它会拉取这个报告文件,通过一个脚本阶段解析其中的内容,根据预设的阈值(如“不允许存在高危漏洞”)来决定是否继续部署。

优势:

  • 实现简单: 无需引入新的基础设施组件,仅依赖现有的制品库。
  • 工具链成熟: 大多数 CI/CD 工具都原生支持与 Artifactory、S3 等的集成。

劣势:

  • 信任链脆弱: 制品库的访问权限通常较为宽泛。一个拥有制品库写入权限的受损服务或凭证,不仅可以替换镜像,还可以篡改那个作为决策依据的扫描报告。部署系统无法轻易地验证报告的真实性。
  • 缺乏原子性: 镜像与其元数据是分离的。可能存在一个时间窗口,在此期间镜像已更新,但元数据尚未更新,导致部署决策基于过时的信息。
  • 策略僵化: 部署策略(例如,哪些漏洞可以豁免,特定环境的安全基线)硬编码在 Spinnaker 的脚本中,修改和审计都非常不便。每次策略变更都需要修改并重新部署管道定义。
  • 审计困难: 追踪某次部署决策所依据的全套元数据(版本、扫描结果、签名、测试覆盖率)非常繁琐,因为这些信息散落在各处。

方案B: 以 etcd 作为可信元数据与策略中心

这个方案引入了一个专用的、经过安全加固的 etcd 集群,作为 CI 和 CD 之间的可信“交接区”。etcd 基于 Raft 协议,提供强一致性保证,这正是我们构建信任链所需要的。

流程如下:

  1. CI 阶段 (GitHub Actions): 构建镜像后,不仅运行扫描,还会生成一个包含所有关键元数据的、结构化的 JSON 对象。这个对象被称为“部署凭证 (Deployment Attestation)”。它包含镜像摘要、SBOM 哈希、测试通过状态、漏洞扫描摘要等。然后,CI 进程使用一个具备严格限制权限的客户端证书,将这份凭证 原子地 写入 etcd 中一个以应用和版本号命名的特定键下。
  2. CD 阶段 (Spinnaker): 部署管道的核心决策点不再是解析文件,而是执行一个任务,该任务使用一个只读权限的客户端证书去查询 etcd。它获取指定版本的部署凭证,并根据存储在 etcd 中另一个路径下的动态安全策略,对凭证进行校验。只有当所有检查项都满足策略要求时,任务才成功退出,管道继续执行。

优势:

  • 强化的信任模型: etcd 的 TLS 认证和 RBAC 机制可以确保只有 CI 管道有权写入凭证,只有 CD 管道有权读取。凭证的完整性可以通过嵌入签名来进一步保证。由于 etcd 的原子操作特性,不存在数据不一致的中间状态。
  • 动态与集中化的策略管理: 安全策略(例如 critical_vulnerability_count_max: 0)也存储在 etcd 中。安全团队可以独立于 CI/CD 管道,直接更新 etcd 中的策略,这些变更会立即对所有后续的部署生效。
  • 可审计性: etcd 的事务日志和 watch 机制提供了完整的变更历史。每一次凭证的写入和策略的修改都有迹可循,极大地简化了合规审计。
  • 高可用性: 一个标准的 3 节点或 5 节点 etcd 集群本身就是高可用的。

最终选择与理由

对于 Milvus 这种承载核心业务数据的系统,方案 A 的脆弱性是不可接受的。我们选择方案 B。虽然它增加了运维一个 etcd 集群的成本,但换来的是一个可验证、可审计、策略驱动的安全部署流程。这种架构将安全左移的理念从代码扫描延伸到了部署决策本身,将安全从“检查项”转变为部署流程的“强制前置条件”。在真实项目中,这种明确的责任边界和可信的自动化门禁,远比增加的运维复杂度更有价值。

核心实现概览

我们通过一个流程图来描绘整个架构。

graph TD
    subgraph "GitHub Actions (CI)"
        A[Code Push] --> B(Build Milvus Image);
        B --> C{Run Security Scans};
        C --> D(Generate SBOM);
        B --> E(Get Image Digest);
        subgraph "Create Attestation"
            C & D & E --> F[JSON Payload];
        end
        F --> G[Sign Payload];
        G --> H[etcdctl put];
    end

    subgraph "Hardened etcd Cluster"
        I[etcd: /attestations/milvus/v2.3.1]
        J[etcd: /policies/milvus/prod]
    end

    subgraph "Spinnaker (CD)"
        K[Trigger Deployment] --> L(Pre-Deployment Stage);
        subgraph "Verification Job (K8s)"
          L --> M[etcdctl get /attestations/...];
          M --> N{Verify Signature & Policy};
          J --> N;
        end
        N -- Pass --> O(Deploy Milvus to K8s);
        N -- Fail --> P(Fail Pipeline);
    end

    H --> I;

1. etcd 的准备与安全加固

首先,你需要一个 etcd 集群。在生产环境中,它必须启用客户端证书认证。

  • 开启 TLS: 为 etcd 集群生成 CA、服务端证书和客户端证书。
  • 创建角色与用户:
    • ci-writer 角色: 对 /attestations/ 前缀拥有写权限。
    • cd-reader 角色: 对 /attestations//policies/ 前缀拥有读权限。
  • 关联用户与角色:
    • 创建 github-actions-user 并赋予 ci-writer 角色。
    • 创建 spinnaker-user 并赋予 cd-reader 角色。
  • 生成对应的客户端证书: github-actions.crt, github-actions.keyspinnaker.crt, spinnaker.key。这些凭证将作为 secrets 存储在 GitHub Actions 和 Spinnaker 的执行环境中。

一个初始的安全策略可以这样写入 etcd:

# etcdctl v3, with TLS flags
ETCDCTL_API=3 etcdctl \
  --endpoints="https://etcd-node1:2379" \
  --cacert="ca.crt" --cert="admin.crt" --key="admin.key" \
  put /policies/milvus/prod '{
    "schema_version": "1.0",
    "rules": [
      {
        "name": "no_critical_vulnerabilities",
        "expression": "attestation.scan_result.summary.critical == 0"
      },
      {
        "name": "sbom_must_exist",
        "expression": "attestation.sbom.format == \"spdx-json\" && attestation.sbom.digest != \"\""
      },
      {
        "name": "image_source_must_be_trusted",
        "expression": "attestation.image.registry == \"our-internal-registry.io\""
      }
    ]
  }'

2. GitHub Actions 工作流实现

这个工作流负责构建、扫描,并最终将凭证写入 etcd。凭证的私钥(用于签名)和 etcd 的客户端证书都应通过 GitHub Secrets 注入。

.github/workflows/milvus-ci.yml:

name: Build and Attest Milvus Service

on:
  push:
    branches:
      - main
    paths:
      - 'src/milvus/**'

env:
  REGISTRY: our-internal-registry.io
  IMAGE_NAME: milvus-service
  ETCD_ENDPOINTS: https://etcd.prod.internal:2379

jobs:
  build-and-push-attestation:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Log in to the Container registry
        uses: docker/login-action@v2
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=,format=short

      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@v4
        with:
          context: .
          file: ./src/milvus/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Run Trivy vulnerability scanner
        id: trivy-scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ steps.meta.outputs.tags }}
          format: 'json'
          output: 'trivy-results.json'
          ignore-unfixed: true
          severity: 'CRITICAL,HIGH'
          # Continue on error so we can record failure in etcd instead of failing the pipeline
          exit-code: '0'

      - name: Generate SBOM
        id: sbom-gen
        run: |
          # In a real project, use a tool like syft
          # syft packages docker:${{ steps.meta.outputs.tags }} -o spdx-json=sbom.spdx.json
          # For this example, we create a placeholder
          echo '{"spdxVersion": "SPDX-2.2", "name": "milvus-sbom"}' > sbom.spdx.json
          echo "sbom_digest=$(sha256sum sbom.spdx.json | awk '{ print $1 }')" >> $GITHUB_OUTPUT

      - name: Prepare Attestation Payload
        id: prep-attestation
        run: |
          # In a real environment, use a more robust tool like 'jq' to build the JSON
          # This is a simplified example for clarity
          IMAGE_DIGEST=$(echo "${{ steps.build-and-push.outputs.digest }}")
          TRIVY_SUMMARY_CRITICAL=$(jq '.Results[0].Vulnerabilities | map(select(.Severity == "CRITICAL")) | length' trivy-results.json)
          TRIVY_SUMMARY_HIGH=$(jq '.Results[0].Vulnerabilities | map(select(.Severity == "HIGH")) | length' trivy-results.json)
          
          cat <<EOF > attestation.json
          {
            "version": "${{ steps.meta.outputs.version }}",
            "git_commit_sha": "${{ github.sha }}",
            "image": {
              "registry": "${{ env.REGISTRY }}",
              "repository": "${{ env.IMAGE_NAME }}",
              "digest": "$IMAGE_DIGEST"
            },
            "scan_result": {
              "scanner": "trivy",
              "summary": {
                "critical": $TRIVY_SUMMARY_CRITICAL,
                "high": $TRIVY_SUMMARY_HIGH
              }
            },
            "sbom": {
              "format": "spdx-json",
              "digest": "sha256:${{ steps.sbom-gen.outputs.sbom_digest }}"
            },
            "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          }
          EOF

      - name: Sign and Push Attestation to etcd
        env:
          ETCD_CA_CERT_B64: ${{ secrets.ETCD_CA_CERT_B64 }}
          ETCD_CLIENT_CERT_B64: ${{ secrets.ETCD_CLIENT_CERT_B64 }}
          ETCD_CLIENT_KEY_B64: ${{ secrets.ETCD_CLIENT_KEY_B64 }}
        run: |
          # Decode etcd certs from secrets
          echo "$ETCD_CA_CERT_B64" | base64 -d > ca.crt
          echo "$ETCD_CLIENT_CERT_B64" | base64 -d > client.crt
          echo "$ETCD_CLIENT_KEY_B64" | base64 -d > client.key
          
          # Install etcdctl
          ETCD_VER=v3.5.9
          wget "https://github.com/etcd-io/etcd/releases/download/${ETCD_VER}/etcd-${ETCD_VER}-linux-amd64.tar.gz"
          tar xzvf etcd-${ETCD_VER}-linux-amd64.tar.gz
          
          # Key for etcd record
          ETCD_KEY="/attestations/milvus/${{ steps.meta.outputs.version }}"

          # In production, you would sign the attestation.json payload itself
          # using a tool like cosign or a simple gpg signature,
          # and include the signature in the final etcd value.
          # For brevity, we are omitting the signing step here, but it's critical.
          
          echo "Writing attestation to etcd key: $ETCD_KEY"
          ./etcd-${ETCD_VER}-linux-amd64/etcdctl put "$ETCD_KEY" --endpoints=${{ env.ETCD_ENDPOINTS }} \
            --cacert=ca.crt --cert=client.crt --key=client.key < attestation.json

3. Spinnaker 部署管道验证

在 Spinnaker 中,我们创建一个“Run Job (Manifest)”阶段,它会在部署 Milvus 的主 manifest 之前运行。这个 Job 启动一个带有 etcdctl 和证书的 Pod,执行验证脚本。

Kubernetes Job Manifest (verify-attestation-job.yml):

apiVersion: batch/v1
kind: Job
metadata:
  name: verify-milvus-attestation-${some-unique-id}
spec:
  template:
    spec:
      containers:
      - name: verifier
        image: bitnami/etcd:3.5
        command: ["/bin/sh", "-c"]
        args:
        - |
          set -e
          set -x

          # Environment variables like APP_VERSION, TARGET_ENV will be passed by Spinnaker
          ETCD_KEY="/attestations/milvus/${APP_VERSION}"
          POLICY_KEY="/policies/milvus/${TARGET_ENV}"

          echo "Fetching attestation from ${ETCD_KEY}"
          etcdctl get ${ETCD_KEY} --endpoints=${ETCD_ENDPOINTS} \
            --cacert=/etc/etcd-certs/ca.crt --cert=/etc/etcd-certs/client.crt --key=/etc/etcd-certs/client.key \
            -w=json | jq '.kvs[0].value' | base64 -d > received_attestation.json

          if [ ! -s received_attestation.json ]; then
            echo "Error: Attestation not found for version ${APP_VERSION}"
            exit 1
          fi

          echo "Fetching policy from ${POLICY_KEY}"
          etcdctl get ${POLICY_KEY} --endpoints=${ETCD_ENDPOINTS} \
            --cacert=/etc/etcd-certs/ca.crt --cert=/etc/etcd-certs/client.crt --key=/etc/etcd-certs/client.key \
            -w=json | jq '.kvs[0].value' | base64 -d > policy.json
          
          echo "--- Attestation ---"
          cat received_attestation.json
          echo "--- Policy ---"
          cat policy.json

          # A real implementation would use a proper policy engine (e.g., OPA)
          # or a more robust script to evaluate expressions.
          # This is a simplified check for demonstration.
          CRITICAL_VULNS=$(jq '.scan_result.summary.critical' received_attestation.json)
          if [ "${CRITICAL_VULNS}" -ne 0 ]; then
            echo "Verification failed: Found ${CRITICAL_VULNS} critical vulnerabilities."
            exit 1
          fi

          echo "All checks passed. Attestation is valid."
          exit 0
        env:
        - name: ETCD_ENDPOINTS
          value: "https://etcd.prod.internal:2379"
        - name: APP_VERSION
          value: "${trigger['parameters']['version']}" # Spinnaker expression to get version
        - name: TARGET_ENV
          value: "prod"
        volumeMounts:
        - name: etcd-certs
          mountPath: "/etc/etcd-certs"
          readOnly: true
      volumes:
      - name: etcd-certs
        secret:
          secretName: spinnaker-etcd-client-certs
      restartPolicy: Never
  backoffLimit: 0

这个 Job 被配置在 Spinnaker 管道中。如果 Job 成功(exit 0),管道继续执行后续的部署步骤。如果 Job 失败(任何非零退出码),整个管道将立即停止并标记为失败,从而有效阻止了不符合安全策略的部署。

架构的扩展性与局限性

此模式的价值在于其通用性。我们可以轻松扩展 etcd 中的凭证模型,加入单元测试覆盖率、端到端测试结果、性能基线数据,甚至是来自合规扫描工具的报告。策略也可以变得更加复杂,例如,允许特定 CVE 在某个时间窗口内被豁免,或者要求部署到生产环境前必须有两名不同团队的成员进行数字签名(通过更新 etcd 中的一个键来实现审批)。

然而,这个架构也存在自身的边界。系统的安全性现在高度依赖于 etcd 集群自身的安全、访问控制的正确配置以及 CI/CD 环节中客户端证书的保管。一个被攻破的 CI 环境仍然可能写入恶意的、但格式正确的凭证,这就要求我们必须加入签名和验签的环节来确保凭证的不可否认性。此外,该模型主要解决了“部署时”的安全门禁问题,对于运行时安全(Runtime Security)则无能为力。它是一种预防性控制,而非检测性或响应性控制。对于追求极致安全和策略灵活性的复杂组织,可能会考虑引入 Open Policy Agent (OPA) 作为策略评估引擎,将 Spinnaker 中的验证脚本替换为对 OPA 的 API 调用,而 etcd 仅仅作为存储 OPA 所需数据的后端。


  目录