CEP 16 - 分片 Repodata
标题 | 分片 Repodata |
状态 | 已接受 |
作者 | Bas Zalmstra <bas@prefix.dev> |
创建于 | 2024 年 4 月 30 日 |
更新于 | 2024 年 7 月 22 日 |
讨论 | https://github.com/conda-incubator/ceps/pull/75 |
实施 |
分片 Repodata
我们提出了一种新的 “repodata” 格式,可以稀疏地获取。这意味着,通常情况下,更小的获取量(只获取你需要的)和更快地更新现有的 repodata(只获取已更改的内容)。
动机
当前的 repodata 格式是一个 JSON 文件,其中包含给定通道中的所有软件包。不幸的是,这意味着它会随着通道中软件包数量的增加而增长。对于像 conda-forge 这样拥有超过 150,000+ 个软件包的大型通道来说,这是一个问题。获取、解析和更新 repodata 变得非常缓慢。
设计目标
- 速度:获取 repodata 必须非常快。无论是在热缓存还是冷缓存的情况下。
- 易于更新:当有新的软件包可用时,通道必须非常容易更新。
- CDN 友好:必须可以使用 CDN 来缓存大部分数据。这降低了通道的运营成本。
- 支持身份验证和授权:必须可以以很少的额外开销来实现身份验证和授权。
- 易于实施:必须相对简单地实施,以方便在不同的工具中采用。
- 客户端可缓存:如果用户有热缓存,则用户应该只需要下载小的增量更改。最好尽可能减少与服务器的通信,以检查数据的新鲜度。
- 带宽优化:任何传输的数据都应该尽可能小。
先前的工作
JLAP
在之前提出的 CEP 中,引入了 JLAP。使用 JLAP,只需要下载对初始下载的 repodata.json
文件的更改,这意味着用户可以大幅节省带宽,从而使获取 repodata 速度更快。
然而,在实践中,修补原始 repodata 可能是一个非常昂贵的操作,无论是在内存方面还是在计算方面,因为涉及的数据量很大。
JLAP 也不能节省任何冷缓存,因为仍然必须下载初始 repodata。CI 运行器通常就是这种情况。
最后,JLAP 的实施非常复杂,这使得实施者难以采用。
ZSTD 压缩
一个值得注意的改进是用 zst
压缩 repodata.json
并提供该文件。在实践中,这会产生一个只有原始大小 20% 的文件(大型案例为 20-30 Mb)。虽然这仍然是一个相当大的文件,但它已经小了很多。
但是,该文件仍然包含通道中的所有 repodata。这意味着每次有人添加单个软件包时(即使用户不需要该软件包),都需要重新下载该文件。
由于文件相对较大,这意味着通常为缓存使用较大的 max-age
,这意味着新软件包需要更长的时间才能在生态系统中传播。
提案
我们提出了一种 “分片” repodata 格式。它的工作原理是将 repodata 分割成多个文件(每个软件包名称一个文件)并递归地获取 “分片”。
分片按其内容的哈希值存储(例如 “内容可寻址”)。这意味着分片的 URL 源自分片的内容。这允许在客户端上高效缓存和重复数据删除分片。由于文件是内容可寻址的,因此无需往返服务器即可检查各个分片的新鲜度。
此外,索引文件存储从软件包名称到分片哈希值的映射。
虽然不是明确要求的,但服务器应支持 HTTP/2,以减少执行大量请求的开销。
Repodata 分片索引
分片索引是一个存储在 <subdir>/repodata_shards.msgpack.zst
下的文件。它是一个 zstandard 压缩的 msgpack
文件,其中包含从软件包名称到分片哈希值的映射。
内容如下所示(以 JSON 格式编写以提高可读性)
{
"version": 1,
"info": {
"base_url": "https://example.com/channel/subdir/",
"shards_base_url": "./shards/",
"created_at": "2022-01-01T00:00:00Z",
"...": "other metadata"
},
"shards": {
// note that the hashes are stored as binary data (hex encoding just for visualization)
"python": b"ad2c69dfa11125300530f5b390aa0f7536d1f566a6edc8823fd72f9aa33c4910",
"numpy": b"27ea8f80237eefcb6c587fb3764529620aefb37b9a9d3143dce5d6ba4667583d"
"...": "other packages"
}
}
shards_base_url
是分片的基本 URL。base_url
和 shards_base_url
都可以是绝对 URL 或相对 URL。URL 相对于分片索引。
索引仍然定期更新,但该文件的大小不会随着每次添加软件包而增加,只有在添加新的软件包名称时才会增加,这种情况发生的频率要低得多。
对于大型案例(conda-forge linux-64),在撰写本文时,该文件为 670kb。
我们建议使用有效期较短的 Cache-Control
max-age
标头(60 秒到 1 小时)来提供该文件,但我们将其留给通道管理员来设置适合该通道的值。
Repodata 分片
单个分片存储在 URL <shards_base_url><sha256>.msgpack.zst
下。其中 sha256
是来自索引的字节的 小写十六进制表示形式,shards_base_url
在分片索引中定义。它是一个 zstandard 压缩的 msgpack 文件,其中包含软件包的元数据。
这些文件是内容可寻址的,这使得它们非常适合通过 CDN 提供服务。它们应使用 Cache-Control: immutable
标头提供服务。
分片包含原本会在 repodata.json
文件中找到的 repodata 信息。它是一个包含以下键的字典
示例(以 JSON 格式编写以提高可读性)
{
// dictionary of .tar.bz2 files
"packages": {
"rich-10.15.2-pyhd8ed1ab_1.tar.bz2": {
"build": "pyhd8ed1ab_1",
"build_number": 1,
"depends": [
"colorama >=0.4.0,<0.5.0",
"commonmark >=0.9.0,<0.10.0",
"dataclasses >=0.7,<0.9",
"pygments >=2.6.0,<3.0.0",
"python >=3.6.2",
"typing_extensions >=3.7.4,<5.0.0"
],
"license": "MIT",
"license_family": "MIT",
"md5": "2456071b5d040cba000f72ced5c72032",
"name": "rich",
"noarch": "python",
"sha256": "a38347390191fd3e60b17204f2f6470a013ec8753e1c2e8c9a892683f59c3e40",
"size": 153963,
"subdir": "noarch",
"timestamp": 1638891318904,
"version": "10.15.2"
}
},
// dictionary of .conda files
"packages.conda": {
"rich-13.7.1-pyhd8ed1ab_0.conda": {
"build": "pyhd8ed1ab_0",
"build_number": 0,
"depends": [
"markdown-it-py >=2.2.0",
"pygments >=2.13.0,<3.0.0",
"python >=3.7.0",
"typing_extensions >=4.0.0,<5.0.0"
],
"license": "MIT",
"license_family": "MIT",
"md5": "ba445bf767ae6f0d959ff2b40c20912b",
"name": "rich",
"noarch": "python",
"sha256": "2b26d58aa59e46f933c3126367348651b0dab6e0bf88014e857415bb184a4667",
"size": 184347,
"subdir": "noarch",
"timestamp": 1709150578093,
"version": "13.7.1"
}
},
// list of strings of keys (filenames) that were removed from either packages or packages.conda
"removed": [
"rich-10.15.1-pyhd8ed1ab_1.tar.bz2"
]
}
原始 repodata 字段中的 sha256
和 md5
从其十六进制表示形式转换为字节。这样做是为了减小分片的总体文件大小。
实施者应忽略未知键,这允许将来在格式中添加其他键,而不会破坏旧版本的工具。
虽然这些文件可能会变得相对较大(数百千字节),但通常对于大型案例(conda-forge),这些文件仍然非常小,例如 100 多字节到几千字节。
获取过程
要获取所有需要的软件包记录,客户端应实施以下步骤
- 获取
repodata_shards.msgpack.zst
文件。标准 HTTP 缓存语义可以应用于此文件。 - 对于每个软件包名称,开始从索引文件中获取相应的哈希值(对于 arch 和 noarch)。分片可以本地缓存,并且由于它们是内容可寻址的,因此无需额外的往返服务器来检查新鲜度。服务器还应使用
immutable
Cache-Control
标头标记这些分片。 - 解析获取的记录的要求,并将要求的软件包名称添加到要获取的软件包集中。
- 循环回到 1. 直到没有新的软件包名称要获取。
垃圾回收
为避免缓存无限增长,我们建议实施一种垃圾回收机制,以删除索引文件中没有条目的分片。服务器应将旧分片保留一段时间(例如 1 周),以便具有较旧分片索引数据的客户端可以获取以前的版本。
在客户端,应每隔一段时间运行垃圾回收过程,以从缓存中删除旧分片。这可以通过将缓存的分片与索引文件进行比较并删除不再引用的分片来完成。
被拒绝的想法
SHA 哈希压缩
SHA 哈希是不可压缩的,因为在压缩器看来,它只是随机数据。我们研究过使用二进制前缀树来实现更好的压缩,但这大大增加了实施的复杂性,这与我们保持简单的目标相冲突。
更短的 SHA 哈希
另一种方法是仅存储 SHA 哈希的前 100 个字节左右。这大大减小了 sha 哈希的总大小,但它使客户端实施更加复杂,因为哈希冲突成为一个问题。
这也使得将来基于 OCI 注册表的实施更加困难,因为 OCI 注册表中的层也通过 SHA256 哈希引用。
将数据存储为结构数组
为了提高压缩率,我们研究了将索引文件存储为结构数组而不是结构数组
[
{
"name": "",
"hash": "",
},
{
"name": "",
"hash": "",
}
]
与
{
"names": [...],
"hashes": [...]
}
这确实产生了稍微好一点的压缩率,但我们认为这使得实施和将来采用稍微困难一些,我们认为这不值得为了一点点尺寸减小而牺牲。
未来的改进
身份验证
使用这种方法,客户端可以同时执行数百个请求。对所有这些请求进行身份验证将给每个请求带来不可忽略的开销,并且还需要更多的服务器资源。最初,与 OCI 注册表令牌类似的一次性身份验证流程已添加到 CEP 中,但为了缩小 CEP 的范围,已将其删除。将引入另一个 CEP 来修改此 CEP,并提供高性能的身份验证流程。
删除冗余键
可以删除 platform
和 arch
,因为可以从 subdir
推断出这些信息。
集成其他数据
随着 repodata 的总大小减小,直接将其他字段添加到 repodata 记录中变得可行。示例包括
- 添加
purl
作为字符串列表(软件包 URL,用于引用软件包的原始来源)(参见:https://github.com/conda-incubator/ceps/pull/63) - 添加
run_exports
作为字符串列表(构建软件包所需的 run-exports)(参见:https://github.com/conda-incubator/ceps/pull/51)
更新优化
我们可以实施对较小的索引更新文件的支持。这可以通过创建每日和每周滚动索引更新文件来完成,这些文件可以代替获取整个 repodata_shards.msgpack.zst
文件。更新操作非常简单(只需使用新条目更新哈希映射)。
为此,我们建议添加以下两个文件
<channel>/<subdir>/repodata_shards_daily.msgpack.zst
<channel>/<subdir>/repodata_shards_weekly.msgpack.zst
它们将包含与 repodata_shards.msgpack.zst
文件相同的格式,但仅包含在过去一天或一周内更新的软件包。null
用于已删除的键。索引文件中的 created_at
字段可用于确定要获取的文件,以确保客户端拥有最新的信息。
在分片的开头或标头中存储 set(dependencies)
为了减少解析分片和开始获取其依赖项所需的时间,我们还可以在文件开头或单独的标头中存储所有依赖项的集合。这可以实现在仍解析记录的同时获取递归依赖项。