ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

放弃应用市场后端:基于对象存储与 manifest 的 ESP32 远程 OTA 更新方案

放弃应用市场后端:基于对象存储与 manifest 的 ESP32 远程 OTA 更新方案

先讲个真实的场景。我之前在维护一批基于 ESP32 的联网设备时,遇到了所有做设备端应用的人都会撞上的分发难题:调试阶段一台台接着 USB 线烧录没问题,等设备铺到现场之后,每次想修复一个温湿度传感器的采样 bug,或者给某个模块加一个功能,都得专门跑一趟现场,甚至要把设备寄回来刷写。刚开始我也和大多数人一样,脑子里冒出来的第一个念头就是——搭一套“应用市场后端”:设备注册、版本管理、下载代理、权限校验、增量发布,全套都上。但冷静下来仔细算了一笔账之后,我把方案改成了更不起眼的“静态对象存储 + manifest 版本文件”,用了一个周末就完成了整套远程应用更新能力,之后连续跑了几个月都没遇到瓶颈。这篇文章就把我当时为什么放弃后端、转用静态对象存储的完整决策过程,以及 ESP32 端落地时的关键细节,一次性说清楚。

1. 先想明白:ESP32 上做应用平台,缺的到底是什么

先说结论:在 ESP32 这个资源量级的设备上做“应用平台”,核心缺的是“可靠的远程文件分发机制”,而不是“一个能干各种逻辑的动态后端”。

所谓的 ESP32 应用平台,在这个语境下指的是让设备具备多模块运行和远程更新能力。举个例子,一台基于 ESP32-S3 的环境监测网关,它上面可能同时跑着温湿度采集模块、OLED 显示菜单模块、Web 配置页面模块,未来还想加一个蓝牙配网模块。这些模块由设备端的一个“平台层”统一调度管理,可以独立升级、独立回退,就像把你手机上的应用一个个单独更新一样。

现在问题来了:你要远程给这些模块升级,设备端真的需要一个“账号体系”吗?不需要。设备端真的需要一个“动态 API 去查询自己该装什么版本”吗?也不一定。设备端最核心的需求其实只有五个:

  1. 知道自己当前装了哪个版本;
  2. 知道“新版本”去哪个地址下载;
  3. 下载回来的二进制是完整的、可信的;
  4. 能在本地完成原子切换,坏了还能回滚;
  5. 整个过程不把设备资源耗干。

注意看,这五件事里没有一件要求“服务端根据设备 ID 动态计算推送什么内容”。在设备数量还不是成千上万、没有精细化灰度需求的时候,这些能力完全可以用一个静态文件系统来实现。

我见过很多开发者绕进“应用市场”这个思路里出不来,觉得 APP 市场就是要有后端,要有后台管理系统,要有开发者上传应用、审核、上架、设备拉取这一整套流程。这个思路在服务器上没错,搬到 ESP32 上就重了,而且重得非常不划算。ESP32 的 RAM 通常只有 320KB 左右(ESP32-S3 有 512KB,但可用部分还要打折),Flash 从 4MB 到 16MB 不等,上面已经睡了 WiFi 协议栈、蓝牙协议栈、主应用、显示驱动。你要再塞进去一个 HTTPS 客户端、JSON 解析器、下载器、校验器、版本管理器,剩下的资源就只够平台自己喘气了。这时候如果服务端还设计成“跟设备唠嗑、动态生成配置”的样子,设备端的负担只会更重。

所以我把问题的定义先拉回来:ESP32 应用平台的起点,是让模块二进制文件和版本清单可以稳定地分发出去。这个“分发”,静态对象存储天生就能完成得很好。

2. 静态对象存储 + manifest 文件,组成一套最小可用的应用分发系统

确定方向后,整个系统的设计可以非常朴素。我选的是 S3/OSS 这一类对象存储服务,本质上它就是一个可以按目录存文件、按 HTTP(S) URL 访问文件的“网盘”。真正干活的是设备端的一个轮询任务和一份 JSON 格式的 manifest 文件。

2.1 目录结构:把所有元数据都做成“文件”

我当时在对象存储桶里规划的目录结构长这样:

myapp-release/ ├── manifest.json ├── apps/ │ ├── sensor-collector/ │ │ ├── 1.3.0/collector.bin │ │ ├── 1.3.0/collector.sig │ │ └── 1.2.1/collector.bin │ └── web-config/ │ ├── 0.9.2/webconfig.bin │ └── 0.9.2/webconfig.sig └── fw/ └── esp32-base/ └── 2.0.0/base.bin

整个系统的“控制平面”就是最顶上那个manifest.json。设备端只认这一个文件的地址,隔一段时间去拉一次。这个文件写得尽量精简,控制在 1~2KB 以内,避免 ESP32 上的 JSON 解析压力过大。一个典型的 manifest 内容长这样:

{ "appId": "sensor-collector", "platform": "esp32s3", "minFirmware": "1.8.0", "version": "1.3.0", "sha256": "a3f9d80c4b2e9a1c743f5b7d2e6f8c4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7", "signature": "MEUCIQDf2q8...", "size": 1048576, "url": "apps/sensor-collector/1.3.0/collector.bin", "releaseNote": "修复 I2C 温湿度传感器偶尔不响应的问题" }

每个应用都有自己的一级目录,目录下面按版本号建子目录。发布一个新版本,本质就是往桶里多传一个目录和几个文件,然后把 manifest.json 里的version、sha256、url、signature这几个字段替换成新值。这个操作可以用一条 shell 命令完成,也可以用简单的脚本封装,完全不需要起一个常驻服务。

2.2 设备端的工作流:轮询清单,比对版本,校验文件,原子切换

设备端不需要理解后端逻辑,它只需要一个“更新代理”任务,循环执行下面四步:

  1. 定期拉取manifest.json(建议用 HTTPS,下面会说证书的事);
  2. 解析 JSON,对比当前模块版本与 manifest 里的version;
  3. 如果发现有新版本,就根据url字段拼接完整下载地址,把.bin文件下载到 Flash 上的临时分区或者 LittleFS 的临时文件里;
  4. 下载完成后先校验 SHA256,再校验签名,全部通过后把新模块 swap 到运行分区,重启加载。

用 ESP-IDF 的接口写起来其实并不复杂,骨架大致是这样:

esp_http_client_config_t cfg = { .url = manifest_url, .cert_pem = root_ca_pem, // 这一步别省,后面细说 }; // 拉取 manifest,用 cJSON 解析 // 对比版本号 if (need_update) { download_file(app_url, tmp_path); if (verify_sha256(tmp_path, expected_sha256) != ESP_OK) return; if (verify_signature(tmp_path, signature) != ESP_OK) return; platform_swap_module(tmp_path); }

这套流程跑起来之后,我发现自己对“应用市场”的执念很快就放下了。发布、回退、灰度预发布,全部变成了“改一个文件”或者“传一个文件”的问题,而不是“调一个接口”的问题。

2.3 “改一个文件”就是回退,这句话值多少钱

传统后端方案里,回退版本通常意味着你要维护一个版本表,改数据库里的状态,或者重新部署一份旧代码,战线很长。而在静态对象存储方案里,回退逻辑简单到令人发指:把 manifest.json 里的version改回上一个版本号,同时把url、sha256、signature一起还原成旧版本对应的值,然后覆盖上传。设备端下一轮轮询立刻就会发现“当前版本高于 manifest 版本,不需要升级”,已经升级到新模块的设备不会再动;那些还没升级的设备会一直停在旧版本上。整个过程在 10 秒内完成,不需要重启任何服务。

这里要特别提一个很多人忽略的点:正因为回退成本低,你在发布新版本时才敢更频繁、更激进地迭代。这带来的长期价值远比“省几台服务器”大得多。

3. 静态对象存储 vs 应用市场后端:五个维度的正面比较

我承认,在设备规模很小、设备端就是裸奔的 ESP32 时,后端方案也能跑通,但代价是“把所有复杂度都背在自己身上”。下面这张表可以直观对比两条路的差异,是我当时做决策时的核心依据。

对比维度静态对象存储方案应用市场后端方案
基础设施对象存储桶,无服务器、无数据库至少一台云服务器 + 数据库实例
初期成本存储几分钱/GB/月,流量几毛钱/GB轻量服务器一年几百起,数据库另算
开发工作量写一个上传脚本 + 设备端下载/校验代码设备注册、版本管理、鉴权、下载代理一套 API
故障域对象存储挂了,设备端只是“暂时检测不到新版本”,不影响本地运行后端挂了,设备连不上 API,甚至下载代理也断掉
全球/多地分发对象存储自带 CDN 和就近加速需要自建带宽、加 CDN,成本陡增
单点故障极低,云厂商保障存储可用性你自己运维的服务器才是最大的单点
版本回退改一个 manifest 字段,覆盖上传改数据库记录,甚至回滚代码、重新部署

具体展开说几个我感触最深的维度。

3.1 成本:一个“应用市场后端”的真实开销被严重低估

很多人觉得写个后端不花钱,但算账要看全生命周期。一台可以被公网访问的云服务器,一年租个几百块起步,GPU 什么的当然用不上,但为了装 MySQL / PostgreSQL,内存至少得 2GB 起步,这个配置每个月也要四五十块钱。数据库如果是云服务商的托管实例,又是另一笔固定支出。更隐蔽的是带宽成本:设备端要下载应用模块,这些流量都要走你的公网带宽。如果哪次发布的应用特别多、设备特别集中,流量账单可以把一个月的利润吃掉。

而对象存储是按量计费的,存储一个月几毛钱到几块钱,流量按 GB 计费,CDN 节点还帮你把流量扛了。设备量没破千之前,每个月费用大概率在个位数以内。省下的不只是钱,而是你不需要花任何心思去运维一台常驻服务器:不用处理进程宕机,不用做数据库备份,不用被 UDP 攻击吓得半夜爬起来看监控。

3.2 故障域:你的设备不该被“你个人维护的服务器”绑架

这一点我是越想越后怕。假如设备端定期上报状态、定期拉取版本,那“后端”就变成了设备正常工作的一环。一旦你自己的服务器因为环境问题、欠费、网络攻击等原因挂掉,所有在线设备都会同时失去“远程控制”能力,甚至有些设备会在错误处理不当的情况下反复重试,把自己搞死。

对象存储则完全不同。即便对象存储服务商那边出现了服务波动,设备端无非就是“这一次轮询没有拿到更新”,本地已装的模块照常运行。设备没有对外发出任何请求,也就没有依赖关系可言。对整个系统的可用性来说,这是一个本质上的区别。

3.3 安全模型:让别人下载了也无妨,反正他装不上

这点可能是整个方案里最反直觉、也最能说服我自己的地方。一说到用对象存储公开分发应用,很多人第一反应是“那不是谁都能下载了?”——对,谁都能下载,但下载了不代表能装到你的设备上。

我采用的方案是给每个应用模块做 ECDSA 签名。编译完.bin之后,用一把私钥对二进制内容签名,生成.sig文件;ESP32 端预先烧入对应的公钥,每次下载完先验签,签名不过直接丢弃。这样一来,攻击者虽然可以把你发布的应用二进制抓走,但他无法伪造一个能通过验签的恶意版本。设备端只执行带合法签名的代码,安全性不依赖于“别人下载不到”,而是依赖于“别人下载了也只能干看着”。

这比你自己写一个后端,然后辛辛苦苦加权限校验、加访问白名单,最后可能因为一个鉴权漏洞被打穿要可靠得多。安全模型设计得越简单,越难出错;动态后端的攻击面明显比静态文件大得多。

3.4 分发覆盖:CDN 天然帮你解决“设备分布在天南海北”的问题

设备铺到现场之后,分布没有任何规律,有些在新加坡,有些在江苏小县城。如果依赖自己的一台云服务器去分发几 MB 的模块,偏远地区的设备下载速度会非常难看,一个 2MB 的模块可能要下载十分钟。而对象存储服务默认就有 CDN 加速能力,节点分布在全国甚至全球各地,设备端拿到的是一个 CDN 边缘节点提供的下载地址,速度通常比直接连源站快一个量级。这同样是你自己部署后端时非常难免费获得的能力。

4. ESP32 端实际落地的关键细节与踩坑记录

静态对象存储方案真正上手后,才发现设备端的工程细节才是决定成败的部分。网上大量文章只讲“用 OTA 升级”,但把一个完整的、多模块的应用更新平台跑稳,需要处理的坑至少有五个。

4.1 HTTPS 证书:最容易翻车的第一关

ESP32 走 HTTPS 访问对象存储域名时,需要提前把受信任的根证书烧进设备。这里有一个经典坑:如果你给对象存储绑定了自定义域名,并且使用了 CDN 的证书链,那么证书链可能不完整,你只烧一个根证书是不够的,得把整个证书链按顺序合并后烧进去。

我最开始就只放了一张根证书,结果设备一直报证书验证失败,日志看了半天才发现是中间证书缺失。后来统一用cert_to_pem.py脚本把证书链拼好,再固定烧录,问题才彻底解决。另外,如果你的模块会访问多个域名(比如 manifest 在 A 域名,二进制在 B 域名),每个域名的证书链都要对应烧录进去。这是一个不踩不知道、踩了很烦躁的点。

4.2 manifest 文件必须精简,JSON 解析是内存杀手

ESP32 的 RAM 非常紧张,cJSON 解析一个 5KB 的 JSON 对象,内存开销会接近其本身大小的 3~5 倍。我当时的 manifest 严格控制在一到两 KB,字段能省则省,不需要的注释和冗余字段全部去掉。解析时也要注意及时释放 cJSON 对象,每次轮询不要重复持有 JSON 所占的内存,否则连续几轮轮询之后,堆内存碎片会很严重,最终导致malloc失败、看门狗复位。如果你用了 ESP-IDF 的动态内存调试,可以在启动后跑几轮更新轮询看看内存水位,通常会发现cJSON相关对象的峰值占用超出预期。

4.3 下载中断与断点续传:弱网环境的必修课

应用模块动辄一两 MB,在室内 WiFi 都不稳定的环境下,一次完整下载很容易失败。我实际测试时,20 次下载里大概有 3~4 次会中途断掉,概率并不低。处理方式有三个层次:

  • 最基础:下载时写到一个临时分区或临时文件,而不是直接覆盖运行分区;
  • 进一步:记录已下载的字节数,借助 HTTP Range 头实现断点续传;
  • 最稳妥:整个文件下载并校验完成后,再做分区切换动作。

如果省掉最后一步,中途断电的时候运行分区可能是半截数据,设备直接变砖。这个跟“不校验签名就运行”属于同一个级别的致命失误,一定要在代码里用状态机严格限制“只有完整且校验通过的镜像才能进入 swap 流程”。

4.4 回滚保险丝:新版本跑不起来怎么办

设备端更新完成后,光有双分区还不够,因为换上新模块后它可能起不来、反复重启、或者跑一段就崩。我会在每个应用模块里加入“启动确认”机制:新模块启动后,在限定时间内(比如 10 秒)向平台层发送一个心跳或者设置一个标志位,平台层如果再收到两次复位,就会自动切换回上一个可用版本。这就像火箭发射时的保险丝,任何阶段的异常都能触发回退到安全的旧状态。如果缺了这一层,一次远程更新失败可能就得“现场螺丝刀拯救”。

4.5 下发节奏控制:不要让所有设备同一秒去下载

设备数量一旦超过三五十台,“全网同时拉取 manifest”就会出现明显的惊群效应。虽然对象存储能扛住,但 CDN 回源压力和日志量都上升得很无谓。我的做法是在设备端做随机延时:每次轮询 manifest 之前,随机 sleep 5 到 30 秒;下载二进制时,再在 0 到 10 秒内随机一次。这能有效打散请求时间,成本为零,效果立竿见影。

我整理了一个设备端发布流程清单,发布一个版本时照着走就行:

  1. 编译出新的 app 二进制 bin 文件;
  2. 计算 SHA256 和 ECDSA 签名;
  3. 将 bin 和 sig 文件按版本号上传到对象存储对应目录;
  4. 修改 manifest.json 中的 version、url、sha256、signature;
  5. 上传覆盖 manifest.json;
  6. 观察设备端日志,确认小批设备升级成功后逐步放量;
  7. 需要回退时,把 manifest.json 恢复到上一个版本内容,覆盖上传即可。

5. 什么时候静态方案不再够用?五个信号决定要不要上后端

静态对象存储方案并不是万能药,我在用它的同时也在持续观察一条边界线:当设备规模和运营复杂度上去之后,后端的能力迟早是需要的。如果你遇到下面五个信号中任何一条,就说明是时候补一个真正的轻量后端了。

  • 按设备精确灰度:静态方案只能“全量切换”或“按目录预发布”,做不到“给 10% 随机设备推新版本、其余 90% 不动”。一旦需要按设备 ID 或按群组差异化下发,就必须引入后端来做设备画像和分流逻辑。
  • 需要在线统计与监控:对象存储只能告诉你“有设备请求了这个文件”,回答不了“有多少设备升级成功了”“哪些设备还在跑旧版本”。如果你需要安装率、在线率、崩溃上报,设备端数据要有一个接收端,这已经超出静态文件的能力范围。
  • 出现用户体系或授权逻辑:商业化的多用户设备、付费应用或账号绑定,后端是跑不掉的,静态存储无法处理“你有权限用、他没有权限用”这种动态判断。
  • 需要动态生成下载地址:私有读场景下的预签名 URL 需要服务端签算,这也需要后端介入。
  • 需要与服务端实时交互:如果应用模块内部存在“从服务端动态拉数据”的需求,静态对象存储只能作为数据文件的存储层,核心业务逻辑要另起服务。

真到这个阶段,我的建议依然不是“推翻静态方案重写应用市场后端”,而是采用混合模式:后端只负责出清单、出签名、出预签名 URL,真正的二进制下载永远走对象存储。这样后端保持无状态,可以水平扩展,也避开了带宽瓶颈。设备端代码基本不用大改,因为 manifest 的 URL 字段可以指到同一个对象存储的地址。

以我个人的实操体会来说,如果你也是一个人在维护几十台到两三百台基于 ESP32 的联网设备,我建议你别急着写那套“应用市场后端”。先在对象存储上把 manifest + 签名 + 双分区回滚这套链路跑通,让设备具备远程更新能力,这个基本盘的价值会远超你最初的预期。等设备规模和数据需求真的撞上那五个信号,再来补后端也不迟,而且那个时候你已经清楚知道“缺的到底是哪一块”,不会在架构上做无用功。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进