実装してみる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 よく使う伝送プロトコル
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、断続ネットワーク対応)。
- 監視と可観測性:
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疎通× → 旧バージョンへ自動戻し。