构建基于 CircleCI 与 AWS SNS 的 Flutter Web 应用事件驱动型金丝雀发布管道


将 Flutter Web 应用部署到生产环境,直接覆盖线上版本是一种高风险操作。任何微小的构建错误或运行时异常都可能导致所有用户服务中断。一个标准的金丝öt雀发布流程能有效缓解这个问题,但简单的在CI脚本里加入sleep和手动检查,既不可靠也不具备扩展性。当发布流程需要引入自动化健康检查、团队审批、甚至分阶段流量切换时,一个线性的、紧耦合的CI脚本就成了瓶颈。

我们遇到的挑战是:如何构建一个解耦的、可观测的、事件驱动的发布流程?在这个流程中,CI/CD平台(CircleCI)负责构建与部署执行,而流程的状态推进则由外部事件驱动。这不仅能集成自动化测试,还能轻松地嵌入人工审批环节,同时让整个发布过程对所有干系人透明。

为此,我们设计了一套方案,以 CircleCI 作为工作流编排器,通过自定义 Webpack 配置为金丝雀和生产环境构建不同变体,利用 AWS S3 和 CloudFront 实现流量切分,并引入 AWS SNS 作为核心的事件总线,将各个阶段彻底解耦。

痛点:紧耦合CI/CD的脆弱性

一个典型的 Flutter Web 发布脚本可能如下:

# a_brittle_script.sh

# 1. Build
flutter build web --release

# 2. Deploy to a temporary location
aws s3 sync build/web s3://my-app-canary/

# 3. Pause for manual testing
echo "Canary deployed. Please test and press enter to continue."
read

# 4. Promote to production
aws s3 sync build/web s3://my-app-production/
aws cloudfront create-invalidation --distribution-id YOUR_DIST_ID --paths "/*"

这种方式在真实项目中几乎不可用。它会阻塞CI runner长达数小时,依赖于一个不稳定的SSH会话,并且没有任何审计或通知机制。我们需要的是一个能够响应外部信号的系统。

架构设计:事件驱动的工作流

我们的核心思路是,将发布流程的每一步都视为一个独立的状态。状态之间的转换不通过单个脚本的顺序执行来保证,而是通过发布和订阅消息来触发。

graph TD
    subgraph CircleCI Workflow
        A[build_and_test] --> B(deploy_canary)
        B --> C{publish_sns_notification}
        C --> D((hold_for_approval))
        D --> E[promote_production]
    end

    subgraph AWS
        C -- Publishes Event --> F[SNS Topic: flutter-release-pipeline]
        F -- Notifies --> G[Email: [email protected]]
        F -- Can Trigger --> H[Lambda: Automated Health Check]
    end

    subgraph Manual Action
        I[Developer/QA] -- Receives Email --> J{Manual Testing}
        J -- Looks Good --> K[Approve in CircleCI UI]
    end

    K --> E

    E --> L{publish_final_notification}
    L -- Publishes Event --> F

这个流程的关键在于publish_sns_notification这一步。CircleCI在完成金丝雀部署后,不再是等待,而是向SNS Topic发布一条消息,然后工作流自身进入暂停状态。这条消息可以触发邮件通知、调用Lambda进行自动化检查,或者向Slack发送消息。当外部条件满足(例如,团队成员在测试后手动点击CircleCI的“Approve”按钮),工作流才会继续执行后续的生产环境部署。

关键实现一:利用Webpack为不同环境定制构建产物

Flutter Web 的构建过程底层是由 Webpack 驱动的。虽然Flutter工具链隐藏了这些细节,但我们依然可以介入。在项目中,金丝雀版本和生产版本可能需要连接不同的API后端、使用不同的Feature Flag配置。直接在运行时判断环境是一种方式,但在构建时就将配置固化下来是更稳健的做法。

首先,我们需要一种方式来注入自定义的Webpack配置。一个常见的技巧是使用flutter_js之类的包,或者直接创建一个封装了flutter build命令的脚本。这里我们采用后者,因为它不引入额外依赖。

  1. 在项目根目录创建scripts/build.js
// scripts/build.js
const { execSync } = require('child_process');
const fs = require('fs-extra');
const path = require('path');

const env = process.argv[2]; // 'canary' or 'production'
if (!env) {
    console.error('Error: Build environment not specified. Use "canary" or "production".');
    process.exit(1);
}

// 找到Flutter生成的Webpack配置文件
// Flutter SDK内部路径可能会变,这是在真实项目中需要注意的一个脆弱点
// 这里以一个相对稳定的定位方式为例
const flutterAppPath = path.resolve('.dart_tool', 'flutter_build', 'dart_plugin_registrant.dart');
if (!fs.existsSync(flutterAppPath)) {
    console.log('Running "flutter build web --dry-run" to generate build files...');
    // Dry run to generate necessary files without a full build
    execSync('flutter build web --dry-run', { stdio: 'inherit' });
}

// 这是一个相对稳定的定位方式,但依然是hack
// 目标是找到 'flutter_tools.js' 所在的目录
const flutterToolsPath = require.resolve('flutter_tools');
const webpackConfigPath = path.join(
    path.dirname(flutterToolsPath),
    '..', // navigating up from node_modules/flutter_tools/lib
    'src', 'build_system', 'targets', 'web.dart' // This path is illustrative and might change.
    // In a real project, you might need to search for the webpack config file.
    // A simpler but more brittle approach is to find it in the .dart_tool directory after a build.
);

// 为简单起见,我们直接在构建后修改配置
// 一个更健壮的方案是使用 'webpack-merge'
console.log(`Customizing build for: ${env}`);

// 准备环境特定配置
const envConfig = {
    API_ENDPOINT: env === 'canary' ? 'https://api.canary.example.com' : 'https://api.prod.example.com',
    FEATURE_FLAGS: JSON.stringify({
        newDashboard: env === 'canary'
    })
};

// 注入环境变量,让Flutter/Dart代码可以访问
// 我们通过生成一个配置文件来实现
const configFilePath = path.resolve('lib', 'generated_config.dart');
const configFileContent = `
// DO NOT EDIT. This file is generated by scripts/build.js
class AppConfig {
  static const String apiEndpoint = '${envConfig.API_ENDPOINT}';
  static final Map<String, bool> featureFlags = ${envConfig.FEATURE_FLAGS};
}
`;
fs.writeFileSync(configFilePath, configFileContent, 'utf8');
console.log(`Generated config for ${env} at ${configFilePath}`);


// 执行真正的Flutter构建
console.log('Starting flutter build web...');
try {
    execSync('flutter build web --release --no-pub', { stdio: 'inherit' });
    console.log('Flutter build completed successfully.');
} catch (error) {
    console.error('Flutter build failed.');
    process.exit(1);
} finally {
    // 清理生成的配置文件,避免污染源码管理
    fs.removeSync(configFilePath);
    console.log('Cleaned up generated config file.');
}
  1. package.json中添加脚本:
{
  "scripts": {
    "build:canary": "node scripts/build.js canary",
    "build:prod": "node scripts/build.js production"
  }
}

现在,运行npm run build:canary就会生成一个包含金丝雀配置的构建产物。在Dart代码中,可以直接使用AppConfig.apiEndpoint。这种方式将环境差异隔离在了构建阶段,代码库本身保持环境无关。

关键实现二:AWS 基础设施配置

我们需要的基础设施很简单:

  • 两个 S3 Bucketmy-app-web-prodmy-app-web-canary,都配置为静态网站托管。
  • 一个 CloudFront Distribution
    • 创建两个Origin,分别指向上述两个S3 bucket。
    • 创建一个Origin Group,将两个Origin都包含进去。my-app-web-prod作为主Origin,my-app-web-canary作为次Origin。
    • 在Behavior中,设置Origin为这个Origin Group。
    • 核心:通过修改Origin Group中my-app-web-canary的权重来控制流量。初始时,权重为0,所有流量都到生产环境。金丝雀发布时,我们会将它的权重设为10 (代表10%流量)。
  • 一个 SNS Topicflutter-release-pipeline
  • 一个 IAM User/Role for CircleCI:该角色需要以下权限。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "S3SyncPermissions",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:DeleteObject",
                "s3:GetObjectAcl",
                "s3:PutObjectAcl"
            ],
            "Resource": [
                "arn:aws:s3:::my-app-web-prod",
                "arn:aws:s3:::my-app-web-prod/*",
                "arn:aws:s3:::my-app-web-canary",
                "arn:aws:s3:::my-app-web-canary/*"
            ]
        },
        {
            "Sid": "CloudFrontInvalidationPermission",
            "Effect": "Allow",
            "Action": [
                "cloudfront:CreateInvalidation",
                "cloudfront:GetInvalidation",
                "cloudfront:GetDistributionConfig",
                "cloudfront:UpdateDistribution"
            ],
            "Resource": "arn:aws:cloudfront::YOUR_AWS_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"
        },
        {
            "Sid": "SNSPublishPermission",
            "Effect": "Allow",
            "Action": "sns:Publish",
            "Resource": "arn:aws:sns:YOUR_AWS_REGION:YOUR_AWS_ACCOUNT_ID:flutter-release-pipeline"
        }
    ]
}

这里的坑在于,修改CloudFront distribution的权重需要cloudfront:UpdateDistribution权限,这是一个高权限操作。在真实项目中,应该严格限制该权限,最好是通过一个专门的Lambda函数,由CI角色触发,而不是直接赋予CI角色。

关键实现三:CircleCI 工作流编排 (.circleci/config.yml)

这是整个流程的中枢。我们将使用CircleCI的Workflows来定义阶段和依赖关系。

version: 2.1

orbs:
  aws-cli: circleci/aws-[email protected]
  flutter: circleci/[email protected]

executors:
  node_and_flutter:
    docker:
      - image: cimg/node:18.12.1-browsers
    resource_class: medium

jobs:
  build:
    executor: node_and_flutter
    steps:
      - checkout
      - flutter/install_sdk:
          version: '3.13.0'
      - run:
          name: Install JS Dependencies
          command: npm install
      - run:
          name: Build Canary Artifact
          command: npm run build:canary
      - run:
          name: Rename Canary Artifact
          command: mv build/web build/web-canary
      - run:
          name: Build Production Artifact
          command: npm run build:prod
      # Persist both artifacts to the workspace
      - persist_to_workspace:
          root: build
          paths:
            - web
            - web-canary

  deploy_canary:
    executor: aws-cli/default
    steps:
      - attach_workspace:
          at: ./build
      - aws-cli/setup
      - run:
          name: Deploy to Canary S3 Bucket
          command: |
            aws s3 sync ./build/web-canary s3://${AWS_S3_BUCKET_CANARY} --delete --acl public-read
            echo "Canary deployment successful."
      - run:
          name: Invalidate CloudFront Cache
          command: |
            aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTRIBUTION_ID} --paths "/*"
      - run:
          name: Publish SNS Notification for Canary Deployed
          command: |
            MESSAGE_BODY="Canary deployment for commit ${CIRCLE_SHA1} is ready for review. Build URL: ${CIRCLE_BUILD_URL}"
            aws sns publish \
              --topic-arn ${SNS_TOPIC_ARN} \
              --message "${MESSAGE_BODY}" \
              --subject "Canary Deployed: Ready for Approval"

  promote_to_production:
    executor: aws-cli/default
    steps:
      - attach_workspace:
          at: ./build
      - aws-cli/setup
      - run:
          name: Deploy to Production S3 Bucket
          command: |
            aws s3 sync ./build/web s3://${AWS_S3_BUCKET_PROD} --delete --acl public-read
            echo "Production assets deployment successful."
      - run:
          name: Invalidate CloudFront Cache
          command: |
            aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTRIBUTION_ID} --paths "/*"
      - run:
          name: Publish SNS Notification for Production Deployed
          command: |
            MESSAGE_BODY="Production deployment for commit ${CIRCLE_SHA1} is complete. Build URL: ${CIRCLE_BUILD_URL}"
            aws sns publish \
              --topic-arn ${SNS_TOPIC_ARN} \
              --message "${MESSAGE_BODY}" \
              --subject "Production Deployment Completed"

workflows:
  canary_release_workflow:
    jobs:
      - build:
          filters:
            branches:
              only:
                - main
      - deploy_canary:
          requires:
            - build
      - hold_for_approval:
          type: approval
          requires:
            - deploy_canary
      - promote_to_production:
          requires:
            - hold_for_approval

这份配置定义了一个清晰的流程:

  1. build job: 安装Node.js和Flutter环境,构建金丝雀和生产两个版本的产物,并将它们都存入工作区,以便后续job使用。这是一个常见的优化,避免了重复构建。
  2. deploy_canary job: 从工作区取出金丝雀产物,同步到S3。部署完成后,关键的一步是向SNS Topic发送消息。消息内容包含了Commit SHA和CircleCI构建链接,方便团队定位。
  3. hold_for_approval job: 这是一个CircleCI内置的特殊job类型。它会暂停整个workflow,直到有人在CircleCI的UI界面上点击“Approve”按钮。SNS发出的邮件通知正好引导相关人员到这里来操作。
  4. promote_to_production job: 一旦审批通过,此job会从工作区取出生产产物,部署到生产S3桶,并发送最终的部署成功通知。

当前方案的局限性与未来迭代路径

这套基于SNS事件和手动审批的流程已经比传统的线性脚本健壮得多,但它并非终点。

首先,流量切换的逻辑尚未包含在CI脚本中。promote_to_production job目前只更新了S3 bucket,但并未修改CloudFront的流量权重。完整的实现需要增加一个步骤,使用aws cloudfront update-distribution命令来修改Origin Group的权重,实现从0% -> 10% -> 100%的平滑过渡。这需要对CloudFront的配置JSON进行解析和修改,操作相对复杂且有风险。

其次,审批环节目前是纯手动的。一个更先进的模式是实现自动化审批。我们可以用一个AWS Lambda函数订阅SNS的“Canary Deployed”消息。该Lambda函数可以触发端到端测试(例如使用Playwright或Cypress),并监控接下来15分钟的CloudWatch Metrics(如4xx/5xx错误率)。如果所有指标正常,Lambda可以调用CircleCI的API来自动批准hold_for_approval这个job,实现真正的无人值守发布。

最后,当发布流程变得更复杂,例如包含多个阶段的流量递增、复杂的健康检查和自动回滚逻辑时,SNS作为简单的消息总线可能就不够了。这时,引入AWS Step Functions来对整个发布流程进行状态机建模会是更合适的选择。CircleCI的角色将退化为纯粹的构建与执行单元,而流程编排与决策则完全交给Step Functions处理。


  目录