Serverless 编排:函数、打包与触发器

用 Terraform 编排 Serverless 工作负载:函数资源的建模方式、制品打包与版本哈希、层与依赖管理、执行角色与最小权限、事件源映射与触发器、冷启动与并发配置,以及多环境发布策略。

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保留并发 2014 天
prod-prod预留 + 保留30 天

发布路径建议是:先部署到 staging 验证触发器连通性,再用别名做权重灰度,最后切换 live 别名。

一句话:Serverless 的「环境隔离」不只是换个名字,并发与保留期的差异才是真正的隔离。

8. 总结

Serverless 编排的复杂度不在函数本身,而在围绕它的六个边界:

环节要点
建模代码、配置、触发入口三层生命周期分开
打包archive_file 或 CI 产物,必须给 source_code_hash
层按变化频率拆分,注意版本不可变
权限每函数一角色,按 ARN 收敛,别忘日志权限
触发器同步与事件源映射分开,批参数决定重试语义
并发预留保下限,保留限上限,两者方向相反
发布版本 + 别名才具备回滚能力
环境命名、并发、日志保留三者一起差异化

一句话收尾:Serverless 把运维负担从「机器」转移到了「配置」。函数跑在哪里不再重要,重要的是制品如何打包、权限如何收敛、事件如何重试——而这些恰恰全是 Terraform 最擅长表达的部分。下一篇进入托管数据库,讨论有状态服务在声明式编排中的另一套难题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「terraform」更多文章

  1. Helm Provider 与应用发布:值注入与回滚
  2. 模块注册表与分发:版本、文档与测试
  3. DNS 与证书编排:托管区域与自动验证