在 WSL2 中搭建 GitLab Community Edition

WSL2 本地 GitLab CI/CD 最小实验记录

从零搭建 GitLab CE、GitLab Runner,并逐步验证用户权限、Runner、Pipeline、Artifact、Manual Deploy、Environment、Protected Branch、Merge Request Pipeline、稀缺 Testbed 调度与 Release Pipeline。

1. 实验目标

这套实验并不是为了单纯“安装一个 GitLab”,而是希望在本地复现真实企业 CI/CD 中的一些核心概念:

  • GitLab Self-Managed
  • GitLab 用户、Group、Project 与权限
  • GitLab Runner
  • Shell Executor
  • Pipeline / Stage / Job
  • Artifacts
  • Manual Deployment
  • Environment
  • Protected Branch
  • Merge Request Pipeline
  • MR-only Pipeline
  • Runner Tag
  • Runner Scope
  • 稀缺测试资源调度
  • Release Pipeline

实验环境:

1
2
3
4
5
6
Windows
└── WSL2 Ubuntu
├── Docker Engine
├── GitLab CE
├── GitLab Runner
└── Local Git repositories

GitLab 使用本地域名:

1
http://gitlab.test:8929

2. 使用 Docker 部署 GitLab CE

创建目录:

1
2
mkdir -p /svc/gitlab
cd /svc/gitlab

创建 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
services:
gitlab:
image: gitlab/gitlab-ce:latest
container_name: gitlab
restart: unless-stopped

hostname: gitlab.test

shm_size: '256m'

environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://gitlab.test:8929'
gitlab_rails['gitlab_shell_ssh_port'] = 2224

ports:
- "8929:8929"
- "2224:22"

volumes:
- ./config:/etc/gitlab
- ./logs:/var/log/gitlab
- ./data:/var/opt/gitlab

启动:

1
docker compose up -d

检查:

1
2
docker ps
docker exec -it gitlab gitlab-ctl status

GitLab 内部实际上包含很多服务,例如:

1
2
3
4
5
6
7
8
9
10
nginx
postgresql
redis
puma
sidekiq
gitaly
prometheus
alertmanager
gitlab-workhorse
...

因此 GitLab 容器启动速度明显慢于普通 Web 服务。


3. GitLab unhealthy 与 /dev/shm

第一次启动后曾出现:

1
Up ... (unhealthy)

但执行:

1
docker exec -it gitlab gitlab-ctl status

可以看到:

1
2
3
4
5
6
7
postgresql   run
redis run
puma run
sidekiq run
nginx run
gitaly run
...

说明 GitLab 核心组件其实都已经启动。

日志中出现:

1
writing value to /dev/shm/gitlab/sidekiq/... failed

Docker 默认共享内存较小,因此在 Compose 中显式增加:

1
shm_size: '256m'

验证:

1
docker exec gitlab df -h /dev/shm

4. gitlab.local、gitlab.test 与域名解析

最开始使用:

1
gitlab.local

后来切换为:

1
gitlab.test

原因之一是 .local 通常和 mDNS 有特殊关系,而 .test 更适合作为测试域名。

Windows:

1
C:\Windows\System32\drivers\etc\hosts

添加:

1
127.0.0.1 gitlab.test

然后:

1
ipconfig /flushdns

验证:

1
ping gitlab.test

GitLab 的:

1
external_url

也必须同步修改:

1
external_url 'http://gitlab.test:8929'

否则会出现一个很隐蔽的问题:

1
2
3
4
5
Runner API URL
→ gitlab.test

但 GitLab 下发的 repository clone URL
→ gitlab.local

最终 Runner 报:

1
Could not resolve host: gitlab.local

检查 GitLab 实际认为自己的 URL:

1
2
docker exec gitlab \
gitlab-rails runner 'puts Gitlab.config.gitlab.url'

必须输出:

1
http://gitlab.test:8929

5. 创建普通用户,而不是一直使用 root

root 只用于 GitLab Instance 管理。

额外创建普通用户:

1
2
3
Username: jason
Type: Regular user
Admin: false

之后日常操作使用 jason。

创建 Group:

1
devops-lab

然后创建项目:

1
devops-lab/ci-playground

这样权限层次变成:

1
2
3
4
5
6
7
8
GitLab Instance
│
├── root
│ └── Administrator
│
└── devops-lab
└── jason
└── Owner

6. Windows / WSL 中区分 GitHub 与本地 GitLab Git Identity

Git 的:

1
2
user.name
user.email

决定的是 Commit Author。

可以使用 conditional include。

例如:

1
2
3
4
5
6
7
8
# ~/.gitconfig

[user]
name = Jason Zhou
email = [email protected]

[includeIf "gitdir:/home/zxy/workspace/gitlab-test/"]
path = ~/.gitconfig-gitlab

然后:

1
2
3
4
5
# ~/.gitconfig-gitlab

[user]
name = Jason Zhou
email = [email protected]

验证:

1
2
3
4
git config user.name
git config user.email

git config --show-origin --get-regexp '^user\.'

注意:

1
2
3
Commit Author
≠
SSH authentication identity

这是两套独立机制。


7. GitHub 与 Local GitLab 使用不同 SSH Key

SSH alias 配置位于:

1
~/.ssh/config

例如:

1
2
3
4
5
6
7
8
9
10
11
12
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github
IdentitiesOnly yes

Host gitlab-local
HostName gitlab.test
Port 2224
User git
IdentityFile ~/.ssh/id_ed25519_gitlab
IdentitiesOnly yes

gitlab-local 只是本机 SSH alias,不是 DNS。

因此:

1
ssh -T git@gitlab-local

实际上等价于:

1
2
3
HostName gitlab.test
Port 2224
IdentityFile ~/.ssh/id_ed25519_gitlab

Clone 时:

1
2
git clone \
git@gitlab-local:devops-lab/ci-playground.git

而直接:

1
2
git clone \
ssh://[email protected]:2224/devops-lab/ci-playground.git

不会匹配:

1
Host gitlab-local

因此可能使用错误的 SSH Key,最终出现:

1
Permission denied (publickey)

8. 第一条 GitLab Pipeline

第一版 .gitlab-ci.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
stages:
- build
- test
- deploy

build-job:
stage: build
script:
- echo "Building..."
- mkdir -p build
- echo "hello gitlab ci" > build/app.txt
artifacts:
paths:
- build/

test-job:
stage: test
script:
- echo "Testing..."
- test -f build/app.txt
- grep "hello gitlab ci" build/app.txt

deploy-job:
stage: deploy
script:
- echo "Deploying..."
- cat build/app.txt

Push 后:

1
2
3
4
5
6
7
8
9
Git push
↓
GitLab Pipeline
↓
build
↓
test
↓
deploy

但一开始所有 Job 都是:

1
Pending

原因:

1
GitLab 没有可用 Runner

9. GitLab Runner:Project Runner 与 Runner Scope

最开始创建的是:

1
Project Runner

它只属于:

1
ci-playground

新的:

1
waterfall-ci-playground

不会自动拥有它。

Runner Scope 可以分为:

1
2
3
Project Runner
Group Runner
Instance Runner

概念上类似:

1
2
3
4
5
6
7
8
Project Runner
→ 某一个/几个 Repo

Group Runner
→ 整个 devops-lab

Instance Runner
→ 整个 GitLab

对于:

1
2
3
4
devops-lab/
├── ci-playground
├── waterfall-ci-playground
└── future-projects

Group Runner 会更合理。


10. Tagged Job 与 Untagged Job

Runner 可以设置 tag:

1
shell

Job 也可以:

1
2
tags:
- shell

意思是:

1
这个 Job 必须找到包含 shell tag 的 Runner

如果 Job 没有:

1
tags:

则属于:

1
untagged job

这时候 Runner 必须启用:

1
Run untagged jobs

否则即使 Runner:

1
Online

Job 仍然会:

1
Pending

因此:

1
2
3
Runner Online
≠
Runner 一定可以执行当前 Job

还要经过 Runner 匹配。


11. User-mode Runner 与 System-mode Runner

第一次手工启动:

1
gitlab-runner run

日志:

1
WARNING: Running in user-mode.

配置文件:

1
/home/gitlab-runner/.gitlab-runner/config.toml

后来改成 system-mode。

systemd:

1
systemctl cat gitlab-runner

实际启动命令:

1
2
3
4
5
/usr/local/bin/gitlab-runner run
--config /etc/gitlab-runner/config.toml
--working-directory /home/gitlab-runner
--service gitlab-runner
--user gitlab-runner

这说明:

1
2
3
4
5
6
7
8
Config:
/etc/gitlab-runner/config.toml

Working Directory:
/home/gitlab-runner

Linux User:
gitlab-runner

启动:

1
2
sudo systemctl enable gitlab-runner
sudo systemctl start gitlab-runner

验证:

1
2
3
sudo systemctl is-enabled gitlab-runner
sudo systemctl is-active gitlab-runner
sudo gitlab-runner verify

这样 WSL 启动后,Runner 也可以跟随 systemd 自动启动。


12. Shell Executor 与 Linux 文件权限

第一次 user-mode Runner 是在:

1
/home/zxy/workspace/gitlab-test/ci-playground

目录中启动的:

1
gitlab-runner run

于是默认 working directory 继承了当前目录,导致 Runner 创建:

1
/home/zxy/workspace/gitlab-test/ci-playground/builds/

但执行用户是:

1
gitlab-runner

最终:

1
rm: cannot remove ... Permission denied

这说明 Shell executor 本质上就是:

1
2
3
4
Runner
→ 在宿主 Linux 上
→ 以普通 Linux 用户
→ 执行普通 shell process

因此:

1
2
3
4
5
Linux ownership
permission
PATH
shell profile
working directory

都会影响 CI。

System-mode Runner 使用:

1
/home/gitlab-runner

作为 working directory 后解决。


13. .bash_logout 导致 Prepare Environment 失败

System Runner 第一次执行 Job:

1
2
Preparing environment
ERROR: Job failed: prepare environment: exit status 1

Shell executor 会启动 login shell,因此会加载:

1
2
3
.profile
.bashrc
.bash_logout

/home/gitlab-runner/.bash_logout 中的默认逻辑导致 CI shell 退出失败。

删除:

1
sudo rm /home/gitlab-runner/.bash_logout

后解决。

这说明 CI 执行失败不一定来自 .gitlab-ci.yml。

也可能来自:

1
2
3
Runner host environment
Linux user profile
shell initialization

14. Artifact:Job 之间如何传递文件

Build:

1
2
3
4
5
6
7
8
9
10
11
12
build-job:
stage: build

script:
- mkdir -p build
- echo "hello gitlab ci" > build/app.txt

artifacts:
paths:
- build/

expire_in: 1 day

Test:

1
2
3
4
5
6
test-job:
stage: test

script:
- test -f build/app.txt
- grep "hello gitlab ci" build/app.txt

重要的是:

1
2
3
4
5
build-job
和
test-job

不是同一个 shell session

build/app.txt 能出现在 test job 中,是因为:

1
2
3
4
5
build
↓
GitLab 上传 artifact
↓
test 下载 artifact

而不是因为两个 Job 共用了同一个工作目录。


15. Manual Deployment

增加:

1
2
3
4
5
6
7
8
deploy-job:
stage: deploy

script:
- echo "Deploying..."
- cat build/app.txt

when: manual

Pipeline:

1
2
3
4
5
build ✅
↓
test ✅
↓
deploy ⏸ manual

必须人工点击:

1
Play / Run

才执行 deploy。

这个就是最简单的:

1
Manual Gate

16. GitLab Environment

把 Deployment 和 Environment 关联:

1
2
3
4
5
6
7
8
9
10
deploy-staging:
stage: deploy-staging

script:
- mkdir -p /home/gitlab-runner/deployments/staging
- cp build/app.txt \
/home/gitlab-runner/deployments/staging/app.txt

environment:
name: staging

GitLab:

1
2
Operate
→ Environments

会出现:

1
staging

这时 GitLab 开始知道:

1
2
3
4
哪个 commit
通过哪个 pipeline
什么时候
部署到了哪个 environment

17. Staging + Production

Pipeline:

1
2
3
4
5
6
7
build
↓
test
↓
staging
↓
production

Staging 自动:

1
2
3
deploy-staging:
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

Production manual:

1
2
3
4
deploy-production:
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual

最终:

1
2
3
4
5
6
7
8
9
main
↓
build
↓
test
↓
staging auto deploy
↓
production manual deploy

18. Protected Branch

为了模拟企业权限模型,创建:

1
developer1

角色:

1
Developer

jason:

1
Owner

保护:

1
main

设置:

1
2
3
4
5
Allowed to merge:
Maintainers

Allowed to push and merge:
No one

于是 Developer:

1
2
3
4
5
6
可以:
feature branch commit/push
创建 MR

不可以:
直接 push main

重要的是:

1
git commit

永远发生在本地。

GitLab 无法禁止你本地 commit。

真正被 Server 阻止的是:

1
git push origin main

19. MR Pipeline 与 Branch Pipeline

一开始:

1
2
3
4
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'

结果:

1
2
3
4
5
feature push
→ branch pipeline

创建 MR
→ MR pipeline

如果 MR 已存在,再 push:

1
2
3
branch pipeline
+
MR pipeline

同一个 commit 被 CI 两次。

对于当前实验,这没有必要。


20. MR-only Pipeline

最终使用:

1
2
3
4
5
6
7
8
workflow:
rules:

- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

- when: never

于是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
feature branch push
→ no pipeline

Create MR
→ build
→ test

MR branch push new commit
→ MR pipeline again

Merge main
→ main pipeline
→ build
→ test
→ staging
→ production manual

这套逻辑非常清晰:

1
2
3
4
5
6
7
8
9
开发代码
↓
MR
↓
CI validation
↓
Merge
↓
CD

21. CloudIQ 风格的持续部署模型

当前 ci-playground 模拟的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
feature
↓
MR
↓
build
↓
test
↓
merge main
↓
main pipeline
↓
staging
↓
production manual

这比较接近持续交付系统。

实际更复杂的系统中,源码 Pipeline 与 Deployment Pipeline 甚至可能是两套系统。

例如:

1
2
3
4
5
6
7
8
9
10
11
Application Repository
↓
build artifact
↓
Deployment Portal
↓
CI
↓
INT
↓
PRD

后续可以再通过 GitLab Multi-project Pipeline 模拟。


22. Waterfall / Unity / PowerStore 风格 Pipeline

另外建立:

1
devops-lab/waterfall-ci-playground

这个模型不同。

核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
feature branch
↓
MR
↓
build
↓
deploy testbed
↓
integration test
↓
全部通过
↓
允许 merge
↓
main

merge main 后
→ 不立即 deploy

到 milestone
↓
official release

这属于:

1
PR / MR 是完整质量 Gate

而不是:

1
merge 后继续逐环境持续部署

23. Waterfall Pipeline:只允许 MR 与 Release Tag

1
2
3
4
5
6
7
8
workflow:
rules:

- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

- if: '$CI_COMMIT_TAG'

- when: never

于是:

1
2
3
4
5
6
7
8
push feature branch
→ no pipeline

create MR
→ validation pipeline

push tag
→ release pipeline

24. 模拟有限的真实 Testbed

Integration Job:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
integration-testbed:
stage: integration

resource_group: testbed-01

script:
- echo "=== RESERVE TESTBED ==="

- mkdir -p \
/home/gitlab-runner/testbeds/testbed-01

- cp build/app.txt \
/home/gitlab-runner/testbeds/testbed-01/app.txt

- echo "=== DEPLOY ==="

- cat \
/home/gitlab-runner/testbeds/testbed-01/app.txt

- echo "=== INTEGRATION TEST ==="

- grep "version=" \
/home/gitlab-runner/testbeds/testbed-01/app.txt

关键:

1
resource_group: testbed-01

这表示:

1
同一时间只能有一个 Job 占用 testbed-01

例如两个 MR:

1
2
3
4
5
6
7
8
MR A build ✅
MR B build ✅

MR A integration
→ Running

MR B integration
→ Waiting

A 完成后:

1
2
MR B
→ 获得 testbed

这可以模拟真实环境里:

1
2
3
4
昂贵物理机
有限 testbed
reserve
机器竞争

25. 为什么 Deploy + Test 放在同一个 Job

如果写成:

1
2
3
deploy-job
↓
test-job

即使两边都使用:

1
resource_group: testbed-01

Deploy Job 结束后 resource 就被释放。

另一条 Pipeline 可能立刻:

1
deploy 到同一台 testbed

覆盖环境。

随后第一条 Pipeline 再测试时:

1
测试的已经不是自己部署的内容

因此如果需求是:

1
2
3
4
从部署开始
一直到测试完成

必须独占 testbed

最简单的方法就是:

1
2
deploy + integration test
→ 同一个 Job

整个 Job 使用:

1
resource_group

26. Merge Gate

项目设置:

1
2
3
Settings
→ Merge Requests
→ Pipelines must succeed

然后故意测试失败:

1
2
- grep "THIS_WILL_FAIL" \
/home/gitlab-runner/testbeds/testbed-01/app.txt

结果:

1
2
3
4
build ✅
integration ❌
↓
MR merge blocked

修复后:

1
2
3
4
build ✅
integration ✅
↓
Merge allowed

这就是:

1
CI 结果成为代码进入 main 的 Gate

27. Official Release Pipeline

Merge 到 main 后:

1
不自动部署

到了 milestone 后打 Tag:

1
2
3
4
5
6
7
git checkout main
git pull

git tag -a v1.0.0 \
-m "Official release v1.0.0"

git push origin v1.0.0

Release Job:

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
release-package:
stage: release

script:
- echo "=== OFFICIAL RELEASE ==="
- 'echo "Release tag: $CI_COMMIT_TAG"'

- mkdir -p build

- echo \
"official-release=$CI_COMMIT_TAG" \
> build/app.txt

- mkdir -p \
"/home/gitlab-runner/releases/$CI_COMMIT_TAG"

- cp build/app.txt \
"/home/gitlab-runner/releases/$CI_COMMIT_TAG/app.txt"

artifacts:
paths:
- build/

rules:
- if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'

因此:

1
2
3
4
5
MR Pipeline
和
Official Release Pipeline

完全独立

28. 一个 YAML 小坑:script 中的冒号

曾出现:

1
2
jobs:release-package:script config should be
a string or a nested array of strings

原因是:

1
- echo "Release tag: $CI_COMMIT_TAG"

其中:

1
:

可能被 YAML 解析器误认为:

1
key: value

因此更安全的写法:

1
- 'echo "Release tag: $CI_COMMIT_TAG"'

CI YAML 不是普通 shell script。

它首先必须经过:

1
2
3
4
5
YAML parser
↓
GitLab CI parser
↓
Shell

29. 当前两套 Pipeline 模型

目前实验已经形成两个独立模型。

模型 A:持续交付

1
2
3
4
5
6
7
8
9
10
11
12
13
feature
↓
MR
↓
build + test
↓
merge
↓
main
↓
staging
↓
production manual

适合:

1
2
3
4
Web Service
SaaS
Cloud Platform
持续部署系统

模型 B:大型产品 / Waterfall Release

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
feature
↓
MR
↓
build
↓
deploy testbed
↓
integration / regression
↓
merge
↓
main
↓
等待 milestone
↓
official release

适合:

1
2
3
4
大型产品
硬件相关测试
昂贵 Testbed
长 Release Cycle

30. Jenkins 与 GitLab CI 概念映射

通过实验后,可以做一个大致映射:

Jenkins GitLab
Jenkins Controller GitLab
Jenkins Agent / Node GitLab Runner
Node Label Runner Tag
Jenkinsfile .gitlab-ci.yml
Stage Stage
Job / Step Job / Script
archiveArtifacts artifacts
input manual job
Agent Pool Runner Pool
Testbed Reservation resource_group
PR Validation MR Pipeline
Release Job Tag Pipeline

尤其是:

1
2
3
GitLab Runner
≈
Jenkins Agent / Node

它本身并不复杂。

复杂的是:

1
2
3
4
5
6
7
8
9
10
Runner 调度
Executor
资源
权限
隔离
缓存
网络
并发
Testbed
稳定性

31. 已遇到的真实故障

整个实验中实际遇到过:

1. GitLab Container Unhealthy

1
/dev/shm

相关。


2. gitlab.local 无法解析

1
2
3
4
Windows hosts
GitLab external_url
Runner URL
clone URL

没有统一。


3. Runner Online 但 Job Pending

原因:

1
Tagged / Untagged Runner mismatch

4. Shell Executor Permission Denied

原因:

1
2
3
4
5
gitlab-runner user

运行在

/home/zxy/...

权限不匹配。


5. Prepare Environment Failed

原因:

1
/home/gitlab-runner/.bash_logout

6. Runner Clone 失败

Runner:

1
gitlab.test

GitLab repository URL:

1
gitlab.local

7. SSH Clone Permission Denied

直接使用:

1
gitlab.test

没有命中:

1
Host gitlab-local

导致 SSH Key 不正确。


8. CI YAML Invalid

1
echo "Release tag: ..."

中的冒号触发 YAML 解析问题。


32. 当前实验覆盖范围

目前已经实际验证:

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
GitLab CE
Docker deployment
User / Group / Project
Git identity separation
SSH aliases
Project Runner
Runner Scope
Shell Executor
systemd Runner
Runner working directory
Linux permission
Runner shell profile
Tagged / Untagged Jobs
Build
Test
Deploy
Artifacts
Manual Job
Environment
Protected Branch
Developer / Owner permissions
MR Pipeline
MR-only Workflow
Main Branch CD
resource_group
Testbed simulation
Merge Gate
Tag Release

还没有完成:

1
2
3
4
5
6
7
8
9
Docker Executor
Docker image build
Runner Cache
Docker-in-Docker
Kubernetes Executor
Autoscaling Runner
Runner Metrics
Multi-project Pipeline
Deployment Project / Deploy Portal simulation

这些可以作为下一阶段。


33. 最重要的认识

最开始看 GitLab Runner 时,很容易觉得:

Runner 不就是一台执行 Job 的机器吗?

实验之后会发现 Runner 本身确实很简单。

真正复杂的是:

1
2
3
4
5
6
7
8
9
10
11
哪台 Runner 执行?
使用什么 Executor?
以什么 Linux 用户执行?
Job 需要什么权限?
如何访问内部网络?
如何管理 Secret?
不同 Pipeline 怎么抢有限资源?
怎样隔离不同项目?
如何限制 Production Deployment?
如何处理 Runner Offline / Pending / Queue?
怎样处理 Cache 和 Artifact?

因此 JD 中单独写:

1
GitLab Runner

通常并不是要求:

1
会 gitlab-runner register

而是希望候选人理解:

1
CI Execution Infrastructure

这也是 GitLab Runner 和 Jenkins Agent / Node 最接近的地方。


34. 下一阶段

下一阶段计划:

1
2
3
4
5
6
7
8
9
10
11
Shell Executor
↓
Docker Executor
↓
不同 Docker image 执行 Job
↓
Cache
↓
Docker image build
↓
Kubernetes Executor

然后再把 Cloud 风格 Pipeline 拆成:

1
2
3
4
5
6
7
8
9
10
11
Application Project
↓
Artifact
↓
Deployment Project
↓
CI
↓
INT
↓
PRD

进一步模拟真实的 Deploy Portal 与多项目交付体系。