
1. 自建私有仓库前先想清楚这几个问题先说一个我自己的经历。去年帮某内部项目搭容器交付平台最开始图省事所有人共享一台测试机上的镜像Docker Hub 拉公有镜像也还算顺。结果项目一进入联调阶段就炸了几台新节点同时 docker pull外网带宽直接被占满部署一次要等十几分钟有些镜像还因为网络抖动拉一半就断重试又继续占带宽。最后我们被逼着上了私有仓库问题才真正解决。很多人对私有仓库的理解就是“搭一个 Registry 然后 push/pull”但实际落地时你首先要搞清楚它解决的到底是什么问题第一是带宽与速度。内网拉镜像是局域网传输几十 GB 的镜像也能在几分钟内分发完毕不依赖外网。第二是稳定性与可用性。Docker Hub 这类公共服务有频率限制频繁拉取容易 429断点续传能力也有限。私有仓库在这一点上完全受你控制。第三是安全与合规。很多项目不允许把镜像推到外部服务私有仓库保证镜像只在内部流转还可以整合认证和审计。那具体到选型上其实没那么复杂。当前主流的自建方案大致有三类方案优点缺点适用场景Docker Registryregistry:2极轻量、部署简单、资源消耗小功能少无 Web UI权限体系弱小团队、边缘节点、单机环境Harbor功能全含 UI、RBAC、镜像复制、漏洞扫描部署和运维成本更高依赖数据库中大型团队、正式生产环境Nexus 或 Artifactory能同时管理多种制品类型上手复杂License 和资源开销要评估已有制品管理平台、多语言项目我个人的建议是如果你的团队就三五个人容器规模在几十个镜像以内直接用 Registry 加 TLS 和 htpasswd 最简单如果超过十个人、有多个项目组或者公司有合规审计要求上 Harbor 是更稳妥的选择。网上大部分教程都直接教 Harbor但我觉得没必要一上来就上重武器。1.1 最小可用的 Registry 部署方式这里我以 registry:2 为例给一个最少能跑通的内网仓库部署。# 1. 创建存放认证文件的目录 mkdir -p /data/registry/auth mkdir -p /data/registry/data # 2. 用 htpasswd 创建用户没有 htpasswd 的话用容器跑 docker run --rm --entrypoint htpasswd registry:2 -Bbn admin YourStrongPass /data/registry/auth/htpasswd # 3. 启动 registry挂载数据目录和认证文件 docker run -d \ --name registry \ --restartalways \ -p 5000:5000 \ -v /data/registry/data:/var/lib/registry \ -v /data/registry/auth:/auth \ -e REGISTRY_AUTHhtpasswd \ -e REGISTRY_AUTH_HTPASSWD_REALMRegistry Realm \ -e REGISTRY_AUTH_HTPASSWD_PATH/auth/htpasswd \ registry:2这套做出来内网其他机器就能通过镜像地址前缀来推送和拉取了写法大概是docker tag nginx:1.27 192.168.10.20:5000/nginx:1.27 docker push 192.168.10.20:5000/nginx:1.27 docker pull 192.168.10.20:5000/nginx:1.27注意手动敲 IP 加端口的方式在中小团队完全够用。但如果你不想让所有机器都手动登录认证或者想用更规范的域名和证书就需要做 TLS 和配置 Docker daemon。1.2 不加 TLS 你会遇到的问题很多新手觉得内网仓库嘛IP 直连不就行了TLS 后面再说。实际操作中的体验是这样的docker 默认把所有 HTTP 仓库都视为 insecure registry你必须在每台客户端的/etc/docker/daemon.json里加一段{ insecure-registries: [192.168.10.20:5000] }然后重启 docker 才能正常 push/pull。如果哪台机器忘了加Docker CLI 会报一个特别令人困惑的错误Error response from daemon: Get http://192.168.10.20:5000/v2/: http: server gave HTTP response to HTTPS client这个错不是说仓库坏了而是客户端默认用 HTTPS 去请求仓库却是纯 HTTP。你一看日志可能以为端口或网络有问题其实只是 TLS 配置不一致。所以如果你要长期维护一个私有仓库我强烈建议早点把 TLS 配好否则后面每接入一台机器都是一个隐形坑。配 TLS 也简单用一条内部信任的 CA 证书签一张服务器证书然后在 registry 容器里挂载证书目录并设置环境变量REGISTRY_HTTP_TLS_CERTIFICATE和REGISTRY_HTTP_TLS_KEY即可。客户端的 Docker daemon 信任这张 CA 之后就不需要再配 insecure-registries也更安全。2. 仓库跑起来只是开始镜像清理与存储回收私有仓库跑起来之后最快乐的时间大约是前三个月。所有人都往里推镜像你甚至会觉得“哎呀这个仓库真省心”。等三个月后再看磁盘占用经常能把你吓一跳——一个不算活跃的测试仓库轻松堆到几百 GB。这里先要澄清一个非常普遍的误解“删除镜像会释放磁盘空间”。这句话在绝大多数情况下是错的。Registry 的存储模型不是按“镜像”为单位管理的而是按 content-addressable 的层blob为单位。你执行 DELETE 接口删掉某个镜像的 manifest 之后registry 只是把引用它的 manifest 标记删除那些 blob 其实还躺在磁盘上。只有当你运行 garbage-collect 之后无引用的 blob 才会真正被清理。而这个 GC 操作经常被忽略因为官方 Registry 镜像不会自动做垃圾回收。你需要手动执行# 进入 registry 容器内执行 GC docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml执行之后它会输出一堆 delete 记录告诉你哪些 blob 被清理了。比较麻烦的是在 registry 容器里停掉服务再 GC可能会让正在拉镜像的节点失败。所以平时更稳妥的方式是在低峰期执行或者干脆再起一个 registry 临时容器挂载同样的数据目录来做 GC。2.1 镜像 API 删除的两种方式现在回看删除本身。Registry v2 API 删除镜像的完整流程是查询该镜像的 manifest digest。用DELETE /v2/name/manifests/digest删除 manifest。此时镜像不可再 pull但 blob 层仍然存在。执行 GC 释放 blob。实际命令大概是这样的# 先获取 digest curl -u admin:password -H Accept: application/vnd.docker.distribution.manifest.v2json \ https://registry.example.com/v2/nginx/manifests/1.27 # 得到 digest 后执行删除 curl -u admin:password -X DELETE \ https://registry.example.com/v2/nginx/manifests/sha256:xxxx但这里有个容易踩坑的地方很多自建仓库没有启用删除接口。需要在 registry 的配置或环境变量里显式开启REGISTRY_STORAGE_DELETE_ENABLEDtrue否则调用 DELETE 会返回 405。我第一次配置时忘了加这个参数折腾了半天才发现是功能根本没开。如果你用的是 Harbor删除镜像是在 UI 里点的点完之后系统会提示“删除完成后需执行垃圾回收”这时候 GC 才是真正释放空间的环节。Harbor 还引入了“清理策略”的概念可以按保留时间或标签数量自动清理旧镜像对于有版本迭代节奏的团队很实用。2.2 磁盘清理项目的另类思路直接清 /var/lib/registry 目录吗有人觉得既然 GC 这么麻烦我直接把/var/lib/registry/docker/registry/v2里的目录删掉不就行了这个操作千万要谨慎。Registry 的目录结构和内容寻址体系有关删除时如果只删了一层会留下大量孤儿目录和损坏的引用可能导致后续 push 和 pull 都异常。而且更尴尬的是你删完目录之后仓库的 UI 和 tag 列表可能还残留着旧镜像的信息因为索引数据没有被同步清掉。我们之前某同事就是手痒删了v2/repositories下面某个项目的目录结果整个仓库都打不开了最后只能从备份恢复。所以如果你想手动清理我建议至少先停掉 registry 容器导出数据目录再操作。如果连备份都没有就别碰目录层级的删除。2.3 镜像大小控制比事后清理更省心其实事后 GC 只是补救更健康的做法是控制镜像本身的体积。我在内部推过一个约定基础镜像尽量用 alpine 或 slim 变体业务代码用多阶段构建。很多团队一开始图方便一个 Dockerfile 里把编译工具链和运行环境全塞进去镜像动辄上 GB几个版本迭代下来磁盘很快就爆。多阶段构建的例子很简单FROM golang:1.22 AS builder WORKDIR /src COPY . . RUN CGO_ENABLED0 GOOSlinux go build -o app . FROM alpine:3.20 COPY --frombuilder /src/app /usr/local/bin/app ENTRYPOINT [app]最终镜像只包含一个编译好的二进制和运行依赖从 1GB 缩减到几十 MB。这比任何私有仓库的清理机制都管用。补充一点像docker system df这种命令可以快速查看宿主机上所有镜像和缓存占用定期看看心里有数。3. Cgroup 给容器划定的资源边界聊完镜像分发另一半是 Cgroup。很多人每天用 Docker但资源限制这条线其实是 Cgroup 在底下撑着。Cgroup 是 Linux 内核提供的资源隔离和控制能力容器运行时利用它限制 CPU、内存、磁盘 IO、网络带宽等资源。Docker 里常见的--cpus、--memory参数最后都会落到 Cgroup 的相应控制文件里。值得注意的是2023 年以后新发布的 Linux 发行版大多默认使用 Cgroup v2而 Ubuntu 22.04、Debian 12、Alibaba Cloud Linux 3 这些系统上Docker 和 containerd 都已经适配 v2。老教程里读/sys/fs/cgroup/memory/memory.limit_in_bytes那套 v1 路径在新的 v2 系统上已经不存在了。你打开/sys/fs/cgroup/cgroup.controllers就能看到当前 cgroup 支持哪些控制器。我在实际项目中见过不止一个人搞混用docker inspect查到了一个内存限制值然后去宿主机上想看看 cgroup 文件里的对应值结果路径写的是 v1 的报 No such file or directory就以为 Docker 没生效。其实 v2 路径下对应的文件是/sys/fs/cgroup/system.slice/docker-容器ID.scope/memory.max这个文件里的数字和docker inspect里的Memory字段是能对上的。3.1 CPU 限制参数背后的换算逻辑Docker 的--cpus1.5看起来很好理解就是限制容器最多使用 1.5 个 CPU 核心。但底层 Cgroup 控制的其实是cpu.maxv2或cpu.cfs_quota_us与cpu.cfs_period_usv1。Cgroup v1 时代控制方式是把一个调度周期切成很多份cpu.cfs_period_us默认是 100000单位微秒即 100mscpu.cfs_quota_us表示在这个周期内进程最多能占用的 CPU 时间。如果你设置--cpus1.5Docker 会把 quota 设置成 150000也就是说每 100ms 周期内最多用 150ms 的 CPU 时间等价于 1.5 核。v2 下简化成cpu.max文件写法是150000 100000同样的含义。这个机制带来一个重要且容易被忽略的现象CPU 限制不是“分配”而是“上限”。容器在空闲时完全可以用不到这些配额一旦需要拉升宿主机还有空闲 CPU它最多能冲到设置的配额。如果多个容器的配额加起来超过了宿主机的核心数就会出现 CPU 时间竞争表现是应用的响应延迟升高但 CPU 使用率看着并没有跑满。所以给容器设置 CPU 限制时我的建议是先不加或者给一个明显高于预期的值用docker stats观察一周再根据真实用量下调到一个合理区间。直接压太低业务起来的时候会像被人按住了脖子。3.2 内存限制与 OOM 行为内存这块比 CPU 更容易出事故。--memory512m在 Cgroup v1 上对应memory.limit_in_bytes在 v2 上对应前面说的memory.max。当容器内存达到限制时触发 OOM 杀进程通常是容器里的主进程被杀表现为容器直接退出。很多人以为设置内存限制就是“内存用完了就等着被杀”但实际上这里有非常多的细节。比如v1 的memory.kmem.limit_in_bytes曾经因为内核版本和 cgroup 版本差异导致在某些发行版上出现“容器内存达到限制但是系统内存并没有耗尽”的情况需要额外处理。v2 的memory.high是一个软限制配合memory.max一起用可以让内核在接近硬限制时主动回收页缓存而不是立刻 OOM。Docker 目前默认不暴露这个参数但 containerd 的高版本已经支持扩展配置。还有一个在实践中非常坑的点内存限制不包含页缓存时看起来容量不足真相是缓存占着可回收内存。我遇到过容器内free -m显示内存几乎被缓存占满但实际业务进程的 RSS 并不高Docker stats 也没超限但有人担心它会 OOM 就反复扩容。扩容后没变化后来发现是页缓存没有按预期回收。排查方式是用cat /sys/fs/cgroup/memory.stat看anon和file两个指标file就是可回收的页缓存。# 容器内存 cgroup v1 指标 cat /sys/fs/cgroup/memory/memory.usage_in_bytes cat /sys/fs/cgroup/memory/memory.stat # v2 指标 cat /sys/fs/cgroup/memory.current cat /sys/fs/cgroup/memory.stat如果anon接近限制而file还有大量缓存说明业务真实内存不高只是在等待内核回收。这种情况通常不需要扩容优先检查是不是容器内有什么进程在疯狂读写临时文件把页缓存顶高了。4. Cgroup 与 Docker 打架的几种典型场景Cgroup 不只是“限制容器资源”这么简单。它还会和 Docker 的启动方式、systemd 的管理方式产生各种冲突。这些冲突平时不会爆发一旦遇到排查起来特别费劲。4.1 cgroupfs 和 systemd 驱动到底选哪个第一次打开一个用 systemd 管理的服务器安装 Docker 后很多人会忽略docker info里的一行关键信息Cgroup Driver: systemdDocker 的 cgroup driver 有两种cgroupfs和systemd。当宿主机使用 systemd 作为 init 系统时推荐且多数发行版默认配置为systemd这样 Docker 创建的 cgroup 会纳入 systemd 的统一管理。但如果你手动改了/etc/docker/daemon.json把 driver 改成cgroupfs一部分机器上会随机出现奇怪的问题容器能正常启动但通过systemctl status看到的服务状态和容器进程不同步或者systemctl stop docker时出现卡顿。这背后的原因不复杂systemd 和 cgroupfs 两套机制都在管理 cgroup 树systemd 在停服务时会尝试清理它自己创建的 cgroup而 Docker 用 cgroupfs 创建的 cgroup 不在 systemd 的管辖范围内两边会出现竞争。具体表现可能是资源统计错乱、容器残留 cgroup 目录或是 kill 进程时失败。所以第一个经验如果你的宿主机是 systemd 系统保持 Docker 的 cgroup driver 为 systemd不要随便改。如果你确实因为某种原因改到了 cgroupfs系统异常时先检查这一项。4.2 容器里想跑 systemd为什么会失败另一个高频问题是在容器里跑 systemd。很多人想着“容器里也像个完整系统一样装个 systemd 管理多进程”但 CentOS 7 时代用特权容器跑 systemd 的玩法到了 Cgroup v2 新内核时代经常跑不通。原因是 systemd 在容器里需要一个可写的/sys/fs/cgroup目录并且要挂载 cgroup 文件系统。在 Cgroup v1 时代你可以通过--privileged和/sys/fs/cgroup挂载来凑合。但 Cgroup v2 下systemd 期望以统一层级方式操作 cgroup 树如果容器只挂载了某个子系统可能直接失败。我们当时踩的坑就是用--privileged启动了一个包含 systemd 的容器启动后 systemctl 提示“Failed to get D-Bus connection”日志里全是 cgroup 相关的报错。查了一阵发现问题出在容器内的 systemd 版本和宿主机内核 cgroup 版本不匹配加上/sys/fs/cgroup没有被正确挂载。最终我们的方案是换掉在容器里跑 systemd 的思路把多进程拆成多个容器用编排工具管理既干净又容易排查。小团队如果有“容器内用 systemd 管理多个内部服务”的执念我建议冷静一下。Docker 的设计哲学就是一个容器只跑一个主进程健康检查、日志采集、进程守护都放在容器外面解决。强行在容器里上 systemd大概率会踩坑而不是省事。4.3 同一个 cgroup 目录里看出多份资源占用还有一种看起来匪夷所思的情况一个容器设置了内存限制在 docker stats 里看到占用正常但在宿主机上top看到这个容器进程的总 PSS 内存远大于限制值。这个其实不一定是 Cgroup 没生效而是 Cgroup 限制的是“该 cgroup 内的所有进程”但 Docker 默认会把部分容器进程放进同一个 cgroup也有可能因为容器使用了主机 PID namespace 或 init 进程传播导致一部分进程没有正确进入 Docker 创建的 cgroup 树。这种情况通常在容器启动时指定了--pidhost或者手动修改了 cgroup 归属后发生。检查方式是先定位容器主进程 PID然后看它所属的 cgroup# 找到容器主进程 docker inspect -f {{.State.Pid}} 容器名 # 查看该进程所属 cgroup cat /proc/PID/cgroup如果发现进程没在system.slice/docker-ID.scope下而是直接挂在根 cgroup 或者 host.slice 下那容器资源限制就不会覆盖到这个进程。这种情况在采用非标准运行时或者某些不规范的启动脚本里更容易出现。5. 把两块拼起来一次生产环境的体检清单聊到这儿私有仓库和 Cgroup 其实是同一个话题的两面仓库管的是“镜像怎么分发”Cgroup 管的是“容器怎么跑”。在实际生产环境里这两者往往会通过项目迭代的节奏交织在一起特别是当机器学习平台、CI/CD 流水线、微服务集群同时跑起来的时候。我给自己维护的某容器平台做过一次体检把检查项整理成了一张清单供你参考。5.1 仓库侧检查项确认所有客户端是否已经走 TLS 访问仓库检查/etc/docker/daemon.json中的 insecure-registries 是否还有残留。检查仓库数据目录的磁盘使用率超过 70% 就应该做至少一次 GC 规划。查看每个镜像仓库的 tag 数量找出无用的latest和test-*标签。最好配置一个保留策略正式环境镜像保留最近 N 个版本测试镜像 30 天自动清理。确认 registry 的删除接口是否开启GC 是否定期执行。如果是 Harbor确认垃圾回收和清理策略是否配置正确。检查客户端拉取失败率。日志里出现 authentication required 或 manifest unknown 时优先排查认证和 tag 是否正确。GC 操作本身不会锁死仓库太久但如果你用的是网络存储IO 压力会比较大。我们后来把 GC 时间放在凌晨 3 点并且用脚本记录 GC 前后磁盘占用方便观察每次清理效果。5.2 运行时资源侧检查项确认宿主机 cgroup 版本stat -fc %T /sys/fs/cgroup输出为cgroup2fs即为 v2。检查 Docker 的 cgroup driver 是否为 systemd和宿主机 init 系统保持一致。对所有核心容器梳理 CPU 和内存限制值。重点看docker inspect里的NanoCpus和MemoryBytes是否与业务预期一致。用docker stats --no-stream采样一次当前资源占用对比限制值找出已经打到 80% 以上阈值的容器。检查最近是否有 OOM 事件。dmesg -T | grep -i oom看内核日志再配合容器的退出码判断是否因 OOM 被杀。一个容易忽视的点所谓“限制值”不是越大越好。过大的内存限制无法触发内核的早期回收业务可能在内存峰值时直接把宿主机打挂过小的限制则会导致容器频繁被 OOM kill服务稳定性差。更合理的做法是给容器留 20% 的余量并且在监控里设置对应告警线。我这里给一张速查表方便你在终端里直接验证 Cgroup 是否生效目标v1 路径v2 路径内存上限值/sys/fs/cgroup/memory/memory.limit_in_bytes/sys/fs/cgroup/memory.max当前内存用量/sys/fs/cgroup/memory/memory.usage_in_bytes/sys/fs/cgroup/memory.currentCPU 配额/sys/fs/cgroup/cpu/cpu.cfs_quota_us/sys/fs/cgroup/cpu.max格式为 quota period当前 CPU 使用/sys/fs/cgroup/cpu/cpu.stat/sys/fs/cgroup/cpu.stat5.3 我的实际操作心得最后分享几条只有动过手才容易体会到的经验。第一条别把私有仓库和 Cgroup 当成两个孤立系统。镜像太大拉取时间长容器调度就会超时进程内存失控OOM 后又被系统反复拉起反过来会拉高仓库的拉取频率和网络压力。很多监控告警的根因其实要往这两条链路交叉的地方找。第二条自动化清理和资源限制要尽早立规矩。等项目跑了大半年再回头做镜像 GC或者再给所有容器补内存限制你会面对一堆不敢动的东西。宁可一开始就花半天时间写好清理脚本和限制规范也别拖到故障日报里再紧急处理。第三条验证永远是最便宜的排错手段。容器启动前先docker inspect看参数拉镜像前先确认仓库日志和磁盘水位这些动作看起来繁琐但能帮你避开一大半的玄学问题。某次线上容器频繁重启所有人都以为业务代码有 bug最后发现是容器的 memory limit 忘记更新新版本镜像内存增长后一启动就被 OOM kill把限制值调大后立刻恢复稳定。总的来说Docker 私有仓库和 Cgroup 是容器化落地过程中最基础也最容易被轻视的两块。仓库解决“镜像从哪来、怎么存”Cgroup 解决“容器能跑多欢、能占多少”。把这两条主线理顺后面再聊网络、存储、编排都会轻松很多。