最近为了优化微服务容器的冷启动速度和 CI/CD 构建效率,团队决定把所有 Node.js 后端应用全部打包成单文件(Single File Bundle)。本来以为用 Esbuild 顺手拈来,结果一脚踩进了动态 require 和各种 C++ 原生模块(.node)的深坑。特别是像 PostgreSQL 的驱动 pg、序列化库以及某些遗留的 SDK,在运行时通过字符串拼接动态加载文件的骚操作,直接在编译后暴毙。把 node_modules 整个揉进 bundle 的过程简直是一部排查问题的血泪史。

把所有依赖塞进一个文件的最大好处是 Docker 镜像可以直接瘦身。我们以前要把整个庞大的 node_modules 打包进镜像,每次 npm install 都要消耗几分钟,容器体积也轻松突破 800MB。现在只需要把编译后的单个 dist/index.js 拷过去,连 package.json 都不用,基础镜像换成纯净的 node:alpine,几秒内完成部署,镜像大小直降到 50MB 左右。

但是在编译过程中,最让人头疼的是一些库使用了动态依赖,比如像下面这种经典的黑盒写法: const moduleName = './adapters/' + type; require(moduleName); Esbuild 在静态分析时根本无法预测到底会引入哪个模块,于是直接略过,导致运行时报 Cannot find module 错误。

为了解决这个问题,我们专门写了一个 Esbuild 插件,在构建时拦截这些特殊库的路径导入,并强制将它们标记为 external(外部依赖),或者使用静态映射将它们打包进来。对于 C++ 原生模块(例如 sharpbcrypt),我们通过自定义插件将二进制文件拷贝到 dist 目录,并在运行时重写其 require 路径。

下面是我们目前在线上稳定运行的 esbuild.config.js 构建脚本:

const esbuild = require('esbuild');
const path = require('path');
const fs = require('fs');

// 自定义插件:处理原生 .node 二进制模块和特定的动态 require 漏洞
const nativeModulesPlugin = {
  name: 'native-modules',
  setup(build) {
    // 拦截所有以 .node 结尾的文件导入
    build.onResolve({ filter: /\.node$/ }, args => ({
      path: args.path,
      namespace: 'node-file',
    }));

    // 当遇到原生模块时,重定向到 dist/node 目录下,并修改 require 路径
    build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => {
      const originPath = path.resolve(args.path);
      const fileName = path.basename(originPath);
      const targetDir = path.resolve(__dirname, 'dist', 'node');
      
      if (!fs.existsSync(targetDir)) {
        fs.mkdirSync(targetDir, { recursive: true });
      }
      
      // 拷贝二进制文件到输出目录
      fs.copyFileSync(originPath, path.join(targetDir, fileName));
      
      return {
        contents: `
          const path = require('path');
          const nativePath = path.resolve(__dirname, 'node', ${JSON.stringify(fileName)});
          module.exports = require(nativePath);
        `,
      };
    });

    // 针对 pg 驱动中的动态 require 提示进行 polyfill
    build.onResolve({ filter: /^pg-native$/ }, args => {
      return { path: args.path, external: true };
    });
  },
};

esbuild.build({
  entryPoints: ['src/app.ts'],
  bundle: true,
  platform: 'node',
  target: 'node18',
  outfile: 'dist/index.js',
  minify: true,
  sourcemap: true,
  // 排除部分实在无法打包的超大原生模块,通过外部容器环境提供
  external: ['fsevents'],
  plugins: [nativeModulesPlugin],
  logLevel: 'info',
  // 开启 Tree-Shaking 剔除无用死代码
  treeShaking: true,
  mainFields: ['module', 'main'],
}).catch(() => process.exit(1));

通过这套插件机制,Esbuild 在 0.8 秒内就把包含上百个 npm 依赖的项目打成了一个干净的单文件。对于 Tree-Shaking,需要特别注意在 package.json 里正确声明 "sideEffects": false,否则 Esbuild 为了避免副作用,会非常保守地保留很多无用模块,导致打包体积翻倍。

虽然单文件部署带来了极速的冷启动和极简的运维心智负担,但如果你的服务高度依赖于各种需要编译的原生 C++ 插件,把它们隔离出来单独作为 external 依然是最稳妥的选择。大家在做后端打包时,遇到过最离谱的动态 require 隐患是哪个库?又是怎么解决的?