跳转至

SOP — Harbor 内网镜像仓库与节点接入

本次变更:在 192.168.31.31(harbor-01)部署 Harbor v2.15.2 作为 5 个上游源的代理缓存, 并在 4 台 k8s 节点上通过 containerd certs.d 完成接入(镜像引用名零改动)。 变更日期:2026-10-01 | 实现体工程:/home/rain/Documents/services/harbor 结果回填:HARBOR-RESULT.md | 日常使用:RUNBOOK.md

目录

  1. 背景
  2. 目标
  3. 前置条件
  4. 影响范围
  5. 分步操作
  6. 验证
  7. 回滚
  8. 风险说明
  9. 附录

1. 背景

k8s 集群在跑容器的镜像来自 5 个海外源(ghcr.io、registry.k8s.io、quay.io、registry.gitlab.com、docker.io),节点重建、Pod 重调度、helm hook 首次运行都要走海外链路。本环境该链路实测不稳定(曾观测到卡住,2026-10-01 复测 1.2~1.8 MiB/s,Harbor 主机侧实测仅约 24 KB/s)。

目标是把「拉镜像」这件事内网化:新增镜像、节点重建、Pod 重调度都从内网取像,海外源只承担首次入库。

触发条件:节点上 config_path = "/etc/containerd/certs.d" 已预留且该目录不存在 —— 只需新建目录即可接入,不必改 config.toml、不必重启 containerd。

2. 目标

变更完成后应达到:

# 目标 判据
G1 镜像引用名零改动 各工程 values/manifests 中 ghcr.io/... 等一律未动
G2 5 个上游源全部覆盖 每个源各拉 1 个镜像能命中 Harbor
G3 节点接入不影响运行中负载 Pod 无重建、无 ImagePullBackOff
G4 幂等 二次执行零变更、全跳过
G5 可回滚 删除 hosts.toml 即回到直连
G6 存量镜像一次入库 58 条(54 条运行中 + 4 条 helm hook)

3. 前置条件

项 要求 实现体中的对应检查
操作机 能 root 免密 ssh 到 5 台主机;Python ≥3.9;工程 .venv 已挂载共享库 check_local
Harbor 主机 Rocky 9 / x86_64;/data 独立数据盘且可用 ≥100 G;80/443 空闲;可出网 check_os / check_data_mount / check_ports / check_upstream
Harbor 主机 执行身份 root;SELinux 状态已记录;时钟与操作机偏差 ≤5 s check_root / check_selinux / check_clock
软件源 Docker CE 源可达(默认阿里云镜像) check_docker_repo
k8s 节点 containerd active;config.toml 已配置 certs.d check_containerd
香港跳板(可选) Harbor 主机对其已配 SSH 免密;不可用时回落直连 upstream_proxy 步骤

一次性检查:

cd /home/rain/Documents/services/harbor
.venv/bin/python main.py precheck          # 只读,报告落 reports/

预期:BLOCK 0 项;报告路径打印在末行。失败判断:出现 BLOCK 项——先按其「说明」列处置,不要跳过。

4. 影响范围

项 说明
涉及主机 1 台新增(192.168.31.31,专为本项新建)+ 4 台既有 k8s 节点(.10/.13/.14/.15)
是否停服 否。Harbor 主机是新装;节点侧只新增 certs.d 文件,不改 config.toml、不重启 containerd
窗口期 节点侧无窗口要求(秒级写入);Harbor 侧从装 Docker 到服务就绪约 20 分钟(含 696 MB 离线包下载)
在跑业务 4 台节点共 64 个 Pod 持续运行;变更前后各采一次 Pod 快照比对
不可逆动作 Harbor 卸载、删除 /data/harbor;节点侧删除镜像——本 SOP 均不涉及

5. 分步操作

下列命令与实现体 utils/harbor_host.py、utils/node_access.py 等价,供阅读、复核与手工复现。 幂等、错误处理与编排逻辑由实现体保证,本节不展开。参数值以 config/default.yaml 为准。

步骤 1:系统包源换国内镜像(utils/harbor_host.py _ensure_repos)

官方 dl.rockylinux.org 本环境实测 3~4 KB/s,AppStream 的 repodata 数十 MB,dnf 会在这一步挂住十几分钟,必须换源。

坑:直接改 repo 文件容易改坏。脚本保留原文件、只把目标段的 mirrorlist 注释掉并补 baseurl;原文件带时间戳备份,可直接还原。

install -m 0755 /dev/null /etc/yum.repos.d/.keep 2>/dev/null || true
cp -a /etc/yum.repos.d/rocky.repo /etc/yum.repos.d/rocky.repo.bak.$(date +%Y%m%d-%H%M%S)
# 把 baseos/appstream/extras/crb 段的 mirrorlist 注释掉,改用:
#   baseurl=https://mirrors.aliyun.com/rockylinux/$releasever/<BaseOS|AppStream|extras|CRB>/$basearch/os/
# 并在文件末尾追加标记行: # harbor-deploy: switched to <mirror>
dnf clean metadata

预期:dnf repolist 正常,dnf makecache 在数十秒内完成。失败判断:仍卡住 → 确认 mirrors.aliyun.com 可达(curl -sI https://mirrors.aliyun.com/rockylinux/9/BaseOS/x86_64/os/repodata/repomd.xml)。

步骤 2:安装 Docker CE(_ensure_docker)

坑:前次被中断的 dnf 会留下锁与陈旧 metadata,必须先清;Docker 源也用国内镜像。

rm -f /var/cache/dnf/*/lock /var/lib/dnf/rpmdb_lock.pid
if [ ! -f /etc/yum.repos.d/docker-ce.repo ] || ! grep -q mirrors.aliyun.com /etc/yum.repos.d/docker-ce.repo; then
  dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
fi
dnf install -y --setopt=install_weak_deps=False \
  docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable docker && systemctl start docker

预期:systemctl is-active docker 输出 active。失败判断:dnf 超时 → 查看 /var/log/dnf.log 是否仍在拉 repodata。

步骤 3:Docker 守护进程配置(_ensure_docker_daemon)

data-root 落到数据盘(避免撑满系统盘);dns 必须设公共 DNS——本机 DNS 是 Clash,会把上游域名解析成 fake-ip(198.18.x.x),而 Go 对 socks5:// 走本地解析,会把 fake-ip 交给代理,导致上游永远连不通(详见步骤 6)。

坑:改完要重启 docker;容器不会自动应用新 DNS,必须重建容器(由步骤 8 的 install.sh 完成)。

cat > /etc/docker/daemon.json <<'JSON'
{
  "data-root": "/data/docker",
  "log-driver": "json-file",
  "log-opts": { "max-size": "50m", "max-file": "3" },
  "live-restore": true,
  "dns": ["223.5.5.5", "119.29.29.29"]
}
JSON
systemctl restart docker
docker info --format '{{.DockerRootDir}}'      # 预期 /data/docker

步骤 4:SELinux 上下文与防火墙(_ensure_selinux_ctx / _ensure_firewalld)

SELinux Enforcing 下容器读宿主机目录需 container_file_t 上下文;firewalld 放行 80/443,以及隧道端口 1080(来源限 docker 网段)。

dnf install -y policycoreutils-python-utils
semanage fcontext -a -t container_file_t '/data/harbor(/.*)?' || \
  semanage fcontext -m -t container_file_t '/data/harbor(/.*)?'
restorecon -R /data/harbor
firewall-cmd --permanent --add-port=80/tcp
firewall-cmd --permanent --add-port=443/tcp
firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='172.16.0.0/12' port port='1080' protocol='tcp' accept"
firewall-cmd --reload

预期:ls -Zd /data/harbor 含 container_file_t。

步骤 5:自签 CA 与服务端证书(_ensure_cert)

install -d -m 700 /data/harbor/certs && cd /data/harbor/certs
openssl genrsa -out ca.key 4096 && chmod 600 ca.key
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \
  -subj '/C=CN/O=Rainchan Internal/CN=rainchan-internal-ca' \
  -addext 'basicConstraints=critical,CA:TRUE,pathlen:0' \
  -addext 'keyUsage=critical,keyCertSign,cRLSign'
openssl genrsa -out server.key 2048 && chmod 600 server.key
openssl req -new -key server.key -out server.csr -subj '/C=CN/O=Rainchan Internal/CN=192.168.31.31'
printf 'subjectAltName=IP:192.168.31.31,DNS:harbor-01\nbasicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n' > san.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt -days 825 -sha256 -extfile san.ext
rm -f server.csr san.ext ca.srl && chmod 644 ca.crt server.crt

预期:openssl x509 -in server.crt -noout -ext subjectAltName 含 IP Address:192.168.31.31。 失败判断:SAN 缺 IP → 节点侧会校验证书失败,必须重签。

步骤 6:上游加速隧道(_ensure_upstream_proxy,可关闭)

Harbor 主机经 Clash 出口到海外源实测约 24 KB/s,首次入库不可行;经香港跳板的 SOCKS5 隧道实测 1.0~2.4 MB/s。

坑(本方案最关键的两处):

① 容器内的 127.0.0.1:1080 是容器自身,不是宿主机。所以隧道必须监听 0.0.0.0,而 harbor.yml 里的代理地址必须写宿主机可达的地址(用 LAN IP 192.168.31.31),再用 firewalld 把来源限制在 docker 网段。 ② SELinux 下 systemd 无法执行 /usr/bin/ssh(ssh_exec_t 类型,init_t 域被拒,报 status=203/EXEC),故复制一份到 /usr/local/sbin/harbor-ssh(restorecon 后为 bin_t)。

[ -f /root/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519 -q
install -m 0755 /usr/bin/ssh /usr/local/sbin/harbor-ssh && restorecon /usr/local/sbin/harbor-ssh
# 单元文件(见 utils/templates/hk_tunnel.service):
cat > /etc/systemd/system/harbor-upstream-tunnel.service <<'UNIT'
[Unit]
Description=Harbor upstream SOCKS5 tunnel via rainchen.top
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/sbin/harbor-ssh -N -D 0.0.0.0:1080 -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts -i /root/.ssh/id_ed25519 root@rainchen.top
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload && systemctl enable --now harbor-upstream-tunnel
ss -lnt | grep 1080        # 预期 0.0.0.0:1080 LISTEN

免密未配置时:把 /root/.ssh/id_ed25519.pub 追加到 rainchen.top 的 /root/.ssh/authorized_keys 后重跑步骤 6。

步骤 7:离线包落盘与校验(_ensure_package)

mkdir -p /data/harbor/pkg && cd /data/harbor/pkg
curl -fL --retry 3 -C - -o harbor-offline-installer-v2.15.2.tgz.part \
  'https://gh-proxy.com/https://github.com/goharbor/harbor/releases/download/v2.15.2/harbor-offline-installer-v2.15.2.tgz'
mv harbor-offline-installer-v2.15.2.tgz.part harbor-offline-installer-v2.15.2.tgz
sha256sum harbor-offline-installer-v2.15.2.tgz
# 预期 = 67517e0ba4a3f9db90731aa560dacbc0b24a0de04d5a1b428c7d32ea96656432

坑:gh-proxy.com 只是传输通道;来源锁定与 sha256 校验以原始 release_url 为准,校验不通过即中止,无跳过开关。

步骤 8:部署 Harbor(_ensure_harbor)

坑:harbor.yml 的 proxy.no_proxy 必须是逗号分隔字符串,写成 YAML 列表会让 prepare 抛 AttributeError: 'list' object has no attribute 'split'。口令以占位符渲染,由目标机脚本就地替换,不进命令行与日志。

# 凭据(600,脚本生成;口令不入终端)
mkdir -p /root/.secrets && chmod 700 /root/.secrets
[ -f /root/.secrets/harbor-admin.pass ] || openssl rand -base64 24 | tr -d '/+=' | head -c 24 > /root/.secrets/harbor-admin.pass
[ -f /root/.secrets/harbor-db.pass ]    || openssl rand -base64 24 | tr -d '/+=' | head -c 24 > /root/.secrets/harbor-db.pass
chmod 600 /root/.secrets/harbor-*.pass

# 解包
mkdir -p /opt/harbor && tar -xzf /data/harbor/pkg/harbor-offline-installer-v2.15.2.tgz \
  --strip-components=1 -C /opt/harbor

# 渲染 harbor.yml(模板:utils/templates/harbor_yml.tmpl,占位符就地替换)
cd /opt/harbor
PW="$(cat /root/.secrets/harbor-admin.pass)"; DBPW="$(cat /root/.secrets/harbor-db.pass)"
sed -e "s|__HARBOR_ADMIN_PASSWORD__|${PW}|" -e "s|__DB_PASSWORD__|${DBPW}|" \
  /tmp/harbor_yml.raw > harbor.yml && chmod 600 harbor.yml && unset PW DBPW

./install.sh
# 就绪判定:轮询而非固定等待
for _ in $(seq 1 60); do
  [ "$(curl -sk -o /dev/null -m 10 -w '%{http_code}' https://192.168.31.31/api/v2.0/ping)" = "200" ] && break
  sleep 5
done

预期:9 个容器 Up (healthy),/api/v2.0/ping = 200。 失败判断:容器非 healthy → cd /opt/harbor && docker compose logs --tail=50 <service>;常见原因是 SELinux 上下文(步骤 4)或 no_proxy 类型(本步骤坑)。

步骤 9:建 registry endpoint 与 proxy cache project(_ensure_projects)

# 建上游端点(凭据走 netrc,不入命令行)
curl -sk --netrc-file /root/.secrets/harbor.netrc -X POST \
  -H 'Content-Type: application/json' -d '{"name":"ghcr.io","type":"github-ghcr","url":"https://ghcr.io","insecure":false,"access_key":"","access_secret":""}' \
  https://192.168.31.31/api/v2.0/registries
# 其余 4 个:registry.k8s.io / quay.io / registry.gitlab.com 用 docker-registry;docker.io 用 docker-hub

# 建同名 proxy cache project(public)
curl -sk --netrc-file /root/.secrets/harbor.netrc -X POST \
  -H 'Content-Type: application/json' -d '{"project_name":"ghcr.io","public":true,"registry_id":1,"storage_limit":-1,"metadata":{"public":"true","proxy_speed_kb":"-1"}}' \
  https://192.168.31.31/api/v2.0/projects

坑:① POST /registries 返回 201 但响应体为空,id 必须回查列表;② Harbor 2.15 不支持 quay 适配器、gitlab 适配器本环境返回 500,二者用 docker-registry;③ registry 的 type 建后不可改,类型不符只能删除重建。

预期:GET /api/v2.0/projects?page_size=100 返回 5 个同名 project。

步骤 10:存量镜像预热(main.py warmup)

在 Harbor 主机上拉取 <harbor-ip>/<project>/<repo>:<tag>,Harbor 作为代理缓存会把 manifest 与全部层落到自身存储;拉完删除本地镜像。

坑:并发传参不能用 xargs -I@(实测会拼坏镜像名),用数组 + jobs 控并发;上游慢时靠并发补吞吐。

# 使 Harbor 主机的 docker 信任自签 CA
mkdir -p /etc/docker/certs.d/192.168.31.31
cp -f /data/harbor/certs/ca.crt /etc/docker/certs.d/192.168.31.31/ca.crt

# 逐条(清单见 config/warmup-images.txt,58 条)
docker pull 192.168.31.31/ghcr.io/cloudnative-pg/postgresql:17.6
docker rmi  192.168.31.31/ghcr.io/cloudnative-pg/postgresql:17.6

预期:Harbor 存储增长,GET /api/v2.0/projects/<project>/repositories/<repo>/artifacts/<tag> 返回 200。失败判断:返回 404 → 看 docker logs harbor-core 是否报 registry unhealthy(代理或 DNS 未生效,回到步骤 3/6)。

步骤 11:节点侧接入(utils/node_access.py,分批)

每台节点只新增 5 个 registry 的 hosts.toml + ca.crt。

坑 1:containerd 默认请求路径是 /v2/<repo>/...,不带 registry 名;须把前缀 /v2/<registry> 写进 host 的 URL,否则请求落不到同名 project,R1 不成立。

坑 2(更隐蔽,2026-10-01 实测踩到并导致 4 台节点全部失效):override_path 在 containerd 2.x 是 bool(true = 不再自动补 /v2)。写成字符串会让整份 hosts.toml 解析失败并被静默忽略 —— 文件在、containerd active、校验项全过,但接入根本没生效,节点悄悄回退直连上游。

install -d -m 755 /etc/containerd/certs.d/ghcr.io
cp /data/harbor/certs/ca.crt /etc/containerd/certs.d/ghcr.io/ca.crt   # 由操作机分发
cat > /etc/containerd/certs.d/ghcr.io/hosts.toml <<'TOML'
# 由 harbor 接入工程生成;删除本文件即回到直连(回滚点)
[host."https://192.168.31.31/v2/ghcr.io"]
  capabilities = ["pull", "resolve"]
  ca = "/etc/containerd/certs.d/ghcr.io/ca.crt"
  override_path = true
TOML
chmod 644 /etc/containerd/certs.d/ghcr.io/{hosts.toml,ca.crt}
# 其余 4 个 registry 同法,host URL 前缀换成 /v2/<对应域名>

如何判定接入真的生效(唯一有效证据):节点侧"文件存在 + containerd active"不算数。

ssh root@192.168.31.10 "journalctl -u containerd --since '5 min ago' | grep -c 'failed to decode'"   # 预期 0
ssh root@192.168.31.31 "docker logs nginx 2>&1 | grep -c '/v2/ghcr.io'"                            # 随节点拉取而增长

Harbor 入口日志(docker logs nginx)出现来自节点的 /v2/<registry>/... 请求,才证明请求真的落到了 Harbor。

预期:systemctl is-active containerd 仍为 active;/etc/containerd/config.toml 未被修改。 失败判断:containerd 异常 → 立即删除刚写入的文件(步骤 12),因为 containerd 不解析该目录时不会受影响。

步骤 12:分批与失败回滚

实现体按 batch.size=2、批内并发 2 推进,failure_threshold=1;批内失败达阈值即停止后续批次并回滚本批(删除本批写入的接入文件)。

# 本批回滚(高危)
rm -f /etc/containerd/certs.d/<registry>/hosts.toml /etc/containerd/certs.d/<registry>/ca.crt

6. 验证

cd /home/rain/Documents/services/harbor
.venv/bin/python main.py drift              # 只读漂移检测(退出码 0=无漂移 1=有漂移 2=检测失败)
.venv/bin/python main.py verify --with-rollback   # A1~A4/A6/A7 采集,报告落 reports/
项 判据
Harbor 9 容器 healthy;ping=200;5 个 project 齐备
节点 4 台各 5 份 hosts.toml + ca.crt;containerd active
幂等 二次执行 deploy-harbor / deploy-nodes 全部「状态已达标,跳过」
回滚 删除 hosts.toml 后确认不再走 Harbor;恢复后重新走 Harbor

状态:第 6 节各项已于 2026-10-01 全部实测通过(A1~A7),明细与验收方法学见 HARBOR-RESULT.md 第 6 节。

7. 回滚

层级 操作 影响
节点接入(首选) 删除 /etc/containerd/certs.d/<registry>/{hosts.toml,ca.crt} 秒级生效,回到直连,Harbor 与已有缓存不受影响
整体恢复 tar -xzf /root/harbor-nodeaccess-backup/<ts>/certs.d.tar.gz -C /etc/containerd/certs.d 恢复到改动前快照
Harbor 服务 cd /opt/harbor && docker compose down 停服务,数据保留
卸载(不可逆) 另出方案:删 /data/harbor、/opt/harbor、隧道单元;还原 repo 与 daemon.json 备份 需先评估

工程入口:main.py rollback(默认预演,--force 才真删)。

备份位置:本次变更的备份统一放在主题目录的 backup/ 下,逐条登记在 backup/README.md。远端侧另有:

  • 各节点 /root/harbor-nodeaccess-backup/<ts>/certs.d.tar.gz
  • Harbor 主机 /opt/harbor/harbor.yml.bak.<ts>、/etc/yum.repos.d/*.bak.<ts>、/etc/docker/daemon.json.bak.<ts>

8. 风险说明

风险 缓解
节点接入写坏 certs.d 导致 containerd 拉取异常 只新增文件、不动 config.toml、不重启 containerd;分批 + 失败阈值自动回滚本批;改动前整目录打包备份
Harbor 主机端口 80/443 被占 预检查 BLOCK 拦截(Harbor 自身在跑时判定为正常占用)
SELinux 阻止容器读数据盘 变更前设 container_file_t 上下文并在预检查中记录状态
上游链路慢/中断 已引入香港隧道;隧道不可用时已有缓存与内网拉取不受影响,仅首次入库回落直连;关闭方式见 RUNBOOK
容器 DNS 把上游解析成 fake-ip daemon.json 设公共 DNS,并纳入幂等判定(容器 DNS 不符即重建)
制品被替换 离线包做来源锁定(固定 release URL + 发布者 + sha256),校验失败即中止,无跳过开关
隧道端口暴露到内网 firewalld 仅放行 172.16.0.0/12 与 127.0.0.1
升级 Harbor 触发全栈重建 升级前手工备份数据库;harbor.yml 由实现体自动备份
误把 Harbor 当上游造成环 集群内自建 GitLab registry 未纳入代理(K7)

9. 附录

文件 说明
HARBOR-RESULT.md 结果回填(9 节契约)
RUNBOOK.md 日常运行、排障、凭据取用
backup/README.md 本次变更备份台账
/home/rain/Documents/services/harbor/ 实现体工程(main.py / deployer.py / utils/ / config/)
HARBOR-HANDOFF.md 原始需求规格书
PROXY-FINDINGS.md 代理速率排查(另一条线)

来源:/home/rain/Documents/k8s_ins/ghcr-slow-20261001/SOP.md | 最后更新:2026-10-01 15:25(由 build/sync.py 同步,请勿在站点侧修改)