我们的一个核心内容平台遇到了性能与更新频率的典型矛盾。它最初是一个完全由 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 应用现在已经具备了核心能力:
- 通过
ServeDir
高效地提供static
目录下的所有文件。 - 拥有一个
POST /_internal/regenerate
端点。 - 这个端点受到 Bearer Token 的保护,防止未经授权的访问。
- 它接收一个包含
slug
的 JSON,读取对应的 Markdown 文件,使用pulldown-cmark
和tera
将其渲染成 HTML,并原子性地覆盖static
目录下的旧文件。 - 包含了基本的日志和错误处理。
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
完成了以下工作:
- 通过
GenericTrigger
接收和解析 Webhook payload。 - 使用 JSONPath 表达式提取出
modified
,added
, 和removed
文件列表。 - 在脚本块中,过滤出需要处理的 Markdown 文件,并将文件路径转换为
slug
。 - 对于需要再生(修改或新增)的页面,并行地向 Axum 服务的
/_internal/regenerate
端点发送curl
请求。 - 使用 Jenkins 的凭据管理来安全地存储 Axum 服务的认证 Token,避免硬编码。
集成的考量与潜在陷阱
将这套系统投入生产环境,还需要注意几个细节:
- 网络隔离: Axum 应用监听的端口(例如 3000)应该只对内网或特定的 IP(如 Jenkins 服务器)开放。再生端点
/_internal/*
绝对不能暴露在公网上。可以使用反向代理(如 Nginx)来配置访问控制。 - 内容同步: Axum 应用本身如何获取最新的 Markdown 内容?在上面的例子中,我们简化地假设 Axum 应用可以直接访问
content/
目录。在真实环境中,这个content
目录需要与 Git 仓库同步。一种方案是在 Jenkins 触发再生请求之前,先执行一步git pull
在 Axum 服务器上的内容目录里。更好的方案是,Jenkins 在调用再生 API 时,直接将 Markdown 文件的内容作为 payload 的一部分传过去,这样 Axum 应用就无需关心 Git 操作,职责更单一。 - 错误处理与重试: 如果
curl
调用失败(例如,Axum 服务临时不可用),Jenkins 的构建应该标记为失败。可以配置 Jenkins 的重试机制来增加系统的鲁棒性。同时,Axum 应用的日志必须足够详尽,以便快速定位再生失败的原因。 - 并发控制: 如果短时间内有大量 commit,可能会同时触发多个再生请求。Axum 应用需要能够处理并发的读写操作。我们采用的“写入临时文件再重命名”的模式在 Unix-like 系统上是原子操作,这可以有效防止并发写入导致的文件损坏。
局限性与未来迭代方向
这套自建的 ISR 方案有效地解决了我们的痛点,但它并非完美。当前的实现是针对单体 Axum 应用的。如果我们的服务是水平扩展、部署在多个节点上的,问题就变得复杂了。Jenkins 需要向每一个节点都发送再生请求,这增加了管理的复杂性。在这种场景下,一个更优的架构是让静态文件存放在共享存储上,比如 NFS、GlusterFS 或者对象存储(如 S3)。Axum 应用从共享存储提供文件,而再生任务只需要更新共享存储中的一份文件即可。
另一个演进方向是进一步解耦。可以引入一个轻量级的消息队列(如 Redis Streams 或 RabbitMQ)。Jenkins 监听到 Git push 后,不再直接调用 Axum API,而是向队列中推送一个“再生任务”消息。Axum 应用或其他专门的 Worker 服务可以订阅这个队列,异步地处理再生任务。这使得系统更具弹性,能够削峰填谷,处理突发的大量内容更新,并且让 Jenkins 和 Axum 之间的耦合度变得更低。