利用 AWS SNS 事件驱动 Gatsby 构建管道的动态 SCSS 主题生成


我们面临一个具体的工程挑战:维护一个基于 Gatsby 的多品牌静态网站矩阵。核心代码库是统一的,但每个品牌都有自己独特的视觉识别系统(VIS),这些系统通过 SCSS 变量文件(_variables.scss)注入。当市场部门需要为某个特定活动临时更换主题色,或品牌部门更新了 LOGO 主色调时,整个流程是手动且低效的:接收需求 -> 工程师手动修改 SCSS 文件 -> 提交代码 -> 等待 CI/CD 流程 -> 部署。这个过程不仅占用了开发资源,而且响应速度慢,容易出错。

我们的目标是构建一个完全自动化的、由事件驱动的系统。任何授权的上游系统(如内部品牌管理平台或 CMS)只需发布一个简单的事件,就能安全、可靠地触发特定站点的视觉更新和重新部署,整个过程无需任何工程师介入。

初步构想是围绕 AWS 的消息服务构建一个解耦的架构。上游系统作为事件生产者,将新的主题配置(例如,JSON 格式的颜色、字体变量)发布到 AWS SNS (Simple Notification Service) 主题。一个 AWS Lambda 函数作为消费者,订阅该主题。一旦收到消息,Lambda 函数的核心职责是:验证消息的合法性与数据结构的正确性,然后将接收到的 JSON 数据动态转换为一个合法的 _variables.scss 文件,并将其注入到我们的构建流程中,最终触发 Gatsby 的重新构建和部署。

技术选型决策相对直接,但细节处见真章。

  • AWS SNS: 选择 SNS 而非直接调用 Lambda 的原因是解耦。事件的生产者无需关心消费者的具体实现、位置或数量。未来我们可以轻松地增加其他订阅者来处理同一事件,例如发送通知、更新数据库等,而无需修改生产者代码。这在企业级架构中至关重要。
  • AWS Lambda: 它是这个场景下完美的胶水代码载体。它无服务器、事件驱动、成本低廉(按需付费),且能轻松授予与 AWS 其他服务交互的精确 IAM 权限。
  • AWS CodeCommit & CodeBuild: 我们选择将动态生成的 SCSS 文件提交回 Git 仓库(CodeCommit),而不是直接传递给构建环境。这样做的好处是显而易见的:所有主题变更都留下了清晰、可追溯的 Git 记录。这对于审计和故障排查至关重要。CodeBuild 则作为我们的 CI/CD 引擎,在检测到新的 commit 后自动执行构建任务。

整个工作流程的架构图如下:

graph TD
    A[上游系统/CMS] -- 1. 发布主题更新JSON --> B(AWS SNS Topic);
    B -- 2. 推送事件 --> C{AWS Lambda 函数};
    C -- 3. 验证与转换 --> D[生成 _variables.scss 内容];
    D -- 4. 使用 AWS SDK 提交文件 --> E(AWS CodeCommit Repository);
    E -- 5. 触发 Webhook/事件 --> F(AWS CodePipeline);
    F -- 拉取最新代码 --> G[AWS CodeBuild];
    G -- 执行 gatsby build --> H[构建产物];
    H -- 部署 --> I[S3 静态网站托管];

    subgraph "事件处理核心"
        C;
        D;
    end

    subgraph "CI/CD 自动化"
        E;
        F;
        G;
    end

步骤一:基础设施定义 (IAM 与 SNS)

在生产项目中,手动配置控制台是不可接受的。我们使用 Terraform 来定义所有基础设施资源,确保其可复现、可审计。

首先是 SNS 主题和 Lambda 执行所需的 IAM 角色与策略。这是整个系统安全性的基石。

# main.tf

# 1. 定义 SNS 主题,用于接收主题更新事件
resource "aws_sns_topic" "theme_update_topic" {
  name = "gatsby-theme-update-topic"
  tags = {
    Project = "DynamicThemingPipeline"
  }
}

# 2. 定义 Lambda 执行角色
resource "aws_iam_role" "theme_updater_lambda_role" {
  name = "ThemeUpdaterLambdaRole"
  assume_role_policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [
      {
        Action    = "sts:AssumeRole",
        Effect    = "Allow",
        Principal = {
          Service = "lambda.amazonaws.com"
        },
      },
    ],
  })
}

# 3. 定义 Lambda 需要的核心权限策略
# 一个常见的错误是给予过于宽泛的权限,例如 CodeCommit:*。
# 我们必须遵循最小权限原则。
resource "aws_iam_policy" "lambda_policy" {
  name        = "ThemeUpdaterLambdaPolicy"
  description = "Policy for the dynamic theming Lambda function"

  policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [
      # 允许写入 CloudWatch Logs
      {
        Effect = "Allow",
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ],
        Resource = "arn:aws:logs:*:*:*"
      },
      # 允许与 CodeCommit 交互的最小权限集
      {
        Effect = "Allow",
        Action = [
          "codecommit:GetFile",
          "codecommit:GetBranch",
          "codecommit:CreateCommit",
          "codecommit:PutFile"
        ],
        # 精确到特定的代码仓库
        Resource = "arn:aws:codecommit:us-east-1:123456789012:your-gatsby-repo"
      },
      # 允许触发 CodeBuild 项目
      {
        Effect   = "Allow",
        Action   = "codebuild:StartBuild",
        Resource = "arn:aws:codebuild:us-east-1:123456789012:build/your-codebuild-project"
      }
    ]
  })
}

# 4. 将策略附加到角色
resource "aws_iam_role_policy_attachment" "lambda_policy_attachment" {
  role       = aws_iam_role.theme_updater_lambda_role.name
  policy_arn = aws_iam_policy.lambda_policy.arn
}

# 5. 授予 SNS 调用此 Lambda 的权限
resource "aws_lambda_permission" "sns_invoke" {
  statement_id  = "AllowExecutionFromSNS"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.theme_updater_lambda.function_name # 稍后定义
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.theme_update_topic.arn
}

# 6. 创建 SNS 主题订阅,将事件推送到 Lambda
resource "aws_sns_topic_subscription" "lambda_subscription" {
  topic_arn = aws_sns_topic.theme_update_topic.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.theme_updater_lambda.arn # 稍后定义
}

这段 IaC 代码定义了工作流的安全边界。任何偏离这些精确权限的操作都将被 AWS 拒绝,这是保障系统稳健性的第一步。

步骤二:核心处理逻辑 - Lambda 函数

这是整个系统的“大脑”。我们使用 Node.js 编写,因为它在 Lambda 环境中启动速度快且生态成熟。函数必须做到以下几点:健壮的输入验证、清晰的逻辑转换、完善的错误处理与日志记录。

// theme-updater-lambda/index.js

const {
  CodeCommitClient,
  GetFileCommand,
  CreateCommitCommand,
} = require("@aws-sdk/client-codecommit");
const { CodeBuildClient, StartBuildCommand } = require("@aws-sdk/client-codebuild");
const Joi = require('joi');

// 从环境变量中获取配置,这是最佳实践,避免硬编码
const REGION = process.env.AWS_REGION;
const REPOSITORY_NAME = process.env.REPOSITORY_NAME;
const BRANCH_NAME = process.env.BRANCH_NAME;
const FILE_PATH = process.env.SCSS_VARIABLES_PATH; // e.g., 'src/styles/abstracts/_variables.scss'
const CODEBUILD_PROJECT_NAME = process.env.CODEBUILD_PROJECT_NAME;

// 初始化 AWS SDK 客户端
const codecommitClient = new CodeCommitClient({ region: REGION });
const codebuildClient = new CodeBuildClient({ region: REGION });

// 定义输入主题数据的验证 Schema
// 在真实项目中,这个 Schema 必须严格定义,防止无效或恶意数据破坏构建
const themeSchema = Joi.object({
  siteId: Joi.string().required(), // 用于区分不同站点
  theme: Joi.object({
    'primary-color': Joi.string().hex().required(),
    'secondary-color': Joi.string().hex().required(),
    'text-color': Joi.string().hex().required(),
    'font-family': Joi.string().replace(/'/g, '"').required(), // 清理字体名称
    'base-font-size': Joi.string().pattern(new RegExp('^\\d+px$')).required(),
  }).required()
}).required();

/**
 * 将验证通过的 JSON 对象转换为 SCSS 变量文件内容
 * @param {object} themeVariables - 包含主题变量的对象
 * @returns {string} - SCSS 文件格式的字符串
 */
const generateScssContent = (themeVariables) => {
  let scssContent = `// This file is auto-generated by the Theme Updater Lambda.\n`;
  scssContent += `// Timestamp: ${new Date().toISOString()}\n\n`;

  for (const [key, value] of Object.entries(themeVariables)) {
    // 确保 SCSS 变量格式正确
    scssContent += `$${key}: ${value};\n`;
  }
  return scssContent;
};

exports.handler = async (event) => {
  // 生产环境日志应为 JSON 格式,便于机器解析
  console.log(JSON.stringify({ message: "Received event", event }));

  try {
    // 1. 从 SNS 事件中解析消息体
    if (!event.Records || event.Records.length === 0) {
      throw new Error("Invalid SNS event structure: no records found.");
    }
    const messageBody = JSON.parse(event.Records[0].Sns.Message);
    console.log(JSON.stringify({ message: "Parsed SNS message", data: messageBody }));

    // 2. 严格验证输入数据
    const { error, value: validatedData } = themeSchema.validate(messageBody);
    if (error) {
      // 如果数据验证失败,这是客户端错误,不应重试。记录错误并正常退出。
      console.error(JSON.stringify({
        level: "ERROR",
        message: "Joi validation failed",
        errorDetails: error.details,
        inputData: messageBody
      }));
      // 直接返回成功,防止 SNS 不断重试无效消息
      return { statusCode: 200, body: "Validation failed, message discarded." };
    }

    const { siteId, theme } = validatedData;
    const newScssContent = generateScssContent(theme);
    
    // 3. 获取最新的 commit ID 作为父提交
    const getBranchParams = {
        repositoryName: REPOSITORY_NAME,
        branchName: BRANCH_NAME,
    };
    const branchData = await codecommitClient.send(new GetBranchCommand(getBranchParams));
    const parentCommitId = branchData.branch.commitId;

    if (!parentCommitId) {
      throw new Error(`Could not find a commit ID for branch: ${BRANCH_NAME}`);
    }

    // 4. 使用 PutFile 或 CreateCommit 创建新的提交
    // CreateCommit 提供了更丰富的选项,如作者信息
    const commitParams = {
      repositoryName: REPOSITORY_NAME,
      branchName: BRANCH_NAME,
      parentCommitId: parentCommitId,
      authorName: "Theme Automation Bot",
      email: "[email protected]",
      commitMessage: `[Auto-Theme] Update theme for ${siteId} via SNS`,
      putFiles: [{
        filePath: FILE_PATH,
        fileContent: Buffer.from(newScssContent), // 内容必须是 Buffer
        fileMode: "NORMAL", // 'NORMAL' or 'EXECUTABLE'
      }],
    };
    const commitResponse = await codecommitClient.send(new CreateCommitCommand(commitParams));
    console.log(JSON.stringify({ 
      message: "Successfully created commit", 
      commitId: commitResponse.commitId 
    }));

    // 5. 触发 CodeBuild 项目
    // 这里的关键是将新的 commit ID 传递给构建过程,确保构建的是我们刚刚提交的版本
    const startBuildParams = {
      projectName: CODEBUILD_PROJECT_NAME,
      sourceVersion: commitResponse.commitId,
    };
    const buildResponse = await codebuildClient.send(new StartBuildCommand(startBuildParams));
    console.log(JSON.stringify({
      message: "Successfully started CodeBuild project",
      buildId: buildResponse.build.id,
    }));

    return {
      statusCode: 200,
      body: JSON.stringify({
        message: "Theme update process completed successfully.",
        commitId: commitResponse.commitId,
        buildId: buildResponse.build.id,
      }),
    };

  } catch (err) {
    // 捕获所有潜在错误,详细记录,并抛出以便 Lambda 重试(如果配置了重试策略)
    console.error(JSON.stringify({
      level: "FATAL",
      message: "An unhandled error occurred in the theme updater lambda",
      errorMessage: err.message,
      errorStack: err.stack,
    }));
    // 重新抛出错误,触发 Lambda 的重试机制
    throw err;
  }
};

打包这个 Lambda 函数时,需要包含 package.json 并安装 @aws-sdk/client-codecommit, @aws-sdk/client-codebuildjoi

步骤三:Gatsby 项目与 CodeBuild 配置

Gatsby 项目侧的改造非常小。我们只需要确保有一个中心化的 SCSS 文件来导入这个动态生成的变量文件。

项目结构示例:

.
├── src
│   ├── components
│   ├── pages
│   └── styles
│       ├── abstracts
│       │   └── _variables.scss  <-- 这个文件由 Lambda 动态生成和覆盖
│       ├── base
│       └── main.scss
└── gatsby-config.js

src/styles/main.scss:

// 1. 导入由 Lambda 动态生成的变量文件
// 它的内容会根据 SNS 事件而改变
@import 'abstracts/variables';

// 2. 在整个项目的样式中安全地使用这些变量
body {
  font-family: $font-family;
  font-size: $base-font-size;
  color: $text-color;
  background-color: #f0f2f5;
}

.primary-button {
  background-color: $primary-color;
  color: white;
  
  &:hover {
    filter: brightness(110%);
  }
}

.secondary-text {
  color: $secondary-color;
}

接下来是 CodeBuild 的 buildspec.yml 文件。这是 CI/CD 流程的核心定义。

# buildspec.yml

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 18
    commands:
      - echo "Installing dependencies..."
      # 使用 npm ci 以获得更快、更可靠的构建
      - npm ci

  build:
    commands:
      - echo "Starting Gatsby build..."
      # GATSBY_CPU_COUNT=1 是在资源受限的 CI 环境中避免内存问题的常见技巧
      - GATSBY_CPU_COUNT=1 npm run build
      - echo "Gatsby build completed."

  post_build:
    commands:
      - echo "Syncing build output to S3..."
      # 将 public/ 目录下的所有文件同步到目标 S3 桶
      # --delete 选项会删除桶中存在但构建产物中不存在的文件
      - aws s3 sync public/ s3://${S3_BUCKET_NAME}/ --delete

artifacts:
  files: [] # 我们直接在 post_build 中部署,所以不需要 CodeBuild 收集产物

cache:
  paths:
    - 'node_modules/**/*'

将这个 buildspec.yml 文件放在 Gatsby 项目的根目录。在 AWS CodeBuild 的项目配置中,我们将 CodeCommit 仓库设置为主源,并配置它在 main 分支有新的提交时自动触发。

最终成果与测试

现在,整个自动化管道已经完成。我们可以通过 AWS CLI 来模拟一次主题更新事件,以测试端到端的流程。

  1. 创建一个 theme-payload.json 文件:

    {
      "siteId": "brand-alpha-campaign-q4",
      "theme": {
        "primary-color": "#C0392B",
        "secondary-color": "#2980B9",
        "text-color": "#2C3E50",
        "font-family": "'Roboto', sans-serif",
        "base-font-size": "16px"
      }
    }
  2. 使用 AWS CLI 发布消息到 SNS 主题:

    aws sns publish \
      --topic-arn "arn:aws:sns:us-east-1:123456789012:gatsby-theme-update-topic" \
      --message file://theme-payload.json \
      --region us-east-1

发布后,我们可以在 CloudWatch Logs 中看到 Lambda 函数被触发的日志,看到它成功验证数据、生成 SCSS 内容、创建新的 CodeCommit 提交。紧接着,CodeBuild 项目会自动启动,拉取包含新 _variables.scss 的代码,执行 gatsby build,最终将带有全新主题色的网站文件部署到 S3。整个过程在几分钟内自动完成。

局限性与未来迭代方向

尽管这个方案解决了最初的痛点,但在生产环境中,我们必须清醒地认识到它的局限性并规划未来的迭代。

  1. 构建性能与成本: 当前方案中,任何微小的主题变更(比如仅仅改变一个颜色值)都会触发一次完整的 gatsby build。对于大型站点,这可能耗时数分钟甚至更长,并且会产生相应的 CodeBuild 成本。一个优化方向是探索 Gatsby 的增量构建,但对于像全局 SCSS 变量这种会影响大量组件的变更,增量构建的效益有限。更根本的解决方案可能是转向支持服务端渲染 (SSR) 或增量静态再生 (ISR) 的框架,如 Next.js。

  2. 并发与竞态条件: 如果短时间内收到多个主题更新事件,可能会触发多个并行的构建任务。这不仅浪费资源,还可能导致旧的构建结果覆盖新的构建结果。一个改进方案是在 SNS 和 Lambda 之间引入一个 SQS FIFO (First-In-First-Out) 队列。Lambda 从 SQS 中拉取消息,并配置队列的 ContentBasedDeduplication 或使用消息组ID来确保同一站点的主题更新是串行处理的,甚至可以实现简单的去抖(debounce)逻辑。

  3. 缺乏预览机制: 当前流程是直接部署到生产环境,这对于市场或品牌团队来说风险较高。一个关键的迭代方向是建立一套预览流程。Lambda 函数可以被修改为:不直接提交到主分支,而是根据 siteId 创建一个新的特性分支(如 theme-preview/brand-alpha-q4),然后触发一个专门用于部署到预览环境的 CodeBuild 项目。只有当相关人员在预览环境中确认无误后,再通过一个 Pull Request 将变更合并到主分支,从而触发生产部署。

  4. 容错与回滚: 当前的回滚机制依赖于 Git。如果新主题有问题,我们需要手动 revert 那个自动生成的 commit 并重新部署。虽然可行,但不够快。可以构建一个更完善的“主题版本控制”系统,允许通过发布一个指向旧版本主题ID的SNS事件来快速回滚到任何一个历史版本。


  目录