TechCraft – エンジニアのためのスキルアップメモ

エンジニアのスキルアップを少しでも加速する技術ブログ

実装してみるOTA(Over-The-Air Update)入門

実装してみるOTA(Over-The-Air Update)入門

〜IoT機器・組込み・Linuxバイスまで:設計指針と実装テンプレート〜

1. はじめに:なぜ今、OTAか?

バイスを現地に出荷した後でも、安全にソフトウェア(ファームウェア/アプリ)を更新できるのが OTA(Over-The-Air
手戻りの高い課題(不具合、脆弱性、機能追加)を遠隔で解消でき、保守コストを劇的に下げます。
一方で、失敗すると「文鎮化(brick)」やマルウェア混入などのリスクも。
本記事は、最小構成でまず動かす実装安全性・信頼性の高い実運用までを段階的に解説します。


2. OTAの基本アーキテクチャ

2.1 コンポーネント

+--------------+      HTTPS/MQTT      +----------------+      Flash/FS      +------------+
| Update Server| <------------------->|  Device Agent  | --->  Partitions   | Bootloader |
+--------------+    (manifest.bin)    +----------------+     (A/B or single)+------------+
        ^                                      |
        |                                  Verifier
        |                                  (Sig/TLS)
   Publisher
   (CI/CD, 人)
  • Update Server:更新ファイルとマニフェストを配布(署名・メタデータ)。
  • Device Agent:サーバに接続し、バージョン確認、ダウンロード、検証、切り替え。
  • Bootloader:A/B起動、ロールバック判定。
  • Publisher:CI/CDや管理者が新バージョンを公開。

2.2 よく使う伝送プロトコル

  • HTTP(S):シンプル・CDN利用可。プル型に向く。
  • MQTT:プッシュ通知・双方向テレメトリ。IoTに適合。
  • CoAP:軽量UDP。超省電力デバイス向け。

3. 最小構成の実装(PoC):HTTP + マニフェスト

目的:ローカル/クラウド上のシンプルなHTTPサーバから、デバイスが自動更新する最小例。

3.1 マニフェスト設計(例)

{
  "version": "1.2.3",
  "artifact": "firmware_v1.2.3.bin",
  "sha256": "b3c1...deadbeef",
  "size": 524288,
  "released_at": "2025-09-01T10:00:00Z",
  "signature": "BASE64_RSA_PSS_SIGNATURE",
  "min_hw": "revB",
  "notes": "Bugfix: BLE connection stability"
}

ポイント - sha256 で整合性チェック。
- signatureオフライン鍵で署名した値(RSA/ECDSA)。
- min_hw でハード互換性を制約。

3.2 超ミニ更新サーバ(Flask例)

# server.py
from flask import Flask, send_file, jsonify
app = Flask(__name__)

MANIFEST = {
  "version": "1.2.3",
  "artifact": "firmware_v1.2.3.bin",
  "sha256": "b3c1...deadbeef",
  "size": 524288,
  "released_at": "2025-09-01T10:00:00Z",
  "signature": "BASE64_RSA_PSS_SIGNATURE",
  "min_hw": "revB",
  "notes": "Bugfix: BLE connection stability"
}

@app.get("/manifest.json")
def manifest():
    return jsonify(MANIFEST)

@app.get("/firmware_v1.2.3.bin")
def fw():
    return send_file("firmware_v1.2.3.bin", as_attachment=True)

if __name__ == "__main__":
    app.run("0.0.0.0", 8080)

4. 組込み(ESP32/Arduino)でのOTA実装

4.1 Arduino(ESP32)HTTP OTAの最小例

#include <WiFi.h>
#include <HTTPClient.h>
#include <Update.h>
#include <ArduinoJson.h>

const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASS";
const char* MANIFEST_URL = "https://example.com/manifest.json";
const char* PUBKEY_PEM = R"(-----BEGIN PUBLIC KEY-----
...(検証用公開鍵)...
-----END PUBLIC KEY-----)";

String currentVersion = "1.0.0"; // 現在のFWバージョン

bool downloadToStream(const String& url, Stream& out) {
  HTTPClient http;
  if (!http.begin(url)) return false;
  int code = http.GET();
  if (code != 200) { http.end(); return false; }
  WiFiClient* stream = http.getStreamPtr();
  uint8_t buf[2048]; int len;
  while ((len = stream->readBytes((char*)buf, sizeof(buf))) > 0) {
    out.write(buf, len);
  }
  http.end();
  return true;
}

void setup() {
  Serial.begin(115200);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nWiFi connected");

  HTTPClient http;
  if (!http.begin(MANIFEST_URL)) return;
  int code = http.GET();
  if (code != 200) { http.end(); return; }

  // マニフェスト解析
  DynamicJsonDocument doc(1024);
  deserializeJson(doc, http.getStream());
  http.end();

  String newVersion = doc["version"].as<String>();
  String artifact   = doc["artifact"].as<String>();
  String sha256     = doc["sha256"].as<String>();
  String signature  = doc["signature"].as<String>();

  if (newVersion == currentVersion) {
    Serial.println("No update.");
    return;
  }
  String url = String("https://example.com/") + artifact;

  // 1) バッファにダウンロード(サイズ小ならRAM、通常はSPIFFS等へ)
  WiFiClientSecure client;
  // 証明書ピンニングやCA設定を推奨(例略)
  if (!client.connect("example.com", 443)) { Serial.println("Conn fail"); return; }

  HTTPClient http2;
  if (!http2.begin(client, url)) return;
  int code2 = http2.GET();
  if (code2 != 200) { http2.end(); return; }

  int contentLen = http2.getSize();
  if (!Update.begin(contentLen)) { http2.end(); return; }

  // 2) ストリーム書き込み
  WiFiClient* stream = http2.getStreamPtr();
  size_t written = Update.writeStream(*stream);
  http2.end();
  if (written != contentLen) { Serial.println("Write mismatch"); return; }

  // 3) ハッシュ検証(Update.setMD5しかない環境もあるため、sha256用APIや自前検証を検討)
  // ここでは簡略化。理想はダウンロード時にsha256を計算して比較。
  // 4) 署名検証(RSA/ECDSA):組込み側で署名と公開鍵で検証(ライブラリ依存。省略)

  if (!Update.end(true)) { Serial.printf("Update error %d\n", Update.getError()); return; }
  Serial.println("Update success! Rebooting...");
  delay(500);
  ESP.restart();
}

void loop() { /* 起動後は通常処理 */ }

ポイント
- 実運用では TLS(証明書ピンニング)署名検証ダウンロード中の sha256 検算 を必須化。
- さらに安全にするには A/B パーティション + ブートローダロールバック を利用(ESP-IDFではotaパーティション運用が標準的)。

4.2 A/B パーティションの考え方(概要)

Flash
 ├─ Bootloader
 ├─ OTA_A (running)
 ├─ OTA_B (inactive)
 └─ NVS / data

更新流れ:
1) 現行Aで稼働
2) Bへ新FW書き込み
3) 次回起動ターゲット=Bに設定
4) 再起動→B起動
5) ヘルスチェックOKならB確定、NGならAへロールバック

5. LinuxバイスRaspberry Pi/組込みLinux)でのOTA

5.1 代表的な選択肢

はじめてなら Mender が比較的導入しやすい(管理サーバ+クライアントでA/B運用)。

5.2 自作ミニ実装(アプリ単位更新)

  • アプリを .tar.gz で配布 → 展開 → systemd再起動。
  • 署名付きマニフェストで整合性検証。
  • 失敗時は前バージョンへ symlink 戻し。

例:簡易アップデータスクリプト

#!/usr/bin/env bash
set -euo pipefail

MANIFEST_URL="https://example.com/app_manifest.json"
WORKDIR="/opt/myapp"
BACKUP="$WORKDIR/releases/prev"
CURRENT="$WORKDIR/current"

manifest=$(curl -fsSL "$MANIFEST_URL")
ver=$(echo "$manifest" | jq -r .version)
url=$(echo "$manifest" | jq -r .artifact)
sha=$(echo "$manifest" | jq -r .sha256)
sig=$(echo "$manifest" | jq -r .signature)

tmp="/tmp/app_${ver}.tar.gz"
curl -fsSL "$url" -o "$tmp"

echo "${sha}  $tmp" | sha256sum -c -

# 署名検証(例:openssl + 公開鍵)
echo "$sig" | base64 -d > /tmp/app.sig
openssl dgst -sha256 -verify /etc/myapp/pubkey.pem -signature /tmp/app.sig "$tmp"

mkdir -p "$WORKDIR/releases/$ver"
tar -xzf "$tmp" -C "$WORKDIR/releases/$ver"

# ロールバック用に現在を待避
rm -f "$BACKUP"
if [ -L "$CURRENT" ]; then
  ln -sfn "$(readlink -f "$CURRENT")" "$BACKUP"
fi

# 切り替え(原子的に)
ln -sfn "$WORKDIR/releases/$ver" "$CURRENT"
systemctl restart myapp.service

# ヘルスチェック(失敗ならロールバック)
sleep 5
if ! systemctl --no-pager --quiet is-active myapp.service; then
  echo "Health check failed. Rolling back..."
  ln -sfn "$BACKUP" "$CURRENT"
  systemctl restart myapp.service
  exit 1
fi

echo "Update success to $ver"

6. 差分(デルタ)更新:帯域・時間の最適化

フルイメージ更新は通信量が重い。以下の手法で軽量化できます:

  • bsdiff/ bsdiff4:バイナリ差分パッチを配布(組込みではRAM/CPU要件に注意)。
  • RTP(Rolling Hash)系:ファイルブロック単位で差分(rsyncアルゴリズム応用)。
  • アプリ層差分:WebやLinuxでは「コンテナレイヤ差分(OCI)」を活用。

注意:差分は破損耐性と検証が難しくなるため、強固なハッシュ・署名を前提に。


7. セキュリティ設計(必須)

1) 署名検証
- 発行側(CI)でアーティファクトに署名(RSA-PSS, ECDSA-P256など)。
- デバイス埋め込み公開鍵で検証。ベンダ鍵の入替計画(キーローテ)を把握。

2) 転送の機密性
- TLS(証明書ピンニング推奨)。
- CDN配信時もエッジでTLS終端+オリジン間TLS

3) アクセス制御
- 認証トークン/デバイス証明書(mTLS)で対象デバイスのみに配布
- 一時的署名URL(S3 Pre-signed URL 等)で期限付きダウンロード。

4) ロールバック
- 起動後のヘルスチェックが×なら自動で前バージョンへ戻す。

5) 安全なストレージ
- フラッシュ書込みの電源断保護(ジャーナリング、二重書込み)。
- 重要領域(ブートローダ、鍵)は書込み保護。


8. 信頼性(R&R:Reliability & Robustness)

  • ステートマシンで更新状態を明示:
    IDLE → CHECKING → DOWNLOADING → VERIFYING → SWITCHING → BOOT_TEST → CONFIRM / ROLLBACK
  • 再開可能ダウンロード(Range requests、断続ネットワーク対応)。
  • 監視と可観測性
    • 成功率、失敗理由(DL失敗/署名×/ヘルス×)、所要時間、帯域。
    • バイスから進捗・結果をテレメトリ送信し、ダッシュボード化。

9. 運用:CI/CDとリリース管理

  • CIでビルド→署名→アーティファクト保管(S3等)→マニフェスト生成を自動化。
  • 段階的ロールアウト:Canary(1%)→10%→100%。
  • 対象絞り込み:地域/ロット/ハードリビジョン/バージョンでセグメント配布。
  • 即時停止スイッチ:異常検知で配布停止できるUI。

10. MQTTベースのプッシュ通知(拡張)

  • トピック例:devices/{deviceId}/update/available で新バージョン告知。
  • バイスは通知受領後、HTTPSで実体を取りに行く「ハイブリッド構成」が堅実。
  • 帯域節約と双方向テレメトリ(進捗%)に有効。

11. よくある落とし穴と対策

  • 文鎮化:Bootloader更新は最後に/極力避ける。A/Bで守る。
  • 電源断:バッテリレベルやAC接続を条件に。途中中断でも不整合が起きない書込み手順
  • 鍵流出:署名鍵はHSM/クラウドKMSで保護。公開鍵ピン留め+ローテ戦略。
  • 互換性崩壊マイグレーション後方互換を維持、DBスキーマは段階移行。
  • 帯域コストCDN/差分更新/時間帯配布で抑制。

12. “まず動かす”ための実装テンプレート(チェックリスト)

  • [ ] HTTPSサーバ(manifest.json + .bin)
  • [ ] 署名付きマニフェスト(CIで自動生成)
  • [ ] デバイス:バージョン照会→ダウンロード→sha256検証→署名検証→適用
  • [ ] 再起動→ヘルステスト→確定/ロールバック
  • [ ] ログ/メトリクス送信 → ダッシュボード可視化
  • [ ] 段階リリースと一時停止機構

13. まとめ

  • OTAは保守コスト削減と価値提供の継続に不可欠。
  • 最小構成は「HTTPS + マニフェスト + 署名 + ロールバック」。
  • 実運用ではA/B・署名・TLS・段階配布が標準装備。
  • 小さく始め、観測とセキュリティを積み重ねることが成功への近道です。

付録A:マニフェスト署名(例:OpenSSL)

# 署名(署名鍵はHSM/KMS等で保護推奨)
openssl dgst -sha256 -sign private.pem -out manifest.sig manifest.json
base64 -w0 manifest.sig > manifest.sig.b64

# 検証(デバイス側と同等)
base64 -d manifest.sig.b64 > sig.bin
openssl dgst -sha256 -verify public.pem -signature sig.bin manifest.json

付録B:段階リリースの疑似ロジック(サーバ側)

import random
def rollout_allowed(device_id, rollout=0.1):  # 10%
    random.seed(hash(device_id) & 0xffffffff)
    return random.random() < rollout

付録C:ヘルスチェックの考え方

  • 起動後 N 秒以内に「心拍(heartbeat)」が来れば成功確定。
  • 失敗:プロセス落ち、ウォッチドッグ発火、API疎通× → 旧バージョンへ自動戻し。