前言
近期,开源项目 nezha-dash 引入了 Docker 部署方式。为了兼容 linux-arm64 架构,项目被打包成多架构镜像并发布到了 Docker Hub。
在此过程中,通过不断改进配置和工具链,显著提升了打包 Next.js 多架构镜像的效率。本文将简单分享这一优化过程。
Next.js 打包
在 Next.js 中,在配置文件(例如:next.config.mjs )中,将 output 配置项设置为 standalone ,即可打包成可使用 node 进行启动的独立前端服务。
因此,将 Next.js 打包成镜像的步骤也十分简单,只需要将 Next.js 构建后的文件放到包含 node 环境的镜像内,设置好启动命令即可完成镜像打包。
官方示例
在 Next.js 的官方文档中,提供了一个 Dockerfile 示例。
由于步骤较为繁琐冗长,因此进行了模糊化处理。
在官方的示例中,采用了分布构建的方式,将构建分为四步:
- 安装构建所需系统内核库
- 安装项目依赖
- 构建项目
- 将构建后的文件与静态文件放入运行镜像中,配置启动命令。
官方示例中采用 node:18-alpine 作为基础镜像,分别进行上述的四个步骤,而其中对于构建性能的优化并没有特别完善。
- 由于 alpine 的精简化,在构建前需要安装系统依赖库
- 在包安装上,也许会花费许多时间
首先,我们针对包管理器与基础镜像这两个模块进行优化。
使用Bun进行加速
在项目中,采用 Bun 作为包管理器,关于 Bun 的介绍,可查看:
Bun — A fast all-in-one JavaScript runtime
通过 Bun 来安装与管理项目中的包,不仅可以以极快的速度安装项目依赖,还可以配合官方的 oven/bun 镜像来加速 Docker 构建。
以下将采用 Bun 作为工具,逐步优化镜像打包性能与镜像大小。
1. 使用 Bun 作为包管理器与基础容器
FROM oven/bun:1 AS base
# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
# Stage 2: Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Stage 3: Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["bun", "run", "server.js"]
在 Dockerfile 中,将 oven/bun:1 作为我们的基础镜像,分布进行构建任务:
- 使用 Bun 安装依赖
- 使用 bun run build 指令(定义在 package.json 中)构建 Next.js 服务
- 将构建好的文件与静态文件放入运行镜像,定义启动命令
相对于官方的示例,不仅步骤简洁了不少,无需手动安装额外的系统依赖。
图上方是pnpm安装包所需时间,下方是bun安装包所需时间,可以看到,使用Bun作为包管理器后,在包安装的速度上也有很大提升。
2.优化最终镜像大小
在使用 Bun 打包镜像后,对于先前的镜像,我们会发现镜像大小显著地增加了,从先前的 67M 膨胀到了目前的 109 M,因此,我们需要对最终的运行镜像大小进行优化。
在官方的示例中,采用了 alpine 系统作为基础镜像,同样的,我们可以保留 oven/bun 作为依赖安装与构建镜像,而将运行镜像改为更加精简的 oven/bun:alpine 。
FROM oven/bun:1 AS base
# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
# Stage 2: Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Stage 3: Production image
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["bun", "run", "server.js"]
可以看到,重新构建后,镜像大小回到了先前最初的大小。
使用统一构建进行加速多架构镜像
多架构镜像
随着 Arm 设备近几年的不断发展,多架构支持的镜像也是十分必要的,在 Arm 设备上虽然也可以运行 Amd64 架构的镜像,但难免在性能上会收到影响。
因此,在发布打包镜像时,可以将 Amd64 与 Amd64 架构放在同一个镜像仓库中,作为多架构镜像进行分发。
在项目中采用 GitHub Actions,通过 git tag 的方式进行触发打包。
name: Build and push Docker image
on:
push:
tags:
- 'v*'
env:
REGISTRY_IMAGE: hamster1963/nezha-dash
ALIYUN_REGISTRY_IMAGE: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash
jobs:
build-and-push:
name: Build and push Docker image
runs-on: ubuntu-latest
environment: Production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to AliYun Container Registry
uses: docker/login-action@v3
with:
registry: registry.cn-guangzhou.aliyuncs.com
username: ${{ secrets.ALI_USERNAME }}
password: ${{ secrets.ALI_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY_IMAGE }}
${{ env.ALIYUN_REGISTRY_IMAGE }}
tags: |
type=raw,value=latest
type=ref,event=tag
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
在 Build and push Docker image 任务中,我们使用 docker/build-push-action@v6 作为工作流工具,在 platforms 参数中,可以很方便地将目标架构传入,Docker 会为我们自动构建这些架构的镜像。
构建速度问题
在首次构建后,会发现,在构建 linux/amd64 架构的镜像时,速度很快,大约 50 秒就可以完成构建,但是构建 linux/arm64 架构镜像时却花费了惊人的 10 分钟,这其中一定发生了什么。
实际上,linux/arm64 构建速度如此慢的原因是由于仿真模拟的性能损耗。
仿真模拟与交叉编译
在目前我们编写的 Dockerfile 下,在为 linux/arm64 架构构建镜像时,是完全采用仿真编译的方式进行的,意味着构建过程需要在架构之间不断转换它们的指令,因此构建性能会收到很大的影响。
因此我们可以朝着交叉编译的方向进行优化,将全部的构建过程放在宿主机的架构上,这样子无需仿真,性能也不会受到太大的影响。
Next.js 交叉编译
对于 Next.js 而言,编译的过程实际上是一系列优化和转译,如果项目使用了 Typescript,则转换为最终的 Javascript ,并进行各种的混淆,压缩。
对于最终产物,由于 Javascript 语言的特性,只需要有 node,即可启动 Next.js 服务,也就是说:
Next.js 的编译产物与架构无关
了解这一点后,Next.js 的交叉编译无非就是将编译产物分发到不同架构镜像中的过程。
统一构建
最终,我们将构建的过程在宿主机架构的镜像中进行,并将其分发到不同架构的运行镜像中,而改造过程也十分简单,只需将我们的 base 容器与架构关联起来。
从
FROM oven/bun:1 AS base
变成
FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
编译后,一样地,从 builder 中分发到不同架构的runner中:
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
需要注意⚠️的是:
FROM oven/bun:1-alpine AS runner
默认会使用--target=$TARGETPLATFORM 的镜像,也就是目标架构的镜像,因此我们可以省略这一参数。
让我们开始构建吧!
FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
# Stage 2: Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Stage 3: Production image
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["bun", "run", "server.js"]
最终,通过一系列的优化,构建多架构镜像的时间:
15m 25s → 2m 14s
完整的 GitHub Actions 文件、Dockerfile 与构建记录,可在我的开源项目中查看: