Featured image of post AI 驱动的 VPS 容器镜像拉取加速:本地 LLM 调度私有镜像仓库 + Pull Cache,带宽省 87%

AI 驱动的 VPS 容器镜像拉取加速:本地 LLM 调度私有镜像仓库 + Pull Cache,带宽省 87%

多节点 VPS 反复拉同一镜像、公共 registry 高峰期超时、出站带宽全花在 127MB 的 image 上——本文介绍如何用本地 LLM(Ollama + Qwen2.5)自动判断每个镜像的「热度」与「时效性」,调度私有 registry mirror + 分层 pull cache + 预取策略,让多节点部署秒级拉取、出站带宽省 87% 以上,全程零数据出域

引言

容器化部署是 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 GB0.9 GB仅 cold 镜像 + 新 digest 首次回源
单次发版拉取 P95142 s6 s内网 1Gbps,blob 本地已有
发版端到端时长18 min4 min预取 30min 前完成,节点 pull 秒级
带宽月费¥86¥12按量付费 1.2GB 内网不计费
LLM 推理成本00全本地,无 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 可以后面再叠,两者不冲突。

总结

这套方案的核心不是「再堆一个组件」,而是把镜像拉取这个被长期低估的出站流量大户,变成可观测、可判断、可自动化的环节:

  1. 内网 pull-through mirror 解决「重复拉取」,出站字节直接降 87%;
  2. 本地 LLM 解决「该锁哪个版本、该预取哪个」,全程零数据出域;
  3. 策略执行器幂等 + 可回滚,出问题 5 分钟内恢复;
  4. 带宽周报自动推送 Telegram,成本变化有数字可追。

零数据出域,全部本地推理,出站带宽省 87%,发版快 4 倍。


selfvps.net — 专注自托管与云成本优化实战,每周一篇,持续更新。

📺 看视频版教程 → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials