Next.js 网站部署踩坑经历小记及前端站点部署技巧

5,159 阅读8分钟

前言

在入职两个月后,我开始了第一个完全由我一个人负责开发的网站项目。这个网站的用途是用于 宣传一个开源社区峰会 以及 沉淀峰会视频内容 ,在峰会进行期间还需要在网站进行 直播 。从网站的开发到部署上线,可谓是踩坑无数,最终实现的网站在这里 apisix-summit.org/

背景

由于时间有限,整体网站的架构是基于 vercel 的一个网站脚手架 virtual-event-starter-kit 之上进行开发,因此并不需要从头构建项目。 这个脚手架使用的是 next.js 实现服务端渲染,其中已经预设了很多第三方平台的接口,例如 datoCMSupstash

当时的我对于 react 并没有那么的熟悉,更别提 next.js 框架以及一些 react 系的第三方库了,因此这次的开发不仅仅是任务,更是一个学习的过程。

部署过程

服务器基本环境搭建

最初网站是通过 vercel 进行部署的,但是后期需要转移到 AWS 的三台裸金属服务器上进行部署并配置 AWS Elastic Load Balancing 负载均衡。由于网站使用的是服务端渲染,因此不能直接打成一个静态包放在 CDN 上,必须跑一个服务才能使用。

为了防止服务挂掉,我使用的 pm2 来守护服务的进程,关于 node.js 和 pm2 的安装我执行的操作大概如下:

sudo apt update 

sudo apt install nodejs npm -y 

sudo npm i n pm2 yarn -g

n stable

这里之所以安装 n 是因为 apt 安装的 node.js 太旧了,通过 n 来快速切换下 node.js 的版本,保证依赖可以顺利安装。

简化部署流程

由于有三台服务器,每次 github 合并了新代码我就得去三台服务器上手动拉取,并构建新的服务。更新一次得花十几分钟,于是我想着要不写个 CD 来进行自动化的部署,于是苦心钻研了下 AWS pipeline ,但是最终公司大佬说没有必要做自动部署,万一代码中有 bug 一旦自动部署,三台服务器中的代码就同时出现问题了,还是人为的去操作比较靠谱。

于是我还是得老老实实去服务器上操作,但是我写了一个小脚本帮助我减少了很多繁琐的操作,脚本的内容如下:

# deploy.sh

cd apache-apisix-summit-website

git config --global --add safe.directory /home/ubuntu/apache-apisix-summit-website

git pull origin main

sudo yarn && sudo yarn build

sudo pm2 restart 0

具体的操作就是去 github 上拉下代码, 然后重新打包,重启服务。这里要注意,如果要使用这种方式从 github 拉取代码需要先配置 ssh 密钥,再将远程仓库设置为 github 里 ssh 协议的地址。

这里其实也可以用 docker 将产物打包成镜像,再在服务器上跑起来,但是

next.js 与负载均衡

在部署完成后,我发现在各个页面间切换的速度特别慢,通过浏览器控制台我发现:每次切换页面都会重新加载所有的资源,于是我推断是服务均衡相关的问题。在不断的搜索之下,最终我看到了这一篇文章 : How to Deploy Next.js on Multiple Servers

大概的意思是,每一次执行 build 操作时,Next.js 都会生成一个新的构建 ID,用于唯一标识新生成的实例。如果我们时将构建完成后的包放到多个服务器上,那么各个服务器上的构建 ID 都是相同的,Next.js 在加载页面时判断 ID 相同就会进行 软刷新,也就是将一些依赖复用。

但我们现在的操作是在三台服务器上各自构建各自的包,因此三台服务器上的 ID 不统一,当我们访问网站时由于加了负载均衡,每一次访问的服务器都可能是不同的,而 Next.js 判断到了 ID 变化就会进行 硬刷新 ,重新加载资源也就导致新页面访问速度很慢。

既然知道了原因,那么就可以开始解决问题了,Next.js 是 随机生成 的构建 ID ,我们需要将三台服务器上的构建 ID 都统一一下,在 next.js 的文档中有这么一篇 Configuring the Build ID 告诉我们可以在配置文件中按照如下方式自定义构建的 ID :

module.exports = {
  generateBuildId: async () => {
    // You can, for example, get the latest git commit hash here
    return 'my-build-id'
  },
}

但是构建的 ID 如果使用静态的会导致新版本的包没办法被 Next.js 识别,继续使用旧的依赖,因此我们需要传入一个与版本相关的值,一旦站点版本更新了就会同步更新。文章中推荐了一种方式,就是使用 next-build-id 这个包进行构建 ID

const nextBuildId = require('next-build-id');
module.exports = {
  // ...
  generateBuildId: () => nextBuildId({ dir: __dirname })
};

nextBuildId 会返回一个本地 git 存储库最新的 git commit 的哈希值,这样咱们更新代码后就会更新构建 ID ,并且保证在三台服务器上都可以同步了。

单人部署小妙招

如果说你只是个人开发者,并且没有使用 github 等工具进行代码托管,那么你也可以使用 scp 进行产物构建后的传输,下面是代码实现:

// 部署服务器
(async () => {
  const client = require("scp2")
  const ora = await import("ora")
  const chalk = require("chalk")
  const spinner = ora.default(chalk.green("正在发布。。。"))

  /*
   host: 服务器ip
   port:scp上传的端口号 (默认:22)
   username:服务器账号
   password:服务器密码
   path:部署到服务器的路径
*/
  
  spinner.start()
  client.scp(
    "./dist/",
    {
      host: server.host,
      port: server.port,
      username: server.username,
      password: server.password,
      path: server.path,
    },
    (err) => {
      spinner.stop()
      if (!err) {
        console.log(chalk.green("成功"))
      } else {
        console.log(err)
      }
    }
  )
})()

只需要将 server 修改为你的服务器相关的信息,使用 node.js 执行一下这个脚本就可以将你的构建产物发送到服务器上指定的位置。

docker 部署

目前大部分的前端都会了解到 docker 容器技术,使用 docker 进行网站打包的话需要先写一个 dockerfile 将网站打包为镜像,对于网站而言 dockerfile 基本的实现思路就是以下几点:

  1. 将当前目录代码复制到容器中,并设置工作目录
  2. 安装依赖
  3. 打包构建
  4. 启动项目

像 react 和 vue 的项目,dockerfile 网上随便就能搜到,针对自己的项目进行一些修改即可,而 next.js 的项目官方也为我们提供了一个现成的 dockerfile :

# Install dependencies only when needed
FROM node:16-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# If using npm with a `package-lock.json` comment out above and use below instead
# COPY package.json package-lock.json ./ 
# RUN npm ci

# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN yarn build

# If using npm comment out above and use below instead
# RUN npm run build

# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app

ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# You only need to copy next.config.js if you are NOT using the default configuration
# COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json

# Automatically leverage output traces to reduce image size 
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

使用官方的 dockerfile 进行打包时,记得在 next.config.js 中将 outputStandalone 开启:

module.exports = {
  experimental: {
    outputStandalone: true,
  },
}

这样打包出来的镜像会比用常规方式打包出来的小很多,常规方式打包大概 1-2Gb ,而用这种方式打包则只有 200-300 Mb ,具体的原理我没有深入了解,仅仅只是尝试了一下。

将站点打包成镜像后,我们还需要把镜像同步至服务器中,并且服务器还得主动去拉最新的镜像重新 run 一次,这一步实现的方式有很多,讲下思路:

  1. 在服务器上跑一个服务,当接受到指定的请求时就会执行 docker pull xxx 拉镜像和 docker run xxx 运行容器
  2. 在 github action 中构建镜像完成后将镜像推到例如 dockerhub 等镜像托管平台。
  3. 推送后向服务器发送一个请求,告诉服务器该更新了,这一步最好传下环境变量让服务端判断一下,避免被其他人攻击。当然也可以手动执行请求的发送。

具体的实现方式就因项目而异啦。

总结

这篇文章主要记录一下 next.js 的部署方式和踩坑,当然如果你是传统网站或者后端服务也可以用作参考,如果对你有帮助可以点赞支持下~