ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

GitLab CI/CD+阿里云OSS:Java项目自动化构建与制品上传实践

GitLab CI/CD+阿里云OSS:Java项目自动化构建与制品上传实践 如果你和我一样日常工作里反复在同一个流程里打转——从 GitLab 拉代码、在本地构建 Java 工程、再把 jar 包传上阿里云 OSS——那你大概率也会在某一天开始琢磨这些动作能不能让机器自己干完。尤其是团队里没有专职运维、服务器资源又紧张的时候重复手动操作带来的不只是时间开销还有“这次是不是漏了什么步骤”的焦虑。这篇文章就把我最近在项目中落地的方案完整拆开自建 GitLab 做代码托管与 CI/CDRunner 负责跑构建任务最终把 Java 制品自动上传到阿里云 OSS用 OSS 当轻量级制品库。全程围绕“Java 项目自动化构建”这条主线涉及 GitLab 部署、Runner 注册、.gitlab-ci.yml编写、OSS 权限配置、制品上传、常见排错以及我从“能跑”到“好用”的优化过程。如果你是中小团队、没有专门运维又暂时不想为 Jenkins 单独养一台机器这套方案可以直接抄。1. 手动流程有多痛这套方案就有多香1.1 一次完整的手动上线过程耗时四十分钟先说个真实场景。下午三点前端同事说网关接口又超时了。我打开电脑先从 GitLab 拉最新代码本地mvn clean package等编译两分钟后 jar 包出来了。然后打开阿里云控制台找到那个熟悉得不能再熟悉的 Bucket点上传选择文件等进度条走完。再 SSH 登录服务器把 jar 包拉下来重命名、备份旧包、执行重启脚本。一套流程走下来熟练了也得 30 到 40 分钟其中真正有点技术含量的工作不到五分钟。更烦的是这种流程一多就会出错。有一次我改完代码本地构建通过了但传上去的是上一个版本的包结果线上查了半天问题最后发现是上传时文件名冲突被旧文件覆盖了。还有一次同事临时要一个带调试日志的包我手动改了配置重新打包结果第二天发现这个包被当成了正式制品运维那边直接拉去部署了。这些事单独看都是小事但累积起来非常消磨耐心。于是我开始认真想能不能在git push之后就触发构建让 GitLab 把 Java 项目自动编译、打包再传到 OSS 的指定目录整个过程不需要任何人去点上传按钮。1.2 为什么选 GitLab CI/CD OSS而不是 Jenkins Nexus当时也做了选型对比。团队代码本来就在 GitLab 上GitLab CI/CD 是和仓库绑定的天然省掉了 Jenkins 那套“和代码仓库建立连接、配 webhook、管理凭证”的成本。另一个现实因素是我们没有多余的机器跑 JenkinsGitLab 已经用 Docker 部署在一台 4 核 8G 的服务器上直接加个 Runner 组件就能用成本几乎为零。制品仓库这个环节Nexus 和 Artifactory 都很强大但对于一个以 jar 包为核心交付物的 Java 项目来说OSS 完全够用了。OSS 有生命周期规则可以自动清理旧版制品有 RAM 权限可以精确控制谁上传、谁下载下载链接还能带签名过期自动失效。更重要的是很多下游部署流程本身就要从 OSS 拉包把制品直接传上去省掉了“从制品库再搬到 OSS”这一步。等于用 OSS 同时承担了制品库和发布源两个角色。1.3 改造前 vs 改造后一张表看清收益流程从“人肉流水线”变成“自动化流水线”之后我拉了一张对比表用在项目周报里效果很直观对比维度手动流程GitLab CI OSS触发方式本地命令 网页点击git push 自动触发单次耗时30-40 分钟3-5 分钟含构建与上传制品可追溯性依赖文件名记忆每次构建带 Pipeline ID可回溯出错率高容易传错、覆盖低自动命名 隔离路径权限控制谁都能传RAM 子账号 Bucket 策略需要维护的组件无GitLab Runner复用现有机器这张表给同事和领导看的时候基本不需要解释太多大家都能一眼看出自动化的价值。后面开始动手做才发现真正的细节都在“环境准备”这一关。2. 基础设施三件套GitLab、Runner 和 OSS Bucket 的搭建与权限设计2.1 用 Docker 快速落地 GitLab 社区版GitLab 我用了社区版CEDocker Compose 直接拉起来。这里要注意GitLab 是个吃内存的主官方推荐至少 4G 内存我实际用下来 4 核 8G 的机器跑 GitLab 加 Runner 是够用的如果再开多个 Docker 容器就要小心了。我的 compose 文件长这样version: 3.8 services: gitlab: image: gitlab/gitlab-ce:16.11.0-ce.0 container_name: gitlab restart: always hostname: gitlab.example.com environment: GITLAB_OMNIBUS_CONFIG: | external_url http://gitlab.example.com:8929 gitlab_rails[gitlab_shell_ssh_port] 2224 ports: - 8929:8929 - 2224:22 volumes: - ./gitlab/config:/etc/gitlab - ./gitlab/logs:/var/log/gitlab - ./gitlab/data:/var/opt/gitlab shm_size: 256m几个关键点说一下。external_url决定了 GitLab 页面上展示的仓库地址如果你后面配置 Runner 时填的 URL 和它不一致会引发注册失败或 Runner 离线。gitlab_shell_ssh_port是 SSH 拉取代码的端口因为容器内部的 22 不能直接映射出去就映射成了 2224。三个 volume 分别对应配置、日志和数据库/代码数据最好放到独立磁盘上防止容器重写后数据丢失。版本不建议直接用latest我之前就被 GitLab 版本自动升级坑过一次配置变动导致 Runner 全部失联。固定一个已知稳定的 CE 版本日常少很多折腾。部署完之后先登录服务器初始化 root 密码接着创建一个专门的项目组把 Java 工程推上去。2.2 OSS Bucket 与 RAM 子账号权限给到“够用且不过”OSS 这边我做了三件事创建 Bucket、创建 RAM 子账号、配置最小权限策略。Bucket 的读写权限选“私有Private”因为制品虽然要供服务器拉取但服务器也是通过内网或签名 URL 访问的完全没必要公开读。如果是通过内网访问 OSS记得在端点Endpoint上选oss-cn-xxx-internal.aliyuncs.com不占用外网流量。RAM 子账号只给上传和列目录的权限不要图省事直接给AdministratorAccess。我用的策略如下{ Version: 1, Statement: [ { Effect: Allow, Action: [ oss:PutObject, oss:GetObject, oss:ListObjects ], Resource: [ acs:oss:*:*:my-backend-bucket/release/* ] } ] }这个策略的 Resource 写到了 Bucket 下的release/前缀意味着子账号只能操作这个目录上传失败排查时也更容易定位是不是权限边界问题。AccessKey 创建后只会显示一次一定要先保存到 GitLab 的 CI/CD Variables 里再关掉页面。2.3 Runner 注册与执行器选择这一步决定后续所有的坑Runner 我跑在 GitLab 同一台机器上用 Docker 方式启动。这样 Runner 本身是一个容器执行构建任务时再通过挂载的 Docker Socket 动态启动新的构建容器。启动命令docker run -d --name gitlab-runner --restart always \ -v /srv/gitlab-runner/config:/etc/gitlab-runner \ -v /var/run/docker.sock:/var/run/docker.sock \ gitlab/gitlab-runner:latest然后注册 Runner。先在 GitLab 项目里找到 Settings - CI/CD - Runners拿到注册 URL 和 token再执行docker exec -it gitlab-runner gitlab-runner register \ --url http://gitlab.example.com:8929 \ --token glrt-xxxx \ --executor docker \ --docker-image maven:3.8.8-eclipse-temurin-17 \ --docker-volumes /root/.m2:/root/.m2 \ --tag-list java-builder \ --description java-builder-runner选择docker执行器是关键的决策。用shell执行器的话构建脚本直接在 Runner 宿主机上跑依赖的 JDK、Maven 版本都得手动装环境一乱就各种奇怪问题。用docker执行器每个 Job 都基于干净的镜像启动容器构构建环境是隔离且可重复的。--docker-volumes /root/.m2:/root/.m2这一步把宿主机的 Maven 本地仓库挂载进构建容器后续缓存依赖才能生效。注册完可以在 GitLab 后台看到 Runner 状态是绿色的然后进入下一步——写流水线。3. 核心流水线一个经过实战打磨的.gitlab-ci.yml长什么样3.1 Pipeline 三阶段设计build、upload、notify流水线我一开始只写了 build 和 upload 两个阶段跑通之后又加了 notify 阶段用于构建完成后在钉钉群里通知。实际结构如下stages: - build - upload - notify variables: MAVEN_OPTS: -Dmaven.repo.local/root/.m2/repository OSS_BUCKET: my-backend-bucket OSS_PREFIX: release/java-demo cache: key: $CI_PROJECT_PATH_SLUG paths: - .m2/repository.m2/repository放到 cache 里是这里最重要的一步。Java 项目的依赖动辄几百 MB没有缓存的话每次 Pipeline 都要把整个 Maven 仓库重新拉一遍构建时间直接从两分钟飙到十分钟。把$CI_PROJECT_PATH_SLUG作为缓存 key是为了避免多个项目之间缓存串掉。build 阶段用一个 Maven 镜像跑构建build: stage: build image: maven:3.8.8-eclipse-temurin-17 tags: - java-builder script: - mvn clean package -DskipTests -B artifacts: paths: - target/*.jar expire_in: 1 dayartifacts会把构建产物暂存到 GitLab并且只在同一个 Pipeline 的后续阶段之间传递。expire_in: 1 day控制它在 GitLab 服务器上的保留时间避免 artifacts 长期占用磁盘。注意这里只打包不测试如果希望把单测也纳入流水线把-DskipTests去掉就好但构建时间会明显变长看团队取舍。3.2 Maven 构建提速三板斧缓存、镜像、并行第一板斧就是上文说的.m2缓存。第二板斧是给 Maven 配国内镜像源。默认中央仓库在国内网络下下载依赖非常慢我在项目根目录放了一个settings.xml只覆盖 central 镜像mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror然后在 build 脚本里加上-s settings.xml参数- mvn -s settings.xml clean package -DskipTests -B第三板斧是并行任务。如果项目是多模块可以用 Maven 的-T参数启用并行构建比如mvn -T 4 clean package实测多核机器上提速明显。不过注意如果模块之间存在依赖关系Maven 会自动处理不用太担心。3.3 制品版本命名的两种思路别再每次手敲制品命名直接决定了你在 OSS 里能不能快速找到想要的那一个。我参考了社区里比较通用的做法提供两个可选方案。日常分支用短 SHA- cp target/java-demo-1.0.0.jar target/java-demo-${CI_COMMIT_SHORT_SHA}.jar正式发版用 Git Tag Pipeline ID- cp target/java-demo-1.0.0.jar target/java-demo-${CI_COMMIT_TAG}-${CI_PIPELINE_ID}.jar我实际用的是第二种并把整个文件复制到目标目录后删除原命名文件避免上传时文件名冲突。这里要特别提醒制品名必须带 Pipeline ID 或 SHA否则同一个文件名的下次构建会直接覆盖上次的产物回到手动流程里“传错包”的老问题。4. 制品上传 OSS 的三种姿势与选型建议4.1 直传 OSScurl 配合预签名 URL灵活但繁琐上传 OSS 这件事阿里云提供了 SDK、ossutil 工具也可以在生成预签名 URL 后配合 curl 直接 PUT。我最早测试时就是同时用了 ossutil 生成签名 URL 和 curl 上传signed_url$(ossutil sign oss://my-backend-bucket/release/java-demo/java-demo-1.0.0.jar --timeout 600) curl -X PUT \ -H Content-Type: application/java-archive \ --upload-file target/java-demo-1.0.0.jar \ $signed_url这种方式的好处是不用安装额外的客户端一个 curl 就能传坏处是预签名 URL 的过期时间管理、URL 编码、Header 设置都要自己维护时间一长脚本很容易变得难以阅读。我的结论是偶尔手动传一次可以用但放进 CI 流水线没必要把复杂度留给自己。4.2 ossutil 才是 CI 里的主力参数逐个讲最终我选了 ossutil 作为上传工具因为它是最通用、最省事的方案。CI 环境里不需要安装复杂依赖下载二进制就能用。before_script里这样装- mkdir -p /tmp/ossutil cd /tmp/ossutil - curl -L -o ossutil.zip https://gosspublic.alicdn.com/ossutil/1.7.20/ossutil-v1.7.20-linux-amd64.zip - unzip -o ossutil.zip - mv ossutil-v1.7.20-linux-amd64/ossutil64 /usr/local/bin/ossutil - chmod x /usr/local/bin/ossutil - cd - - ossutil config -e ${OSS_ENDPOINT} -i ${OSS_ACCESS_KEY_ID} -k ${OSS_ACCESS_KEY_SECRET} -L CHOSS_ENDPOINT、OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET这些变量我放在 GitLab 项目的 CI/CD Variables 里勾选 Masked 和 Protected这样在日志里不会明文出现。上传命令这样写- ossutil cp target/java-demo-${CI_PIPELINE_ID}.jar \ oss://${OSS_BUCKET}/${OSS_PREFIX}/${CI_PIPELINE_ID}/ \ --update --retry 3--update表示如果目标端已有同名文件且大小一致则跳过避免重复上传--retry 3是上传失败时自动重试三次非常实用。如果你的网络环境比较差还可以加--big-file-threshold调整分片上传的阈值jar 包很大时建议研究一下。4.3 上传完整性校验md5 对不上就重试制品上传完不等于任务结束必须确认文件完整。OSS 每个 Object 都有 ETagOSS 的 ETag 对普通文件来说就是文件内容的 MD5。我在 upload 阶段后面加了一段校验- local_md5$(md5sum target/java-demo-${CI_PIPELINE_ID}.jar | awk {print $1}) - oss_md5$(ossutil stat oss://${OSS_BUCKET}/${OSS_PREFIX}/${CI_PIPELINE_ID}/java-demo-${CI_PIPELINE_ID}.jar | grep ETag | awk -F {print $2}) - if [ $local_md5 ! $oss_md5 ]; then echo MD5 mismatch exit 1; fi如果校验不通过Pipeline 会直接失败并触发 notify 阶段的通知避免把损坏的包留给下游使用。这个操作看着简单但真正遇到过一次文件损坏导致线上启动失败后我再也没省略过校验。5. 从构建到上传的排错实录这些问题我替你先踩了5.1 Runner 注册完一直离线我第一次注册 Runner 后后台界面等了半天都显示“runner has never contacted this instance”。排查下来有两个原因。第一个是 GitLab 实例的 URL 填错了。注册时--url必须能通过 Runner 容器访问到 GitLab 服务比如我在 compose 里配的 external_url 是http://gitlab.example.com:8929Runner 容器里就要用这个地址去访问。如果填了内网 IP容器之间网络不通自然连不上。第二个是 token 过期。GitLab 后续版本引入了不同的 token 体系项目里的 Runner 注册 token 和老的全局 token 不一样复制的时候要看清是glrt-开头的还是别的格式。排查时可以用docker logs gitlab-runner看日志里面通常有明确的报错信息。5.2 构建在 CI 里比本地慢问题出在 Maven 仓库流水线第一次跑通我一看耗时十四分钟比本地多十倍。日志一拉发现 Maven 正在从中央仓库慢慢下载依赖才意识到 CI 环境和本地环境最大的差异是本地~/.m2里已经攒了一堆依赖CI 容器里是空的。解决办法就是前面说的两件事挂载--docker-volumes /root/.m2:/root/.m2让依赖仓库持久化再用阿里云镜像源把下载速度提上来。改完之后同样的项目构建时间降到了两分半左右。如果发现 cache 不生效检查一下 runner 的config.toml确认volumes配置真的被加进去了有时候 register 时写的参数会被后续的配置文件覆盖掉。5.3 AccessDenied 排查Bucket 权限策略和 endpoint 别搞混上传阶段碰到403 AccessDenied大部分人第一反应是 AK/SK 错了但我那次 AK/SK 完全没问题最后发现是 endpoint 填错了。Bucket 在华东 2上海我在环境变量里写成oss-cn-hangzhou.aliyuncs.comOSS 服务端直接拒绝。这个错误很隐蔽因为其他地方的代码可能根本没用到 Bucket 所在的 region。还有个典型错误是 RAM 策略里的 Resource 路径写窄了。比如我上面策略只授权了release/*如果上传脚本里路径写成了test/xxx或者没有带release/前缀也会 AccessDenied。建议排错时先把脚本里的完整 OSS 路径打印出来对着 RAM 策略逐步核对基本一下就能定位。5.4 文件传上去了下载 URL 却有中文乱码这个问题是下游同事反馈的从浏览器点进去下载 jar 包文件名乱码。查下来是 Content-Disposition 响应头的问题。OSS 默认会用 Object 的原始文件名做下载名但如果文件名里有中文或特殊字符某些客户端不会自动做 URL 解码。我的处理方法是上传时显式设置 Content-Disposition用 RFC 5987 格式指 UTF-8 文件名ossutil cp target/java-demo-${CI_PIPELINE_ID}.jar \ oss://${OSS_BUCKET}/${OSS_PREFIX}/${CI_PIPELINE_ID}/ \ --meta Content-Disposition:attachment;filename*UTF-8java-demo-${CI_PIPELINE_ID}.jar顺带提一句文件名里的中文我后面干脆全改成英文了统一走 Pipeline ID 命名从根本上绕开编码问题。5.5 流水线显示成功产物却是上一个版本最诡异的一次是流水线全绿但下载下来的 jar 包却是上一版代码编出来的。排了一个下午才找到原因Runner 并发跑两个分支的构建Docker executor 默认会为每个 Job 分配独立的容器但我在 register 时挂载了一个很宽的 volume把宿主机的整个构建目录共享给了不同 Job。两个构建同时跑就出现了“互相覆盖工作目录”的竞态问题。解决方案是限制 Runner 并发数或者给每个 Job 指定独立的构建目录。最省事的是在 Runner 的config.toml里设concurrent 1让每个 Runner 一次只跑一个 Job。虽然会排队但换来的是构建目录的确定性这个取舍我认为非常值。6. 从“能跑”到“好用”环境隔离、版本治理与日常维护6.1 同一套 yaml根据分支自动切 dev/prod流水线跑顺之后下一个需求是环境隔离开发分支的构建传到 dev 路径打 tag 的时候才传到 prod 路径。GitLab rules 里的 variables 支持这个逻辑我在.gitlab-ci.yml里这样写upload: stage: upload variables: OSS_ENV_PATH: dev rules: - if: $CI_COMMIT_TAG variables: OSS_ENV_PATH: prod - if: $CI_COMMIT_BRANCH master variables: OSS_ENV_PATH: prod - when: always script: - ossutil cp target/java-demo-${CI_PIPELINE_ID}.jar oss://${OSS_BUCKET}/release/java-demo/${OSS_ENV_PATH}/${CI_PIPELINE_ID}/核心思路是默认上传到 dev当 Pipeline 因为 tag 触发或分支是 master 时OSS_ENV_PATH被覆盖成 prod。这个规则我踩过一个坑rules 里定义的 variables 只对当前 Job 生效不能在variables全局区里复用所以我把OSS_ENV_PATH的默认值写在 Job 内保证不传 tag 时不至于变成空路径。6.2 磁盘和 OSS 都在膨胀生命周期管理怎么做自动化之后人变懒了磁盘也开始报警。GitLab 服务器跑了一段时间之后Docker 镜像、Job 的工作目录、artifacts 缓存堆了几十个 G。我的处理策略是双管齐下。宿主机上定期执行清理docker system prune -f docker builder prune -fGitLab 里对每个项目设置 artifacts 保留时间如上文expire_in: 1 day。OSS 这边的旧制品也不能无限堆我在 OSS 控制台配置了生命周期规则按前缀匹配release/java-demo/dev/文件超过 30 天自动删除release/java-demo/prod/的包保留 180 天。这样既保住了可追溯性又不会让存储成本失控。6.3 构建完成记得喊一声钉钉通知与产物地址最后加的是 notify 阶段。构建本身是机器干的但人总得知道结果。我用钉钉群里一个自定义机器人的 Webhook上传成功后直接把下载地址推出来notify: stage: notify image: alpine:3.18 tags: - java-builder script: - apk add --no-cache curl - curl -X POST ${DINGTALK_WEBHOOK} \ -H Content-Type: application/json \ -d {\msgtype\:\text\,\text\:{\content\:\Java构建成功: ${CI_PROJECT_NAME} | ${CI_PIPELINE_ID}\n下载链接: https://${OSS_BUCKET}.${OSS_ENDPOINT}/${OSS_PREFIX}/${CI_PIPELINE_ID}/java-demo-${CI_PIPELINE_ID}.jar\}}这里有个细节Webhook 地址和产物 URL 都不希望出现在日志里所以 Webhook 我放在了 GitLab CI/CD Variables 里URL 则用变量拼接避免在明文里写死 Bucket 名。通知不单发成功失败时我会再加一个when: on_failure的 Job内容更简短只提醒“构建失败先去 GitLab 看日志”。这两个 Job 配合群里的消息噪音小了很多但关键信息一个不少。这套流程从最开始的手动上传到现在我已经跑了快三个月。最深的体会是自动化最大的价值不是省那半个小时而是把“人肉操作可能引入的错误”挡在流程之外。你不用担心忘了传包、传错目录、或者把开发环境的配置混到正式包里去——这些事现在都由规则保证而不是靠某个人细心。如果你也在集成 GitLab 和 OSS 的过程中踩到别的坑尤其是 Runner 配置和权限策略这两个环节大概率都能在本文对应的小节里找到影子。
返回列表