Caching & CDNs with micro-frontends

Alex O'Callaghan

使用微前端进行缓存与 CDN

微前端(micro-frontend)架构中的缓存比单体前端更为复杂。系统包含一个壳应用、多个远程清单及其所引用的代码块,而它们各自的部署频率不同,对数据陈旧的容忍度也不同。本文介绍我们在 Mintel 的实践、过去遇到的问题,以及目前尚未完全解决的部分。

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

我们的技术栈

我们使用 Webpack Module Federation 运行约 30 个微前端。壳应用是一个纯静态的 Jamstack 应用,部署到 S3,通过 CloudFront 提供服务,最外层则由 Akamai 置于所有服务之前。各远程应用位于固定且明确的 URL。

UserAkamaiCloudFrontPython servicesDjango MPAS3 bucketMFE routesAPI routesMPA routes

部署就是执行一条 rclone 命令,将构建后的 dist 目录复制到 S3 中每个 MFE 对应的特定子目录。壳应用和每个远程应用都是独立部署的,由不同团队分别维护。

我们如何配置各类资源

不同资源有不同的缓存要求。下面介绍我们目前的设置及其原因。

index.html - 不缓存

壳应用的 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,以阻止浏览器缓存它。我们还通过 dynamic remote loading(动态远程加载),使用 module-federation-import-remote 包,默认向 remoteEntry.js URL 添加缓存清除查询参数。由于我们的 CloudFront 分配将查询字符串纳入缓存键,这样即使文件被 CloudFront 缓存,每次请求也会获得唯一 URL,从而绕过缓存并从 S3 获取最新版本。

代码块 - 不设置显式标头

remoteEntry.js 所引用的 JavaScript 代码块通过 Webpack 的 contenthash 替换机制,使用基于内容寻址(content-addressed)的方式命名。当文件内容发生变化时,哈希值会变化,文件名也会随之变化。这意味着你可以积极缓存代码块,因为新的部署会生成新的文件名,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 层处理,并适用于所有 MFE 路由。

用户请求流程

远程应用采用延迟加载方式,通过 React.lazy 和动态导入进行封装。只有当用户导航到需要某个远程应用的位置时,壳应用才会获取该远程应用的 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,这是标准的 SPA 全捕获设置,这些错误在到达 Akamai 之前被转换成了 200 响应。Akamai 无法得知发生了任何问题,于是照常缓存这些响应。AWS 恢复后,用户仍然会从 Akamai 边缘节点和自己的浏览器缓存中获取这些已缓存的错误响应。

在 Akamai 中清除缓存既慢又麻烦。你无法使用通配符指定路径并清除所有匹配某个模式的内容,而必须提供具体 URL。面对几十个 MFE 和数百个 JavaScript 代码块文件,这在压力之下并不现实。我们最终进入了一个应急作战室,匆忙提交一批清除请求;这些请求一直处于加载状态,而缓存则随着 TTL 自然过期,在几小时内逐渐失效。这也无法帮助浏览器中缓存了错误响应的用户。

我们最终采用的应急方案是修改关键 MFE 的 Webpack 配置中的 contenthash 长度,然后重新部署。修改 contenthash 长度会改变所有生成文件的文件名,迫使 CDN 将它们视为新资源,而不是提供缓存的错误响应。这个方法奏效了,但我们是在压力下想到它的,它并不是文档化运行手册中的步骤。

从那以后,我们禁用了 Akamai 对 MFE 资源的缓存,并为 S3 存储桶增加了多区域故障转移,以降低再次陷入相同处境的风险。我们还开始为 index.html 显式设置 no-cache,确保变更能够快速生效,并让任何错误的回退响应不会被长时间缓存。

对于“如果错误响应被缓存了,计划是什么”这个问题,诚实的答案仍然是:我们没有一个干净利落的解决方案。需要快速让所有内容失效时,修改 contenthash 长度、强制全面生成新文件名的办法仍然是我们的核选项。

改进我们的缓存策略

撰写本文让我们有机会反思当前缓存策略的运作方式。系统正常运行时,人们通常不会经常重新审视缓存配置,而目前的设置在实际运行中也一直足够可靠。

然而,我们的缓存配置分散在 index.html 的 S3 对象元数据和 remoteEntry.js 的 Akamai 规则中,其他资源完全没有显式缓存标头。没有一个统一的位置可以查看完整的缓存策略。出于对之前事故痛苦经历的强烈回应,我们在最外层的 Akamai 层完全不进行缓存,但这损害了性能并增加了带宽成本。

更清晰的方法是通过 S3 对象元数据,在源站为每种资源设置显式的 Cache-Control 标头,并将 CDN 层视为遵循源站标头的缓存,而不是定义缓存策略的地方。这也意味着,如果我们将来替换或重新配置 Akamai 层,缓存行为会由源站决定,而不会在不知不觉中丢失。

我们完全没有为代码块设置缓存标头,因此依赖 CDN 和浏览器的启发式规则来决定缓存时长。为经过内容哈希命名的代码块设置显式的 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"
}

MFE 发现服务

不过,我们并没有这样做,尽管我们一年前就已经识别出这种模式,并将其作为未来可能的发展方向加入了架构蓝图。它可以发现团队遗漏 contenthash 配置的问题,但这是一个纪律问题,不足以成为迁移 30 个 MFE 的理由。在每个 CDN 层进行更积极的缓存也有助于提升性能并降低带宽成本,但如果没有对缓存命中率和带宽成本进行详细分析,就很难量化其影响;而使用当前方法为代码块设置不可变标头,也能达到类似效果。

发现服务还可以支持更复杂的部署模式,例如金丝雀发布或部署级别的功能开关,但这些都会增加复杂性和运维负担。在大多数情况下,我们能够直接在应用逻辑中使用功能开关,这样更容易判断用户正在运行的代码。金丝雀发布要真正发挥作用,也需要投入时间建立自动化监控和告警策略。

发现服务仍然是未来可能采用的选项,但我们当前更直接的行动重点,是在更简单的部署方式下改进现有缓存策略。

原文由 Alex O'Callaghan 发布

本文章由 gpt-5.6-luna 进行翻译