Harbor 私有镜像仓库实战:HTTPS 信任链、镜像命名规则、删除 tag 不等于释放磁盘与 GC 停机流程

镜像从哪里来:一个被忽视的供应链问题

用 Docker 的人都写过这句命令:docker pull nginx:latest。它看起来天经地义,但背后有件事值得停下来想一秒:这个镜像从 Docker Hub 拉下来,你信任它的唯一依据是「名字对」

对个人站长来说,私有镜像仓库这件事通常被排在很后面——毕竟站点就跑几个容器,从公共仓库拉不是挺好吗。但真到了生产环境,有几个场景会逼着你必须有私有仓库:

  • 镜像拉不下来就起不来。Docker Hub 对匿名用户的拉取频率有限制,对国内网络也有可达性问题;Cloudflare 之类服务出故障时,全世界的容器编排都会跟着受影响。生产环境依赖公共仓库,等于把可用性交给了别人。
  • 自己的镜像无处安放。你自己构建的镜像(定制化的 WordPress、加了模块的 PHP-FPM、内部工具)不可能推到公共仓库,而每次部署都靠 docker save / docker load 传 tar 包,运维体验极差。
  • 版本管理靠 latest 是灾难。如果没有私有仓库的 tag 体系,回滚某个版本只能翻历史 tar 包,而且不知道哪个 tar 是哪个版本。
  • 基础镜像的安全底线。不加约束地拉 latest,某天上游发布了不兼容或带漏洞的版本,你的服务会在没有任何操作的情况下突然崩溃或暴露。

这篇文章讲 Harbor——CNCF 毕业项目、当前自建容器镜像仓库的事实标准。相比直接用 registry:2 那个极简镜像,Harbor 多出来的核心价值是权限控制、镜像扫描、镜像复制、垃圾回收、Web 界面这几件事,而这些恰好是「能用于生产」和「只能自己玩」的分界线。

安装 Harbor 本身不复杂,真正的难点在于:如何规划存储、如何配置 HTTPS 与信任链、如何做垃圾回收把磁盘空间收回来、以及如何和 Docker Compose 的部署流程接起来。这四个问题都会讲透。

一、安装前的容量与架构决策

Harbor 的官方安装方式是 docker compose(早期版本用 docker-compose),它会在宿主机上起六七个容器:核心服务、数据库、Redis、Jobservice、Registry、Trivy 扫描器、可选的可信内容组件。所以第一件要做的决策是资源规划

最低与推荐配置。官方最低要求是 2 核 4G,但这只是「能启动」。实际使用中:

  • 2 核 4G——够跑一两个仓库、偶尔扫描镜像。Trivy 扫描时内存会明显上涨。
  • 4 核 8G——推荐的舒适配置,扫大镜像不卡、GC 顺利。
  • 磁盘——这是唯一真正需要认真规划的资源。经验值是每个镜像 tag 平均 200-400MB(取决于基础镜像),一个项目保留 20 个历史版本就是 5-8GB。要留够 50-100GB。

存储驱动的选择。这是 Harbor 部署里最容易做错、后期最难改的一个决策。Harbor 的数据分两层:数据库里存元数据(项目、用户、镜像清单的索引),而真正的镜像层数据存在 Registry 的存储后端。这个后端有几个选项:

  • 本地文件系统(默认,filesystem)——最简单,镜像层直接在磁盘上。
  • S3 兼容对象存储——适合镜像量大、需要水平扩展的场景。可以用 AWS S3,也可以用自建的 MinIO。对象存储的优势是垃圾回收不需要停机,而且天然适合做异地复制。

对个人站长,建议从小规模文件系统开始,但目录规划时留好扩展余地。因为切换存储后端需要迁移所有镜像层数据,是个有停机窗口的操作。一个实用的做法是:即使现在用本地存储,也把数据目录挂在一个独立的逻辑卷上,将来要迁移时只需离线复制 /data 目录。

域名与 HTTPS 的强约束。这里有个必须提前知道的硬性要求:Docker 客户端默认只允许通过 HTTPS 访问仓库,或者通过被显式加入 insecure-registries 的 HTTP 地址。这意味着两个选择:

  • 配真实域名的 HTTPS 证书(推荐,下文详细讲)。
  • 每台客户端都改 /etc/docker/daemon.json 加入 insecure-registries,然后重启 Docker。缺点是多机管理很麻烦,且明文传输。

结论:直接用 HTTPS。Harbor 的 HTTPS 需要四样东西:一个域名、一张证书、以及证书文件和私钥文件。用 Let's Encrypt 签发即可。注意一个细节:Harbor 期望你在 harbor.yml 里直接给出证书文件路径,而不是由 Harbor 自己申请,所以要先签发好证书再安装,或者安装后替换证书文件并重启。

二、完整安装流程

第一步,下载并解压安装包。

cd /opt
wget https://github.com/goharbor/harbor/releases/download/v2.11.0/harbor-offline-installer-v2.11.0.tgz
tar xzf harbor-offline-installer-v2.11.0.tgz
cd harbor

offline 版本的原因很直接:它内置了全部镜像,安装时不需要从网络拉取,避免了「安装到一半网络超时」的问题。代价是包有几个 GB,下载慢一点。

第二步,申请证书(在安装之前)。

# 用 certbot 以 standalone 模式签发(需要 80 端口暂时空闲)
systemctl stop nginx    # 如果宿主机有 nginx 占用 80
certbot certonly --standalone -d harbor.example.com
systemctl start nginx

# 确认证书文件位置
ls -l /etc/letsencrypt/live/harbor.example.com/

第三步,改配置文件。从模板复制一份再改:

cp harbor.yml.tmpl harbor.yml

以下是要改的部分(harbor.yml 是 YAML 格式,缩进必须用空格,不能有 Tab):

# 主机名:必须是客户端能解析到的域名,不能写 localhost 或 IP
hostname: harbor.example.com

# HTTP 配置:对外只开 HTTPS 时,让 HTTP 端口只做跳转或干脆注释掉
http:
  port: 80

https:
  port: 443
  certificate: /etc/letsencrypt/live/harbor.example.com/fullchain.pem
  private_key: /etc/letsencrypt/live/harbor.example.com/privkey.pem

# 管理员初始密码:务必改掉,默认是 Harbor12345
harbor_admin_password: 改成你的强密码

# 数据库密码
database:
  password: 改成你的数据库密码
  max_idle_conns: 100
  max_open_conns: 900

# 数据目录:镜像层和数据库文件都放这里
data_volume: /data

# 扫描器配置
trivy:
  ignore_unfixed: false
  skip_update: false
  insecure: false

# 日志
log:
  level: info
  local:
    rotate_count: 50
    rotate_size: 200M
    location: /var/log/harbor

第四步,执行安装。

./install.sh --with-trivy

--with-trivy 表示同时启用漏洞扫描组件。不加这个参数,Harbor 装完了但扫描功能是灰的。安装脚本会自动生成 docker-compose 文件、准备数据目录、加载镜像并启动所有容器。整个过程三到十分钟。

第五步,验证。

docker compose ps              # 所有容器状态应为 healthy 或 running
curl -sI https://harbor.example.com/ | head -3

浏览器打开 https://harbor.example.com,用 adminharbor.yml 里设的密码登录。

三、从客户端推送第一个镜像

很多人卡在这一步——docker login 报错,docker push 报错,但错误信息都比较晦涩。下面走一遍完整流程并解释每一步。

第一步,在 Harbor 界面创建项目。登录后进入「项目」→「新建项目」,名字例如 myapps。关键选项:

  • 访问级别——勾选「公开」则任何人可拉取;不勾选则为私有,需要登录且授权才能拉。个人使用建议私有
  • 内容信任——启用后可要求镜像必须签名,个人使用可以不启用。
  • 自动扫描——勾上「推送时自动扫描」,每次推送镜像自动跑漏洞扫描。

第二步,登录。

docker login harbor.example.com
# 输入用户名 admin 和密码

如果这一步报 x509: certificate signed by unknown authority,说明客户端不信任服务器证书。对于 Let's Encrypt 的正常证书,通常是系统 CA 包过旧。对于自签名证书,需要把 CA 证书装到客户端的信任库:

# Debian/Ubuntu 客户端:安装自定义 CA
cp your-ca.crt /usr/local/share/ca-certificates/harbor-ca.crt
update-ca-certificates
systemctl restart docker     # 必须重启 Docker,否则它不会重新读信任库

第三步,给镜像打上仓库前缀的 tag。这是最容易混淆的一点:Docker 的 push 目标由镜像名本身决定,不是由某个参数指定。所以必须先 tag:

# Harbor 的镜像命名规则:主机名/项目名/镜像名:标签
docker tag nginx:1.25 harbor.example.com/myapps/nginx:1.25

# 记住这条规则:三段式,项目名在中间
# 少了项目名会报 "project not found"
# 用了 IP 而不是域名,证书校验会失败

第四步,推送并验证。

docker push harbor.example.com/myapps/nginx:1.25

# 拉取回来验证一次,确保镜像真的可用
docker rmi harbor.example.com/myapps/nginx:1.25
docker pull harbor.example.com/myapps/nginx:1.25
docker images | grep harbor.example.com

一个高频错误:镜像名里带了端口但 push 时又被解析成另一个主机。比如 harbor.example.com:443/myapps/nginx,端口 443 会被当作 registry 端口,导致 TLS 握手异常。正确做法是镜像名里不带端口(省略端口时默认走 443)。

四、构建自己的镜像并纳入版本管理

有了仓库,接下来是把「构建」这一步也管起来。以一个定制化的 PHP-FPM 镜像为例:

# Dockerfile
FROM php:8.2-fpm-alpine

# 安装常用扩展
RUN docker-php-ext-install pdo_mysql opcache \
    && apk add --no-cache fcgi

# 调整 PHP 生产参数
RUN { \
      echo 'opcache.enable=1'; \
      echo 'opcache.memory_consumption=128'; \
      echo 'opcache.validate_timestamps=0'; \
      echo 'upload_max_filesize=32M'; \
      echo 'memory_limit=256M'; \
    } > /usr/local/etc/php/conf.d/zz-production.ini

WORKDIR /var/www/html

标签策略。这是镜像管理里最影响长期维护性的决策。推荐三种标签并存:

# 1. 语义化版本:用于生产部署,永不覆盖
docker build -t harbor.example.com/myapps/php-fpm:8.2.20 .

# 2. commit 短哈希:精确对应代码版本,排查问题时唯一可靠
docker build -t harbor.example.com/myapps/php-fpm:git-$(git rev-parse --short HEAD) .

# 3. latest:只用于开发便利,生产环境绝不引用
docker build -t harbor.example.com/myapps/php-fpm:latest .

docker push harbor.example.com/myapps/php-fpm --all-tags

为什么生产环境绝不能用 latest因为 latest 是可变的。今天部署拉到的和明天拉到的可能完全不同版本,回滚时不知道该回滚到哪个具体行为。更麻烦的是 Docker 的镜像缓存机制:docker compose pull 在本地已有该 tag 时不一定重新拉,导致不同机器上跑着不同版本,出现「本地能复现、线上不能」的迷惑现象。

多架构构建的实用场景。如果你的开发机是 ARM(Apple Silicon 或 ARM 服务器),而部署目标是 x86,构建出来的镜像在目标机 exec format error。解法是用 buildx 一次构建双架构并推送:

docker buildx create --name multiarch --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t harbor.example.com/myapps/php-fpm:8.2.20 \
  --push .

注意这里必须用 --push 而不是 --load,因为多架构镜像清单无法加载到本地 Docker 的镜像库里。

五、垃圾回收:这一步不做,磁盘一定会满

这是 Harbor 运维里最重要、也最容易被忽略的一件事。删除镜像 tag 不等于释放磁盘空间。

原因是分层存储的机制:多个镜像 tag 可能共享同一个基础层。当你在界面上删除一个 tag 时,Harbor 只是删掉了索引记录,底层的数据块仍然留在存储里。这些「幽灵数据」累积起来会非常可怕——删除了一堆镜像但磁盘空间一点没变,是 Harbor 用户的经典困惑。

正确流程分两步,而且顺序和执行方式都有讲究:

# 第一步:删除不需要的 tag(可以在网页界面操作,或用 API)
curl -s -X DELETE \
  -u "admin:密码" \
  "https://harbor.example.com/api/v2.0/projects/myapps/repositories/php-fpm/artifacts/sha256:xxxx"

# 第二步:执行垃圾回收
# 重要:为了让 GC 能删除数据块,必须让 Registry 进入只读模式(阻止新推送)
docker compose stop
docker run -it --name gc --rm \
  --volumes-from registry \
  -v /data/registry:/storage \
  -v /opt/harbor/common/config/registry/:/etc/registry/ \
  goharbor/registry-photon:v2.11.0 garbage-collect \
  --config /etc/registry/config.yml

docker compose start

为什么不建议在运行状态下用 GC 的删除模式?Harbor 的 GC 支持一个「不删数据、只报告」的 dry-run 模式,可以先看看会释放多少。但真正执行删除时,如果 Registry 正在处理推送,存在数据竞争风险——GC 可能把某个正在被写入的层判定为「没有引用」而删除,导致镜像损坏。所以稳妥的做法是停机 GC,对于个人站点,凌晨执行、停机几分钟完全可以接受。

还有一个补充手段:配置保留策略,让 Harbor 自动清理旧 tag,从源头控制增长:

# 在项目设置 → 策略 → 保留策略中配置
# 推荐规则:
#   保留最新的 10 个 tag
#   保留最近 30 天内推送的
#   匹配 * 的镜像
#   未勾选「应用于最新推送的 N 个 artifact」时会更激进地清理

先配保留策略再定期 GC,磁盘增长就完全可控了。

六、用 API 自动化:把镜像清理做成定时任务

Harbor 有完整的 REST API,把「列出超大 tag → 删除 → 报告」这套流程自动化,比手工点界面可靠得多:

#!/usr/bin/env python3
# /opt/harbor-cleanup.py —— 清理超过 90 天未拉取的旧 tag
import requests, datetime, sys

HARBOR = "https://harbor.example.com"
AUTH = ("admin", "你的密码")
KEEP_DAYS = 90
now = datetime.datetime.now(datetime.timezone.utc)

s = requests.Session()
s.auth = AUTH
s.verify = True

projects = s.get(f"{HARBOR}/api/v2.0/projects?page_size=100").json()
deleted = 0
for proj in projects:
    pname = proj["name"]
    repos = s.get(f"{HARBOR}/api/v2.0/projects/{pname}/repositories?page_size=100").json()
    for repo in repos:
        rname = repo["name"].split("/", 1)[-1]
        arts = s.get(
            f"{HARBOR}/api/v2.0/projects/{pname}/repositories/{rname}/artifacts",
            params={"page_size": 100, "with_tag": "true"},
        ).json()
        for art in arts:
            tags = [t["name"] for t in art.get("tags") or []]
            if not tags:
                continue
            # 跳过 latest,避免误删开发便利标签
            if "latest" in tags:
                continue
            pushed = datetime.datetime.fromisoformat(
                art["push_time"].replace("Z", "+00:00"))
            age = (now - pushed).days
            if age < KEEP_DAYS:
                continue
            print(f"删除 {pname}/{rname}:{tags[0]} (已 {age} 天)")
            r = s.delete(
                f"{HARBOR}/api/v2.0/projects/{pname}/repositories/{rname}"
                f"/artifacts/{art['digest']}")
            if r.status_code in (200, 202):
                deleted += 1

print(f"共删除 {deleted} 个 tag,记得随后手动执行一次 GC 释放空间")

三个细节:

  • 用 digest 而不是 tag 做删除标识。API 的删除接口接受 digestsha256:...)而非 tag 名。用 tag 删除时,如果界面上显示的是多个 tag 共享同一 digest,实际会一次删掉全部。
  • 保护 latest脚本里显式跳过了带 latest 的 artifact,避免误删开发用的便利标签。
  • 删除后必须手动 GC。API 删除只是标记,磁盘释放仍需第六节那套停机 GC 流程。任何声称「DELETE 就释放了空间」的说法都是错的。

七、备份与灾难恢复

Harbor 的备份比想象中简单,因为需要备份的东西很集中:

  • /data 目录——包含 registry 的镜像层数据、数据库文件、Redis 数据。
  • harbor.yml——配置文件的备份极其重要。丢了它,重建时所有参数都要凭记忆重写,域名、证书路径、数据卷路径错一个就得排查很久。
  • 证书文件——Let's Encrypt 的 /etc/letsencrypt 目录。
#!/bin/bash
# /root/backup-harbor.sh
set -euo pipefail
DEST=/backup/harbor/$(date +%Y%m%d)
mkdir -p "$DEST"

# 配置文件(最重要的,因为小且不可再生)
cp /opt/harbor/harbor.yml "$DEST/"

# 数据目录(镜像量大,耗时较长)
tar --xattrs -czf "$DEST/harbor-data.tar.gz" -C /opt/harbor/data .

find /backup/harbor -maxdepth 1 -type d -mtime +7 -exec rm -rf {} \;
echo "Harbor 备份完成:$DEST  大小: $(du -sh "$DEST" | cut -f1)"

备份规模提醒。Harbor 备份的体积通常大于预期,因为镜像层数据本身就不小。如果 /data 有 80GB,压缩备份可能要跑几十分钟,tar 包也仍是几十 GB。对镜像仓库而言,更实际的策略是「不备份镜像数据,只备份配置与元数据,镜像从构建流程重建」——前提是你的 Dockerfile 和构建流程都已经纳入版本管理(这正是上一节强调 tag 策略的原因)。这样备份体积从几十 GB 降到几 MB,恢复时间从小时级降到分钟级。

恢复流程。在一台新机器上重建:

# 1. 下载同版本安装包
wget .../harbor-offline-installer-v2.11.0.tgz && tar xzf ...
# 2. 用备份的 harbor.yml 覆盖模板,确认证书路径存在
# 3. 如果恢复了数据目录,先解压到 /opt/harbor/data
# 4. 执行安装
./install.sh --with-trivy

注意必须用相同版本的安装包。跨大版本恢复(用 2.11 的包去装载 2.10 的数据)会触发数据库迁移,失败时数据可能处于半迁移状态,非常难救。

八、常见问题

Q:docker push 返回 denied: requested access to the resource is denied三个常见原因:一是没登录(docker login);二是镜像名里的项目名不存在(Harbor 不会自动创建项目,必须先在界面建);三是当前用户没有该项目的推送权限——admin 有全部权限,普通用户需要在项目成员里授权,角色至少是「维护人员」。

Q:Harbor 占内存太多?先看是谁在吃。Trivy 扫描器在扫描大镜像时会明显占用内存和 CPU,可以把 harbor.yml 里的扫描配置改成按需扫描而不是推送即扫。另外 Harbor 的两个 proxy 容器(nginx)和 jobservice 也可以限制资源。用 docker stats 看实际占用:

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}"

Q:能不能让 Harbor 和网站服务共用一台 2 核的 VPS?技术上可以,但不推荐。Harbor 的容器数量和内存占用都不小,和网站抢资源会导致网站响应变慢。如果只有一台机器,建议至少在推送镜像的时段注意观察负载。更好的方案是 Harbor 单独一台,反正它不需要强 CPU。

Q:镜像扫描报了一堆漏洞,怎么办?这是常态而非异常。基础镜像(尤其是 debian:bullseye 这类)会持续报出系统包的 CVE。实操上分三类处理:一是优先换基础镜像,用 Alpine 或 distroless 版本能消掉大部分;二是修自己的层,Dockerfile 里 apt-get upgrade 更新系统包;三是评估忽略,只在 harbor.yml 里用 ignore_unfixed 忽略没有修复版本的漏洞。

Q:多个服务器要拉镜像,每次都走公网带宽太贵?用 Harbor 的远程复制功能,让两个 Harbor 实例之间同步镜像。这样每台机器从就近的仓库拉,既省带宽又快。配置在「仓库管理」加一个远端、在「复制」里建规则即可。

Q:怎么限制某个项目只能用某个基础镜像?Harbor 有「镜像代理缓存」功能:配置一个指向 Docker Hub 的代理项目,客户端拉 harbor.example.com/dockerhub-proxy/nginx 时 Harbor 会代为拉取并缓存。好处是缓存过的镜像后续拉取很快,且可以统一做扫描。注意代理缓存的项目不占你的镜像数量统计,但会占磁盘。

总结

自建 Harbor 的投入产出比取决于你到底有多少自建镜像。如果所有服务都用官方镜像、不做定制构建,那 Docker Hub 加镜像加速已经够用;但一旦你开始构建自己的镜像、需要版本回滚、需要控制基础镜像来源,私有仓库就从「锦上添花」变成「基础设施」。

回看全文,最需要记住的是这几条:

  • 安装之前先定证书和数据目录。Harbor 要求证书文件预先存在,切换存储后端代价高昂,这两个决策必须在 install.sh 之前想清楚。
  • 镜像名就是 push 目标。三段式命名 域名/项目名/镜像名:标签,项目必须先在界面创建。理解这一点能省掉大量返工。
  • 删除 tag 不等于释放磁盘,必须跑 GC。而且 GC 需要停机,配置保留策略可以把增长控制在源头。
  • 用语义化版本 tag,生产环境永远不要用 latest可回滚是私有仓库最核心的价值,tag 策略直接决定这个价值能不能兑现。
  • 备份的重点是配置和构建流程,不是镜像数据本身。镜像可以从 Dockerfile 重建,配置丢了就要靠记忆——这两者的备份价值差了一个数量级。

最后给一个务实的落地路径:如果目前还没到需要私有仓库的规模,可以从一件小事开始——把每次 docker run 用的镜像版本从 latest 改成具体版本号(比如 nginx:1.25.3)。这一步不需要任何额外基础设施,就能立刻获得可复现的部署和可回滚的能力。等你积累出几个自己构建的镜像时,再按本文装 Harbor 接管这一切,那时它的价值也会立刻显现出来。

Last modification:September 23rd, 2026 at 10:25 pm

Leave a Comment