团队最近一次线上事故,源于一个极其微小却致命的疏忽:一个为新功能所需的 Firestore 复合索引,在 staging 环境测试通过后,并未被同步部署到 production 环境。iOS 新版本发布后,相关查询功能在生产环境全面瘫痪,直到用户反馈涌入,我们才在深夜紧急手动创建索引。那一刻,我们意识到,依赖 Firebase 控制台手动管理 Firestore 规则和索引的“敏捷”模式,已经变成了悬在我们头上的达摩克利斯之剑。
对于一个以移动开发为主的团队来说,后端即服务(BaaS)如 Firebase 是我们的生命线。但随着业务复杂度的提升,其基础设施配置的管理复杂度也呈指数级增长。开发、预发布、生产三套环境,每套环境都有几十条安全规则和近百个索引。任何变更都依赖于人的细心和一份不断过时的文档。这种工作流不仅效率低下,而且风险极高。我们需要一个能够版本化、可审查、自动化部署的方案。
最初的构想很简单:将 firestore.rules 和 firestore.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 Admin 和 Cloud 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)",
});
});
// ...导出...
这种方法的威力在于:
- 可读性:索引定义清晰、结构化,一目了然。
- 安全性:TypeScript 的类型检查能帮助我们避免
order或queryScope等字段的拼写错误。 - 自动化:当移动端开发者需要一个新索引时,他们不再需要去控制台操作或请求后端工程师。他们只需在这个数组中添加一个新对象,提交一个 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 项目中创建资源、验证、然后销毁。这是我们提升这套系统健壮性的下一个演进方向。