一、迁移的动机与前置评估
成本维度
Jenkins 需要自建 master 与 agent,运维成本随规模线性上升
GitLab CI 需要自建 runner,且与 GitLab 实例绑定
GitHub Actions 托管 runner 按分钟计费,公共仓库免费
维护维度
Jenkins 插件生态老化,升级插件常引发兼容问题
Jenkinsfile 的 Groovy 沙箱限制多,调试体验差
不迁移的理由(同样要评估)
Jenkins 上有大量难以迁移的定制插件与脚本
GitLab 的 DAG 与父子流水线有独特能力
已有自建 GPU 集群且成本已摊薄
迁移前必须盘点的内容
[ ] 流水线总数与复杂度分布(简单 / 中等 / 复杂各多少)
[ ] 使用的 Jenkins 插件清单,逐个确认有无 Actions 替代
[ ] 凭据清单与类型(用户名密码 / SSH 密钥 / 证书 / token)
[ ] 自托管 agent 的硬件需求(GPU、大内存、特殊操作系统)
[ ] 构建产物的存储位置与保留策略
[ ] 平均与峰值并发,用于估算 Actions 分钟数与并发上限
[ ] 与外部系统的集成点(制品库、通知、部署平台)
迁移顺序
第 1 批 最简单、无外部依赖的构建与测试流水线
第 2 批 带制品发布的流水线,验证凭据迁移与制品存储
第 3 批 带部署的流水线,验证环境门禁与 OIDC 免密
第 4 批 复杂的多阶段流水线,验证 reusable workflow 与矩阵
最后 Jenkins 上残留的定时任务,迁移到 schedule 与 workflow_dispatch
二、概念映射总表
2.1 Jenkins 到 GitHub Actions
Jenkins 概念 GitHub Actions 对应
Jenkinsfile .github/workflows/*.yml
pipeline { agent any } jobs.<id>.runs-on
stage('Build') job(用 needs 串起来)
steps { sh 'x' } steps: - run: x
parallel { } jobs 之间的隐式并行(无 needs 即并行)
environment { } env(workflow / job / step 三级)
post { always { } } if: always()
when { branch 'main' } if: github.ref == 'refs/heads/main'
parameters { } workflow_dispatch.inputs
triggers { cron('') } on.schedule
credentials('id') secrets.NAME
withCredentials([...]) env: { X: ${{ secrets.Y }} }
stash / unstash upload-artifact / download-artifact
archiveArtifacts actions/upload-artifact
shared library reusable workflow 或 composite action
agent { label 'gpu' } runs-on 的 group 与 labels
retry { } 无原生支持,用第三方 action 或脚本
timeout(time: 20) timeout-minutes
2.2 GitLab CI 到 GitHub Actions
GitLab CI 概念 GitHub Actions 对应
.gitlab-ci.yml .github/workflows/*.yml
stages: [build, test] jobs 之间用 needs 表达依赖
stage: build needs 指向的前置 job
script: steps: - run:
before_script Actions 无对应,每个 job 重复或用 composite action
after_script if: always() 的收尾步骤
rules: on: 与 job 级 if: 的组合
only: / except: if:(GitLab 已废弃 only/except)
artifacts: actions/upload-artifact
dependencies: needs 与 download-artifact
cache: actions/cache
variables: env / vars
CI/CD Variables secrets / vars
extends: / include: 无直接对应,用 reusable workflow 或 YAML 锚点
parallel: 4 矩阵 strategy.matrix
environment: environment
when: manual environment 的 required reviewers
allow_failure: true continue-on-error: true
tags: [gpu] runs-on 的 labels
services: / image: services 或 container:
2.3 心智模型的差异
Jenkins:「一台机器上顺序执行若干阶段,阶段内部用 Groovy 编程」
强项是脚本能力,条件分支与循环随意写;弱项是状态多、难复现
GitLab CI:「一组 job 按 stage 分组,stage 之间串行,stage 内并行」
强项是结构清晰,include 与 extends 复用方便;弱项是 stage 是隐式依赖
GitHub Actions:「一组 job 组成 DAG,每个 job 在干净的 runner 上执行」
强项是默认隔离、默认并行、依赖显式;弱项是 job 间无共享文件系统
最关键的差异是 job 之间不共享工作目录。Jenkins 的 stage 共享 workspace,上一步的文件下一步直接可用;GitLab 的 job 会重新 clone,但可以通过 artifacts 传递;Actions 的 job 各自在全新的 runner 上,产物必须显式上传为 artifact 再下载,或者干脆把多个步骤合并到同一个 job 里。
三、Jenkinsfile 迁移实战
3.1 原始结构
pipeline {
agent any
environment { REGISTRY = 'registry.example.com' }
options { timeout(time: 30, unit: 'MINUTES') }
stages {
stage('Install') { steps { sh 'npm ci' } }
stage('Test') {
parallel {
stage('Unit') { steps { sh 'npm run test:unit' } }
stage('Lint') { steps { sh 'npm run lint' } }
}
}
stage('Build') {
steps {
sh "docker build -t $REGISTRY/app:$GIT_COMMIT ."
sh "docker push $REGISTRY/app:$GIT_COMMIT"
}
}
}
post {
always { junit 'reports/**/*.xml' }
failure { slackSend channel: '#ci', message: "Build failed: ${env.BUILD_URL}" }
}
}
3.2 等价的工作流
name: CI
on:
push:
branches: [main]
pull_request:
env:
REGISTRY: registry.example.com
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
target: [unit, lint]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run ${{ matrix.target == 'unit' && 'test:unit' || 'lint' }}
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-${{ matrix.target }}
path: reports/**/*.xml
build:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/app:sha-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
失败通知改用独立的汇总 job:
notify:
needs: [test, build]
if: always() && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
steps:
- run: |
curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
-H 'Content-Type: application/json' \
-d '{"text":"Build failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
3.3 映射要点
1) parallel 块 → 矩阵或独立的 job
Jenkins 的 parallel 内嵌 stage 无法一对一映射,
最简单的做法是把并行分支拆成独立 job(无 needs 即并行)
2) post { always } → if: always()
Actions 没有 post 块,每个需要「总是执行」的步骤单独加 if
3) junit 插件 → dorny/test-reporter 或 upload-artifact
Actions 不会自动解析测试报告,需要显式上传与渲染
4) archiveArtifacts → actions/upload-artifact
artifact 默认保留 90 天,numToKeepStr 需换算成 retention-days
5) timeout(time: 30) → timeout-minutes: 30
Actions 的默认值是 360 分钟,必须显式收紧
6) GIT_COMMIT → github.sha
github.sha 是完整 40 位,需要短哈希时自己截取
四、GitLab CI 迁移实战
4.1 原始结构
stages: [build, test, deploy]
build:
stage: build
script: [npm ci, npm run build]
artifacts:
paths: [dist/]
expire_in: 1 week
test:unit:
stage: test
script: [npm ci, npm run test:unit]
artifacts:
reports:
junit: reports/junit.xml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
deploy:prod:
stage: deploy
script: [./deploy.sh]
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
4.2 等价的工作流
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci && npm run test:unit
- uses: actions/upload-artifact@v4
if: always()
with:
name: junit
path: reports/junit.xml
deploy-prod:
needs: [build, test-unit]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./deploy.sh
4.3 关键差异
1) stage 是隐式依赖,needs 是显式依赖
GitLab 里所有 build 完成后 test 才开始
Actions 里不写 needs 就完全并行,写 needs 才有顺序
迁移时最容易出错的地方:漏写 needs 导致 job 乱序
2) rules 与 on 的职责不同
GitLab 的 rules 同时决定「是否触发」与「何时算成功」
Actions 的 on 决定触发,if 决定是否执行
复杂的 rules 条件要拆成 on 与 job 级 if 两部分
3) when: manual 没有直接对应
Actions 用 environment 的 required reviewers 替代,
语义更接近「审批」而非「手动点击执行」
4) extends 与 YAML 锚点
Actions 不支持 extends,只能用 YAML 锚点或 reusable workflow
锚点可用但可读性差,跨文件复用必须用 reusable workflow
5) artifacts 与 reports
GitLab 的 artifacts:reports:junit 会自动在 MR 中渲染
Actions 需要额外的 action 才能达到同等体验
4.4 条件语义对照
GitLab GitHub Actions
$CI_COMMIT_BRANCH == "main" github.ref == 'refs/heads/main'
$CI_PIPELINE_SOURCE == "merge_request_event" github.event_name == 'pull_request'
$CI_COMMIT_TAG startsWith(github.ref, 'refs/tags/')
$CI_MERGE_REQUEST_TARGET_BRANCH_NAME github.base_ref
$CI_COMMIT_MESSAGE github.event.head_commit.message
rules:changes 无原生对应,用 paths 过滤器或 dorny/paths-filter
# rules:changes 的替代:只在特定路径变更时触发
on:
push:
branches: [main]
paths:
- "src/**"
- "package.json"
需要按目录分别跳过下游 job 时,用 dorny/paths-filter 产出布尔输出,再让下游 job 用 needs 加 if 判断。
五、共享库到可复用工作流
5.1 Jenkins 共享库的形态
// vars/buildAndPush.groovy,在 Jenkinsfile 中用 @Library 引入
def call(Map config) {
def image = "${config.registry}/${config.name}:${env.GIT_COMMIT}"
sh "docker build -t ${image} ${config.context ?: '.'}"
sh "docker push ${image}"
return image
}
5.2 对应到 reusable workflow
# my-org/ci-workflows/.github/workflows/build-and-push.yml
on:
workflow_call:
inputs:
registry: { type: string, required: true }
name: { type: string, required: true }
outputs:
image:
description: "推送后的镜像引用"
value: ${{ jobs.build.outputs.image }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- id: meta
run: echo "image=${{ inputs.registry }}/${{ inputs.name }}:sha-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.image }}
# 调用方
jobs:
publish:
uses: my-org/ci-workflows/.github/workflows/build-and-push.yml@v1
with:
registry: registry.example.com
name: app
5.3 对应到 composite action
# .github/actions/build-and-push/action.yml
name: Build and push
inputs:
registry: { description: 镜像仓库地址, required: true }
name: { description: 镜像名, required: true }
outputs:
image:
value: ${{ steps.meta.outputs.image }}
runs:
using: composite
steps:
- id: meta
shell: bash
run: echo "image=${{ inputs.registry }}/${{ inputs.name }}:sha-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.image }}
维度 reusable workflow composite action
粒度 整条流水线 一组步骤
runs-on 内部自己声明 由调用方决定
secrets 通过 secrets 显式传入 不直接接收,用 env 传递
适用 多仓库共享标准流水线 共享一段可复用的步骤
限制 最多 4 层嵌套 不能包含 runs-on
从 Jenkins 共享库迁移的建议
1) 共享库里的「整条流水线」方法 → reusable workflow
2) 共享库里的「工具方法」(如计算版本号)→ composite action
3) 共享库里的「常量」→ 组织级 Variables
4) 共享库里的「凭据读取」→ 全部改为 OIDC 或 Secrets
六、凭据迁移
6.1 Credentials 到 Secrets
Jenkins 凭据类型 GitHub 对应
Username with password 两个 Secret(用户名 + 密码)
Secret text 单个 Secret
Secret file 把文件内容作为 Secret,用时写到磁盘
SSH Username with private key SSH_PRIVATE_KEY Secret + ssh-agent
Certificate 证书内容作为 Secret,用时解码
GitHub App 直接用内置 GITHUB_TOKEN
Jenkins 的 withCredentials 把值注入环境变量,Actions 直接引用 Secret 即可:
- uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ secrets.REG_USER }}
password: ${{ secrets.REG_PASS }}
6.2 SSH 密钥与 OIDC
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Add known hosts and deploy
run: |
mkdir -p ~/.ssh
ssh-keyscan -H deploy.example.com >> ~/.ssh/known_hosts 2>/dev/null
ssh deploy@deploy.example.com 'cd /srv/app && ./pull-and-restart.sh'
必须处理 known_hosts,否则首次连接会因主机密钥确认而卡住;ssh-keyscan 有中间人风险,生产环境应把已知主机密钥写死。更彻底的做法是用 OIDC 加云 API 替代 SSH 部署,消除长期密钥。
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-northeast-1
迁移动作
[ ] 在云侧创建 OIDC 提供者与角色
[ ] 信任策略限定到具体仓库与分支
[ ] workflow 声明 permissions: id-token: write
[ ] 删除 Secret 中的长期 AccessKey
[ ] 审计日志确认旧密钥已无调用
凭据可见性分层
仓库级 Secret 仅该仓库可见,适合仓库独有的部署密钥
组织级 Secret 按 all / private / selected 分发,适合通用凭证
Environment Secret 仅绑定该环境的 job 可见,适合生产凭证
Variables 非敏感配置,可明文读回
七、缓存与矩阵迁移
7.1 缓存迁移
# GitLab
cache:
key:
files: [package-lock.json]
paths: [node_modules/]
# Actions
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
映射要点
1) GitLab 的 key.files 相当于 hashFiles
2) GitLab 无 restore-keys,Actions 的 restore-keys 是额外能力
3) Actions 的缓存总量按仓库限制(默认 10GB),需要关注淘汰
4) 语言级 setup action 大多内置缓存,优先用它们而非手写 cache
actions/setup-node 的 cache: npm
actions/setup-python 的 cache: pip
actions/setup-go 的 cache: true
Jenkins 缓存迁移
Jenkins 通常直接把 workspace 留在 agent 上作为缓存,
迁移后必须显式用 actions/cache,
否则每次都在全新的 runner 上从零安装依赖。
7.2 矩阵与服务容器
# Actions 的 matrix,对应 GitLab 的 parallel:matrix
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
include:
- node: 20
coverage: true
exclude:
- node: 18
coverage: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test
矩阵能力对照
能力 GitLab Actions
笛卡尔积 支持 支持
显式组合 matrix 数组 include
排除 不支持 exclude
动态矩阵 不支持 fromJSON 表达式
fail-fast 不支持 fail-fast 参数
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app
服务容器的关键差异
1) GitLab 的服务容器与 job 共享网络命名空间,可用服务名直连
2) Actions 的服务容器需要显式 ports 映射,用 localhost 访问
3) Actions 必须配置 health check,否则应用可能先于数据库就绪而失败
八、双跑灰度与回退预案
双跑阶段的关注点
1) 结论一致性:两者成功失败判定是否相同
2) 产物一致性:构建产物的 sha256 是否一致
3) 耗时对比:Actions 与 Jenkins 的 P50 与 P90 差异
4) 覆盖完整性:Jenkins 上跑的检查是否都已迁移
5) 退出标准:连续 20 次结论一致即可关闭 Jenkins 侧
做法是在迁移期让同一次提交同时触发两边,各自把产物摘要写入 artifact,再由一个对比 job 拉取两边的摘要做 diff。摘要一致说明构建可复现,不一致则需要逐个排查工具链版本与构建参数。
灰度切换
阶段 1 仅观察 Actions 跑但不阻塞合并,暴露环境差异与脚本兼容问题
阶段 2 并行阻塞 两者都作为必需检查,确认判定一致
阶段 3 切换必需检查 Jenkins 降级为观察,Actions 独立承载
阶段 4 关闭 Jenkins 保留只读一段时间以便回查
回退触发条件
1) Actions 出现长时间不可用
2) 关键流水线的结论与 Jenkins 持续不一致
3) 有检查项无法在 Actions 中实现
回退动作
1) 把必需检查切回 Jenkins
2) 保留 Actions 流水线继续观察
3) 记录本次回退原因,作为下一轮迁移的输入
前提
在阶段 4 之前,Jenkins 侧的流水线配置必须保持可运行,
不要提前删除 Jenkinsfile。
九、常见坑清单
工作目录与文件传递
1) 假设 job 之间共享工作目录
Jenkins 的 stage 共享 workspace,Actions 的 job 各自独立。
表现:build job 生成的 dist/ 在 deploy job 中不存在。
解法:用 upload-artifact 与 download-artifact 传递,
或把相关步骤合并到同一个 job。
2) checkout 深度默认是 1
依赖 git 历史的步骤(版本号推导、changelog)会失败。
解法:with: fetch-depth: 0。
3) cd 不会跨步骤保留
每个 run 步骤都从 GITHUB_WORKSPACE 开始。
解法:用 working-directory 参数,或每个步骤内显式 cd。
环境变量与 shell 行为
4) env 的作用域容易搞混
workflow 级所有 job 可见;job 级该 job 内可见;step 级仅该步骤可见
5) 步骤间传递变量必须用 GITHUB_ENV
在 run 里 export FOO=bar 只对当前步骤有效
6) 传递输出必须用 GITHUB_OUTPUT
旧写法 ::set-output 已废弃
7) 默认 shell 与 set -e
Actions 的 run 步骤在 Linux 上默认使用 bash -e {0},
即已开启 -e,但不开启 -u 与 -o pipefail
8) 管道中的失败被吞掉
没有 pipefail 时,cmd1 | cmd2 中 cmd1 失败不会被察觉,
排查「明明失败了却显示成功」时优先检查这一项
- name: Set version
id: ver
run: |
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Strict script
shell: bash
run: |
set -euo pipefail
echo "version is $VERSION and ${{ steps.ver.outputs.version }}"
触发与条件
9) fork PR 拿不到 secret
GitHub 对来自 fork 的 pull_request 不下发任何 secret,
Jenkins 与 GitLab 在自建环境下通常没有这个限制。
解法:用 pull_request_target(注意安全)或 workflow_run 两段式。
10) paths 过滤器在分支保护上的副作用
配置了 paths 的 workflow 若未命中路径则不触发,
作为必需检查时会一直显示 pending。
解法:搭配一个总是运行的「汇总 job」作为必需检查。
11) on.schedule 使用 UTC
cron 表达式一律按 UTC 解释,需要换算本地时间。
12) workflow_dispatch 的 inputs 都是字符串
布尔与数字类型在表达式中仍可能被当成字符串比较,
解法:显式用 == 'true' 比较,或声明 type 后用 fromJSON 转换。
制品与保留
13) artifact 默认保留 90 天
Jenkins 的 logRotator 与 GitLab 的 expire_in 语义不同,
解法:按需设置 retention-days,并在组织级设置上限。
14) artifact 名称冲突
矩阵中多个 job 上传同名 artifact 会失败,
解法:在 name 中加入 ${{ matrix.* }} 区分。
15) 缓存与 artifact 混用
缓存用于加速(可丢失),artifact 用于交付(必须可靠),
不要把构建产物只放在缓存里,缓存淘汰后无法恢复。
9.1 迁移检查清单
代码层
[ ] 所有 Jenkinsfile 与 .gitlab-ci.yml 已盘点
[ ] 每个 stage / job 都有对应的 Actions job
[ ] parallel 块已拆成独立 job 或矩阵
[ ] post / after_script 已改为 if: always() 的步骤
凭据层
[ ] 所有 Credentials 已映射为 Secrets 或 OIDC
[ ] 长期云 AccessKey 已删除并确认无调用
[ ] Environment secret 已用于生产凭证
[ ] GITHUB_TOKEN 权限已按 job 最小化
能力层
[ ] 缓存已迁移且命中率正常
[ ] 矩阵与 include / exclude 已配置
[ ] 服务容器已配置 health check
[ ] 测试报告已能渲染,制品已归档且保留期合理
流程层
[ ] 双跑对比已连续 20 次结论一致
[ ] 必需检查已切换为 Actions
[ ] 回退预案已书面化
[ ] Jenkins 已保留只读一段时间
总结
从 Jenkins 与 GitLab CI 迁移到 GitHub Actions,表面上是 YAML 语法转换,实质上是心智模型的替换:Jenkins 的阶段共享同一台机器与工作目录,GitLab 用 stage 表达隐式依赖,而 Actions 把每个 job 放在全新的隔离 runner 上,依赖必须用 needs 显式声明、产物必须用 artifact 显式传递。抓住这条主线,迁移中最常见的翻车点,比如上一步生成的文件下一步找不到、漏写 needs 导致乱序、fork PR 拿不到 secret,就都能提前预判。工具层面,Jenkins 共享库对应 reusable workflow 或 composite action,Credentials 对应 Secrets 与 OIDC,stash 与 artifacts 对应 upload-artifact 与 download-artifact,cache 对应 actions/cache 与语言级 setup action 的内置缓存。流程层面,务必先用双跑对比确认结论与产物一致,再分四个阶段灰度切换,并在阶段四之前保持 Jenkins 可运行,让回退始终是一个选项。
延伸阅读:
- GitHub Actions 可复用工作流 — 共享库迁移的目标形态
- GitHub Actions 复合 Action — 步骤级复用
- GitHub Actions 缓存优化 — 缓存 key 与命中率
- GitHub Actions 矩阵策略 — 并行与组合
- GitHub Actions 容器作业 — 服务容器与自定义镜像
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。