はじめに
自作の CTI サーバー(電話の着信を検知して、顧客情報をクライアントに表示する仕組み)を、10年以上 Raspberry Pi 2 上で動かしてきた。ネットワークのミラーポートで NTT ひかり電話の SIP パケットを監視し、着信番号を抽出して WebSocket でクライアントに配信する、という構成だ。
問題は Raspberry Pi 2 がとっくに手に入らなくなったこと。引き合いが来るたびに中古を探す羽目になっていた。そこで後継ボードを Radxa Zero 3E に定め、サーバー側を作り直した。その記録を残しておく。
結論から言うと、開封から「実機で動いて配れるイメージができる」まで、実質3日ほどだった。
なぜ Radxa Zero 3E か
候補を検討して、最終的に Zero 3E にした理由は主に3つ。
- 有線 LAN が内蔵されている。 ミラーポート監視には必須。
- 無線モジュールを持たない。 技適の心配が要らない(顧客に納める機器として地味に大きい)。
-
供給が保証されている。 メーカーが 2030 年まで供給を明言していた。Pi 2 の二の舞を避けたい。
一方で難点もあった。日本に代理店がなく、専用ケースも既製品がほぼ無い。 今回は 2GB 版を Arace(Radxa 公式直販)で 1個 6,600 円(別途送料)で入手した。AliExpress でも探したが、割高で怪しいものが多く、避けた。日本の Amazon 等では転売で 15,000 円ほどの出品もある。公式直販から買うのが結局いちばん安く確実だった。
RK3566(Cortex-A55 クアッド、arm64)、2GB RAM、microSD 起動。Raspberry Pi Zero とほぼ同じ 65×30mm のサイズ感で、RJ45 が短辺から生え、USB-C が2つ長辺に並ぶ。
移行前にやったこと:正本化とテストデータの採取
いきなり新ボードにコードを書き始めず、先に足元を固めた。これが後で効いた。
-
旧サーバーのソースを正本化した。 実は12年間ほとんど版管理されておらず、「実機にしかない改修」と「リポジトリにしかないコード」が食い違っていた。実機の内容を正として Git に取り込み直した。
-
稼働中の実機から、テスト用のフィクスチャを採った。 実際に電話をかけて、
- tcpdump のテキスト出力(パーサへの入力)
- 旧サーバーが WebSocket に流した JSON(期待値)
- DB に記録された履歴
の3層を、同じ着信について記録した。着信(登録済み/未登録)、非通知、発信抑制など、いくつかのパターンで。個人情報はマスクしてから保存した。
このフィクスチャがあると、新実装が「旧サーバーと同じ入力に同じ出力を返すか」を機械的に検証できる。移行先を書きながら、旧版との等価性をテストで担保できたのが大きい。 稼働中の旧機が生きているうちにしか採れないデータなので、先にやっておいて正解だった。
何で書いたか:Node.js、依存は最小
再実装は Node.js(LTS)で。旧サーバーもロジックは JavaScript だったので、一番の資産である SIP 解析ロジックをほぼそのまま引き継げる、という理由が大きい。
ただし、旧サーバーが動かなくなった直接の原因は npm 依存の腐敗だった(Express 3、古い WebSocket ライブラリ、ネイティブ拡張の SQLite などが軒並み EOL)。同じ轍を踏まないよう、依存を極限まで減らした。
-
HTTP / REST →
node:http(標準)。Express は使わない -
SQLite →
node:sqlite(Node 22 以降で標準。ネイティブ拡張が不要になったのが効いた) -
WebSocket →
wsのみ(唯一の外部依存。バージョンは厳密固定) -
CSV パース、multipart、Shift_JIS デコード → すべて標準(
TextDecoder等)で自前実装
結果、外部依存は実質 ws 一つ。package-lock.json を固定し、npm ci で再現できるようにした。
なお、Node のバージョン管理は nvm と n が二重に入っていてハマったので、nvm に一本化し、プロジェクトごとに .nvmrc を置いた。node:sqlite が安定して使えるのは 22.13 以降だったので、そこも .nvmrc で固定。
ハマったところ:ネットワークを2回落とした
一番の落とし穴はここだった。ミラーポート監視用のインターフェースの扱いで、社内ネットワークを2回ダウンさせた。
Radxa Zero 3E + Armbian では、内蔵 Ethernet のインターフェース名が eth0 でも end0 でもなく end1 だった。これをミラーポート(スイッチのミラー出力)に繋いだところ、社内ネット全体がインターネットに繋がらなくなった。
原因は、ミラー側インターフェースが DHCP を投げようとし、受信したフレームに応答パケットを返して、それがミラー元ネットワークに逆流していたこと。旧機(Pi 2)では、ミラー側に別サブネットの固定 IP を最初から振っていたので、この問題が表面化していなかった。設計上の意味があったわけだ。
対策:
- ミラー側(
end1)を DHCP 無効・別サブネットの固定 IP にする - 送信を完全に遮断する(nftables で当該インターフェースの OUTPUT を drop)
- mDNS もミラー側で応答させない(avahi の
deny-interfaces)
これで「受信専用」になり、逆流しなくなった。ミラーポート監視をやる人は、監視側インターフェースを送信できない状態に閉じ込めておくのが安全だと思う。
Armbian は Netplan 経由で systemd-networkd を管理する構成で、既定が match: name "e*" で全 Ethernet に DHCP を投げていた。内蔵 Ethernet が enx+MAC の別名も持っていて match の除外に手こずったので、最終的に Netplan を使わず /etc/systemd/network/ に直接 .network を置いた。
Radxa Zero 3E の実機で気づいたこと
- 物理スイッチが一つもない。 リセットも、電源も、Maskrom(低レベル復旧モード)ボタンすら無い。3W にはある Maskrom が 3E には無い。
- したがって microSD が起動不能になったら、抜いて別マシンで焼き直すのが唯一の復旧手段。 ゴールデンイメージが文字通り最後の砦になる。
-
電源 LED はシャットダウン後、消灯までやや時間がかかるが、最終的には消える。停止確認は
pingの応答断か LED 消灯で。 - /boot は ext4(単一パーティション)。Raspberry Pi のように「カードを挿すだけで FAT の /boot が読める」わけではないので、世代管理はイメージのファイル名でやることにした。
- USB-C が2つ並んでいて、片方が電源(OTG)、片方がホスト。基板のシルクに
USB_OTG/5V INと書いてあるが、挿し間違えると起動しないので注意。
物理スイッチが無い環境での固定 IP 設定
旧機(Pi 2)では、GPIO に Matrix LED とタクトスイッチを載せた自作の基板を挿していて、これで IP を LED に表示し、タクトスイッチ長押しで DHCP に戻す、という物理的な非常手段があった。
Zero 3E では、この自作基板は使わない方針にした。物理スイッチも無い。そこで、非常手段をソフトウェアで実現した。
固定 IP を適用するとき、一定時間内に「確定」操作がなければ自動的に元に戻すタイムアウトロールバックを入れた。
- 適用 → 新 IP で管理画面に再アクセスできたら「確定」→ ロールバック解除
- 確定しなければ、数分後に自動で元(DHCP)に戻る
肝は、ロールバックをアプリのプロセス内の setTimeout でやらないこと。 アプリが落ちたら復旧しない。systemd-run の一時タイマーで、独立したスクリプトとしてスケジュールした。これなら本体プロセスが死んでも、OS のタイマーが元に戻してくれる。物理タクトスイッチの、より確実な代替になった。
mDNS の -N 現象
セットアップ時の接続に mDNS(ホスト名.local)を使えるようにしたが、運用で -2.local -3.local に化ける現象がある。同じ名前を主張する応答者がいたり、再起動で名前を一瞬手放した隙に起きる、mDNS の仕様上の挙動だ。
過去には別プロダクトで -365.local まで積み上がった例もあった(再起動のたびに番号が増え、過去の名前がネットワークのキャッシュに残って成仏しない)。ここまで来ると人間が試せる範囲を超える。
なので、接続の主軸は固定 IP、mDNS は補助という位置づけにした。日常は .local で十分繋がるが、化けたときの保険として固定 IP を必ず設定できるようにしてある。
「絶対に止まらない」ための可用性設計
旧機は「何か問題が起きたら自動で再起動する」設計だった。病院の電話対応を支えるので、止まってはいけない。これを systemd で再現した。
-
クラッシュ →
Restart=on-failureで自動再起動 - ハング(プロセスは生きているが応答しない)→ ハートビートファイルを定期的に更新し、鮮度が切れたら別タイマーが再起動(旧機の cron 監視スクリプト相当)
- メモリリーク → cgroup のメモリ使用量を監視し、閾値を超えたら予防的にサービス再起動(旧機は「使用率が一定以上でリブート」していた。今回はボードごと再起動でなくサービス再起動にしてダウン時間を最小化)
ポイントは、「事故による停止」と「意図的な停止」を区別すること。 ライセンス無効化のような意図的な停止では、プロセス自体は生かしたまま、監視系のみを内部で止める。プロセスが生きているので systemd から見れば正常=自動再起動の対象にならず、再起動ループに陥らない。
ケースは 3D プリントで
専用ケースの既製品がほぼ無いので、コミュニティ製の STL(Radxa Zero 3E 用、蓋とベースの2部品)を 3D プリントすることにした。
ここで、日本と中国の受託の価格差を実感した。
- 日本の受託(MJF / PA12 ナイロン) … 2部品で約 6,000 円
- 中国の受託(同じ MJF / PA12、色は黒) … 2部品で本体 6 ドルほど、送料込みでも 12 ドル(約 1,800 円)
プリント基板を中国に発注したときとまったく同じ構図だった。品質・精度が本当に要る領域は国内の業者に分があるが、こうした「普通に良いもの」を1個〜少量作るなら、中国の受託が圧倒的に安い。量産するならまとめて発注すれば送料も薄まる。
(教訓:慣れない領域では、AI や検索の最初の提案を鵜呑みにせず、一度「他に安い選択肢は?」と調べる余白を作ったほうがいい。急いで日本の受託で1個発注してしまった後で気づいた。)
納品用イメージの作り方
最後に、工場出荷状態のゴールデンイメージを作った。
- 検証で入れた設定・データ(ライセンス状態、テスト顧客、履歴、シェル履歴、個人情報を含むバックアップ等)をすべてクリアして工場出荷状態に戻す
-
ddで microSD からイメージを採取し、SHA-256 を記録、圧縮して保管 - リポジトリにバージョンタグを打ち、イメージ・タグ・コードのコミットが一致するようにした(「このイメージはどのソースから作られたか」が後から追える)
- イメージとチェックサムを、ローカルとクラウドの2箇所に保管
Maskrom が無い=焼き直しが唯一の復旧手段なので、このイメージが失われると製品を再現できない。複数箇所への保管は必須。
まとめ
- Raspberry Pi 2 → Radxa Zero 3E への移行は、有線 LAN 内蔵・技適不要・供給保証、という点で妥当だった
- 先に正本化とテストデータ採取をやったことで、新実装の等価性をテストで担保でき、迷わず進められた
- ハマったのは主にネットワーク(ミラー側インターフェースの逆流)。監視側は送信遮断で受信専用に閉じ込めるのが安全
- 物理スイッチが無い分は、ソフトウェア(タイムアウトロールバック、systemd の監視)で補える
- 開封から配れるイメージまで、実質3日ほど。AIのお陰であることは言うまでもない。
同じように「古い SBC で動いている自作機器を、新しいボードに載せ替えたい」人の参考になれば。特にミラーポート監視をやる場合、監視側インターフェースの扱いには気をつけてほしい。