将 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
命令的脚本。这里我们采用后者,因为它不引入额外依赖。
- 在项目根目录创建
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.');
}
- 在
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 Bucket:
my-app-web-prod
和my-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 Topic:
flutter-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
这份配置定义了一个清晰的流程:
-
build
job: 安装Node.js和Flutter环境,构建金丝雀和生产两个版本的产物,并将它们都存入工作区,以便后续job使用。这是一个常见的优化,避免了重复构建。 -
deploy_canary
job: 从工作区取出金丝雀产物,同步到S3。部署完成后,关键的一步是向SNS Topic发送消息。消息内容包含了Commit SHA和CircleCI构建链接,方便团队定位。 -
hold_for_approval
job: 这是一个CircleCI内置的特殊job类型。它会暂停整个workflow,直到有人在CircleCI的UI界面上点击“Approve”按钮。SNS发出的邮件通知正好引导相关人员到这里来操作。 -
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处理。