Featured image of post Linux运维 03:Jenkins 流水线部署前后端项目(MySQL/Redis 容器化)踩坑全记录

Linux运维 03:Jenkins 流水线部署前后端项目(MySQL/Redis 容器化)踩坑全记录

myapp 项目(Spring Boot 3.x + Vue3)从零到上线:MySQL/Redis 容器化、两条 Jenkins 流水线、后端前端部署全流程,9 个坑的报错原文、根因与修复。

接上一篇《在一台已有 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)jenkins28082 / 50001jenkins/jenkins:lts(2.568.3)上一篇已搭好
MySQLmyapp-mysql3307→3306mysql:8.0.42库 continew_admin
Redismyapp-redis6379redis:7.2.8AOF 持久化,用 db 1
后端myapp-backend-server18000(host 网络)自构建(Liberica JDK 17)continew-admin.jar
前端myapp-frontend-server8085→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),但一查之下放弃了:

  1. 主机 MySQL root 密码未知:mysql -uroot -p<候选密码> 试了两个候选密码,都是 ERROR 1045 (28000): Access denied。它是 2026 年 1 月别人手工编译安装(/usr/local/mysql)的 MySQL 5.7.38。
  2. 里面有个来路不明的业务库 skb:跑了 8 个多月,虽然当前没有活跃连接(ss -tn state established | grep 3306 为空),但改密码、升级都有不可控风险。
  3. 项目官方要求 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(最终版):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
services:
  mysql:
    image: mysql:8.0.42
    container_name: myapp-mysql
    restart: always
    ports:
      - "3307:3306"
    volumes:
      - /opt/docker/myapp-mysql/data/:/var/lib/mysql/
    environment:
      TZ: Asia/Shanghai
      MYSQL_ROOT_PASSWORD: <MySQL密码>
      # 初始化数据库(表结构由后端 Liquibase 自动创建)
      MYSQL_DATABASE: continew_admin
    command:
      --default-authentication-plugin=mysql_native_password
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_general_ci
      --explicit_defaults_for_timestamp=true
      --lower_case_table_names=1

数据库只需要建库:MYSQL_DATABASE: continew_admin。表结构不用手工导——后端首次启动时 Liquibase 会自动执行全部变更集建表。

2.3 坑 1:容器反复重启,mysqld 报 Permission denied

第一次 docker-compose up -d 后容器状态一直是 Restarting,日志:

1
2
3
mysqld: Can't read dir of '/etc/mysql/conf.d/' (OS errno 13 - Permission denied)
mysqld: [ERROR] Stopped processing the 'includedir' directive in file /etc/my.cnf at line 36.
mysqld: [ERROR] Fatal error in defaults handling. Program aborted!

报错在说什么

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(普通服务器默认)755drwxr-xr-x所有人都能读、能进入
027(这台等保镜像)750drwxr-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)。

于是局面变成:

1
2
3
4
5
6
目录: drwxr-x---  属主 root(uid 0)
进程: mysqld      身份 mysql(uid 999)
                        ↓
              既不是属主,也不是同组
                        ↓
              落入其他人权限位 "---" → 什么都做不了 → errno 13

关键认知:Linux 权限看的是 uid 数字,不是用户名。宿主机上 root 的私有目录挂进容器,对容器里的 uid 999 来说就是"别人的私有目录"。

修复(两步都要,各解决一个问题)

1
2
3
4
5
6
7
8
9
# 1) 初版 compose 里有一行 conf.d 挂载,既然没有自定义配置要放,直接删掉最干净
sed -i "\|/etc/mysql/conf.d|d" /opt/dockerfile/myapp-mysql/docker-compose.yml

# 2) 放开数据目录权限
chmod 755 /opt/docker /opt/docker/myapp-mysql /opt/docker/myapp-mysql/data

docker-compose up -d
docker exec myapp-mysql mysql -uroot -p<MySQL密码> -e "SHOW DATABASES;"
# → continew_admin 出现
  • 第 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 个多月。替换它之前必须确认没数据:

1
2
3
4
/home/redis6/src/redis-cli -a <Redis密码> info keyspace
# # Keyspace   ← 下面一行都没有,16 个逻辑库零 key
ss -tn state established | grep 6379
# 无任何活跃连接

结论:空库 + 无业务连接,可以安全下线。

3.2 备份并停用主机 Redis

1
2
3
/home/redis6/src/redis-cli -a <Redis密码> shutdown nosave
systemctl disable redis redis-server 2>/dev/null   # 没有 systemd 服务,纯防御
mv /home/redis6 /home/redis6.bak.20260916          # 备份观察几天,没问题再删

3.3 myapp-redis compose

/opt/dockerfile/myapp-redis/docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
services:
  redis:
    image: redis:7.2.8
    container_name: myapp-redis
    restart: always
    ports:
      - "6379:6379"
    volumes:
      - /opt/docker/myapp-redis/data/:/data/
    environment:
      TZ: Asia/Shanghai
    command: "redis-server --appendonly yes --requirepass <Redis密码>"
1
2
3
4
chmod 755 /opt/docker/myapp-redis /opt/docker/myapp-redis/data   # umask 坑,同上
docker-compose up -d
docker exec myapp-redis redis-cli -a <Redis密码> set __probe ok
docker exec myapp-redis redis-cli -a <Redis密码> get __probe    # → "ok"

设计要点:

  • 端口沿用 6379、密码沿用主机 Redis 的密码——后端配置一行不用改。
  • 后端配置 REDIS_DB: 1:同机 bid 项目习惯用 db 0,myapp 用 db 1 做逻辑隔离,就算以后共用实例也不串数据。
  • 开 AOF(--appendonly yes)持久化。

四、部署目录与脚本

服务器上的部署文件统一放 /opt/dockerfile/,数据放 /opt/docker/(这是 bid 项目的既有约定,照用):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/opt/dockerfile/
├── myapp-backend/
│   ├── Dockerfile                  # 后端镜像
│   ├── myapp-backend-compose.yml    # 后端编排
│   └── myapp-backend.shell          # 部署脚本(删旧→构建→启动)
├── myapp-frontend/
│   ├── myapp-frontend.yml           # 前端编排
│   └── images-pkg/
│       ├── Dockerfile              # 前端镜像
│       ├── nginx/default.conf      # nginx 配置
│       ├── build_image.sh          # 构建镜像
│       ├── deploy-image-compse.sh  # 部署入口(构建+启动)
│       └── dist/                   # 流水线 scp 上来的构建产物
├── myapp-mysql/docker-compose.yml
└── myapp-redis/docker-compose.yml

后端 Dockerfile(/opt/dockerfile/myapp-backend/Dockerfile):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
FROM bellsoft/liberica-openjdk-debian:17.0.14

COPY continew-admin.jar /app/bin/app.jar
WORKDIR /app/bin
RUN mkdir -p /app/logs

ENTRYPOINT ["java", \
            "-XX:+UseZGC", \
            "-XX:MetaspaceSize=128m", \
            "-XX:MaxMetaspaceSize=256m", \
            "-XX:+HeapDumpOnOutOfMemoryError", \
            "-XX:HeapDumpPath=/app/logs/oom.hprof", \
            "-XX:ErrorFile=/app/logs/hs_err_pid%p.log", \
            "-Djava.security.egd=file:/dev/./urandom", \
            "-jar", "app.jar"]

后端编排 myapp-backend-compose.yml(全文都是要点,后面详解几个环境变量):

 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
services:
  myapp-backend-server:
    image: myapp-backend
    container_name: myapp-backend-server
    environment:
      TZ: Asia/Shanghai
      PROFILES_ACTIVE: prod
      # 数据源:本机 myapp-mysql 容器(3307 端口;network_mode: host 所以用 127.0.0.1)
      DB_HOST: 127.0.0.1
      DB_PORT: 3307
      DB_USER: root
      DB_PWD: <MySQL密码>
      DB_NAME: continew_admin
      # 缓存:本机 myapp-redis 容器 6379,用 db 1
      REDIS_HOST: 127.0.0.1
      REDIS_PORT: 6379
      REDIS_PWD: <Redis密码>
      REDIS_DB: 1
      # 字段加密密钥(测试环境复用仓库 dev 示例值,正式环境必须换成独立生成的密钥对!)
      FIELD_ENCRYPT_PASSWORD: <AES密钥>
      FIELD_ENCRYPT_PUBLIC_KEY: <RSA公钥>
      FIELD_ENCRYPT_PRIVATE_KEY: <RSA私钥>
      # snail-job 已关闭,但占位符仍需解析,填不会使用的占位值(见坑 6)
      SCHEDULE_USERNAME: admin
      SCHEDULE_PASSWORD: unused-disabled
      SCHEDULE_TOKEN: unused-disabled
      # 关闭 snail-job 调度;application.url 用于跨域放行前端地址
      SPRING_APPLICATION_JSON: '{"snail-job":{"enabled":false},"application":{"url":"http://10.10.10.44:8085"}}'
    volumes:
      - /opt/docker/myapp-backend/logs/:/app/logs/
      - /opt/docker/myapp-backend/data/file/:/app/data/file/
    restart: always
    network_mode: "host"

部署脚本 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。

清理(一条命令完成,误操作后及时、完整地回滚很重要):

1
2
3
4
5
# 在 .43 上:
rm -rf /opt/dockerfile/myapp-backend /opt/dockerfile/myapp-frontend /opt/docker/myapp-backend
/usr/local/mysql/bin/mysql -uroot -p<密码> -e "DROP DATABASE IF EXISTS continew_admin;"
sed -i.bak "/jenkins2-to-43-deploy/d" /root/.ssh/authorized_keys
ls /opt/dockerfile/   # 确认只剩 bid 项目的目录

然后把文件推到 .44,重新生成/安装部署密钥(ed25519 密钥对,公钥追加到 .44 的 /root/.ssh/authorized_keys),流水线里的 DEPLOY_HOST、前端 VITE_API_* 全部改成 .44。

五、后端流水线

Jenkins2 新建 Pipeline 任务 1_后端_myapp-admin_测试(Pipeline script 直接写在任务里)。核心配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
tools { jdk 'jdk17'; maven 'maven3.9' }        // 名字与全局工具配置一致
parameters { string(name: 'BRANCH_NAME', defaultValue: 'dev') }
environment {
    JAR_FILE_NAME      = 'continew-admin.jar'
    LOCAL_JAR_PATH     = "continew-server/target/${JAR_FILE_NAME}"
    DEPLOY_PATH        = '/opt/dockerfile/myapp-backend'
    DEPLOY_SCRIPT_NAME = 'myapp-backend.shell'
    DEPLOY_HOST        = '10.10.10.44'
    DEPLOY_USER        = 'root'
    GIT_CREDENTIALS_ID = 'myapp-git'
}

三个阶段:

  1. Checkout:git branch: "${BRANCH_NAME}", credentialsId: 'myapp-git', url: 'https://git.example.com/myapp-safe/myapp-admin.git'
  2. Build:mvn clean package -P fat-jar -DskipTests --batch-mode
  3. 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
ERROR: [ssh-agent] Could not find specified credentials: myapp-ssh-43
  • 第 1 次:脚本里凭据 ID 还是旧名字 myapp-ssh-43,且这条 SSH 凭据压根没建(检查 jenkins_home/credentials.xml,里面只有 myapp-git 一条)。
  • 第 2 次:直接把任务配置里的 ID 全局替换掉:
    1
    2
    3
    
    sed -i "s/myapp-ssh-43/myapp-ssh-44/g; s/10\.10\.10\.43/10.10.10.44/g" \
        /home/compose-jenkins2/jenkins_home/jobs/*/config.xml
    docker restart jenkins2
    
  • 第 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 是绑定挂载,宿主机放好容器内就能用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 私钥放进 jenkins_home(容器内即 /var/jenkins_home/.ssh/)
mkdir -p /home/compose-jenkins2/jenkins_home/.ssh
cp /home/compose-jenkins2/jenkins2_deploy_key /home/compose-jenkins2/jenkins_home/.ssh/deploy_key
printf '%s\n' 'Host 10.10.10.44' \
              '    IdentityFile /var/jenkins_home/.ssh/deploy_key' \
              '    IdentitiesOnly yes' \
  > /home/compose-jenkins2/jenkins_home/.ssh/config
chown -R 1000:1000 /home/compose-jenkins2/jenkins_home/.ssh
chmod 700 /home/compose-jenkins2/jenkins_home/.ssh
chmod 600 /home/compose-jenkins2/jenkins_home/.ssh/{deploy_key,config}

# 容器内验证免密通
docker exec jenkins2 ssh -o StrictHostKeyChecking=no -o BatchMode=yes \
    root@10.10.10.44 "echo CONTAINER_SSH_OK"     # → CONTAINER_SSH_OK

# 把流水线里的 sshagent 步骤换成普通 script 块(直接改任务 config.xml 再重启)
sed -i "s/sshagent(\[env\.SSH_CREDENTIALS_ID\])/script/" \
    /home/compose-jenkins2/jenkins_home/jobs/*/config.xml
docker restart jenkins2

要点:IdentitiesOnly yes 防止 ssh-agent 里其它钥匙干扰;BatchMode=yes 验证时确保不会卡在交互输入。改完之后不用再建任何凭据,直接跑构建即通。

坑 4:./myapp-backend.shell: Permission denied(exit 126)

Maven 构建全绿,Deploy 传完 jar,最后一步远程执行部署脚本报:

1
2
3
+ ssh root@10.10.10.44 'cd /opt/dockerfile/myapp-backend && ./myapp-backend.shell'
bash: line 1: ./myapp-backend.shell: Permission denied
ERROR: script returned exit code 126

根因:部署文件是用 tar 包传到服务器的——本地 tar -czf 打包时存的是 macOS 上写出来的 644 权限,tar 解包会用包内权限覆盖服务器上已有的文件。第一次传完我在服务器上 chmod +x 过,但第二次传包(改推 .44 那次)又把执行权限覆盖掉了。

修复(三个脚本一起处理,并记住:每次重新传 tar 包后都要补一遍):

1
2
3
chmod +x /opt/dockerfile/myapp-backend/myapp-backend.shell \
         /opt/dockerfile/myapp-frontend/images-pkg/build_image.sh \
         /opt/dockerfile/myapp-frontend/images-pkg/deploy-image-compse.sh

重新构建,部署通过。

坑 5:snail-job 占位符导致后端起不来

容器反复重启,日志:

1
2
3
4
5
Application run failed
BeanCreationException: Error creating bean with name 'feignConfiguration'
Caused by: org.springframework.util.PlaceholderResolutionException:
Could not resolve placeholder 'SCHEDULE_PASSWORD' in value "${SCHEDULE_PASSWORD}"
    <-- "${snail-job.server.api.password}"

背景: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 里补三个永远不会被使用的占位值(反正调度已关闭):

1
2
3
      SCHEDULE_USERNAME: admin
      SCHEDULE_PASSWORD: unused-disabled
      SCHEDULE_TOKEN: unused-disabled

重建容器:

1
Started ContiNewAdminApplication in 17.987 seconds
1
2
3
curl -s -o /dev/null -w '%{http_code}' http://localhost:18000/    # → 200
docker exec myapp-mysql mysql -uroot -p<密码> -e "SHOW TABLES;" continew_admin | wc -l
# Liquibase 已自动建好全部表

六、前端流水线

任务 2_前端_myapp-admin-ui_测试,核心配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
tools { nodejs 'node22' }
parameters { string(name: 'BRANCH_NAME', defaultValue: 'dev') }
environment {
    BUILD_OUTPUT       = 'dist'
    REMOTE_DEPLOY_PATH = '/opt/dockerfile/myapp-frontend/images-pkg'
    DEPLOY_HOST        = '10.10.10.44'
    DEPLOY_USER        = 'root'
    GIT_CREDENTIALS_ID = 'myapp-git'
    // shell 环境变量优先级高于 .env 文件,覆盖 .env.production 里的上游演示地址
    VITE_API_BASE_URL  = 'http://10.10.10.44:8085/api'
    VITE_API_WS_URL    = 'ws://10.10.10.44:8085/api'
}

三个阶段:

  1. Checkout:https://git.example.com/myapp-safe/myapp-admin-ui.git,dev 分支
  2. Install & Build:
    1
    2
    3
    4
    5
    
    npm config set registry https://registry.npmmirror.com
    command -v pnpm || npm install -g pnpm@9
    pnpm config set registry https://registry.npmmirror.com
    pnpm install
    pnpm build        # 读 .env.production,VITE_* 环境变量优先生效
    
  3. Deploy:
    1
    2
    3
    
    ssh root@10.10.10.44 "cd /opt/dockerfile/myapp-frontend/images-pkg && rm -rf dist && mkdir -p dist"
    scp -r dist/. root@10.10.10.44:/opt/dockerfile/myapp-frontend/images-pkg/
    ssh root@10.10.10.44 "cd /opt/dockerfile/myapp-frontend/images-pkg && bash deploy-image-compse.sh"
    

远程三件套:deploy-image-compse.sh(入口:构建镜像 → compose 启动)→ build_image.sh(删旧容器/镜像 → docker build)→ myapp-frontend.yml:

1
2
3
4
5
6
7
services:
  myapp-frontend:
    image: myapp-frontend
    container_name: myapp-frontend-server
    ports:
      - "8085:80"
    restart: always

前端 Dockerfile(最终版,chmod 那行是坑 6 的修复):

1
2
3
4
5
6
FROM nginx:1.27.0

COPY dist/ /usr/share/nginx/html/
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
# 构建产物可能带有 640/750 权限(CI umask),nginx 用户需要可读
RUN chmod -R a+rX /usr/share/nginx/html

nginx default.conf:

 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
server {
    listen 80;
    server_name _;
    client_max_body_size 100m;

    # 前端静态资源(history 路由回退到 index.html)
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
        index index.html;
    }

    # /api/ 代理到后端(172.17.0.1 为宿主机,后端端口 18000,代理时去掉 /api 前缀)
    location /api/ {
        proxy_pass http://172.17.0.1:18000/;
        proxy_ignore_client_abort on;
        proxy_http_version 1.1;

        # WebSocket 支持
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "Upgrade";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

两个设计点:

  • 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 错误日志:

1
open() "/usr/share/nginx/html/index.html" failed (13: Permission denied)

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),这样每次流水线部署都自动正确:

1
RUN chmod -R a+rX /usr/share/nginx/html

a+rX 大写 X:只对目录和已有执行位的文件加执行权限,不会把 js/css 误加执行位。

重新跑前端流水线 → curl http://localhost:8085/ 返回 200,页面打开。

坑 7:页面左上角 logo 不显示

页面能打开了,但左上角 logo 裂开。排查链:

  1. curl http://localhost:8085/logo.png → 200,文件本身存在;
  2. 前端 Logo.vue 的 logo 地址来自后端站点配置接口(appStore.getLogo()),不是写死的;
  3. 查后端种子数据 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,重建表不覆盖):

1
2
docker exec myapp-mysql mysql -uroot -p<密码> continew_admin \
    -e "UPDATE sys_option SET value='/logo.png' WHERE code='SITE_LOGO';"

还有一层波折:UPDATE 完、重启后端,接口返回的还是旧值——站点配置走了 JetCache 两级缓存,重启只清了本地缓存,Redis(db 1)里还有。清缓存:

1
2
3
docker exec myapp-redis redis-cli -a <密码> -n 1 keys "OPTION:*"
# OPTION:SITE / OPTION:TENANT / OPTION:LOGIN_CAPTCHA_ENABLED / ...
docker exec myapp-redis redis-cli -a <密码> -n 1 del OPTION:SITE ...

接口验证返回 "SITE_LOGO","value":"/logo.png",浏览器强刷(Cmd+Shift+R)后 logo 正常。

顺带得到换 logo 的正规姿势:新 logo 放前端 public/ 重新构建;后台"系统管理 → 系统配置"里改 SITE_LOGO(界面改会自动失效缓存,不用手清 Redis)。

坑 8:右上角设置面板少了一大半

最后一个隐蔽问题:点右上角设置图标,抽屉里只有"系统主题"和"色弱模式",而上游原版还有"系统布局 / 界面显示 / 哀悼模式 / 复制配置"等一整片。

第一怀疑是二开时删了功能——逐文件 diff 两个仓库,功能代码零改动(差异只有 logo/favicon/应用名等替换)。真凶在源码里:

1
2
3
4
5
<!-- SettingDrawer.vue -->
const settingOpen = JSON.parse(import.meta.env.VITE_APP_SETTING)
<a-divider v-if="settingOpen" orientation="center">系统布局</a-divider>
<a-descriptions v-if="settingOpen" ...>界面显示...</a-descriptions>
...

整个抽屉的板块都挂在 v-if="settingOpen" 上,而 settingOpen 来自构建期环境变量 VITE_APP_SETTING。仓库 .env.production 里明明写的是 true——但我在流水线的 environment 里加了一行 VITE_APP_SETTING = 'false'(当时误以为这是个演示开关),shell 环境变量优先级高于 .env 文件,构建产物里这些板块全被隐藏。

修复:删掉流水线任务里的那行变量(任务 → Configure → 删行 → Save),重新构建前端,浏览器强刷,完整面板回来。

教训:Vite 项目里 shell 环境变量 > .env 文件。流水线 environment 里写的每一个 VITE_* 都是在覆盖仓库配置,加之前先想清楚。

七、上线验证清单

检查项命令/方式结果
jenkins2curl localhost:8082/login200
MySQLSHOW DATABASES; 有 continew_admin✅
Redisset/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全程未受影响 ✅

八、遗留事项(上线后别忘)

  1. 凭据轮换:搭建过程中 root 密码、数据库密码、部署私钥都经过多人/多窗口传递,环境跑通后统一轮换。
  2. FIELD_ENCRYPT_* 必须换:测试环境复用了仓库 dev 示例密钥,正式上线前换成独立生成的 AES/RSA 密钥对。
  3. /home/redis6.bak.20260916 观察几天无异常后可删除。
  4. snail-job 调度目前是关闭的;需要定时任务时,把项目的 schedule-server 单独容器化(注意 17888 与 bid 冲突,改端口映射)。
  5. 旧 Jenkins(8080)择机升级:cd /home/compose && docker-compose pull && docker-compose up -d(先备份 jenkins_home)。

九、经验总结(9 个坑一览)

#坑一句话根因一句话解法
1mysqld 起不来 errno 13服务器 umask 027,挂载目录 750 容器内用户进不去删掉无用 conf.d 挂载 + chmod 755
2文件放错服务器凭记忆操作没核对 IP完整回滚(文件/库/公钥)再重推
3ssh-agent 找不到凭据 ×3凭据 ID 变更 + 页面建凭据没保存成功弃凭据,容器内放密钥 + ssh config
4远程脚本 Permission deniedtar 解包覆盖掉 chmod +x每次传包后补 chmod;或改用 bash 执行
5SCHEDULE_PASSWORD 占位符崩溃enabled=false 也会解析占位符compose 补三个占位值
6nginx 403umask 027 → dist 640,nginx 用户读不了Dockerfile chmod -R a+rX
7logo 404种子数据默认 /logo.svg,项目只有 png;还有 Redis 缓存UPDATE sys_option + 清 OPTION:* 缓存
8设置面板缺失流水线 VITE_APP_SETTING=false 覆盖 .env删环境变量重建
9SSH 间歇被重置高频连接触发云主机安全防护(HSS)合并命令减少连接次数,间隔重试

三条贯穿始终的主线经验:

  • umask 027 是这台等保服务器最大的隐形规则:所有"容器里读不到挂载目录/文件"的诡异问题,先查权限。
  • 每一处"覆盖"都要警惕:tar 覆盖文件权限、shell 环境变量覆盖 .env、缓存覆盖数据库新值——改配置前先想清楚谁覆盖谁。
  • CI 凭据不是只有页面一条路:绑定挂载 + 密钥文件 + ssh config,比图形界面凭据更可控、可排错、可备份。
使用 Hugo 构建
主题 Stack 由 Jimmy 设计