利用GitHub Actions与服务发现构建包含DVC版本化模型的动态预览环境


团队扩张后,最先崩溃的往往不是生产系统,而是开发流程。我们的问题始于一个混合型项目——一个包含传统Web服务和机器学习模型的应用。开发人员每次提交Pull Request,都必须在本地艰难地搭建一个包含特定版本模型和后端服务的环境来进行端到端测试,这个过程既耗时又极易出错。当两个PR依赖于不同版本的模型时,情况就变得一团糟。我们需要一个能在每次PR时自动创建、包含正确数据/模型版本、且完全隔离的预览环境。

最初的构想是在CI/CD流程中动态部署整个应用栈。技术选型并不复杂:GitHub Actions作为流程引擎,Docker负责环境隔离。但魔鬼藏在细节里:如何处理那些动辄几个G的模型文件?如何让E2E测试框架Cypress动态地知道新创建的预览环境的访问地址?以及,如何让团队成员直观地了解所有活跃的预览环境状态?

这就是整个技术方案的起点。我们决定引入DVC (Data Version Control)来解决模型版本化问题,引入基于Consul的服务发现机制来解耦服务间的通信,并开发一个Flutter移动端仪表盘来提供全局视图。

架构概览

整个流程由一个GitHub Actions workflow驱动。当开发者创建一个Pull Request时,该workflow会被触发,执行以下核心步骤:

  1. 代码与模型检出: 拉取PR分支的最新代码,并使用DVC拉取该代码版本所对应的模型文件。
  2. 环境构建与部署: 使用Docker Compose构建应用服务和模型的容器镜像,并将其部署到内部的容器集群中。
  3. 服务注册: 部署完成后,每个服务实例会向Consul服务注册中心注册自己,报告其动态分配的IP和端口。
  4. 端到端测试: Cypress测试任务从Consul查询到Web前端的访问地址,然后执行一系列E2E测试。
  5. 状态反馈: 测试结果和预览环境的访问链接会通过评论回写到Pull Request中。
  6. 环境清理: 当PR被合并或关闭时,另一个workflow会触发,自动清理并销毁对应的预览环境,并从Consul中注销服务。

以下是这个流程的简化表示:

graph TD
    A[Dev Push to PR] --> B{GitHub Actions Trigger};
    B --> C[Checkout Code & DVC Pull Model];
    C --> D[Docker Compose Up];
    D --> E[Services Register to Consul];
    E --> F[Run Cypress E2E Tests];
    F --> G[Query Consul for Service URL];
    E --> H[Flutter Dashboard];
    H --> I[Query Consul for All Environments];
    F --> J[Post Test Results to PR];
    K[PR Merged/Closed] --> L{GitHub Actions Trigger};
    L --> M[Docker Compose Down];
    M --> N[Deregister from Consul];

    subgraph "CI Workflow"
        C
        D
        E
        F
        G
        J
    end

    subgraph "Cleanup Workflow"
        M
        N
    end

    subgraph "Observability"
        H
        I
    end

GitHub Actions: 流程编排的核心

我们的核心逻辑全部定义在.github/workflows/preview-environment.yml中。这个文件负责编排整个动态环境的生命周期。在真实项目中,一个常见的错误是把所有逻辑都堆在一个巨大的run脚本里,这使得调试和维护变得异常困难。我们选择将流程分解为多个独立的、有明确输入输出的job

这是创建预览环境的workflow核心部分:

# .github/workflows/preview-environment.yml
name: Deploy Preview Environment

on:
  pull_request:
    types: [opened, synchronize, reopened]

env:
  # 使用PR编号确保环境的唯一性
  ENV_NAME: pr-${{ github.event.pull_request.number }}
  # Consul地址,通过GitHub Secrets注入
  CONSUL_HTTP_ADDR: ${{ secrets.CONSUL_ADDR }}
  # 用于服务注册的Token
  CONSUL_HTTP_TOKEN: ${{ secrets.CONSUL_TOKEN }}

jobs:
  setup-and-deploy:
    runs-on: self-hosted # 我们的部署目标是内网,因此使用自托管的runner
    environment: preview
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Setup DVC
        uses: iterative/setup-dvc@v1

      # 这里的DVC_AUTH_... 应当配置为你的DVC远端存储的访问凭证
      # 这是关键一步,确保拉取的是与当前代码commit绑定的模型版本
      - name: Pull DVC tracked models
        env:
          DVC_AUTH_USER: ${{ secrets.DVC_USER }}
          DVC_AUTH_PASSWORD: ${{ secrets.DVC_PASSWORD }}
        run: dvc pull -v

      # 使用PR编号作为项目名,确保docker-compose隔离
      - name: Build and Deploy Services
        run: |
          docker-compose -p ${{ env.ENV_NAME }} up -d --build
          echo "Deployment initiated for ${{ env.ENV_NAME }}"

      # 等待服务启动并注册
      - name: Wait for service registration
        run: |
          echo "Waiting for web-service to be available in Consul..."
          timeout=180
          interval=5
          elapsed=0
          while [ $elapsed -lt $timeout ]; do
            # 使用jq解析Consul API的返回
            SERVICE_URL=$(curl -s -H "X-Consul-Token: $CONSUL_HTTP_TOKEN" "$CONSUL_HTTP_ADDR/v1/catalog/service/${{ env.ENV_NAME }}-web-service" | jq -r '.[0].ServiceAddress + ":" + (.[0].ServicePort|tostring)')
            if [ "$SERVICE_URL" != "null:null" ]; then
              echo "WEB_SERVICE_URL=$SERVICE_URL" >> $GITHUB_ENV
              echo "Service found: $SERVICE_URL"
              exit 0
            fi
            sleep $interval
            elapsed=$((elapsed + interval))
          done
          echo "::error::Service registration timed out after $timeout seconds."
          exit 1

  e2e-testing:
    needs: setup-and-deploy
    runs-on: ubuntu-latest # 测试可以在GitHub托管的runner上运行
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install Cypress
        run: npm install cypress

      - name: Run Cypress Tests
        # 将动态获取的服务URL作为baseUrl传递给Cypress
        env:
          # 从上一个job继承环境变量
          CYPRESS_BASE_URL: http://${{ needs.setup-and-deploy.outputs.service_url }}
        run: npx cypress run --spec "cypress/e2e/smoke-test.cy.js"

这里的关键点在于Wait for service registration这一步。我们没有使用sleep硬编码等待时间,而是主动轮询Consul的API。这是一种更健壮的方式,当服务成功注册后,测试流程会立即开始,提高了流水线的执行效率。如果超时,工作流会失败并给出明确的错误信息。

服务发现: 解耦动态环境的命脉

在动态环境中,服务的IP和端口是不可预测的。硬编码或通过环境变量传递是一种脆弱的模式。服务发现是解决此问题的正确方法。我们选择了Consul

每个服务在启动后,需要执行一个注册脚本。这里以web-service为例,它的Dockerfile会包含一个entrypoint.sh脚本。

# Dockerfile for web-service
FROM python:3.9-slim

WORKDIR /app
COPY . .
RUN pip install -r requirements.txt

# 这个脚本负责启动应用和注册服务
ENTRYPOINT ["/app/entrypoint.sh"]

entrypoint.sh脚本的核心逻辑是启动应用进程,然后向Consul注册自己。

#!/bin/sh
set -e

# 从环境变量中获取服务名和Consul地址
# ENV_NAME 是由GitHub Actions传入docker-compose.yml,再传入容器的
SERVICE_NAME="${ENV_NAME}-web-service"
CONSUL_AGENT_URL="${CONSUL_HTTP_ADDR}"
CONSUL_AGENT_TOKEN="${CONSUL_HTTP_TOKEN}"

# 获取容器的IP地址,这在不同网络模式下可能需要调整
CONTAINER_IP=$(hostname -i)
# 假设服务运行在8000端口
SERVICE_PORT=8000

# 准备注册的JSON payload
REGISTRATION_PAYLOAD=$(cat <<EOF
{
  "ID": "${SERVICE_NAME}-${HOSTNAME}",
  "Name": "${SERVICE_NAME}",
  "Tags": ["preview", "${ENV_NAME}"],
  "Address": "${CONTAINER_IP}",
  "Port": ${SERVICE_PORT},
  "Check": {
    "HTTP": "http://${CONTAINER_IP}:${SERVICE_PORT}/health",
    "Interval": "10s",
    "Timeout": "2s",
    "DeregisterCriticalServiceAfter": "1m"
  }
}
EOF
)

echo "Registering service with payload: ${REGISTRATION_PAYLOAD}"

# 使用curl调用Consul API进行注册
curl \
    --request PUT \
    --header "X-Consul-Token: ${CONSUL_AGENT_TOKEN}" \
    --data "${REGISTRATION_PAYLOAD}" \
    "${CONSUL_AGENT_URL}/v1/agent/service/register"

# 在后台运行注册逻辑后,在前台启动主应用
# 这样如果应用崩溃,容器也会退出
echo "Starting application..."
exec python app.py

这个脚本做了几件非常重要的事情:

  1. 唯一的服务ID: ID结合了服务名和容器的HOSTNAME,确保了唯一性。
  2. 标签 (Tags): 我们打了preview和环境名(如pr-123)两个标签,这对于后续的查询和管理至关重要。
  3. 健康检查 (Check): 这是生产级服务注册的必备项。Consul会定期请求服务的/health端点。如果检查失败,Consul会自动将这个服务实例标记为不健康,并最终在DeregisterCriticalServiceAfter设定的时间后自动注销,实现了故障自愈。

DVC: 保证模型与代码的一致性

机器学习项目中,一个常见的痛点是无法将模型、数据和代码的版本精确地对应起来。DVC通过生成指向真实数据存储的轻量级元数据文件(.dvc文件),并将其提交到Git中,优雅地解决了这个问题。

我们的项目结构大致如下:

.
├── .dvc/
├── models/
│   ├── model.pkl.dvc  # DVC元数据文件
│   └── .gitignore
├── .dvcignore
├── dvc.yaml
└── dvc.lock

models/model.pkl被添加到.gitignore中,而models/model.pkl.dvc则由Git跟踪。当在GitHub Actions中执行dvc pull时,DVC会读取.dvc文件中的信息,从配置好的远端存储(如S3、GCS或SSH服务器)下载正确的模型文件。这确保了预览环境中的模型版本与该PR中的代码版本是严格匹配的,消除了版本不一致导致的所有潜在问题。

Cypress E2E测试: 消费服务发现的成果

Cypress测试本身是标准的,但关键在于它如何与我们的动态环境集成。在github-actions-preview-env.yml中,我们看到了CYPRESS_BASE_URL这个环境变量。

// cypress/e2e/smoke-test.cy.js

// Cypress会自动使用 CYPRESS_BASE_URL 作为 cy.visit('/') 的前缀
describe('Preview Environment Smoke Test', () => {
  it('should load the homepage and display the correct model version', () => {
    cy.visit('/');
    cy.contains('h1', 'Welcome to Hybrid App').should('be.visible');

    // 假设页面上有一个元素显示当前加载的模型版本号
    // 这个版本号应该由后端服务在加载DVC管理的模型后提供
    cy.get('#model-version').should('contain.text', 'v2.1.0');
  });

  it('should successfully get a prediction', () => {
    cy.visit('/predict');
    cy.get('input[name="feature1"]').type('1.23');
    cy.get('input[name="feature2"]').type('4.56');
    cy.get('button[type="submit"]').click();

    // 等待API调用完成并验证结果
    cy.get('#prediction-result', { timeout: 10000 })
      .should('be.visible')
      .and('not.be.empty');
  });
});

这个测试的价值在于,它不仅仅是测试前端UI,而是对整个技术栈(前端、后端服务、ML模型)的集成验证。由于baseUrl是从Consul动态获取的,这个测试用例可以无缝地在任何一个PR的预览环境中运行,无需任何手动配置。

Flutter仪表盘: 提供全局可观测性

命令行和GitHub PR评论对于单个任务是高效的,但当团队中同时存在十几个活跃的预览环境时,管理者和QA需要一个更直观的视图。为此,我们构建了一个简单的Flutter应用。

这个应用的核心功能是调用Consul的API,获取所有标记为preview的服务,并按环境名(pr-xxx)进行分组展示。

// lib/services/consul_api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;

// 这是一个简化的模型类,仅包含我们需要的信息
class PreviewEnvironment {
  final String name;
  final String webServiceUrl;
  final String modelServiceUrl;
  final String status; // 'passing', 'warning', 'critical'

  PreviewEnvironment({
    required this.name,
    required this.webServiceUrl,
    required this.modelServiceUrl,
    required this.status,
  });
}

class ConsulApiService {
  final String consulUrl; // e.g., "http://consul.my-company.internal:8500"
  final String consulToken;

  ConsulApiService({required this.consulUrl, required this.consulToken});

  Future<List<PreviewEnvironment>> fetchPreviewEnvironments() async {
    final Map<String, PreviewEnvironment> environments = {};

    try {
      // 查询所有健康检查信息,这是获取服务状态的最高效方式
      final response = await http.get(
        Uri.parse('$consulUrl/v1/health/state/any'),
        headers: {'X-Consul-Token': consulToken},
      );

      if (response.statusCode == 200) {
        final List<dynamic> checks = jsonDecode(response.body);

        for (var check in checks) {
          final serviceName = check['ServiceName'] as String;
          final List<dynamic> tags = check['ServiceTags'] as List<dynamic>;

          // 我们只关心包含'preview'标签的服务
          if (tags.contains('preview')) {
            String? prTag = tags.firstWhere((t) => t.startsWith('pr-'), orElse: () => null);
            if (prTag == null) continue;

            final String serviceUrl = "${check['ServiceAddress']}:${check['ServicePort']}";
            final String currentStatus = check['Status'];

            // 按PR标签分组
            environments.update(
              prTag,
              (existing) => PreviewEnvironment(
                name: prTag,
                webServiceUrl: serviceName.contains('web-service') ? serviceUrl : existing.webServiceUrl,
                modelServiceUrl: serviceName.contains('model-service') ? serviceUrl : existing.modelServiceUrl,
                // 如果任何一个服务的状态不是'passing',整个环境的状态就不是'passing'
                status: (existing.status == 'passing' && currentStatus == 'passing') ? 'passing' : 'critical',
              ),
              ifAbsent: () => PreviewEnvironment(
                name: prTag,
                webServiceUrl: serviceName.contains('web-service') ? serviceUrl : 'N/A',
                modelServiceUrl: serviceName.contains('model-service') ? serviceUrl : 'N/A',
                status: currentStatus,
              ),
            );
          }
        }
        return environments.values.toList();
      } else {
        // 在真实应用中需要更完善的错误处理
        throw Exception('Failed to load data from Consul');
      }
    } catch (e) {
      // 日志记录
      print('Error fetching preview environments: $e');
      return [];
    }
  }
}

这段Dart代码展示了如何利用Consul的健康检查API来构建一个聚合视图。UI层(未展示)会消费这个fetchPreviewEnvironments方法返回的列表,渲染出一个包含所有环境及其健康状态的仪表盘,点击每个环境还可以看到详情链接,极大地方便了团队的日常协作。

方案的局限性与未来迭代

这套系统解决了我们最紧迫的问题,但它并非完美。首先,资源成本是一个需要持续关注的问题。大量的预览环境会消耗显著的计算和存储资源,因此一个严格的生命周期管理和自动清理机制是必不可少的。我们正在实现一个基于TTL(Time-To-Live)的策略,自动销毁超过48小时未活动的预览环境。

其次,对于有状态的服务,特别是数据库,这套方案还不够成熟。目前我们为每个环境提供一个空的数据库实例,但这无法满足需要特定种子数据的测试场景。未来的一个迭代方向是集成数据库快照或使用类似PostgreSQL的模板数据库功能来快速克隆一个包含基础数据的数据库。

最后,随着服务数量的增加,docker-compose的管理复杂度会上升。长远来看,将部署目标迁移到Kubernetes,并利用OperatorHelm来管理预览环境的生命周期,会是一个更具扩展性和鲁棒性的选择。服务发现可以自然地过渡到Kubernetes的内置Service机制,而健康检查等概念也能无缝对接。


  目录