备注:这是Toradex技术解决方案架构师Drew Moseley的客座文章,在本文中他解释了如何使用OSTree (也叫libostree)开源操作系统构建和部署工具以及Docker软件容器更新Linux物联网设备固件。
每天都有越来越多的联网设备被推向市场,估计到2027年,物联网(IoT)市场的总规模将高达1.5万亿美元。加油机、医疗设备和销售点系统的联网越来越多,使人们,即使是完全的卢德分子(Luddites,仇视新奇发明的人)也几乎不可能避免与这些设备互动。在家里,电表、电灯开关和安防摄像头等设备通常都是联网的,可以实现智能家居功能。
这些设备的软件的复杂程度随着功能的增加而增加,在现场有软件缺陷的设备的数量正在增加。在许多情况下,这些系统的设计、生产和运输都没有考虑到在最初的程序加载之后提供软件更新。这是一个非常严重的问题,可能远远超出给设备所有者带来的麻烦,或增加制造商的保修或召回费用。在许多情况下,物联网设备可以聚集成大型的物联网僵尸网络,由于数量庞大,已经被用来对关键的基础设施进行大规模攻击,例如针对Dyn域名服务(DNS)供应商的分布式拒绝服务攻击(DDOS),导致Twitter和Github等大型企业组织的服务中断。
为什么要更新设备?
为现场的设备提供软件更新的最主要的原因是解决软件漏洞问题。并非所有的漏洞都会成为上面提到的导致大规模网络攻击的漏洞,但对你的品牌的产品来说,风险是很大的。而且,随着用户家中的设备越来越多,有可能将多个设备的漏洞串联起来,以更广泛地访问用户的数据。在一个令人难忘的事件中,一个不知名的赌场由于一个互联网鱼缸温度计的漏洞,使其高额赌客数据库被破解。787梦想飞机中大约有1400万行代码(可能仅限于航空电子系统,不包括诸如机上娱乐系统等),而仅Linux内核中就有大约2800万行代码(截至2020年1月)。请记住,Linux内核只是Linux系统的一个部分,所以你应该开始感觉到问题的严重了。这些众多的代码行无疑将包含许多错误,本应在你的产品生命周期内被修复。
为你的设备提供更新功能也能够向你的用户提供新的功能。根据商业模式,这可能有助于长期保留客户,或者只是提供向上销售的能力和增加收入。鉴于更新功能的好处,你可能会想,为什么有些设备在出厂时都没有更新功能。而且我很难定义一个完全不需要软件更新的应用场景。
OTA服务器
任何完全自动化的OTA更新解决方案都需要一个服务器来管理机群,并允许操作人员管理设备。深入讨论服务器端不在本文的讨论范围之内,但有许多可用的选项。一般来说,你会希望选择一个端到端的解决方案,这意味着更新服务器和更新客户端都已经被开发成一个完整的解决方案,或者至少服务器和客户端的组合已经经过充分测试并相互融合。
更新方法
可以进行软件更新常见的方法,有如下几种。
- 基于封装的就地更新:这是大多数桌面操作系统使用的机制。基本上,一个安装程序或软件包在当前活动的操作系统镜像中运行。这可以安装系统所需的任何东西,但可能很难确保你的整套设备中的所有设备都运行与在设计实验室中测试时完全相同的二进制文件。
- 非对称镜像更新:这种方法通常使用一个单独的安装程序硬盘分区,能够下载适当的镜像并覆盖主操作系统分区。这消除了原地更新所发生的部分安装软件包集的担忧,但可能会导致你的用户长时间停机。直到最近,大多数手机更新都采用这种方法,我相信我们都曾为这些更新所花费的时间而感到恼火。
- 对称镜像更新(通常称为双A/B更新):这种方法使用完全空余的硬盘分区,包含一个主动分区和一个被动分区。当系统在活动分区中运行时,更新客户端可以下载并安装一个完整的镜像到被动分区。由于这一切都可能在你的应用程序代码处于活动状态时在后台发生,因此它消除了非对称镜像更新带来的停机问题。然而,由于使用完全空余的分区,通常比其他方法需要更多的硬盘设备存储。
- 基于OSTree 的更新:这是后面的主题,它提供了良好的功能组合,允许将设备停机时间降至最低,并且不需要额外的存储设备来容纳空余分区。
OSTree更新
OSTree项目的文档对其定义如下。
libostree既是一个共享库,也是一套命令行工具,它结合了上传和下载可引导文件系统树的 “类似git “模型,以及部署它们和管理引导程序配置的层。
定义有点模糊不清,所以让我们通过一个示例来说明。首先,我们将在开发工作站的一个子目录下创建一个空的存储库。OSTree通常用于整个文件系统,但为了简单起见,我们将在这个示例中使用如下一个目录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
$ ostree init --repo repo $ tree -F repo repo ├── config ├── extensions/ ├── objects/ ├── refs/ │ ├── heads/ │ ├── mirrors/ │ └── remotes/ ├── state/ └── tmp/ └── cache/ 9 directories, 1 file |
我们已经初始化了一个空的存储分区作为版本库。可以看到它已经创建了一些空目录和一个配置文件。版本库的元数据与git有很多共同之处。你可以看到比如 refs/heads等熟悉的目录,它们的使用方式也很相似。现在让我们向版本库添加一个文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
$ mkdir -p rootfs/etc $ echo 'enabled=1' > rootfs/etc/config_file.txt $ ostree --repo=repo commit --branch=main rootfs 1d0f1459b634bfbd5f573ae56a55a03a35b8941d4c45ab07dbdd6edc462c3e77 $ tree -F . . ├── repo/ │ ├── config │ ├── extensions/ │ ├── objects/ │ │ ├── 1d/ │ │ │ └── 0f1459b634bfbd5f573ae56a55a03a35b8941d4c45ab07dbdd6edc462c3e77.commit │ │ ├── 2a/ │ │ │ └── 28dac42b76c2015ee3c41cc4183bb8b5c790fd21fa5cfa0802c6e11fd0edbe.dirmeta │ │ ├── 92/ │ │ │ └── d6c7afcaedabd4504d2e16de3ffc200cd156ac733306cf7b8991f56859bcd5.file │ │ ├── af/ │ │ │ └── 98568126959bb302d2b86a25646360bd7f4fafa657490da635edf1450b32d4.dirtree │ │ └── ef/ │ │ └── 8396cb2291fe95f7b187a90fce5c6c973388c793730be1f698143bf7b01eb0.dirtree │ ├── refs/ │ │ ├── heads/ │ │ │ └── main │ │ ├── mirrors/ │ │ └── remotes/ │ ├── state/ │ └── tmp/ │ └── cache/ └── rootfs/ └── etc/ └── config_file.txt |
我们看到,已经创建出许多新的对象。如带.dirmeta、.dirtree和.commit扩展名的文件是元数据,分别跟踪文件和目录元数据(权限、所有权等)、目录树结构和提交元数据。refs/heads/main文件包含了新提交文件的commit hash值。
1 2 3 4 5 |
$ ostree --repo=repo show $(cat repo/refs/heads/main) commit 1d0f1459b634bfbd5f573ae56a55a03a35b8941d4c45ab07dbdd6edc462c3e77 ContentChecksum: f34528e7549c000b13abceb403b65298f9d54b1c2987bd02ab0a3ea1169fec27 Date: 2021-05-27 20:21:01 +0000 (no subject) |
还要注意的是,以.file为扩展名创建的对象与我们创建的文件是相同的。通常被称为内容可寻址存储,简单地说,对象存储中的文件是根据其内容来命名的。对象的名称(在本例中为92/d6c7afcaedabd4504d2e16de3ffc200cd156ac733306cf7b8991f56859bcd5.file)是由文件本身的sha256sum以及文件属性生成的。
1 2 3 |
$ sha256sum rootfs/etc/config_file.txt $(find repo -name *.file) 16f4affa3003cb1ae3f22d5e0be86a5b6fc16bbf40662629df0aa5ad0ff52e15 rootfs/etc/config_file.txt 16f4affa3003cb1ae3f22d5e0be86a5b6fc16bbf40662629df0aa5ad0ff52e15 repo/objects/92/d6c7afcaedabd4504d2e16de3ffc200cd156ac733306cf7b8991f56859bcd5.file |
值得注意的是,这些文件实际上是对相同文件系统块的硬链接。这是OStree的一个重要原则,表明它将非常节省空间;任何在不同版本之间没有变化的文件都不会被重复,从而大大节省了空间,无论是在设备上的块存储还是在获取新修订版本时的下载带宽。
1 2 3 |
$ stat rootfs/etc/config_file.txt repo/objects/92/d6c7afcaedabd4504d2e16de3ffc200cd156ac733306cf7b8991f56859bcd5.file | grep Inode: Device: 10301h/66305d Inode: 58746231 Links: 2 Device: 10301h/66305d Inode: 58746231 Links: 2 |
就像使用git一样,我们可以删除文件,然后再从版本库中查找出来。
1 2 3 4 5 6 7 |
$ rm -rf rootfs $ ostree --repo=repo checkout main rootfs && tree -F rootfs rootfs └── etc/ └── config_file.txt 1 directory, 1 file |
现在让我们给版本库添加一个完整的根文件系统。我使用Buildroot为QEMU Arm设备创建了一个简单的文件系统。
1 2 3 4 5 6 7 8 9 |
$ git clone git://git.buildroot.net/buildroot -b 2021.02.2 $ cd buildroot $ make qemu_arm_versatile_defconfig $ make $ sudo mount output/images/rootfs.ext2 /mnt $ rm -rf rootfs $ mkdir rootfs $ sudo tar -C /mnt -cf - . | tar -C rootfs/ -xf - $ ostree --repo=repo commit --branch=main --subject="Initial Linux system" rootfs |
现在我们将创建第二个版本的文件系统。我使用了之前的Buildroot配置,并添加了bc工具,在运行make menuconfig时,bc工具被列在Target packages/Miscellaneous下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ rm -rf rootfs $ mkdir rootfs $ sudo tar -C /mnt -cf - . | tar -C rootfs/ -xf - $ ostree --repo=repo commit --branch=main --subject="Added bc binary" rootfs $ ostree --repo=repo show main commit e4568293f591b80cdf68451f6329683fbecd42ec9a5915ea44b8c77bbbf47a41 ContentChecksum: 4a9765852b6134a39444751734cc0d70ee207999b73216919644cd69ad0b3d50 Date: 2021-05-28 13:24:47 +0000 Added bc binary $ ostree --repo=repo diff main M /usr/bin/bc M /usr/bin/dc |
现在,假设我们决定不再需要bc二进制文件。不需要重新创建,就可以简单地重新运行以前的版本。首先,我们检查bc是否存在于当前文件系统中;然后我们重新运行;最后,我们再次验证bc是busybox的符号链接:
1 2 3 4 5 6 |
$ ls rootfs/usr/bin/bc -l .rwxr-xr-x 63k dmoseley 27 May 17:18 rootfs/usr/bin/bc* $ rm -rf rootfs $ ostree --repo=repo checkout 819beafa993f254adc0fc4f80bbc417a59099271317e9d3e0fe7768234dfacc3 rootfs $ ls rootfs/usr/bin/bc -l lrwxrwxrwx 17 dmoseley 27 May 17:14 rootfs/usr/bin/bc -> ../../bin/busybox |
对无线系统更新来说,最后一个重要的功能是远程存储库。类似于git使用存储库,都是远程访问的数据存储的方式,包扩OSTree元数据。下面的例子是运行在Toradex Verdin i.MX8M Mini系统上的,Torizon是一个基于OSTree的工业嵌入式Linux系统。我们把它连接到Toradex TorizonCore OSTree资源库,并检查了晚间发布的最新版本。还有一些额外的细节(此处未涵盖),与启动时切换到新版本的文件系统有关,所以这是一个原子操作(Atomic Operation)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
root@verdin-imx8mm-06759464:~# ostree admin status * torizon 8c514a595469283bb69603f2fa32395b5dbbc3d80a8f9d824dc661f1b68c4d82.0 Version: 5.1.0+build.1 origin refspec: torizon:5/verdin-imx8mm/torizon/torizon-core-docker/release root@verdin-imx8mm-06759464:~# ostree remote add --no-gpg-verify torizon <https://feeds.toradex.com/ostree/> root@verdin-imx8mm-06759464:~# ostree pull torizon:5/verdin-imx8mm/torizon/torizon-core-docker/nightly 403 metadata, 2772 content objects fetched; 170803 KiB transferred in 54 seconds root@verdin-imx8mm-06759464:~# ostree log 5/verdin-imx8mm/torizon/torizon-core-docker/nightly commit befaaa1691c26881cbcbf804509761dffc767da5679fba895fdcf8d3cc898b80 ContentChecksum: 8f6aec54c23fa0e453526d44c5d03f759af512d24e303e5f43b1fa8ea19e356a Date: 2021-05-26 04:02:04 +0000 Version: 5.3.0-devel-20210525+build.308 5.3.0-devel-20210525+build.308 << History beyond this commit not fetched >> |
有了上面介绍的OSTree的一系列功能,我们就有了OTA更新系统所需的基本功能。
- 储存整个文件系统的多个版本的能力。
- 保留的旧版本被用来提供强大的重新运行工具。
- 硬链接用于优化存储空间。
- 远程存储库可允许连接的设备通过“无线”下载并更新。

容器
容器化是OS Level Virtualization(操作系统级虚拟化)的一种形式,应用程序允许在隔离的环境运行。它们与虚拟机的不同之处在于,它们并不对整个硬件平台进行虚拟化,也不运行完整的操作系统。它们的主要用例是将应用程序与它运行所需的所有依赖项、库等一起封装起来。在容器中设置应用程序可以确保满足所有依赖项,而无需在基本操作系统中安装额外的程序包。比如,如果你正在运行一个 NodeJS 应用程序,你会将它打包到一个包含JavaScript 运行时和所有其他所需组件的容器中。你可以指定每个依赖项的版本,一起测试所有内容,然后部署确切的组合;这消除了基本操作系统镜像可能具有特定软件包的不同版本的担忧。此外,如果需要,它允许不同的容器包含不同版本的组件;例如,你可以运行一个使用 Python v2 运行 Python 应用程序的包,以及另一个包含需要 Python v3 的包的容器。
除了关联性管理外,容器还可以与系统的其他组件隔离,潜在增加了安全性。使用基本OS内核的标准特性,容器只能局限于文件系统的某些部分、某些设备,甚至可以局限于多-核操作系统中的特定CPU。根据你正在使用的容器运行时间,你能够限制单个容器的整体CPU或内存使用量,允许系统根据使用模式进行调整。
容器系统的第三个主要特点是其内置的交付机制。使用docker,最流行的容器引擎之一,你可以创建新容器,这些容器继承了各种软件提供商提供的许多基础镜像的功能。如果你有一个用 Python v3 编写的应用程序,并且想要在 Debian 风格的环境中运行它,你可以创建一个Dockerfile,如下所示:
1 2 3 |
FROM python:buster COPY myapp.py /myapp.py CMD python /myapp.py |
然后在包含应用程序的同一目录中创建 myapp.py 文件。我们将使用如下所示作为我们的测试应用程序:
1 |
print("Hello from myapp") |
你现在可以通过以下步骤直接在创建系统上创建和运行此容器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ docker build . -t myapp:latest Sending build context to Docker daemon 404.5kB Step 1/3 : FROM python:buster ---> a6a0779c5fb2 Step 2/3 : COPY myapp.py /myapp.py ---> f8e6a139e325 Step 3/3 : CMD python /myapp.py ---> Running in 408690452f74 Removing intermediate container 408690452f74 ---> 179d244081b9 Successfully built 179d244081b9 Successfully tagged myapp:latest $ docker run --rm myapp Hello from myapp |
第一条命令用以建立镜像和标签,名称为myapp,版本为最新。第二条命令用Dockerfile中用CMD语言指定的start命令运行容器。注意,在这一点上明确一些,我们并没有在我们的嵌入式设备上运行。你可以使用容器在你的桌面上进行大量的开发和故障排除。对于许多应用开发任务,这比直接在嵌入式设备上做开发更有效率。当你准备在你的嵌入式设备上运行容器时,你可以把整个工作目录复制到你的设备上,然后重新运行上述命令。当你在测试或处理少量设备时,这将是可行的,然而当你的设备群规模增加时,你需要一个更好的交付机制。Docker提供了一个方便的机制来分享镜像。当我们在Docker文件中指定FROM语言时,其实我们已经使用了这个机制。这表明docker将我们的镜像建立在docker hub上的python:buster镜像上。你也可以将你的镜像推送到docker hub或任何其他docker仓库,这对当你想保持你的容器的私密性时非常有用。一旦你创建了docker hub账户,就可以为目标架构创建自定义镜像,并使用以下方法发布。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$ docker login Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to <https://hub.docker.com> to create one. Username: myuser Password: Login Succeeded $ docker buildx create --name mybuilder --use mybuilder $ docker buildx build --platform linux/arm64,linux/amd64,linux/arm/v7 -t myuser/myapp:latest --push . [+] Building 200.2s (16/16) FINISHED <snip> => pushing layers 176.3s => pushing manifest for docker.io/drewmoseley/myapp:latest 1.3s => [auth] myuser/myapp:pull,push token for registry-1.docker.io 0.0s |
由于我们已为Arm32、Arm64和AMD64创建了版本,你可以从基于这些架构的任何系统上运行你的镜像,具体方法如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
colibri-imx8x-06748684:~$ docker run --rm myuser/myapp:latest Unable to find image 'myuser/myapp:latest' locally latest: Pulling from myuser/myapp c54d9402d498: Already exists a91bbb3592d6: Already exists 08d8c28b9129: Already exists c70b3803033b: Already exists 4d0a70a69a4a: Already exists c35b5a95ec3d: Already exists 45d8b9f6ef0c: Already exists 07fa13da7fc4: Already exists f87548a0f8e2: Already exists ccc4c71f2e3a: Pull complete Digest: sha256:099010e9ad1a7719a418422b29eecc2bafdd4c95edff6bc36d7dbb8f98f303c1 Status: Downloaded newer image for myuser/myapp:latest Hello from myapp |
灵活、熟悉的软件环境,以及内置的程序打包和交付机制的结合,为使用容器作为应用部署环境提供了令人信服的理由。
OTA更新器
OStree和容器的结合提供了一个丰富的功能集,我们可以从中开发完整的OTA更新能力。这两个项目合在一起,提供了一个强大的系统,能够处理正在开发的连接设备的需求。
如上所述,OSTree 提供:
- 一种用于管理操作系统二进制文件的稳定、耐电的机制。
- 有效利用存储和下载带宽,能够重复使用任何未修改的文件。
- 重新加载功能可确保损坏的更新不会使你的成套设备毫无生气。
使用容器来容纳你的应用程序堆栈,可以提供。
- 灵活且熟悉的软件开发环境。你的开发人员可以继续在他们的桌面Linux 系统中使用他们已经熟悉的工具。
- 内置的封包装和交付机制。我们不需要在这里再发明一次轮子(reinvent the wheel,即浪费资源与人力,重复进行无意义的事),可以利用经过行业验证的解决方案。
- 活跃的开发者社区。可供重用的文档、博客和现有庞大的容器数量,可以用作开发工作的起点。
使用这样的系统允许更新系统中的任何组件,包括内核、设备树和应用程序代码。如果部署得当,你可以确保你的设备群在整个生命周期内保持运行良好。
为了使更新能力发挥最大作用,它需要是自动的、无人值守的,并且可以通过某种网络连接提供;通常称为空中(OTA,over-the-air),但这并不一定意味着是无线连接。这些设备的用户不会将它们视为需要维护的计算机,而是将它们视为应该“正常工作”的设备。如果更新需要用户的干预,那么你的设备中很可能有许多过时的设备。在一些使用情况下,如医疗设备,设备的连接可能被故意限制,但即使在这些情况下,更新也应该在可行的情况下自动进行,如当设备被连接到其对接站进行充电时。
安全性
虽然安全性不是本文的重点,但如果我们不至少给予讨论一下,就会疏忽大意。任何软件系统的最大威胁之一是运行任意代码的能力。由于OTA更新系统的全部意义在于让新的代码在系统上安装和运行,因此必须特别小心,以确保正在安装的代码没有被篡改,并且是你系统的预期镜像。
以下几点应予以考虑:
- 物理安全:你可以控制服务器基础架构,请确定尽可能地锁定物理访问。但请注意,客户端设备不太可能实现。
- 传输加密:你必须确保客户端和服务器之间的传输正确加密。理想情况下,你将在两个端点上使用正确的TLS 证书验证,以确保你正在与预期的设备通信。
- 图像验证:你的客户端设备需要一种机制来验证正在安装的图像。应使用加密验证来防止任意软件安装。
- 安全密钥管理:任何安全架构都将依赖于某种密钥。该体系结构应提供使旧密钥过期并轮换新密钥的机制,并提供适当的密钥保护。
有一些开源框架提供了极其安全的设计,可以在实现OTA更新系统时使用。Update Framework和Uptane是两个值得注意的项目,如果你需要设计一个定制的更新系统,应该考虑一下。
有许多开源项目实现了OTA更新系统,你可以将其整合到你的设计中,以避免设计系统的开销和风险。Torizon平台是我目前正在参与的项目,它实现了本文中所描述的完整的OTA系统。OStree提供了有限的安全功能,例如对提交和delta对象进行加密签名。Torizon是基于Uptane架构,提供了一个现成的高度安全的端到端OTA更新解决方案。
结论
我们已经讨论了如何结合几个开源项目作为基础设施来创建一个完全自动化、端到端的OTA更新解决方案。
使用OSTree作为我们的主要操作系统存储,可以提供一个非常节省空间的解决方案,并且不需要使用完全冗余的分区。它提供了原子式的、事务性的更新,使设备用户的停机时间降到最低。OSTree经过精心设计,对不可预测的电源周期具有弹性,并允许在发现更新问题时进行重新加载。
为应用程序堆栈使用容器提供了一个方便的封包和交付机制,可以独立于基本操作系统来处理。容器相对来说比较容易使用,许多开发者已经掌握了使用容器的技能。你可以选择一个与你的桌面Linux发行版相匹配的基础镜像,这将使你在一个熟悉的环境中工作,并有一套丰富的工具供你使用。或者你可以选择一个容器优化的基础镜像(如Alpine Linux),它被设计得很小,而且本质上更安全;毕竟,最安全的软件是尚未安装的软件。
在为你的开发人员考虑到构建的可重现性、可维护性和开发者的灵活性时,将容器和OSTree结合起来可以得到最好的结果。使用诸如 Torizon 之类的系统,可以提供使用此处描述的架构的现成解决方案。这使你能够迅速开始开发你的应用程序,而无需担心OSTree、容器和OTA更新的细节,同时你知道你拥有一个可靠的解决方案来管理设备群的生命周期。
为你的设备提供适当的更新应该被认为是任何现代连接设备设计的必备条件。该必备条件对你的用户和你的品牌来说非常重要,不容忽视。

文章翻译者:Rita Wang,CNX中文站翻译人员,文字功底扎实,将科技文献以通俗易懂的形式呈现给读者,对开源硬件、AI、IoT等领域多有涉猎。