Astro 博客在 4G 内存服务器上的构建优化实战

2301 字
12 分钟
Astro 博客在 4G 内存服务器上的构建优化实战

Astro 博客在 4G 内存服务器上的构建优化实战#

当你兴冲冲地把 Astro 博客部署到一台 4 核 4G 的云服务器上,执行 pnpm build,然后看到进程被无情 Killed——欢迎来到低内存构建的地狱。

本文记录了我在 4G 内存服务器上构建 Astro + Tailwind CSS v4 + Sharp 图片优化项目时,遇到的 OOM(Out of Memory)问题,以及一步步优化到稳定构建的完整过程。

一、问题背景#

技术栈#

  • 框架: Astro 6.0.8
  • 样式: Tailwind CSS v4 + @tailwindcss/vite
  • 图片处理: Sharp(Astro 内置图片优化)
  • 搜索: Pagefind
  • 代码高亮: Expressive Code
  • 其他: Swup 页面过渡、KaTeX 数学公式、MDX

服务器配置#

  • CPU: 4 核
  • 内存: 3.8 GiB(实际可用)
  • Swap: 无(初始状态)
  • 系统: Ubuntu Linux 6.8.0
  • 常驻进程: Docker 容器(OpenClaw ~734MB)、宝塔面板(~110MB)、Nginx、containerd、云监控 Agent 等,合计占用约 1.2GB

也就是说,留给构建进程的内存只有 ~2.6GB

崩溃现象#

Terminal window
$ pnpm build
generating optimized images
/_astro/firefly.EzH7fIYR_2kkPj.webp (1/50)
/_astro/avatar.BcAu2wMi_yT6PR.webp (2/50)
/_astro/firefly.EzH7fIYR_16B3gR.webp (3/50)
Killed
# exit code: 137

Exit code 137 = 进程收到 SIGKILL,被 Linux OOM Killer 杀掉了。每次都死在 Sharp 图片优化阶段——这个阶段需要将图片解码到内存中进行处理,50 张图片并行处理时内存瞬间飙升。

二、问题分析#

2.1 内存去哪了?#

通过 ps aux --sort=-%memdocker stats 分析:

进程内存占用
OpenClaw(Docker 容器)~734 MB
宝塔面板~110 MB
Docker daemon~90 MB
containerd~55 MB
云监控 Agent × 2~110 MB
Nginx worker~37 MB
常驻合计~1.2 GB

3.8G 总内存 - 1.2G 常驻 ≈ 2.6G 可用。而 Astro 构建过程中:

  1. Vite 打包阶段: Rollup 并行处理文件、Tree-shaking 分析、CSS 处理,约 800MB-1.2GB
  2. 图片优化阶段: Sharp 解码 + 编码多张大图片,内存峰值可达 2-4GB
  3. 两者叠加轻松超过 2.6G

2.2 为什么没用到 Swap?#

通过 dmesg 查看 OOM 日志:

oom-kill: constraint=CONSTRAINT_NONE, task=node, pid=3089
Out of memory: Killed process 3089 (node)
total-vm:102897108kB, anon-rss:2528824kB

关键发现:Swap 虽然已配置,但使用量为 0。原因是 Linux 默认的 vm.swappiness=60,在内存快速消耗的场景下,OOM Killer 可能在 Swap 被充分利用之前就介入了。

三、优化方案#

3.1 Astro 配置优化(astro.config.mjs)#

禁用实验性功能#

experimental: {
// Rust 编译器在低内存环境不稳定,禁用
rustCompiler: false,
// 队列渲染会增加并发内存占用,禁用
queuedRendering: { enabled: false },
},

queuedRendering 原本是为了提升构建速度而并行渲染页面,但在低内存环境下,并行意味着更高的内存峰值。禁用后改为串行渲染,用时间换稳定性。

图片处理限制#

image: {
layout: "constrained",
service: {
entrypoint: 'astro/assets/services/sharp',
config: {
// 限制输入图片的最大像素数,防止超大图片撑爆内存
limitInputPixels: 268402689,
},
},
},

Sharp 处理一张 4000×3000 的图片时,解码后需要 4000 × 3000 × 4 bytes ≈ 48MB 的原始像素数据。limitInputPixels 设置了一个上限,超过的图片会被拒绝处理而非 OOM。

禁用 Swup 预加载#

swup({
// ...
preload: false, // 禁用预加载,减少构建时内存
}),

限制代码高亮并发#

expressiveCode({
// 限制同时处理的代码高亮任务数
maxConcurrentTasks: 2,
// ...
}),

代码高亮需要对每个代码块做语法分析和主题着色,多个代码块并行处理时内存会叠加。限制为 2 个并发任务可以显著降低峰值。

MDX 优化#

mdx({
optimize: true, // 启用 MDX 编译优化
}),

3.2 Vite/Rollup 构建优化#

限制并行文件操作#

build: {
rollupOptions: {
maxParallelFileOps: 5, // 默认值通常更高
},
}

Rollup 默认会并行读取和处理大量文件,每个文件都需要内存来存储 AST。限制并行数可以降低内存峰值。

简化 Tree-shaking#

rollupOptions: {
treeshake: {
moduleSideEffects: false,
},
}

完整的 Tree-shaking 需要分析每个模块的副作用,这需要在内存中维护完整的模块依赖图。设置 moduleSideEffects: false 告诉 Rollup “所有模块都没有副作用”,可以跳过部分分析。

手动代码分割#

output: {
manualChunks: (id) => {
if (id.includes('node_modules')) {
if (id.includes('@fancyapps/ui')) return 'vendor-fancyapps';
if (id.includes('pagefind')) return 'vendor-pagefind';
if (id.includes('photoswipe')) return 'vendor-photoswipe';
if (id.includes('overlayscrollbars')) return 'vendor-overlayscrollbars';
return 'vendor';
}
if (id.includes('/src/pages/')) return 'pages';
},
}

将大型第三方库分到独立 chunk,避免一个巨大的 vendor bundle 在内存中处理。

禁用不必要的输出#

build: {
sourcemap: false, // 不生成 sourcemap
manifest: false, // 不生成 manifest
reportCompressedSize: false, // 不计算压缩大小
target: 'es2020', // 更现代的 target,减少 polyfill
}

Sourcemap 会将每个文件的映射关系保存在内存中,对于构建产物较大的项目,这部分内存开销不可忽视。

3.3 图标生成脚本优化(generate-icons.js)#

分批处理文件#

const BATCH_SIZE = 50;
for (let i = 0; i < files.length; i += BATCH_SIZE) {
const batch = files.slice(i, i + BATCH_SIZE);
for (const file of batch) {
// 处理文件...
}
}

原来是一次性遍历所有文件收集图标名称,改为分批处理,每批 50 个文件。

分批加载图标 + 强制 GC#

const ICON_BATCH_SIZE = 20;
for (let i = 0; i < iconArray.length; i += ICON_BATCH_SIZE) {
const batch = iconArray.slice(i, i + ICON_BATCH_SIZE);
for (const iconName of batch) {
const svg = await getIconSvg(iconName);
// ...
}
// 每批处理完后强制垃圾回收
if (global.gc) {
global.gc();
}
}

通过 --expose-gc 暴露 GC 接口,每处理 20 个图标后主动触发垃圾回收,防止内存持续累积。

清空缓存#

// 生成完成后清空图标集缓存
iconSetCache.clear();

3.4 构建脚本(build-extreme.sh)#

将构建过程分为独立阶段,每个阶段用独立的 Node 进程运行,进程退出后内存自然释放:

#!/bin/bash
set -e
# 内存限制
export NODE_OPTIONS="--max-old-space-size=2048 --max-semi-space-size=64 --expose-gc"
# 清理旧缓存
rm -rf dist .astro
# 阶段 1:图标生成(1GB 限制)
node --max-old-space-size=1024 --expose-gc scripts/generate-icons.js
# 阶段 2:Astro 构建(2GB 限制)
npx astro build
# 阶段 3:搜索索引
npx pagefind --site dist

关键设计:

  • --max-old-space-size=2048: V8 老生代堆内存限制为 2GB,超过时触发 GC 而非无限增长
  • --max-semi-space-size=64: 新生代内存限制为 64MB(默认 16MB),适当增大可以减少频繁 GC
  • --expose-gc: 暴露 global.gc() 接口,允许手动触发垃圾回收
  • 分阶段执行: 图标生成和 Astro 构建在不同进程中运行,互不影响

3.5 package.json 新增脚本#

{
"scripts": {
"build": "node --max-old-space-size=2048 scripts/generate-icons.js && astro build && pagefind --site dist",
"build:extreme": "node --max-old-space-size=1536 scripts/generate-icons.js && node --max-old-space-size=2048 ./node_modules/astro/astro.js build && pagefind --site dist",
"build:lowmem": "set NODE_OPTIONS=--max-old-space-size=2048 && pnpm build",
"clean": "rm -rf dist .astro",
"clean:win": "rmdir /s /q dist 2>nul & rmdir /s /q .astro 2>nul"
}
}

三级构建策略:

  • pnpm build: 标准构建,带基本内存限制
  • pnpm build:lowmem: 低内存构建
  • pnpm build:extreme: 极致低内存构建,用于 4G 服务器

四、部署过程中遇到的坑#

4.1 --optimize-for-size 不允许在 NODE_OPTIONS 中使用#

node: --optimize-for-size is not allowed in NODE_OPTIONS

Node.js 出于安全考虑,部分 V8 flag 不允许通过 NODE_OPTIONS 环境变量传递,只能通过命令行直接传递。解决方案是从 NODE_OPTIONS 中移除,改为直接在 node 命令后面加参数。

4.2 node ./node_modules/.bin/astro 报语法错误#

basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
^^^^^^^
SyntaxError: missing ) after argument list

.bin/astro 是一个 shell 脚本包装器,不能用 node 直接执行。正确的方式是用 npx astro build 或者找到真正的入口文件 ./node_modules/astro/bin/astro.mjs

4.3 Swap 加了但没用上#

添加了 4GB Swap 后,构建仍然 OOM:

Terminal window
$ free -h
Swap: 4.0Gi 0B 4.0Gi # Swap 使用量为 0!

原因是默认的 vm.swappiness=60 不够激进。解决方案:

Terminal window
# 提高 swappiness,让系统更积极使用 Swap
sysctl vm.swappiness=80
# 允许内存过度分配,延迟 OOM Killer 的介入
sysctl vm.overcommit_memory=1
  • vm.swappiness=80: 默认值 60 意味着系统倾向于保留物理内存,设为 80 后系统会更积极地将不活跃页面换出到 Swap
  • vm.overcommit_memory=1: 默认值 0 是启发式策略,可能在内存快速消耗时过早触发 OOM Killer。设为 1 表示总是允许分配(overcommit),让进程有机会使用 Swap

4.4 Vite 的 optimizeDeps.disabled 警告#

(!) Experimental optimizeDeps.disabled and deps pre-bundling during build
were removed in Vite 5.1.

Astro 配置中的 optimizeDeps.disabled 选项在 Vite 5.1 后已移除。虽然是警告不影响构建,但后续可以清理掉这个配置。

五、最终效果#

经过以上所有优化后:

✅ 构建完成!
generating static routes ✓ Completed in 1.47s.
generating optimized images ✓ Completed in 15.59s. (50/50)
build ✓ Completed in 30.15s.
16 page(s) built in 32.99s
指标优化前优化后
构建结果OOM Killed✅ 成功
构建时间N/A(崩溃)~33 秒
图片优化第 5 张崩溃50 张全部完成
峰值内存>3.8GB(OOM)~2.5GB

六、总结#

在低内存服务器上构建现代前端项目,核心思路就是用时间换空间

  1. 降低并发: 禁用队列渲染、限制文件并行操作、限制代码高亮并发
  2. 分阶段执行: 图标生成、Astro 构建、Pagefind 索引分三个进程
  3. 主动管理内存: V8 堆大小限制、手动 GC、清空缓存
  4. 系统层面配合: 添加 Swap、调整 swappiness、允许 overcommit
  5. 减少输出: 禁用 sourcemap、manifest、压缩大小报告

如果你也在小服务器上跑 Astro 构建遇到 OOM,希望这篇文章能帮到你。最后一句忠告:如果预算允许,升级到 8G 内存是最简单的方案 😄


本文涉及的所有优化代码已提交至项目仓库,构建脚本位于 scripts/build-extreme.sh,配置优化详见 astro.config.mjsdocs/BUILD_OPTIMIZATION.md

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Astro 博客在 4G 内存服务器上的构建优化实战
https://boke.hackerdream.xyz/posts/astro-build-optimization-4g-server/
作者
晴天
发布于
2026-03-25
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录