next.js in vercel
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 如下:
- next build
{
"mode": "serverless",
"//": "serverless 模式下,next 会将用户端的依赖合并至结果文件",
}
- next 项目没有用户端所谓的
index.js,需要创建一个 entry.js 供 nft 使用
require('next')
require('next/dist/bin/next')
- 使用 @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)
- 通过查看 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/**/*',
]
- 更新 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)