从 GitHub 自动化部署到网页性能优化

650 阅读15分钟

前提

一切都和速度有关,手动部署慢,网页加载慢。

首先解决部署问题。

代码是托管在 Github 上的,那么使用 Github Actions 是一种自然的选择。但是上一次使用 GitHub Actions 已经是一年前了,现在除了知道这东西的存在其他基本都忘了。

第二,当前网页打开速度太慢(15s+),当然服务器配置太低是问题(t6 实例 2 核 + 2G + 2MB 带宽 + 40G 云盘 🙃),因为这是我的个人练习项目。另一个问题是图片太多,首屏是三张占满屏幕的轮播大图,并且还使用了自定义字体。众所周知,图片和其他大量的静态资源是影响网页打开速度的大头。

so,我的目标是:

  1. 自动化部署应用,在代码 push 或 PR merge 到 main 分支时,自动部署到远程服务器(阿里云);
  2. 优化网页加载性能,目标是快 10 倍;

确定目标后细化一些步骤,首先我们将目标 1 标记为 g1;同理目标 2 为 g2。

g1 的实施过程

细化目标

直接问 ChatGPT 4o,确定大致流程:

  1. checkout 代码
  2. ssh 远程连接服务器(通过密钥)
  3. 在服务器对应文件夹拉取最新仓库代码(首先已经在服务器某个位置拉取过了)
  4. 根据 Dockerfile 构建镜像
  5. 停止并删除原有容器
  6. 运行新的镜像

实操

Checkout & SSH

首先 4o 生成的代码中各种 actions 的版本都比较老,需要对照着使用较新的版本,比如 checkout@v4.1.5ssh-agent@v0.9.0 等。

另外,直接生成的代码也比较基础,很多参数需要自己到对应的文档上查看,比如因为仓库是 private,所以 actions/checkout 不能直接使用,需要配置 ssh-key 参数。

同理,webfactory/ssh-agent 也需要添加额外参数 ssh-private-key,对应连接远程服务器的私钥。最终,我的使用方式是:

name: Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4.1.5
        with:
          ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
          repository: <your_repository_address>
          ref: main

      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.9.0
        with:
          log-public-key: false
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

因为安全问题,我们当然不能直接在文件中填入私钥或公钥地址,需要使用 secrets 引用数据,具体参考官方文档 Using secrets in GitHub Actions

tip: 通过在 commit message 加上 [ci skip] 可以跳过 actions 的执行

另一个有点麻烦的问题是公/私钥的管理,你需要包括但不限于做以下操作:

  • 在服务器生成公/私钥(基操)
  • 将公钥加入 Github 账户的 SSH Keys 内(基操)
  • 将公钥加入服务器的 ~/.ssh/authorized_keys
  • 将私钥添加到项目的 secrets 中,同样参考 Using secrets in GitHub Actions

以上就是我所作的所有操作,至少这解决了我的问题。

Build a new docker image

Dockerfile

nextjs 官方提供了模板,cv 下来先。

根据我个人的需求,我首先将 npm 源改为了阿里源pnpm config set registry https://registry.npmmirror.com

如果你只需要这样,那到这一步就够了,关闭 Dockerfile,再次回到 Github Actions 的编辑中。

Back to main.yml

回到上面完成一半的 yml 中,添加以下代码(也是 4o 生成的):

...

      - name: Pull latest code and build Docker image
        env:
          SERVER_ROLE: ${{ secrets.SERVER_ROLE }}
          SERVER_IP: ${{ secrets.SERVER_IP }}
        run: |
          ssh -o StrictHostKeyChecking=no "$SERVER_ROLE"@"$SERVER_IP" << 'EOF'
            cd <your_source_path_on_server>
            git checkout main
            git pull origin mian
            docker build -t <container_name> .
            docker rm -f <container_name>
            docker run -d --name <container_name> --restart unless-stopped -p 3000:3000 <container_name>
          EOF

有几点需要说明:

  1. 依然使用了 secrets 隐藏服务器 roleip
  2. 定义了容器的重启策略 --restart unless-stopped,在非手动停止时,自动重启。这样在服务器重启后也会跟随 Docker 启动。你可以根据自己的需求调整,参考官网

那么,好。顺利的话 g1 就 ok 了(真的顺利了😱)。

g1 has done

过程都是总结的,实际自然没有那么顺利。比如因为很久没写 Github Actions 和 Dockerfile 了,所以很多东西都忘了。并且在最后,我并没确认我的流程是否是行业的最佳实践,我想我解决了问题,暂时够了(later equals never 🙂)。

该说不说,有了 AI 加持我不需要到处找资料或者再去读冗长的官方文档了。当然很多东西最好还是去官网比对下,避免有些东西太过时。

另外,CSDN 真是烦死了,好多文章要收费(原创倒是没问题),然后复制还要登录。幸好对于前端来说,在网页上复制东西大部分时候都是可以解决的。

google 和 ai 真好用🤤

最终代码

name: Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4.1.5
        with:
          ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
          repository: <your_repository_address>
          ref: main

      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.9.0
        with:
          log-public-key: false
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
          
      - name: Pull latest code and build Docker image
        env:
          SERVER_ROLE: ${{ secrets.SERVER_ROLE }}
          SERVER_IP: ${{ secrets.SERVER_IP }}
        run: |
          ssh -o StrictHostKeyChecking=no "$SERVER_ROLE"@"$SERVER_IP" << 'EOF'
            cd <your_source_path_on_server>
            git checkout main
            git pull origin mian
            docker build -t <container_name> .
            docker rm -f <container_name>
            docker run -d --name <container_name> --restart unless-stopped -p 3000:3000 <container_name>
          EOF
FROM node:18-alpine AS base
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories && apk update

FROM base AS deps
RUN apk add --no-cache libc6-compat build-base wget
RUN wget -O - https://github.com/jemalloc/jemalloc/releases/download/5.3.0/jemalloc-5.3.0.tar.bz2 | tar -xj && \
    cd jemalloc-5.3.0 && \
    ./configure && \
    make && \
    make install

WORKDIR /app

COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm config set registry https://registry.npmmirror.com && pnpm i --frozen-lockfile && pnpm run clean; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM base AS runner
WORKDIR /app

COPY --from=deps /usr/local/lib/libjemalloc.so.2 /usr/local/lib/

ENV NODE_ENV production

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

COPY --from=builder /app/public ./public

RUN mkdir .next
RUN chown nextjs:nodejs .next

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

ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so.2

CMD HOSTNAME="0.0.0.0" node server.js

g2 实施过程

根据端到端的过程,可优化的点是相当多的,比如花钱升级服务,升级网络协议,加上各种缓存,预处理/预加载各种资源,压缩资源大小等等。

但是我不想花钱,没钱。

那么在实施优化之前,先简单讲讲 Lighthouse

Lighthouse

打开浏览器控制台,应该能在 tab 栏看到 Lighthouse,我们可以用这个工具测试网站的加载速度。该工具提供了一系列性能指标(Performance Metrics),比如 FCPLCPCLSINPTTFBTBT 等等。

针对这些关键指标的得分,我们能够有目标的优化网站加载性能。

细化目标

  1. 升级网络协议到 http2
  2. 参考 nextjs 部署相关文档以及性能优化的章节:
    1. Deploying
    2. Optimizations
  3. CDN
  4. 根据浏览器 Lighthouse 得分找到需要优化的部分

实操

HTTP 2

这个应该是基本操作,使用阿里云的免费 SSL 证书,配置 nginx 即可。

Images

compress

压缩图片,我一般使用 tinyPNG(应该有自动化方案,暂不优化此流程)。

avif & webp

优先使用这两个格式的图片,更小,兼容性也得到现代浏览器的普遍支持。

在这里,我选择了 webp,虽然 avif 更小,但是我的机器性能太差,还是选择更快的算法。另一个考量是,在一屏的内容中,图片没有那么多,优先将少部分的图片处理好传回来让用户看到为主。

这是根据 nextjs 官网以及个人情况得到的解决方案:

AVIF generally takes 20% longer to encode but it compresses 20% smaller compared to WebP. This means that the first time an image is requested, it will typically be slower and then subsequent requests that are cached will be faster.

实际的体感并没有什么不同,只是一个小的优化。不过如果使用 CDN,那么我会优先考虑使用 avif。

priority & sizes & placeholder

首先,nextjs 是已经对图片做了优化的。另外,可以简单的使用一些属性来微调图片加载,比如:

  • priority 提高 LCP 图片的获取优先级
  • sizes 根据各种预定尺寸生成不同大小的图片(非常重要🤞)
  • placeholder 准确来说这只是提高了用户体验

memory

其实正常情况下,不用接下来的优化也是可行的,毕竟很少有情况会用这么低配置的服务器。但问题是配置太差,以至于打开首页容器就挂掉了(处理图片导致内存爆了),我不得不从安装 sharp 入手优化图片的处理。

不过在使用 sharp 之前,我先做的是使用虚拟内存缓缓,不要随随便便就把内存爆了。基本命令如下:

swapon --show
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
swapon --show

此时使用 glances 可以看到 swap 有了。接下来设置系统重启时 swap 仍然生效:

echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo sysctl vm.swappiness=60
echo 'vm.swappiness=60' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

swap 虽然慢,但不至于让服务随便就挂了。再次打开网页,发现 swap 被用去了一大半,容器也运行正常。

sharp & jemalloc

nextjs 文档中提到了使用 sharp 这个库提高图片处理性能,该库可以高效的处理或转换图片。

不得不说,使用 sharp 是我的痛苦之源。当然,单单安装个库并没有什么难的,麻烦的是,sharp 建议在 linux 系统中使用 jemalloc 来优化内存分配。还当然,只是安装个 jemalloc 也不难,但问题是在容器中使用 jemalloc。

首先安装 jemalloc,过程非常的简单,从 Github 仓库的 release 中下载最新版本的包,在服务器解压,如果你没有什么特殊要求,比如自定义安装路径,那么直接进入解压目录,一波命令运行 ./autogen.sh && make && make install

接下来设置 shell 的环境变量 LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,可参考官方 wiki

本来以为这一步之后就好了,重新运行 nextjs 应用即可。但是我忘了,我是要在容器中使用 jemalloc,而容器中无法使用宿主环境的 jemalloc(因为容器中的系统和宿主环境系统不一样)。

问 4o,提供了三种方法:

  1. 在容器内安装 jemalloc
  2. 匹配宿主环境和容器内的 libc 版本
  3. 在容器内编译 jemalloc

其实还有一种方法,就是将 jemalloc 源码加入项目代码中,我觉得不太合适,不考虑。

方法 2 要在宿主环境中去编译出适合容器的版本,我觉得灵活度太低,如果我要升级容器环境,那么编译 jemalloc 的操作也要进行适配,放弃之。

方法 1 通过 apk 在构建镜像阶段直接安装 jemalloc,看起来很美好,但在我的实践过程中(spent my entire day),根本无法安装上,我不知道为什么,我尝试了多种办法:

  • 更换 apk 源为阿里源
  • 更换 apk 源为科大讯飞源
  • 不得已更换会官方源(偶尔网络可以连接)
  • 以上三种都不行,就是无法安装上 jemalloc,于是
  • 改为安装 jemalloc-dev
  • 改为安装 libjemalloc2
  • 也不行

我以为能够非常简单的安装好 jemalloc,没想到这么难,原因查了半天都没查到(google、4o 都无法帮到我)遂放弃之。

只有方法 3 了,事实上在实验方法 2 的过程中,试了试方法 3,但是当时看构建日志,安装 wget 用了非常久,并且失败了,遂回到方法 2 继续实验。

在方法 3 中,需要从远程下载 jemalloc,但是我的第一想法是我的本地有 jemalloc 源码,我是否可以直接引用然后编译到镜像内呢?

这一想法害了我,经过了几个小时的实验,最终发现我无法在 build 阶段引用到宿主环境下 Dockerfile 所在目录外的文件(可能行,我没查到),我懒得再详细阅读 docker 冗长的文档了。

退而求其次,直接从网络下载 jemalloc 源码。

经过方法 2 的极尽折磨,我首先确定了安装不上 jemalloc 不是源的问题,那么我直接一行命令 sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories && apk update 解决网络问题。

然后使用 wget 下载即可:

RUN wget -O - https://github.com/jemalloc/jemalloc/releases/download/5.3.0/jemalloc-5.3.0.tar.bz2 | tar -xj && \
    cd jemalloc-5.3.0 && \
    ./configure && \
    make && \
    make install

Dockerfile 成品代码中处理 jemalloc 的片段非常简单,但是达到简单的过程却并不容易。幸好最后成了,在重新运行 nextjs 容器后,我确认了 sharp 是否使用了 jemalloc:

docker exec -it <container_name> /bin/sh
cat /proc/<pid>/smaps | grep jemalloc

非常美妙的输出:

7fb39226c000-7fb392274000 r--p 00000000 fd:01 2753758    /usr/local/lib/libjemalloc.so.2
7fb392274000-7fb39230a000 r-xp 00008000 fd:01 2753758    /usr/local/lib/libjemalloc.so.2
7fb39230a000-7fb392322000 r--p 0009e000 fd:01 2753758    /usr/local/lib/libjemalloc.so.2
7fb392322000-7fb392328000 r--p 000b6000 fd:01 2753758    /usr/local/lib/libjemalloc.so.2
7fb392328000-7fb392329000 rw-p 000bc000 fd:01 2753758    /usr/local/lib/libjemalloc.so.2

最后,要验证成效,再次打开网页,发现 swap 没有再被使用了,并且内存也没爆。

CDN

在做完以上优化后,网页第一次加载的速度提升到了 8-10 秒左右。再分析 Lighthouse 得分,很容易发现,主要耗时仍然是文件、图片的加载。

受限于服务器性能,想要提高文件加载速度,只能求助于外部力量。在这里,即 CDN。

我们应该多多少少了解过 CDN,比如现在仍在大量使用的优化性能手段,就是将项目中使用到的外部依赖通过 CDN 引入,这样可以极大的减少打包后的代码体积。另外,通过将前端静态文件存入 CDN 服务,特别是对于纯静态的网站,也极为常见。

fonts

使用 CDN 极为简单,现在的云服务厂商基本都有提供,我们可以通过按需使用付费,配合 OSS,即可享受迅速、便宜的快速加载。

那么我们简单更改 nextjs 代码,将原来使用 next/font 的相关代码改为使用自定义的 @font-face 即可。

@font-face {
  font-family: "__pingFangSc_903e63";
  src: url("https://cdn.xxx.com/fonts/PingFangSC-Regular.woff2")
    format("woff2");
  font-display: swap;
  font-weight: 400;
}

@font-face {
  font-family: "__pingFangSc_Fallback_903e63";
  src: local("Arial");
  ascent-override: 90.09%;
  descent-override: 20.06%;
  line-gap-override: 8.52%;
  size-adjust: 105.67%;
}

.__className_903e63 {
  font-family: "__pingFangSc_903e63", "__pingFangSc_Fallback_903e63";
}

这里我投了个懒,直接在浏览器中复制了原本 nextjs 生成的 @font-face 声明。其中的 font-displaysize-adjust,是对于替换字体的策略以及避免替换字体瞬间的布局闪动的定义。

images

在这次优化过程中,nextjs 最令我不满意的一点就是没有提供在 build 阶段生成图像的功能。之所以需要这个功能,是因为在某一尺寸的屏幕初次加载页面时,nextjs 会根据浏览器的判断,生成大小最合适的图片。当服务器性能不足时(如我),那么势必在初次生成时,消耗大量的时间。资源在传输上花费的时间其实并不多,更多的是在等待服务器图片的生成。

google 了一番,有大量的人都在吐槽这个问题(Optimize images during next build)。社区中自然是有一些解决方案,但都不符合我的需求,比如大多现有的解决方案都是为了解决在使用 Static Exports 时,提供预先生成图片。而我的服务是运行在容器中的。

我是否需要自己写一个插件或者库来解决这个问题?答案是不需要,因为我只需要将少量的大图片放在 CDN 即可(我不想引入新的问题)。

我选择了首页的三张轮播大图,以及另外四个主要页面的首图,通过 CDN 访问它们。这样一来,在访问主页时,不需要再即时生成图片。

<picture>
  <source srcSet={banner.avif} type="image/avif" />
  <source srcSet={banner.webp} type="image/webp" />
  <Image
    src={banner.webp}
    alt=""
    className="size-full object-cover object-[75%_center]"
    width={1920}
    height={984}
    priority={index === 0}
    unoptimized
  />
</picture>

阿里云提供了通过 url query 参数调整图片的功能,不过我没使用。我简单的使用 avif 和 webp 两种格式保存了图片, 优先使用 avif。

Conclusion

实际影响网站最终展示速度的因素还有很多,比如用户网络本身就比较慢,或者服务器原有的负荷就较高,分配给 nextjs 运行的当然就更少,等等。

当然,如果有钱,很多优化不用做就可以很快了。

🤖 希望有大佬能指正我的错误或优化我的流程,批评我、鞭策我。

Resources