纸上山河 记录技术、思考与生活

使用 WorkBuddy 搭建静态博客的整过流程

从技术选型到服务器上线的完整实录,含六个真实的踩坑记录

约 10 分钟 making

搭一个博客听起来是个小需求,实际上它横跨了技术选型、前端实现、服务器配置、DNS 解析、备案合规五个环节。这篇文章记录我用 WorkBuddy 从零搭这个博客的完整流程——包括那些踩过的坑,因为顺利的部分没有参考价值。


一、技术选型:一条判据排除 90% 的方案

1.1 需求其实很小

个人博客,单作者,只做两件事——写文章、发布文章。部署到自己的云服务器。

看起来需求明确,但真正的问题在于:很多需求描述里藏着你不需要的东西。要 CMS、要用户体系、要评论、要富文本编辑器——这些都不是需求,是想象出来的。

1.2 一条判据定生死

整个选型过程可以压缩成一个问题:

访客访问的时候,你的服务器上需不需要有东西在跑?

这个问题决定了成本结构:

  • 不需要 → 静态生成。成本是 CDN 费用(接近零)
  • 需要 → 服务端渲染。成本是常驻进程 + 长期运维

我的答案是「不需要」。于是所有方案收敛到同一条路:文章在本地写成 Markdown,构建成静态 HTML,扔给 Nginx。

1.3 逐个排除

排除 Next.js —— 它很强,但每篇文章都要带上 React runtime(压缩后约 85KB)。而我的博客只有一个移动端菜单按钮需要交互。为一个开菜单的需求付85KB 的运行时,不合理。

排除 Hugo —— 构建速度确实快一个数量级(0.4s vs 3.8s),但 Go 模板的反直觉和 MDX 非原生是真实的学习成本。我的文章量(几百篇)在它面前体现不出优势。

排除 Eleventy —— 图片优化、sitemap、RSS 都要自己拼插件。而这些恰好是 Astro 的内置能力。

选择 Astro 5 —— Content Collections 用 Zod schema 校验 frontmatter、零运行时 JS、Shiki 构建期代码高亮、内置 MDX 支持。全部命中,没有一项是「用不上的能力」。

1.4 样式:参考稿的直接移植

我手上有一份 HTML 设计稿(治愈手账风),直接从里面提取了设计令牌:

:root {
  --paper: #f4ecde;      /* 纸张色 */
  --ink:   #3b3128;      /* 墨色 */
  --brown: #a9714b;      /* 棕色系点缀 */
  --r-xl:  34px;         /* 大圆角卡片 */
}

纸张纹理、衬线标题栈、手账装订线——这些直接移植,不用重新设计。

但有个意外发现:参考稿里没有深色模式(theme、dark 在全文出现 0 次)。所以我另设计了一套「夜间书斋」色板,--paper 改成 #211c17,棕色系整体提亮以保证暗底对比度。


二、搭建流程

2.1 项目骨架

src/
├── content/blog/       文章(Markdown)
├── content.config.ts   Zod schema 校验
├── layouts/            BaseLayout + BlogPost
├── components/         PostCard / PostList / Pagination
├── pages/              index / blog/[slug] / tags / archive / about
└── styles/global.css   设计令牌 + 组件样式 + 深色色板

2.2 内容源:Zod schema 当第一道防线

用 Astro Content Collections 之后,frontmatter 就是唯一的数据入口。它和正文不同步、格式容易手误——所有问题都得靠校验拦下来。

schema: ({ image }) => z.object({
  title: z.string(),
  publishDate: z.coerce.date(),
  draft: z.boolean().default(false),
  tags: z.array(z.string()).default([]),
})

关键在 z.coerce.date()。这一行的选择影响日常写作体验:

写法效果
z.coerce.date()接受 2026-10-21,内部是 Date 对象
z.string()接受一切字符串,包括「去年」

写错格式时,构建那一刻就报错:

✘ [ERROR] Expected date, received string

这比线上发现「某篇文章日期显示成 undefined」好得多。

2.3 分页

首页每页 6 篇,路由规则是第 1 页 /、第 N 页 /page/N:

export const getStaticPaths = (async () => {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  const total = Math.max(1, Math.ceil(posts.length / PAGE_SIZE));
  return Array.from({ length: Math.max(0, total - 1) }, (_, i) => ({
    params: { page: String(i + 2) },
  }));
}) satisfies GetStaticPaths;

2.4 深色模式:唯一必须内联的代码

三段式设计:CSS 变量定义两套色板 → 内联脚本读取偏好 → 跟随系统变化。

最容易踩的坑是闪烁。 如果主题判定脚本放在 <body> 里,用户会先看到浅色再变深色——那一下闪烁比没有深色模式更糟。必须内联进 <head> 同步执行:

<script is:inline>
  const saved = localStorage.getItem('theme');
  const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;
  if (saved === 'dark' || (!saved && prefersDark)) {
    document.documentElement.classList.add('dark');
  }
</script>

这段代码不能 defer、不能 async,否则照样闪。

代码高亮用了 Shiki 双主题,切换时不需要重新高亮:

shikiConfig: {
  themes: { light: 'github-light', dark: 'github-dark' },
  defaultColor: false,
}
:root.dark .astro-code span { color: var(--shiki-dark) !important; }

一次高亮输出两套颜色,CSS 在暗色下接管。

2.5 服务器:多阶段构建把镜像压到 12MB

FROM node:22-alpine AS builder
RUN npm ci && npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

单阶段构建的镜像是 1.2GB,换成多阶段只剩 12MB。

瘦身不只是省磁盘——最终镜像里没有 Node 运行时,也就没有 Node 生态的 CVE 需要跟踪。对一个只提供静态内容的站点,这是攻击面直接归零。


三、出现的问题

这一节是本文最有价值的部分。以下六个问题全部真实发生,且大部分由我自己的失误造成。

3.1 中文标签双重编码(判断失误)

现象:中文标签页 404。

我的第一反应是错的。 我看到构建产物目录是编码名 /tags/%E6%8A%80%E6%9C%AF/,就认定「链接需要编码」,于是加了 encodeURIComponent。

结果更糟——全站标签 404。

根因:Astro 会自动对 getStaticPaths 的 params 做URL 编码。我手动编码造成了双重编码。

正确做法:getStaticPaths 的 params 和模板里的 href 两边都用原始中文值,谁都不要编码:

params: { tag }// 原始值,不要 encodeURIComponent
<a href={`/tags/${tag}`}>{tag}</a>

教训:当「修复」让问题从局部扩大到全局时,先怀疑自己的判断方向,而不是继续加大力度。

3.2 中文 slug 被吞字

npm run new 生成的文件名丢了字:「整过流程」变成「整过流程」(少了一个字)。

根因:

.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')

\u4e00-\u9fa5 只覆盖 CJK 基本区,而有些常用字在扩展A 区(U+3400–U+4DBF)或扩展区。落在区段外的就被过滤掉了。

修复:改用 Unicode 属性,它自动覆盖所有扩展区:

.replace(/[^\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}a-z0-9_\-\s]/gu, '')

3.3 existsSync 从错误的模块导入

import { mkdir, writeFile, existsSync } from 'node:fs/promises';
//                                          ^^^^^^^^^^ 不存在

existsSync 是同步 API,在 node:fs 里。node:fs/promises 只有异步版本。

这个错误能存在这么久,是因为这个脚本从来没被真正运行过。项目搭完就直接进入部署环节了。

修复:

import { mkdir, writeFile, access } from 'node:fs/promises';

async function exists(p) {
  try { await access(p); return true; } catch { return false; }
}
// 调用处记得加 await
if (await exists(filePath)) { ... }

3.4 空值导致 schema 校验失败

脚本生成的模板里有这一行:

subtitle:

YAML 会把空值解析成 null,但 schema 要求 string | undefined:

✘ [InvalidContentEntryDataError] data does not match collection schema

修复:可选字段为空时直接不输出该行,而不是输出键名。

顺带把草稿默认改成 draft: true——新建的文章不该直接发布到线上。原来提示语写着「把 draft 改成 false」,但默认值已经是 false,自相矛盾。

3.5 健康检查误报:unhealthy 但服务正常

现象:容器显示 Up 12 hours (unhealthy),但 curl 返回 200。

排查链条:

docker logs        → Configuration complete, ready to start up  ✅
start worker process 28/29                → 正常运行          ✅
ls html 目录      → 27 个页面齐全             ✅
curl -I localhost → HTTP/1.1 200 OK            ✅

Nginx 完全正常,但健康检查持续失败。

根因:健康检查用 wget --spider 发 HEAD 请求,而我的 nginx.conf 里 location / 配了 expires -1 + add_header Cache-Control: no-cache。响应头里 Cache-Control 出现了两次(expires 自己生成一条,add_header 又加一条)。BusyBox wget 遇到 no-cache 的 HEAD 响应会误判失败。

两处修复:

# 改用 GET,丢弃响应体
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1
# 删掉 expires -1,只用 add_header,避免重复头
location / {
    add_header Cache-Control "no-cache" always;
}

教训:unhealthy 不等于服务不可用。判断顺序应该是「先看日志确认进程 → 再从宿主机 curl 验证实际响应 → 最后才看健康检查」。

3.6 部署脚本的三次返工

这个脚本我改了三版才跑通,每个版本都有独立的 bug。

第一次:set -e 下 cd 到不存在的目录直接终止,clone 逻辑根本没机会执行。

# 错误:目录不存在时 cd 就挂了
cd "$REMOTE_DIR"
if [ -d .git ]; then ...

# 正确:先判断,再决定动作
if [ -d "$REMOTE_DIR/.git" ]; then
  cd "$REMOTE_DIR" && git pull
else
  git clone "$REMOTE_URL" "$REMOTE_DIR" && cd "$REMOTE_DIR"
fi

第二次:令牌传输失败。printf '%s' 不输出换行符,服务器 read 等不到行尾直接失败。

第三次:unset GIT_TOKEN 写在了拼接 URL 之后,但后面还有两处 if [ -n "$GIT_TOKEN" ] 判断——变量已经被清空了,所有分支都走错。

最终方案是令牌与脚本分两条通道:

TOKEN_FILE="$(mktemp -t token.XXXXXX)"
chmod 600 "$TOKEN_FILE"
printf '%s' "$GIT_CRED" > "$TOKEN_FILE"
trap 'rm -f "$TOKEN_FILE"' EXIT          # 无论成败都删

# stdin 第一行传令牌,其余是脚本主体
{ cat "$TOKEN_FILE"; echo; cat; } | ssh "$SSH_TARGET" bash -s -- ...

令牌不经过命令行参数、不落盘、错误信息自动脱敏。

这三个 bug 的共同点:都写完了但没实测。这是最不该犯的错。


四、需要注意的事项

4.1 备案:域名比想象中麻烦

ICP 备案期间,国内服务器的 80/443 端口会被拦截。这不是配置错误,是政策要求。

这意味着「先部署好,等备案通过再上线」是可行的,但你要接受一个事实:备案期间只能通过 IP + hosts 验证,无法用域名访问。

过渡方案有三种:

  • 境外服务器(最省心)—— 不需要备案,缺点是国内延迟高 100~300ms
  • IP + hosts 直连 —— 本地临时绑定,绕过 DNS 但绕不过备案限制
  • CDN 前置 —— 注意这条路备案期间也不通,国内 CDN 同样要求备案完成

4.2 DNS:根域名是独立记录

我踩过一个很容易忽略的坑。最初的解析状态是:

makingitbig.cn     →  无记录      ❌
www.makingitbig.cn →  119.45.254.59  ← 已过期的旧服务IP

@ 是根域名的独立记录,必须单独添加。 而 www 那条指向的是旧服务商留下的遗留 IP——如果当时没排查就直接访问,会以为服务器有问题。

判断依据很简单:

dig +short makingitbig.cn    # 根域名
dig +short www.makingitbig.cn # 子域

两条都要指向你当前的新服务器。

4.3 Git 托管:国内必须用 Gitee

实测数据(同一台国内机器):

站点状态耗时
gitee.com2002.16s
github.com超时>10s

GitHub 在国内网络环境下基本不可用。而部署服务器在国内,拉不动代码就等于部署不了。

好在 deploy.sh 从 origin 动态读取仓库地址,切 Gitee / GitHub 零成本——这一点在设计时就留了余地:

REMOTE=$(git remote get-url origin)

4.4 内存:1G 机器构建会OOM

Astro 构建要装 300+ 依赖,1G 内存的机器很可能被 OOM killer 干掉。我在安装脚本里加了自动检测:

if [[ "$(free -m | awk '/Mem:/ {print $2}')" -lt 2048 ]]; then
  fallocate -l 2G /swapfile
  mkswap /swapfile && swapon /swapfile
  grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi

这个判断在部署时真的生效了——脚本检测到内存不足 2GB,自动加了 2GB swap。

4.5 端口绑定:127.0.0.1 意味着外部访问不到

我用的端口映射是:

ports:
  - "127.0.0.1:8080:80"

127.0.0.1 前缀意味着只对宿主机开放,外部访问 服务器IP:80 是不通的。

这是有意设计:对外入口交给宿主机层的 Nginx 处理,容器只提供上游服务。好处是容器重建不影响反代配置,还能复用宿主机已有的 Nginx。

备案通过后需要部署反代,两种选择:

方案适合
宿主机 Nginx 反代 + certbot想要完全掌控配置
Traefik 自动签证书想要全自动

4.6 安全组:不要开「全部」

阿里云安全组里如果有一条:

允许 IPv4  任意位置  全部 (-1/-1)

立刻删掉。 这等于向全网敞开所有端口,任何人都能连你的 22、3306、6379 和各种管理服务。

白名单模式只放必要端口。RDP(3389) 在 Linux 服务器上用不上,也该删。

4.7 AI 协作的真实边界

这次全程用 AI 工具完成,写下来几条判断标准:

AI 擅长:设计令牌移植、组件模板生成、配置文档编写、错误信息解读、问题定位

AI 会犯错的地方:

  • 方向性错误——3.1 的双重编码,改对之前越改越糟
  • 未经实测的代码——3.6 那三个 bug 都是写完没跑
  • 顺序与依赖——unset 变量后面还在用,heredoc 转义混乱

人必须把关的:

  • 需求判断(哪些是真正需要的,哪些是想当然的)
  • 架构决策(这条判据为什么是这个而不是那个)
  • 线上验证(curl 一下比任何静态检查都可靠)

最有用的一条经验:验证优先于交付。3.6 那三个 bug,任何一个只要实测一次就能立刻发现。而我选择先交给你跑——结果就是你成了第一个测试员。下次我会先跑通再给你。


五、部署完成后的状态

容器:Up (healthy)
解析: makingitbig.cn → 121.43.250.184
SSH:  开放
80/443: 备案期间被拦截(正常)

站点已经完整跑通,27 个页面、296KB、零运行时 JS。剩下的是等备案通过后配反代和证书。


结语

用 AI 搭博客,不是「一句话生成一个项目」,而是「一句话启动一个项目 + 十几轮验证」。

真正花时间的从来不是写代码,而是:

  • 判断哪些需求是真需求
  • 决定技术方案背后的判据是什么
  • 在 AI 给出错误方向时及时叫停
  • 不把没验证过的东西交出去

最后一条尤其重要。这篇文章里所有的坑,我都本可以在交给你之前自己发现。

工具能加速执行,但判断力永远没法外包。