构建基于 Git Webhook 驱动的 Axum 增量静态再生自动化流水线


我们的一个核心内容平台遇到了性能与更新频率的典型矛盾。它最初是一个完全由 Axum 实现的动态服务,通过服务端渲染(SSR)直接从数据库中拉取 Markdown 内容并转换为 HTML。随着流量增长,即便有缓存,数据库的压力和渲染开销也变得不可忽视。转向完全的静态站点生成(SSG)是显而易见的下一步,但这引入了新的问题:每一次微小的文本修改,比如修正一个错别字,都需要在 Jenkins 上触发一次完整的构建、测试和部署流程,这个过程耗时接近十分钟。对于一个每天需要更新数十次内容的团队来说,这种延迟是无法接受的。

我们需要的是一种兼具 SSG 性能和 SSR 灵活性的混合模式,即增量静态再生(Incremental Static Regeneration, ISR)。然而,我们并没有使用 Vercel 或 Netlify 这类平台,整个技术栈是自托管的,核心是 Rust (Axum) 和 Jenkins。挑战在于,如何在我们现有的、略显“传统”的 CI/CD 体系上,为高性能的 Rust 应用实现一个高效、可靠的 ISR 机制。

初步构想是解耦内容变更和应用部署。应用本身应该是一个长期运行的、稳定的服务。内容的更新,应该通过一种轻量级的、外部触发的机制来完成,而不是每次都重新部署整个应用。Git 自然成了这个流程的起点。我们的内容以 Markdown 文件的形式存储在一个独立的 Git 仓库中。理想的流程是:当内容编辑者向这个仓库推送 commit 时,一个 Webhook 应该被触发,最终只重新生成被修改的那个页面的静态 HTML 文件,并替换掉服务器上的旧文件。整个过程应该在秒级完成。

架构设计与技术权衡

核心决策是将 Axum 应用的角色一分为二:它既是面向公众的静态文件服务器,也是一个内部的、按需内容再生器。Jenkins 则扮演着粘合剂的角色,监听 Git 事件并调用 Axum 的内部再生接口。

sequenceDiagram
    participant Editor as 内容编辑者
    participant GitRepo as Git 内容仓库
    participant Jenkins as Jenkins 服务器
    participant AxumApp as Axum 应用

    Editor->>+GitRepo: git push (修改 content.md)
    GitRepo-->>+Jenkins: 触发 Webhook
    Jenkins->>Jenkins: 解析 payload, 发现修改了 content.md
    Jenkins->>+AxumApp: POST /_internal/regenerate (携带slug和token)
    AxumApp->>AxumApp: 验证Token
    AxumApp->>AxumApp: 拉取最新 content.md
    AxumApp->>AxumApp: 渲染 HTML
    AxumApp->>AxumApp: 覆盖 /static/content.html
    AxumApp-->>-Jenkins: 返回 200 OK
    Jenkins-->>-GitRepo: 响应 Webhook

这个架构的关键点在于 Axum 应用需要一个特殊的、受保护的内部 API 端点。这个端点不能暴露给公网,它的唯一职责就是接收来自 Jenkins 的指令,执行单个页面的重建。

Phase 1: 具备静态服务与再生能力的 Axum 应用

我们首先需要一个能提供基本静态文件服务,并包含再生逻辑的 Axum 应用。项目结构大致如下:

.
├── Cargo.toml
├── content/          // 模拟的内容源
│   └── hello-world.md
├── static/           // 预构建或再生后存放 HTML 的地方
│   └── hello-world.html
└── src/
    └── main.rs

Cargo.toml 依赖项:

[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.4", features = ["fs", "trace"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
pulldown-cmark = "0.9"
tera = "1.19"
anyhow = "1.0"
headers = "0.3"
hyper = "0.14"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

核心的 src/main.rs 文件需要处理两种请求:对外的页面访问和对内的再生请求。

use axum::{
    async_trait,
    extract::{FromRequestParts, State},
    http::{header, Request, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::{get_service, post},
    Json, Router,
};
use headers::{authorization::Bearer, Authorization};
use serde::Deserialize;
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use tera::{Context, Tera};
use tokio::fs;
use tower_http::services::ServeDir;
use tracing::{error, info};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

// 用于承载应用状态,如模板引擎和配置
#[derive(Clone)]
struct AppState {
    templates: Arc<Tera>,
    config: Arc<AppConfig>,
}

struct AppConfig {
    static_dir: PathBuf,
    content_dir: PathBuf,
    auth_token: String,
}

// 재생 요청의 페이로드
#[derive(Deserialize)]
struct RegeneratePayload {
    slug: String,
}

// 主函数,设置路由和服务
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            "info,tower_http=debug",
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    let static_dir = PathBuf::from("./static");
    let content_dir = PathBuf::from("./content");
    
    // 从环境变量中读取认证令牌,这是生产实践中的基本要求
    let auth_token = std::env::var("REGEN_AUTH_TOKEN")
        .expect("REGEN_AUTH_TOKEN must be set for security");

    let config = Arc::new(AppConfig {
        static_dir: static_dir.clone(),
        content_dir,
        auth_token,
    });

    // 初始化 Tera 模板引擎
    let tera = Tera::new("templates/**/*.html")?;
    let app_state = AppState {
        templates: Arc::new(tera),
        config,
    };

    let app = Router::new()
        .route("/_internal/regenerate", post(handle_regenerate))
        .route_layer(middleware::from_fn_with_state(
            app_state.clone(),
            auth_middleware,
        ))
        .fallback_service(get_service(ServeDir::new(static_dir)).handle_error(
            |error| async move {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("Failed to serve static file: {}", error),
                )
            },
        ))
        .with_state(app_state);

    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    info!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

// 核心的再生处理逻辑
async fn handle_regenerate(
    State(state): State<AppState>,
    Json(payload): Json<RegeneratePayload>,
) -> impl IntoResponse {
    info!("Received regeneration request for slug: {}", payload.slug);

    let slug = &payload.slug;
    let md_path = state.config.content_dir.join(format!("{}.md", slug));
    let html_path = state.config.static_dir.join(format!("{}.html", slug));

    match fs::read_to_string(&md_path).await {
        Ok(md_content) => {
            let parser = pulldown_cmark::Parser::new(&md_content);
            let mut html_output = String::new();
            pulldown_cmark::html::push_html(&mut html_output, parser);

            let mut context = Context::new();
            context.insert("content", &html_output);
            context.insert("title", slug); // 简单起见,用 slug 做标题

            match state.templates.render("page.html", &context) {
                Ok(rendered_html) => {
                    // 这里的写入操作需要更健壮。在真实项目中,应该先写入临时文件,然后原子性地重命名
                    // 这样可以防止用户在写入过程中请求到一个不完整的文件
                    let temp_path = html_path.with_extension("html.tmp");
                    if let Err(e) = fs::write(&temp_path, rendered_html).await {
                        error!("Failed to write temporary HTML file: {}", e);
                        return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to write temp file".to_string());
                    }
                    if let Err(e) = fs::rename(&temp_path, &html_path).await {
                        error!("Failed to rename temp file to final HTML: {}", e);
                        return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to finalize file".to_string());
                    }
                    info!("Successfully regenerated page: {}", slug);
                    (StatusCode::OK, "Page regenerated".to_string())
                }
                Err(e) => {
                    error!("Failed to render template for slug {}: {}", slug, e);
                    (
                        StatusCode::INTERNAL_SERVER_ERROR,
                        "Template rendering failed".to_string(),
                    )
                }
            }
        }
        Err(e) => {
            error!("Failed to read markdown file for slug {}: {}", slug, e);
            (
                StatusCode::NOT_FOUND,
                format!("Markdown content not found for slug: {}", slug),
            )
        }
    }
}

// 认证中间件,保护内部 API
async fn auth_middleware<B>(
    State(state): State<AppState>,
    request: Request<B>,
    next: Next<B>,
) -> Result<Response, StatusCode> {
    let auth_header = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|header| header.to_str().ok());

    if let Some(auth_header) = auth_header {
        if auth_header.starts_with("Bearer ") {
            let token = &auth_header["Bearer ".len()..];
            if token == state.config.auth_token {
                return Ok(next.run(request).await);
            }
        }
    }
    
    error!("Authentication failed for internal endpoint");
    Err(StatusCode::UNAUTHORIZED)
}

// `templates/page.html` 示例
/*
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{{ title }}</title>
</head>
<body>
    <article>
        {{ content | safe }}
    </article>
</body>
</html>
*/

这个 Axum 应用现在已经具备了核心能力:

  1. 通过 ServeDir 高效地提供 static 目录下的所有文件。
  2. 拥有一个 POST /_internal/regenerate 端点。
  3. 这个端点受到 Bearer Token 的保护,防止未经授权的访问。
  4. 它接收一个包含 slug 的 JSON,读取对应的 Markdown 文件,使用 pulldown-cmarktera 将其渲染成 HTML,并原子性地覆盖 static 目录下的旧文件。
  5. 包含了基本的日志和错误处理。

Phase 2: 配置 Jenkins 流水线

接下来是 Jenkins 的部分。我们需要创建一个流水线,它由 Git 仓库的 Webhook 触发。这里我们使用 Jenkins 的 Generic Webhook Trigger 插件,它非常灵活,可以从 Webhook 的 JSON payload 中提取信息。

假设我们的 Git 仓库在 push 事件时发送如下格式的 payload (以 GitHub 为例简化):

{
  "ref": "refs/heads/main",
  "commits": [
    {
      "id": "...",
      "modified": ["docs/new-feature.md"],
      "added": ["docs/another-post.md"],
      "removed": ["docs/old-page.md"]
    }
  ]
}

我们的 Jenkinsfile 需要解析这个 payload,找出所有变更的 .md 文件,然后分别调用 Axum 的再生或清理接口。

// Jenkinsfile

pipeline {
    agent any

    // 使用 Generic Webhook Trigger 插件
    // 配置:Post content parameters -> a single JSON parameter `payload`
    //       Token -> a secret token to verify the webhook source
    triggers {
        GenericTrigger(
            genericVariables: [
                [key: 'ref', value: '$.ref'],
                [key: 'modifiedFiles', value: '$.commits[0].modified', a_single_json_parameter: 'payload'],
                [key: 'addedFiles', value: '$.commits[0].added', a_single_json_parameter: 'payload'],
                [key: 'removedFiles', value: '$.commits[0].removed', a_single_json_parameter: 'payload']
            ],
            token: 'YOUR_JENKINS_WEBHOOK_SECRET', // 与 Git Webhook 配置中一致
            printPostContent: true,
            printContributedVariables: true
        )
    }

    environment {
        // 将 Axum 应用的地址和认证 Token 存储在 Jenkins 的凭证管理器中
        AXUM_APP_URL = 'http://your-axum-app.internal:3000'
        AXUM_AUTH_TOKEN = credentials('axum-regeneration-token')
    }

    stages {
        stage('Parse Changed Files') {
            steps {
                script {
                    // Jenkins 的 JSON 解析可能有些笨拙,这里我们直接处理字符串
                    def allChanges = []
                    // Groovy 的 GJson 对这类操作支持得很好
                    def payload = readJSON text: params.payload
                    
                    def modified = payload.commits[0].modified ?: []
                    def added = payload.commits[0].added ?: []
                    def removed = payload.commits[0].removed ?: []
                    
                    def toRegenerate = (modified + added).unique()
                    def toDelete = removed.unique()

                    // 过滤出我们关心的 Markdown 文件
                    def mdToRegenerate = toRegenerate.findAll { it.endsWith('.md') && it.startsWith('content/') }
                    def mdToDelete = toDelete.findAll { it.endsWith('.md') && it.startsWith('content/') }
                    
                    // 将文件名转换为 slug
                    def slugsToRegenerate = mdToRegenerate.collect { it.minus('content/').minus('.md') }
                    def slugsToDelete = mdToDelete.collect { it.minus('content/').minus('.md') }

                    // 使用 stash 在不同 stage 间传递数据
                    stash name: 'slugs', includes: '', allowEmpty: true, encoding: 'UTF-8',
                          write: [
                            [path: 'regenerate.txt', text: slugsToRegenerate.join('\n')],
                            [path: 'delete.txt', text: slugsToDelete.join('\n')]
                          ]
                }
            }
        }

        stage('Trigger Regeneration') {
            steps {
                script {
                    unstash 'slugs'
                    def slugs = readFile('regenerate.txt').tokenize('\n')
                    
                    if (slugs.isEmpty()) {
                        echo "No pages to regenerate."
                    } else {
                        // 并行触发再生,提高效率
                        def tasks = [:]
                        for (slug in slugs) {
                            def currentSlug = slug.trim()
                            if (currentSlug) {
                                tasks[currentSlug] = {
                                    echo "Triggering regeneration for: ${currentSlug}"
                                    sh """
                                        curl -s -o /dev/null -w "%{http_code}" -X POST \\
                                             -H "Content-Type: application/json" \\
                                             -H "Authorization: Bearer ${AXUM_AUTH_TOKEN}" \\
                                             -d '{"slug": "${currentSlug}"}' \\
                                             "${AXUM_APP_URL}/_internal/regenerate"
                                    """
                                }
                            }
                        }
                        parallel tasks
                    }
                }
            }
        }
        
        // 为了完整性,我们应该还有一个处理删除的 stage
        // 这需要在 Axum 应用中实现一个 /_internal/purge 端点
        stage('Trigger Deletion') {
            steps {
                script {
                    // 此处省略,逻辑与 Regeneration 类似,但调用 DELETE 方法或不同的端点
                    unstash 'slugs'
                    def slugs = readFile('delete.txt').tokenize('\n')
                    if (!slugs.isEmpty()) {
                        echo "Deletion logic not implemented yet. Slugs to delete: ${slugs}"
                    }
                }
            }
        }
    }
}

这个 Jenkinsfile 完成了以下工作:

  1. 通过 GenericTrigger 接收和解析 Webhook payload。
  2. 使用 JSONPath 表达式提取出 modified, added, 和 removed 文件列表。
  3. 在脚本块中,过滤出需要处理的 Markdown 文件,并将文件路径转换为 slug
  4. 对于需要再生(修改或新增)的页面,并行地向 Axum 服务的 /_internal/regenerate 端点发送 curl 请求。
  5. 使用 Jenkins 的凭据管理来安全地存储 Axum 服务的认证 Token,避免硬编码。

集成的考量与潜在陷阱

将这套系统投入生产环境,还需要注意几个细节:

  1. 网络隔离: Axum 应用监听的端口(例如 3000)应该只对内网或特定的 IP(如 Jenkins 服务器)开放。再生端点 /_internal/* 绝对不能暴露在公网上。可以使用反向代理(如 Nginx)来配置访问控制。
  2. 内容同步: Axum 应用本身如何获取最新的 Markdown 内容?在上面的例子中,我们简化地假设 Axum 应用可以直接访问 content/ 目录。在真实环境中,这个 content 目录需要与 Git 仓库同步。一种方案是在 Jenkins 触发再生请求之前,先执行一步 git pull 在 Axum 服务器上的内容目录里。更好的方案是,Jenkins 在调用再生 API 时,直接将 Markdown 文件的内容作为 payload 的一部分传过去,这样 Axum 应用就无需关心 Git 操作,职责更单一。
  3. 错误处理与重试: 如果 curl 调用失败(例如,Axum 服务临时不可用),Jenkins 的构建应该标记为失败。可以配置 Jenkins 的重试机制来增加系统的鲁棒性。同时,Axum 应用的日志必须足够详尽,以便快速定位再生失败的原因。
  4. 并发控制: 如果短时间内有大量 commit,可能会同时触发多个再生请求。Axum 应用需要能够处理并发的读写操作。我们采用的“写入临时文件再重命名”的模式在 Unix-like 系统上是原子操作,这可以有效防止并发写入导致的文件损坏。

局限性与未来迭代方向

这套自建的 ISR 方案有效地解决了我们的痛点,但它并非完美。当前的实现是针对单体 Axum 应用的。如果我们的服务是水平扩展、部署在多个节点上的,问题就变得复杂了。Jenkins 需要向每一个节点都发送再生请求,这增加了管理的复杂性。在这种场景下,一个更优的架构是让静态文件存放在共享存储上,比如 NFS、GlusterFS 或者对象存储(如 S3)。Axum 应用从共享存储提供文件,而再生任务只需要更新共享存储中的一份文件即可。

另一个演进方向是进一步解耦。可以引入一个轻量级的消息队列(如 Redis Streams 或 RabbitMQ)。Jenkins 监听到 Git push 后,不再直接调用 Axum API,而是向队列中推送一个“再生任务”消息。Axum 应用或其他专门的 Worker 服务可以订阅这个队列,异步地处理再生任务。这使得系统更具弹性,能够削峰填谷,处理突发的大量内容更新,并且让 Jenkins 和 Axum 之间的耦合度变得更低。


  目录