
最近折腾了一次从 GCP(Google Cloud,谷歌云)到 AWS(Amazon Web Services,亚马逊云)的 Serverless(无服务器)迁移,踩了几个默认值不对齐的坑。这篇把 Cloud Run、Cloud Run functions、App Engine、Eventarc、Cloud Tasks、Scheduler 和 Workflows 分别迁到 AWS 后该选什么、哪些行为会变、账单哪里会涨,以及一套可落地的迁移顺序讲清楚。
所谓“Serverless”,就是把你写的代码交给云平台托管。不用选服务器,不用打补丁,也不用让机器一直运行。云平台在有请求或事件时启动代码,没人用了再把它停掉。
在 GCP 上,Serverless 通常包括 Cloud Run、Cloud Run functions 和 App Engine,也包括把它们串起来的 Eventarc、Pub/Sub、Cloud Tasks、Cloud Scheduler 与 Workflows。
团队迁移这些应用,原因往往很直接:公司其他系统都在 AWS,数据已经搬过去了,或者应用需要靠近 AWS 上的其他服务。
Serverless 看着像最容易迁的一类,因为压根没有服务器要复制。但两家云的默认行为并不一样,真正的活儿都藏在这里。Cloud Run 可以让多个请求共享一个应用实例,Lambda 通常一个实例只处理一个请求;Cloud Run 请求最长能跑一小时,Lambda 15 分钟就结束。听起来只是小数点级别的差异,却足以让原本正常跑在 GCP 上的应用直接出问题。
“Serverless 实际上是一种思维模式:做技术决策时,要优先考虑它能否让你更专注于业务真正有差异化的部分。”
— Ben Kehoe,AWS Serverless Hero,演讲 Serverless as a mindset
这也是判断迁移是否成功的一个标准。目标不是把每个 GCP 服务一对一照搬过去,而是迁移完以后少维护一些东西。这篇主要解决这些问题:
下面先列出 GCP Serverless 服务与 AWS 最接近的替代方案,具体差异会在后文逐项展开。
| GCP | AWS 中最接近的方案 | 需要重点留意 |
|---|---|---|
| Cloud Run services | Amazon ECS Express Mode,或 AWS Lambda | Cloud Run 的一个实例可以同时处理多个请求,Lambda 不行 |
| Cloud Run jobs | Fargate 上的 ECS tasks,或 AWS Batch | 任务编号与重试机制不同 |
| Cloud Run functions | AWS Lambda | 15 分钟执行上限,以及新的代码入口 |
| App Engine standard | Lambda,或 Elastic Beanstalk | 旧的内置服务必须替换 |
| App Engine flexible | Elastic Beanstalk,或 Fargate 上的 ECS | app.yaml 中的配置要换成新配置 |
| API Gateway / Cloud Endpoints | Amazon API Gateway | 登录方式和限制不同 |
| Eventarc | Amazon EventBridge | 事件结构不同,读取事件的代码要改 |
| Pub/Sub push | SNS 和 SQS,或 EventBridge | 重试要通过队列配置 |
| Cloud Tasks | Amazon SQS 加 Lambda | SQS 只负责保存任务,必须有消费者读取 |
| Cloud Scheduler | EventBridge Scheduler | 每个调度都要指定自己的时区和目标 |
| Workflows | AWS Step Functions | 工作流需要重写,不能直接转换 |
| Secret Manager | AWS Secrets Manager,或 Parameter Store | 访问权限来自应用的 IAM role |
那 App Runner 呢? 它看起来是 Cloud Run 最明显的对应物,但 AWS 已于 2026 年 4 月 30 日停止向新客户开放 App Runner。对于新应用,AWS 推荐改用 ECS Express Mode。
Cloud Run 把应用跑在容器里。流量上涨时,它会增加实例;流量消失后,它会回收实例。迁到 AWS,主要有两个合理选择:
下面把主要差异放在一起看。Lambda 的数字来自 Lambda quotas page:
| 项目 | Cloud Run | ECS Express Mode | Lambda |
|---|---|---|---|
| 每个实例处理的请求数 | 最多 1,000,常见配置是 80 | 取决于应用自身能处理多少 | 1 |
| 最长请求时间 | 60 分钟 | 由负载均衡器决定 | 15 分钟 |
| 缩容到零 | 支持 | 不支持,至少运行一个实例 | 支持 |
| 保持温热 | 最小实例数 | 最小任务数 | 预置并发 |
| 最大请求体 | 32 MiB | 受负载均衡器限制 | 6 MB |
| 部署内容 | 容器 | 容器 | Zip 文件或容器 |
由这张表会引出两个问题。
第一是数据库连接。在 Cloud Run 上,100 个请求可能只共享两个应用实例和少量数据库连接;到了 Lambda,100 个请求意味着 100 个实例,每个实例都会建立自己的连接,数据库很容易被连接数拖垮。此时可以使用 RDS Proxy 复用连接,或者干脆把应用继续放在 ECS。
第二是执行时长。凡是可能超过 15 分钟的任务,都不能原样放到 Lambda 上。
然后是冷启动。所谓冷启动,就是 Lambda 启动新实例时产生的短暂等待。很多团队对这点有些过度焦虑:
“不会增加用户可感知延迟的冷启动,通常不值得专门操心。”
— Yan Cui,AWS Serverless Hero,Running Serverless at Scale
如果用户确实能感觉到,可以开启 provisioned concurrency,提前保留可用实例;Java、Python 和 .NET 还可以使用 SnapStart。
经验法则:如果 Cloud Run 服务是一个完整 Web 应用,包含框架、数据库或长请求,就把原容器迁到 ECS Express Mode;如果它只是流量波动明显的小型 API 或 webhook,就迁到 Lambda。
还有一种折中方案:Lambda Managed Instances。它让 Lambda 运行在由 AWS 托管的 EC2 机器上,一个实例可以同时处理多个请求,适合稳定且持续繁忙的流量。前提是你的代码能安全地并行处理多个请求。
Cloud Run jobs 会持续运行容器直到任务结束,经常还会同时启动多个 task。迁到 AWS 后,可以把同一个容器作为 Fargate 上的 ECS task 运行,再由 EventBridge Scheduler 或 Step Functions 触发。如果是大规模任务,可以拆分后使用 AWS Batch 的 array jobs。
这里有一个容易漏掉的改动:Cloud Run 通过 CLOUD_RUN_TASK_INDEX 告诉每个任务自己的编号,AWS Batch 对应的环境变量是 AWS_BATCH_JOB_ARRAY_INDEX。
Cloud Run functions,过去叫 Cloud Functions,与 AWS Lambda 的对应关系最直接。两者都是一小段代码,在请求或事件到达时运行。真正需要改的是代码外围:
| 项目 | Cloud Run functions | Lambda |
|---|---|---|
| 入口 | Functions Framework(@functions_framework.http) |
handler(event, context) |
| 访问地址 | 内置 | Function URL 或 API Gateway |
| 事件触发 | Eventarc 或 Pub/Sub | SQS、SNS、EventBridge 等 |
| 内存与 CPU | 分别设置 | 设置内存,CPU 随内存增长 |
| 最长运行时间 | HTTP 请求 60 分钟 | 15 分钟 |
| 本地文件 | 保存在内存中 | /tmp,最大 10 GB |
大部分工作都集中在入口和触发器,业务逻辑本身通常不用大改。建议把业务逻辑单独放在一个文件里,然后分别为两个云平台写一层很薄的入口。这样两边仍能复用同一套逻辑和测试。
App Engine 是 GCP 最早推出的 Serverless 服务。迁移方式取决于你使用的是哪一种运行时:
App Engine 还有几个很容易被忽略的小配置文件:cron.yaml 要转成 EventBridge Scheduler 调度,dispatch.yaml 要转成负载均衡器规则,queue.yaml 则要转成 SQS 队列。
Serverless 应用通常通过事件互相通信,比如“订单已创建”“文件已上传”。也正是这些小差异,最容易制造隐蔽 Bug:
source、detail-type 和 detail。所有读取事件的函数都要做一点调整。所有 handler 都要保证重复执行时安全。 两个云平台都可能重复投递同一条消息。AWS 上,如果一个批次中有消息失败,默认会重试整个批次,除非你明确报告哪些消息失败。如果 handler 会发邮件或给银行卡扣款,应保存业务 ID,确保同一件事不会执行两次。
谁能调用哪个服务,两边遵循的基本思路相同,主要是名称发生了变化:
| GCP | AWS |
|---|---|
| 服务绑定 service account | Lambda function 或 ECS task 绑定 IAM role |
在服务上使用 roles/run.invoker |
Lambda resource policy,或在 function URL 上使用 IAM 登录 |
| 服务之间使用 token 调用 | IAM 签名调用,或 API Gateway 私有 API |
| “Allow unauthenticated” | 公开 function URL 或公开 API |
| Identity-Aware Proxy | 负载均衡器登录加 Amazon Cognito |
| API Gateway 中的 API keys | Amazon API Gateway 中的usage plans 和 API keys |
每个函数或服务都应拥有独立的 IAM role,并且只授予实际需要的权限。即使旧应用共用同一个 service account,也不要在 AWS 上照搬成一个权限过大的万能角色。
两个云平台都会按代码运行时间和请求次数收费,但最终金额取决于具体计费项:
| 项目 | GCP | AWS |
|---|---|---|
| 按请求计费 | Cloud Run:CPU 时间、内存时间、请求数 | Lambda:内存时间与请求数 |
| 常驻容器 | Cloud Run 实例计费 | Fargate:按秒计 CPU 与内存费用,另加负载均衡器 |
| 保持温热 | 最小实例数,空闲价格较低 | 预置并发,开启期间持续收费 |
| API | API Gateway 按调用次数计费 | API Gateway 按调用次数计费;HTTP API 比 REST API 便宜 |
| 事件与队列 | Eventarc、Pub/Sub、Cloud Tasks | EventBridge、SNS、SQS,均按请求计费 |
| 折扣 | 承诺使用折扣 | Compute Savings Plans,覆盖 Lambda 和 Fargate |
最容易让团队意外的有三项:
先让新环境运行几周,再购买 Savings Plan。只有真实使用数据,才适合作为长期承诺的依据。
先看一个 GCP HTTP 函数与 AWS Lambda 的对应写法:
# GCP: Cloud Run function (Python)
import functions_framework @functions_framework.http
def hello(request): name = request.args.get("name", "world") return {"message": f"Hello, {name}"} # AWS: Lambda behind a function URL or HTTP API (Python)
import json def handler(event, context): params = event.get("queryStringParameters") or {} name = params.get("name", "world") return { "statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": json.dumps({"message": f"Hello, {name}"}), }
再来看读取 Pub/Sub 消息的函数,以及 AWS 中读取 SQS 的对应实现:
# GCP: triggered by a Pub/Sub topic through Eventarc
import base64, json
import functions_framework @functions_framework.cloud_event
def on_order(cloud_event): data = base64.b64decode(cloud_event.data["message"]["data"]) process(json.loads(data)) # AWS: triggered by an SQS queue, with partial batch failures
import json def handler(event, context): failed = [] for record in event["Records"]: try: process(json.loads(record["body"])) except Exception: failed.append({"itemIdentifier": record["messageId"]}) return {"batchItemFailures": failed}
最后是一个 Cloud Run service 的 Terraform 定义,以及 AWS Lambda 的对应写法:
# GCP: a Cloud Run service
resource "google_cloud_run_v2_service" "api" { name = "api" location = "us-central1" template { containers { image = "us-docker.pkg.dev/shop-prod/apps/api:1.4.2" resources { limits = { cpu = "1", memory = "512Mi" } } } scaling { min_instance_count = 1 } max_instance_request_concurrency = 80 service_account = google_service_account.api.email }
} # AWS: the same API as a Lambda function from a container image
resource "aws_lambda_function" "api" { function_name = "api" package_type = "Image" image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/api:1.4.2" memory_size = 1024 # CPU grows with memory timeout = 30 role = aws_iam_role.api.arn # was the service account
} resource "aws_lambda_function_url" "api" { function_name = aws_lambda_function.api.function_name authorization_type = "AWS_IAM" # was roles/run.invoker
}
这些代码只展示基本结构,不是可以直接复制的完整配置。真正落地前,仍然要核对当前文档与实际限制。
“失败无可避免,所有东西最终都会失败。”
— Werner Vogels,Amazon CTO,10 Lessons from 10 Years of AWS
迁移后经常出问题的是这些不起眼的细节:
projects/.../secrets/... 这样的 Secret Manager 路径会变成 Secrets Manager 或 Parameter Store 中的名称。代码里所有引用都要全局搜索。