博客前端用的是 Yohaku,后端是 Mix Space。因为官方没有提供 Docker镜像,而我还有些自定义的修改,所以就在自己的 next-blog 仓库里补了构建流程。
服务器上的其他服务基本都交给 Docker 管理,博客也想放在一起。服务器上还有一个定时同步任务,每天把上游更新同步到自己的分支,保留我添加的修改,再推回 GitHub。推送后由 Actions 构建镜像、上传 GHCR,服务器运行这个镜像。之后更新代码,不用再在服务器上装依赖、编译一次。
这篇记录一下我现在的配置。只涉及前端,Mix Space 后端、数据库和域名反向代理需要另外准备好。
构建镜像
项目是一个 monorepo,网页部分在 apps/web,用 pnpm 管理依赖。Next.js 配置里已经启用了 standalone 输出:
output: 'standalone',
构建完成后,可以用生成的 server.js 启动,不需要把整个开发目录都放进运行容器。
我的 Dockerfile 分成了 base、deps、builder 和 runner 几个阶段。前面准备环境、安装依赖,中间构建,最后复制运行所需的文件。
基础环境用的是 node:lts-alpine,通过 Corepack 启用 pnpm:
FROM node:lts-alpine AS base
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable
RUN npm install -g --arch=x64 --platform=linux sharp
FROM base AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY . .
RUN pnpm install
这里的 Sharp 是图片处理依赖。我目前的配置写死了 Linux x64,记录在这里,免得以后换到 ARM 机器还直接照搬。
node:lts-alpine 也是一个会变的标签。如果之后要重新整理这份 Dockerfile,我会把 Node 版本固定下来,减少同一份代码隔一段时间构建出不同结果的情况。
API 地址在构建时传入
构建阶段的主要配置如下,省略了当前工作流没有传入的其他参数:
FROM base AS builder
RUN apk update && apk add --no-cache git
WORKDIR /app
COPY --from=deps /app/ .
ENV NODE_ENV=production
ARG BASE_URL
ENV BASE_URL=${BASE_URL}
ENV NEXT_PUBLIC_API_URL=${BASE_URL}/api/v3
ENV NEXT_PUBLIC_GATEWAY_URL=${BASE_URL}
RUN pnpm --filter @yohaku/web build
BASE_URL 填后端的公开访问地址,不带末尾的 /api/v3,Dockerfile 会拼上这一段。比如 API 通过 https://blog.example.com/api/v3 访问,这里就填 https://blog.example.com。
我当前版本使用的是 /api/v3。旧配置里可能还是 /api/v2,不能只改个域名就继续用,要和实际后端版本对应。
这几个变量里有 NEXT_PUBLIC_ 前缀,浏览器使用的值会在构建时写入产物。所以换 API 地址,需要重新构建镜像。只在 Compose 里改环境变量,再重启容器,不能替换已经编译进去的地址。
也因为这个原因,我构建出来的镜像带着自己的后端地址,并不是一个随便换个配置就能给其他站点用的通用镜像。
standalone 的目录
运行阶段最需要注意的是复制位置。这个项目的启动文件在 apps/web/server.js,不是 /app/server.js。
我现在保留的是下面这几行:
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/.next/server ./apps/web/.next/server
EXPOSE 2323
ENV PORT=2323
ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp
CMD ["sh", "-c", "echo 'Mix Space Web [Yohaku] Image.' && node apps/web/server.js"]
public 和 .next/static 都单独复制。只拿 standalone 目录,并不等于图片、字体、JS 和 CSS 这些静态资源都一起准备好了。
上面是我仓库里实际使用的复制方式,其中额外复制了 .next/server,不代表所有 Next.js 项目都需要原样加这一行。
完整的运行阶段还安装了 fontconfig、霞鹜文楷和 Geist 字体,并执行 fc-cache 更新字体缓存。这部分我也保留在镜像里,容器启动时不再临时下载。
每天同步一次上游
自己的仓库除了上游代码,还加了 Dockerfile、镜像构建工作流,以及博客的自定义修改。只更新一次不够,后面还要继续跟上游走,又不能直接把自己的分支覆盖掉。
我在服务器的 1Panel 里建了一个 Shell 计划任务,名字就叫“同步代码”,每天凌晨 00:01 执行一次,调度表达式是:
1 0 * * *
仓库里用 origin 指向自己的仓库,upstream 指向上游。自己的分支是 master,上游是 main,这里两个名字不一样。
平时说“合并上游”,不过我这个脚本实际用的是 rebase。它先切到自己的分支,拉取自己远端的更新,再抓取上游分支。需要更新时,把自己的提交重新应用到上游代码之后。
下面只摘出主要的 Git 操作,省略了原脚本的目录、远端配置和更新判断,不能直接当作完整定时任务复制:
git checkout master
git pull --rebase origin master
git fetch upstream main
# 原脚本在检查更新之后执行这一段
if git rebase upstream/main; then
git push origin master --force-with-lease
else
git rebase --abort
exit 1
fi
这样,上游的新提交会进来,我自己增加的修改也会重新应用。比如构建镜像的配置,不用每次更新之后再手动补一遍。
但“保留自己的修改”也有前提:这些改动要已经提交到 Git,而且能和上游的新代码兼容。它不会替我判断两边同时修改的逻辑应该怎么取舍。发生冲突时,脚本会中止这次 rebase,需要我再检查和处理。
rebase 之后提交历史可能改变,所以推送用的是 --force-with-lease。它会检查远端是否符合本地预期,比直接强推多一道保护,但也不是随便覆盖远端的保证。自己的其他设备同时在改这个分支时,仍然需要注意同步。
同步完成并推送到 GitHub 后,就会触发后面的 Actions:
1Panel 每天 00:01 运行同步脚本
↓
抓取上游 main,rebase 到自己的 master
↓
推送到自己的 GitHub 仓库
↓
GitHub Actions 构建并推送镜像到 GHCR
↓
服务器拉取镜像、重新创建前端容器
最后一步单独列出来,是因为这个同步脚本里没有 Docker 更新命令。已经确认自动完成的是代码同步和镜像打包,镜像构建成功不代表线上容器也换成了新版本。
GitHub Actions
镜像放在 GHCR,地址是:
相同格式:ghcr.io/<仓库所有者>/<仓库名>
我的镜像:ghcr.io/xgenya/next-blog
仓库的 Actions Secrets 里配置一个 BASE_URL,值就是前面说的后端地址。推送镜像用 Actions 自带的 GITHUB_TOKEN,工作流需要 packages: write 权限。
下面是按现有工作流删去注释、精简标签规则后的版本:
name: Build Docker image
on:
workflow_dispatch:
push:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BASE_URL=${{ secrets.BASE_URL }}
提交代码或者手动运行工作流,都会构建并推送镜像。latest 用来拉取最新构建,SHA 标签用来区分具体版本。
这份配置的 push 没有限制分支,多分支开发时,每个分支推送都有可能更新 latest。如果只想发布主分支,就要补上分支限制。
仓库原来的工作流还配置了 QEMU,不过没有指定多平台构建参数,不能因此就认为镜像同时支持 amd64 和 arm64。
这里也要分清:镜像推送成功后,服务器不会自动更新。部署还需要下面这一步。
服务器上用 Compose 启动
我线上容器叫 next-blog,Compose 服务名还沿用了以前的 shiro。名字没有影响运行,更新时记得用对服务名就行。
按当前镜像、端口和挂载整理出来的配置如下,重启策略在示例中使用 unless-stopped:
services:
shiro:
image: ghcr.io/xgenya/next-blog:latest
container_name: next-blog
restart: unless-stopped
ports:
- "2323:2323"
volumes:
- ./public:/app/apps/web/public
public 的挂载需要留意。宿主机目录会盖住镜像里的同名目录,如果拿一个空目录挂进去,镜像自带的静态文件也会被遮住。
我服务器上已经有对应的 public 目录。第一次部署、又没有需要单独维护的静态文件,可以先去掉这项挂载,让容器使用镜像内的文件。
在服务器的 Compose 目录里执行:
docker compose pull shiro
docker compose up -d shiro
如果 GHCR 镜像设为私有,需要先在服务器上登录 GHCR,使用有读取该镜像权限的凭据。这里的仓库和镜像地址都应替换成自己的,也不要把登录凭据写进 Compose。
前端监听 2323,外面再通过反向代理接域名和 HTTPS。我这里用的是 OpenResty。页面和后端 API 的路由要分别配置,不能把整个域名下的请求都转给前端。
更新后检查一下
我一般会先看容器状态和启动日志:
docker compose ps
docker compose logs --tail=100 shiro
curl -I http://127.0.0.1:2323
能返回 HTTP 响应之后,再从域名打开首页和一篇文章,看看内容、图片和样式有没有问题。容器启动成功只能说明进程起来了,API 地址配错时,页面仍然可能拿不到数据。
后续更新还是 pull 和 up -d。如果需要退回某次构建,就把 Compose 的镜像标签改成保留的 SHA 标签,再重新拉取、启动;具体标签以 GHCR 里的实际值为准。
配置目前还有可以整理的地方,比如依赖安装前就复制了整个项目,代码改动也会影响依赖层缓存,Node 版本也没有固定。另外,当前 Dockerfile 保留了一些密钥相关的 ARG 和 ENV 声明,但这份 Actions 只传了 BASE_URL。以后真要用到构建密钥,不应直接通过这些参数塞进去,得单独处理,避免留在构建记录或产物里。
现在这套已经在给博客跑前端了。上游更新由定时任务同步,我自己的提交也会一起参与构建。等镜像打包完成,再在服务器拉取更新,暂时这样用着。