/blog/post/a5df0869-879b-45a2-94b4-7eea0e294c8f

博客建设

关于博客系统二三事(二):自动化部署

每一次版本大更新的时候都要手动部署,实在是太麻烦了,不如让它自动化吧(笑)

  • 日常
  • 博客建设
  • Docker
  • Docker Compose

December 5, 2024

上回书说道,彼时的 Nocturne,由多个部件组成,场面之混乱当属一绝。脑部一下每次大版本更新的时候都会出现各种各样奇怪的小情况(什么一不小心忘记更新依赖导致 npm build 完之后出现 hash mismatch (Next.js SSR 部分客户端报错(忘记 npm update 了),一不小心少了个环境变量导致哪个服务启动失败啊之类的)。考虑到其实每一次的操作并没有太大的区别,其实一切都可以通过自动化的流程来解决,于是便有了这篇文章。

当然,由于这篇文章并不是一次性写完的,比如说第一次落笔的日期是2024年12月5日,第二次完善(或者说完成?)的时间已经来到了25年的2月20日,所以说可能阅读体验会有所割裂 (什么,你问中间那段时间干什么去了?问就是摸鱼去了(笑)),我会努力将逻辑调整清楚的。

*现在有第三次了:2025-11-03.... 也许我该增加一个类似 change log 的板块。

Docker,启动!

项目组成非常简单,三个部分,前端,后端,数据库。为了方便维护(个人喜好),我将 compose.yaml 分割了一下,大致结构展示如下:

1nocturne 2├── .env 3├── frontend 4│ │── .dockerignore 5│ └── ... // 源码 6├── backend 7│ │── .dockerignore 8│ └── ... // 源码 9├── container 10│ │── dev 11│ │ ├── backend.containerfile 12│ │ └── frontend.containerfile 13│ └── release 14│ ├── backend.containerfile 15│ └── frontend.containerfile 16├── dev.compose.yaml 17├── prod.compose.yaml 18└── Makefile

从上往下的逻辑舒徐开始介绍:

  • .env: 这个文件包含一大堆环境变量,相当与是我的项目的配置文件,大致内容如下:

    1# database settings 2DB_HOST=localhost 3DB_PORT=5432 4 5# database & backend settings 6POSTGRES_PASSWORD=passwd 7POSTGRES_USER=nocturne 8POSTGRES_DB=nocturne 9POSTGRES_HOST=postgres 10 11# backend settings 12BLOG_GIT_URL=https://github.com/Lumither/blog-posts.git 13WORK_DIR=/nocturne 14 15# frontend settings 16API_LOCAL_URL=http://backend:3001 17API_REMOTE_URL=https://lumither.com/api
  • frontend: 前端源码文件夹,包含一个 .dockerignore 用来排除不希望放入容器的东西(不必要或者影响构建),示例如下(其实和 .gitignore 内容没有太大区别的样子):

    1/node_modules 2/.next 3/out 4/build 5 6.DS_Store 7 8*.pem 9.env*.local
  • backend: 如上,这是它的 .dockerignore:

    1/target 2/Cargo.lock

    其实这一部分的处理非常粗糙,但是在后面不影响结果,所以嘛....(开摆)

  • container: 一个用来存放 dockerfile 的目录,分开dev和 production 方便管理,待会我会详细谈谈 production 部分(dev 的部分不会涉及 CI/CD,而且也只在开发环境起作用,此时不需要 docker 反而更方便调试,所以不多进行说明)

  • dev.compose.yaml: 由名字可得,dev 环境的 Docker Compose 配置文件,不做过多解释。

  • prod.compose.yaml: 生产环境的 comopsefile,内容如下:

    1services: 2 frontend: 3 build: 4 context: ./frontend 5 dockerfile: ../container/release/frontend.containerfile 6 restart: always 7 ports: 8 - "3000:3000" 9 env_file: 10 - ./.env 11 12 backend: 13 build: 14 context: ./backend 15 dockerfile: ../container/release/backend.containerfile 16 args: 17 ARCH: ${ARCH} 18 restart: always 19 ports: 20 - "3001:3001" 21 env_file: 22 - ./.env 23 dns: 24 - 1.1.1.1 25 26 postgres: 27 image: postgres:16.3 28 ports: 29 - "5432:5432" 30 env_file: 31 - ./.env

    唯一值得一提的是我在 backend 那一部分添加了一个 ARCH,详细作用我会在后面体积。

  • Makefile: 就是用来构建的啦:

    1ARCH := $(shell uname -m) 2ifeq ($(ARCH), arm64) 3 ARCH := aarch64 4endif 5 6all: release 7 8release: 9 docker compose -f prod.compose.yaml build --build-arg ARCH=$(ARCH) 10 11dev: 12 docker-compose -f dev.compose.yaml up --build 13 14clean: 15 docker-compose -f prod.compose.yaml -f dev.compose.yaml rm -fsv

    同上,我稍候会提起为什么会有一个 if block 在这里。

由于在正真开始写这一团东西之前我还没有完全决定使用 Docker,毕竟还有 等其它的容器化方案。所以一开始创建文件的时候使用了 .containerfile 这种 unopinionated 的文件扩展名。

迭代

在一开始,我直接按照我平时部署服务的流程写了一遍 dockerfile,大概是这个样子的:

  • frontend.containerfile:

    1FROM node:latest 2WORKDIR /app 3ENV NEXT_TELEMETRY_DISABLED=1 4COPY package*.json ./ 5RUN npm install 6COPY . . 7RUN npm run build 8CMD npm start
  • backend.containerfile:

    1FROM rust:latest 2WORKDIR /app 3COPY . . 4RUN cargo build -r 5CMD cargo r -r

总而言之就是简单粗暴,但是可以跑。但是这里有个巨大的问题,那就是容器的体积:

NameSize
postgres630.85MB
nocturne-frontend3.59GB
nocturne-backend3.61GB

Hummm.. 对于一个普普通通的服务来说,这个大小有点难以接受,优化,必须优化。经过小小的资料查询,我发现了一个有意思的东西,既来自谷歌的 distroless 方案

"Distroless" images contain only your application and its runtime dependencies. They do not contain package managers, shells or any other programs you would expect to find in a standard Linux distribution.

对于 nodejs 服务,它提供了 gcr.io/distroless/nodejs22,而一般的“mostly-statically compiled“可执行提供了 gcr.io/distroless/cc

frontend.containerfile

对前端配置稍加修改,配合 Multi-stage builds,便获得了如下 frontend.containerfile

1FROM node:22 AS builder 2WORKDIR /app 3ENV NEXT_TELEMETRY_DISABLED=1 4COPY package*.json ./ 5RUN npm install 6COPY . . 7RUN npm run build 8 9FROM gcr.io/distroless/nodejs22 AS production 10ENV NEXT_TELEMETRY_DISABLED=1 11COPY --from=builder /app/next.config.mjs ./ 12COPY --from=builder /app/.next/standalone ./ 13COPY --from=builder /app/.next/static ./.next/static 14ENV HOSTNAME="0.0.0.0" 15CMD ["./server.js"]

[!NOTE] 除 gcr.io/distroless/ccgcr.io/distroless/basegcr.io/distroless/static 以外,其它带特定语言支持的 distroless 都自带默认的 ENTRYPOINT,所以应使用

1CMD ["./server.js"]

而不是

1ENTRYPOINT ["./server.js"]

这个版本去除了没必要的 node_modules,同时将 Next.js 设置为了 standalone 构建方案,这样的话它就会自动抽取依赖以获得最小的打包体积。有一点需要注意的是 COPY --from=builder /app/.next/static ./.next/static 这一步非常重要,各种 CSS 都被单独提了出来,需要将其拷贝至对应位置,否则样式就会完全失效。

backend.containerfile

我对 backend.containerfile 做了较大的改变,现在是这个样子的:

1FROM rust:latest AS builder 2WORKDIR /app 3COPY . . 4RUN ["cargo", "build", "-r"] 5 6FROM gcr.io/distroless/cc AS production 7ARG ARCH 8COPY --from=builder /app/target/release/api_server ./api_server 9COPY --from=builder /usr/lib/${ARCH}-linux-gnu/libz.so.1 /usr/lib/${ARCH}-linux-gnu/ 10ENTRYPOINT ["./api_server"]

[!NOTE] 注意:gcr.io/distroless/cc里面由于缺少内置的 shell,所以你需要以 vector form 来写入 ENTRYPOINT。

1# this is valid 2ENTRYPOINT ["./api_server"] 3 4# these are not 5ENTRYPOINT "./api_server" 6ENTRYPOINT ./api_server

这个 dockerfile 里面有一个参数 ARCH,用来表示当前计算机的 CPU 架构,(一般)POSIX 系统下可以通过 uname -m 获得(至少 IBM 的机子不是1)。由于 gcr.io/distroless/cc 中没有提供 zlib支持,而我的 nocturne 动态连接了它,其中一种解决方法就是直接将 builder 中的直接复制过去。libz.so.1 的路径不是固定的,它会根据计算机架构而变化,所以我们需要获得当前(生产环境)的架构而确定 libz.so.1 的位置。这里我们通过外部 Makefile 自动获取。

1ARCH := $(shell uname -m) 2ifeq ($(ARCH), arm64) 3 ARCH := aarch64 4endif

由于 MacOS 系统特性会返回 "arm64",而 Linux 社区一般使用 "aarch64"(至少在动态库路径上),所以这里需要做一个小转换提高通用性。关于两者之间的区别可以参考这篇 Stack Overflow

至此,nocturne 容器化完成,可以通过一下命令构建并启动:

1# 构建 release 版本 2make 3 4# 启动 5docker compose -f prod.compose.yaml up
NameSize
postgres (unoptimized)630.85MB
nocturne-frontend3.59GB -> 361MB
nocturne-backend3.61GB -> 61.7MB

*最终的 image 大小,感觉 frontend 还是有较大的优化空间的(碎碎念)

[!NOTE] 顺带一提,distroless 并不是唯一的方案,另一个常用的方案是 Alpine

A minimal Docker image based on Alpine Linux with a complete package index and only 5 MB in size!

听起来不错,但是有个小问题,它是一个基于 Alpine Linux 的方案,而 Alpine Linux 是使用的是 musl libc。先不说现在已知 Rust 在 musl 上有性能兼容性问题2,市面上相当一部分的组件在设计的时候好像就没有这方面太多的考虑,所以为了兼容性,使用经典的 glibc 自然就是最稳妥的选择了。

CI/CD

时间又向后拨几个月(8个月,拖延病没救了(确信),终于拿出了一版可以用的方案。

首先,隆重介绍 cr.lmt.moe,一个 Harbor 实例,说白了就是 self-host 了一个 container registry,跑在家里的 pve 上面,由 cloudflare tunnel 将服务暴露(不过目前在 push image 时有概率会 HTTP 524 导致的上传失败,具体原因还没找出来,也许以后会换掉这个方案)。当然,也可以使用 github 的 cr 服务,如果没记错的话对公开仓库是无限量免费的,使用上也不会有什么区别,至少都是符合 OCI 标准的(当然,也许微软的服务器可能会快点?),所以关于 Harbor 相关的处理不会在这里提及,也许以后我会来说说。

方案很简单,github action 自动构建容器,然后 push 到 rc 上,再由自己的服务器去拉取运行。目前的 workflow 大概是这个样子的:

1name: build release nocturne image 2on: 3 push: 4 branches: 5 - release 6 7env: 8 REGISTRY: cr.lmt.moe 9 TAG: release 10 PROJECT: nocturne 11 12 13jobs: 14 build: 15 runs-on: ubuntu-latest 16 steps: 17 - name: checkout 18 uses: actions/checkout@v4 19 20 - name: setup builder 21 uses: docker/setup-buildx-action@v3 22 23 - name: registry login 24 uses: docker/login-action@v3 25 with: 26 registry: ${{ env.REGISTRY }} 27 username: ${{ secrets.REGISTRY_USERNAME }} 28 password: ${{ secrets.REGISTRY_PASSWORD }} 29 30 - name: build containers 31 run: | 32 make build 33 34 - name: tag and push 35 run: | 36 PROJECT_NAME=$(basename "$PWD") 37 38 ALL_IMAGES=$(docker compose -f prod.compose.yaml config --services 2>/dev/null) 39 PULLED_IMAGES=$(docker compose -f prod.compose.yaml config 2>/dev/null \ 40 | awk '/image:/ {print $2}' \ 41 | sort -u \ 42 | sed 's/:.*//' \ 43 ) 44 TO_PUSH=$(comm -23 \ 45 <(echo "$ALL_IMAGES" | sort) \ 46 <(echo "$PULLED_IMAGES") \ 47 ) 48 49 FMT_STRING=$(echo "$TO_PUSH" | xargs printf '%s, ' | sed 's/, $//') 50 echo "services to push: $FMT_STRING" 51 52 for SERVICE in $TO_PUSH; do 53 LOCAL_TAG="${PROJECT_NAME}-${SERVICE}:latest" 54 REMOTE_TAG="${REGISTRY}/${PROJECT}/${SERVICE}:${TAG}" 55 56 if docker image inspect "$LOCAL_TAG" >/dev/null 2>&1; then 57 echo "Pushing $REMOTE_TAG" 58 docker tag "$LOCAL_TAG" "$REMOTE_TAG" 59 docker push "$REMOTE_TAG" 60 else 61 echo "warn: image $LOCAL_TAG not found, skipping $SERVICE" 62 fi 63 64 done

稍微注意的就是可以通过跳过 pull 下来的 image 来减小不必要的传输。Makefile 的定义是这个样子的:

1ARCH := $(shell uname -m) 2ifeq ($(ARCH), arm64) 3 ARCH := aarch64 4endif 5 6ifneq (,$(wildcard ./.env)) 7 include ./.env 8 export 9endif 10ifneq (,$(wildcard ./.env.local)) 11 include ./.env.local 12 export 13endif 14 15USE_CR ?= false 16CHANNEL ?= release 17ifeq ($(USE_CR), true) 18 COMPOSE_FILE := prod.cr.compose.yaml 19else 20 COMPOSE_FILE := prod.compose.yaml 21endif 22 23DOCKER_ENV := ARCH=$(ARCH) TAG=$(CHANNEL) 24DOCKER_COMPOSE := $(DOCKER_ENV) docker compose -f $(COMPOSE_FILE) 25 26.PHONY: all 27all:up 28 29.PHONY: build 30build: 31 $(DOCKER_COMPOSE) pull 32 $(DOCKER_COMPOSE) build 33 34.PHONY: up 35up: 36 $(DOCKER_COMPOSE) up -d 37 38.PHONY: down 39down: 40 $(DOCKER_COMPOSE) down 41 42.PHONY: clean 43clean: 44 $(DOCKER_COMPOSE) down --rmi all --volumes

相比之前我们会多一个对 USE_CR, CHANNEL 环境变量的检测,如果前者为 true,那么将使用拉取 cr 版本的 compose file,反之将使用本地构建的版本,CHANNEL 是用来表示版本的,考虑到我会有 canaryrelease 两种通道,虽然当下并没有什么区别。然后下面的是 cr 版本的 compose file:

1services: 2 frontend: 3 depends_on: 4 - backend 5 image: cr.lmt.moe/nocturne/frontend:${TAG} 6 restart: always 7 ports: 8 - "0.0.0.0:3000:3000" 9 env_file: 10 - ./.env 11 12 13 backend: 14 depends_on: 15 - migrate 16 image: cr.lmt.moe/nocturne/backend:${TAG} 17 restart: always 18 ports: 19 - "0.0.0.0:3001:3001" 20 env_file: 21 - ./.env 22 - ./.env.local 23 dns: 24 - 1.1.1.1 25 26 27 migrate: 28 image: postgres:17 29 depends_on: 30 postgres: 31 condition: service_healthy 32 env_file: 33 - ./.env 34 - ./.env.local 35 volumes: 36 - ./migration/ddl:/migrations:ro 37 - ./migration/migration.sh:/migration.sh:ro 38 entrypoint: [ "/bin/sh", "/migration.sh" ] 39 40 41 postgres: 42 image: postgres:17 43 ports: 44 - "5432:5432" 45 env_file: 46 - ./.env 47 - ./.env.local 48 healthcheck: 49 test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] 50 interval: 5s 51 timeout: 3s 52 retries: 10 53 start_period: 5s 54 restart: always

相比之前的版本多一个 migrate 容器,用来初始化数据库,修复了之前在数据库启动失败的时候后端无限重启导致服务不可用的问题。同时将 port binding 的部分增加了对所有流量的监听,而不是只处理本机流量。

至此,自动构建完成了,然后就是自动更新运行的容器的部分。<待续>

Footnotes

  1. https://en.wikipedia.org/wiki/Uname#Examples

  2. https://superuser.com/a/1820423/2021323

© 2024-2025 Lumither Tao

Powered by Next.js and Rust, built with passion and love