Xidorn Blog

Ubuntuのシステム情報をMQTT経由でHome Assistantに報告する

Home Assistantを使っていると、自然とより多くのデバイスを統合したくなります。多くの統合機能が標準で備わっていますが、汎用的なコンピューティングデバイスを統合するには、デバイス自体が情報を提供できるかどうかにかかっています。

最初は、いくつかのRaspberry Piデバイスノードを追加して、その状態を監視したいと考えていました。ネットでRPi-Reporter-MQTT2HA-Daemonというプロジェクトを見つけました。これはRaspberry Piの状態をMQTT経由でHome Assistantに報告できるもので、非常に便利だと感じ、これらのデバイスにインストールしました。しかし、後に新たなニーズが生まれ、このプロジェクトでは満たすことが難しくなりました。そこで、これに触発されて、Ubuntuをインストールしたデバイスの状態をHome Assistantに報告するためのubuntu-mqtt2ha-reporterを別途開発しました。

動機

前述の通り、RPi-Reporter-MQTT2HA-Daemon自体は非常に便利で、デバイスにインストールして実行するだけで、Home Assistant上に自動的にそのデバイスと関連センサーが表示されます。では、なぜ私がわざわざ別のプロジェクトを開発したのか?理由は多岐にわたります。

最大の理由は、もちろんこのプロジェクトがRaspberry Piに限定されていることです。最初はいくつかのRaspberry Piをデバイスノードとして購入・展開しましたが、次第にそれらが安価でもなく、性能も良くないことに気づきました。市場には企業から放出された中古のSFF(スモールフォームファクター)x86デバイスが多数あり、時にはA$100未満で手に入ります。状態は良好で、ケースや電源、より安定したストレージが付属しているだけでなく、より高速なCPUと大容量のメモリも搭載しています。サイズが少し大きく、消費電力が若干高いかもしれませんが、同程度の価格帯でRaspberry Piよりも一段上の性能を持っています。1このようなデバイスをいくつか手に入れた後、Raspberry Pi以外のデバイスで動作する報告サービスが必要になりました。

最初のアイデアは、そのプロジェクトをフォークして、Raspberry Piに特化しすぎないようにコードを修正することでした。しかし、そのコードを見てみると、いくつかの問題点に気づきました:

そこで、私は自分のニーズに合ったサービスをRustでゼロから構築することにしました。そのプロジェクトの全機能を備える必要はありませんが、少なくともx86デバイスで使え、デプロイが容易で、より明確で拡張可能なコード構造を持ち、機能追加がしやすいものであるべきだと考えました。

設計と実装

サービス全体はTokio非同期フレームワーク上で動作し、MQTT接続の処理にはrumqttを使用しています。設定ファイルはTOML形式2で、定義と解析にはシンプルにSerdeを利用しています。

サービスのメインループは、4つの独立して実行されるループで構成されています:MQTTクライアントのイベントループ、可用性の定期的な発行、コマンド受信処理、そしてセンサー状態の定期的な発行です。

センサー

各センサータイプは独立したモジュールとして実装されており、各モジュールは任意の数のセンサーを提供できます。例えば、CPUモジュールは全体のCPU使用率と各論理CPUの使用率の両方を提供し、これらは別々のセンサーに分かれています。ただし、各モジュールはデータを単一のMQTTトピックにしか報告できず、値テンプレート(value template)を使って異なるセンサーのデータを抽出します。すべてのセンサーモジュールはtrait Sensorを実装する必要があります:

trait Sensor: 'static {
    type Payload: Serialize + 'static;
    fn topic(&self) -> &str;
    fn discovery_data(&self) -> Vec<SensorDiscovery<'_>>;
    async fn get_status(&self) -> Result<Self::Payload, Error>;
}

このうちdiscovery_dataは、サービス開始時に呼び出され、Home Assistantが関連するセンサーの状態情報を設定・受信できるようにするための発見情報(discovery information)を生成します。get_statusは定期的に呼び出され、送信するセンサーデータを取得します。発見データと状態ペイロードは、センサー情報が正しく表示されるように協調する必要があります。例えば、CPUセンサーが送信するデータは次のような形式です。

{
  "total": 0.4,
  "per_cpu": [0.3, 0.5, 0.5, 0.4]
}

そして、その発見データでは次のように指定されます。

// CPU使用率
"value_template": "{{ value_json.total }}",
// CPU0使用率
"value_template": "{{ value_json.per_cpu[0] }}",

異なるセンサーモジュールが提供するデータは同時に送信する必要はなく、センサーループは対応するget_statusが返ってきた時点で送信します。例えば、CPUセンサーは過去1分間のCPU使用率を監視するため、サービス開始から1分後に初めて報告可能なデータが得られます。一方、メモリモジュールは単に/proc/meminfoを読み取ってデータを取得するため、すぐに値を返すことができます。この場合、まずメモリの状態が送信され、CPUはデータが利用可能になった後に送信されます。

CPUセンサーは特に厄介なセンサーです。多くのシステム監視ソフトウェアでは単純なパーセンテージで表示されますが、システムが直接このパーセンテージを提供しているわけではなく、自分で計算する必要があります。ここでの実装は、独立したループで1分ごとに/proc/statを読み取り、システムが異なるタスクに費やした総サイクル数を取得し、そのデータに基づいて過去1分間のビジーサイクルの割合を計算し、その結果をチャネルに送信するというものです。get_statusが呼び出されるたびに、このチャネルから最後に更新されたデータを読み取って返します。ここで1分間の平均を取るのは妥協案です。ほとんどのローカル監視ソフトウェアはサンプリング間隔を非常に短く設定し、CPU使用率の変動が連続的に見えるようにします。しかし、リモートに報告し、デフォルトで5分に1回しか報告しない場合、100ミリ秒や1秒のサンプリング間隔で得られるデータの意味はかなり小さくなると感じたため、より長い時間範囲の平均を使用することにしました。

CPUセンサーと同様に、ネットワークスループットのセンサーも、独立したループで総転送量をサンプリングし、それをサンプリング間隔で割ってスループットレートを算出します。

その他のセンサーはほとんどが比較的シンプルで、基本的にはシステムが提供するデータを解析するだけです。例えば、メモリセンサーは/proc/meminfoを、ロードアベレージセンサーは/proc/loadavgを読み取り、ディスクセンサーはstatvfsコールを使用し、APTセンサーはapt-get --just-printを実行し、再起動センサーは/var/run/reboot-required/var/run/reboot-required.pkgsを読み取ります。

コマンドボタン

センサーと同様に、各コマンドボタンも独立したモジュールであり、それぞれtrait Commandを実装します:

trait Command: 'static {
    fn topic(&self) -> &str;
    fn discovery_data(&self) -> Vec<CommandDiscovery<'_>>;
    async fn execute(&self) -> Result<(), Error>;
}

既存のコマンドボタンはすべて、単にコマンドを一つ実行するだけです。Home Assistantはコマンドボタンからの情報返却をサポートしておらず、成功したかどうかさえも返せないので、内部インターフェースと実装も非常にシンプルです。

しかし、コマンドボタンを実装する上での複雑さの一つは、権限管理にあります。セキュリティのベストプラクティスとして、このサービス自体は非特権ユーザーで実行されるべきですが、再起動やスリープのようなコマンドはroot権限を必要とします。解決策は、sudoerファイルを設定し、これらの特定のコマンドをサービスが実行されているユーザーがパスワードなしで実行できるようにすることです:

ubuntu-mqtt2ha-reporter ALL=(root) NOPASSWD: \
    /usr/bin/systemctl reboot, \
    /usr/bin/systemctl suspend

発見データ

発見データには、Home Assistantのデバイスベースの発見メカニズムを使用しているため、すべてのセンサーとコマンドボタンが単一の発見データメッセージに含まれています。私の最初の実装や、RPi-Reporter-MQTT2HA-Daemonの実装では、各項目ごとに独立した発見メッセージを送信していました。この方法では、各発見データにデバイス識別子、接続情報、可用性トピックなど、多くの重複部分が含まれていました。デバイスベースのメカニズムは、この問題を解決します。

デバイスの一意な識別子には、/etc/machine-idのドキュメントで推奨されている方法を使用しています。つまり、このファイルに保存されている識別子を読み取り、アプリケーション固有のキーを加えて暗号学的ハッシュを計算し、その結果を識別子として使用します。systemdはsd_id128_get_machine_app_specific関数を提供していますが、今となっては思い出せない理由で、ドキュメントに記述されているアルゴリズムに従ってHMAC-SHA256で自前で実装しました。

ライフサイクル管理

rumqttを使ったMQTT接続の管理は複雑ではありませんが、いくつか微妙なテクニックがあります。このパッケージは、メッセージの送受信を推進するためにユーザー自身がイベントループを構築することに依存しています。これは、イベントループが独自のコルーチンで実行される必要があり、メッセージの送受信コードとコルーチンを共有できず、また、このコルーチンはメッセージの送信とサブスクライブのコードが実行される前に開始し、すべてのメッセージが送信完了した後に終了する必要があります。

前述の通り、メインループ内の一つのループは定期的に可用性情報を送信し、このループはサービスがシャットダウンする直前にデバイスを「利用不可」とマークするメッセージを送信する役割も担っています。つまり、MQTTのイベントループが、この「利用不可」をマークするメッセージが送信された後に終了することを保証しなければなりません。言い換えれば、メインループ内の4つのループは対等ではありません:

メインループのライフサイクル

メインループの終了を要求する方法は2つあります:

  1. SIGINTまたはSIGTERMシグナルを受信し、システムがプロセスの停止を要求する場合。デバッグ時やsystemdによる停止時に一般的なメインループの終了方法であり、プロセス全体も終了させます。
  2. システムがスリープ状態に入ろうとしていることを検出した場合。このサービスは実行中にsystemdのinhibitor lockを保持しており、システムがスリープに入ろうとしていることを検出すると、メインループも終了させ、デバイスがスリープに入る前に正しく「利用不可」とマークされるようにします。その後、メインループが終了すると、サービスはinhibitor lockを解放し、システムが再びウェイクアップするのを待ちます。3

Debianパッケージ

当初、このサービスをデプロイするには、単にファイルをターゲットデバイスに転送し、手動で設定していました。しかし、デプロイするデバイスが増え、短期間にバージョンが頻繁に更新されるようになると、手動でのデプロイは非常に面倒になりました。自動構成ツールを使うことも考えましたが、最終的にはターゲットがUbuntuデバイスだけであることを考慮し、標準で備わっているスーパーカウパワーがあるAPTパッケージ管理を使うのが一番便利ではないかと考えました。

検索してみると、cargo-debというツールがRustプロジェクトを直接debファイルにパッケージ化できることを知り、これを使うことにしました。debパッケージを使えば、必要なユーザーやグループの作成、systemdサービスの設定、sudoerファイルの設定などを自動化できます。その後、さらにデプロイを容易にするために、プライベートなAPTリポジトリを構築し、ターゲットマシンで自動更新されるように設定しました。4もちろん、プライベートAPTリポジトリの設定はまた別の話ですが。

ユースケース

このサービスの最大のユースケースは、もちろん様々なデバイスの監視です。私はこれを自分のVPSと、自宅でUbuntuを動かしているデバイスにデプロイして、その状態を監視しています。アップデートを適用するために再起動が必要かどうかを示すセンサーがあり、デバイスを直接再起動できるコマンドボタンもあるため、再起動待ちのデバイスをHome Assistantから直接再起動でき、わざわざSSHで接続する必要がありません。このプロセスは完全に自動化することも可能ですが、私は少し手動の部分を残しておきたいと思っています。

それ以外にも、このサービスが提供する情報を使って他の自動化も行っています。例えば、自宅のテレビにPCを接続して簡易的なセットトップボックスとして使い、何らかの理由でテレビで直接再生できないコンテンツを再生しています。以前、Nintendo Switchを購入したとき、Switchをテレビに接続していると、Switchの電源を入れるとテレビが自動的にオンになり、対応する入力ソースに切り替わり、テレビの電源を切るとSwitchも自動的にオフになることに気づきました。資料を調べた結果、これはHDMI-CECメカニズムによって制御されていると確信しましたが、私の接続ケーブルはHDMI-CECをサポートしているものの、接続しているPCのグラフィックカードはサポートしていませんでした。しかし、このサービスのおかげで、Home Assistantのオートメーションを設定し、このPCがウェイクアップしたことを検出すると自動的にテレビをオンにして入力ソースを切り替え、さらに別のオートメーションでテレビがオフになったことを検出するとPCも自動的にスリープ状態にするようにできました。

今後

私自身のニーズは基本的に満たされたので、このプロジェクトは再び一時的に非アクティブな状態になるかもしれません。

今後考えられる改善点としては、以下のようなものがあります:

いずれにせよ、このプロジェクトは現在、一応使える状態にあると思います。何か提案や改善点があれば、issueやpull requestを歓迎します。

脚注

  1. 私が今一番気に入っている中古SFFは、Dell Wyse 5070シリーズのシンクライアントです。消費電力はRaspberry Piに近いのに、性能はかなり良いです。実は、私のHome Assistantは最初Raspberry Pi 4Bで動かしていましたが、後に5070に移行しました。移行後は負荷とコア温度が大幅に下がり、プラグインの更新などのタスクの速度は著しく向上しました。

  2. 現在の設定項目であればINI形式でも十分かもしれませんが、TOMLの方がより多くの可能性を残しており、より完全なフォーマット定義があり、Rustでのサポートも成熟しています。

  3. 当初は、/usr/lib/systemd/system-sleep/にスクリプトを配置し、システムがスリープする前にサービスを自動的に停止させ、ウェイクアップ後に再開させるという方法をとっていました。しかし、これをdebパッケージに含めようとしてドキュメントを読んだところ、この方法はハックと見なされており、より正しい方法はinhibitor lockを使用することだと知り、このメカニズムに切り替えました。

  4. cargo-debでビルドされたパッケージは、もともと自動更新でサポートされていませんでした。私はこのためにPRを提出し、生成されるパッケージが他のdebパッケージに近くなるようにして、より広範な互換性を確保しました。