01项目概述
keepix.cloud是一个媒体托管平台,用于处理需要服务他人文件的产品:商店中的目录照片、市场中的评论图片、应用中的演示视频。您通过一个签名请求发送原始文件,答案中已经包含了完成的地址——图像的命名WebP变体集,视频的HLS阶梯。这些文件从客户自己的子域名提供服务,因此读者看到的是他们的品牌,而不是我们。
前提是媒体不是您每个项目中构建的一项功能。每个允许人们上传内容的产品最终都会重复编写相同的内容:调整大小、格式转换、命名方案、缓存策略、元数据剥离、删除与此人相关的所有内容的路径。第五次编写时,它又是糟糕的——而出错的部分(泄露的GPS标签、结果是脚本的SVG)会静默失败,没有错误提示,也没有日志记录。
因此,我们围绕一个想法构建了这个服务:地址由服务声明并由客户存储,绝不在客户端计算。这个单一的决定消除了媒体集成中最常见的故障方式——一个页面完美渲染,但产品照片的位置却是个空洞。
我们设计并构建了整个产品:输入管道、变体模型、视频阶梯、具有自己域名和签名密钥的多租户模型、计量计费、仪表板、公共API合同和文档。以下是它解决的问题、如何构建以及我们会以相同方式做出的工程决策。
02背景和问题
存储上传的图片很简单。但在负载下,保持多年来正确的媒体路径,跨多个产品和其他人的文件,这并不容易。当我们拆解这个任务时,陷阱很快就显现出来。
- 任意宽度是一个陷阱。像这样的 URL
?w=473看起来灵活,实际上是两个问题:一个缓存永远不会预热,因为每个页面请求的数字略有不同,以及任何人都可以通过连续请求一千个宽度来占用处理器的免费方式。 - 计算出的地址会静默中断。规则 "id → shard → path",嵌入在客户端代码中,直到另一端第一次布局更改时才会生效。然后页面仍然渲染,产品的照片位置只是一个空洞——没有错误,日志中没有任何内容。
- 元数据是一个没人注意到的泄漏。某人家中的GPS坐标出现在评论照片的EXIF中,不会引发错误,不会破坏页面,也不会被监控。只有当有人查看时,这才算是泄漏。
- SVG 不是图片。它是可执行的标记。作为图像被接受并从客户端自己的域提供时,内部的脚本代表该域运行。
- 删除必须是可证明的。GDPR删除请求通过标记行已删除而字节仍保留在磁盘上的回应,是一次未发生的删除。
- 视频是一个集合,而不是一个文件。一次上传必须变成一个可播放的阶梯、一个海报帧和一个后备——而这些都不能在接受文件的请求中发生。
- 账单在小额时必须诚实。一个千兆字节的成本可以低于一美分;四舍五入到整美分会消耗掉整个计费账单,使发票变成虚构。
还有一个要求,它塑造了整个设计:该服务必须完全不连接互联网。一个媒体主机代表客户从第三方地址获取图像,知道是谁请求了什么,并将这些信息交给它所使用的任何CDN。keepix 不知道任何第三方地址,这是一种代码属性,而不是定价页面上的承诺。
03项目目标
这个任务变成了我们在每个阶段都坚持的目标清单:
- 一个请求进来,完成的地址输出。客户发送原始请求,立即获得要存储的地址——无需第二次调用,无需等待图像处理完成。
- 一个命名的、有限的变体集合。三种图像宽度和四种视频形式,按文件类型声明,以便缓存保持热,处理器不需要外包。
- 客户自己的域名。 从
img.theirdomain,服务在地址栏中不可见。 - 可以永久保存的缓存。 地址携带内容的哈希值,因此替换文件会更改其地址并立即到达读者。
- 路径中内置的安全性。 类型由解析确定,病毒扫描拒绝而不是放行,每个图像重新编码,元数据被剥离,SVG在两端都被拒绝。
- 可证明的删除。 客户自己的一个请求
owner_ref,并且一个答案说明了有多少对象和文件被转移。 - 具有真实隔离的多租户。拥有自己的域名、自己的种类和变体、自己的上限,两个签名密钥以便于轮换而不造成停机。
- 可以检查的计量计费。存储按平均值而非峰值计算,流量按实际传输的字节计算,操作逐一计数——在仪表板中按天细分。
- 一个承认其操作的API。每个答案都说明了创建了什么和跳过了什么,并且有一个干燥的对账端点用于检查批量迁移。
04我们做了什么
keepix被分成几个清晰的部分,每个部分都有一个任务:
- 摄入。一个签名的端点接受原始数据及其类型、密钥和所需变体列表。图像会在同一响应中返回准备好的;视频进入队列,响应中会说明这一点。
- 处理管道。类型检测、病毒扫描、重新编码、元数据剥离、变体构建——以固定顺序进行,无法在每个请求中跳过。
- 交付。一个独立的主机,没有会话,没有JavaScript和
Set-Cookie,直接从磁盘提供文件,具有一年期的不可变缓存。 - 租户API。使用HMAC-SHA256签名,具有解析地址、批量对账、导出库存、删除对象和清除属于某人的所有内容的端点。
- 仪表板。文件夹、拖放、按标题、描述和标签搜索——以及按天细分的使用情况。它在没有JavaScript的情况下完全可用;脚本增加了便利性,而不是工作能力。
- 账单。套餐加上计量超额,按百分之一美分计算,停止接收而不是在免费计划上默默花费钱。
- 库存。一个市场层,租户可以在这里买卖自己的作品,共享相同的存储和交付路径。
以下内容是对这些部分内部决策的更深入了解——那些以后更改会很昂贵的决策。
05解决方案架构
该服务有三个空间,物理上分开的入口点,而这种分离是整个设计的支柱:
- 公共交付 — 完全没有 API。网络服务器在磁盘上查找文件并提供服务。未找到意味着 404,而不是“动态生成”。该路径上没有可执行代码,这就是交付主机能够承载的原因
default-src 'none'; sandbox并且意味着这一点。 - 租户的私有 API。每个请求都经过签名;签名涵盖方法、路径及其查询字符串、时间戳和主体的哈希。签署路径而不包含查询将留下
种类和键无符号 — 可交换,没人会注意到。 - 管理员 API用于控制面板,具有自己的入口点和凭据。
答案始终是 JSON,且始终是英语,每个拒绝代码只有一种措辞 — 因此相同的失败在每个客户端的日志中读取相同,无论他们的产品使用什么语言。
内部,存储分为一个冷树保存原件,永远不会向外提供,而是提供变体的树。保留原件使得一年后无需客户再次上传任何内容就能创建新变体成为可能 — 这也是为什么“我们不提供您发送的文件”可以成为绝对规则而不是通常的原因。
06文件的生命周期
文件经过固定的顺序,顺序是关键:
- 类型是通过解析确定的 — 从内容中,而不是从扩展名或客户端所说的内容中。输出文件的扩展名由服务来分配。
- 病毒扫描运行,并且可以拒绝。如果扫描仪无法访问,输入会回答
503而不是让文件通过。在检查未完成时静默通过文件是对已完成工作的谎言,而这种谎言往往会在很久之后才浮出水面。 - 图像被重新编码。到达的文件中没有一个字节会被重新提供。
- 元数据在重新编码中不会保留。EXIF和GPS因为路径的构建方式而消失,而不是因为上传者记得去除它们。
- 变体已构建并且它们的地址在同一答案中返回。
SVG既不被接受也不被提供 — 无论是进入时还是退出时。检查会运行两次,分别针对声明的内容类型和文件的前几个字节,因为对于SVG,类型嗅探器通常只会说“文本”。提供的格式包括WebP、JPEG、PNG、MP4、HLS播放列表及其片段;其他所有内容都是一个415.
输入是幂等的:相同的sha256在相同的密钥下不会重新执行工作,并返回相同的地址。因此,在连接中断后重试是免费的,这在文件大小为两GB且网络为手机时尤为重要。
07命名变体,而非任意宽度
图像有三个命名宽度 — 缩略图 400, 卡片 1000, 完整 1400,所有 WebP 格式,质量为 82 — 你可以通过名称请求一个变体。这个集合是有限的,并且根据文件类型声明。这是整个服务所依赖的决策,它消除了一个问题类别,而不是一个错误。
有两个规则决定何时变体可以合法地不出现,这两个规则的存在是为了避免对读者撒谎:
- 没有任何东西被拉伸。如果原始图像的宽度小于变体的宽度,变体将保持原始的大小,答案会诚实地说明这一点 —
w和h字段携带真实的数字。放大的副本看起来像是服务的错误,而实际上确实是。 - 相同的副本不会被存储两次。如果两个宽度会产生完全相同的文件,则不会创建第二个地址。在两个地址上有两个字节相同的文件意味着在某人的手机上下载了同样的东西。
这意味着答案必须被读取,而不是假设:你可能请求两个变体却只得到一个,并附有一个跳过的块来解释原因。我们将其设定为一个正常的、预期的答案,而不是一个边缘案例,因为针对乐观形状编写的集成在用户上传的第一个小图像上会失败。
稍后添加新变体不需要重新上传任何内容:它是从冷树中的原始文件构建的。这就是保留原始文件的全部原因。
08视频:一个 HLS 梯子,一个海报和一个预览
一个视频上传变成了一组,因为这正是播放器实际需要的:一个HLS 梯形最多三个质量级别(640、1280 和 1920 像素),一个海报在持续时间的 10% 处截取,一个静音的预览剪辑用于悬停和自动播放的前几秒,以及一个渐进的MP4作为旧客户端的后备方案。
没有比原始宽度更大的步骤,原因与不拉伸图像相同:放大复制品看起来像是服务的错误。海报是在持续时间的十分之一处截取,而不是在第一帧,因为真实视频的第一帧通常是黑色的。
视频处理不会在接受文件的请求内部发生——它会进入一个队列,响应中会说明这一点。接收响应诚实地说明了哪些部分现在已准备好,哪些部分仍在等待,因此客户端可以存储已有内容并稍后再请求,而不是在转码过程中阻塞用户的上传界面。
09您自己的域名和永久缓存
文件从租户自己的子域名提供——img.theirdomain——租户通过 A 记录将其指向服务。读者看到的是他们的品牌和地址,而根本看不到 keepix。在免费计划中,域名是共享的;两条路径的工作方式相同。
租户可以连接任意数量的域名,但只能有一个规范的一个,且这一限制是故意的:如果有两个规范域名,一个文件会有两个地址,这意味着两个缓存,以及相同的字节在同一个读者的手机上下载两次。交付头对于每个租户都是相同的,并且不会改变:一年期的
交付头对于每个租户都是相同的,并且不会更改:一年期的 不可变的 缓存,nosniff, default-src 'none'; sandbox,一个宽松的CORS策略用于读取,而不是单个 Set-Cookie。永久缓存之所以安全,正是因为 地址携带内容的哈希值:更改字节,地址也会随之更改,因此替换的图像会立即到达读者,而不是等待TTL。
这正是为什么地址不能在客户端计算的原因。它由服务返回,存储在它所属的记录旁边,并通过 resolve 如果它丢失的话再次请求。
10接收:类型,病毒扫描,元数据
服务上的每个内容都是某个人发送的文件——评论中的照片、演示视频。接收规则是为了他们的利益而制定的,而不是为了形式的需要,每个文件的存在都是因为其他选择悄然失败。
通过解析而不是通过扩展来确定类型并不是迂腐:扩展是上传者完全控制的唯一事物。而不是通过扩展,这并不是迂腐:扩展是上传者完全控制的唯一事物。当扫描器故障时拒绝会影响可用性,并确保“已扫描”意味着已扫描。重新编码每个图像意味着精心制作的文件永远无法以其原始形式到达读者。在重新编码过程中剥离元数据意味着它无法被遗忘。
在交付方面,保证是结构性的而非程序性的。交付主机没有会话,没有JavaScript,也没有任何可执行代码;内容以内容安全策略的形式发送,这将阻止浏览器运行任何内容,即使有危险的内容进入。两个独立的层,因为有趣的失败是第一层配置错误而无人注意的情况。
交付日志,包含读者地址,存在于24小时。服务中没有任何东西需要它更长时间,这使得保留更长时间只是对其他人痕迹的简单积累。
11抹去关于一个人的所有信息
每个对象都携带一个owner_ref——无论租户称该文件的所有者为何。对于目录图像,它是空的:它们与个人无关。对于评论照片和用户视频,这是必需的。
一个请求会删除其下的所有内容,而答案命名一个数字:有多少对象和多少文件被处理。这个数字是端点的关键。个人数据网关应该检查执行,而不是意图——一行标记为删除而字节仍然在磁盘上的情况是一个未发生的删除,直到有人查看它,它将继续未发生。
擦除同时删除记录和字节,而不是按计划进行,并且它会带走早期版本的延迟副本。这也是使keepix可用于具有自身GDPR义务的产品的部分:客户端代码中的删除流程可以内联调用它并读取计数,而不是相信某个地方的清理工作会处理它。
签名密钥的工作方式相同。一个租户始终在使用两个密钥,因为使用一个密钥轮换意味着停机——而停机是轮换被无限期推迟的原因。密钥在发放时只显示一次,永远不会交还给控制面板:能够签署请求是写入他人存储的权利。
12签名 API 和对账
每个私有请求携带四个头部——租户、密钥ID、时间戳和签名——签名是HMAC-SHA256基于方法、带查询的完整路径、时间戳和主体的哈希。窗口为300秒。在同一秒内重复的相同签名请求将被拒绝为重放,这是保护按预期工作,合同也明确说明,而不是让集成商去发现它。
端点集很小,每个端点都回答一个真实的操作问题:
放置— 接受一个原始文件;放置变体— 接受一个已经完成的副本而不重新编码,以便转移客户已经拥有的内容。重新编码一个完成的副本意味着毫无意义地失去质量。resolve和解决批次(最多1000个键) — 这个键对应的地址是什么。这是一个修复工具:如果记录丢失,你询问而不是猜测。对账— 一次最多检查1000条记录的干检查,不会改变任何内容,并回答好的/缺失/不匹配/待处理每条记录加上批次的摘要。库存— 进行反向比较的导出,服务的列表与客户端的列表进行比较,按键本身分页,以便在接受过程中页面不会滑动。对象,擦除,配额,ping,以及一个未签名的healthz,它表示前端是活着的,仅此而已——没有版本,没有数据库状态。
调和值得单独提及,因为它是证明整个合同合理性的端点。在一次大规模迁移后,唯一重要的问题是:一切都通过了吗?逐个文件回答这个问题意味着请求的数量与文件数量相同,因此没有人这样做——然后迁移在上传脚本没有崩溃的基础上被宣布完成。批量调和使得诚实的答案变得足够便宜,实际上可以获得。
13仪表板、使用情况和账单
仪表板是一个完全没有代码的库:文件夹、用鼠标拖动文件、按标题、描述和标签搜索。它可以完全不依赖JavaScript——脚本增加了便利性,而不是工作能力。这不是怀旧;这是确保面板在糟糕连接下仍然可用,并且在没有浏览器引擎的情况下仍然可测试的最便宜的方法。
使用量从月初开始计算,并按天细分:每个日期的存储量、交付流量和操作数量,包裹消耗了多少,以及额外的内容。细分比总数更重要——无法归因于某一天的账单是无法争辩的账单。
账单中的三个决策值得提及:
- 存储是整个期间的平均值,而不是峰值。单一天的大量上传不应影响整个月的费用。
- 交付流量是实际交付的字节数,取自服务器日志——而不是"文件大小 × 请求次数",这会夸大每个范围请求和每个放弃的下载。
- 费率以百分之一美分为单位。一个千兆字节的费用可能低于一美分;四舍五入到整美分会吞噬整个计费账单,并悄悄地将发票变成虚构。
在免费计划中根本没有计量账单:流量停止,而不是默默开始花费客户的钱。一个自行决定向从未输入过卡片的人收费的服务,是一个没人推荐两次的服务。
14技术栈
我们选择了一个服务的技术栈,其热路径是一个处理文件的网络服务器:
- PHP 8.4 用于 API 和控制面板——成熟、在这个路径上足够快且运行成本低,这对有免费层的产品很重要。
- 一个专用的交付主机 直接从磁盘提供服务,完全没有应用代码在请求路径中。
- WebP 用于固定质量的图像,H.264 + AAC 在 HLS 用于视频,具有渐进式 MP4 回退。
- 一个队列用于视频工作,因此转码从不在上传请求中。
- HMAC-SHA256每个租户使用两个实时密钥进行请求签名,并有一个300秒的窗口。
- 一个冷树的原始文件在服务路径之外,这使得后续生成新变体和重建成为可能。
该服务在其依赖项上故意采取保守态度。它不进行任何外部请求,不使用第三方CDN,并且不保存关于客户端用户的记录,除了租户、密钥、类型、字节计数和变体。你没有的每一个依赖项都是一个不会在星期二破坏你的媒体的依赖项。
15设计和用户体验
公共网站只有一个任务:使基础设施产品对将实际集成它的工程师可读。因此,第一个屏幕显示真实的请求和真实的答案——以JSON格式返回的地址——而不是它们的抽象。集成者大约在三十秒内决定媒体服务是否适合他们的代码,他们通过查看其响应形状来做出决定。
网站的其余部分以相同的方式编写。每个功能都与其约束一起陈述:命名变体 和为什么拒绝任意宽度;一个永久缓存和地址中的哈希使其安全;一个擦除端点和它回答的数字。仅列出服务可以做什么的文档让读者在生产中发现边界。
在仪表板中,同样的原则转化为无JavaScript的基线和逐日使用情况细分:屏幕上没有读者无法检查的内容,也没有在脚本加载失败时停止工作的内容。
16我们的工作方式
项目分阶段进行,每个阶段结束时都有可演示的内容:
- 先签合同。我们在实施之前编写了API合同——签名方案、端点、拒绝代码、响应形状——因为合同是客户构建的部分,也是更改成本高的部分。
- 输入和变体。管道以固定顺序进行,内置了不拉伸和不存储重复项的两个规则。
- 交付。单独的主机、设置的头部、使永久缓存安全的内容哈希地址。
- 视频。梯子、海报和预览,以及不断从上传请求中转码的队列。
- 多租户。域、种类、变体、上限,以及双密钥轮换模型。
- 计费和仪表板。按天计量的计数,然后是其上的库用户界面。
- 真实的迁移。我们将一个实时目录迁移到服务上,并分批进行对账——这就是对账端点形成其形状的原因。
最后阶段的价值超过了任何内部测试。移动数十万真实文件发现了测试套件无法发现的问题:服务接受并丢失的对象、存储地址漂移的记录,以及验证哈希的检查与仅确认行存在的检查之间的区别。一个没有任何可验证内容的验证报告成功——因此我们让弱模式说明它处于哪个模式。
17结果
最终得到的是一个产品可以依赖的媒体主机:一个请求进来,完成的地址出来,从客户端域提供,缓存可以永久保留。
- 命名变体和HLS阶梯,一次构建并在内容哈希地址后永久缓存。
- 客户自己的子域名:读者看到的是他们的品牌,而不是我们的。
- 安全内置于输入路径 — 解析类型,拒绝病毒扫描,重新编码,剥离元数据,SVG在两端都被拒绝。
- 以数字回应的擦除,以便个人数据网关可以检查执行而不是意图。
- 多租户,拥有自己的域名、上限和无停机时间的双密钥轮换。
- 按百分之一美分计费,按天细分,以及一个停止而不是消费的免费计划。
18要点
keepix是一个大部分工程投入于决定服务内容的案例拒绝做的事情。没有任意宽度,没有计算地址,没有SVG,没有出站请求,没有在服务路径上保留原始字节,没有在免费计划上计费。每一个都消除了一个完整的失败类别,而不是处理一个。
我们将带到下一个基础设施项目的主要教训:让诚实的答案变得容易获取。按批次进行和解,按天分解的使用情况,一个返回计数的擦除,一个命名跳过内容的接收响应——在每种情况下,替代方案不是错误答案,而是根本没有答案,而没有答案是团队默默接受的。
如果您需要在自己的域名下提供媒体服务,一个必须安全处理他人上传文件的文件管道,或者一个合同比界面更重要的API优先产品,这就是我们从头到尾所做的工作。



