Caching & CDNs with micro-frontends

Alex O'Callaghan

微前端中的缓存与 CDN

原文由 Alex O'Callaghan 发布,订阅该博客

在微前端架构中处理缓存要比在单体前端中复杂得多。系统中包含外壳应用、多个远程清单以及它们所引用的代码分片,每一部分的发布节奏不同,对内容过期程度的容忍度也不一样。本文将介绍我们在 Mintel 是如何处理这个问题的,过去遇到过哪些故障,以及目前仍未完全解决的难点。

更新于 2026 年 6 月:我后续又写了一篇关于优化微前端缓存策略的文章,介绍了我们为提升性能、降低带宽成本所做的改动。

我们的技术栈

我们使用 Webpack Module Federation 运行着约 30 个微前端。外壳应用是一个纯静态的 Jamstack 应用,部署在 S3 上,通过 CloudFront 提供服务,最外层则由 Akamai 统一代理。各个远程应用都位于固定且众所周知的 URL 上。

UserAkamaiCloudFrontPython servicesDjango MPAS3 bucketMFE routesAPI routesMPA routes

部署只需一条 rclone 命令,即可将构建好的 dist 目录复制到 S3 上每个微前端对应的子目录中。外壳应用和各个远程应用都是独立部署的,由不同的团队分别维护。

如何为不同类型的资源配置缓存

不同资源的缓存需求各不相同。下面是我们目前的配置及原因。

index.html —— no-cache

外壳应用的 index.html 负责启动整个应用。一旦它过期,后续的一切都可能出错。我们通过 S3 对象元数据为它设置 Cache-Control: no-cache 响应头,这一过程在部署时自动完成。

no-cache 并不意味着文件不会被缓存。它的意思是 CDN 或浏览器在提供缓存内容前,必须先向源站进行校验。如果源站返回 304,则使用缓存副本;如果内容已发生变化,则返回最新的文件。

remoteEntry.js —— 永不缓存

remoteEntry.js 是 Module Federation 的清单文件。它告诉外壳应用去哪里加载某个远程应用的代码分片。当你部署一个远程应用时,这个文件的内容会变,但文件名保持不变。

过期的 remoteEntry.js 会导致两种故障。一种是显性的错误:如果它指向的是上一次部署产生的、已被替换的代码分片,就会出现运行时错误。另一种则更为隐蔽:用户迟迟拿不到新版本的应用。远程团队明明已经发布了修复或新功能,但由于清单文件过期,用户仍在运行旧代码。

在 Akamai 层,我们为 remoteEntry.js 设置了 Cache-Control: no-store, max-age=0,从而阻止浏览器缓存它。我们还通过 module-federation-import-remote 这个包实现了动态远程加载,它默认会在 remoteEntry.js 的 URL 后追加一个用于破坏缓存的查询参数。由于我们的 CloudFront 分发会将查询字符串纳入缓存键,这就确保了即使文件在 CloudFront 上已被缓存,每次请求也会因为 URL 唯一而绕过缓存,直接从 S3 获取最新版本。

代码分片(Chunks)—— 不设置显式缓存头

remoteEntry.js 所引用的 JS 代码分片通过 Webpack 的 contenthash 占位符实现了内容寻址。当文件内容发生变化时,哈希值会改变,文件名也随之改变。这意味着你可以对这些分片进行激进的缓存——新版本部署时会产生全新的文件名,CDN 会自动将其视为全新资源。

output.filename 中配置这一点非常简单:

output: {
  filename: '[name].[contenthash].js',
}

我们也遇到过团队忘记为远程应用的资源文件名配置 contenthash 的情况。分片以可预测的文件名部署后被缓存,后续的部署直到缓存 TTL 自然过期后才对用户生效。

目前我们没有为代码分片设置显式的 Cache-Control 头。CloudFront 会根据 S3 返回的 Last-ModifiedETag 头推算出一个启发式 TTL 来缓存这些文件。在 Akamai 层,我们对源站响应设置了 no-store 策略,因此所有的缓存实际上只发生在浏览器或 CloudFront 上。

404 处理

我们的外壳应用是一个单页应用,路由在客户端完成。如果用户直接访问某个路由或刷新页面,CDN 会在 S3 上查找对应路径的文件,找不到时默认会返回 404。

解决方法是在 CloudFront 中配置:当源站返回 4xx 时,改回 index.html。AWS 在 CloudFront 分发设置中将这一功能称为自定义错误响应

Akamai 会将所有不匹配已知 API 或 MPA 路由的请求都代理到 CloudFront,因此这一回退逻辑在 CloudFront 层处理,并对所有微前端路由生效。

用户请求流程

远程应用采用懒加载,通过 React.lazy 和动态 import 包装。只有当用户导航到需要某个远程应用的页面时,外壳应用才会去拉取对应的 remoteEntry.js

UserBrowser cacheAkamaiCloudFrontS3GET /shell/index.htmlindex.htmlGET /remote-a/remoteEntry.js?t=1234remoteEntry.jsGET /remote-a/chunk.abc123.jschunk.abc123.jsStore chunk.abc123.jsGET /remote-a/chunk.xyz789.jsServed from browser cacheNew chunks are fetched; previously loaded chunks can be served from the browser cache.

CloudFront / S3 故障

上述架构是随着时间逐步演进而来的,一定程度上也是对线上故障的回应。2025 年 1 月,AWS 出现的一次故障导致我们的 CloudFront 源站无法从 S3 拉取内容,返回了 404 NoSuchBucket 错误。由于 CloudFront 已配置为对来自 S3 的 4xx 响应返回 index.html——这是单页应用常见的兜底配置——这些错误在到达 Akamai 之前就被转换成了 200 响应。Akamai 无法感知到异常,照常将其缓存。等到 AWS 恢复后,用户仍会从 Akamai 边缘节点和本地浏览器缓存中拿到这些错误的缓存响应。

在 Akamai 上做缓存清除既慢又痛苦。你无法通过通配符批量清除匹配某个模式的所有路径,必须提供具体的 URL。面对数十个微前端和数百个 JS 分片文件,在紧急情况下这根本不现实。我们最后只能进入紧急作战室,手忙脚乱地提交清除请求,看着请求一直在加载,缓存则在接下来几个小时里随着 TTL 自然过期才慢慢失效。而且,这对那些已在浏览器中缓存了错误响应的用户也无济于事。

我们最后想到的应急办法,是在几个核心微前端的 Webpack 配置中修改 contenthash 的长度,然后重新部署。改变 contenthash 长度会让所有生成的文件名都发生变化,从而迫使 CDN 将其视为全新资源,而不是继续提供缓存的错误响应。这个办法奏效了,但它是在高压下临时想出来的,并不是预案文档中的标准操作。

自那以后,我们禁用了 Akamai 对微前端资源的缓存,并为 S3 存储桶增加了多地域容灾,以降低再次陷入同样困境的风险。我们也开始为 index.html 显式设置 no-cache,确保变更能被快速感知,错误的回退响应也不会被长期缓存。

坦白说,对于“如果再次出现错误响应被缓存该怎么办”这个问题,我们目前仍然没有一个干净利落的解决方案。修改 contenthash 长度这一技巧,依然是我们需要在短时间内全量失效时的“核选项”,用它来强制生成全新的文件名。

改进缓存策略

写这篇文章的过程,也是对现有缓存策略的一次有益梳理。当系统运行正常时,缓存配置往往不会被经常回顾,而目前这套方案在实践中也基本够用。

不过,我们的缓存配置分散在多处:index.html 依赖 S3 对象元数据,remoteEntry.js 依赖 Akamai 规则,而其他资源则完全没有显式的缓存头。没有一个地方能完整地看到全貌。我们目前在最外层的 Akamai 上完全不做缓存,这是对之前故障痛苦经历的强烈反应,但这也损害了性能并增加了带宽成本。

更清晰的做法是,在源站通过 S3 对象元数据为每种资源类型显式设置 Cache-Control 头,并将各层 CDN 视为遵循源站头信息的缓存,而不是定义缓存策略的地方。这样一来,即便将来替换或重新配置 Akamai 层,缓存行为也会跟随源站,而不会被悄然丢失。

我们目前完全没有为代码分片设置缓存头,只能依赖 CDN 和浏览器的启发式策略来决定缓存时长。为带 contenthash 的分片显式设置 Cache-Control: max-age=31536000, immutable,并让 Akamai 重新遵循源站缓存头,将是一个不错的改进,能确保它们作为不可变资源被积极且正确地缓存——但以我们现有的架构,无法保证每个团队都已正确地将构建产物的文件名配置为使用 contenthash。不过,倒是存在另一种能同时解决这两个问题的方案,只是它需要更大幅度的架构调整。

另一种方案:带版本的 URL 与发现服务

以上所有方案都假设远程应用位于固定且众所周知的 URL 上。这是最简单的部署模型,但也正是 remoteEntry.js 难以缓存的根本原因——因为当你在原地覆写同一个文件时,你永远无法安全地对其进行长期缓存。

更稳健的做法是在 URL 本身中加入版本信息:

https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js

使用带版本的路径后,remoteEntry.js 就和其它代码分片一样成为了内容寻址的文件。你可以用 max-age=31536000, immutable 来缓存它。旧版本会一直在 S3 中保留,因此正在使用中的用户不会因为一次部署而中断。回滚也只需将清单指向之前的版本,而无需重新部署。

要实现这一点,外壳应用就不能硬编码远程应用的 URL。你需要一个发现服务——外壳在启动时会调用它来获取每个远程应用的当前 URL:

{
  "remote-a": "https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js",
  "remote-b": "https://cdn.example.com/remote-b/v2.1.0/remoteEntry.js"
}

微前端发现服务

不过,我们目前并没有采用这种方式,尽管一年多前我们就已经识别出这一模式,并将其作为可能的未来方向写入了架构蓝图。它固然能兜住那些忘记配置 contenthash 的团队,但这本质上是规范执行的问题,并不足以成为迁移 30 个微前端的理由。在每一层 CDN 上做更积极的缓存也确实有助于提升性能、降低带宽成本,尽管如果不深入分析缓存命中率和带宽成本,很难量化其具体影响;而且通过现有方案为分片设置 immutable 头,也能达到类似的效果。

引入发现服务还能支持更复杂的部署模式,比如金丝雀发布或在部署层面的功能开关,但这都会增加复杂度和运维负担。大多数情况下,我们能够直接在应用逻辑内部实现功能开关,这样更容易弄清楚用户实际运行的是哪部分代码。金丝雀发布要真正发挥作用,也需要在自动化监控和告警策略上投入时间。

虽然发现服务仍是一个潜在的未来选项,但我们更近期的重点,还是在现有相对简单的部署模式下,持续改进当前的缓存策略。

本文章由 muse-spark-1.2-contributor 进行翻译

评论