[post] 从一次博客部署,到一套可复用的服务器初始化方案

📅 2026-01-10

🔄 2026-01-10

⌚ Reading time: 3 min

🏷️


0. 引言:系统整体工作流程(全景视角)

在这套博客系统中,刻意把写作、构建和部署拆分成了几个彼此解耦的阶段。日常内容的开发只发生在 GitHub 的 hugodev 分支,这个分支只包含 Hugo 源码,用来承载写作本身,而不关心任何部署形态。每当 hugodev 有新的提交,GitHub Actions 会自动触发构建流程,将源码生成纯静态文件,并把结果推送到 pub 分支。pub 分支的定位非常明确,它不是给人编辑的,而是作为一个稳定、可被机器直接使用的部署接口存在。

为了让整个流程在国内网络环境下更加稳定,服务器并不直接从 GitHub 获取构建产物,而是通过 Gitee 对 pub 分支进行镜像同步。服务器端不参与任何构建过程,也不需要安装 Hugo 或 CI 工具,它只以固定节奏从 Gitee 拉取 pub 分支的最新内容,并在内容发生变化后 reload nginx,使更新对外生效。整个服务器侧的行为被刻意压缩为三件事:定时同步、静态托管、对外服务。

下面这张图展示了这套流程的完整链路,以及各个阶段之间的边界关系:

                ┌────────────────────┐
                │   Local Writing    │
(Markdown / Hugo)                └─────────┬──────────┘
                          │ git push
                ┌────────────────────┐
                │   GitHub Repo      │
                │   hugodev branch   │
(source only)                └─────────┬──────────┘
                          │ GitHub Actions
(build)
                ┌────────────────────┐
                │   GitHub Repo      │
                │     pub branch     │
(static artifacts)                └─────────┬──────────┘
                          │ mirror sync
                ┌────────────────────┐
                │       Gitee        │
                │     pub branch     │
(domestic mirror)                └─────────┬──────────┘
                          │ cron (pull)
        ┌────────────────────────────────┐
        │            Server              │
        │                                │
        │  cron → git pull (pub)        │        → nginx reload          │
        │                                │
(no build tools, no CI)        └────────────────────────────────┘

这种拆分方式带来的直接结果是,构建环境与运行环境完全分离。博客的生成逻辑被固定在 CI 中,而服务器只面对已经生成好的静态文件。即使将来不再使用 Hugo,或更换为其他静态站点生成器,这套流程中真正需要调整的,也只是构建阶段;服务器侧的逻辑可以保持不变。同样地,当服务器需要迁移或重装时,也不需要关心构建工具链,只要能够从仓库获取 pub 分支并提供静态服务即可。

最终形成的结构是,写作发生在开发分支 hugodev,构建发生在 CI,分发由仓库镜像承担,而服务器只负责运行。每一层的职责都被刻意限制在最小范围内,这不仅降低了单点复杂度,也让系统在面对工具替换、环境变化或时间流逝时,依然能够保持清晰和可控。

在这套流程最终稳定下来之后,服务器上的站点目录结构也变得非常简单:

/var/www/xym-ee.github.io/
├── index.html
├── sync.log
└── tools/
    ├── blog_env_init.sh
    └── sync_from_gitee.sh

index.html 是构建完成后的静态入口文件,sync.log 用于记录服务器侧的自动同步行为,而 tools 目录则只放与环境和运行相关的脚本。构建过程并不会在服务器上留下任何痕迹。

这些设计最终指向同一个结果:服务器不再绑定具体工具,失败被自然分层,变化被限制在可控范围内。

1. 问题的起点:重复部署

在最初动手部署的时候,对于这种个人应用的简单场景其实并没有真正困难的问题。照着教程安装 nginx,把静态文件放到对应目录里,配置好站点路径,确认浏览器能够正常访问,这些步骤本身并不复杂。只要耐心一点,几乎都能一次完成。

但问题在于,这样的部署过程高度依赖当下的理解状态。每当更换一台新机器,或者在一段时间之后回头重新配置环境时,几乎都需要重新去翻教程、重新对照命令、重新在脑子里梳理一遍流程。单次看起来并不难,但这个“重新理解一遍”的过程,本身就是一种隐性的成本。

一旦环境发生变化,问题就不再是“再部署一次”,而是“重新理解一次之前做过什么”。当初为什么要这么配、哪些步骤是必要的、哪些只是临时权宜之计,这些信息如果没有被明确记录下来,那么相当于重新走一遍学习和试错过程。我的一些博客内容就是在记录配置信息。

真正需要解决的,并不是“如何把服务跑起来”,而是如何让这件事在下一次、下下次依然成立。如果每次换机器都需要重新摸索,那么这个系统的稳定,其实只是停留在表面。一旦时间拉长,或者环境变化频繁,这种“能跑就行”的状态,很快就会变成负担。

一个更根本的问题:**如果明天换一台全新的服务器,我是否还能用同样的方式,把整个系统完整地还原出来?**如果答案是否定的,那么说明问题并不在某一次部署失败,而在于系统本身缺乏可重复性。

相比一次性的部署,env_init 描述的是一种状态 —— 一台新的、空白的机器,通过明确的步骤,被初始化为“可以长期承载这个系统”的状态。它不依赖个人记忆,也不依赖当时的上下文,而是把部署过程本身变成了一种可以被反复执行、被清楚理解的描述。

那么后面的所有设计选择,关注的都不再是“当前能不能跑”,而是当环境变化、时间推移、细节被遗忘时,这套系统是否依然可靠

将脚本命名为 env_init,意味着它描述的是一种状态,而不是一次性操作。这也直接要求脚本本身必须是可重复执行的。

在实际实现中,这种幂等性通过一系列明确的约束来实现的。例如,脚本一开始就会拒绝以 root 身份运行:

# 防止 sudo 执行
if [ "$EUID" -eq 0 ]; then
  echo "❌ Do not run this script with sudo"
  exit 1
fi

为了避免脚本在不同权限上下文中产生不可预期的副作用。环境初始化应该由普通用户触发,必要的权限提升只发生在明确的位置。

2. 构建与运行的边界:CI 该做什么,服务器该做什么

在明确了系统需要具备可重复性之后,接下来的问题其实非常自然:哪些事情应该发生在 CI,哪些事情应该留在服务器上。

在这套博客系统中,我把 Hugo 的构建完全放在 CI 中完成。CI 的职责只有一个:把源码变成可以被直接使用的静态结果,并将这个结果以 pub 分支的形式固定下来。

服务器不参与构建,不需要理解 Hugo,也不需要知道文章是如何生成的。它只面对一件事情:如何稳定地提供已经生成好的静态文件。从这个角度看,服务器和具体的生成工具之间不存在直接依赖关系,哪怕将来完全不再使用 Hugo,这一侧的逻辑也不需要发生变化。

这种拆分带来的一个直接好处,是构建失败和运行失败在结构上被区分开来。CI 失败意味着构建过程本身存在问题,而服务器侧的问题只可能发生在同步或服务阶段。

在这个前提下,仓库本身也发生了角色变化:它成为构建系统与运行系统之间的明确接口。CI 负责向这个接口写入结果,服务器只负责从这个接口读取结果。只要这个接口保持稳定,其它部分就可以独立演化。

在最初搭建这套流程时,我也尝试过让 CI 直接 SSH 到服务器,在构建完成后执行 git pull 和 nginx reload。从结果上看,这种方式完全可行,页面也能按预期更新。但部署失败时,很难第一时间判断问题究竟发生在构建阶段、网络阶段,还是服务器自身的运行状态上。失败的语义开始变得混杂,而不是清晰地指向某一层。

在边界划清之后,blog_env_init.sh 所做的事情本身其实并不多,每一项都指向一个明确的结果状态。

首先是系统依赖的准备:

sudo apt update
sudo apt install -y git nginx

sudo systemctl enable nginx
sudo systemctl start nginx

这些操作本身是幂等的,多次执行并不会改变系统状态,但它们明确表达了运行这个系统所依赖的最小组件集合。

接下来是站点目录的权限修正:

sudo chown -R "$USER:$USER" "$SITE_DIR"

这一步的目的不是调整权限本身,而是确保后续所有自动化行为都可以在普通用户权限下完成。

nginx 的配置同样被视为环境初始化的一部分,而不是部署步骤中的临时操作:

sudo cp "$NGINX_CONF_SRC" "$NGINX_CONF_DST"
sudo ln -sf "$NGINX_CONF_DST" "/etc/nginx/sites-enabled/${SITE_NAME}"
sudo rm -f /etc/nginx/sites-enabled/default

sudo nginx -t
sudo systemctl reload nginx

配置文件来源于仓库本身,这使得 nginx 的运行状态不再依赖服务器上的手工修改,而是可以通过代码被完整还原。

3. 服务器侧的节奏选择:cron 不是越频繁越好

对于一个静态博客来说,发布的即时性并不是核心需求。相比“尽快上线”,更重要的是系统在长期运行中保持简单和安静。因此,服务器侧并不需要在每一次构建完成后立即同步,而只需以一个稳定、低频的节奏拉取更新。

cron 在这里承担的不是“实时触发”的角色,而是为系统设定一个可以接受的延迟上限。频率的降低并不会影响最终结果,却能减少不必要的系统噪声,也让运行状态更容易被忽略和信任。

服务器侧的同步由 cron 触发,其职责非常单一:

CRON_JOB="0 */6 * * * ${SITE_DIR}/tools/sync_from_gitee.sh"
#!/bin/bash
set -e

SITE="/var/www/xym-ee.github.io"
LOG="$SITE/sync.log"

{
  echo "==== $(date) ===="
  cd "$SITE"
  git pull origin pub
  sudo systemctl reload nginx
} >> "$LOG" 2>&1

同步行为虽然是后台执行的,但并不是不可观测的。每一次自动拉取都会被记录到 sync.log 中,这使得系统的运行状态可以被回溯,而不需要人为介入。

4. 权限边界与最小授权:让系统自己跑,而不是靠人盯着

在服务器侧,同步和服务并不需要完整的管理权限。通过将定时任务运行在普通用户下,并只为必要的操作开放最小范围的 sudo 权限,可以把潜在风险限制在可控范围内。

权限在这里不是为了防止“人为失误”,而是为了让系统在无人干预的情况下,也不会意外越界。

为了让同步脚本能够在不输入密码的情况下 reload nginx,而不扩大权限范围,初始化脚本只为这一条命令开放 sudo 权限:

$USER ALL=NOPASSWD: /bin/systemctl reload nginx

这使得自动化流程可以完整运行,同时又不会把服务器的控制权整体交给后台脚本。

5. 日志不是写完就算:logrotate 与系统的自我清理能力

自动拉取本身是一种后台行为,如果完全不可观测,系统就会变得不透明。因此,同步过程需要通过日志被记录下来,用来确认系统是否在按预期运行。

但日志的存在并不意味着需要被长期保留。对于一个稳定运行的系统来说,日志更多是用于异常时的回溯,而不是日常阅读。为同步日志配置 logrotate,让日志既能被记录,也能被按规则清理,是系统具备长期自维护能力的前提。

为了避免同步日志在长期运行中无限增长,env_init 同时负责为 sync.log 配置 logrotate:

${SITE_DIR}/sync.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
    su $USER $USER
}

这段配置并不关注日志内容本身,而只关心生命周期。日志被记录,是为了在异常时能够定位问题;日志被清理,是为了让系统在没有异常时,不引入新的风险。

6.当多种方案结果相同时,设计差异体现在哪里

在这套系统中,许多不同的实现方式在结果上并没有区别:博客都会更新,页面也都能正常访问。如果只从“是否成功”来判断,这些方案看起来是等价的。

真正的差异并不体现在一切顺利的时候,而是在环境变化、时间推移或细节被遗忘之后,系统是否依然容易理解、容易修复、也容易被信任。设计的价值,并不在于让系统“看起来更复杂”,而在于让它在不可避免的变化中,仍然保持清晰。

当一个系统不再依赖人的记忆,也不需要被频繁关注,却依然能够按预期运行时,这种差异才真正显现出来。

总结这套方案的最终目标:不是追求复杂度,而是让系统长期安静运行。

源码,blog_env_init.sh 实现

#!/bin/bash
set -e

# ===== 防止 sudo 执行 =====
if [ "$EUID" -eq 0 ]; then
  echo "❌ Do not run this script with sudo"
  exit 1
fi

# ===== 脚本所在目录(关键)=====
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"

SITE_NAME="xym-ee.github.io"
SITE_DIR="/var/www/${SITE_NAME}"

NGINX_CONF_SRC="${BASE_DIR}/xym-ee.github.io.conf"
NGINX_CONF_DST="/etc/nginx/sites-available/${SITE_NAME}"

CRON_JOB="0 */6 * * * ${SITE_DIR}/tools/sync_from_gitee.sh"

echo "[env_init] start"


# ===== 系统依赖 =====
sudo apt update
sudo apt install -y git nginx

sudo systemctl enable nginx
sudo systemctl start nginx


# ===== 站点目录 =====
sudo chown -R "$USER:$USER" "$SITE_DIR"

# ===== nginx 配置 =====
sudo cp "$NGINX_CONF_SRC" "$NGINX_CONF_DST"
sudo ln -sf \
  "$NGINX_CONF_DST" \
  "/etc/nginx/sites-enabled/${SITE_NAME}"

sudo rm -f /etc/nginx/sites-enabled/default

sudo nginx -t
sudo systemctl reload nginx


# ===== cron 同步 =====
echo "[bootstrap] install cron job for user: $USER"
crontab -l 2>/dev/null | grep -v sync_from_gitee.sh > /tmp/cron.tmp || true
echo "$CRON_JOB" >> /tmp/cron.tmp
crontab /tmp/cron.tmp
rm -f /tmp/cron.tmp


# ===== sudo 权限(允许无密码 reload nginx)=====
SUDOERS_FILE="/etc/sudoers.d/nginx-reload"

echo "[bootstrap] configure sudo for nginx reload"

sudo tee "$SUDOERS_FILE" > /dev/null <<EOF
$USER ALL=NOPASSWD: /bin/systemctl reload nginx
EOF

sudo chmod 440 "$SUDOERS_FILE"


# ===== logrotate =====
LOGROTATE_DST="/etc/logrotate.d/${SITE_NAME}-sync"

echo "[bootstrap] configure logrotate for sync.log (user: $USER)"

sudo tee "$LOGROTATE_DST" > /dev/null <<EOF
${SITE_DIR}/sync.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
    su $USER $USER
}
EOF

echo "[bootstrap] done"