接上一篇《在一台已有 Jenkins 的服务器上,再搭一套完全隔离的 Jenkins》。 本文记录把 myapp 项目(基于 ContiNew Admin 4.2.0 二开:Spring Boot 3.x 后端 + Vue3 前端)部署到测试环境(10.10.10.44)的全过程:MySQL/Redis 容器化、两条 Jenkins 流水线、后端/前端上线,以及一路踩到的 9 个坑,每个都给出报错原文、根因和修复方法。 (文中密码、密钥均已脱敏,请替换成自己的;脚本在真实环境验证通过。)
一、目标架构
服务器 10.10.10.44 上已有 bid 项目的整套容器(后端 8080/8081、前端 80、snailjob 17888)和主机版 MySQL 5.7。myapp 项目全部新开端口,与 bid 完全隔离:
| 组件 | 容器名 | 端口 | 镜像 | 说明 |
|---|---|---|---|---|
| Jenkins(CI) | jenkins2 | 8082 / 50001 | jenkins/jenkins:lts(2.568.3) | 上一篇已搭好 |
| MySQL | myapp-mysql | 3307→3306 | mysql:8.0.42 | 库 continew_admin |
| Redis | myapp-redis | 6379 | redis:7.2.8 | AOF 持久化,用 db 1 |
| 后端 | myapp-backend-server | 18000(host 网络) | 自构建(Liberica JDK 17) | continew-admin.jar |
| 前端 | myapp-frontend-server | 8085→80 | 自构建(nginx:1.27.0) | dist + 反代后端 |
数据流:浏览器 → 8085(nginx)→ 静态页面;页面请求 /api/* → nginx 反代 → 后端 18000 → MySQL 3307 / Redis 6379。
两条 Jenkins 流水线(复刻 bid 老任务的模式):
1_后端_myapp-admin_测试:git 拉代码 → Maven 构建 jar → scp 到服务器 → 远程构建镜像并重启容器2_前端_myapp-admin-ui_测试:git 拉代码 → pnpm 构建 dist → scp 到服务器 → 远程构建镜像并重启容器
二、MySQL 容器化
2.1 3306 之争:为什么最终用了 3307
最初的设想是直接用主机上已有的 MySQL(3306),但一查之下放弃了:
- 主机 MySQL root 密码未知:
mysql -uroot -p<候选密码>试了两个候选密码,都是ERROR 1045 (28000): Access denied。它是 2026 年 1 月别人手工编译安装(/usr/local/mysql)的 MySQL 5.7.38。 - 里面有个来路不明的业务库
skb:跑了 8 个多月,虽然当前没有活跃连接(ss -tn state established | grep 3306为空),但改密码、升级都有不可控风险。 - 项目官方要求 MySQL 8.0+(ContiNew Admin 文档),虽然扫过建表 SQL 没发现 8.0 专属语法,5.7"也许"能跑,但没必要赌。
决定:主机 MySQL 一个字不动,myapp 用独立容器跑 mysql:8。宿主 3306 被占,容器映射 3307:3306。
2.2 myapp-mysql compose
/opt/dockerfile/myapp-mysql/docker-compose.yml(最终版):
| |
数据库只需要建库:
MYSQL_DATABASE: continew_admin。表结构不用手工导——后端首次启动时 Liquibase 会自动执行全部变更集建表。
2.3 坑 1:容器反复重启,mysqld 报 Permission denied
第一次 docker-compose up -d 后容器状态一直是 Restarting,日志:
报错在说什么
MySQL 的主配置文件 /etc/my.cnf 第 36 行有一条 !includedir /etc/mysql/conf.d/,意思是"启动时把 conf.d 目录下所有 .cnf 都读进来"。所以 mysqld 启动的第一步就是去扫这个目录——哪怕它是空的,也必须能打开它。
errno 13 就是 Linux 的 EACCES(Permission denied):mysqld 连目录都进不去,直接判定"配置加载失败",放弃启动(Fatal error)。于是容器起来 → 挂掉 → 重启 → 又挂掉,状态一直是 Restarting。
根因:umask 027 + 容器内外的"人"不一样
先说 umask:它是新建文件/目录时默认权限的"减法模板"。
| umask | 新建目录权限 | 长这样 | 含义 |
|---|---|---|---|
| 022(普通服务器默认) | 755 | drwxr-xr-x | 所有人都能读、能进入 |
| 027(这台等保镜像) | 750 | drwxr-x--- | 属主全权、同组可读、其他人一律没门 |
drwxr-x--- 三个一组:rwx(属主 root)/ r-x(root 组)/ ---(其他人,什么都做不了)。
这台服务器 root 的 umask 是 027(等保合规镜像的加固项),我用 root 跑 mkdir -p 建出来的 /opt/docker、/opt/docker/myapp-mysql/conf、/opt/docker/myapp-mysql/data 全是 750、属主 root——我自己毫无感觉,因为 root 无视权限位。
再说容器里是谁在访问:bind mount(- .../conf/:/etc/mysql/conf.d/)把宿主机目录原封不动映射进容器,权限位和属主 uid 保持不变。而 mysql:8 官方镜像里,mysqld 故意不用 root 跑(安全设计),它用的是 mysql 用户(uid 999)。
于是局面变成:
关键认知:Linux 权限看的是 uid 数字,不是用户名。宿主机上 root 的私有目录挂进容器,对容器里的 uid 999 来说就是"别人的私有目录"。
修复(两步都要,各解决一个问题)
| |
- 第 1 步(删 conf.d 挂载):这行挂载本来是"以防以后要加自定义配置"预留的,实际一个配置文件都没放。空目录惹出致命错,不如不挂——mysql 镜像自带的 /etc/mysql/conf.d 本来就是空的,删掉零损失。(另一条路是保留挂载 + chmod 755,也能修好,但留着一个没用的挂载点没意义。)
- 第 2 步(chmod 755 数据目录):conf.d 可以删,数据目录
/var/lib/mysql不能不挂(不然数据全在容器层里,删容器就没)。它同样是 750 root:root,必须放开。755 的含义:属主可读可写可进入,其他人可读可进入、不可写——够 mysql 用户用了,又不至于像 777 那样谁都能写。
这个坑为什么"还会再出现一次"
同一个根因换了个马甲出现在前端(见坑 6):Jenkins 容器 umask 也是 027 → pnpm build 出来的 dist 文件是 640/750 root:root → scp 原样传到服务器 → nginx 容器里 worker 进程用 nginx 用户(非 root) 去读 → 又是"其他人权限位 --- 什么都做不了" → 浏览器看到 403 Forbidden。
这两个坑是同一条规律:
“文件属主是 root 且权限 750/640” + “消费它的进程是另一个 uid” = 必然 Permission denied。
经验:在等保加固过的服务器上(umask 027 是标配),凡是挂载进容器给非 root 进程用的目录/文件,建完顺手 chmod 755(或 chown 成容器里的 uid),可以一次避开这一类所有坑。
三、Redis 容器化
3.1 先确认主机 Redis 里没有重要数据
主机上还有一个源码编译的 Redis(/home/redis6/src/redis-server 0.0.0.0:6379),2026-01-07 编译安装,已经连续跑了 8 个多月。替换它之前必须确认没数据:
结论:空库 + 无业务连接,可以安全下线。
3.2 备份并停用主机 Redis
3.3 myapp-redis compose
/opt/dockerfile/myapp-redis/docker-compose.yml:
设计要点:
- 端口沿用 6379、密码沿用主机 Redis 的密码——后端配置一行不用改。
- 后端配置
REDIS_DB: 1:同机 bid 项目习惯用 db 0,myapp 用 db 1 做逻辑隔离,就算以后共用实例也不串数据。 - 开 AOF(
--appendonly yes)持久化。
四、部署目录与脚本
服务器上的部署文件统一放 /opt/dockerfile/,数据放 /opt/docker/(这是 bid 项目的既有约定,照用):
| |
后端 Dockerfile(/opt/dockerfile/myapp-backend/Dockerfile):
| |
后端编排 myapp-backend-compose.yml(全文都是要点,后面详解几个环境变量):
| |
部署脚本 myapp-backend.shell(流水线最后一步远程执行它):查询并删除旧容器 → 删除旧镜像 → docker build -t myapp-backend . → docker-compose -f myapp-backend-compose.yml up -d。
坑 2:部署文件放错了服务器
一个值得记录的事故:最初按旧方案的记忆,把整套 /opt/dockerfile/myapp-* 推到了 10.10.10.43(dev 机),还在它的主机 MySQL 里建了 continew_admin 库、往 authorized_keys 写了部署公钥。用户及时指出:应该是 .44。
清理(一条命令完成,误操作后及时、完整地回滚很重要):
| |
然后把文件推到 .44,重新生成/安装部署密钥(ed25519 密钥对,公钥追加到 .44 的 /root/.ssh/authorized_keys),流水线里的 DEPLOY_HOST、前端 VITE_API_* 全部改成 .44。
五、后端流水线
Jenkins2 新建 Pipeline 任务 1_后端_myapp-admin_测试(Pipeline script 直接写在任务里)。核心配置:
| |
三个阶段:
- Checkout:
git branch: "${BRANCH_NAME}", credentialsId: 'myapp-git', url: 'https://git.example.com/myapp-safe/myapp-admin.git' - Build:
mvn clean package -P fat-jar -DskipTests --batch-mode - Deploy(ssh 免密,密钥方案见坑 4):
ssh-keyscan 10.10.10.44 >> ~/.ssh/known_hosts(首次)- 远程备份旧 jar:
mv continew-admin.jar continew-admin.jar.$TIMESTAMP(可回滚) scp continew-server/target/continew-admin.jar root@10.10.10.44:/opt/dockerfile/myapp-backend/ssh root@10.10.10.44 'cd /opt/dockerfile/myapp-backend && ./myapp-backend.shell'
构建实况:11 个 Maven 模块,enforcer / spotless / checkstyle / flatten 四道质量门禁全过,全量构建约 1 分钟,产物 continew-server/target/continew-admin.jar(Spring Boot repackage 后的 fat jar)。
两个无害 WARNING,知道即可:
maven-compiler-plugin:4.0.0-beta-x has unmet prerequisites—— 插件 4.x 需要 Maven 4,Maven 3.9 自动回退选了 3.16.0,不影响构建;- continew-extension 模块 pom 缺插件版本号声明,建议后续补上。
坑 3:ssh-agent 凭据三连败
Deploy 阶段最初写的是 sshagent([env.SSH_CREDENTIALS_ID]) { ... },结果连续失败三次:
| |
- 第 1 次:脚本里凭据 ID 还是旧名字
myapp-ssh-43,且这条 SSH 凭据压根没建(检查jenkins_home/credentials.xml,里面只有myapp-git一条)。 - 第 2 次:直接把任务配置里的 ID 全局替换掉:
- 第 3 次:手动去页面建了凭据再跑,还是
Could not find specified credentials: myapp-ssh-44——credentials.xml 证据显示 SSH 凭据从未真正保存成功。手动建凭据有 4 个常见失手点:Kind 选错(必须是 SSH Username with private key)、建到了用户级存储而不是 System → Global、ID 没手填变成 UUID、忘了点 Create。
连续卡三次后换思路:不用 Jenkins 凭据了,用容器内密钥文件——反正 jenkins_home 是绑定挂载,宿主机放好容器内就能用:
| |
要点:IdentitiesOnly yes 防止 ssh-agent 里其它钥匙干扰;BatchMode=yes 验证时确保不会卡在交互输入。改完之后不用再建任何凭据,直接跑构建即通。
坑 4:./myapp-backend.shell: Permission denied(exit 126)
Maven 构建全绿,Deploy 传完 jar,最后一步远程执行部署脚本报:
根因:部署文件是用 tar 包传到服务器的——本地 tar -czf 打包时存的是 macOS 上写出来的 644 权限,tar 解包会用包内权限覆盖服务器上已有的文件。第一次传完我在服务器上 chmod +x 过,但第二次传包(改推 .44 那次)又把执行权限覆盖掉了。
修复(三个脚本一起处理,并记住:每次重新传 tar 包后都要补一遍):
重新构建,部署通过。
坑 5:snail-job 占位符导致后端起不来
容器反复重启,日志:
背景:prod 配置里 snail-job.enabled: true 写死,而 17888 端口被 bid 项目的 snailjob 占着,所以测试环境决定关掉调度——compose 里已经用 SPRING_APPLICATION_JSON: '{"snail-job":{"enabled":false},...}' 关了。
但坑在于:即使 enabled=false,snail-job 的 feignConfiguration bean 创建时依然会解析 ${SCHEDULE_USERNAME/PASSWORD/TOKEN} 占位符(SCHEDULE_TOKEN 在 yml 里连默认值都没有)。占位符解析失败 = 启动直接崩。
修复:compose 里补三个永远不会被使用的占位值(反正调度已关闭):
重建容器:
| |
六、前端流水线
任务 2_前端_myapp-admin-ui_测试,核心配置:
| |
三个阶段:
- Checkout:
https://git.example.com/myapp-safe/myapp-admin-ui.git,dev 分支 - Install & Build:
- Deploy:
远程三件套:deploy-image-compse.sh(入口:构建镜像 → compose 启动)→ build_image.sh(删旧容器/镜像 → docker build)→ myapp-frontend.yml:
前端 Dockerfile(最终版,chmod 那行是坑 6 的修复):
nginx default.conf:
| |
两个设计点:
172.17.0.1是 docker0 网桥网关,即宿主机;后端用 host 网络跑在 18000,容器内通过这个地址访问。proxy_pass http://172.17.0.1:18000/;末尾的/会剥掉/api前缀:/api/system/user→ 后端收到/system/user。所以前端VITE_API_BASE_URL统一带/api,同域访问,无跨域问题。
坑 6:8085 打开直接 403 Forbidden
流水线全绿、容器在跑,浏览器打开 http://10.10.10.44:8085/ 却是 403 Forbidden。
第一直觉是"dist 没传上去、目录为空",上机检查抓到真凶——nginx 错误日志:
| |
dist 文件权限是 -rw-r-----(640)、目录 drwxr-x---(750),属主 root;而 nginx 的 worker 进程用 nginx 用户运行,读不了 root 的私有文件 → 403。
根源和 MySQL 那次是同一个 umask 坑:Jenkins 容器 umask 027 → pnpm build 产物 640/750 → scp 原样保留权限到服务器。
修复做在 Dockerfile 里(而不是手工 chmod),这样每次流水线部署都自动正确:
| |
a+rX大写 X:只对目录和已有执行位的文件加执行权限,不会把 js/css 误加执行位。
重新跑前端流水线 → curl http://localhost:8085/ 返回 200,页面打开。
坑 7:页面左上角 logo 不显示
页面能打开了,但左上角 logo 裂开。排查链:
curl http://localhost:8085/logo.png→ 200,文件本身存在;- 前端
Logo.vue的 logo 地址来自后端站点配置接口(appStore.getLogo()),不是写死的; - 查后端种子数据
main_data.sql:默认值是1(6, 'SITE', '系统LOGO', 'SITE_LOGO', NULL, '/logo.svg', ...)/logo.svg,但项目public/目录里只有logo.png和favicon.ico(二开时把 svg 换成了 png),浏览器请求/logo.svg→ 404 → 裂开。
修复:改数据库里 sys_option 表的当前值(改 value 字段,种子只写 default_value,重建表不覆盖):
还有一层波折:UPDATE 完、重启后端,接口返回的还是旧值——站点配置走了 JetCache 两级缓存,重启只清了本地缓存,Redis(db 1)里还有。清缓存:
接口验证返回 "SITE_LOGO","value":"/logo.png",浏览器强刷(Cmd+Shift+R)后 logo 正常。
顺带得到换 logo 的正规姿势:新 logo 放前端
public/重新构建;后台"系统管理 → 系统配置"里改 SITE_LOGO(界面改会自动失效缓存,不用手清 Redis)。
坑 8:右上角设置面板少了一大半
最后一个隐蔽问题:点右上角设置图标,抽屉里只有"系统主题"和"色弱模式",而上游原版还有"系统布局 / 界面显示 / 哀悼模式 / 复制配置"等一整片。
第一怀疑是二开时删了功能——逐文件 diff 两个仓库,功能代码零改动(差异只有 logo/favicon/应用名等替换)。真凶在源码里:
整个抽屉的板块都挂在 v-if="settingOpen" 上,而 settingOpen 来自构建期环境变量 VITE_APP_SETTING。仓库 .env.production 里明明写的是 true——但我在流水线的 environment 里加了一行 VITE_APP_SETTING = 'false'(当时误以为这是个演示开关),shell 环境变量优先级高于 .env 文件,构建产物里这些板块全被隐藏。
修复:删掉流水线任务里的那行变量(任务 → Configure → 删行 → Save),重新构建前端,浏览器强刷,完整面板回来。
教训:Vite 项目里 shell 环境变量 > .env 文件。流水线 environment 里写的每一个
VITE_*都是在覆盖仓库配置,加之前先想清楚。
七、上线验证清单
| 检查项 | 命令/方式 | 结果 |
|---|---|---|
| jenkins2 | curl localhost:8082/login | 200 |
| MySQL | SHOW DATABASES; 有 continew_admin | ✅ |
| Redis | set/get/del 读写探针 | ✅ |
| 后端 | curl localhost:18000/ | 200 |
| 后端表结构 | SHOW TABLES;(Liquibase 自动建) | ✅ |
| 前端 | curl localhost:8085/ | 200 |
| 登录 | 浏览器 http://10.10.10.44:8085,admin/123456 | ✅ |
| logo / 设置面板 | 浏览器强刷确认 | ✅ |
| 旧 Jenkins(8080)及 bid 全部容器 | docker ps | 全程未受影响 ✅ |
八、遗留事项(上线后别忘)
- 凭据轮换:搭建过程中 root 密码、数据库密码、部署私钥都经过多人/多窗口传递,环境跑通后统一轮换。
FIELD_ENCRYPT_*必须换:测试环境复用了仓库 dev 示例密钥,正式上线前换成独立生成的 AES/RSA 密钥对。/home/redis6.bak.20260916观察几天无异常后可删除。- snail-job 调度目前是关闭的;需要定时任务时,把项目的 schedule-server 单独容器化(注意 17888 与 bid 冲突,改端口映射)。
- 旧 Jenkins(8080)择机升级:
cd /home/compose && docker-compose pull && docker-compose up -d(先备份 jenkins_home)。
九、经验总结(9 个坑一览)
| # | 坑 | 一句话根因 | 一句话解法 |
|---|---|---|---|
| 1 | mysqld 起不来 errno 13 | 服务器 umask 027,挂载目录 750 容器内用户进不去 | 删掉无用 conf.d 挂载 + chmod 755 |
| 2 | 文件放错服务器 | 凭记忆操作没核对 IP | 完整回滚(文件/库/公钥)再重推 |
| 3 | ssh-agent 找不到凭据 ×3 | 凭据 ID 变更 + 页面建凭据没保存成功 | 弃凭据,容器内放密钥 + ssh config |
| 4 | 远程脚本 Permission denied | tar 解包覆盖掉 chmod +x | 每次传包后补 chmod;或改用 bash 执行 |
| 5 | SCHEDULE_PASSWORD 占位符崩溃 | enabled=false 也会解析占位符 | compose 补三个占位值 |
| 6 | nginx 403 | umask 027 → dist 640,nginx 用户读不了 | Dockerfile chmod -R a+rX |
| 7 | logo 404 | 种子数据默认 /logo.svg,项目只有 png;还有 Redis 缓存 | UPDATE sys_option + 清 OPTION:* 缓存 |
| 8 | 设置面板缺失 | 流水线 VITE_APP_SETTING=false 覆盖 .env | 删环境变量重建 |
| 9 | SSH 间歇被重置 | 高频连接触发云主机安全防护(HSS) | 合并命令减少连接次数,间隔重试 |
三条贯穿始终的主线经验:
- umask 027 是这台等保服务器最大的隐形规则:所有"容器里读不到挂载目录/文件"的诡异问题,先查权限。
- 每一处"覆盖"都要警惕:tar 覆盖文件权限、shell 环境变量覆盖 .env、缓存覆盖数据库新值——改配置前先想清楚谁覆盖谁。
- CI 凭据不是只有页面一条路:绑定挂载 + 密钥文件 + ssh config,比图形界面凭据更可控、可排错、可备份。
