实战 Runbook:MySQL + Go + Vue 全链路 CI/CD 真实发布
前面 05 装好了 Harbor / Gitea / Jenkins / ArgoCD 四件套,但"装好了"不等于"能用"。这篇用一个真实的三层应用(Vue 前端 + Go 后端 + MySQL)把整条链路接通,并跑一次真实发布和一次GitOps 滚动升级,证明这套环境能落地生产。
全程在本集群真机上跑通,下面的输出都是实测。
0. 目标链路
开发者 git push 源码 (demo-app)
→ Jenkins 拉代码 (Pipeline from SCM)
→ Kaniko 在 Pod 内构建 backend + frontend 镜像
→ 推到 Harbor (10.0.24.28:30002/bootcamp/*)
→ Jenkins 改 部署repo (demo-deploy) 的镜像 tag 并 push 回 Gitea ← CI 与 CD 的交接点
→ ArgoCD watch demo-deploy,自动 sync
→ K8s 滚动更新 demo 命名空间的 Pod
应用运行时:Vue(nginx) --/api/--> Go backend --> MySQL(Longhorn PVC)
两个 repo 分开是 GitOps 的关键:源码 repo 触发 CI,部署 repo 描述期望状态、被 ArgoCD watch。镜像 tag 的每次变更都在 Git 里可审计、可回滚。
1. 应用项目结构
源码仓库 demo-app:
demo-app/
├── backend/
│ ├── go.mod # 依赖 go-sql-driver/mysql
│ └── main.go # /api/items (GET/POST) + /api/info + /healthz,启动建表
├── frontend/
│ ├── package.json # vue3 + vite
│ ├── vite.config.js
│ ├── index.html
│ ├── src/main.js
│ ├── src/App.vue # 调 /api/items 增删查
│ └── nginx.conf # 静态 + 反代 /api/ 到 backend:8080
├── Dockerfile.backend # golang 多阶段 → alpine
├── Dockerfile.frontend # node 构建 → nginx
└── Jenkinsfile # kaniko 构建两镜像 + 更新部署repo
后端连库(环境变量注入,密码来自 K8s Secret)+ 启动自动建表的核心:
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?parseTime=true",
env("DB_USER","root"), env("DB_PASS","bootcamp"),
env("DB_HOST","mysql"), env("DB_NAME","demo"))
// 重试 40 次等 MySQL 起来(Pod 启动竞速,见踩坑 3)
db.Exec(`CREATE TABLE IF NOT EXISTS items (id INT AUTO_INCREMENT PRIMARY KEY, ...)`)
MySQL 8.0 认证插件坑:mysql:8.0 默认
caching_sha2_password。go-sql-driver/mysql v1.8.1原生支持它(通过 RSA 公钥交换,无需 TLS),不用加--default-authentication-plugin之类的启动参数。注意--mysql-native-password=ON是 MySQL 8.4+ 的写法,在 8.0 上会让 mysqld 启动失败。
前端 nginx 反代,让前后端同源、免 CORS:
location /api/ { proxy_pass http://backend:8080/api/; }
location /healthz { proxy_pass http://backend:8080/healthz; }
location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }
部署仓库 demo-deploy:
demo-deploy/manifests/
├── 00-namespace.yaml # demo
├── 01-mysql-secret.yaml # root 密码
├── 02-mysql.yaml # PVC(longhorn 2Gi) + Deployment(Recreate) + Service
├── 03-backend.yaml # Deployment×2 + Service,image tag 由 CI 改写
└── 04-frontend.yaml # Deployment×2 + Service(NodePort 31000)
MySQL 用
strategy: Recreate:单副本挂 RWO PVC,默认 RollingUpdate 会让新旧 Pod 抢同一个卷触发Multi-Attach。
2. Jenkinsfile:Kaniko 构建 + GitOps 回写
关键点:①一个 agent Pod 里放 两个 kaniko 容器(各构建一个镜像,kaniko 不适合一个容器跑两次)+ 一个 git 容器;②harbor-auth Secret 以 config.json 挂进 /kaniko/.docker;③最后 clone 部署 repo、sed 改 tag、push 回去。
pipeline {
agent { kubernetes { yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- { name: kaniko-backend, image: gcr.io/kaniko-project/executor:debug, command: ["/busybox/cat"], tty: true,
volumeMounts: [{ name: docker-config, mountPath: /kaniko/.docker }] }
- { name: kaniko-frontend, image: gcr.io/kaniko-project/executor:debug, command: ["/busybox/cat"], tty: true,
volumeMounts: [{ name: docker-config, mountPath: /kaniko/.docker }] }
- { name: git, image: alpine/git:latest, command: ["cat"], tty: true }
volumes:
- { name: docker-config, secret: { secretName: harbor-auth, items: [{ key: .dockerconfigjson, path: config.json }] } }
''' } }
environment { REG = '10.0.24.28:30002/bootcamp'; TAG = "${BUILD_NUMBER}" }
stages {
stage('Build backend') { steps { container('kaniko-backend') {
sh '/kaniko/executor --context dir://${WORKSPACE} --dockerfile Dockerfile.backend --destination ${REG}/backend:${TAG} --insecure --skip-tls-verify --cache=false' } } }
stage('Build frontend') { steps { container('kaniko-frontend') {
sh '/kaniko/executor --context dir://${WORKSPACE} --dockerfile Dockerfile.frontend --destination ${REG}/frontend:${TAG} --insecure --skip-tls-verify --cache=false' } } }
stage('Update deploy repo (GitOps)') { steps { container('git') { sh '''
git clone http://bootcamp:bootcamp@gitea-http.gitea.svc.cluster.local:3000/bootcamp/demo-deploy.git deploy
cd deploy
sed -i "s|image: .*/backend:.*|image: ${REG}/backend:${TAG}|" manifests/03-backend.yaml
sed -i "s|image: .*/frontend:.*|image: ${REG}/frontend:${TAG}|" manifests/04-frontend.yaml
git config user.email ci@local; git config user.name jenkins
git commit -am "ci: release build ${TAG}" || echo "no change"
git push origin main
''' } } }
}
}
--insecure --skip-tls-verify:Harbor 是学习环境 HTTP,kaniko push 要加这两个;同时各节点 containerd 也要信任该 HTTP registry(05 节第 3 步的certs.d)。生产应给 Harbor 配 HTTPS 后去掉。
3. 准备:Harbor 项目 + Gitea 仓库
# Harbor 建公开项目 bootcamp(公开 → 节点 containerd 拉镜像免 imagePullSecret)
curl -u admin:bootcamp -X POST http://10.0.24.31:30002/api/v2.0/projects \
-H "Content-Type: application/json" -d '{"project_name":"bootcamp","public":true}'
# Gitea 建两个 repo
for r in demo-app demo-deploy; do
curl -u bootcamp:bootcamp -X POST http://10.0.24.31:30022/api/v1/user/repos \
-H "Content-Type: application/json" \
-d "{\"name\":\"$r\",\"private\":false,\"default_branch\":\"main\",\"auto_init\":false}"
done
# 推代码(git 内嵌 http 凭据)
cd demo-app && git init -b main && git add -A && git commit -m init && \
git remote add origin http://bootcamp:bootcamp@10.0.24.31:30022/bootcamp/demo-app.git && git push -u origin main
cd demo-deploy && git init -b main && git add -A && git commit -m init && \
git remote add origin http://bootcamp:bootcamp@10.0.24.31:30022/bootcamp/demo-deploy.git && git push -u origin main
harbor-auth 推送凭据 Secret(05 节第 7 步已建在 jenkins ns,Kaniko 用它 push):
kubectl create secret docker-registry harbor-auth -n jenkins \
--docker-server=10.0.24.28:30002 --docker-username=admin --docker-password=bootcamp
4. 创建 Jenkins 流水线 job 并触发
Jenkins 有 CSRF 保护,API 操作要带 crumb + cookie。job 用 "Pipeline script from SCM" 指向 demo-app 的 Jenkinsfile:
J=http://10.0.24.31:30808; AUTH="admin:bootcamp"
# config.xml: flow-definition + CpsScmFlowDefinition + GitSCM(url=...demo-app.git, */main, scriptPath=Jenkinsfile)
CRUMB=$(curl -s -c /tmp/jc -u "$AUTH" "$J/crumbIssuer/api/json" | jq -r .crumb)
curl -s -b /tmp/jc -u "$AUTH" -H "Jenkins-Crumb:$CRUMB" -H "Content-Type:application/xml" \
--data-binary @config.xml "$J/createItem?name=demo-app"
curl -s -b /tmp/jc -u "$AUTH" -H "Jenkins-Crumb:$CRUMB" -X POST "$J/job/demo-app/build" # 触发构建
demo-app 是公开 repo,Jenkins controller 在集群内可匿名 clone
http://gitea-http.gitea.svc.cluster.local:3000/...,无需配 git 凭据。
构建过程真实输出(控制台关键行):
Pod [Pending][ContainersNotReady] ... # agent pod 拉 kaniko/git 镜像
+ /kaniko/executor ... --destination 10.0.24.28:30002/bootcamp/backend:2 ...
INFO Building stage 'golang:1.22-alpine' # go mod tidy + build
INFO Building stage 'alpine:3.20'
+ /kaniko/executor ... --destination .../frontend:2 ...
INFO Building stage 'node:20-alpine' # npm install + vite build
INFO Building stage 'nginx:alpine'
+ git clone ... demo-deploy.git && sed -i ... && git push # 回写部署 repo
Finished: SUCCESS
构建完成后核对:
# Harbor 有镜像
curl -s -u admin:bootcamp http://10.0.24.31:30002/api/v2.0/projects/bootcamp/repositories | grep -o '"name":"[^"]*"'
# "bootcamp/frontend" "bootcamp/backend"
# 部署 repo 的 image tag 已被 CI 改写
curl -s -u bootcamp:bootcamp http://10.0.24.31:30022/bootcamp/demo-deploy/raw/branch/main/manifests/03-backend.yaml | grep image:
# image: 10.0.24.28:30002/bootcamp/backend:2
5. ArgoCD Application 接管部署
# repo 连接(HTTP 公开 repo,insecure=true 允许 http)
apiVersion: v1
kind: Secret
metadata:
name: demo-deploy-repo
namespace: argocd
labels: { argocd.argoproj.io/secret-type: repository }
stringData:
type: git
url: http://gitea-http.gitea.svc.cluster.local:3000/bootcamp/demo-deploy.git
insecure: "true"
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: demo, namespace: argocd }
spec:
project: default
source:
repoURL: http://gitea-http.gitea.svc.cluster.local:3000/bootcamp/demo-deploy.git
targetRevision: main
path: manifests
destination: { server: https://kubernetes.default.svc, namespace: demo }
syncPolicy:
automated: { prune: true, selfHeal: true }
syncOptions: [ CreateNamespace=true ]
顺序很重要:先跑一次 Jenkins 构建(把部署 repo 的 tag 从占位
:0改成真实:2),再创建 ArgoCD Application。否则 ArgoCD 会先去拉:0镜像 →ImagePullBackOff。
应用后 ArgoCD 自动 sync,真实收敛过程:
[10s] sync=Synced health=Progressing pods=[0/1 0/1 0/1 1/1 0/1] # mysql 先起
[120s] sync=Synced health=Progressing pods=[0/1 0/1 1/1 1/1 1/1] # frontend 起,backend 等 mysql
[230s] sync=Synced health=Healthy pods=[1/1 1/1 1/1 1/1 1/1] # backend 连上库,全绿
$ kubectl get pods,svc,pvc -n demo
pod/backend-… 1/1 Running 1 (...) # restarts=1:等 mysql 初始化时重试耗尽过一次
pod/frontend-… 1/1 Running
pod/mysql-… 1/1 Running
service/frontend NodePort 80:31000/TCP
pvc/mysql-data Bound pvc-… 2Gi RWO longhorn
6. 端到端验证:前端 → 后端 → MySQL
F=http://10.0.24.31:31000
curl -s $F/ | grep -o '<title>[^<]*</title>' # <title>Demo CI/CD</title>
curl -s $F/api/info # {"app":"demo-backend","version":"1.0.0"}
curl -s -X POST $F/api/items -H 'Content-Type: application/json' -d '{"name":"第一条-CICD跑通"}' # {"id":1,...}
curl -s $F/api/items # 返回写入的条目(经 nginx→backend→MySQL)
实测:前端 200、/api/info 返回版本、POST 三条全 201、GET 读回三条带时间戳。前端 nginx 反代 → Go 后端 → MySQL 全链路通。
数据持久化验证(Longhorn 的价值)
删掉 MySQL Pod 模拟节点故障/重调度,数据应不丢:
kubectl delete pod -n demo -l app=mysql # 新 Pod 会重挂同一个 Longhorn 卷
kubectl wait -n demo --for=condition=Ready pod -l app=mysql --timeout=180s
curl -s $F/api/items # 还是那三条,id/时间戳不变 ✓
实测 MySQL Pod 在另一节点重建后,三条数据原样还在 —— 数据真的存在 Longhorn 多副本卷上。
7. GitOps 滚动升级(真实发布闭环)
改一行源码版本,走完整流水线,看 ArgoCD 自动把线上滚到新版:
# 1) 改源码并 push
sed -i 's/const version = "1.0.0"/const version = "2.0.0"/' backend/main.go
git commit -am "feat: 后端 2.0.0" && git push origin main
# 2) 触发 Jenkins 构建 #3 → backend:3/frontend:3 → 部署 repo tag 改成 :3
# 3) ArgoCD 自动检测并滚动升级
真实滚动过程:
[30s] deploy镜像=...backend:2 线上版本="1.0.0" # 还没同步
[40s] deploy镜像=...backend:3 线上版本="1.0.0" # ArgoCD 同步了 manifest,Pod 正在滚
[50s] deploy镜像=...backend:3 线上版本="2.0.0" # 新 Pod 就绪,版本切换
curl -s $F/api/info # {"app":"demo-backend","version":"2.0.0"} ← 升级成功
curl -s $F/api/items # 三条数据仍在 ← 跨版本升级数据不丢
这就是完整闭环:git push 源码 → CI 构建镜像 → 改部署 repo → ArgoCD 自动滚动升级 → 数据持久。滚动更新天然零停机(旧 Pod 在新 Pod 就绪前继续服务)。
8. 踩坑记录(实测)
| 坑 | 现象 | 原因 / 解法 |
|---|---|---|
| 部署 repo 文件名对不上 | 构建到最后报 sed: manifests/backend.yaml: No such file or directory,镜像其实已推成功 | Jenkinsfile sed 的文件名要和部署 repo 实际文件名(带序号 03-backend.yaml)一致 |
| MySQL 8.0 认证参数 | 加了 --mysql-native-password=ON 后 mysqld 起不来 | 那是 8.4+ 的写法;8.0 直接不加,靠 go-sql-driver 1.8 支持 caching_sha2_password |
| Pod 启动竞速 | backend restarts=1、日志 connection refused :3306 | backend 比 MySQL 先起;靠应用内重试(40×3s)+ readinessProbe 自愈,别删 Pod |
| ArgoCD 拉到占位镜像 | 先建 App 后跑 CI → ImagePullBackOff :0 | 先跑一次 CI 把 tag 改成真实值,再建 ArgoCD Application |
| kaniko 推 HTTP Harbor | push 失败 | --insecure --skip-tls-verify + 节点 containerd certs.d 信任该 HTTP registry |
| 一个 kaniko 容器构建两镜像 | 第二次构建异常 | kaniko 不适合一个容器跑两次;agent Pod 里放两个 kaniko 容器各构建一个 |
9. 入口速查
| 用途 | 地址 | 账号 |
|---|---|---|
| 应用前端 | http://<节点>:31000 | — |
| Gitea(源码/部署 repo) | http://<节点>:30022 | bootcamp/bootcamp |
| Jenkins(看构建) | http://<节点>:30808 | admin/bootcamp |
| Harbor(看镜像) | http://<节点>:30002 | admin/bootcamp |
| ArgoCD(看同步/拓扑) | http://<节点>:30080 | admin / kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d |
到这里,从一行代码改动到生产环境滚动上线、数据持久不丢的完整链路就全部验证通过了。