为什么要自己搭
GitHub 免费,GitLab.com 也免费,为什么要在家里自己搭一套?
原因很简单:数据在自己手里。代码、CI 构建记录、制品,全都在家里的磁盘上,不依赖任何第三方平台的可用性和政策。另外,GitLab 自托管版本的 CI/CD 功能是完整的,不像托管版有各种套餐限制。
但「自己搭」这件事,从第一台机器开始,就会遇到一连串问题。
第一阶段:一台机器压一切
最初的做法是:一台机器,装 GitLab Omnibus,Runner 注册在同一台机器上,镜像和包直接用 GitLab 内置的 Container Registry 和 Package Registry。
这个方案能跑。跑 git push 没问题,触发一个简单的 pipeline 也没问题。
但只要 CI 开始做稍微重一点的事情——比如编译 Android 项目、打一个 Docker 镜像——这台机器立刻变成灾区。
资源竞争
CI 构建是资源消耗大户,而且是突发性的。平时风平浪静,一旦有 push 触发构建,CPU 和内存瞬间飙满。
这台机器同时还要跑 GitLab 本身:Puma(Rails 主应用)、Sidekiq(后台任务)、Gitaly(管仓库读写)、PostgreSQL(数据库)、Redis(缓存)。这些组件对响应时间都有要求,尤其是 git push 和 Web 界面的交互,延迟超过几秒用户体验会很差。
两件事挤在一台机器上,结果是:CI 跑得越猛,GitLab 的交互就越卡。严重的时候 git push 会超时,pipeline 自己把自己搞死了。
存储问题
更麻烦的是存储。
GitLab Omnibus 默认把所有数据放在 /var/opt/gitlab 下:代码仓库、PostgreSQL 数据文件、Redis 持久化、还有内置 Registry 的镜像数据,全部堆在同一个目录里。
代码的增长速度是缓慢的,但镜像的增长速度不是。一个带有 Android SDK 的构建镜像轻松超过 5GB,CI 跑了几十次,每次推一个新镜像,存储的消耗速度远超预期。磁盘满了之后 PostgreSQL 写不进去,GitLab 的状态会直接报错,整个服务不可用。
这两个问题指向同一个结论:必须拆开。
拆的逻辑
拆之前先想清楚:什么东西应该在一起,什么东西不应该在一起。
判断标准只有一个:资源特征是否兼容。
- GitLab 主服务需要稳定的内存和快速的磁盘 IO,不能被打扰
- CI 构建需要大量 CPU,资源需求是突发的、不可预测的
- 制品存储需要大量磁盘,增长速度快,和代码存储的增长曲线完全不同
三种特征,三台机器,每台只做一件事。
flowchart TD
Dev["开发者\ngit push"] --> GL
subgraph 内网三机
GL["GitLab 主节点\nOmnibus 单机部署\nWeb / Git / DB / Cache"]
RN["Runner 节点\ngitlab-runner + dockerd\n执行 CI 构建任务"]
NX["Nexus 制品库\nNexus Repository Manager\n8081: Web/API 8082: Docker Registry"]
end
GL -->|"触发 pipeline"| RN
RN -->|"拉取依赖 / 推送镜像"| NX
RN -->|"上报构建结果"| GL
CF["Cloudflare"] --> OW["OpenWrt\n端口转发"]
OW --> TR["Traefik\n反向代理"]
TR --> GL
GitLab 主节点:只管代码和调度
这台机器只做两件事:托管代码仓库 和 调度 CI/CD 流水线。
用的是 GitLab Omnibus 安装包,而不是 Docker Compose 方式部署。
为什么选 Omnibus?
GitLab 本身是一个复杂的系统,内部组件非常多:
| 组件 | 说明 |
|---|---|
| NGINX / Workhorse | Web 入口,处理 HTTP 请求和大文件上传 |
| Puma | Rails 主应用,处理 Web 请求 |
| Sidekiq | 后台异步任务,比如发邮件、调度 pipeline |
| Redis | 缓存和 Sidekiq 的队列存储 |
| PostgreSQL | 主数据库,存所有项目、用户、流水线状态 |
| Gitaly | Git RPC 服务,所有仓库的读写都经过它 |
用 Docker Compose 部署意味着要给每个组件写配置、管理组件间的网络、处理各自的环境变量,维护成本很高。Omnibus 把这些全部打包好了,所有配置统一在 /etc/gitlab/gitlab.rb 一个文件里改,改完 gitlab-ctl reconfigure 自动生效。代价是部署方式不够灵活,但 Homelab 场景不需要那么多灵活性。
关于 Gitaly 的内存问题。
Gitaly 是这套组件里最需要关注的一个。它负责所有仓库的 Git 操作:clone、push、fetch,全部走 Gitaly。仓库少、访问频率低的时候感觉不出来,但仓库一多,或者 CI 构建里有频繁的 git clone 操作,Gitaly 的内存消耗会很明显。
Omnibus 默认给 Gitaly 的内存上限比较保守,实际使用中可能需要在 gitlab.rb 里调整 Gitaly 的并发参数,否则高并发下容易出现 rpc error: code = ResourceExhausted 的报错。
Runner 节点:干脏活的那台机器
Runner 节点只跑两个东西:gitlab-runner 和 dockerd。
gitlab-runner 是 GitLab 的 CI 执行代理。GitLab 主节点触发一个 pipeline job,Runner 接收到任务,在本地执行,把结果上报回去。
这台机器用的是 Docker executor:每个 job 起一个独立的 Docker 容器,在容器里执行构建命令,job 跑完容器销毁。容器之间互相隔离,不会因为一个 job 的错误影响其他 job。
sequenceDiagram
participant GL as GitLab 主节点
participant RN as Runner 节点
participant NX as Nexus
GL->>RN: 分发 pipeline job
RN->>NX: 拉取构建基础镜像
RN->>RN: 起 Docker 容器执行构建
RN->>NX: 推送构建产物(镜像/包)
RN->>GL: 上报构建状态(成功/失败)
为什么 Runner 必须独立?
CI 构建对资源的需求是突发的、峰值不可控的。一个 Android 项目编译时,CPU 会长时间跑满,内存消耗几个 GB 是正常的。如果这些发生在 GitLab 主节点上,PostgreSQL 的写操作会被争抢,Web 请求的响应时间会拉长,严重时 GitLab 自己的健康检查会超时,服务降级。
独立出去之后,Runner 把机器资源跑爆也不影响 GitLab 主节点的可用性。这是资源隔离最直接的收益。
Nexus:不只是镜像仓库
Nexus Repository Manager 是这套架构里功能最多、也最容易被低估的一环。
对外暴露两个端口:
8081:Nexus 的 Web 管理界面和 REST API8082:Docker Registry 入口,实现了标准的 Docker Registry v2 协议
为什么不用 GitLab 内置的 Registry
GitLab 本身有 Container Registry 和 Package Registry,为什么要另起一个 Nexus?
第一个原因:存储隔离。
GitLab 内置 Registry 的数据和代码仓库、数据库放在同一个数据目录下。CI 推镜像这件事非常容易把磁盘搞满。存储满了,不只是镜像推不进去,PostgreSQL 也写不了,GitLab 整个服务就挂了。把制品存储拆到独立机器,磁盘满了最多是推包失败,不会波及代码服务。
第二个原因:依赖缓存代理。
这是 Nexus 最被低估的功能。
CI 构建每次都需要拉依赖:npm 包、Maven 依赖、PyPI 包、Docker 基础镜像。每次去公网拉,速度受网络状况影响,不稳定。Nexus 支持配置代理仓库——把对 npm registry、Maven Central、PyPI、Docker Hub 的请求全部经过 Nexus,Nexus 第一次回源缓存下来,后续请求直接命中本地缓存。
在国内的网络环境下,这个收益非常明显。不做缓存的时候,npm install 偶尔会因为镜像源抖动超时,构建失败;加了 Nexus 代理之后,依赖全部走内网,速度稳定,不受外网干扰。
第三个原因:职责分离。
GitLab 只管代码和流水线状态,不知道制品存在哪。Nexus 只管制品的存储、版本、访问控制,不知道代码是什么。两个系统互不依赖,升级 GitLab 不影响 Nexus,Nexus 出问题不影响代码托管,备份策略也可以各自制定。
外部访问:从公网到内网的一条链路
这套东西在家里的内网,但需要能在外面访问。这条链路涉及四个环节,每个环节都有具体的原因。
sequenceDiagram
participant Dev as 开发者(外网)
participant CF as Cloudflare
participant OW as OpenWrt
participant TR as Traefik
participant GL as GitLab 主节点
Dev->>CF: HTTPS 请求(gitlab.example.com)
CF->>OW: DNS 解析到家庭公网 IP
OW->>TR: 防火墙规则转发到内网 Traefik
TR->>GL: 按域名转发到 GitLab(内网 HTTP)
GL-->>Dev: 响应
Cloudflare 只做 DNS,不做代理。
Cloudflare 有两种模式:代理模式(橙云)和 DNS Only 模式(灰云)。代理模式下流量会经过 Cloudflare 的边缘节点,好处是隐藏真实 IP;DNS Only 只做域名解析,流量直接打到源站。
这里必须用 DNS Only。原因是我用的是非标端口,Cloudflare 的代理模式只支持特定端口列表(80、443、8080 等有限几个),非标端口的流量会被 Cloudflare 直接丢弃,根本到不了家里。
OpenWrt 做端口转发。
国内宽带运营商封锁了 80 和 443 端口的入站流量,在这两个端口上直接对外服务是不可行的。通过 OpenWrt 的防火墙规则,把外部访问的自定义端口映射到内网 Traefik 机器的对应端口上。
Traefik 做反向代理和 TLS 终结。
Traefik 承接从 OpenWrt 转发过来的流量,根据域名规则路由到对应的内网服务。TLS 证书由 Traefik 通过 ACME 协议从 Let’s Encrypt 自动申请和续期,GitLab 内部走 HTTP,不需要单独管理证书。
GitLab 这边需要在 gitlab.rb 里配置 nginx['real_ip_trusted_addresses'],告诉它信任来自 Traefik IP 的请求头,这样 X-Forwarded-For 里的真实客户端 IP 才能被正确记录,GitLab 的访问日志才有意义。
这套架构的局限
说清楚能做什么,更要说清楚不适合做什么。
没有高可用。 GitLab 主节点是单点,机器挂了服务就没了。两人团队的 Homelab,偶尔停服可以接受,不值得为此引入 HA 的复杂度。
Runner 是单点,并发有上限。 只有一台 Runner,同时只能跑有限数量的并发 job,多余的 job 排队等待。量上来了加机器横向扩展,注册新 Runner 不需要改其他任何配置。
Nexus 没做集群。 单机部署,挂了之后 CI 构建拉依赖会失败,但不影响代码托管。代码服务的可用性和制品服务的可用性是解耦的。
没有异地备份。 数据全在本地,靠定时脚本备份。磁盘物理损坏的情况下,最近一次备份之前的数据会丢失。这是 Homelab 和生产环境最大的差距,接受这个风险是有意识的选择。
小结
三机分工的核心思路是:让每台机器只做一件事,出问题知道去哪找,挂了影响面不扩散。
这套架构不是最开始就设计好的,而是从单机一路踩坑踩出来的。资源竞争和存储问题逼着拆分,每次拆分都让系统更清晰一点。
对两人团队的 Homelab 来说,这已经是合理的复杂度上限了。再往上加节点,维护成本会超过收益。