使用 Pulumi 与 Terraform 构建生产级 Serverless Feature Store 的架构权衡


为机器学习模型提供实时、一致的特征是一项非平凡的基础设施挑战。团队需要一个低延迟的在线 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 格式组织,便于 AthenaSpark 进行批量查询和模型训练。
  • 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 方案的优劣分析

  • 优点:

    1. 声明式纯粹性: HCL 强制你只描述“最终状态”,而不是“如何达到”。这使得代码意图清晰,易于审查。
    2. 生态系统: 拥有最广泛的 Provider 支持和海量的社区模块,几乎任何云服务或SaaS产品都有现成的模块可用。
    3. 状态管理: terraform plan 提供了强大的变更预览能力,让你在应用前能精确知道会发生什么,这在生产环境中至关重要。
    4. 强制解耦: 代码与逻辑的分离(Lambda代码必须预先打包)是一种约束,但也促使团队建立更规范的 CI/CD 流程。
  • 缺点:

    1. 逻辑表达能力有限: HCL 不是通用编程语言。对于需要循环、条件判断、数据转换等复杂逻辑的场景,实现起来非常笨拙(需要依赖 count, for_each, locals 等技巧)。例如,如果要根据一个列表动态创建多个 API Gateway 端点,代码会变得难以阅读。
    2. 测试困难: 基础设施代码本身的单元测试和集成测试比较困难,通常需要依赖 Terratest 等第三方工具,学习曲线陡峭。
    3. 代码复用: 虽然有模块化机制,但模块内部的逻辑复用能力远不如函数或类。

在真实项目中,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 方案的优劣分析

  • 优点:

    1. 编程语言的全部能力: 可以使用循环、函数、类、设计模式来组织基础设施代码。这在处理复杂、动态的基础设施时提供了无与伦比的灵活性。
    2. 统一的工具链: 开发人员可以使用他们熟悉的 IDE、linter、调试器和测试框架(如 Jest, Pytest)来开发和测试基础设施代码,降低了学习曲线。
    3. 代码与基础设施的紧密集成: aws.lambda.CallbackFunction 这种资源允许将应用逻辑和基础设施定义放在一起,简化了小型 Serverless 应用的开发部署流程。
    4. 强大的抽象能力: 可以构建非常高级别的组件。例如,我们可以创建一个 FeatureStore 类,它封装了所有相关的资源(S3, DynamoDB, Lambda, API Gateway),调用者只需 new FeatureStore("my-fs") 即可创建一个完整的实例。
  • 缺点:

    1. 灵活性的代价: 过度的灵活性可能导致难以维护的代码。如果团队没有良好的软件工程实践,很容易写出命令式风格的、充满副作用的、难以理解的“面条式”基础设施代码。
    2. 预览(Preview)的复杂性: 当代码中包含复杂的逻辑时,pulumi preview 的输出可能不如 terraform plan 那样直观和确定。因为代码的执行结果可能依赖于外部数据或复杂的计算。
    3. 生态系统相对较小: 虽然 Pulumi 社区在快速成长,但其预构建的组件和模块数量仍少于 Terraform。
    4. 状态管理: 和 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 并非银弹。它的主要局限性在于:

  1. 冷启动延迟: AWS Lambda 的冷启动问题可能会影响对延迟极度敏感的在线推理场景。预置并发(Provisioned Concurrency)可以缓解此问题,但这会增加成本并引入更多配置复杂性。
  2. 数据一致性: 摄入逻辑同时写入 DynamoDB 和 S3,这是一个双写操作,缺乏原子性。在生产环境中,更可靠的模式是使用 DynamoDB Streams。当数据写入在线存储时,会产生一个事件流,触发另一个 Lambda 函数将数据可靠地、异步地写入离线存储 S3。
  3. 在线存储选型: DynamoDB 是一个优秀的通用选择,但对于某些特定类型的特征(如向量嵌入),专门的数据库(如 OpenSearch 或专用的向量数据库)可能更合适。
  4. 特征回填与版本管理: 当前架构未显式处理特征数据的回填(backfilling)和版本控制。这通常需要独立的批处理作业(如 EMR 或 Glue Job)来完成,并且需要更复杂的元数据管理系统。

无论是选择 Terraform 还是 Pulumi,上述的迭代和演进都是可能的。IaC 的真正价值在于,它为这些复杂的架构变更提供了安全网,使我们能够充满信心地、以小步快跑的方式持续改进我们的基础设施。


  目录