1. Serverless 在 Terraform 中的建模方式
一句话总结: 函数在 Terraform 里只是一个资源,但「代码制品」「执行身份」「触发入口」三者的生命周期各不相同,必须拆开建模才能避免每次改配置都重新部署代码。
Serverless 的吸引力在于「没有服务器」,但代价是配置面反而变宽:一个函数要同时描述运行时、内存、超时、环境变量、IAM 角色、网络位置、日志组、事件源。用控制台点一遍很快,用 Terraform 写清楚才是可复现的。
resource "aws_lambda_function" "api_handler" {
function_name = "${local.prefix}-api-handler"
role = aws_iam_role.lambda_exec.arn
handler = "index.handler"
runtime = "nodejs20.x"
memory_size = 512
timeout = 15
filename = data.archive_file.api_zip.output_path
source_code_hash = data.archive_file.api_zip.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.main.name
LOG_LEVEL = var.log_level
}
}
tags = local.common_tags
}
三个生命周期必须区分开:
| 关注点 | 变化频率 | 变更代价 |
|---|---|---|
| 代码制品 | 每次提交 | 秒级,滚动替换 |
| 配置项(内存、超时、环境变量) | 每次调优 | 秒级,但会重建执行环境 |
| 触发入口(API、队列、规则) | 低频 | 影响调用方,需谨慎 |
一句话:把「函数定义」和「函数代码」看作两个独立可变的层,是 Serverless 编排的第一原则。
2. 打包与制品管理
一句话总结: Terraform 不负责编译,它只消费一个 zip;
archive_file适合纯脚本函数,容器镜像与外部构建产物则应走「构建阶段产出 + 引用」的路线。
最朴素的做法是用 archive_file 就地打包源码目录:
data "archive_file" "api_zip" {
type = "zip"
source_dir = "${path.module}/src/api"
output_path = "${path.module}/.build/api.zip"
}
resource "aws_lambda_function" "api_handler" {
filename = data.archive_file.api_zip.output_path
source_code_hash = data.archive_file.api_zip.output_base64sha256
}
source_code_hash 是关键:Terraform 据此判断代码是否变化。如果省略它,改了代码后 terraform plan 会显示无变更,这是新手最常见的「部署没生效」根因。
当依赖体积超过内联打包的舒适区(例如带原生扩展的 Python),更稳的路径是先由 CI 构建产物,再用对象存储中转:
resource "aws_s3_object" "artifact" {
bucket = aws_s3_bucket.artifacts.id
key = "functions/api/${var.git_sha}.zip"
source = "${path.module}/.build/api.zip"
etag = filemd5("${path.module}/.build/api.zip")
}
resource "aws_lambda_function" "api_handler" {
s3_bucket = aws_s3_object.artifact.bucket
s3_key = aws_s3_object.artifact.key
source_code_hash = filebase64sha256("${path.module}/.build/api.zip")
}
2.1 构建产物不进版本库
.build/、*.zip 必须写进 .gitignore。把二进制制品提交进仓库会让 diff 噪声爆炸,也会让 plan 因为文件哈希变化而每次都显示更新。
| 方式 | 适用场景 | 注意点 |
|---|---|---|
archive_file 内联打包 | 纯脚本、依赖少 | 需显式 source_code_hash |
| CI 构建 + S3 中转 | 含原生依赖、体积大 | 需固定 git_sha 命名 |
| 容器镜像 | 超过 250MB 或需自定义运行时 | 走 ECR,见容器专题 |
一句话:
source_code_hash漏写是 Serverless 编排中最隐蔽的一类「假成功」。
3. 层与依赖管理
一句话总结: 层把「变化频率不同的依赖」从函数代码里剥离出来,让公共库只上传一次;但层是版本化不可变的,引用方式决定了升级成本。
resource "aws_lambda_layer_version" "shared_deps" {
layer_name = "${local.prefix}-shared-deps"
filename = "${path.module}/.build/layer.zip"
source_code_hash = filebase64sha256("${path.module}/.build/layer.zip")
compatible_runtimes = ["nodejs20.x", "nodejs18.x"]
}
resource "aws_lambda_function" "api_handler" {
layers = [aws_lambda_layer_version.shared_deps.arn]
}
关键在于 aws_lambda_layer_version 的资源语义:内容变化会产生新版本而不是覆盖旧版本,旧版本继续存在。因此升级层不是替换而是新增,需要在生命周期上做处理:
resource "aws_lambda_layer_version" "shared_deps" {
# ……
lifecycle {
create_before_destroy = true
}
}
| 策略 | 优点 | 代价 |
|---|---|---|
| 单层承载全部依赖 | 结构简单 | 任一依赖变更即全量重传 |
| 按变化频率分层 | 传输量小 | 层数量上限需关注 |
| 每函数独立打包 | 无耦合 | 重复上传,冷启动略慢 |
一句话:层的价值不在「省空间」,而在「让高频变化的业务代码与低频变化的依赖解耦」。
4. 执行角色与最小权限
一句话总结: 函数的权限由执行角色决定,而角色必须同时包含日志写入权限;把所有策略内联进一个角色会迅速失控,按数据源拆分策略并附加更易审计。
每个函数都需要一个执行角色,至少要有 CloudWatch Logs 的写权限,否则函数能跑但日志为空:
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda_exec" {
name = "${local.prefix}-lambda-exec"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy_attachment" "basic_logs" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
业务权限按「访问哪种数据」拆成独立策略文档,而不是塞进一个巨型 JSON:
data "aws_iam_policy_document" "dynamo_access" {
statement {
sid = "ReadWriteOwnTable"
effect = "Allow"
actions = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
]
resources = [
aws_dynamodb_table.main.arn,
"${aws_dynamodb_table.main.arn}/index/*",
]
}
}
resource "aws_iam_role_policy" "dynamo" {
name = "dynamo-access"
role = aws_iam_role.lambda_exec.id
policy = data.aws_iam_policy_document.dynamo_access.json
}
| 反模式 | 后果 | 替代做法 |
|---|---|---|
| 直接附加 AdministratorAccess | 越权风险无上限 | 按资源 ARN 精确授权 |
| 一个角色给所有函数共用 | 权限交集放大 | 每函数一角色 |
| 忘记日志权限 | 排障时无日志 | 附加基础执行策略 |
资源写 * | 无法审计影响面 | 收敛到具体 ARN |
一句话:函数角色的权限边界,就是整个 Serverless 应用的爆炸半径。
5. 触发器与事件源
一句话总结: 同步触发(API Gateway)与异步事件源映射(SQS、Kinesis、DynamoDB Streams)在 Terraform 中是不同资源,后者的批大小与并发参数直接影响吞吐与失败重试行为。
API Gateway 的集成需要把「函数调用权限」显式授予网关服务:
resource "aws_lambda_permission" "allow_apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api_handler.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}
异步事件源映射则是一个独立资源,参数调优的空间很大:
resource "aws_lambda_event_source_mapping" "orders" {
event_source_arn = aws_sqs_queue.orders.arn
function_name = aws_lambda_function.api_handler.arn
batch_size = 10
maximum_batching_window_in_seconds = 5
function_response_types = ["ReportBatchItemFailures"]
scaling_config {
maximum_concurrency = 20
}
}
ReportBatchItemFailures 配合函数返回的失败列表,可以实现部分批次重试——否则一条坏消息会让整批重放。
| 事件源 | 触发模式 | 关键参数 |
|---|---|---|
| API Gateway | 同步 | 超时上限、集成超时 |
| SQS | 异步轮询 | batch_size、maximum_concurrency |
| Kinesis / Streams | 分片有序 | 分片并发、bisect_batch_on_function_error |
| EventBridge | 定时 / 规则 | schedule_expression |
| S3 通知 | 异步 | 前缀过滤、避免自触发循环 |
5.1 避免自触发循环
若函数写入的桶与触发它的桶是同一个,就会形成无限循环。做法是让函数写入带特定前缀的「输出」路径,而触发器只监听「输入」前缀。
resource "aws_s3_bucket_notification" "uploads" {
bucket = aws_s3_bucket.uploads.id
lambda_function {
lambda_function_arn = aws_lambda_function.api_handler.arn
events = ["s3:ObjectCreated:*"]
filter_prefix = "incoming/"
}
}
一句话:异步事件源的参数不是「性能调优」,而是「正确性开关」——批大小错了,重试语义就错了。
6. 冷启动与并发控制
一句话总结: 冷启动由运行时、包体积与初始化逻辑共同决定;预留并发既能消除冷启动,也能成为保护下游数据库的限流阀,但会持续计费。
resource "aws_lambda_function" "api_handler" {
# ……
memory_size = 1024 # CPU 与内存同比例分配,提升内存通常同时降低冷启动耗时
timeout = 15
}
# 预留并发:为关键函数保留常驻实例
resource "aws_lambda_provisioned_concurrency_config" "api" {
function_name = aws_lambda_function.api_handler.function_name
qualifier = aws_lambda_alias.live.name
provisioned_concurrent_executions = 5
}
并发维度的两类控制方向相反,务必分清:
| 配置 | 作用方向 | 典型用途 |
|---|---|---|
| 预留并发 | 保证下限、隔离其他函数 | 核心 API 抗冷启动 |
| 预留并发 = 0 | 暂停该函数 | 紧急熔断 |
| 保留并发 | 限制上限 | 保护下游数据库 |
resource "aws_lambda_function_event_invoke_config" "api" {
function_name = aws_lambda_function.api_handler.function_name
maximum_retry_attempts = 1
maximum_event_age_in_seconds = 300
}
缩短重试窗口可以避免过期事件在故障恢复后被集中重放。
6.1 版本与别名的发布模型
用别名指向版本,才能让预留并发与灰度发布有落脚点:
resource "aws_lambda_alias" "live" {
name = "live"
function_name = aws_lambda_function.api_handler.function_name
function_version = aws_lambda_function.api_handler.version
}
publish = true 时每次代码变更产生新版本,别名是「稳定引用」,调用方只认别名,回滚就是改别名指向。
一句话:没有版本和别名的 Serverless 部署,等于没有回滚能力。
7. 多环境与发布策略
一句话总结: Serverless 的多环境差异集中在命名、并发与内存上,用变量与
locals收敛前缀,避免把环境判断散落到每个资源里。
locals {
prefix = "${var.project}-${var.environment}"
env_defaults = {
dev = {
memory = 512
reserved_concurrency = -1
log_retention_days = 7
}
prod = {
memory = 1024
reserved_concurrency = 100
log_retention_days = 30
}
}
cfg = local.env_defaults[var.environment]
}
日志保留期是最容易被忽略的成本项:默认「永不过期」,长期运行会持续累积存储费用。
resource "aws_cloudwatch_log_group" "api" {
name = "/aws/lambda/${local.prefix}-api-handler"
retention_in_days = local.cfg.log_retention_days
}
| 环境 | 函数名后缀 | 并发策略 | 日志保留 |
|---|---|---|---|
| dev | -dev | 不限 | 7 天 |
| staging | -stg | 保留并发 20 | 14 天 |
| prod | -prod | 预留 + 保留 | 30 天 |
发布路径建议是:先部署到 staging 验证触发器连通性,再用别名做权重灰度,最后切换 live 别名。
一句话:Serverless 的「环境隔离」不只是换个名字,并发与保留期的差异才是真正的隔离。
8. 总结
Serverless 编排的复杂度不在函数本身,而在围绕它的六个边界:
| 环节 | 要点 |
|---|---|
| 建模 | 代码、配置、触发入口三层生命周期分开 |
| 打包 | archive_file 或 CI 产物,必须给 source_code_hash |
| 层 | 按变化频率拆分,注意版本不可变 |
| 权限 | 每函数一角色,按 ARN 收敛,别忘日志权限 |
| 触发器 | 同步与事件源映射分开,批参数决定重试语义 |
| 并发 | 预留保下限,保留限上限,两者方向相反 |
| 发布 | 版本 + 别名才具备回滚能力 |
| 环境 | 命名、并发、日志保留三者一起差异化 |
一句话收尾:Serverless 把运维负担从「机器」转移到了「配置」。函数跑在哪里不再重要,重要的是制品如何打包、权限如何收敛、事件如何重试——而这些恰恰全是 Terraform 最擅长表达的部分。下一篇进入托管数据库,讨论有状态服务在声明式编排中的另一套难题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。