GOLANG子模块移动与GITHUB ACTION工作流重构
背景
我在 《从GITHUB ACTION起搭建简易CI/CD系统》 一文中提到了一个简单的 GITHUB CI 工作流,它大大节约了我在个人运维上花费的时间。
但是因为我给我的线上环境降了配置(典中典 1c2g),暴露出了很多问题:打包需要消耗大量资源(特别是内存资源不足的情况下,需要通过 --max-old-space-size=4096 和 swap 强行打包前端),会导致服务器上的各种服务立刻处于不可用状态。(虽然这也让我养成了一个习惯:本地完整开发完功能再压缩推到服务器、以及新建开发分支。)
更难受的是:更新服务后我往往会在后台记录更新日志,但此时刚好服务不可用,就很影响体验。于是就萌生了一个想法:把打包也拆分到 GitHub 上做。
同时个人项目往往是“浮沙筑高台”,早期快速起步留下的技术债,后面总得花时间还。我是先开发了 platform 的后端再开发前端,导致前端工程被嵌在了后端中:不仅难看,而且在 IDE 开发时还需要把前端文件夹排除掉(不然搜索结果一堆不想关的文件,node_modules 更是呵呵呵)。刚好借这个机会一起改一下。
GIT与GOLANG子模块改造
项目架构重构
改动前项目架构如下:
backend
mian_go_lib
frontend
.git相关
.go.mod相关
.go.work相关
可见 Go 项目中包含依赖库(这个通过 go.mod)是没问题的,但还包裹了一整个 frontend。
首先需要把 go.mod go.sum go.work go.work.sum 这四个 Go 工程文件移动进 backend 里面。这其实改变了 Go 工程的根目录,所以需要注意修改 go.work 中的路径。在这个例子里面,就是将 ./mian_go_lib 改成 ../mian_go_lib。
同时因为 Go 工程在 Git 中的路径变了,需要将 go.mod中项目路径 改成真实路径:
module github.com/intmian/platform/backend
需要注意:如果你的工程文件被别人以依赖库引入,那引入方会失效。
然后我们自测一下:cd 到 main 目录,build 成功不出现报错就没问题了。
Git 子模块移动
然后项目就变成以下结构:
backend
mian_go_lib
frontend
.git相关
出于编辑方便的考虑,最好也将 mian_go_lib 移动到后端工程内。这样 Goland 可以直接编译(不用担心搞坏 Goland 的工程识别)。
顺便一提:之所以 mian_go_lib 被引入进项目内,是因为我需要随用随改(如果以 go mod 下载会有几分钟的延迟,我不可能在一个人的独立项目里面成立中台组、单独写用例)。
首先需要确定本地没有乱七八糟的提交:
PS C:\GITHUB\platform> git status
On branch master
Your branch is up to date with 'origin/master'.
nothing to commit, working tree clean
PS C:\GITHUB\platform> git submodule status
2f059c4833bd4474145f4dda3b7de26c7d33ac6b mian_go_lib (golimit-180-g2f059c4)
然后需要修改 .gitmodules 文件,将老的依赖删除。清除如下:
[submodule "mian_go_lib"]
path = mian_go_lib
url = http://github.com/intmian/mian_go_lib
将文件加入暂存区(不然 git 读取不到):
git add .gitmodules
删除子模块:
git submodule deinit -f -- mian_go_lib
git rm -f mian_go_lib
重新拉子模块:
git submodule add http://github.com/intmian/mian_go_lib backend/mian_go_lib
如果你的主工程不依赖于子工程的最新状态(在此例子中不存在),需要 checkout 第一步中的节点:
git checkout 2f059c4833bd4474145f4dda3b7de26c7d33ac6b
同步子模块:
git submodule sync
git submodule update --init --recursive
看一下有没有玩坏:
git status
一般此时不会有奇怪的提交。
移动到根目录然后提交(不可分布提交):
cd ..
git commit -m "refac submoudules: 后端依赖库移动到后端"
正常 push 就行。
建议单独弄一个分支,避免玩坏。
重构 GITHUB ACTION
原来的 action 运行脚本如下:
name: 自动部署
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: SSH登录
uses: webfactory/ssh-agent@v0.5.3
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
- name: 执行更新脚本
run: |
ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'source ~/.zshrc && zsh 更新脚本'
更新脚本大概是这样(原封不动贴一下):
base_addr="/root/data/plat"
# 拉取最新代码
cd $base_addr/platform
git fetch origin
git reset --hard origin/master
git clean -dfx # 这个命令用于删除未跟踪的文件和目录
git reset --hard
git submodule update --init --recursive --force
# 前端打包
cd $base_addr/platform/frontend
yarn install --frozen-lockfile
NODE_OPTIONS="--max-old-space-size=4096" yarn run build
source_dir="$base_addr/platform/frontend/dist"
target_dir="$base_addr/pack/front"
echo "前端已打包"
# 编译后端
cd $base_addr/platform
# export GOPROXY=https://goproxy.cn
go build -o "$base_addr/pack/platform_back_build" ./backend/main/main.go
echo "后端已编译"
# 查找并停止旧的后台进程(通过进程名 'platform_back' 来识别)
pid=$(pgrep -f "$base_addr/pack/platform_back")
if [ -n "$pid" ]; then
echo "正在停止旧的后台进程,PID: $pid"
kill "$pid"
# 等待进程退出的循环逻辑
for i in {1..10}; do # 最多等待100秒
if ps -p "$pid" > /dev/null; then
echo "等待进程退出中..."
sleep 2
else
echo "旧的后台进程已成功停止"
break
fi
done
# 如果超时还未退出,强制终止
if ps -p "$pid" > /dev/null; then
echo "进程未在规定时间内退出,强制终止"
kill -9 "$pid"
echo "旧的后台进程已被强制终止"
fi
else
echo "未找到旧的后台进程"
fi
# 替换前端
# 删除目标目录下的所有文件
rm -rf $target_dir/*
# 拷贝源目录下的所有文件到目标目录,这个文件夹被挂在在caddy镜像的容器内部,被暴露至互联网
cp -r $source_dir/* $target_dir/
echo "前端已替换"
# 替换后端
mv $base_addr/pack/platform_back_build $base_addr/pack/platform_back
echo "后端已替换"
# 后台运行新的进程
log_dir="$base_addr/pack/log"
# 检查 log 目录是否存在,如果不存在则创建它
if [ ! -d "$log_dir" ]; then
mkdir -p "$log_dir"
echo "日志目录 $log_dir 已创建"
fi
timestamp=$(date +"%Y%m%d_%H%M%S")
log_file="$log_dir/${timestamp}_svr.log"
cd $base_addr/pack
nohup "$base_addr/pack/platform_back" > "$log_file" 2>&1 &
现在改成下面这个工作流(按前后端分别 build,然后在 deploy 里打包产物 scp 到服务器):
name: 构建并部署 platform
on:
push:
branches:
- master
jobs:
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
steps:
# 1. 拉代码
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
# 2. Node 环境
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 18
cache: yarn
cache-dependency-path: frontend/yarn.lock
# 3. 前端构建
- name: Build frontend
working-directory: frontend
run: |
yarn install --frozen-lockfile
yarn build
# 4. 上传前端构建产物
- name: Upload frontend dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: frontend/dist
build-backend:
name: Build Backend
runs-on: ubuntu-latest
steps:
# 1. 拉代码
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
# 2. Go 环境
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
# 3. 后端构建(完全保持你原来的命令)
- name: Build backend
working-directory: backend
run: |
go build -o ./main/platform_back ./main/main.go
# 4. 上传后端产物
- name: Upload backend binary
uses: actions/upload-artifact@v4
with:
name: backend-bin
path: backend/main/platform_back
deploy:
name: Deploy
runs-on: ubuntu-latest
needs:
- build-frontend
- build-backend
steps:
# 1. 下载前端产物
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: release/front
# 2. 下载后端产物
- name: Download backend binary
uses: actions/download-artifact@v4
with:
name: backend-bin
path: release
# 3. 打包
- name: Prepare release package
run: |
tar czf release.tar.gz release
# 4. SSH key
- name: Setup SSH
uses: webfactory/ssh-agent@v0.5.3
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
# 5. 上传并部署
- name: Upload & deploy
run: |
scp -o StrictHostKeyChecking=no release.tar.gz \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/root/data/plat/pack/
ssh -o StrictHostKeyChecking=no \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
"bash /root/data/plat/updateAfterGA.sh"
服务器侧用一个新的脚本 updateAfterGA.sh 来完成解压、替换和重启:
#!/usr/bin/env bash
set -e
BASE_DIR="/root/data/plat"
PACK_DIR="$BASE_DIR/pack"
RELEASE_DIR="$PACK_DIR/release"
FRONT_TARGET="$PACK_DIR/front"
BIN_TARGET="$PACK_DIR/platform_back"
LOG_DIR="$PACK_DIR/log"
echo "== 解压新版本 =="
rm -rf "$RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
tar xzf "$PACK_DIR/release.tar.gz" -C "$PACK_DIR"
echo "== 停止旧进程 =="
pid=$(pgrep -f "$BIN_TARGET" || true)
if [ -n "$pid" ]; then
echo "Stopping old process: $pid"
kill "$pid"
for i in {1..10}; do
if ps -p "$pid" > /dev/null; then
sleep 2
else
break
fi
done
if ps -p "$pid" > /dev/null; then
kill -9 "$pid"
fi
fi
echo "== 替换前端 =="
rm -rf "$FRONT_TARGET/*"
mkdir -p "$FRONT_TARGET"
cp -r "$RELEASE_DIR/front"/* "$FRONT_TARGET/"
echo "== 替换后端 =="
cp "$RELEASE_DIR/platform_back" "$BIN_TARGET"
chmod +x "$BIN_TARGET"
echo "== 启动服务 =="
mkdir -p "$LOG_DIR"
timestamp=$(date +"%Y%m%d_%H%M%S")
cd "$PACK_DIR"
nohup "$BIN_TARGET" > "$LOG_DIR/${timestamp}_svr.log" 2>&1 &
echo "== 检查服务状态 =="
# 等1s后检查后端进程是否启动,检测闪崩
sleep 1
new_pid=$(pgrep -f "$BIN_TARGET" || true)
if [ -n "$new_pid" ]; then
echo "后端进程已启动,PID: $new_pid"
else
echo "后端进程未启动,请检查日志!"
exit 1
fi
# 检查前端文件是否存在
if [ -d "$FRONT_TARGET" ] && [ "$(ls -A "$FRONT_TARGET")" ]; then
echo "前端文件已存在"
else
echo "前端文件不存在,请检查部署!"
exit 1
fi
echo "== 部署完成 =="
时间大大节约:从原来的 8 分钟变成了一分多一点。


当然还有两个地方可以改进:
- 可以将前后端触发条件改成:对应文件夹有改动才编译
- 可以将部署脚本也放在 GitHub 上,然后 CI 触发时传过去
但是以后再说了。最近写点东西基本也是周三活动日或者中午忙里偷闲写的,有空下次一定吧。
也不想写一个很长的后记来同步最近发现的技术玩具和项目、个人近况了,以后空了再说吧。