我之前其实写过一些 cicd 的教程,但是写的太烂了,这篇算重构

直入正题,我会用我的一个 gin-rush-template 来做为 demo 演示

你可以模仿下面的过程为自己的项目实现自动化部署

我的 demo 的其实写的很烂的,但是你只需要知道它有三个特点,无需关注其他细节:

  1. 会从 config/config.yaml 读取配置文件
  2. 需要连接 MySQL 依赖
  3. 能提供一个 /ping 接口

我们先做到让它在本地跑起来,首先 clone 下来,然后跟随下面的操作

1
2
3
4
5
6
7
8
# 复制一份配置文件
cp config/config.example.yaml config/config.yaml

# 运行 MySQL 依赖
docker-compose -f docker-compose-env.yml up -d

# 运行 Go 程序
go run main.go

如果没有报错,并且 curl 能正常提供服务,那么就没有问题了

1
2
curl http://127.0.0.1:8080/ping
{"message":"pong"}

image-2024032273050526 PM

或者你可以在浏览器中手动访问 http://127.0.0.1:8080/ping


体验手动部署

安装 Docker

接下来将体验手动编译并部署 Docker Image 到服务器,我以本地 OrbStack 提供的 Debian12 arm64 虚拟机为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
nx@debian:~$ screenfetch
_,met$$$$$gg. nx@debian
,g$$$$$$$$$$$$$$$P. OS: Debian
,g$$P"" """Y$$.". Kernel: aarch64 Linux 6.5.13-orbstack-00122-g57b8027e2387
,$$P' `$$$. Uptime: 3h 56m
',$$P ,ggs. `$$b: Packages: 275
`d$$' ,$P"' . $$$ Shell: bash 5.2.15
$$P d$' , $$P Disk: 442G / 1.4T (32%)
$$: $$. - ,d$$' CPU: Apple - @ 8x 2GHz
$$\; Y$b._ _,d$P' RAM: 979MiB / 5250MiB
Y$$. `.`"Y$$$$P"'
`$$b "-.__
`Y$$
`Y$$.
`$$b.
`Y$$b.
`"Y$b._
`""""

请注意,本教程全程使用 arm64 架构(或称 aarch64 架构),而一般情况下云服务商提供的服务器为 amd64 架构(或称 x64 或 x86_64 架构)

服务器一般仅能运行当前的架构的 Docker Image,如果你的本地架构与目标架构不相同,则需要使用 bulidx 进行交叉编译,这并不在本文的讨论过程中

或者你可以跳过实践「手动部署」部分,而记得在后文编写 GitHub Actions 时记得选择与目标匹配的架构编译

首先是在服务器安装 Docker 和 Docker Compose,这一点可以前往官方文档查看

1
2
3
4
nx@debian:~$ docker -v
Docker version 20.10.24+dfsg1, build 297e128
nx@debian:~$ docker-compose -v
Docker Compose version v2.25.0

当然,你在本地也应当安装 Docker 和 Docker Compose,一般情况下 Docker Desktop 或者 OrbStack 会是很好的选择

如果你从未听说过 Docker 与 Docker Compose,可以前往 Bilibili 或者 YouTube 学习

编译 Docker Image

接下来从项目根目录的 Dockerfile 编译 Docker Image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM golang:latest as go-build-stage

ENV GOPROXY https://goproxy.cn,direct

WORKDIR /go/src/app

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

FROM scratch

WORKDIR /app

COPY --from=go-build-stage /go/src/app/main .

CMD ["./main"]
1
docker build -t gin-rush-template:v1 .

编译完成后检查得到的 Image

1
2
3
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
gin-rush-template v1 c21723202467 About a minute ago 21.1MB

推送至 Docker Hub

现在我们要将这个 Image 传递到服务器上,这里可以上传到某些镜像仓库中,或者导出为文件再在服务器上从文件导入

这里我们直接上传到 Docker Hub(或者你可以使用一些云厂商的服务,这就需要查看对应的文档)

假设你已经注册了 Docker Hub 的账号,请在终端使用 docker login 进行登陆

在推送镜像之前,需要确保镜像被正确标记,以便符合 Docker Hub 的格式要求

一般的格式为

1
<你的用户名>/<应用名>:<版本标签>

而我们目前的镜像并没有用户名,所以需要使用 docker tag 重新命名

1
docker tag gin-rush-template:v1 yourusername/gin-rush-template:v1

请将 yourusername 替换为你的 Docker Hub 用户名,比如我需要执行

1
docker tag gin-rush-template:v1 nxofficial/gin-rush-template:v1

现在,我们得到了一个名为 nxofficial/gin-rush-template 的镜像,它与之前的镜像指向一个相同的 ID,说明它们其实是一样的

1
2
3
4
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
gin-rush-template v1 c21723202467 14 minutes ago 21.1MB
nxofficial/gin-rush-template v1 c21723202467 14 minutes ago 21.1MB

下面,可以使用 docker push 推送镜像(如果被拒绝请先使用 docker login 进行登陆)

1
2
3
4
5
docker push nxofficial/gin-rush-template:v1
The push refers to repository [docker.io/nxofficial/gin-rush-template]
c84414e107f0: Pushed
3b8bd6ab22a4: Pushed
v1: digest: sha256:4d1b5f800ba3ec7022fc9dec77ec1e8fd9d2dc103825373ab2dec2c3b6015fb5 size: 734

现在在 Docker Hub 上已经能找到我们推送的镜像

image-2024032280040822 PM

你可以看见,这个镜像是 arm64 架构

拉取并运行镜像

下面的任务就是在服务器上拉取并运行镜像

首先找到 deploy 目录,其中的文件需要上传至服务器

1
2
3
4
5
6
tree .
.
├── config.yaml
└── docker-compose.yaml

1 directory, 2 files

其中 congfig.yaml 是要映射进 Docker 容器的配置文件

注意如何在 Docker 网络中访问某个容器提供的服务:直接使用服务名作为域名即可

1
2
3
Mysql:
Host: "mysql" # 而不是 127.0.0.1
Port: "3306"

docker-compose.yaml 用于编排服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
version: '2.1' # 之所以不用3是因为3砍了好用的健康检查而逼你用没人用的 Docker Swarm

networks:
gin-rush-template-net:
driver: bridge

services:
app:
image: nxofficial/gin-rush-template:v1 # 这里后面被改成了 latest,但是先使用 v1 体验一下
container_name: gin-rush-template-app
volumes:
- ./config.yaml:/app/config/config.yaml # 将配置文件映射进来
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
networks:
- gin-rush-template-net

mysql:
image: mysql:8.0
container_name: gin-rush-template-mysql
environment:
MYSQL_ROOT_PASSWORD: 12345678
MYSQL_DATABASE: gin-rush-template
TZ: Asia/Shanghai
healthcheck:
# MySQL 就绪检测
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s
retries: 10
privileged: true
restart: always
networks:
- gin-rush-template-net

之后运行即可,他会自动拉下来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
$ sudo docker-compose up
WARN[0000] /Users/nx/GolandProjects/gin-rush-template/deploy/docker-compose.yaml: `version` is obsolete
[+] Running 2/0
✔ Container gin-rush-template-mysql Created 0.0s
✔ Container gin-rush-template-app Created 0.0s
Attaching to gin-rush-template-app, gin-rush-template-mysql
gin-rush-template-mysql | 2024-03-22 21:21:20+08:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.36-1.el8 started.
gin-rush-template-mysql | 2024-03-22 21:21:20+08:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
gin-rush-template-mysql | 2024-03-22 21:21:20+08:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.36-1.el8 started.
gin-rush-template-mysql | '/var/lib/mysql/mysql.sock' -> '/var/run/mysqld/mysqld.sock'
gin-rush-template-mysql | 2024-03-22T13:21:21.278083Z 0 [Warning] [MY-011068] [Server] The syntax '--skip-host-cache' is deprecated and will be removed in a future release. Please use SET GLOBAL host_cache_size=0 instead.
gin-rush-template-mysql | 2024-03-22T13:21:21.279792Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.36) starting as process 1
gin-rush-template-mysql | 2024-03-22T13:21:21.284859Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
gin-rush-template-mysql | 2024-03-22T13:21:21.367493Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
gin-rush-template-mysql | 2024-03-22T13:21:21.494743Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
gin-rush-template-mysql | 2024-03-22T13:21:21.494766Z 0 [System] [MY-013602] [Server] Channel mysql_main configured to support TLS. Encrypted connections are now supported for this channel.
gin-rush-template-mysql | 2024-03-22T13:21:21.495550Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
gin-rush-template-mysql | 2024-03-22T13:21:21.504335Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
gin-rush-template-mysql | 2024-03-22T13:21:21.504354Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.36' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server - GPL.
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.078ms] [rows:-] SELECT DATABASE()
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [1.725ms] [rows:1] SELECT SCHEMA_NAME from Information_schema.SCHEMATA where SCHEMA_NAME LIKE 'gin-rush-template%' ORDER BY SCHEMA_NAME='gin-rush-template' DESC,SCHEMA_NAME limit 1
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.887ms] [rows:-] SELECT count(*) FROM information_schema.tables WHERE table_schema = 'gin-rush-template' AND table_name = 'user' AND table_type = 'BASE TABLE'
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.053ms] [rows:-] SELECT DATABASE()
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.200ms] [rows:1] SELECT SCHEMA_NAME from Information_schema.SCHEMATA where SCHEMA_NAME LIKE 'gin-rush-template%' ORDER BY SCHEMA_NAME='gin-rush-template' DESC,SCHEMA_NAME limit 1
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [1.952ms] [rows:-] SELECT * FROM `user` LIMIT 1
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.816ms] [rows:-] SELECT column_name, column_default, is_nullable = 'YES', data_type, character_maximum_length, column_type, column_key, extra, column_comment, numeric_precision, numeric_scale , datetime_precision FROM information_schema.columns WHERE table_schema = 'gin-rush-template' AND table_name = 'user' ORDER BY ORDINAL_POSITION
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.070ms] [rows:-] SELECT DATABASE()
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.190ms] [rows:1] SELECT SCHEMA_NAME from Information_schema.SCHEMATA where SCHEMA_NAME LIKE 'gin-rush-template%' ORDER BY SCHEMA_NAME='gin-rush-template' DESC,SCHEMA_NAME limit 1
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.534ms] [rows:-] SELECT count(*) FROM information_schema.statistics WHERE table_schema = 'gin-rush-template' AND table_name = 'user' AND index_name = 'idx_user_deleted_at'
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.039ms] [rows:-] SELECT DATABASE()
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.149ms] [rows:1] SELECT SCHEMA_NAME from Information_schema.SCHEMATA where SCHEMA_NAME LIKE 'gin-rush-template%' ORDER BY SCHEMA_NAME='gin-rush-template' DESC,SCHEMA_NAME limit 1
gin-rush-template-app |
gin-rush-template-app | 2024/03/22 13:21:26 /go/src/app/internal/global/database/mysql.go:36
gin-rush-template-app | [0.309ms] [rows:-] SELECT count(*) FROM information_schema.statistics WHERE table_schema = 'gin-rush-template' AND table_name = 'user' AND index_name = 'idx_user_email'
gin-rush-template-app | [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
gin-rush-template-app | - using env: export GIN_MODE=release
gin-rush-template-app | - using code: gin.SetMode(gin.ReleaseMode)
gin-rush-template-app |
gin-rush-template-app | [GIN-debug] POST /login --> gin-rush-template/internal/module/user.Login (3 handlers)
gin-rush-template-app | [GIN-debug] POST /register --> gin-rush-template/internal/module/user.Create (3 handlers)
gin-rush-template-app | [GIN-debug] GET /ping --> gin-rush-template/internal/module/ping.(*ModulePing).InitRouter.func1 (3 handlers)
gin-rush-template-app | [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
gin-rush-template-app | Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
gin-rush-template-app | [GIN-debug] Listening and serving HTTP on 0.0.0.0:8080
gin-rush-template-app | 2024/03/22 13:21:26 Init Module: User
gin-rush-template-app | 2024/03/22 13:21:26 Init Module: Ping
gin-rush-template-app | 2024/03/22 13:21:26 InitRouter: User
gin-rush-template-app | 2024/03/22 13:21:26 InitRouter: Ping

如果你的服务器位于国内,可能会遇到网络问题,但这不属于本文的内容

测试运行

在服务器上测试

1
2
curl 127.0.0.1:8080/ping
{"message":"pong"}

在外部测试

1
2
curl http://debian.orb.local:8080/ping
{"message":"pong"}

可见服务已经正常运行


实现自动化部署

为了实现自动化部署,我们需要有两个环节的自动化:

  1. 每次提交自动构建新镜像并推送至 Docker Hub
  2. 在 Docker Hub 每次收到新的更新时自动拉取并替换为新的 Image

我们可以先实现第二个自动化

自动拉取并替换

我们可以先更改一下代码,突出与之前的不同

1
2
3
4
5
6
7
8
 func (p *ModulePing) InitRouter(r *gin.RouterGroup) {
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
+ "version": "v2",
})
})
}

为了监听更新,我们可以使用 Watchtower ,他能自动监听镜像的更新并进行替换

1
2
3
4
5
6
7
8
9
watchtower:
image: containrrr/watchtower
container_name: gin-rush-template-watchtower
command: --interval 5
volumes:
- /var/run/docker.sock:/var/run/docker.sock # 需要将 Docker 的 sock 映射进去以控制 Docker
restart: always
networks:
- gin-rush-template-net

同时,为了始终拉取最新的版本,我们可以将 app 的预期版本设置为 latest

1
2
- image: nxofficial/gin-rush-template:v1
+ image: nxofficial/gin-rush-template:latest

然后在本地编译 Docker Image

1
2
3
4
5
# 构建镜像并添加多个标签
docker build -t nxofficial/gin-rush-template:v2 -t nxofficial/gin-rush-template:latest .
# 将两个标签都推送过去
docker push nxofficial/gin-rush-template:latest
docker push nxofficial/gin-rush-template:v2

image-2024032295026637 PM

但你可以看见 latest 和 v2 其实是一个版本,每次重复操作就可以保留每个版本的 Docker Image 并且将 latest 指向最新版本

image-2024032295053683 PMimage-2024032295100261 PM

现在,重新启动 docker-compose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
nx@debian:/Users/nx/GolandProjects/gin-rush-template/deploy$ sudo docker-compose down
WARN[0000] /Users/nx/GolandProjects/gin-rush-template/deploy/docker-compose.yaml: `version` is obsolete
[+] Running 3/0
✔ Container gin-rush-template-app Removed 0.0s
✔ Container gin-rush-template-mysql Removed 0.0s
✔ Network deploy_gin-rush-template-net Removed 0.0s
nx@debian:/Users/nx/GolandProjects/gin-rush-template/deploy$ sudo docker-compose up
WARN[0000] /Users/nx/GolandProjects/gin-rush-template/deploy/docker-compose.yaml: `version` is obsolete
[+] Running 7/7
✔ app 2 layers [⣿⣿] 0B/0B Pulled 32.2s
✔ 11af50565267 Already exists 0.0s
✔ 7eb7e9370331 Pull complete 25.0s
✔ watchtower 3 layers [⣿⣿⣿] 0B/0B Pulled 31.4s
✔ 57241801ebfd Pull complete 9.7s
✔ 3d4f475b92a2 Pull complete 9.4s
✔ b6a140e9726f Pull complete 21.0s
[+] Running 4/3
✔ Network deploy_gin-rush-template-net Created 0.0s
✔ Container gin-rush-template-watchtower Created 0.1s
✔ Container gin-rush-template-mysql Created 0.1s
✔ Container gin-rush-template-app Created 0.0s

看看,现在已经是 v2 了

1
2
curl 127.0.0.1:8080/ping
{"message":"pong","version":"v2"}

这时,再把版本改成 v3 并重新上传

1
2
3
docker build -t nxofficial/gin-rush-template:v3 -t nxofficial/gin-rush-template:latest .
docker push nxofficial/gin-rush-template:v3
docker push nxofficial/gin-rush-template:latest

发现 Watchtower 有响应了

1
2
3
4
5
6
gin-rush-template-watchtower  | time="2024-03-22T13:57:01Z" level=info msg="Session done" Failed=0 Scanned=3 Updated=0 notify=no
gin-rush-template-watchtower | time="2024-03-22T13:57:21Z" level=info msg="Found new nxofficial/gin-rush-template:latest image (e3283961517d)"
gin-rush-template-watchtower | time="2024-03-22T13:57:36Z" level=info msg="Stopping /gin-rush-template-app (cbfe32e3ed41) with SIGTERM"
gin-rush-template-app exited with code 2
gin-rush-template-watchtower | time="2024-03-22T13:57:37Z" level=info msg="Creating /gin-rush-template-app"
gin-rush-template-watchtower | time="2024-03-22T13:57:38Z" level=info msg="Session done" Failed=0 Scanned=3 Updated=1 notify=no

测试,貌似完美

1
2
curl 127.0.0.1:8080/ping
{"message":"pong","version":"v3"}

自动打包并上传

接下来就要实现每次自动打包并上传的功能了,这一般可以使用 GitHub Actions

在项目根目录下创建 .github/workflows/docker-publish.yaml

我选择了使用 bulidx 实现交叉编译以支持多种架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
name: Build and Push Docker Image

on:
push:
branches:
- main # 指定触发事件的分支,这里是 main 分支

jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v3

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# 记得在 GitHub 仓库的 Secrets 中添加 DOCKER_USERNAME DOCKER_PASSWORD DOCKER_REPOSITORY 三个环境变量
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Set short SHA
id: shortsha
run: echo "SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_ENV

- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
# 使用 commit hash 作为镜像 tag
tags: |
${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:latest
${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:${{ env.SHORT_SHA }}
platforms: linux/amd64,linux/arm64 # 为多个架构编译,如果你确定只需要其中一种,可以仅保留一种

同时请前往 GitHub 上配置你的环境变量,这样运行时就可以读取

image-20240322102120002 PM

之后推送 commit,等待自动化编译完成

image-20240322104728856 PM

image-20240322104704203 PM

实际上很多时候会使用 commit hash 作为 tag,我在这里也是这么处理的

回到服务器,发现 Watchtower 的确拉取了最新的镜像

1
2
curl 127.0.0.1:8080/ping
{"message":"pong","version":"v4"}

大功告成❤️