先聊个事。我接手过不少Spring Boot项目的交付,团队成员把代码提交到GitLab之后,日常操作基本停留在git push、git pull,再手动mvn package跑一下,剩下的全靠"我感觉应该没问题"。代码质量检查就更原始了,要么靠少数人Review的时候肉眼扫,要么等合并完出了问题再去翻日志。这种模式在小团队还能勉强撑住,一旦涉及多人协作、多分支并行、迭代节奏加快,问题就会集中爆发:代码风格不一致、重复代码堆积、关键方法没有任何单元测试覆盖,等发现问题往往已经晚了。
后来我在一套Spring Boot项目上把GitLab CI/CD持续集成环境完整落地,并且把SonarQube嵌进了流水线。整套跑起来之后,每次合并请求触发流水线,自动完成编译、单元测试、覆盖率统计、静态代码分析,质量门禁不过直接红色Fail,想合并都合不进去。这篇文章把我整个搭建过程、配置文件、踩过的坑、排查思路全部记录下来,给同样在折腾Spring Boot + GitLab CI/CD + SonarQube这套组合的同学一个可以直接参考的路径。
1. 为什么非要在CI流水线里塞一个SonarQube
1.1 代码评审永远比机器慢半拍
我以前也认为SonarQube是多此一举,代码好坏靠人Review就行。后来在一个迭代节奏非常快的项目上,我发现一个现实问题:Code Review不可能覆盖所有细节。
举个例子,团队成员在某个Service里顺手写了一个new BigDecimal(double),这个用法在数值精度要求高的场景下是致命的,但代码Review的时候一眼扫过去很难发现,因为这段代码藏在一个几百行的支付计算逻辑里,其他人根本没精力逐行看。而SonarQube的规则引擎几秒钟就能扫出来,并且告诉你违反的是哪条Java规范,严重级别是什么。这就是机器分析的价值:它不累、不跳行、不靠运气。
从成本角度算一笔账更直观。一个中型Spring Boot项目,Controller、Service、Mapper、Utils加起来少说几百个类,靠人肉Review一次至少要几个小时,而且不同的Reviewer对同一段代码的敏感点完全不一样。把SonarQube放进CI/CD之后,每次push都会自动跑一遍规则集,质量基线是统一且稳定的,不存在"今天这个Reviewer没注意"的情况。
1.2 把质量门禁从"人盯人"变成"机器卡点"
另一个核心动机是让质量检查具备"强制执行力"。人在Review的时候碍于情面经常放过小问题,但机器不会。SonarQube的Quality Gate就是这样一个硬性卡点:当Bug数、漏洞数、坏味道、覆盖率指标超过阈值时,流水线直接Fail,开发者必须修完才能往下一步走。
我见过不少团队的CI只做编译和打包,SonarQube作为独立Job跑在定时任务里,或者干脆让开发本地手动运行。这种"检查与交付脱节"的做法效果会大打折扣:开发者提交的代码明明已经引入了高严重度问题,代码还是被合进去了,SonarQube的结果只是事后的一纸报告,没人有动力去修。
把SonarQube集成进GitLab CI/CD之后,逻辑完全不一样。流水线的sonar阶段跑完静态分析,如果Quality Gate返回ERROR,流水线直接中断,后面的部署阶段根本不会执行。这种"机器卡点"比任何流程制度都可靠,因为它不需要人盯,只需要配置正确。
2. 先把两个基础服务跑起来:SonarQube与GitLab Runner
2.1 SonarQube部署与初始配置
SonarQube官方有社区版、开发者版、企业版和数据中心版。我们这套搭建用的是社区版,免费、功能对小型团队完全够用,Java生态的核心规则、覆盖率统计、质量门禁这些都包含。我用Docker Compose在一台Linux服务器上部署的,配置如下:
version: "3" services: sonarqube: image: sonarqube:10.0-community container_name: sonarqube ports: - "9000:9000" environment: - SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true volumes: - sonarqube_conf:/opt/sonarqube/conf - sonarqube_data:/opt/sonarqube/data - sonarqube_logs:/opt/sonarqube/logs - sonarqube_extensions:/opt/sonarqube/extensions volumes: sonarqube_conf: sonarqube_data: sonarqube_logs: sonarqube_extensions:你说为什么不直接用一个容器跑完拉倒,还要挂这么多volume?因为SonarQube的数据、插件、日志如果不持久化,容器一删全部归零,重新创建后连项目、规则配置、质量门禁都没了,这在真实环境里是没法接受的。
SonarQube启动后有两个初始配置很关键:
- 浏览器访问
http://服务器IP:9000,默认账号密码是admin/admin,第一次登录必须改掉。 - 去 Administration -> Security -> Users 里创建一个专用账号,或者直接用admin账号生成一个Token。这个Token是给后面的Sonar Scanner用的,比把账号密码写在配置文件里安全得多。
这里我踩过一个坑:很多教程在配置sonar.login时直接放明文密码,一旦.gitlab-ci.yml或sonar-project.properties泄漏,别人就能直接登录你的SonarQube控制台。正确做法是只给Scanner账号分配"执行分析"的权限,Token用完还可以随时撤销。
2.2 Runner注册与执行器选型
GitLab Runner是流水线的执行者,你有几个选择:共享Runner、项目专属Runner、组级Runner。建议每个项目配一个专属Runner,隔离性更好,避免多个项目抢同一台Runner的编译资源导致互相影响。
安装Runner本身不复杂,关键是注册时的executor选择。我试过两种:docker executor和shell executor,最后在Spring Boot项目上选择了shell。原因是Spring Boot的Maven构建非常吃缓存,shell executor可以复用本机的~/.m2/repository,第二次构建开始几乎不用重新下载依赖。docker executor每次都是干净环境,虽然隔离性好,但依赖下载那部分时间浪费得很明显,构建速度慢了一倍不止。
Runner注册命令大致如下:
gitlab-runner register \ --url http://gitlab.example.com \ --token <你的注册token> \ --executor shell \ --description "springboot-runner"注册token需要在GitLab管理后台的 Admin Area -> CI/CD -> Runners 页面找,每个项目也有自己的专属token,建议用项目级token,这样Runner只处理这个项目的任务。
注册完成后在GitLab的Runner列表里能看到这台Runner,状态是online就代表注册成功。接着进入项目设置页,把Runner的tag配置好。这里有个坑:.gitlab-ci.yml里定义job时如果不指定tags,默认是不会被没有被tag的Runner执行的。我一开始在job里没写tags,流水线一直卡在pending状态,折腾了半天才发现Runner和job之间是靠tag关联的。
3. .gitlab-ci.yml里与SonarQube挂钩的关键设计
3.1 流水线阶段与Sonar阶段的摆放位置
Spring Boot项目的流水线阶段我这样设计:
stages: - build - test - sonar - deploy逻辑不复杂:build阶段执行编译打包,test阶段跑单元测试并生成覆盖率报告,sonar阶段做静态分析,最后的deploy一般就是推送镜像或者执行远程发布脚本。
很多人的误区是把sonar放在test阶段之前,这样静态分析结果会缺失单元测试数据,因为覆盖率报告还没生成。正确顺序一定是test在前、sonar在后。
3.2 sonar-project.properties 和 sonar-scanner 配合方式
进入sonar阶段,需要先建立一个sonar-project.properties文件放在项目根目录,这是SonarScanner读取的核心配置。我的配置大致如下:
sonar.projectKey=my-springboot-app sonar.projectName=SpringBoot Application sonar.projectVersion=1.0.0 sonar.sources=src/main/java sonar.tests=src/test/java sonar.java.binaries=target/classes sonar.java.test.binaries=target/test-classes sonar.java.source=17 sonar.host.url=http://你的SonarQube地址 sonar.login=<你的SonarQube Token> sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml重点解释几个参数:
sonar.projectKey是SonarQube里项目的唯一标识,第一次跑的时候如果不存在,SonarScanner会自动在SonarQube里创建这个项目。sonar.java.binaries指定的是编译后的class文件目录,SonarQube分析Java代码需要读到字节码,光有源码它没法准确判断类型信息,这也是后面一个常见报错的根源。sonar.coverage.jacoco.xmlReportPaths是JaCoCo覆盖率报告的路径,没有这一行,SonarQube页面上永远看不到覆盖率数据。
然后.gitlab-ci.yml里sonar阶段的job可以这样写:
sonar: stage: sonar script: - mvn clean verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar artifacts: paths: - target/site/jacoco/jacoco.xml expire_in: 7 days实际项目里我没有用单独的sonar-scanner命令,而是直接在Maven里调用sonar-maven-plugin,好处是不用额外安装Scanner,依赖Maven仓库就行,版本也好控制。
3.3 跨Job传递单元测试产物与覆盖率
这是整套配置里最容易翻车的点。假设你的流水线把test和sonar拆成了两个独立job,那么sonar job默认环境下没有test job产生的target目录。如果sonar job直接跑起来,大概率会报错:找不到target/classes或者target/site/jacoco/jacoco.xml。
解决办法有两种:
第一种,给test job加artifacts,把target目录传递给后续job。GitLab CI/CD的job默认是隔离的,只有通过artifacts才能跨job传递文件。
test: stage: test script: - mvn test jacoco:report artifacts: paths: - target/site/jacoco/jacoco.xml - target/classes - target/test-classes expire_in: 7 days第二种,直接在同一个job里把mvn verify和sonar分析串起来,像上面3.2里写的那样。我自己后来更多采用第二种,因为省去artifacts的传递和解析时间,流程也更简单。如果你的项目模块很多,编译一次时间很长,建议用第一种,至少可以避免sonar阶段重复编译。
这里顺便提一个经验:不管用哪种方式,最终SonarQube页面上一定要能看到覆盖率数据。看不到覆盖率,说明jacoco.xml的路径没对,或者sonar.coverage.jacoco.xmlReportPaths配置里的路径写错了。排查顺序是:先确认target/site/jacoco/jacoco.xml是否存在,再确认property里的路径和实际一致。
4. 流水线真实跑通的踩坑记录
4.1 sonar.java.binaries 找不到class文件
我第一次跑通流程的时候,sonar job直接报错:
ERROR: Error during SonarScanner execution java.lang.IllegalStateException: No files nor directories matching 'target/classes'原因就是前面说的:test job产出的target目录没有传给sonar job。我当时用的是独立的sonar job,又没有在test job里声明artifacts,所以sonar job所在环境里根本没有target目录。
排查方法我至今觉得值得写下来:
- 先看sonar job的日志,确认它是在哪个目录下执行的,以及该目录下有没有target文件夹。
- 如果没有,说明是job隔离导致的文件不存在,回到test job,检查artifacts配置。
- 如果有target但里面没有classes,说明
mvn test阶段没有触发compile,这种情况一般把命令改成mvn test或mvn verify就行,确保编译产物生成。
这个坑的本质是GitLab CI/CD的job隔离模型。很多人第一次接触时想不通"我明明在test job里编译过,为什么sonar job找不到class",就是因为默认job之间文件完全不共享。
4.2 Java版本不匹配:一次分析在收集阶段直接失败
另一个让我印象深刻的坑是Java版本不匹配。SonarQube 10.0服务端本身要求JDK 17运行,分析Java项目时如果扫描器用的是JDK 8,会在收集阶段直接报错:
Unsupported class file major version 61这个报错的意思是扫描器在解析class文件时发现版本太高,它的class解析能力不支持。这类问题通常在CI机器升级JDK之后出现:项目的pom.xml里改成了Java 17编译,但Runner机器上的JAVA_HOME还指向JDK 8。
解决办法一般是两条路:
- 把Runner机器的JDK升级到17,并且把
JAVA_HOME、PATH环境变量切到新JDK。 - 或者在
.gitlab-ci.yml里用variables显式指定:
variables: JAVA_HOME: "/usr/lib/jvm/java-17-openjdk-amd64"这个坑告诉我们一个原则:CI环境的JDK版本必须和项目pom里配置的Java版本保持一致,或者更高。不然编译能过,SonarQube分析却会挂在字节码版本上,而且报错信息还不太直观。
4.3 质量门禁显示"Pending":回调地址与等待参数
SonarQube分析完成之后,正确的结果应该是流水线里的sonar job成功结束,GitLab的合并请求页面上能看到SonarQube的检查结果:通过或者不通过。
我第一次配置完成后发现一个问题:SonarQube分析已经跑完,但GitLab页面上一直显示"SonarQube analysis is pending"。排查了一晚上,最后定位到两个原因。
第一个原因,GitLab和SonarQube集成的回调地址没配置。需要在SonarQube项目的 Administration -> General Settings -> External Providers -> GitLab 里填写GitLab地址,同时SonarQube那边也需要配置允许GitLab回调,两边地址必须能互相访问。如果SonarQube部署在内网,GitLab在另一网段,还需要在GitLab侧把SonarQube相关的Webhook或者IP加进白名单。
第二个原因是.gitlab-ci.yml里缺少等待质量门禁的配置。SonarScanner默认执行完分析就直接退出,不会等SonarQube那边的Quality Gate计算完成。这会导致流水线看起来跑完了,但结果还没回来。解决方法是加两个参数:
-Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300qualitygate.wait=true表示等待门禁结果,timeout=300表示最长等待300秒。如果5分钟内门禁还没算完,job会Fail。加了这两个参数之后,GitLab里就能看到实时的门禁结果了。
5. 进阶:让SonarQube真正"卡住"不合格的代码
5.1 Quality Gate状态绑定合并请求
很多人止步于SonarQube能跑出报告,但没有真正让它约束合并流程。让质量门禁在合并请求层面生效,需要做两件事:
第一件事,SonarQube里的Quality Gate必须绑定到项目上。默认的Sonar way门禁对有强迫症的团队来说可能偏宽松,我一般会按项目特点调整阈值。例如一个支付核心服务,我会把新增Bug数量的阈值直接设成0,覆盖率不低于60%,关键漏洞不允许存在。
第二件事,在GitLab项目设置里,把SonarQube检查结果作为合并请求必须通过的检查项。具体在GitLab的Settings -> Merge Requests里配置,或者在项目级的CI/CD设置里从SonarQube创建Merge Request Decoration。这样当SonarQube返回门的OVERALL状态为ERROR时,GitLab会直接把合并按钮置灰,开发者必须处理完问题再重新触发流水线。
5.2 增量检查与项目分级
接着说一个实用的经验:当项目代码量变大之后,全量SonarQube分析会越来越慢,一个几百个类的Spring Boot项目全量扫一次可能要5到10分钟,这段时间里开发者只能干等。
一种优化方案是开启SonarQube的增量检查(Incremental Analysis)。社区版对增量分析支持有限,但至少在Pull Request模式下,SonarQube只分析本次变更的代码,而不是整个项目。配置方式是在SonarScanner里加:
-Dsonar.pullrequest.key=$CI_MERGE_REQUEST_IID -Dsonar.pullrequest.branch=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME -Dsonar.pullrequest.base=$CI_MERGE_REQUEST_TARGET_BRANCH_NAMEGitLab CI里内置了CI_MERGE_REQUEST_IID、CI_MERGE_REQUEST_SOURCE_BRANCH_NAME这些变量,直接用就行。这样每次分析只看本次改动引入了哪些新问题,会快很多。
另外建议把项目按照重要程度分级。我把项目分成了核心支付服务和普通业务服务,核心服务用严格门禁(新增Bug数为0、覆盖率更高),普通服务用宽松门禁(允许一定数量的坏味道)。不这样做,等团队人数多了以后,你会发现所有开发都在疲于应付SonarQube的规则,反而忽略了真正重要的业务问题。分级策略能让规则服务于业务价值,而不是为了合规而合规。
6. 这套流程跑起来之后,我对CI和代码质量的一点体会
整套环境搭建下来,最大的变化不是流水线变长了,而是整个团队的交付心态变了。以前"代码能跑"就是完成,现在"代码能跑 + SonarQube通过 + 覆盖率达标"才算完成。听起来是流程变繁琐了,实际效果是代码评审环节的压力大幅下降,Reviewer终于可以把精力放在业务逻辑和架构设计上,而不是花时间挑变量命名、魔法数、空指针这些低级问题。
最后分享一个综合性的建议:如果你打算在Spring Boot项目上落地这套组合,第一次折腾时间预算按两到三天比较现实,主要集中在SonarQube部署、Runner注册、GitLab与SonarQube集成回调这三块。如果你第一次构建卡住了,先去看job日志,gitlab-ci.yml的报错信息往往会在前面几十行就给出答案,不用急着怀疑环境问题。
还有一个小技巧,备着早晚用得上:SonarQube的插件目录建议挂独立volume,之后想加Checkstyle规则或者前端分析插件都要往里面放jar包,不提前挂volume的话,容器一更新就全丢了。这个我吃过亏,折腾过一次重新配置所有插件和规则之后,就老老实实补上了。