使用 Pulumi 和 GitHub Actions 为 iOS 应用实现 Firestore 基础设施的声明式管理


团队最近一次线上事故,源于一个极其微小却致命的疏忽:一个为新功能所需的 Firestore 复合索引,在 staging 环境测试通过后,并未被同步部署到 production 环境。iOS 新版本发布后,相关查询功能在生产环境全面瘫痪,直到用户反馈涌入,我们才在深夜紧急手动创建索引。那一刻,我们意识到,依赖 Firebase 控制台手动管理 Firestore 规则和索引的“敏捷”模式,已经变成了悬在我们头上的达摩克利斯之剑。

对于一个以移动开发为主的团队来说,后端即服务(BaaS)如 Firebase 是我们的生命线。但随着业务复杂度的提升,其基础设施配置的管理复杂度也呈指数级增长。开发、预发布、生产三套环境,每套环境都有几十条安全规则和近百个索引。任何变更都依赖于人的细心和一份不断过时的文档。这种工作流不仅效率低下,而且风险极高。我们需要一个能够版本化、可审查、自动化部署的方案。

最初的构想很简单:将 firestore.rulesfirestore.indexes.json 文件纳入 Git 版本控制,然后编写 shell 脚本调用 firebase-tools CLI 进行部署。这方案在初期看似可行,但很快就暴露了问题。脚本需要处理不同环境的 Firebase 项目ID、服务账户凭证,并且难以实现对多个资源(例如,一个新功能可能同时需要修改规则、增加索引、并更新一个 Cloud Function)的原子化更新。脚本本身也逐渐变得复杂,成为了新的维护负担。

我们评估了 Terraform。它是 IaC 领域的标准,其 Google Provider 对 Firebase 的支持也相当完善。但 HCL 语言对我们团队来说是一个新的学习曲线,它与我们日常使用的 Swift 和 TypeScript 在心智模型上存在隔阂。我们希望找到一个能让我们在现有技术栈内解决问题的工具。

这时 Pulumi 进入了我们的视野。它允许使用通用编程语言(如 TypeScript, Python, Go)来定义云基础设施。对我们而言,这意味着可以用 TypeScript——我们早已用来编写 Cloud Functions 的语言——来管理整个 Firebase 项目。我们可以使用熟悉的 IDE、NPM 生态、类型检查、以及模块化能力来组织我们的基础设施代码。这不仅仅是“代码化”,更是将基础设施真正融入到了我们的软件工程体系中。这个决策几乎没有犹豫。

第一步:项目初始化与多环境栈配置

我们的目标是为 dev, staging, prod 三个 Firebase 项目建立对应的 Pulumi 栈。每个栈都将独立管理一套完整的 Firestore 配置。

首先,创建一个新的 Pulumi 项目:

# 安装 Pulumi CLI (如果尚未安装)
# brew install pulumi

# 创建一个新的 TypeScript 项目
mkdir firebase-infra && cd firebase-infra
pulumi new gcp-typescript

# 输入项目名称、描述等信息...
# 完成后,Pulumi 会生成基础的项目结构

项目结构的核心是 index.ts(我们的基础设施定义入口)和 Pulumi.yaml(项目配置文件)。为了管理多环境,我们创建了三个栈,并分别为它们配置了对应的 GCP 项目 ID。

Pulumi.yaml:

name: firebase-infra-manager
runtime: nodejs
description: Declarative management for our app's Firebase infrastructure.

创建并配置栈:

# 创建 dev 栈
pulumi stack init dev

# 为 dev 栈配置 GCP 项目 ID
pulumi config set gcp:project my-app-dev-project-id

# 创建 staging 栈
pulumi stack init staging
pulumi config set gcp:project my-app-staging-project-id

# 创建 prod 栈
pulumi stack init prod
pulumi config set gcp:project my-app-prod-project-id

执行后,会生成对应的 Pulumi.<stack-name>.yaml 文件,它们存储了每个环境的特定配置。

Pulumi.dev.yaml:

config:
  gcp:project: my-app-dev-project-id

处理认证是关键一步。我们在 GCP 中为 Pulumi 创建了一个专用的服务账户,并授予其 Firebase Rules AdminCloud Datastore Index Admin 等必要权限。然后将生成的 JSON 密钥文件内容设置为 Pulumi 的机密配置。

# 将服务账户密钥设置为机密信息
# Pulumi 会自动加密并存储在配置文件中
pulumi config set --secret gcp:credentials "$(cat ./gcp-credentials.json)"

这样,Pulumi 在执行时就能自动使用正确的凭证来操作对应的 GCP 项目。

第二步:将 Firestore 安全规则代码化

我们不再维护一个巨大的 firestore.rules 文件,而是将其分解为逻辑上独立的 TypeScript 模块。这种方式极大地提升了可读性和可维护性。

项目结构调整:

firebase-infra/
├── src/
│   ├── firestore/
│   │   ├── rules.ts       # 安全规则生成逻辑
│   │   └── indexes.ts     # 索引定义
│   └── index.ts         # Pulumi 入口文件
├── node_modules/
├── package.json
├── Pulumi.yaml
└── ...

src/firestore/rules.ts 中,我们定义了一个函数来动态构建规则字符串。这使得我们可以添加注释、使用常量,甚至进行一些简单的逻辑组合。

src/firestore/rules.ts:

// src/firestore/rules.ts

// 定义可复用的规则片段
const isUserAuthenticated = "request.auth != null";
const isRequestingUser = (userId: string) => `request.auth.uid == ${userId}`;

// 定义集合路径的常量
const USERS_PATH = "/users/{userId}";
const POSTS_PATH = "/posts/{postId}";

/**
 * 生成完整的 Firestore 安全规则字符串
 * @returns {string} The complete ruleset string.
 */
export function generateFirestoreRules(): string {
  // 在真实项目中,这里会有更复杂的逻辑
  // 比如根据环境注入不同的规则,或者从其他模块组合规则
  const rules = `
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    
    // 用户只能读取和更新自己的文档
    match ${USERS_PATH} {
      allow read, update: if ${isRequestingUser("userId")};
      allow create: if ${isUserAuthenticated};
    }

    // 所有认证用户都可以读取帖子,但只有创建者能修改
    match ${POSTS_PATH} {
      allow read: if ${isUserAuthenticated};
      allow create, update, delete: if resource.data.authorId == request.auth.uid;
    }
  }
}
  `;

  // 在部署前进行简单的验证,防止低级错误
  if (!rules.includes("rules_version = '2';")) {
    throw new Error("Firestore rules must start with 'rules_version = '2';'");
  }

  return rules;
}

接着,在主入口文件 src/index.ts 中使用这个函数来创建 Pulumi 资源。

src/index.ts:

// src/index.ts

import * as gcp from "@pulumi/gcp";
import * as pulumi from "@pulumi/pulumi";
import * as fs from "fs";
import { generateFirestoreRules } from "./firestore/rules";

// 1. 定义 Firestore 安全规则
// 从我们的模块中生成规则内容
const firestoreRulesContent = generateFirestoreRules();

// 创建一个规则集 (Ruleset) 资源
// 这代表了我们规则的一个不可变版本
const firestoreRuleset = new gcp.firebaserules.Ruleset("firestore-ruleset", {
    source: {
        files: [{
            name: "firestore.rules",
            content: firestoreRulesContent,
        }],
    },
    // 将项目ID与资源关联
    project: gcp.config.project,
});

// 创建一个发布 (Release) 资源
// 它将指定的规则集应用到 Firestore 服务上
const firestoreRelease = new gcp.firebaserules.Release("firestore-release", {
    // 发布的名称必须是 "cloud.firestore"
    name: `projects/${gcp.config.project}/releases/cloud.firestore`,
    rulesetName: firestoreRuleset.name,
    project: gcp.config.project,
}, {
    // 明确告诉 Pulumi,这个发布依赖于规则集的创建
    dependsOn: [firestoreRuleset],
});

// ...后续会添加索引资源...

// 导出 release 名称,方便在 Pulumi 控制台查看
export const releaseName = firestoreRelease.name;

现在,每次我们修改 rules.ts 并运行 pulumi up,Pulumi 都会计算出规则的变化,创建一个新的 Ruleset,然后更新 Release,从而原子化地部署新规则。整个过程清晰可追溯。

第三步:声明式管理复合索引

这才是真正解决我们痛点的部分。我们将所有复合索引的定义集中在一个 TypeScript 文件中,使用结构化的对象数组来描述它们。

src/firestore/indexes.ts:

// src/firestore/indexes.ts

// 定义一个清晰的索引结构类型
interface FirestoreIndexConfig {
  collection: string;
  queryScope?: "COLLECTION" | "COLLECTION_GROUP";
  fields: {
    fieldPath: string;
    order?: "ASCENDING" | "DESCENDING";
    arrayConfig?: "CONTAINS";
  }[];
}

// 索引定义的唯一真实来源 (Single Source of Truth)
export const firestoreIndexes: FirestoreIndexConfig[] = [
  {
    collection: "posts",
    queryScope: "COLLECTION",
    fields: [
      { fieldPath: "authorId", order: "ASCENDING" },
      { fieldPath: "createdAt", order: "DESCENDING" },
    ],
  },
  {
    collection: "products",
    queryScope: "COLLECTION",
    fields: [
      { fieldPath: "category", order: "ASCENDING" },
      { fieldPath: "price", order: "ASCENDING" },
      { fieldPath: "rating", order: "DESCENDING" },
    ],
  },
  {
    collection: "userFollows",
    queryScope: "COLLECTION_GROUP", // 这是一个集合组查询索引
    fields: [
      { fieldPath: "followerId", order: "ASCENDING" },
      { fieldPath: "followedAt", order: "DESCENDING" },
    ],
  },
  // ... 其他所有索引
];

src/index.ts 中,我们遍历这个数组,为每个定义创建一个 gcp.firestore.Index 资源。

src/index.ts (续):

// ...接上文...
import { firestoreIndexes } from "./firestore/indexes";

// 2. 批量定义 Firestore 复合索引
firestoreIndexes.forEach((indexConfig, i) => {
    // 为每个索引资源生成一个唯一的、稳定的名称
    const indexName = `firestore-index-${indexConfig.collection}-${i}`;
    
    new gcp.firestore.Index(indexName, {
        project: gcp.config.project,
        collection: indexConfig.collection,
        queryScope: indexConfig.queryScope || "COLLECTION",
        fields: indexConfig.fields.map(field => ({
            fieldPath: field.fieldPath,
            // 默认排序为 ASCENDING
            order: field.order,
            // 处理数组包含查询
            arrayConfig: field.arrayConfig,
        })),
        // Firestore 索引的 databaseId 默认为 '(default)'
        database: "(default)",
    });
});

// ...导出...

这种方法的威力在于:

  1. 可读性:索引定义清晰、结构化,一目了然。
  2. 安全性:TypeScript 的类型检查能帮助我们避免 orderqueryScope 等字段的拼写错误。
  3. 自动化:当移动端开发者需要一个新索引时,他们不再需要去控制台操作或请求后端工程师。他们只需在这个数组中添加一个新对象,提交一个 Pull Request。基础设施的变更就和业务代码变更一样,进入了相同的审查和部署流程。

第四步:通过 GitHub Actions 实现 GitOps 工作流

最后一步是将整个流程自动化。我们的目标是:

  • 推送到 develop 分支时,自动更新 dev 环境。
  • 合并到 main 分支时,自动更新 prod 环境。
  • staging 环境可以配置为手动触发,或在合并到 release/* 分支时触发)

我们在项目根目录下创建 .github/workflows/pulumi-deploy.yml

.github/workflows/pulumi-deploy.yml:

name: Deploy Firebase Infra with Pulumi

on:
  push:
    branches:
      - main
      - develop

jobs:
  update-infra:
    name: Update Firebase Infrastructure
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install Pulumi CLI
        uses: pulumi/actions@v4

      - name: Configure GCP Credentials
        uses: 'google-github-actions/auth@v1'
        with:
          credentials_json: '${{ secrets.GCP_CREDENTIALS }}'

      - name: Install Dependencies
        run: npm install

      - name: Select Pulumi Stack and Deploy
        env:
          # Pulumi Access Token 用于登录 Pulumi Service
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
        run: |
          # 根据 Git 分支选择对应的 Pulumi 栈
          if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
            pulumi stack select prod
          elif [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then
            pulumi stack select dev
          else
            echo "Branch not configured for deployment"
            exit 1
          fi
          
          # 自动执行更新,--yes 参数表示跳过交互式确认
          # 在生产环境中,更安全的做法是先运行 pulumi preview,需要人工审批后再 apply
          # 但对于我们的内部流程,自动 apply 已经足够
          echo "Deploying stack: $(pulumi stack --show-name)"
          pulumi up --yes

这个工作流依赖于两个 GitHub Secrets:

  • GCP_CREDENTIALS: 我们之前用于本地执行的服务账户 JSON 密钥。
  • PULUMI_ACCESS_TOKEN: 从 Pulumi Service 生成的访问令牌,用于 CI/CD 环境中非交互式登录。

现在,整个流程闭环了。一位 iOS 开发者在开发新功能时,发现需要一个新的查询索引。他会在 indexes.ts 中添加索引定义,然后提交一个 PR 到 develop 分支。团队其他成员可以在 PR 中清晰地看到基础设施的变更,就像审查其他代码一样。PR 合并后,GitHub Actions 会自动将这个新索引部署到 dev 环境。iOS 应用在 dev 环境测试通过后,相关代码和基础设施变更被合并到 main 分支,CI/CD 再次触发,将索引和规则安全、准确地部署到生产环境。

graph TD
    subgraph Local Development
        A[iOS 开发者在 `feature/new-query` 分支修改 `indexes.ts`]
    end

    subgraph GitHub
        B[创建 Pull Request 到 `develop`] --> C{Code Review};
        C --> D[合并到 `develop` 分支];
        D --> E{GitHub Action 触发};
    end

    subgraph CI/CD Pipeline
        E --> F[Pulumi Action 运行];
        F --> G[选择 `dev` 栈];
        G --> H[执行 `pulumi up --yes`];
    end

    subgraph Firebase Cloud
        H --> I[Dev 环境 Firestore 索引被创建/更新];
    end
    
    D -- 合并到 main 分支后 --> J{GitHub Action 再次触发}
    J --> K[选择 `prod` 栈]
    K --> L[执行 `pulumi up --yes`]
    L --> M[Prod 环境 Firestore 索引被创建/更新]

这个流程彻底解决了我们最初的痛点。手动操作被消除,配置有了唯一的、版本化的数据源,部署过程实现了自动化。我们移动开发团队现在有能力以一种安全、可扩展的方式,全权负责他们所依赖的后端基础设施。

遗留问题与未来迭代方向

这套方案并非银弹。首先,Pulumi 的状态管理是一个核心依赖。我们目前使用官方的 Pulumi Service,它提供了优秀的可视化界面和团队协作功能。但对于有更强数据主权要求的组织,可以考虑将状态后端配置为自托管的 GCS Bucket。这会增加一些管理状态锁的复杂性,以换取更大的控制权。

其次,我们的 GitHub Action 工作流为了简化,直接在 main 分支上执行 pulumi up --yes。在一个更严格的生产环境中,最佳实践应该是在 PR 阶段生成并评论一个 pulumi preview 的结果,然后需要一个审批步骤(比如添加一个特定的 PR 标签或评论)才能在合并后真正执行 pulumi up。这可以作为防止意外变更的最后一道防线。

最后,我们还没有为这套 IaC 代码库引入单元测试和集成测试。对于 rules.ts 中的规则生成逻辑,完全可以像测试普通 TypeScript 代码一样编写单元测试。对于索引等资源的集成测试则更复杂,可能需要 Pulumi 的自动化 API 在一个临时的 Firebase 项目中创建资源、验证、然后销毁。这是我们提升这套系统健壮性的下一个演进方向。


  目录