为机器学习模型提供实时、一致的特征是一项非平凡的基础设施挑战。团队需要一个低延迟的在线 Feature Store 用于模型推理,一个高吞吐的离线 Feature Store 用于模型训练,以及一套可靠的数据回填与同步机制。直接采购商业方案成本高昂,而自建则意味着巨大的运维负担。一个常见的折中方案是利用云厂商的全托管服务构建一个 Serverless Feature Store,它能最大限度地减少运维开销,同时提供良好的弹性。
然而,真正的挑战在于如何以一种可复现、可审计、可演进的方式来定义和管理这套由数十个云资源组成的复杂架构。这正是基础设施即代码(IaC)的用武之地。问题随之而来:在众多 IaC 工具中,我们应该选择哪一个?是选择业界标准的 Terraform,还是选择利用通用编程语言能力的 Pulumi?
本文将围绕构建一个基于 AWS Serverless 服务的生产级 Feature Store 展开,深入对比并完整实现两种 IaC 方案:使用 Terraform (HCL) 和 Pulumi (TypeScript)。我们将探讨的不是哪个工具“更好”,而是在这个特定场景下,它们的架构表达能力、代码组织方式、以及对复杂逻辑处理的优劣权衡。
架构定义:一个 Serverless Feature Store 的核心组件
在深入代码之前,我们必须清晰地定义系统的边界和组件。我们的目标架构如下:
graph TD subgraph "客户端 (SDK/API)" A[Model Inference Service] --> B{API Gateway}; C[Data Science Pipeline] --> B; end subgraph "数据摄入与检索 (AWS Lambda)" B -- POST /features/{entity}/{feature_set} --> D[Ingest Lambda]; B -- GET /features/{entity}/{feature_set} --> E[Retrieval Lambda]; end subgraph "存储层" D -- Write --> F[DynamoDB: Online Store]; D -- Write Parquet --> G[S3 Bucket: Offline Store]; E -- Read --> F; H[Athena / Spark] -- Read for Training --> G; end style F fill:#f9f,stroke:#333,stroke-width:2px style G fill:#ccf,stroke:#333,stroke-width:2px
- API 层: 使用
API Gateway
提供统一的 RESTful 接口,用于特征的写入与读取。 - 计算层: 使用
AWS Lambda
函数处理业务逻辑。一个用于数据摄入(Ingest Lambda
),另一个用于数据检索(Retrieval Lambda
)。 - 在线存储: 使用
DynamoDB
作为低延迟的键值存储,服务于实时推理请求。主键设计为entity_id
(分区键) 和feature_set_name
(排序键)。 - 离线存储: 使用
S3
存储特征数据的历史快照,通常以Parquet
格式组织,便于Athena
或Spark
进行批量查询和模型训练。 - IAM: 精确控制每个组件的权限,遵循最小权限原则。
现在,让我们分别用两种不同的 IaC 工具来实现这套架构。
方案 A: Terraform - 声明式的严谨与生态的成熟
Terraform 使用 HCL (HashiCorp Configuration Language) 进行声明式定义。其核心优势在于其DSL的专注性和巨大的社区模块生态。在真实项目中,我们不会将所有资源都写在一个 main.tf
文件里,而是通过模块化来组织代码。
项目结构
一个合理的 Terraform 项目结构如下:
.
├── modules
│ └── feature_store
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments
│ ├── staging
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ └── production
│ ├── main.tf
│ └── terraform.tfvars
└── main.tf
核心模块实现 (modules/feature_store/main.tf
)
这个模块是 Feature Store 基础设施的核心定义。
# modules/feature_store/main.tf
# -----------------------------------------------------------------------------
# 变量定义 (在 variables.tf 中)
# -----------------------------------------------------------------------------
# variable "project_name" { type = string }
# variable "environment" { type = string }
# variable "dynamodb_read_capacity" { type = number, default = 5 }
# variable "dynamodb_write_capacity" { type = number, default = 5 }
# ... 等等
# -----------------------------------------------------------------------------
# S3 Bucket for Offline Store
# -----------------------------------------------------------------------------
resource "aws_s3_bucket" "offline_store" {
bucket = "${var.project_name}-offline-store-${var.environment}"
tags = {
Name = "${var.project_name}-offline-store-${var.environment}"
Environment = var.environment
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "offline_store_encryption" {
bucket = aws_s3_bucket.offline_store.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# -----------------------------------------------------------------------------
# DynamoDB Table for Online Store
# -----------------------------------------------------------------------------
resource "aws_dynamodb_table" "online_store" {
name = "${var.project_name}-online-store-${var.environment}"
billing_mode = "PROVISIONED"
read_capacity = var.dynamodb_read_capacity
write_capacity = var.dynamodb_write_capacity
hash_key = "entity_id"
range_key = "feature_set"
attribute {
name = "entity_id"
type = "S"
}
attribute {
name = "feature_set"
type = "S"
}
# 在生产环境中,启用PITR是必须的
point_in_time_recovery {
enabled = true
}
tags = {
Name = "${var.project_name}-online-store-${var.environment}"
Environment = var.environment
}
}
# -----------------------------------------------------------------------------
# IAM Roles and Policies
# -----------------------------------------------------------------------------
# Lambda 执行角色
resource "aws_iam_role" "lambda_exec_role" {
name = "${var.project_name}-lambda-exec-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})
}
# 附加到 Lambda 角色的策略,授予对 DynamoDB 和 S3 的访问权限
resource "aws_iam_policy" "lambda_policy" {
name = "${var.project_name}-lambda-policy-${var.environment}"
description = "Policy for Feature Store Lambda functions"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:Query"
]
Resource = aws_dynamodb_table.online_store.arn
},
{
Effect = "Allow"
Action = [
"s3:PutObject"
]
Resource = "${aws_s3_bucket.offline_store.arn}/*"
},
{
# CloudWatch Logs 权限
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_policy_attach" {
role = aws_iam_role.lambda_exec_role.name
policy_arn = aws_iam_policy.lambda_policy.arn
}
# -----------------------------------------------------------------------------
# Lambda Functions
# 注意: 这里的代码源是预先打包好的 zip 文件,这是 Terraform 的标准做法。
# CI/CD 流程需要负责构建和上传这个 zip 包。
# -----------------------------------------------------------------------------
resource "aws_lambda_function" "ingest_lambda" {
function_name = "${var.project_name}-ingest-lambda-${var.environment}"
role = aws_iam_role.lambda_exec_role.arn
handler = "app.ingest_handler"
runtime = "python3.9"
timeout = 30
memory_size = 256
# 假设 CI 已经将代码打包并上传到 S3
s3_bucket = var.lambda_source_bucket
s3_key = var.ingest_lambda_zip_key
environment {
variables = {
DYNAMODB_TABLE = aws_dynamodb_table.online_store.name
S3_BUCKET = aws_s3_bucket.offline_store.name
}
}
}
resource "aws_lambda_function" "retrieval_lambda" {
# ... 与 ingest_lambda 类似,但 handler 和 zip key 不同
function_name = "${var.project_name}-retrieval-lambda-${var.environment}"
role = aws_iam_role.lambda_exec_role.arn
handler = "app.retrieval_handler"
runtime = "python3.9"
# ... 其他配置
}
# -----------------------------------------------------------------------------
# API Gateway
# -----------------------------------------------------------------------------
resource "aws_api_gateway_rest_api" "api" {
name = "${var.project_name}-api-${var.environment}"
description = "API for Feature Store"
}
# ... (此处省略了 API Gateway 资源的详细定义,包括 aws_api_gateway_resource,
# aws_api_gateway_method, aws_api_gateway_integration 等,它们相当冗长)
# -----------------------------------------------------------------------------
# Outputs (在 outputs.tf 中)
# -----------------------------------------------------------------------------
# output "api_endpoint" {
# value = aws_api_gateway_rest_api.api.execution_arn
# }
# ...
Terraform 方案的优劣分析
优点:
- 声明式纯粹性: HCL 强制你只描述“最终状态”,而不是“如何达到”。这使得代码意图清晰,易于审查。
- 生态系统: 拥有最广泛的 Provider 支持和海量的社区模块,几乎任何云服务或SaaS产品都有现成的模块可用。
- 状态管理:
terraform plan
提供了强大的变更预览能力,让你在应用前能精确知道会发生什么,这在生产环境中至关重要。 - 强制解耦: 代码与逻辑的分离(Lambda代码必须预先打包)是一种约束,但也促使团队建立更规范的 CI/CD 流程。
缺点:
- 逻辑表达能力有限: HCL 不是通用编程语言。对于需要循环、条件判断、数据转换等复杂逻辑的场景,实现起来非常笨拙(需要依赖
count
,for_each
,locals
等技巧)。例如,如果要根据一个列表动态创建多个 API Gateway 端点,代码会变得难以阅读。 - 测试困难: 基础设施代码本身的单元测试和集成测试比较困难,通常需要依赖 Terratest 等第三方工具,学习曲线陡峭。
- 代码复用: 虽然有模块化机制,但模块内部的逻辑复用能力远不如函数或类。
- 逻辑表达能力有限: HCL 不是通用编程语言。对于需要循环、条件判断、数据转换等复杂逻辑的场景,实现起来非常笨拙(需要依赖
在真实项目中,Terraform 的严谨性是一把双刃剑。它保证了基础设施的稳定性,但当需要实现更动态或复杂的部署逻辑时,开发体验会变得很差。
方案 B: Pulumi - 通用编程语言的灵活性
Pulumi 允许使用 TypeScript, Python, Go 等通用编程语言来定义基础设施。这彻底改变了游戏规则。我们可以使用熟悉的工具链——IDE、包管理器、测试框架——来管理基础设施。
我们将使用 TypeScript 来实现与 Terraform 完全相同的架构。
项目结构
Pulumi 的项目结构更像一个标准的软件工程项目:
.
├── Pulumi.yaml
├── Pulumi.staging.yaml
├── Pulumi.production.yaml
├── index.ts
├── package.json
└── tsconfig.json
核心代码实现 (index.ts
)
// index.ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
import * as awsx from "@pulumi/awsx"; // 更高阶的封装,简化操作
// -----------------------------------------------------------------------------
// 配置管理
// -----------------------------------------------------------------------------
const config = new pulumi.Config();
const projectName = pulumi.getProject();
const stackName = pulumi.getStack(); // 'staging' or 'production'
// -----------------------------------------------------------------------------
// S3 Bucket for Offline Store
// -----------------------------------------------------------------------------
const offlineStoreBucket = new aws.s3.Bucket(`${projectName}-offline-store-${stackName}`, {
// 使用 pulumi.interpolate 来构建字符串,更类型安全
bucket: `${projectName}-offline-store-${stackName}`,
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "AES256",
},
},
},
tags: {
Environment: stackName,
},
});
// -----------------------------------------------------------------------------
// DynamoDB Table for Online Store
// -----------------------------------------------------------------------------
const onlineStoreTable = new aws.dynamodb.Table(`${projectName}-online-store-${stackName}`, {
name: `${projectName}-online-store-${stackName}`,
attributes: [
{ name: "entity_id", type: "S" },
{ name: "feature_set", type: "S" },
],
hashKey: "entity_id",
rangeKey: "feature_set",
billingMode: "PROVISIONED",
readCapacity: config.getNumber("dynamodbReadCapacity") || 5,
writeCapacity: config.getNumber("dynamodbWriteCapacity") || 5,
pointInTimeRecovery: {
enabled: true,
},
tags: {
Environment: stackName,
},
});
// -----------------------------------------------------------------------------
// IAM Role and Policies
// 这里展示了编程语言的强大之处:我们可以创建可复用的函数
// -----------------------------------------------------------------------------
function createLambdaRole(name: string): aws.iam.Role {
const role = new aws.iam.Role(name, {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: "lambda.amazonaws.com" }),
});
// 将多个策略逻辑地组织在一起
const lambdaPolicy = new aws.iam.Policy(`${name}-policy`, {
policy: pulumi.all([onlineStoreTable.arn, offlineStoreBucket.arn]).apply(([tableArn, bucketArn]) => JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Query"],
Resource: tableArn,
},
{
Effect: "Allow",
Action: ["s3:PutObject"],
Resource: `${bucketArn}/*`,
},
],
})),
});
new aws.iam.RolePolicyAttachment(`${name}-policy-attachment`, {
role: role,
policyArn: lambdaPolicy.arn,
});
// 附加 CloudWatch Logs 权限
new aws.iam.RolePolicyAttachment(`${name}-cw-attachment`, {
role: role,
policyArn: aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole,
});
return role;
}
const lambdaExecRole = createLambdaRole(`${projectName}-lambda-role-${stackName}`);
// -----------------------------------------------------------------------------
// Lambda Functions and API Gateway
// 使用 awsx.apigateway.API,这是一个更高层次的抽象,极大地简化了 API Gateway 的定义
// -----------------------------------------------------------------------------
// Pulumi 的一个巨大优势:可以直接将 Lambda 的 handler 代码内联定义,或者从文件中引入
// 无需手动打包和上传 zip 文件,Pulumi 会在部署时自动处理
const ingestLambda = new aws.lambda.CallbackFunction(`${projectName}-ingest-lambda-${stackName}`, {
role: lambdaExecRole,
runtime: aws.lambda.Runtime.Python3d9,
memorySize: 256,
timeout: 30,
environment: {
variables: {
DYNAMODB_TABLE: onlineStoreTable.name,
S3_BUCKET: offlineStoreBucket.bucket,
},
},
callback: async (event) => {
// 这是 Lambda 的实际执行代码,使用 Python 的示例
// 在真实项目中,这段代码会放在一个单独的 'app.py' 文件中
const aws_sdk = require("aws-sdk");
const dynamo = new aws_sdk.DynamoDB.DocumentClient();
// ... 业务逻辑 ...
console.log("Ingestion logic executed.");
return { statusCode: 200, body: "Success" };
},
});
// 类似的 retrievalLambda 定义...
const api = new awsx.apigateway.API(`${projectName}-api-${stackName}`, {
routes: [
{
path: "/features/{entity}/{feature_set}",
method: "POST",
eventHandler: ingestLambda,
},
{
path: "/features/{entity}/{feature_set}",
method: "GET",
// eventHandler: retrievalLambda, // 指向 retrieval lambda
// 这里为了简化,也指向 ingestLambda
eventHandler: ingestLambda,
},
],
});
// -----------------------------------------------------------------------------
// Outputs
// -----------------------------------------------------------------------------
export const apiEndpoint = api.url;
export const onlineStoreTableName = onlineStoreTable.name;
export const offlineStoreBucketName = offlineStoreBucket.bucket;
Pulumi 方案的优劣分析
优点:
- 编程语言的全部能力: 可以使用循环、函数、类、设计模式来组织基础设施代码。这在处理复杂、动态的基础设施时提供了无与伦比的灵活性。
- 统一的工具链: 开发人员可以使用他们熟悉的 IDE、linter、调试器和测试框架(如 Jest, Pytest)来开发和测试基础设施代码,降低了学习曲线。
- 代码与基础设施的紧密集成:
aws.lambda.CallbackFunction
这种资源允许将应用逻辑和基础设施定义放在一起,简化了小型 Serverless 应用的开发部署流程。 - 强大的抽象能力: 可以构建非常高级别的组件。例如,我们可以创建一个
FeatureStore
类,它封装了所有相关的资源(S3, DynamoDB, Lambda, API Gateway),调用者只需new FeatureStore("my-fs")
即可创建一个完整的实例。
缺点:
- 灵活性的代价: 过度的灵活性可能导致难以维护的代码。如果团队没有良好的软件工程实践,很容易写出命令式风格的、充满副作用的、难以理解的“面条式”基础设施代码。
- 预览(Preview)的复杂性: 当代码中包含复杂的逻辑时,
pulumi preview
的输出可能不如terraform plan
那样直观和确定。因为代码的执行结果可能依赖于外部数据或复杂的计算。 - 生态系统相对较小: 虽然 Pulumi 社区在快速成长,但其预构建的组件和模块数量仍少于 Terraform。
- 状态管理: 和 Terraform 一样依赖于状态文件,但其默认使用 Pulumi Service 进行状态管理,虽然方便,但也可能引发对第三方服务依赖的担忧(当然也可以配置为使用 S3 等后端)。
决策与权衡
回到最初的问题:对于构建 Serverless Feature Store,应该选择哪种工具?
选择 Terraform 的场景:
- 团队的基础设施管理经验更偏向于传统的系统管理或 DevOps,对严格的声明式范式更适应。
- 项目对基础设施的变更控制要求极高,
terraform plan
的确定性输出是不可或缺的审计环节。 - 需要大量利用现有的 Terraform 模块来集成其他系统(如 Datadog, Snowflake 等)。
- 团队希望将基础设施定义(IaC)和应用逻辑代码(Lambda)严格分离,通过独立的 CI/CD 流水线进行管理。
选择 Pulumi 的场景:
- 团队成员主要是软件工程师,对 TypeScript/Python 等语言非常熟悉,希望用统一的技术栈完成所有开发工作。
- 基础设施本身需要复杂的逻辑。例如,需要根据配置文件动态生成数十个相似的微服务,每个服务都有自己的数据库、队列和权限。用 HCL 实现这种场景将是场噩梦。
- 希望对基础设施代码进行单元测试和集成测试,将其作为一等公民纳入软件开发生命周期。
- 希望构建更高层次的内部平台抽象(即平台工程),将复杂的云资源封装成简单的、对开发者友好的组件。
对于我们的 Feature Store 案例,两种方案都能完成任务。但是,如果预见到未来需要支持多种特征集、动态环境部署、或者需要将特征的元数据(schema, validation rules)也纳入 IaC 管理,那么 Pulumi 的编程能力将提供更大的优势。我们可以用代码读取元数据配置文件,然后动态生成所需的 DynamoDB 表结构、API Gateway 验证模型和 Lambda 逻辑。
方案的局限性与未来迭代
我们所构建的这个 Serverless Feature Store 并非银弹。它的主要局限性在于:
- 冷启动延迟: AWS Lambda 的冷启动问题可能会影响对延迟极度敏感的在线推理场景。预置并发(Provisioned Concurrency)可以缓解此问题,但这会增加成本并引入更多配置复杂性。
- 数据一致性: 摄入逻辑同时写入 DynamoDB 和 S3,这是一个双写操作,缺乏原子性。在生产环境中,更可靠的模式是使用 DynamoDB Streams。当数据写入在线存储时,会产生一个事件流,触发另一个 Lambda 函数将数据可靠地、异步地写入离线存储 S3。
- 在线存储选型: DynamoDB 是一个优秀的通用选择,但对于某些特定类型的特征(如向量嵌入),专门的数据库(如 OpenSearch 或专用的向量数据库)可能更合适。
- 特征回填与版本管理: 当前架构未显式处理特征数据的回填(backfilling)和版本控制。这通常需要独立的批处理作业(如 EMR 或 Glue Job)来完成,并且需要更复杂的元数据管理系统。
无论是选择 Terraform 还是 Pulumi,上述的迭代和演进都是可能的。IaC 的真正价值在于,它为这些复杂的架构变更提供了安全网,使我们能够充满信心地、以小步快跑的方式持续改进我们的基础设施。