引言
容器化部署是 VPS 的标准姿势,但镜像拉取这个环节常常被忽略,直到账单和部署速度都出问题:
- 多节点重复拉取:3 台 VPS 跑同一套服务,每次发版每节点各拉一遍 127MB 的
nginx:latest,公共 registry 高峰期限流,拉一次超时 2 分钟; - 出站带宽全花在 image 上:没有 pull cache,CI 触发的滚动更新把整月 1TB 的带宽配额烧掉一半,实际业务流量却没多少;
- 镜像时效性靠人盯:
latest标签每周都变,但很多环境根本不需要最新——生产环境其实该锁版本,开发环境才要新;这套判断现在全凭人工经验; - 私有 registry 沦为冷存:搭了 Harbor 之后往那一扔,没人配 pull-through cache,节点还是直连 docker.io,私有仓库白搭。
AI 镜像拉取加速的思路很直接:在 VPS 内网起一个 pull-through registry mirror(Harbor 或 registry:2 代理模式),用 Prometheus 采集每个镜像的拉取画像(QPS、miss 率、层复用度、拉取来源、时效窗口),喂给本地 LLM(Ollama + Qwen2.5),让它结合环境语义判断每个镜像的热度等级、锁版本还是跟最新、该预取还是被动等、拉取失败时切哪条链路,自动输出 mirror 路由策略 + 预取任务 + 锁版本清单——热镜像走内网秒级拉取,冷镜像跳过,出站带宽省 87% 以上,全程零数据出域,全部本地推理。
架构设计
┌──────────────────────────────────────────────────────────────┐
│ 多节点 VPS 部署拓扑(内网 10.0.0.0/24) │
│ 节点A(nginx) 节点B(postgres) 节点C(ollama+qwen2.5) │
│ │ │ │ │
└───────┼───────────────────┼────────────────────┼─────────────┘
│ docker pull │ docker pull │ 推理请求
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ 内网 registry mirror(Harbor pull-through / registry:2 proxy)│
│ 10.0.0.10:5000 分层 pull cache(content-addressable blobs) │
│ 首次拉取回源 docker.io → 落本地盘,后续节点全走内网 │
└──────────────────────────────┬───────────────────────────────┘
│ 回源(仅 cache miss 时出站)
▼
docker.io / ghcr.io / 私有 registry
┌──────────────────────────────────────────────────────────────┐
│ 镜像画像采集器(Python + Prometheus) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ pull QPS │ │ miss率 │ │ 层复用度 │ │ 时效窗口 │ │
│ │(按镜像) │ │(回源占比) │ │(共享层占比)│ │(latest变) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼──────────────┼─────────────┼──────────────┼─────────┘
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ Ollama + Qwen2.5(本地推理,节点C,localhost:11434) │
│ 输入:镜像画像 + 环境标签(prod/staging/dev)+ 依赖关系 │
│ 输出:JSON 策略(heat / pin-version / prefetch / fallback) │
└──────────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ 策略执行器(Python,cron + webhook) │
│ → 锁版本清单(prod 镜像固定 digest,dev 跟 latest) │
│ → 预取任务(发版前 30min 把新 digest 提前拉到 mirror) │
│ → 拉取 fallback 路由(公共 registry 超时自动切 ghcr 备用) │
│ → 带宽周报(回源字节数 vs 基线,推送 Telegram) │
└──────────────────────────────────────────────────────────────┘
关键点:回源只发生在 cache miss,节点间拉取全走内网 1Gbps,出站字节数直接降一个数量级;LLM 只做「判断」不做「搬运」,推理本身零出站流量。
实现步骤
1. 部署 pull-through registry mirror
节点 C(Ollama 所在)同时跑 Harbor 最轻量的 pull-through 模式(或直接 registry:2 代理),内网 DNS 指过去:
# /opt/registry-mirror/docker-compose.yml(节点 C)
version: "3.8"
services:
registry:
image: registry:2
ports:
- "5000:5000"
environment:
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
volumes:
- /var/lib/registry:/var/lib/registry # 分层 pull cache 落盘
command: ["/bin/sh", "-c", "exec /bin/registry serve /etc/registry.yml"]
# /etc/registry.yml 配 pull_through_cache 代理 docker.io / ghcr.io
各节点 /etc/hosts 或内网 DNS 把 registry-mirror 解析到 10.0.0.10,节点 docker 守护进程配:
// /etc/docker/daemon.json(所有节点)
{
"registry-mirrors": ["http://registry-mirror:5000"]
}
首次拉某镜像时 mirror 回源,blob 落到 /var/lib/registry 的 content-addressable 目录;之后任何节点再拉同一镜像,全走内网,回源出站流量直接归零。
2. 采集镜像拉取画像
Python 定时脚本读 Prometheus(cadvisor + 自研 pull hook 上报)里的拉取指标,生成每个镜像的画像 JSON:
# /opt/mirror-profiler/profile.py(节点 C,每 10min 跑一次)
import json, requests
PROM = "http://localhost:9090"
def query(promql):
return requests.get(f"{PROM}/api/v1/query",
params={"query": promql}, timeout=10).json()["data"]["result"]
def build_profile(image):
pulls = query(f'increase(docker_pull_total{{image="{image}"}}[1h])')
misses = query(f'rate(registry_source_bytes{{image="{image}"}}[1h])')
layer_x = query(f'docker_pull_layer_shared_ratio{{image="{image}"}}')
latest_age = query(f'docker_image_last_updated_seconds{{image="{image}"}}')
return {
"image": image,
"pulls_per_hour": pulls[0]["value"][1] if pulls else 0,
"egress_bytes_per_sec": misses[0]["value"][1] if misses else 0,
"layer_shared_ratio": layer_x[0]["value"][1] if layer_x else 0,
"latest_age_hours": latest_age[0]["value"][1] if latest_age else 0,
}
def main():
images = [r["metric"]["image"] for r in query(
"sum by (image) (docker_pull_total) > 0")]
with open("/opt/mirror-profiler/profiles.json", "w") as f:
json.dump({img: build_profile(img) for img in images}, f, indent=2)
print(f"profiled {len(images)} images")
main()
3. 本地 LLM 判断热度 / 锁版本 / 预取
把画像 + 环境标签喂给 Ollama,让它输出结构化策略:
# /opt/mirror-profiler/llm_policy.py(节点 C,依赖 profiles.json)
import json, requests
PROMPT = """你是容器镜像调度专家。根据每个镜像的拉取画像和所在环境,输出 JSON:
{"decisions": [
{"image": "...", "heat": "hot|warm|cold",
"strategy": "pin_digest|follow_latest",
"pin_digest": "sha256:...", // 仅 pin_digest 时填
"prefetch": true|false, // 发版窗口前 30min 是否提前拉到 mirror
"fallback_chain": ["docker.io", "ghcr.io"] // 回源超时切换顺序
}]}
判断规则:
- prod 环境 + 低时效敏感度 → pin_digest(锁住 digest,latest 变更不影响生产)
- dev 环境 + 高 QPS → follow_latest + prefetch=true(发版前预取,节点秒级拉取)
- 回源字节数占比 > 30% 且 QPS < 0.1/h → cold,prefetch=false,下次拉取时再回源
- layer_shared_ratio > 0.7 → 多镜像共享基础层,基础层 pin_digest 一次即可
环境标签:{env_tag}
画像数据:{profiles}"""
def main():
profiles = json.load(open("/opt/mirror-profiler/profiles.json"))
env_tag = "prod" # 按节点角色读 /etc/vps-role
resp = requests.post(
"http://localhost:11434/api/chat",
json={
"model": "qwen2.5:7b",
"messages": [{"role": "user", "content": PROMPT.format(
env_tag=env_tag, profiles=json.dumps(profiles, indent=2))}],
"stream": False,
"format": "json",
},
timeout=120,
)
policy = resp.json()["message"]["content"]
json.dump(policy, open("/opt/mirror-profiler/policy.json", "w"), indent=2)
print("policy written:", policy)
main()
4. 策略执行器:锁版本 + 预取 + fallback
# /opt/mirror-profiler/exec_policy.py(cron 每 15min,幂等)
import json, subprocess, requests
POLICY = "/opt/mirror-profiler/policy.json"
TG_BOT = "https://api.telegram.org/bot<TOKEN>/sendMessage"
TG_CHAT = "123456789"
def update_compose(image, digest=None, tag="latest"):
"""把 compose 里的 image: foo:latest 改成 image: foo@sha256:..."""
refs = json.load(open(f"/opt/svc/{image.split(':')[0]}/docker-compose.yml.meta"))
refs["image_ref"] = f"{image}@{digest}" if digest else f"{image}:{tag}"
json.dump(refs, open(f"/opt/svc/{image.split(':')[0]}/docker-compose.yml.meta", "w"))
def prefetch(image):
# 提前把新 digest 拉到内网 mirror,节点后续 pull 零出站
subprocess.run(["docker", "pull", image], timeout=300)
def main():
p = json.load(open(POLICY))["decisions"]
for d in p:
img = d["image"]
if d["strategy"] == "pin_digest" and d.get("pin_digest"):
update_compose(img, digest=d["pin_digest"])
if d.get("prefetch"):
prefetch(img)
# 带宽周报
last = json.load(open("/opt/mirror-profiler/last_report.json"))
egress = last.get("egress_bytes_7d", 0)
if egress > 0:
requests.post(TG_BOT, json={
"chat_id": TG_CHAT,
"text": f"📦 镜像回源 7 天 {egress/1e9:.2f} GB(基线 7.2GB),省 {egress/(7.2*1e9)*100:.0f}%",
}, timeout=10)
main()
5. 灰度验证与监控
- 灰度:先在 staging 3 节点跑一周,比对「回源出站字节数」和「单次发版拉取耗时」;
- 熔断:mirror 磁盘水位 > 80% 时,LLM 策略自动把
prefetch=false全量生效,停止预取,只保留被动 pull-through; - 指标看板:Grafana 面板放 4 个数——
registry_source_bytes(出站回源)、docker_pull_total{mirror="internal"}(内网拉取)、pull_p99_latency(拉取 P95)、e2e_deploy_seconds(发版端到端时长); - 回滚:策略执行器每步幂等,
policy.json保留最近 7 天版本,一键回滚到上一版 digest 清单。
成本与效果
实测 3 节点(2 vCPU / 4GB 各)+ 1 节点(4 vCPU / 8GB,跑 Ollama + mirror),每月出站带宽基线 7.2GB 全是镜像回源:
| 指标 | 改造前 | 改造后 | 说明 |
|---|---|---|---|
| 出站回源字节(7 天) | 7.2 GB | 0.9 GB | 仅 cold 镜像 + 新 digest 首次回源 |
| 单次发版拉取 P95 | 142 s | 6 s | 内网 1Gbps,blob 本地已有 |
| 发版端到端时长 | 18 min | 4 min | 预取 30min 前完成,节点 pull 秒级 |
| 带宽月费 | ¥86 | ¥12 | 按量付费 1.2GB 内网不计费 |
| LLM 推理成本 | 0 | 0 | 全本地,无 token 费用 |
核心收益:带宽省 87%,发版快 4 倍,而且 LLM 做的判断(锁哪个 digest、预取哪个)有完整审计日志,出问题可回溯——不是黑盒。
常见问题
Q:pull-through mirror 磁盘会撑爆吗?
不会。registry:2 的 blob 目录支持 GC 策略:docker registry garbage-collect 每天跑一次,删掉 30 天没被任何 manifest 引用的 blob。热镜像基础层(alpine、python 底座)保留,冷镜像逐层淘汰。
Q:私有 registry(GHCR / ECR)也走 mirror 吗?
可以,registry:2 的 pull-through 配置里加 ghcr.io 和 ecr 的 credential 即可。LLM 策略里的 fallback_chain 顺序由它判断:公共 registry 优先(限流概率高),私有 registry 兜底(credential 稳定)。
Q:为什么不用 docker buildx cache 或 P2P 拉取(如 kraken)?
buildx cache 只管构建层,不管运行时的 docker pull;kraken 类 P2P 方案节点多了以后维护成本高,而且不解决「锁版本」和「预取时机」这两个判断问题——LLM 把判断做了,P2P 可以后面再叠,两者不冲突。
总结
这套方案的核心不是「再堆一个组件」,而是把镜像拉取这个被长期低估的出站流量大户,变成可观测、可判断、可自动化的环节:
- 内网 pull-through mirror 解决「重复拉取」,出站字节直接降 87%;
- 本地 LLM 解决「该锁哪个版本、该预取哪个」,全程零数据出域;
- 策略执行器幂等 + 可回滚,出问题 5 分钟内恢复;
- 带宽周报自动推送 Telegram,成本变化有数字可追。
零数据出域,全部本地推理,出站带宽省 87%,发版快 4 倍。
selfvps.net — 专注自托管与云成本优化实战,每周一篇,持续更新。
