WSL2 本地 GitLab CI/CD 实验:Runner、DinD 与 Container Registry

WSL2 本地 GitLab CI/CD 实验:Runner、DinD 与 Container Registry

实验状态:已完成(2026-10-08)
实操范围:GitLab CE、Shell / Docker Executor、MR Pipeline、Protected Branch、Artifact、Cache、Docker Socket Binding、Docker-in-Docker(DinD)、GitLab Container Registry、Runner 并发与 resource_group。
Kubernetes Executor 仅做架构梳理,没有部署集群。
本文是分阶段实际操作与排障记录,不是可直接投产的安全配置手册。文中的 HTTP Registry、privileged DinD、Docker Socket 挂载仅用于隔离的本地 Lab。

实验后期的主要交付链路:

1
2
3
4
5
6
7
8
Merge Request
↓
Build Job ── Artifact ──┐
Maven Build ────────────┴─ needs ─→ DinD Build
↓
GitLab Container Registry
↓
Remove → Pull → Verify

1. 实验目标

我过去使用 Jenkins 做过 CI/CD、自动化测试、测试环境部署和生产 SRE,但没有长期实际使用过 GitLab CI。

因此这次实验并不是单纯学习 .gitlab-ci.yml 的语法,而是希望通过一个本地可控环境理解:

  • GitLab CI Pipeline 如何触发;
  • GitLab Runner 在整个系统中的角色;
  • Shell Executor 和 Docker Executor 有什么区别;
  • Job 之间如何传递文件;
  • Cache 和 Artifact 有什么区别;
  • Runner 如何和受限测试资源配合;
  • Docker Executor 为什么会引入新的网络、镜像仓库和安全问题;
  • CI Job 应该如何选择运行镜像;
  • 这些机制和过去使用 Jenkins 时的经验如何对应。

实验环境:

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

2. GitLab CE 本地部署

GitLab CE 运行在 Docker 中。

初期部署时简化后的 Compose 配置(Registry 配置后来在第 28 节加入):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
services:
gitlab:
image: gitlab/gitlab-ce:latest

hostname: gitlab.test

environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://gitlab.test:8929'

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

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

shm_size: "256m"

部署初期在 Windows 本机使用过如下 hosts 映射:

1
127.0.0.1 gitlab.test

后来改为多台开发机访问同一个 GitLab,统一使用 E5 Windows 的局域网地址 192.168.5.3。WSL 实际解析结果也曾是 gitlab.test → 192.168.5.3,因此后续必须处理 Windows → WSL2 的端口转发。不要把早期的 127.0.0.1 hosts 示例当作所有设备最终的配置。

最初使用过:

1
gitlab.local

后来改为:

1
gitlab.test

原因之一是 .local 通常与 mDNS 有特殊关联,本地实验使用 .test 更明确。

GitLab 内部实际使用的 URL 可以通过:

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

检查。

期望结果:

1
http://gitlab.test:8929

这个配置后来非常重要,因为 GitLab Runner clone repository 时会使用 GitLab 生成的 repository URL。


3. 用户、Group 与 Project

为了避免所有操作都使用 root,我创建了普通用户:

1
jason

然后创建 Group:

1
devops-lab

以及实验项目:

1
2
devops-lab/ci-playground
devops-lab/waterfall-ci-playground

root 只作为 GitLab Instance Administrator 使用。

日常代码、MR、Pipeline 等操作都使用普通用户。

这样更接近真实企业里的权限模型。


4. Protected Branch 实验

创建另一个 Developer 用户后,对 main 设置 Protected Branch。

实验结果:

1
2
3
4
Developer
├── 可以 push feature branch
├── 可以创建 Merge Request
└── 不能直接 push main

也就是说:

1
2
3
4
5
6
7
8
9
feature branch
↓
Merge Request
↓
CI
↓
Merge
↓
main

而不能直接绕过流程:

1
2
3
Developer
↓
main

Protected Environment 在当前 GitLab CE / Free 环境不可用,因此暂时没有继续做该实验。


5. GitLab Runner:Shell Executor

首先安装了 GitLab Runner,并使用:

1
Shell Executor

Runner 最终作为 systemd service 运行:

1
systemctl status gitlab-runner

服务实际类似:

1
2
3
4
gitlab-runner run
--config /etc/gitlab-runner/config.toml
--working-directory /home/gitlab-runner
--user gitlab-runner

5.1 User Mode Runner 的问题

最开始手动运行:

1
gitlab-runner run

导致 Runner 工作目录受到启动位置影响。

出现过:

1
2
rm: cannot remove ... gitlab_runner_env:
Permission denied

原因是:

1
gitlab-runner 用户

在操作:

1
zxy 用户拥有的目录

之后改为 systemd system-mode Runner。

这样 Runner:

1
2
3
运行用户:gitlab-runner
工作目录:/home/gitlab-runner
配置:/etc/gitlab-runner/config.toml

职责边界更清楚。


5.2 .bash_logout 导致 Prepare Environment 失败

切换到 system-mode 后遇到:

1
2
3
4
Preparing environment

ERROR: Job failed:
prepare environment: exit status 1

最终定位到:

1
/home/gitlab-runner/.bash_logout

删除该文件后 Shell Executor 恢复正常。

这个问题让我意识到:

Shell Executor 并不是简单执行几条 shell 命令,它还受到 Runner 用户 shell profile 的影响。

例如:

1
2
3
.bashrc
.profile
.bash_logout

都可能影响 CI Job。


6. Runner Tags

最初 Runner 在线,但 Job 一直处于 Pending。

Runner Tag:

1
shell

Job 没有:

1
tags:

同时 Runner 又没有允许:

1
Run untagged jobs

因此无法调度。

关系可以理解为:

1
2
3
4
5
6
7
Job tags
↓
Runner tags
↓
匹配
↓
Runner 执行 Job

例如:

1
2
3
job:
tags:
- shell

只能由包含:

1
shell

tag 的 Runner 执行。


7. 最小 Pipeline

最初的 Pipeline:

1
2
3
4
stages:
- build
- test
- deploy

Build:

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

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

artifacts:
paths:
- build/

Test:

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

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

Deploy:

1
2
3
4
5
deploy-job:
stage: deploy

script:
- cat build/app.txt

这完成了最基本的:

1
2
3
4
5
Build
↓
Test
↓
Deploy

8. Artifact

Build Job 中:

1
2
3
artifacts:
paths:
- build/

会让 GitLab 保存:

1
build/app.txt

后续 Job 可以继续使用。

这个机制后来在 Docker Executor 实验中被进一步验证。


9. Docker Executor

除了 Shell Runner,又注册了一个:

1
devops-lab-docker-runner

Executor:

1
docker

测试 Job:

1
2
3
4
5
6
7
8
9
10
11
docker-env-test:
tags:
- docker

image: alpine:latest

script:
- whoami
- hostname
- pwd
- cat /etc/os-release

实际结果:

1
2
whoami
→ root
1
2
hostname
→ runner-aww8idcik-project-3-concurrent-0
1
2
pwd
→ /builds/devops-lab/waterfall-ci-playground

OS:

1
Alpine Linux

这和 Shell Executor 有非常明显的区别。


10. Shell Executor vs Docker Executor

项目 Shell Executor Docker Executor
执行位置 Runner Host 临时 Container
OS Host OS Image 决定
用户 gitlab-runner Image 决定
工具链 Host 安装 Image 提供
文件系统隔离 较弱 较强
Job 结束 环境仍存在 Container 销毁
依赖环境 Host Docker + Registry

Docker Executor 最大的一个优点是:

1
image: python:3.13

或者:

1
image: maven:3.9-eclipse-temurin-21

Job 可以自己定义工具链。

不需要在 Runner Host 上安装所有版本的:

1
2
3
4
5
6
Java
Python
Node.js
Maven
Gradle
...

11. Docker Executor 网络问题

Docker Executor 第一次 clone repository 时出现:

1
Failed to connect to gitlab.test:8929

Shell Executor 可以访问:

1
gitlab.test

但 Container 不能。

原因是:

1
2
3
Host 网络命名空间
≠
Container 网络命名空间

对于 Container:

1
127.0.0.1

指向的是 Container 自己,而不是 WSL Host。

Runner 配置中增加:

1
2
3
extra_hosts = [
"gitlab.test:host-gateway"
]

之后 Job Container 可以访问:

1
gitlab.test

这也是 Docker Executor 相比 Shell Executor 很重要的区别:

Host 能访问某个服务,不代表 CI Container 也能访问。

后续如果使用:

1
2
3
4
5
GitLab
Registry
Artifactory
Internal API
Test Environment

都必须站在 Job Container 的网络视角重新检查。


12. Docker Registry 网络波动

Docker Executor 还遇到过:

1
Pulling docker image alpine:latest

然后:

1
net/http: timeout awaiting response headers

Retry 后成功。

Maven Image 也出现过长时间卡在:

1
2
Pulling docker image
maven:3.9-eclipse-temurin-21

这说明 Docker Executor 又增加了一层依赖:

1
2
3
4
5
GitLab Runner
↓
Docker daemon
↓
Container Registry

所以:

1
Pipeline 失败

不一定意味着:

1
script 失败

也可能发生在:

1
Preparing Docker Executor

阶段。

常见排查方向:

1
2
3
4
5
6
Registry
DNS
Proxy
Network
Authentication
Pull Policy

13. Docker Job 文件隔离

为了验证不同 Docker Job 是否共享文件系统,做了如下实验。

Job A:

1
2
3
4
5
docker-write:
image: alpine

script:
- echo "hello" > /tmp/docker-test

Job B:

1
2
3
4
5
docker-read:
image: alpine

script:
- cat /tmp/docker-test

结果:

1
2
cat: can't open '/tmp/docker-test':
No such file or directory

原因:

1
2
3
4
5
Job A
→ Container A

Job B
→ Container B

两个 Job 并不是同一个 Container。


14. 使用 Artifact 跨 Job 传递文件

将 Job A 修改为:

1
2
3
4
5
6
7
8
docker-write:
script:
- mkdir -p output
- echo "hello" > output/result.txt

artifacts:
paths:
- output/

Job B:

1
2
3
docker-read:
script:
- cat output/result.txt

之后成功。

因此:

1
2
3
4
5
6
7
Job Container A
↓
Artifact
↓
GitLab / Runner
↓
Job Container B

而不是:

1
2
3
Container A filesystem
↓
Container B

15. Cache

接下来验证 Cache。

测试配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cache-build:
tags:
- docker

image: alpine

cache:
key: demo-cache

paths:
- .cache/

script:
- mkdir -p .cache
- |
if [ -f .cache/dependency.txt ]; then
echo "Cache HIT"
cat .cache/dependency.txt
else
echo "Cache MISS"
echo "downloaded dependency" > .cache/dependency.txt
fi

第一次:

1
Cache MISS

后续 Pipeline:

1
2
Cache HIT
downloaded dependency

Runner 日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
Creating cache demo-cache-protected...

.cache/: found 2 matching artifact files and directories

Primary cache does not exist remotely,
uploading existing archive

No URL provided,
cache will not be uploaded to shared cache server.

Cache will be stored only locally.

Created cache

16. Local Cache

这里的:

1
No URL provided

并不是错误。

当前没有配置:

1
2
3
S3
GCS
MinIO

等 Shared Cache Backend。

所以 Cache 保存在当前 Runner 本地。

可以把 cache 看成一种:

1
2
3
4
Disposable
Shared
Mutable
Performance Optimization

即:

1
2
3
4
5
6
7
8
9
10
Cache 正常
→ Build 更快

Cache Miss
→ 重新生成
→ Build 变慢

Cache 损坏
→ 清理 / 重新生成
→ Build 应该仍然能够恢复

因此:

Cache 不应该成为构建正确性的唯一来源。


16.1 并发 Job 与 Cache

如果多个 Job 使用同一个 cache key:

1
2
cache:
key: maven-cache

同时又都允许:

1
pull + push

那么多个 Job 可能同时恢复 cache,再分别写入并上传。

例如:

1
2
3
4
5
6
7
8
9
10
Job A             Job B
│ │
restore restore
│ │
modify modify
│ │
save save
└──────┬──────────┘
↓
same cache

因此共享 cache 本质上属于:

1
Shared Mutable State

在大规模并发环境中,需要考虑:

  • Cache key 设计;
  • 哪些 Job 可以写 cache;
  • 哪些 Job 只读取 cache;
  • Cache 损坏后的恢复方式;
  • Local Cache 与 Shared Cache 的故障域。

当前实验暂时不继续深入这一部分。


17. Artifact vs Cache

目前实验后,我对两者的理解是:

Artifact

目的:

1
传递 Pipeline 的构建结果

例如:

1
2
3
4
5
JAR
ZIP
Binary
Test Report
Build Output

典型流程:

1
2
3
4
5
Build
↓
Artifact
↓
Test / Deploy

Artifact 更接近:

1
2
3
Pipeline Output
Versioned by Job / Pipeline
Correctness Relevant

Cache

目的:

1
减少重复下载或重复计算

例如:

1
2
3
4
.m2/repository
npm cache
pip cache
Gradle cache

即使 Cache 被删除:

1
Build 仍然应该能够完成

只是:

1
速度变慢

因此一个重要原则是:

Cache 应该是可重新生成的。


18. Maven Cache

为了验证真实 Dependency Cache,创建了一个最小 Maven 项目。

CI:

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
maven-build:
stage: build

tags:
- docker

image: maven:3.9-eclipse-temurin-21

variables:
MAVEN_OPTS: >
-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository

cache:
key: maven-cache

paths:
- .m2/repository/

script:
- echo "=== Maven cache before build ==="
- du -sh .m2/repository 2>/dev/null || true

- mvn clean package

- echo "=== Maven cache after build ==="
- du -sh .m2/repository

第一次运行:

1
Maven 下载 dependencies

Runner 保存:

1
.m2/repository

第二次运行:

1
Maven 没有重新下载这些 dependencies

说明 Maven Cache 生效。

完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pipeline #1
↓
.m2 empty
↓
Maven download
↓
Runner cache

Pipeline #2
↓
Restore cache
↓
.m2 already exists
↓
Maven reuse dependencies

19. MR Pipeline

最开始配置:

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

- if: '$CI_COMMIT_BRANCH'

这样会产生:

1
2
3
Branch Pipeline
+
Merge Request Pipeline

也就是 feature branch push 一次,创建 MR 后又跑一次。

实际需求并不是每一个开发过程中的 push 都执行完整 CI。

所以最终改成:

1
2
3
4
5
6
7
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

- when: never

最终效果:

1
2
Feature Push
→ No Pipeline
1
2
Create / Update MR
→ CI Pipeline
1
2
Merge main
→ main Pipeline

这更符合希望模拟的开发流程。


20. Waterfall CI Model

另一个项目:

1
waterfall-ci-playground

用于模拟以前接触过的一种更偏传统产品研发的流程:

1
2
3
4
5
6
7
8
9
10
11
Feature
↓
PR / MR
↓
Build
↓
Deploy Testbed
↓
Integration Test
↓
允许 Merge

Merge 后:

1
不会立即发布正式版本

而是在某个 milestone:

1
创建正式 Release

因此 Workflow:

1
2
3
4
5
6
7
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

- if: '$CI_COMMIT_TAG'

- when: never

也就是说:

1
2
3
4
5
6
7
8
9
10
11
Feature Push
→ No Pipeline

MR
→ Build + Integration

Merge
→ No Release

Tag
→ Release

21. 有限 Testbed 与 resource_group

实际硬件测试中,一个非常重要的问题是:

1
Testbed 数量有限

两个 Pipeline 如果同时部署同一台机器:

1
2
3
4
5
Pipeline A Deploy
↓
Pipeline B Deploy
↓
Pipeline A Test

Pipeline A 测到的已经可能不是自己的代码。

GitLab 可以使用:

1
resource_group: testbed-01

限制:

1
2
同一个 resource_group
一次只允许一个 Job 执行

因此:

1
2
3
4
5
6
7
Pipeline A
Deploy + Test
↓
release resource
↓
Pipeline B
Deploy + Test

这里还有一个重要设计点:

对于本次模拟的独占 Testbed,Deploy 和 Test 最好放在同一个占有 resource_group 的 Job 内。

否则:

1
2
3
4
5
Deploy Job
→ resource released

Test Job
→ later

中间可能被其他 Pipeline 抢走 Testbed。


22. Docker Image 与 CI Job Image

在开始 Docker Image Build 之前,需要先明确:

1
image: docker:cli

并不等于:

1
docker exec -it <container> sh

docker exec 的含义是:

1
2
3
已经存在的 Container
↓
进入其中执行命令

而 GitLab CI 中:

1
image: docker:cli

表示:

1
2
3
4
5
6
7
GitLab Runner
↓
根据 docker:cli Image
↓
创建新的 Job Container
↓
执行 script

例如:

1
2
3
4
5
6
7
8
9
docker:cli image
↓
┌─────────────────────────┐
│ CI Job Container │
│ │
│ /bin/sh │
│ docker CLI │
│ basic utilities │
└─────────────────────────┘

因此:

CI Job 的 image: 可以理解为这个 Job 的“临时工作机模板”。


23. 如何选择 CI Job Image

选择 Image 时,不需要背大量 Image 名字。

更合理的思路是:

1
2
3
4
5
6
7
这个 Job 要执行什么命令?
↓
需要哪些 Runtime / Tools?
↓
寻找最接近需求的可信 Image
↓
选择合适的版本 Tag

例如:

1
2
mvn + JDK
→ maven image
1
2
Python
→ python image
1
2
npm / node
→ node image
1
2
Docker CLI
→ docker:cli
1
2
简单 shell 工具
→ alpine

23.1 Image 可以包含多个工具

Image 并不是:

1
2
3
4
JDK
Maven
Node
Docker

只能四选一。

例如:

1
maven:3.9-eclipse-temurin-21

本身就是一个组合环境:

1
2
3
Maven 3.9
+
Eclipse Temurin JDK 21

因此 Java Maven Build 可以直接:

1
2
3
4
5
java-build:
image: maven:3.9-eclipse-temurin-21

script:
- mvn clean package

23.2 Spring Boot 通常不是 CI 环境中的安装工具

对于 Spring Boot 项目:

1
2
3
JDK
Maven
Spring Boot

并不是需要在 Job Container 中分别安装三个软件。

通常结构是:

1
2
3
4
5
6
Job Container
├── JDK
├── Maven
└── checked-out repository
├── pom.xml
└── src/

Spring Boot:

1
由 pom.xml 定义

例如:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

然后:

1
2
3
4
5
6
7
Maven
↓
读取 pom.xml
↓
下载 Spring Boot dependencies
↓
Compile / Test / Package

因此最普通的 Spring Boot Job 可能只有:

1
2
3
4
5
6
java-build:
image: maven:3.9-eclipse-temurin-21

script:
- mvn clean test
- mvn package

23.3 一个 Job 同时需要很多工具怎么办

如果只缺少少量附加工具,例如:

1
2
curl
jq

可以临时安装:

1
2
3
4
5
6
7
8
image: maven:3.9-eclipse-temurin-21

before_script:
- apt-get update
- apt-get install -y curl jq

script:
- mvn test

如果团队长期固定需要:

1
2
3
4
5
6
7
JDK
Maven
Docker CLI
kubectl
helm
curl
jq

则更适合维护自己的 CI Image:

1
registry.company.com/ci/java-build:2026.09

例如:

1
2
3
4
FROM maven:3.9-eclipse-temurin-21

RUN apt-get update \
&& apt-get install -y curl jq

然后:

1
image: registry.company.com/ci/java-build:2026.09

这种方式与过去 Jenkins Agent 预装工具的思路很相似:

1
2
3
4
5
6
Jenkins Agent
= 长期机器上预装工具

Docker Executor
= 把工具环境固化成 Image
每个 Job 临时创建

23.4 不要把所有职责都塞进一个 Job

另一种方法是拆 Job。

例如:

1
2
3
Maven Build
→ Docker Build
→ Kubernetes Deploy

可以拆成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Job A
Maven + JDK
→ Build JAR
│
│ Artifact
▼
Job B
Docker CLI
→ Build Container Image
│
▼
Job C
kubectl / Helm
→ Deploy

所以可以使用这样的判断:

1
2
3
4
5
6
7
8
少量附加工具
→ Job 中临时安装

稳定且反复使用的组合工具
→ 自定义 CI Image

职责明显不同
→ 拆成多个 Job

23.5 本地检查陌生 Image

如果不确定某个 Image 里面有什么,可以先本地检查。

例如:

1
docker run --rm docker:cli docker version

查看 OS:

1
docker run --rm docker:cli cat /etc/os-release

进入 shell:

1
2
3
docker run --rm -it \
--entrypoint sh \
docker:cli

然后:

1
2
3
which docker
which sh
cat /etc/os-release

Maven Image:

1
2
3
docker run --rm \
maven:3.9-eclipse-temurin-21 \
mvn --version

因此 Image 选择不需要完全依赖名称猜测。


24. Docker Socket Binding —— 已验证

为了让 Docker Executor 中的 Job 使用 Docker CLI,Runner 配置增加:

1
2
3
4
5
6
[runners.docker]

volumes = [
"/cache",
"/var/run/docker.sock:/var/run/docker.sock"
]

然后使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
docker-socket-test:
stage: build

tags:
- docker

image: docker:cli

script:
- echo "=== Docker version ==="
- docker version

- echo "=== Docker info ==="
- docker info

- echo "=== Visible containers ==="
- docker ps

实验成功。


24.1 docker version

实际输出同时存在:

1
2
Client:
Version: 29.8.1

以及:

1
2
Server: Docker Engine - Community
Version: 29.8.0

其中:

1
Client

来自:

1
docker:cli Job Container

而:

1
Server

来自:

1
WSL Host Docker daemon

两者版本不完全相同,但 Docker API 都是:

1
1.56

因此能够正常通信。


24.2 docker info

更加明确的证据来自:

1
2
3
4
5
6
Operating System: Ubuntu 26.04.1 LTS
Kernel Version: 6.18.33.2-microsoft-standard-WSL2
Name: DESKTOP-P5CGV6I
CPUs: 36
Total Memory: 31.29GiB
Docker Root Dir: /var/lib/docker

当前 CI Job 使用的是:

1
docker:cli

Image。

但 docker info 返回的却是:

1
WSL2 Ubuntu Host

的信息。

这证明:

1
2
3
4
5
6
7
Job Container
│
│ Docker CLI
│
│ /var/run/docker.sock
▼
WSL Host Docker daemon

24.3 Job 可以看到 Host Container

docker ps 实际看到:

1
2
runner-aww8idcik-project-3-concurrent-0-...-build
gitlab

其中:

1
gitlab

是宿主机长期运行的 GitLab Container。

另外:

1
runner-...-build

正是当前正在运行这个 CI Job 的 Container。

也就是说:

Job Container 通过 Host Docker daemon,甚至可以看到“自己”。


24.4 这不是 Docker-in-Docker

当前结构并不是:

1
2
3
4
Host Docker
└── Job Container
└── Docker daemon
└── Container

而更接近:

1
2
3
4
5
6
7
8
WSL Host Docker daemon
│
├── GitLab
│
├── CI Job Container
│ └── docker CLI
│
└── CI 创建的其他 Container

Job Container 中只有:

1
Docker CLI

它通过:

1
/var/run/docker.sock

直接向宿主机 Docker daemon 发命令。

因此这种结构也常被理解为:

1
Sibling Containers

而不是:

1
Nested Containers

25. Docker Socket 的安全边界

当前 Runner 没有要求:

1
privileged = true

但:

1
/var/run/docker.sock

本身已经提供了很高权限。

如果 Job 可以控制 Host Docker daemon,那么它理论上可以:

1
2
3
4
5
6
创建 Container
删除 Container
查看其他 Container
管理 Docker Volume
管理 Docker Network
挂载 Host Directory

因此:

1
2
3
4
5
Docker Job Container isolation
↓
mount docker.sock
↓
Security boundary significantly reduced

这也是为什么:

1
privileged = false

并不意味着当前 Job 就是低权限的。

第 27 节通过 DinD 对比:

1
2
3
Socket Binding
vs
Docker-in-Docker

理解二者不同的安全和资源边界。


26. Docker Socket Binding:实际构建 Docker Image

第 24~25 节已经证明 CI 中的 Docker CLI 能经由挂载的 /var/run/docker.sock 访问 WSL 宿主机 Docker Daemon。这次进一步完成真正的 Image Build。

实验用 Dockerfile:

1
2
3
FROM alpine:3.24
COPY build/app.txt /app/app.txt
CMD ["cat", "/app/app.txt"]

build-job 生成 build/app.txt 并保存为 Artifact,镜像构建 Job 通过 needs 获取该文件:

1
2
3
4
5
6
7
8
9
10
11
12
docker-image-build:
stage: image
tags: [docker]
image: docker:cli
needs:
- job: build-job
artifacts: true
script:
- cat build/app.txt
- docker build -t waterfall-demo:$CI_COMMIT_SHORT_SHA .
- docker image inspect waterfall-demo:$CI_COMMIT_SHORT_SHA
- docker run --rm waterfall-demo:$CI_COMMIT_SHORT_SHA

这段脚本只展示实验所需的关键 Job;项目还包含 Maven 等其他 Job,实际提交需要保持 .gitlab-ci.yml 中对应的 Stage 和 Workflow 一致。

第一次 docker build 在访问 Docker Hub Token 服务时失败:

1
2
failed to fetch anonymous token
lookup auth.docker.io on 172.19.96.1:53: i/o timeout

随后在 WSL 宿主机手动拉取 alpine:3.24,重新运行该 Job 后成功。先前已证明 Job 使用的是宿主机 Daemon,因此它能利用宿主机已有的基础镜像。

通过后,docker image ls waterfall-demo 可以在 WSL 宿主机看到镜像;这是 Socket Binding 与下一节 DinD 镜像存储行为的关键区别。


27. Docker-in-Docker:独立 Docker Daemon

27.1 为 DinD 单独注册 Group Runner

我没有直接在原 Docker Runner 上开启 privileged,而是在 devops-lab Group 下新建 Runner:

1
2
3
4
5
Runner name: devops-lab-dind-runner
Scope: Group Runner
Executor: docker
Tag: dind
Run untagged jobs: disabled

注册时容易误把 dind 当成 Executor。实际上 gitlab-runner register 提示 Enter an executor 时仍应输入 docker;dind 是选用的 Runner Tag 和 CI 服务模式,并不是 Executor 类型。

新 Runner 的核心配置(注册产生的 Token 等字段省略):

1
2
3
4
5
6
7
8
9
10
[[runners]]
name = "devops-lab-dind-runner"
executor = "docker"
[runners.docker]
image = "docker:cli"
privileged = true
volumes = ["/cache"]
extra_hosts = ["gitlab.test:host-gateway"]
pull_policy = "if-not-present"
dns = ["192.168.5.1", "223.5.5.5"]

这里没有挂载宿主机 /var/run/docker.sock。传统 DinD 要启动自己的 Docker Daemon,因此本次 Lab 的 Runner 开启了 privileged = true。这属于高风险设置,不能直接作为面向不可信代码的公共 Runner 配置。关闭 Untagged Job 仅避免误调度,Runner Tag 本身也不是权限隔离边界。

27.2 DinD Job

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
docker-dind-build:
stage: image
tags: [dind]
image: docker:cli
services:
- name: docker:dind
alias: docker
command:
- "--dns=192.168.5.1"
- "--dns=223.5.5.5"
variables:
DOCKER_HOST: "tcp://docker:2375"
DOCKER_TLS_CERTDIR: ""
needs:
- job: build-job
artifacts: true
- job: maven-build
artifacts: false
script:
- docker version
- docker build -t waterfall-dind:$CI_COMMIT_SHORT_SHA .
- docker run --rm waterfall-dind:$CI_COMMIT_SHORT_SHA

上面是 DinD 基础构建实验的目标配置;第 28 节的 Registry 实验改用了另一个 Dockerfile,以避开 Docker Hub 访问问题。

DOCKER_HOST=tcp://docker:2375 指向 DinD Service。DOCKER_TLS_CERTDIR="" 关闭该实验网络内 DinD 的 TLS,不可照搬到开放网络。常规做法是限制服务网络并优先采用更安全的构建架构。

27.3 DNS 故障:Maven 与 DinD 同时受影响

最初 Maven 失败:

1
2
3
repo.maven.apache.org: Temporary failure in name resolution
Plugin org.apache.maven.plugins:maven-clean-plugin:3.2.0
could not be resolved

DinD 构建则失败在:

1
lookup registry-1.docker.io on 172.19.96.1:53: i/o timeout

Windows ipconfig 显示 vEthernet (WSL) 为 172.19.96.1/20。这个 IP 是当时 Windows 侧 WSL 虚拟网卡地址,不是人为给 WSL 固定的服务 IP,也不能因为 DNS 出问题就删除它。

我分别在普通 Docker 容器内尝试默认 DNS、223.5.5.5 和路由器 192.168.5.1:默认 DNS 解析失败,后两者成功。因此针对 两个 Docker Executor Runner 增加:

1
2
[runners.docker]
dns = ["192.168.5.1", "223.5.5.5"]

对 DinD 的独立 Daemon 还增加了服务参数:

1
2
3
4
5
6
services:
- name: docker:dind
alias: docker
command:
- "--dns=192.168.5.1"
- "--dns=223.5.5.5"

调整后 Maven 与相应 DinD Job 均曾构建通过,确认该 DNS 问题获得缓解。但之后又遇到 registry-1.docker.io:443 的连接超时:DNS 能解析,不代表 Docker Hub HTTPS 链路就一定可达。国内网络访问受限时,DinD 的临时镜像存储也不能直接复用 WSL 宿主机已拉取的 Alpine Image。

27.4 Socket Binding 与 DinD 对比

维度 Docker Socket Binding Docker-in-Docker
Daemon WSL 宿主机 Docker Daemon DinD Service 内独立 Daemon
连接方式 Unix Socket 本实验 TCP 2375
镜像存储 宿主机镜像存储 DinD Service 自己的临时存储
基础镜像缓存 可直接复用宿主机缓存 默认无法复用宿主机缓存
主要风险 Job 可控制宿主机 Docker Daemon 传统 privileged DinD 会削弱隔离

本实验中两者均能执行 Docker 命令,但它们控制的是不同的 Daemon。后续用 Container Registry 将镜像从 DinD 环境交付出去。


28. GitLab Container Registry:Build → Push → Pull

28.1 启用本地 Registry

在现有 GitLab CE Docker Compose 的 GITLAB_OMNIBUS_CONFIG 中增加:

1
2
external_url 'http://gitlab.test:8929'
registry_external_url 'http://gitlab.test:5600'

端口映射对应增加:

1
2
3
4
ports:
- "8929:8929"
- "2224:22"
- "5600:5600"

执行 docker compose up -d 重新创建容器后,保持原有 config、logs、data 持久化目录不变。通过 docker exec gitlab gitlab-ctl status 可看到 registry 进程运行。

最初尝试的是端口 5050,但它遇到了 Windows/WSL2 网络访问问题,最终改为 5600。

28.2 代理、portproxy 与 Windows 排除端口

首次执行:

1
curl -i http://gitlab.test:5050/v2/

得到的竟是 503 Forwarding failure (Privoxy@localhost)。这不是 Registry 的 503,而是 WSL 的 HTTP 代理尝试通过 SOCKS5 转发本地服务失败。使用下面的命令可以排除代理干扰:

1
curl -i --noproxy '*' http://gitlab.test:5050/v2/

此时转为 Connection refused。进一步检查:

1
2
3
WSL → 127.0.0.1:5050/v2/       HTTP 401(正常)
WSL → gitlab.test:5050/v2/ Connection refused
getent ahostsv4 gitlab.test 192.168.5.3

说明 Registry 内部正常,问题发生在访问 Windows LAN 地址 192.168.5.3 的路径上。查看 Windows:

1
2
3
netsh interface portproxy show all
Get-NetTCPConnection -LocalPort 5050 -State Listen
netsh interface ipv4 show excludedportrange protocol=tcp

虽然 portproxy 已有 0.0.0.0:5050 → 172.19.106.103:5050,但 Windows 没有创建监听。原因最终定位为 Windows 的排除端口范围:

1
5041    5140

5050 落在这个范围内。因此没有去删除系统保留区间,而是调整 Registry 对外端口为 5600,同步修改 GitLab 配置、Docker Compose、Windows portproxy 和允许访问的 Windows 防火墙规则。

1
2
3
# 在管理员 PowerShell 执行;WSL IP 应先用 hostname -I 核对
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=5050
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=5600 connectaddress=172.19.106.103 connectport=5600

最终:

1
curl -i --noproxy '*' http://gitlab.test:5600/v2/

返回:

1
2
3
HTTP/1.1 401 Unauthorized
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Bearer realm="http://gitlab.test:8929/jwt/auth",service="container_registry"

这里的 401 是预期行为:Registry 已经可达,但尚未携带认证信息。WSL IP 可能随网络重建而变化,Windows portproxy 的目标 IP 需要相应核对和更新。

28.3 Docker Hub 不稳定时,用 scratch 进行封闭实验

DinD 访问 Docker Hub 一度出现:

1
2
Head https://registry-1.docker.io/v2/library/alpine/manifests/3.24
... dial tcp 75.126.164.178:443: i/o timeout

为避免外部 Registry 阻断本次内部 Registry Push/Pull实验,新建 Dockerfile.registry-test:

1
2
FROM scratch
COPY build/app.txt /app/app.txt

scratch 是 Docker 的特殊空基础镜像,不会为基础镜像层请求 Docker Hub;它也没有 shell 或 cat,所以不能像 Alpine 镜像一样直接 docker run ... cat,验证采用 docker create + docker cp。

28.4 DinD 登录、Push、删除、Pull 与文件校验

下面是本次成功实验的核心 Job 配置;build-job 和 maven-build 的定义仍在其他位置:

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
29
30
31
32
33
34
docker-dind-build:
stage: image
tags: [dind]
image: docker:cli
services:
- name: docker:dind
alias: docker
command:
- "--insecure-registry=gitlab.test:5600"
- "--dns=192.168.5.1"
- "--dns=223.5.5.5"
variables:
DOCKER_HOST: "tcp://docker:2375"
DOCKER_TLS_CERTDIR: ""
BUILDX_NO_DEFAULT_ATTESTATIONS: "1"
NO_PROXY: "localhost,127.0.0.1,gitlab.test,192.168.5.3,docker"
no_proxy: "localhost,127.0.0.1,gitlab.test,192.168.5.3,docker"
needs:
- job: build-job
artifacts: true
- job: maven-build
artifacts: false
script:
- docker version
- printf '%s' "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- docker build -f Dockerfile.registry-test -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker image rm "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker pull "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker image inspect "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker create --name registry-test "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" /unused
- docker cp registry-test:/app/app.txt ./registry-result.txt
- cat registry-result.txt
- docker rm registry-test

这里使用了 GitLab CI 内置的 CI_REGISTRY、CI_REGISTRY_IMAGE、CI_REGISTRY_USER、CI_REGISTRY_PASSWORD。--password-stdin 避免把认证密码直接写进命令参数;不要在日志中打印密码。

由于 Registry 是 HTTP,本次受控 Lab 在 DinD Daemon 上指定 --insecure-registry=gitlab.test:5600。这不等于给 WSL 宿主机 Docker Daemon 配置了同样的 HTTP Registry 信任;不同 Daemon 需分别管理。生产环境应使用 TLS。

28.5 Layer 上传成功,但 Manifest 提交失败

首次运行时,各 Layer 已显示 Pushed,最后仍报:

1
error from registry: blob unknown to registry - sha256:21bb52...

这说明镜像 Push 不只是传输各个 Layer,还要由 Registry 接受最终 Manifest / Image Index。排查方向指向 BuildKit 默认 Attestation 与 Registry 校验之间的兼容性;不能仅凭 Layer 显示 Pushed 判断镜像上传完成。

在 Job 变量中加入:

1
BUILDX_NO_DEFAULT_ATTESTATIONS: "1"

重新运行后 Pipeline 通过,完成镜像 Push、从 DinD 本地删除、重新 Pull 和文件校验。这个 Workaround 是为了验证实验链路,不能简单推广为所有 Registry 生产环境的标准做法。

至此确认:DinD 临时环境内构建的镜像,可以通过 GitLab Container Registry 保存并重新获取。本次没有验证从 WSL 宿主机另一个 Docker Daemon 拉取该 HTTP Registry 镜像;也没有通过 scratch 镜像验证应用容器实际启动。


29. Runner 并发与 resource_group 实验

29.1 为什么最初看不出资源锁效果

在当前 Runner 服务的 /etc/gitlab-runner/config.toml 中发现:

1
concurrent = 1

开始使用两个 Pipeline 进行验证时,第一个 testbed-exclusive 正在等待 90 秒,第二个 Pipeline 连 maven-build 等 Build Job 都停留在 Pending。这并不意味着所有 Job 被 resource_group 锁住:全局 Runner 并发为 1,本身就足以使无关 Job 排队。

将全局并发改为:

1
concurrent = 4

重新运行后,另一个 testbed-exclusive 曾在界面显示:

1
此作业正在等待资源:testbed-01

这确认 GitLab 能识别资源占用,但跨 Pipeline 演示又受到 Maven Central 下载缓慢影响:等第二条 Pipeline 跑到 Testbed 阶段时,第一条早已结束。因此改为一个不依赖 Maven 的最小实验。

29.2 同一 Pipeline 两个 Job 直接竞争 Testbed

保留 testbed Stage,在同一 Pipeline 添加两个临时 Job:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
testbed-lock-a:
stage: testbed
tags: [shell]
needs: []
resource_group: testbed-01
script:
- echo "Job A started, pipeline=$CI_PIPELINE_ID"
- date -u
- sleep 30
- echo "Job A finished"
- date -u

testbed-lock-b:
stage: testbed
tags: [shell]
needs: []
resource_group: testbed-01
script:
- echo "Job B started, pipeline=$CI_PIPELINE_ID"
- date -u
- sleep 30
- echo "Job B finished"
- date -u

这里的 needs: [] 是实验用的显式无依赖声明,让两个 Job 可以在 Pipeline 创建时直接进入调度,而不等待前面 build、image 等 Stage。它不是普通部署 Job 必须采用的配置。

验证结果:两个 Job 没有同时持有 testbed-01,后一个等待前一个释放资源后执行;实验通过。随后删除两个临时 Job,恢复原有 Pipeline。

29.3 最终理解

机制 层级 目的
concurrent GitLab Runner 进程 限制总体同时执行的 Job 数
limit 单个 Runner 限制该 Runner 可同时执行的 Job 数
needs Pipeline 内 Job DAG 明确依赖并允许跨 Stage 提前调度
resource_group 同一 GitLab 项目内 让同一资源组的 Job 互斥执行

resource_group 并不是整个 GitLab Group 中的全局分布式锁。对于真实物理 Testbed,必须结合资源所有权、超时回收和 Deploy/Test 锁范围设计。对本实验而言,已验证的是同一项目中 Job 的资源互斥。


30. Kubernetes Executor:架构学习(未实操)

本次没有安装 Kubernetes Runner,也没有创建 Kubernetes CI Pod。这里只总结 Kubernetes Executor 的典型工作方式,不把理论当作实验结果。

1
2
3
4
5
6
7
8
9
10
11
GitLab Server
│ Job
▼
GitLab Runner(Kubernetes Executor)
│ Kubernetes API
▼
Kubernetes Cluster
└── Job Pod
├── Build Container # 执行 CI script
├── Helper Container # Git、Artifact、Cache 等辅助操作
└── Service Container # 按配置提供数据库等服务(可选)

与已经体验过的两种 Executor 对比:

维度 Shell Executor Docker Executor Kubernetes Executor
运行位置 Runner Host Docker 容器 Kubernetes Pod
工具来源 Host 环境 Job Image Job Image
隔离边界 较弱 容器 Pod / 容器及集群策略
扩缩容 依赖 Host 依赖 Docker Host 利用集群资源调度
典型排障 Shell Profile / 权限 网络 / 镜像 / Daemon Pod Events / 调度 / ServiceAccount / NetworkPolicy

Kubernetes Executor 不自动提供 Docker Daemon。 在 Kubernetes Job 里构建容器镜像,仍需选择适当的构建方式和权限模型,例如专门的 BuildKit 方案;不能直接假设 docker build 一定可用。


31. 总结:与 Jenkins 经验的对应关系

从最初的 GitLab CE 部署到最终的 Registry 和资源互斥实验,这次最大的收获不是记住几个 YAML 关键字,而是把 CI/CD 拆成明确的边界:

  • GitLab Server 管理项目、MR、Pipeline、Artifact、Registry 和资源组调度;
  • GitLab Runner 实际领取和执行 Job,Executor 决定执行环境;
  • Artifact 传递准确的构建产物,Cache 只负责加速可重建的数据;
  • needs 表达依赖关系,Stage 表达默认阶段顺序,resource_group 处理有限资源互斥;
  • Socket Binding 和 DinD 看似都运行 docker build,实则有不同的 Daemon、安全边界和镜像存储;
  • Container Registry 才是脱离单个构建环境、让镜像可交付和复用的关键环节。

实际踩坑也非常集中:Shell Runner 的 .bash_logout、容器访问 gitlab.test、Docker Hub/Maven Central 的 DNS 与网络、WSL2 Windows portproxy 的端口排除、Privoxy 误代理本地 Registry、BuildKit Attestation 与 Registry Manifest 校验、Runner 全局并发掩盖 resource_group 等。

这些问题提醒我:Pipeline Failed 不一定是代码错误;需要先定位失败发生在 Git、Runner 准备环境、工具下载、构建、镜像上传,还是资源调度环节。

此次实验到这里告一段落。后续如果继续扩展,我更希望围绕安全的构建方案、可靠的内部镜像仓库、Runner 可观测性和 Kubernetes Executor 的真实资源限制展开,而不是继续为单纯增加组件而增加组件。