利用CDC与Docker Swarm实现由MariaDB驱动的Gatsby站点增量构建


静态站点生成器(SSG)如 Gatsby,其核心优势在于预构建带来的极致性能。但这也引入了一个固有的矛盾:内容的任何微小变更,理论上都需要触发一次完整的站点构建与部署流程。当内容源是数据库时,这一矛盾尤为突出。轮询数据库检查变更是一种低效且不优雅的方案,而手动触发构建则完全违背了自动化的初衷。

我们面临的挑战是:如何让 Gatsby 站点能够“感知”到后端 MariaDB 数据库中数据的实时变化,并以一种高效、事件驱动的方式,仅仅对变更内容触发一次增量的构建与部署?这要求我们构建一个从数据库事务日志到CI/CD管道的端到端数据流。

架构构想与技术决策

要解决这个问题,核心在于捕获数据库的变更事件。Change Data Capture (CDC) 是为此而生的技术。我们将采用 Debezium,一个顶级的开源分布式 CDC 平台。它通过读取 MariaDB 的二进制日志(binary log),将行级别的 INSERTUPDATEDELETE 操作转化为结构化的事件流。

这个事件流需要一个可靠的载体,Apache Kafka 是不二之选。它能为我们提供事件的持久化、削峰填谷以及消费者解耦的能力。

事件有了,谁来消费并执行动作?我们需要一个轻量级的服务,它订阅 Kafka 中的变更主题,解析事件,然后根据预设逻辑调用 GitHub API 来触发一个特定的 GitHub Actions 工作流。考虑到性能和部署简易性,我们将使用 Go 语言编写这个消费-触发器服务。

最后,所有这些中间件(MariaDB, Zookeeper, Kafka, Debezium Connect)和我们的自定义服务都需要被可靠地部署和管理。相比于 Kubernetes 的复杂性,对于这样一个中等规模的后台系统,Docker Swarm 提供了恰到好处的编排能力,配置简单且资源占用更低。

整个数据流动的路径因此变得清晰:

  1. 业务应用向 MariaDB 写入数据。
  2. MariaDB 将变更写入其 binary log。
  3. Debezium 的 MariaDB Connector 监控 binary log,捕获变更。
  4. Debezium 将变更事件发布到 Kafka 的指定主题中。
  5. 我们的 Go 服务 (cdc-trigger) 消费 Kafka 主题中的事件。
  6. cdc-trigger 解析事件,判断是否需要触发构建,并调用 GitHub API。
  7. GitHub Actions 接收到 repository_dispatch 事件,启动 Gatsby 增量构建和部署流程。
graph LR
    subgraph Docker Swarm Cluster
        A[MariaDB] -- Binlog --> B[Debezium Connect];
        B -- Produces Event --> C[Kafka];
        D[cdc-trigger Service] -- Consumes Event --> C;
    end

    subgraph External Services
        E[GitHub API];
        F[GitHub Actions];
    end

    U(User/Application) -- Writes Data --> A;
    D -- Triggers Workflow --> E;
    E -- Dispatches Event --> F;
    F -- Builds & Deploys --> G[Gatsby Site];

    style A fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#9cf,stroke:#333,stroke-width:2px
    style D fill:#f96,stroke:#333,stroke-width:2px
    style F fill:#ff9,stroke:#333,stroke-width:2px

第一步:环境编排 - Docker Swarm 与基础设施配置

我们需要一个 docker-compose.yml 文件来定义整个技术栈。在真实项目中,配置、密钥等都应该使用 Docker Secrets 进行管理,这里为了演示清晰,我们直接写入文件。

# docker-compose.yml
version: '3.8'

services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.3.0
    networks:
      - internal-net
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
    deploy:
      mode: replicated
      replicas: 1
      placement:
        constraints: [node.role == manager]

  kafka:
    image: confluentinc/cp-kafka:7.3.0
    networks:
      - internal-net
    depends_on:
      - zookeeper
    ports:
      - "9092:9092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
    deploy:
      mode: replicated
      replicas: 1

  mariadb:
    image: mariadb:10.6
    networks:
      - internal-net
    environment:
      MYSQL_ROOT_PASSWORD: 'rootpassword'
      MYSQL_DATABASE: 'gatsby_content'
      MYSQL_USER: 'user'
      MYSQL_PASSWORD: 'password'
    volumes:
      - mariadb_data:/var/lib/mysql
      - ./mariadb_config/custom.cnf:/etc/mysql/conf.d/custom.cnf
    deploy:
      mode: replicated
      replicas: 1

  connect:
    image: debezium/connect:2.1
    networks:
      - internal-net
    depends_on:
      - kafka
      - mariadb
    ports:
      - "8083:8083"
    environment:
      BOOTSTRAP_SERVERS: 'kafka:29092'
      GROUP_ID: 1
      CONFIG_STORAGE_TOPIC: 'debezium_connect_configs'
      OFFSET_STORAGE_TOPIC: 'debezium_connect_offsets'
      STATUS_STORAGE_TOPIC: 'debezium_connect_status'
      # Debezium MariaDB connector JAR is included in debezium/connect image
    deploy:
      mode: replicated
      replicas: 1

  cdc-trigger:
    # We will build this image locally later
    image: cdc-trigger:latest
    networks:
      - internal-net
    depends_on:
      - connect
    environment:
      KAFKA_BROKERS: 'kafka:29092'
      KAFKA_TOPIC: 'dbserver1.gatsby_content.products' # Matches Debezium topic naming convention
      GITHUB_TOKEN: 'your_github_pat'
      GITHUB_REPO_OWNER: 'your_username'
      GITHUB_REPO_NAME: 'your_gatsby_repo'
      LOG_LEVEL: 'info'
    deploy:
      mode: replicated
      replicas: 1

volumes:
  mariadb_data:

networks:
  internal-net:
    driver: overlay

这里的关键是 MariaDB 的配置。Debezium 需要启用 binary logging (binlog_format=ROW) 并且有一个拥有足够权限的用户。

./mariadb_config/custom.cnf:

# MariaDB configuration for Debezium
[mysqld]
server-id                = 223344
log-bin                  = mysql-bin
binlog_format            = ROW
binlog_row_image         = FULL
expire_logs_days         = 10
gtid_mode                = on
enforce_gtid_consistency = on
log_slave_updates        = 1

在部署之前,需要初始化 Docker Swarm (docker swarm init)。然后使用 docker stack deploy -c docker-compose.yml cdc_stack 来部署整个服务栈。

第二步:配置 Debezium Connector

connect 服务启动后,它的 REST API 在端口 8083 上可用。我们需要通过这个 API 来注册我们的 MariaDB connector。

创建一个 JSON 配置文件 register-mariadb.json:

{
  "name": "gatsby-mariadb-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "tasks.max": "1",
    "database.hostname": "mariadb",
    "database.port": "3306",
    "database.user": "root",
    "database.password": "rootpassword",
    "database.server.id": "184054",
    "database.server.name": "dbserver1",
    "database.include.list": "gatsby_content",
    "table.include.list": "gatsby_content.products",
    "database.history.kafka.bootstrap.servers": "kafka:29092",
    "database.history.kafka.topic": "dbhistory.gatsby",
    "include.schema.changes": "false",
    "decimal.handling.mode": "double",
    "snapshot.mode": "initial"
  }
}

配置解析:

  • connector.class: 使用 Debezium 的 MySQL Connector,它兼容 MariaDB。
  • database.server.name: dbserver1,这将成为 Kafka 主题的前缀,最终主题会是 dbserver1.gatsby_content.products
  • database.include.listtable.include.list: 精确指定我们只关心 gatsby_content 数据库中的 products 表。在真实项目中,这是必须的性能优化,避免监听不相关的表变更。
  • database.history.kafka.topic: Debezium 用它来存储数据库 schema 的历史记录,这是容错所必需的。

使用 curl 注册这个 connector:

curl -i -X POST -H "Accept:application/json" -H "Content-Type:application/json" \
localhost:8083/connectors/ -d @register-mariadb.json

如果一切顺利,你应该会收到 201 Created 响应。此时,Debezium 已经开始监控 products 表了。

第三步:核心逻辑 - Go 消费触发器服务 (cdc-trigger)

这是连接事件源和 CI/CD 的桥梁。它的职责单一且明确:消费消息、解析、调用 API。我们将使用 segmentio/kafka-go 库来处理 Kafka 消费。

main.go:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/google/go-github/v48/github"
	"github.com/segmentio/kafka-go"
	"golang.org/x/oauth2"
)

// Config holds all configuration for the application
type Config struct {
	KafkaBrokers  []string
	KafkaTopic    string
	GithubToken   string
	GithubOwner   string
	GithubRepo    string
}

// DebeziumMessage represents the structure of a Debezium CDC event
type DebeziumMessage struct {
	Payload struct {
		Op string `json:"op"` // c = create, u = update, d = delete
		After *json.RawMessage `json:"after"`
		Before *json.RawMessage `json:"before"`
	} `json:"payload"`
}

func main() {
	cfg := loadConfig()
	ctx := context.Background()

	// Setup Kafka Reader
	reader := kafka.NewReader(kafka.ReaderConfig{
		Brokers:        cfg.KafkaBrokers,
		Topic:          cfg.KafkaTopic,
		GroupID:        "gatsby-build-trigger-group",
		MinBytes:       10e3, // 10KB
		MaxBytes:       10e6, // 10MB
		CommitInterval: time.Second,
	})
	defer reader.Close()

	// Setup GitHub Client
	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cfg.GithubToken})
	tc := oauth2.NewClient(ctx, ts)
	githubClient := github.NewClient(tc)

	log.Println("Starting CDC event consumer...")

	for {
		msg, err := reader.FetchMessage(ctx)
		if err != nil {
			log.Printf("Error fetching message: %v", err)
			continue
		}

		log.Printf("Received message from topic %s, partition %d, offset %d", msg.Topic, msg.Partition, msg.Offset)

		var debeziumMsg DebeziumMessage
		if err := json.Unmarshal(msg.Value, &debeziumMsg); err != nil {
			log.Printf("Error unmarshalling message: %v. Message: %s", err, string(msg.Value))
			// Acknowledge message even if it's malformed to avoid processing loop
			reader.CommitMessages(ctx, msg)
			continue
		}
		
		// The core logic: we only care that *something* changed.
		// A more complex implementation could extract changed IDs for more granular builds.
		if debeziumMsg.Payload.Op == "c" || debeziumMsg.Payload.Op == "u" || debeziumMsg.Payload.Op == "d" {
			log.Printf("Change detected (op: %s). Triggering GitHub Actions workflow.", debeziumMsg.Payload.Op)
			
			err := triggerWorkflow(ctx, githubClient, cfg)
			if err != nil {
				log.Printf("Failed to trigger workflow: %v", err)
				// Do not commit message, so we can retry
				continue 
			}
		}

		// Acknowledge the message to Kafka
		if err := reader.CommitMessages(ctx, msg); err != nil {
			log.Printf("Failed to commit messages: %v", err)
		}
	}
}

func triggerWorkflow(ctx context.Context, client *github.Client, cfg Config) error {
	// `repository_dispatch` is a special event type for triggering workflows externally.
	eventRequest := &github.RepositoryDispatchEventRequest{
		EventType: "db_update_event", // This name must match the one in the workflow file
		ClientPayload: json.RawMessage(`{"source": "cdc-pipeline"}`),
	}

	_, resp, err := client.Repositories.Dispatch(ctx, cfg.GithubOwner, cfg.GithubRepo, *eventRequest)
	
	if err != nil {
		return fmt.Errorf("dispatching event failed: %w", err)
	}

	if resp.StatusCode != http.StatusNoContent {
		return fmt.Errorf("unexpected status code from GitHub API: %d", resp.StatusCode)
	}

	log.Printf("Successfully dispatched 'repository_dispatch' event to %s/%s", cfg.GithubOwner, cfg.GithubRepo)
	return nil
}


func loadConfig() Config {
	// In a production system, use a more robust config library or secret management.
	token := os.Getenv("GITHUB_TOKEN")
	if token == "" {
		log.Fatal("GITHUB_TOKEN environment variable not set")
	}

	return Config{
		KafkaBrokers:  strings.Split(os.Getenv("KAFKA_BROKERS"), ","),
		KafkaTopic:    os.Getenv("KAFKA_TOPIC"),
		GithubToken:   token,
		GithubOwner:   os.Getenv("GITHUB_REPO_OWNER"),
		GithubRepo:    os.Getenv("GITHUB_REPO_NAME"),
	}
}

Dockerfile for this service:

FROM golang:1.19-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# Build the binary with optimizations
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o cdc-trigger .

FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/cdc-trigger .
# Add ca-certificates for TLS communication with GitHub API
RUN apk --no-cache add ca-certificates

CMD ["./cdc-trigger"]

构建镜像: docker build -t cdc-trigger:latest .

这个 Go 服务的错误处理逻辑很简单:如果触发 GitHub Actions 失败,它不会提交 Kafka 消息的 offset。这意味着当服务重启或重连后,它会重新消费这条消息,提供了一种简单的“至少一次”投递保障。

第四步:配置 GitHub Actions 工作流

最后一步是在 Gatsby 项目的仓库中创建一个工作流,它会响应我们服务发出的 repository_dispatch 事件。

.github/workflows/incremental-build.yml:

name: Incremental Build on DB Update

on:
  repository_dispatch:
    types: [db_update_event] # Must match the EventType in our Go service

jobs:
  build-and-deploy:
    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'
          cache: 'npm' # Cache npm dependencies

      - name: Log event payload
        run: echo "Workflow triggered by event: ${{ toJSON(github.event.client_payload) }}"

      - name: Install dependencies
        run: npm install

      - name: Build Gatsby site
        # GATSBY_INCREMENTAL_BUILDING is an internal flag Gatsby might use.
        # The key is that Gatsby's caching mechanism should be enabled by default.
        # We also need to configure Gatsby to fetch data from our MariaDB.
        run: npm run build
        env:
          # Pass database credentials securely via GitHub Secrets
          DB_HOST: ${{ secrets.DB_HOST }}
          DB_USER: ${{ secrets.DB_USER }}
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
          DB_NAME: ${{ secrets.DB_NAME }}

      - name: Deploy to GitHub Pages (or any other provider)
        # This is an example deployment step.
        # Replace with your actual deployment logic (e.g., to S3, Netlify, etc.)
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public

这里的关键是 on.repository_dispatch.types 必须精确匹配 Go 服务中发送的 EventType。Gatsby 的增量构建能力依赖其内部缓存,只要 .cachepublic 目录被保留,后续的 npm run build 会自动进行增量构建。GitHub Actions 的缓存功能 (actions/cache) 对 node_modules 和 Gatsby 的 .cache 目录进行缓存,是实现高效构建的关键。

方案的局限性与未来迭代路径

这套架构虽然实现了功能闭环,但在生产环境中应用前,必须正视其局限性:

  1. 构建风暴(Thundering Herd): 如果短时间内有大量数据库写入(例如,批量导入1000条记录),当前的 cdc-trigger 服务会连续触发1000次构建请求。这是一个灾难。一个必要的改进是在 Go 服务中实现“防抖”(Debouncing)或“节流”(Throttling)逻辑。例如,在收到第一个事件后,启动一个5分钟的计时器,在此期间收到的所有后续事件只重置计时器而不触发构建。只有当计时器结束后,才触发一次构建。

  2. 构建粒度粗糙: 当前方案只能告知 Gatsby “有东西变了”,而不能告知“是哪个产品ID变了”。Gatsby 仍需全量拉取数据来确定差异。更高级的方案是,cdc-trigger 可以解析出变更记录的ID,并通过 client_payload 将这些ID传递给 GitHub Actions。Gatsby 的 gatsby-node.js 文件随后可以读取这些ID,只为相关的页面重新生成查询和页面,从而实现真正的、粒度更细的增量构建。

  3. 中间件运维成本: Zookeeper, Kafka, Debezium Connect 组成了一个不小的分布式系统,它们自身的维护、监控和故障排查都需要投入精力。对于数据变更不频繁或对延迟不敏感的小型项目,这套方案可能过于复杂。评估业务需求与运维成本的平衡是技术选型时必须考虑的。

  4. 容错与一致性: Debezium 和 Kafka 提供了强大的“至少一次”投递保障,但整个链路的端到端一致性需要更精细的设计。例如,如果 GitHub Actions 构建失败,事件是否需要被重新处理?这可能需要引入一个持久化的任务队列和状态机来管理构建任务的生命周期,而不是简单地依赖于 Kafka 的消息重试。


  目录