next.js in vercel

@hehe

next.js in vercel

由于团队以 js/ts 为主要开发语言,为了后续简化和规范部署流程,最近打算 dockerize 之前的 next 项目。当然,COPY . . & RUN yarn 也能用, 但随着项目发展,依赖增多 node_modules 随之膨胀,IMAGE SIZE 轻松达到数百兆,不太理想,那有些什么方法来缓解?本文由此而来。

intro

先介绍一下现有的 best practices

multistage builds

Multistage builds feature in Dockerfiles enables you to create smaller container images with better caching and smaller security footprint.

multistage 将 build 流程划分为多个 stage,每个 stage 都可以是干净的环境,通过 COPY --from=stageName 拷贝前面某个 stage 的文件

# install
FROM node:lts-alpine as base
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile && yarn build
COPY . .

# build
FROM node:lts-alpine as builder
WORKDIR /app
COPY --from=base /app/package.json /app/yarn.lock ./
RUN yarn install --production --frozen-lockfile
COPY --from=base /app/dist .

# copy
FROM mhart/alpine-node:slim-12
WORKDIR /app
COPY --from=builder /app .

# ...

这可以让我们不用再考虑清除各种 cache 和中间文件,从 stage 获取结果文件就行了,减少心智负担,也减少 image 大小。

(开启 buildkit 后可以加快 build 速度,同时也不会生成多余的中间态 image)

(很遗憾,node 官方到现在还没提供 no-yarn-no-npm-image,示例中的 mhart/alpine-node 仅供参考)

yarn autoclean

Cleans and removes unnecessary files from package dependencies.

最早知道类似的方法,是在 tj 的 node-prune,功能很简单:清理 node_modules 里所有在 blacklist 的文件。

本来想用 node-prune,但毕竟需要额外的接入步骤和环境 (golang)。后来了解到 yarn 其实内置了类似功能:autoclean

测试了一个 api 项目,最终减少 11mb+ (好过没有)

...
yarn autoclean v1.22.4
[1/1] Cleaning modules...
info Removed 3882 files
info Saved 11.09 MB.
Done in 2.92s.
...

但是显然,删除无用文件的方式,确实 安全 但不算完美: esm/cjs/umd/lib/mjs 哪个能删,这些都只能 了代码才晓得。

再想想

vercel

next.js 既然由 vercel 团队开发,那是不是可以从其开源项目中,找到合适的优化方案

ncc

Simple CLI for compiling a Node.js module into a single file, together with all its dependencies, gcc-style.

首先进入视野的是 @vercel/ncc,其通过 webpack 将 node 项目 (js/jsx/ts) 打包为 single file 的方式,前端同学早就习以为常, 但一直以来,服务端端对此鲜有借鉴,一方面,服务端对于包大小并不敏感,硬盘不值钱,传统的 npm install + pm2,配合服务器镜像等方式,也能完成线上部署(又不是不能用.jpg)。

但在新的云服务架构下,不论是基于 docker 还是 JAMStack/serverless,分发包的体积对部署效率所产生的影响,已经不再是微不足道了 (事实上,云平台对用户的资源大小也存在着限制,资源大小也决定了冷启动的时间)。

nft (node-file-trace)

在多个 next.js 的 issues/discussions 下,看到类似这样的官方回复:

We used to use ncc for Node and Next.js deployments but we switched to node-file-trace instead.

意味着,现在在 vercel 部署 next,平台已经转而使用了 @vercel/ntf ,这是个什么东西?

This package is used in @vercel/node and @vercel/next to determine exactly which files (including node_modules) are necessary for the application runtime.

This is similar to @vercel/ncc except there is no bundling performed and therefore no reliance on webpack. This achieves the same tree-shaking benefits without moving any assets or binaries.

和 ncc 类似,@vercel/nft 则是通过 acron 得到代码执行所需的依赖,某种程度都实现了 tree-shaking, 但不同的是,ncc 是通过 webpack 的能力将项目打包;@vercel/nft 则没有打包,最终产物是 fileList,而具体对列表作出何种处理,则交由用户选择。

ntf cli 就提供了一个例子:

const { fileList, esmFileList, warnings } = await nodeFileTrace(files, opts);
const allFiles = fileList.concat(esmFileList);
const stdout = [];

if (action === 'print') {
  // ... print action ...
} else if (action === 'build') {
  rimraf.sync(join(cwd, outputDir));
  for (const f of allFiles) {
    const src = join(cwd, f);
    const dest = join(cwd, outputDir, f);
    const dir = dirname(dest);
    await mkdir(dir, { recursive: true });
    await copyFile(src, dest);
  }
}

执行 npx nft build app.js

// app.js
const {ApolloClient} = require('@apollo/client')
console.log(ApolloClient)
-> du -sh dist/node_modules
464K	dist/node_modules

最终将 copy nodeFileTrace 产出的所有文件到 dist 目录,没有 bundle 流程,就是纯纯的 copy

why nft

那这样做的目的是什么?看起来从得到 fileList 到打包 single file 完全是顺理成章的事,而 vercel 却放弃后者,开始保留起目录结构来了?

先看看打包成单文件有什么问题:

  • 不能 diff 依赖,每次部署都是整个项目(哪怕只有一个文件),对于依赖的变化知之甚少
  • 对于约定大于配置的应用,支持困难 (next.js 的 api pages 目录,graphql-tools 的 mergeResolvers ...)
  • webpack 不是万能的,需要对特殊的包做兼容 (手动拷贝 bull 的 lua 文件 ...)

那和我周树人有什么关系:

  • 支持多输入文件,解决约定大于配置 (npx nft build src/app.ts src/modules/a.resolver.ts)
  • 更高的执行效率,没有 webpack 的层层 wrapper,没有魔法
  • 更灵活的控制,是 tar 包发布,还是创建 Lambda Layer,还是先和上一个版本的 fileList 做 diff 再看情况处理,能做的事情很多,很多很多

那我要杠了,如果用 ncc 将所有 dependencies 打包一个做 lambda layer,而用户代码再打个包行不行?

答:可以是可以,不过这就放弃了 tree-shaking,毕竟 理解代码 是优化的终点。 就好比当初只要一个 cdn 地址,jquery/lodash 所有方法随心用,到如今,每个库/框架都要小心翼翼保证 tree-shakable (lighthouse 这玩意儿可怕哟)。

vercel/vercel

众所周知,vercel 是 next.js 背后的 👩,配置好 GitHub/GitLab/.. 之后只要一个 git push,剩下的打包发布上线都由 vercel 完成。这个 "完成" 背后,就有 vercel runtime 的参与。

那 vercel runtime 有什么好点子?

no dockerfile

rauchg: In short, the usage of Docker containers creates an opaque box that doesn't allow us to fully take advantage of modern cloud primitives.

搜索之后得知,vercel (之前叫 zeit/now) 是从 v2.0 开始取消的 Dockerfile 部署方式,目的是为了对项目能有更好的控制,高效的利用云端 (aws/gcp) 资源 (lambda/cdn)。

讲得通

虽然和本文初衷略有相背,不过不要紧,殊途同归,先看看用了 @vercel/nft 的 @vercel/next (next runtime for vercel),都做了些什么

runtime

等等等等,说到 vercel runtime,顺便得提一句,事实上 vercel 不光支持 node 和 next,也支持其他语言 (go/python/ruby ...),长话短说:只要能在 lambda 上跑,管你什么语言。

vercel 对其平台的 runtime 定义是:符合 Runtime interface 的 node package (DEVELOPING_A_RUNTIME.md):

interface Runtime {
  version: number;
  build: (options: BuildOptions) => Promise<BuildResult>;
  analyze?: (options: AnalyzeOptions) => Promise<string>;
  prepareCache?: (options: PrepareCacheOptions) => Promise<CacheOutputs>;
  shouldServe?: (options: ShouldServeOptions) => Promise<boolean>;
  startDevServer?: (
    options: StartDevServerOptions
  ) => Promise<StartDevServerResult>;
}

这里最重要的就是 build: (options: BuildOptions) => Promise<BuildResult>,输入代码文件列表和一些配置信息,输出 Files/routes 等信息。

最终由 vercel 调用云服务商的接口完成部署 (lambda@edge)

@vercel/next

那 next runtime 有什么特别的地方?

其主要代码位于 now-next/src/index.ts,按 runtime 要求,export build + prepareCache,其代码执行流程如下:

// 这里应该有一张流程图,改天再补,存个档
// 多说一句,index.ts 快两千行代码,到处 if else,怎么也不给整理一下,气人

下面是 @vercel/next 里用到 nft 的地方:

// 收集 pages 内依赖的文件列表
const { fileList, reasons: nonApiReasons } = await nodeFileTrace(
  nonApiPages,
  {
    base: baseDir,
    processCwd: entryPath,
  }
);

nft 除了返回 fileList,还有每个文件被引用的原因 (reasons object) 这里过滤了初始文件 (reason.type === 'initial')

const collectTracedFiles = (
  reasons: NodeFileTraceReasons,
  files: { [filePath: string]: FileFsRef }
) => async (file: string) => {
  const reason = reasons[file];
  if (reason && reason.type === 'initial') {
    // Initial files are manually added to the lambda later
    return;
  }

  // ...
  files[file] = new FileFsRef(/* ... */);
};

// 收集 tracedFiles
await Promise.all(
  fileList.map(collectTracedFiles(nonApiReasons, tracedFiles))
);

// pseudoLayer: { [fileName: string]: PseudoFile }
// PseudoFile 里有文件 buffer
let { pseudoLayer, pseudoLayerBytes } = await createPseudoLayer(tracedFiles);

// createLambda
/* for loop */
  const lambdas: { [key: string]: Lambda } = {};
  lambdas[group.lambdaIdentifier] = createLambdaFromPseudoLayers({
    files: {...launcherFiles},
    layers: [...pseudoLayers, ...pageLayers],
    handler: path.join(
      path.relative(baseDir, entryPath),
      'now__launcher.launcher'
    ),
  })
/* end */

最后返回这些玩意供 vercel 部署

return {
  output: {
    ...publicDirectoryFiles,
    ...lambdas,
    // Prerenders may override Lambdas -- this is an intentional behavior.
    ...prerenders,
    ...staticPages,
    ...staticFiles,
    ...staticDirectoryFiles,
  },
  routes: {
    // ...
  },
}

不得不说,有点东西,总结下来,vercel/next 会把 pages/**/* 的 pages 组成 lambdaGroup (只要小于 lambdaByteLimit), 最终将 lambdas 和一些静态文件作为 output 返回给 vercel,vercel 完成接下来的部署,这部分暂时没有看到公开资料。

export class Lambda {
  public type: 'Lambda';
  public zipBuffer: Buffer;
  public handler: string;
  public runtime: string;
  public memory?: number;
  public maxDuration?: number;
  public environment: Environment;

  constructor({
    zipBuffer,
    handler,
    runtime,
    maxDuration,
    memory,
    environment,
  }: LambdaOptions) {
    this.type = 'Lambda';
    this.zipBuffer = zipBuffer;
    this.handler = handler;
    this.runtime = runtime;
    this.memory = memory;
    this.maxDuration = maxDuration;
    this.environment = environment;
  }
}

那拿到 output 和 routes 等数据之后的处理,已不在本文的讨论范围,移步 serverless-nextjs/serverless-next.js

callback

回到正题,毕竟初衷是想追求小体积,那最终结论如何?

workflow 如下:

  1. next build
{
  "mode": "serverless",
  "//": "serverless 模式下,next 会将用户端的依赖合并至结果文件",
}
  1. next 项目没有用户端所谓的 index.js,需要创建一个 entry.js 供 nft 使用
require('next')
require('next/dist/bin/next')
  1. 使用 @vercel/ntf 查找 next 项目的所有依赖文件,打包 deps.tar.gz
const {nodeFileTrace} = require('@vercel/nft')
const tar = require('tar')

const ignore = [] // TODO
const {fileList, /* reasons */} = await nodeFileTrace(['entry.js', 'next.config.js'], {ignore})

tar.c({gzip: true, file: 'deps.tar.gz'}, files)
  1. 通过查看 reasons 和 deps.tar.gz 的文件列表,选定线上不需要的依赖,ignore 参数忽略; 例如 sharp,包体积大 项目又没用到 next/image,就很适合 ignore (而且也不知道出于什么原因使用了 try { require('sharp') } catch {} 的方式,所以忽略了也不会造成 Cannot find module sharp)
// 例如
const ignore = [
  // ...
  'node_modules/next/dist/cli/next-dev.js',
  'node_modules/next/dist/cli/next-export.js',
  
  // ...
  'node_modules/@ampproject/toolbox-optimizer/**/*',
  'node_modules/sharp/**/*',
]
  1. 更新 Dockerfile 内容: 拷贝解压 deps.tar.gz 至工作目录
FROM mhart/alpine-node:slim-12
WORKDIR /app

COPY . .
RUN tar -xzf deps.tar.gz && \
  rm -rf deps.tar.gz

FROM mhart/alpine-node:slim-12
WORKDIR /app
COPY --from=0 /app .
CMD ["node_modules/next/dist/bin/next", "start", "-p", "80"]

得到的效果是:

deps.tar.gz 612K 解压后 2.6M,对比 next 83M (install size)