前一段工作告一段落,正好把最近在 Ubuntu 22.04 上落地 GitLab + Jenkins 这套 CI/CD 的完整过程整理成笔记。起因也很朴素:团队每次发布版本都要有人远程登录测试机,手动拉代码、打包、传文件、重启服务,哪一步漏了都很难第一时间发现。某次因为只更新了 jar 包却没同步配置文件,问题直到用户反馈才暴露出来。当时就想,与其每次靠“提醒”来兜底,不如把流程固化成一条可以重复执行的流水线。
这篇记录围绕“在 Ubuntu 22.04 上安装 GitLab 和 Jenkins、打通推送触发构建、实现自动打包部署”展开,适合正准备搭建小型 CI/CD 环境的个人开发者或两三人的业务团队参考。我不会只贴命令,会尽量把每个配置背后的原因、踩过的坑、以及部署阶段容易忽略的权限和环境变量问题都讲清楚。整个方案不需要 Kubernetes,不依赖云厂商的托管 CI 服务,两台 2 核 4G 的 Ubuntu 主机就能跑起来,GitLab、Jenkins、Nginx 全部通过 apt 安装,维护成本相对可控。
1. 这一套组合到底解决什么问题
1.1 手动发版流程里最痛的那几件事
如果你经历过手动发版,应该对下面这个流程不陌生:提交代码、登录跳板机、拉取分支、执行构建、找到目标产物的路径、上传到服务器、备份旧包、重启服务、再盯一会儿日志。这套流程单次执行可能只要十分钟,但它的问题在于“人”是流程中最不稳定的环节。
- 今天在 A 机器上构建,明天在 B 机器上构建,产物的环境和路径不一致。
- 某次漏了备份,回滚时找不到上一个可用版本。
- 测试环境的手工操作没有记录,线上出问题时无法追溯当时到底部署了什么。
- 新成员加入后,很难从文字文档里完整复现“老手”的发布步骤。
CI/CD 解决的就是这些问题:把代码提交、静态检查、单元测试、打包、部署这些动作串成一条自动化链路,每次推送都会触发,构建过程有日志,失败时有通知,部署版本有记录。
1.2 GitLab 和 Jenkins 在流水线里的分工
很多人容易把 GitLab 和 Jenkins 混在一起,觉得都是“做 DevOps 的工具”。实际拆开看,两件事的分工非常清晰:
GitLab 负责代码托管、分支管理、合并请求、Webhook 事件等代码协作层面的工作。当开发者把代码推送到 GitLab,或者一个合并请求被接受时,GitLab 并不知道如何构建这个项目,但它能以 Hook 的方式把“有代码更新了”这个事件发出去。
Jenkins 负责拿到事件后真正做构建、测试、部署。它会根据配置去拉取代码,执行 Maven、Node、shell 等命令,把产物放到指定位置,再通过 SSH 推到目标服务器完成部署。
简单类比:GitLab 像前台接待,记录谁什么时候提交了什么;Jenkins 像执行部门,接到通知后开始干活,并把结果反馈给前台。
1.3 我的环境假设和最低要求
这次搭建使用的是两台 Ubuntu 22.04 服务器:
| 角色 | 建议配置 | 用途 |
|---|---|---|
| GitLab 服务器 | 2 核 4G,50G 磁盘 | 代码仓库、Webhook 事件源 |
| Jenkins 服务器 | 2 核 4G,50G 磁盘 | 执行构建、测试、部署任务 |
| 部署目标服务器 | 按业务需求 | 运行 systemd 服务的业务机器 |
如果资源紧张,把 GitLab 和 Jenkins 装在同一台机器上也行,但需要注意端口冲突,后面我会讲怎么处理。示例项目用demo-backend,一个 Java + Maven 项目,构建产物是 jar 包,最终通过 systemd 部署到目标服务器。换成 Node 项目或者 Python 项目也没关系,核心逻辑不变,只是构建命令不同。
2. Ubuntu 22.04 上把两台机器装到顺手为止
2.1 装软件之前的系统准备
很多人拿到新机器就直接开始装 GitLab,结果后面因为时区不对、openssh-server 没装、时区时间同步有问题,白白多折腾好几个小时。我习惯先做一轮基础准备:
sudo apt update && sudo apt upgrade -y sudo timedatectl set-timezone Asia/Shanghai sudo apt install -y curl openssh-server ca-certificates tzdata这里关键是openssh-server,GitLab 仓库操作和 Jenkins SSH 部署都需要它。时区设置则避免后续日志时间看起来“慢了八小时”的错觉。
如果打算用 Nginx 做反向代理,也顺手装掉:
sudo apt install -y nginx提示:执行某些安装脚本前,建议先打开脚本地址用浏览器看一眼内容,确认仓库地址和安装逻辑正常再执行,别把“curl 管道到 bash”变成无脑习惯。
2.2 GitLab CE 的安装和首次初始化
GitLab 分商业版和社区版,通常我们直接用社区版就够。安装过程并不复杂,但 reconfigure 阶段比较耗时,属于“卡住不要慌”的类型:
curl -sS https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash sudo EXTERNAL_URL="http://git.example.com" apt install gitlab-ce注意看EXTERNAL_URL,这个变量特别重要。我见过有人图省事直接用 IP 安装,后续如果换服务器或做反向代理,GitLab 内部生成的链接全是 IP,改起来非常难受。尽量在安装前就决定好域名,比如git.example.com。
安装完成后,GitLab 会自动执行 reconfigure,这个过程经常持续几分钟。完成后需要在浏览器里打开http://git.example.com并用 root 账号登录。首次登录密码在系统里有一个临时生成的文件:
sudo cat /etc/gitlab/initial_root_password这个文件会在 24 小时内被自动删除,所以拿到密码后立刻登录并修改,是最关键的一步。
2.3 Jenkins 的安装和首次初始化
Jenkins 在 Ubuntu 22.04 上建议用官方 apt 源安装。Ubuntu 22.04 自带的 OpenJDK 版本比较新,官方 LTS 版本对 Java 11 / 17 都支持得不错,所以先装 JDK:
sudo apt install -y openjdk-17-jdk然后添加 Jenkins 官方源并安装:
curl -fsSL https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key | sudo tee /usr/share/keyrings/jenkins-keyring.asc > /dev/null echo "deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian-stable binary/" | sudo tee /etc/apt/sources.list.d/jenkins.list > /dev/null sudo apt update sudo apt install -y jenkins sudo systemctl enable --now jenkinsJenkins 默认跑在 8080 端口,使用独立的jenkins系统用户,工作目录在/var/lib/jenkins。打开页面后要求输入初始管理员密码:
sudo cat /var/lib/jenkins/secrets/initialAdminPassword这一步建议选择“安装推荐的插件”,后面再按需要补装。推荐插件已经包含 Git、Pipeline 等常用能力,能省不少事。
2.4 用 Nginx 把 GitLab 和 Jenkins 的反向代理理顺
如果 GitLab 和 Jenkins 装在同一台机器上,最大的问题就是 80 端口冲突。GitLab 默认自带 nginx 监听 80,而 Jenkins 默认独占 8080。我采用的方案是:把 GitLab 的内部端口改为 8081,由统一的外部 Nginx 同时代理git.example.com和ci.example.com。
编辑 GitLab 配置:
sudo vim /etc/gitlab/gitlab.rb找到相关行修改或追加:
external_url 'http://git.example.com' nginx['listen_port'] = 8081 nginx['listen_https'] = false然后重新配置生效:
sudo gitlab-ctl reconfigure外部 Nginx 中添加两个 server 块,GitLab 对应 8081,Jenkins 对应 8080:
server { listen 80; server_name git.example.com; location / { proxy_pass http://127.0.0.1:8081; 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; client_max_body_size 100m; } } server { listen 80; server_name ci.example.com; location / { proxy_pass http://127.0.0.1:8080; 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; client_max_body_size 100m; } }这里要特别注意:GitLab 默认会对请求头里的 Host 做校验,外部反代必须把Host原样传过去,否则会出现“页面能打开但很多资源地址跳错”的问题。
防火墙方面,我只开放必须的端口:
sudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw enable不要把 8080 和 8081 直接暴露到公网,统一让外部通过 Nginx 的 80/443 进来,后续做 HTTPS 和访问认证也方便。
3. 把 GitLab 和 Jenkins 串起来:项目、令牌、Webhook
3.1 在 GitLab 侧准备好项目和个人访问令牌
两边的软件跑起来只是第一步,真正让它们联动需要三样东西:GitLab 项目、Jenkins 的 API 凭据、Webhook 配置。
先在 GitLab 创建一个用于测试的组和项目,例如cicd-demo/demo-backend。然后在 Jenkins 中访问 GitLab 时,最好使用一个独立的机器人用户,而不是 root 本身的令牌。
我踩过的坑:用 root 的 Personal Access Token 一时方便,但后期如果离职或误操作,登录权限很难精细化回收。单独建一个名为
jenkins-bot的普通用户,只授予项目组开发者或报告者权限,再创建令牌,安全性和可维护性都更好。
创建令牌时权限范围至少要包含api,这样 Jenkins 插件才能读取项目信息、创建 Webhook 和触发构建。令牌生成后只会显示一次,复制保存好。
3.2 Jenkins 侧安装插件并配置系统级凭据
进入 Jenkins “系统管理 → 插件管理”,除了默认推荐的插件,我通常额外确认这几个:
| 插件 | 用途 |
|---|---|
| GitLab | 与 GitLab 集成,解析 Webhook 事件 |
| Pipeline | 声明式流水线支持 |
| SSH Agent | 在流水线中使用 SSH 私钥凭据 |
| Credentials Binding | 把秘密参数注入构建环境 |
| Timestamper | 构建日志显示耗时,排障好用 |
然后在“系统配置”里添加 GitLab 连接:
- 连接名称:随意,例如
git-lab-prod - GitLab 地址:
http://git.example.com - Credentials:选择之前创建的 GitLab 令牌,凭据类型选“GitLab API token”
接着创建两个常用凭据:
- Git 仓库访问凭据,类型选“Username with password”,使用
jenkins-bot用户和密码,或者用该用户的访问令牌也可以。 - 部署服务器 SSH 私钥,类型选“SSH Username with private key”,用户名写目标服务器上的部署用户(后面建),私钥粘贴 Jenkins 所生成的私钥内容。
创建凭据的路径是“系统管理 → 凭据 → 系统 → 全局凭据 → 添加凭据”。这里的credentialsId(比如我下面写的deploy-key)会在 Jenkinsfile 里被引用,命名时注意结构清晰。
3.3 Webhook 配置和常见联动失败原因
在 Jenkins 里新建一个 Pipeline Job,源码管理选择 Git,填上 GitLab 仓库地址和凭据,然后勾选“Build when a change is pushed to GitLab”。保存后,Job 页面会生成一个 Webhook URL 和 Secret Token。
把这个 URL 和 Token 填到 GitLab 侧:项目 → 设置 → 网络钩子。触发事件至少勾选“推送事件”,如果希望合并请求也能触发验证,可以一并勾选“合并请求事件”。
Jenkins Webhook 示例 http://ci.example.com/project/demo-backend Secret Token 示例 gJlQk2x0..."配置完成后,在 GitLab 的 Webhook 页面点“测试 → Push events”。返回 200 表示打通。如果联动失败,重点检查四个方向:
| 现象 | 常见原因 |
|---|---|
| 测试 Webhook 返回 304 或 404 | Webhook URL 写错,或 Jenkins Job 名称包含空格/特殊字符,需要 URL 编码 |
| 点击测试能通但实际推送不触发 | 项目 hooks 权限不足,jenkins-bot对项目没有足够权限 |
| Jenkins 收到事件但没构建 | 系统配置中的 GitLab 连接没配好,或条件分支过滤设置不对 |
| 一直返回 504 | 外网或同网段访问不到 Jenkins,检查防火墙和 Nginx 配置 |
这块最让人困惑的点是“测试能通但实际不触发”,很多时候不是配置问题,而是 GitLab 的机器人用户权限没放开,事件发不出来或发送不完整。先给jenkins-bot在项目里授受适当的 Reporter 或 Developer 权限,再逐一排查。
4. 第一个流水线:从 Jenkinsfile 骨架开始
4.1 为什么直接上 Pipeline Job 而不是 Freestyle Job
早期很多教程用 Freestyle Job 通过界面点选构建步骤,适合快速验证,但问题也很明显:配置存在 Jenkins 里,不属于代码领域,换台 Jenkins 就要重新点一遍。推荐直接使用 Pipeline Job,把Jenkinsfile放进项目仓库,流水线本身就是版本化代码,新人看文件就能理解构建过程。
声明式流水线(Declarative Pipeline)语法更结构化,读起来像“配置 + 步骤”,比脚本式流水线更克制,也更加适合作团队基线。
4.2 一个可运行的 Java 项目 Jenkinsfile
下面是我在一个 Maven 项目里实际使用的精简版 Jenkinsfile:
pipeline { agent any options { timestamps() disableConcurrentBuilds() buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '5')) } tools { jdk 'JDK17' } environment { DEPLOY_HOST = '192.0.2.10' DEPLOY_USER = 'deploy' APP_NAME = 'demo-backend' } stages { stage('Checkout') { steps { checkout scm } } stage('Unit Test') { steps { sh './mvnw test' } } stage('Build') { steps { sh './mvnw -q clean package -DskipTests' } } stage('Deploy') { when { branch 'main' } steps { sshagent(['deploy-key']) { sh """ set -e ssh ${DEPLOY_USER}@${DEPLOY_HOST} "mkdir -p /opt/${APP_NAME}/releases" scp target/${APP_NAME}.jar ${DEPLOY_USER}@${DEPLOY_HOST}:/opt/${APP_NAME}/releases/${APP_NAME}-${BUILD_NUMBER}.jar ssh ${DEPLOY_USER}@${DEPLOY_HOST} "ln -sf /opt/${APP_NAME}/releases/${APP_NAME}-${BUILD_NUMBER}.jar /opt/${APP_NAME}/current.jar && sudo systemctl restart ${APP_NAME}" """ } } } } post { success { echo "构建成功: ${env.BUILD_URL}" } failure { echo "构建失败: ${env.BUILD_URL}" } } }几个容易被忽略的细节:
checkout scm会自动按 Pipeline Job 源码管理里配置的仓库地址和分支拉代码,不用手动写git clone。tools块引用的JDK17需要在“系统管理 → 全局工具配置”里预定义,安装方式和路径随意。- 使用
./mvnw而非全局mvn,项目里的 Maven Wrapper 能锁定版本,构建环境更一致。 sshagent(['deploy-key'])会在当前构建节点临时加载 SSH 私钥,环境变量方式不可用时,这个插件是处理私钥最干净的手段。scp和ssh命令里使用${BUILD_NUMBER}是为了区分每次构建产生的不同产物,方便回滚。
4.3 参数、手动审批和不同分支的差异化处理
流水线一旦跑起来,需求会很快变多,最常见的有三种:
手动参数化部署
在pipeline外层加parameters:
parameters { string(name: 'REMOTE_TAG', defaultValue: 'latest', description: '需要部署的镜像或版本标签') }然后在部署阶段使用${params.REMOTE_TAG},这样既能实现“一键部署”,又能做到发版前确认版本号。
带审批的发布
对生产环境,不要直接自动部署,加一层input让操作人确认,可以减少误触发:
stage('Confirm Production Deploy') { when { branch 'main' } steps { input message: '确认部署到生产环境?', ok: '开始部署' } }按分支执行不同阶段
测试环境通常每个分支都构建,生产环境只接受main分支或特定 Tag。用when { branch 'main' }或when { tag pattern "v.*" }控制即可,不要让一个 Job 把所有环境全写死。
5. 部署环节最容易翻车的三个细节
5.1 专用部署用户和 SSH 免密的正确姿势
很多运维习惯用 root 部署,这样确实省事,但风险也太大了。Jenkins 里一旦凭据泄露,攻击者等于拿到了服务器的 root 权限。建议单独创建一个低权限用户:
sudo adduser deploy sudo mkdir -p /opt/demo-backend/releases sudo chown -R deploy:deploy /opt/demo-backendSSH 免密要以 Jenkins 节点的jenkins用户身份建立,不是用你自己登录服务器的账号。在 Jenkins 服务器上执行:
sudo -H -u jenkins bash -c "ssh-keygen -t ed25519 -N '' -f /var/lib/jenkins/.ssh/id_ed25519" sudo -H -u jenkins ssh-copy-id deploy@192.0.2.10把公钥拷到部署机器的deploy用户后,Jenkins 里的 SSH 私钥凭据对应填/var/lib/jenkins/.ssh/id_ed25519的内容。
注意:不要在 Jenkins 的 ssh 命令里简单粗暴地
-o StrictHostKeyChecking=no。正确做法是把部署机器的 host key 提前写入/var/lib/jenkins/.ssh/known_hosts,既能避免首次连接卡在指纹确认,又不至于完全放下主机校验。
5.2 systemd 服务单元和部署后的启动方式
部署脚本里常见到用nohup java -jar xxx &把服务拉起来,这个做法问题很多:SSH 会话断开、父进程退出、环境变量加载不完整,都会导致服务异常。最稳的做法是交给 systemd 托管。
在部署机上创建服务文件:
[Unit] Description=demo-backend service After=network.target [Service] User=deploy Group=deploy WorkingDirectory=/opt/demo-backend EnvironmentFile=/etc/demo-backend.env ExecStart=/usr/bin/java -jar /opt/demo-backend/current.jar Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target把服务注册好:
sudo systemctl daemon-reload sudo systemctl enable demo-backendcurrent.jar是一个指向具体发布版本的符号链接,每次部署时用ln -sf切换到新版本。这样回滚时只需要把符号链接重新指回上一版 jar,再重启服务,不需要把历史包物理挪动位置。
如果希望deploy用户能重启服务而不需要密码,可以配置一条精细化的 sudo 规则:
echo 'deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart demo-backend' | sudo tee /etc/sudoers.d/demo-backend这个做法比直接给deploy用户开放所有 sudo 权限要安全得多,而且 Jenkins 部署阶段里那句sudo systemctl restart demo-backend就能顺利执行。
5.3 权限、环境变量、旧包堆积这些不起眼的问题
部署阶段出现的故障,很大比例不是代码问题,而是服务器环境问题。
权限问题。目标目录/opt/demo-backend必须属于deploy用户,否则scp会直接失败,错误信息可能只在日志中表现为 “Permission denied”。这种错误排查时先ls -ld看清目录属主,别急着改脚本。
环境变量问题。systemd 服务不会加载 shell 的.bashrc/.bash_profile,数据库地址、密钥这类配置不要塞在启动命令里,统一放在/etc/demo-backend.env,文件权限设为 600,然后让EnvironmentFile指向它。
sudo chown root:deploy /etc/demo-backend.env sudo chmod 640 /etc/demo-backend.env旧包堆积问题。每次部署生成一个带构建号的 jar,长期积累会吃满磁盘。可以在系统层面定期清理,只保留最近几个版本:
sudo find /opt/demo-backend/releases -name '*.jar' -mtime +30 -delete也可以直接在 Jenkinsfile 里把产物保留数量交给buildDiscarder管理,释放 Jenkins 侧的构建记录和产物,降低连续构建期间磁盘膨胀的风险。
6. 上线之后的安全加固与日常维护建议
6.1 别让 Jenkins 和 GitLab 裸奔在浏览器里
流水线跑通之后的下一步,不是继续加功能,而是把安全补齐。我见过不少团队把 Jenkins 的 8080 端口直接暴露在公网,管理页面没有任何访问控制,这是非常危险的,因为 Jenkins 一旦被拿到执行权限,等于掌握了整条部署链路。
优先做三件事:
- 启用 HTTPS。域名如果已经解析到公网 IP,使用 Let’s Encrypt 之类的证书签发工具,用 Certbot 配合 Nginx 模式签证书,能避免 GitLab 和 Jenkins 的管理页面明文传输密码和令牌。
- 给 Jenkins 的 Nginx 站点加一层 Basic Auth。在 Nginx server 块里加入认证配置,即使公网能访问到
ci.example.com,没有账号也进不了管理界面:
sudo apt install -y apache2-utils sudo htpasswd -c /etc/nginx/.htpasswd ci_admin然后在 Jenkins 的 Nginx 配置 location 块中加上:
auth_basic "CI Access"; auth_basic_user_file /etc/nginx/.htpasswd;- 关闭 GitLab 公开注册。如果不需要对外开放注册,管理员在管理后台将“允许新用户注册”关闭,避免陌生账号扫描进来。
还有一个容易被忽略的点:GitLab 初始root密码要立即修改,Jenkins 初始管理员密码只要用一次,授权完成后建议停用或改写初始账号策略。凭据令牌一旦发现外流,马上到 GitLab 用户设置里吊销重建。
6.2 构建缓存与流水线提速
流水线稳定后,构建速度会逐渐成为排队的瓶颈。Maven 项目最有效的提速手段是“保留依赖缓存”。Jenkins 节点上的~/.m2/repository第一次构建会慢慢下载依赖,第二次之后命中本地仓库,速度会有量级提升。
不要每次构建前都强行删除工作区。默认情况下 Jenkins 会在同一个 workspace 上复用,只要保证代码用 Git 流转,本地目录的缓存可以大胆保留。如果担心磁盘被多个项目占满,可以给 Jenkins 节点配置独立的构建磁盘,或者定期通过 Groovy 清理长时间不用的 workspace。
还有两个常见调整:
- 把静态检查、单元测试、构建产物校验这几个阶段尽量并行执行,利用 Jenkins 的
parallel块,让 CPU 多核充分跑起来。 - Jenkins 的构建日志输出加上
timestamps(),方便看每一步耗时,定位“卡在哪里”比“报什么错”更重要。
6.3 磁盘日志和备份策略
CI 系统本身也是业务系统,不备份等于白搭。我的习惯是:
- GitLab 每天凌晨做一次全量备份。GitLab 自带
gitlab-backup create命令,配合 crontab 定时执行,把备份文件同步到另一台机器或对象存储。 - Jenkins 不常用自带备份插件,直接把
/var/lib/jenkins里关键的jobs、credentials、plugins目录做 tar 归档即可。配置和 Jenkinsfile 都属于“可以重建的代码”,真正不可再生的是凭据和 Job 配置。 - 对构建记录设置
buildDiscarder策略,保留最近 30 次或最近 10 个版本,防止构建历史和日志文件无限膨胀。
实际操作中我还有一个习惯:每次升级插件或改动系统级配置前,先快照或打包/var/lib/jenkins,一旦升级后出现插件不兼容,可以快速回退。这个习惯落地的成本很低,却能在关键时刻省下半天恢复时间。
这套流程从安装到跑通,前后大概花了两天,其中大部分时间花在 Webhook 的连通性和部署权限问题上。只要把权限和事件流理清楚,剩下的无非是命令行的机械组合。真正有价值的部分不是某一条命令,而是“代码推送之后,经过一系列可重复的步骤,稳定地把应用发布到目标环境”这件事本身。过程中遇到的各种报错,大部分都能从日志和时间线里找到答案,希望这篇记录能让你少走点弯路。