blog-website

背景

近期,公司代理侧的流量负担持续上升;在高峰期访问常见开发资源(包管理器、SDK、依赖镜像、CDN 文件等)会出现明显抖动。为降低重复下载带来的外网带宽开销与峰值波动,决定在内网引入一套“通用下载缓存代理”。

为了让使用侧尽量接近“零配置”,整体链路采用“内网 DNS 指向 + HTTPS 入口统一管理 + 缓存层只处理 HTTP + 回源保持 TLS”的组合方式:

flowchart LR
  Client[开发机/CI] -->|信任企业 CA| CA[企业 CA 证书]
  Client -->|域名解析指向| DNS[内网 DNS 覆盖]

  DNS --> Caddy[Caddy
  反向代理入口
  终止 TLS/管理证书]
  Client -->|HTTPS| Caddy
  Caddy -->|HTTP| Squid[Squid
  反代缓存 accel]
  Squid -->|HTTPS 回源| Origin[源站/CDN/仓库]

下文按“共性问题 -> 原因 -> 配置落地”的方式,复盘这套方案的结构设计与关键取舍,并总结实践中遇到的典型问题与对应的解决策略。

0. 目标与约束

1. 结构设计

1.1 数据流设计(HTTPS 入口 + HTTP 缓存 + TLS 回源)

核心思路是把“HTTPS 解密”与“缓存”拆开:

flowchart LR
    Client[客户端/CI] -->|HTTPS| Caddy["Caddy: 443<br />SSL终止"]
    Caddy -->|HTTP| Squid["Squid: 3128<br />反代缓存"]
    Squid -->|TLS via cache_peer| Origin[源站/仓库/CDN]
    Squid <-->|store_id_program| Rewriter[Store ID Rewriter]

1.2 配置与代码分层(可扩展、可审计、默认安全)

这套方案把“共性能力”与“域名/业务规则”拆成三层:

一个重要的“安全阀”是:conf.d 的最后通常放一个默认拒绝(例如 store_id_access deny all),确保新增域名不会因为漏配而误重写。

1.3 目录结构(为什么要“模块化”)

与其把所有规则塞进一个超长的 squid.conf,这套配置选择了“主配置 + 模块目录 + 脚本规则”的组合。这样做的直接收益是:新增/下线某类资源时,只需要增删一个模块文件,变更边界清晰。

squid/
├── squid.Dockerfile          # 容器镜像(安装 squid + supervisor)
├── supervisor/               # 容器内进程编排:初始化 + 主进程 + 日志轮转
├── etc/
│   ├── squid.conf            # 基线配置(端口/ACL/缓存参数/日志/include)
│   └── conf.d/               # 规则模块(按域名集合拆分)
│       ├── 00-unreal-engine.conf   # Unreal Engine CDN
│       ├── 10-github.conf          # GitHub 资产
│       ├── 20-cdn.conf             # 主流 CDN (jsDelivr, cdnjs, etc.)
│       ├── 30-microsoft.conf       # 微软下载 (VS, VS Code, Windows Update)
│       ├── 35-unity.conf           # Unity 下载
│       ├── 40-golang.conf          # Golang 模块代理
│       ├── 45-maven.conf           # Maven/Gradle 仓库
│       ├── 50-python.conf          # Python/PyPi仓库
│       ├── 55-nodejs.conf          # NodeJs/yarn仓库
│       └── 99-deny-store-id.conf   # 默认拒绝 store_id
└── script/
    ├── store_id_rewriter.py  # Store ID 重写入口
    └── domains/              # 域名/版本匹配规则(正则聚合)
        ├── __init__.py
        ├── github.py
        ├── cdn.py
        ├── microsoft.py
        ├── unity.py
        ├── unreal_engine.py
        ├── golang.py
        ├── maven.py
        ├── python.py
        └── nodejs.py

2. 共性问题与解决方案(以及配置如何落地)

2.1 问题:HTTPS 资源不好缓存

原因:正向代理想缓存 HTTPS 往往需要解密流量;而不做 MITM 就拿不到明文。

解决:把 HTTPS 放在入口终止,缓存层只处理 HTTP;回源仍然保持 TLS。

配置体现(概念示例,避免逐行照抄):

这使得缓存层既不需要 MITM,又能对 HTTPS 资源进行“可控缓存”。

补充一个很容易忽略的坑:DNS 回环/污染。如果代理节点的 DNS 把某些源站解析回了内网入口,可能导致请求在 Caddy/Squid 之间打转。

因此主配置会显式指定公共 DNS 服务器,目的是让回源解析更可预测、避免“自己代理自己”。

2.2 问题:URL 动态参数导致缓存永远 MISS

现象:下载链路经常带签名/追踪参数(典型是云存储签名链接)。同一个文件内容,每次 URL 不同,缓存键不同,自然命中率很低。

根因:缓存键通常包含完整 URL(含 query),而 query 往往不影响内容,甚至是纯鉴权参数。

解决:启用 store_id_program,用脚本生成“内容等价”的 Store ID。

这套脚本的策略可以抽象成三段式(比“对某域名就删参数”更安全):

  1. 明确排除:先用排除规则拦截“不应该被规整”的 URL(例如快照/临时版本)。
  2. 安全剥离:对“内容由 path 唯一确定”的 URL,剥离 query/fragment。
  3. 条件剥离:对 CDN 等场景,仅在 path 中出现“明确版本号”时才剥离;否则保持原 URL,让缓存短周期自更新。

这样做的核心收益是:提高命中率的同时,尽量避免把“会变的东西”缓存成“不会变”。

快速自测(不依赖运行 Squid)

# 期望:剥离签名参数 -> OK store-id=...
echo "https://release-assets.githubusercontent.com/xxx?X-Amz-Algorithm=AWS4" | python3 /opt/squid/script/store_id_rewriter.py

# 期望:带版本号 -> OK
echo "https://cdn.jsdelivr.net/npm/vue@3.2.0/dist/vue.js" | python3 /opt/squid/script/store_id_rewriter.py

# 期望:@latest -> ERR(不重写)
echo "https://cdn.jsdelivr.net/npm/vue@latest/dist/vue.js" | python3 /opt/squid/script/store_id_rewriter.py

2.3 问题:同域名混合“下载内容”和“API/元数据”

现象:同一个生态会同时存在:

解决:用 ACL 做“语义分流”,然后分别配置缓存与重写策略。

典型落地方式:

这比“对整个域名一刀切”更稳:既能提升命中率,又不容易引入“更新不及时”的副作用。

2.4 问题:源站 Cache-Control 过于保守,导致代理不敢缓存

原因:不少分发站点会返回 no-store/private/must-revalidate 等头,目的是让客户端尽量拿到最新内容,但对“版本化大文件”来说会严重浪费带宽。

解决:在 refresh_pattern 上使用“有限度的强制策略”,并把范围控制在“可判定稳定”的下载内容上。

一个典型模式是:

# 仅作为模式演示:对“下载类”放宽缓存指令
refresh_pattern -i download\.example\.com 10080 100% 43200 \
  override-expire ignore-reload ignore-no-store ignore-private ignore-must-revalidate \
  ignore-no-cache store-stale

同时,主配置里常会配合“过期窗口”来优化体验:允许在一定时间内直接命中过期缓存、后台再验证,减少用户等待与回源抖动。

2.5 问题:大文件与并发下载放大回源压力

现象:CI/多客户端并发拉取同一资源时,容易出现“回源风暴”(多个 MISS 同时打到源站)。

解决

2.6 问题:上游识别“你是代理”导致风控/限速

原因:某些源站会根据 ViaX-Forwarded-For 等头判断代理行为。

解决:在反代缓存场景下,尽量减少额外头部暴露:关闭 via,删除/禁止部分转发头。

这属于“可用性增强”,但仍建议只在受控环境中使用,并配合 ACL 限制访问来源。

2.7 问题:容器里既要初始化又要长期运行,还要做日志轮转

原因

解决:使用 Supervisor 作为容器内的“最小进程编排器”:

在实现上通常还会配合 Squid 自身的 logfile_rotate(例如保留 5 份),避免轮转文件无限增长。

stateDiagram-v2
    [*] --> Init: 容器启动
    Init --> Squid: 初始化完成
    Squid --> [*]: 容器停止

    state LogRotate {
        [*] --> Check
        Check --> Rotate: 超阈值
        Rotate --> Check
        Check --> Check: 未超阈值
    }

    Squid --> LogRotate: 并行运行

2.8 问题:同域名里的“配置/元数据”端点触发频繁刷新(TCP_REFRESH_UNMODIFIED)

现象:访问日志中出现类似下面的记录:

TCP_REFRESH_UNMODIFIED/200 ... GET http://public-cdn.cloud.unity3d.com/config/production

解读:客户端看到的响应仍是 200,但 Squid 的状态 TCP_REFRESH_UNMODIFIED 表示“对象已过期并触发再验证(revalidate),确认上游内容未变化后继续返回本地缓存”。

原因(共性规律,不限定 Unity):

影响:该类端点的“短周期再验证”会表现为日志噪音与额外的上游请求;如果与“长 TTL / 强制缓存 / Store ID 规整”混用,还可能放大不必要的验证流量。

解决方案(与本仓库配置一致):通过“允许短窗口内返回过期缓存 + 后台再验证更新”的方式,把 revalidate 的等待成本从客户端路径上移除。

落地通常分两层:

配置对应关系:

# etc/squid.conf
# 允许返回过期缓存的时间窗口 (秒)
refresh_stale_hit 86400 seconds
# etc/conf.d/35-unity.conf
# store-stale: 验证时先返回过期缓存,后台更新
refresh_pattern -i public-cdn\.cloud\.unity3d\.com 10080 100% 43200 \
    override-expire ignore-reload ignore-no-store ignore-private ignore-must-revalidate ignore-no-cache store-stale

这样处理后,日志中依然可能出现 TCP_REFRESH_UNMODIFIED(因为 Squid 仍在做验证以保持一致性),但对客户端而言更接近“stale-while-revalidate”:优先拿到可用结果,同时缓存会在后台被更新。

可选优化:如果确实确认某些 path(如 /config/)属于高频变更元数据,且不希望其继承“下载域名”的长缓存策略,再单独做 path 分流、缩短 TTL,并避免纳入 Store ID 规整会更稳。

2.9 问题:系统 CA 正常,但 Yarn/Node 仍报证书不受信任

现象:浏览器访问 HTTPS 正常,系统也安装了 CA 证书,但 yarn install 仍然报错(证书链不受信任)。

原因

解决方案:显式把额外 CA 证书注入 Node 的信任链。

推荐做法是设置环境变量 NODE_EXTRA_CA_CERTS 指向你的自定义 CA PEM 文件(例如公司/自建代理的根证书):

# Linux/macOS
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/my-proxy-ca.pem

# Windows PowerShell(示例)
$env:NODE_EXTRA_CA_CERTS = "C:\\path\\to\\my-proxy-ca.pem"

yarn install

把它归为“共性问题”的原因是:只要遇到“工具 A(浏览器)没问题、工具 B(语言包管理器/CLI)报证书问题”,优先怀疑它使用了不同的信任源;对 Node 生态,NODE_EXTRA_CA_CERTS 往往是最直接、侵入性最小的修复手段。

2.10 问题:NuGet 元数据请求出现大量 TCP_MISS_ABORTED

现象:访问日志中出现大量类似记录,集中在 api.nuget.org 的 registration 索引(JSON 元数据)请求:

TCP_MISS_ABORTED/000 0 GET http://api.nuget.org/v3/registration5-gz-semver2/.../index.json - HIER_NONE/- -
TCP_MISS_ABORTED/000 0 GET http://api.nuget.org/v3/registration5-gz-semver2/.../index.json - FIRSTUP_PARENT/<ip> -
TCP_MISS/200 ... GET http://api.nuget.org/v3/registration5-gz-semver2/.../index.json - FIRSTUP_PARENT/<ip> application/json

其中:

原因

影响

建议

2.11 问题:cache.log 出现 Vary object loop(Accept-Encoding 变体循环)

现象cache.log 里出现类似日志,并且集中在某些元数据/配置类端点:

varyEvaluateMatch: Oops. Not a Vary match on second attempt, 'http://public-cdn.cloud.unity3d.com/config/production' 'accept-encoding="gzip, deflate, br, zstd"'
clientProcessHit: Vary object loop!

原因

影响

建议(按侵入性从低到高):

配置示例(对应本仓库的 setup-router/squid/etc/Caddyfile.example 思路):

(squid_proxy_unity_public_cdn) {
  encode zstd gzip

  @unity_public_cdn_config path /config*
  handle @unity_public_cdn_config {
    reverse_proxy squid:3128 {
      header_up Host {host}
      header_up Accept-Encoding "gzip"
    }
  }

  handle {
    reverse_proxy squid:3128 {
      header_up Host {host}
      header_up Accept-Encoding "identity"
    }
  }
}

public-cdn.cloud.unity3d.com {
  import squid_proxy_unity_public_cdn
}

3. 使用方式

我们的实施配置开源并放在了 https://github.com/owent/docker-setup/tree/main/setup-router/squid 。 有兴趣的小伙伴可以自取。

3.1 容器方式(Podman/Docker)

容器方式的关键是把“配置/脚本”以只读挂载,把“缓存/日志”以可写挂载:

podman build -f squid.Dockerfile -t squid-cache .

# 注意这里只挂载squid.conf文件和conf.d目录,因为 /etc/squid 下有其他文件,不能掩盖,否则会启动失败。
podman run -d \
  --name squid \
  -p 3128:3128 \
  -v /path/to/squid/etc/squid.conf:/etc/squid/squid.conf:ro \
  -v /path/to/squid/etc/conf.d:/etc/squid/conf.d:ro \
  -v /path/to/squid/script:/opt/squid/script:ro \
  -v /path/to/squid/cache:/var/spool/squid \
  -v /path/to/squid/logs:/var/log/squid \
  squid-cache

3.2 手动安装方式(不使用容器)

手动安装的核心流程是:放置配置与脚本、初始化缓存目录、验证配置、再启动/重载。

cp -r etc/squid.conf /etc/squid/
cp -r etc/conf.d /etc/squid/

mkdir -p /opt/squid/script
cp -r script/* /opt/squid/script/
chmod +x /opt/squid/script/store_id_rewriter.py

mkdir -p /var/spool/squid
chown squid:squid /var/spool/squid

squid -z
squid -k parse
squid -k reconfigure

3.3 验证与观测(命中率与空间)

我们可以把“能不能用”和“有没有命中”分开验证:

tail -f /var/log/squid/access.log | grep -E "HIT|MISS|REVALIDATE"
du -sh /var/spool/squid/
ls -la /var/spool/squid/swap.state

如果需要查看缓存目录信息,可查询 Squid 内建管理接口:

curl -s "http://127.0.0.1:3128/squid-internal-mgr/storedir"

日志轮转可以人工触发(用于验证 supervisor/配置是否生效):

podman exec squid squid -k rotate

4. 如何扩展:新增一个“下载域名 + API 域名”的模块

新增支持的通用步骤是:

  1. etc/conf.d/ 新增一个模块文件:
    • 定义下载与 API 的 ACL。
    • 为每个域名配置 cache_peercache_peer_access
    • store_id_access 只允许下载流量进入重写。
    • refresh_pattern 给下载与 API 不同的缓存周期。
  2. 如需 Store ID 重写:在 script/domains/ 添加正则规则,并在 __init__.py 聚合导出。

这样扩展的好处是:规则可审计、可回滚,且默认不会误把未知域名纳入重写。

5. 风险和边界

最后

欢迎有兴趣的小伙伴们互相交流。