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。
崩溃现象
$ 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: 137Exit code 137 = 进程收到 SIGKILL,被 Linux OOM Killer 杀掉了。每次都死在 Sharp 图片优化阶段——这个阶段需要将图片解码到内存中进行处理,50 张图片并行处理时内存瞬间飙升。
二、问题分析
2.1 内存去哪了?
通过 ps aux --sort=-%mem 和 docker 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 构建过程中:
- Vite 打包阶段: Rollup 并行处理文件、Tree-shaking 分析、CSS 处理,约 800MB-1.2GB
- 图片优化阶段: Sharp 解码 + 编码多张大图片,内存峰值可达 2-4GB
- 两者叠加轻松超过 2.6G
2.2 为什么没用到 Swap?
通过 dmesg 查看 OOM 日志:
oom-kill: constraint=CONSTRAINT_NONE, task=node, pid=3089Out 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/bashset -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_OPTIONSNode.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:
$ free -hSwap: 4.0Gi 0B 4.0Gi # Swap 使用量为 0!原因是默认的 vm.swappiness=60 不够激进。解决方案:
# 提高 swappiness,让系统更积极使用 Swapsysctl vm.swappiness=80
# 允许内存过度分配,延迟 OOM Killer 的介入sysctl vm.overcommit_memory=1vm.swappiness=80: 默认值 60 意味着系统倾向于保留物理内存,设为 80 后系统会更积极地将不活跃页面换出到 Swapvm.overcommit_memory=1: 默认值 0 是启发式策略,可能在内存快速消耗时过早触发 OOM Killer。设为 1 表示总是允许分配(overcommit),让进程有机会使用 Swap
4.4 Vite 的 optimizeDeps.disabled 警告
(!) Experimental optimizeDeps.disabled and deps pre-bundling during buildwere 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 |
六、总结
在低内存服务器上构建现代前端项目,核心思路就是用时间换空间:
- 降低并发: 禁用队列渲染、限制文件并行操作、限制代码高亮并发
- 分阶段执行: 图标生成、Astro 构建、Pagefind 索引分三个进程
- 主动管理内存: V8 堆大小限制、手动 GC、清空缓存
- 系统层面配合: 添加 Swap、调整 swappiness、允许 overcommit
- 减少输出: 禁用 sourcemap、manifest、压缩大小报告
如果你也在小服务器上跑 Astro 构建遇到 OOM,希望这篇文章能帮到你。最后一句忠告:如果预算允许,升级到 8G 内存是最简单的方案 😄
本文涉及的所有优化代码已提交至项目仓库,构建脚本位于 scripts/build-extreme.sh,配置优化详见 astro.config.mjs 和 docs/BUILD_OPTIMIZATION.md。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!