プログラミング
OpenTelemetryでエスプレッソマシンを計装する
Instrumenting my espresso machine with OpenTelemetry (clickhouse.com)
要約
この記事では、筆者がエスプレッソマシンの抽出プロセスを改善するために、OpenTelemetryを使用してマシンを計装した事例を紹介しています。エスプレッソマシンを分散システムとみなし、ClickStack(ClickHouse、OTelコレクター、HyperDX)を用いてオブザーバビリティを確保しました。ESP32マイクロコントローラーへのOTLPの適合と、ネットワークI/Oが制御ループをブロックしないようにすることが主な課題でした。
全文翻訳
ほとんどのオブザーバビリティの話は、本番環境でのインシデントから始まります。この話は、まずいエスプレッソの一杯から始まります。美味しいエスプレッソを追求したことがないなら、その問題は一文で説明できます。始めはシンプルに思えます。豆を挽き、平らにタンピングし、圧力をかけて熱湯を通します。しかし、コーヒーが単なる機能的なものから、完璧にしたいと思うものになった瞬間、宇宙全体があなたに敵対していることを発見します。湿度は、一晩で豆の挽き具合を変えます。挽き具合は、水がお湯のパックを通過する速度を変えます。圧力は、抽出する風味を形作り、抽出時間は、甘みに行き着くか、苦くて焦げたものになるかを決定します。豆自体が動くターゲットであり、週ごとに熟成していきます。これらの変数のそれぞれが他の変数に影響を与え、あなたの唯一のフィードバックは味と昨日の試みの漠然とした記憶です。
私がここでやろうとしていることは、不調なシステムに対して行うことと同じです。可能な限り多くの変数を測定し、クエリできる場所に保存し、一つずつ制御を開始します。私はGaggiaエスプレッソマシンを持っており、GaggiMateを実行しています。これは、標準の脳をタッチスクリーン、PIDループ、圧力プロファイリングに置き換えるオープンソースのESP32コントローラーです。これは本当に印象的なキットです。しかし、一杯のエスプレッソが酸っぱくなったり、パックを通り抜けてしまったりしたとき、その理由が全くわかりませんでした。挽き具合が粗すぎたのでしょうか?抽出中にボイラーの温度が下がったのでしょうか?パックの片側から水が均等に抽出されずに流れてしまったのでしょうか?これらはまさに、オブザーバビリティが、少なくとも分散システムのために、解決するために作られた質問です。そこで、私はエスプレッソマシンを、本番環境でデバッグしたい他のサービスと同じように扱いました。OpenTelemetryで計装し、テレメトリをClickHouse Cloudに送信し、その上にClickStackを配置しました。エスプレッソマシンは驚くほど正直な分散システムであることがわかり、実際のインフラストラクチャのオブザーバビリティを実行するのと同じスタックが、マイクロコントローラーが30秒のエスプレッソを抽出するのを汗一つかかずに処理します。これは、その話のオタクバージョンです。プロトバフ、FreeRTOSスケジューリング、リングバッファ、カスタムコレクター、そしてそれらを結びつけるエージェントです。
ESP32にClickStackを使う理由
ClickStackはClickHouseのオブザーバビリティスタックです。取り込み用のOpenTelemetryコレクター、ストレージとクエリ用のClickHouse、そしてログ、メトリクス、トレース、セッションリプレイ用のUIとしてのHyperDXです。その全体的な目的は、スキーマに依存せず、OTelネイティブであることです。テレメトリがKubernetesクラスタから来るのか、コーヒーマシンから来るのかは気にしません。OTLPを話す限りは。この最後の部分が、このプロジェクトを可能にしたのです。データモデルを発明する必要はありませんでした。一杯のエスプレッソは、ClickStackがすでに理解している概念にきれいにマッピングされます。
メトリクス:ボイラー温度と目標値、ボイラー圧力(bar)、ポンプ流量、パック抵抗、スケール重量(グラム)、さらに総ショット数、抽出時間、消費水量、接続イベントの累積カウンター。
トレース:各ショットは親スパンであり、各抽出フェーズ(プリインフュージョン、ランプ、抽出)の子スパンがあります。各ショットスパンは20以上の属性を持ちます:ピーク圧力、流量、温度安定性、パック抵抗のばらつき。しかし、すべてがスパンに属するわけではありません。温度、圧力、流量は、ショット中に速すぎるため、単一のイベントで正確にキャプチャすることはできません。そのため、高速に変化する状態はメトリクスとして送信され、ショット自体はトレースとして送信されます。ショットはリクエストです。フェーズは子スパンです。品質の数値はスパン属性です。そのように見ると、エスプレッソマシンは、非常に短く、非常に美味しいトレースを持つ単なるサービスです。抽出プロファイルで設定された各フェーズを概説するトレース。各フェーズはスパンです:プリインフュージョン、ブルーム、ランプ、ホールド、ディクライン。
マイクロコントローラーでOTLPを取得する
OTelの実装における難しい部分はハードウェアでした。ESP32は、数百キロバイトの利用可能なRAMと、放棄できないリアルタイム制御ループを持っています。すぐに3つの問題が発生しました。
1. OTLPのプロトバフスキーマはESP32には大きすぎる
OTLPはプロトバフとして定義されており、これは素晴らしいことです。マイクロコントローラー用に正確に構築された成熟した組み込みプロトバフライブラリ、nanopbがあります。しかし、完全な上流のOTLP定義をそれに供給すると、コンパイル時に次のような結果になりました。
1#error Enable PB_FIELD_32BIT to support messages exceeding 64kB in size: 2 otlp_ScopeMetrics, otlp_ResourceMetrics, otlp_ExportMetricsServiceRequestCopy command
完全なスキーマは、コーヒーマシンが必要とするものよりもはるかに多くの情報を含んでおり、nanopbのフィールドサイジングはそれに反発します。より大きなフィールドサポートを有効にして、すべての場所でメモリコストを支払うのではなく、実際に必要なサブセットを1つの自己完結型のotlp.protoにフラット化し、上流のOTLPに一致するフィールド番号を保持しました。これがその様子です。スキーマ全体は1つのファイルであり、フィールド番号は意図的に順不同になっています。なぜなら、それらは私の番号ではなく、上流の番号だからです。
1// Flattened subset of OTLP. The protobuf wire format only depends on field 2// numbers and wire types, not package names or file layout. Keep every field 3// number identical to upstream OTLP or collectors will silently drop data. 4message NumberDataPoint { 5repeated KeyValue attributes = 7; 6fixed64 start_time_unix_nano = 2; 7fixed64 time_unix_nano = 3; 8oneof value { 9double as_double = 4; 10sfixed64 as_int = 6; 11} 12repeated Exemplar exemplars = 5; 13}Copy command
このフィールド番号の規律が、すべてのトリックです。番号が一致するため、ClickStackコレクター(および任意の標準OTelコレクター)は、デバイスのストリップダウンされたバイトを、完全なSDKの出力と同一にデシリアライズします。デバイスは方言を話しますが、コレクターはOTLPを聞き取ります。
2. ネットワークI/Oはエスプレッソをブロックできない
これは計装の最重要規則であり、クラウド環境よりもマイクロコントローラーでははるかに許容度が低いです。抽出制御ループは50msごとに実行されます。TLSハンドシェイクがそれを停止させると、PIDは抽出中にボイラー温度の調整を停止します。最悪の場合、コーヒーが台無しになり、最悪の場合、安全上の問題になります。そのため、プラグインはESP32の2つのコアに分割されています。制御ループとセンサーイベントハンドラはコア0を所有します。エクスポーターはコア1にピン留めされた独自のFreeRTOSタスクを持ち、そこでブロックするTLSハンドシェイクが抽出に触れることは決してありません。
1// Pin to core 1 so blocking TLS handshakes stay off core 0 (WiFi MAC, LWIP, 2// AsyncTCP, and the brew control loop all live there). 3xTaskCreatePinnedToCore(exportTaskFn, "OtelExport", 16384, this, 1, &taskHandle, 1);Copy command
2つのタスクは状態(最新のセンサー読み取り値、実行中のショット統計)を共有します。これはミューテックスを意味し、この設計で間違いやすい場所です。ナイーブなバージョンは共有状態をロックし、ペイロードを構築し、ネットワークに送信し、ロックを解除します。それはデータを保護しますが、TLSハンドシェイクに数秒かかる間、コア0のすべてのセンサーイベントハンドラが同じミューテックスを待って立ち往生することを意味します。うまくいくバージョンはスナップショットパターンです。ロックを取得し、共有状態をローカル変数にコピーし、ロックを解除し、それからエンコーダーまたはネットワークに触れます。ロックは、数ダースバイトをコピーするためにマイクロ秒間保持され、I/Oを横断することはありません。
1void OpenTelemetryPlugin::exportMetrics() { 2uint64_t shots; 3double brewSecs; 4std::vector<otel::Attribute> resAttrs; 5 6lock(); // held just long enough to copy 7shots = shotsTotal; 8brewSecs = brewSecondsTotal; 9resAttrs = resourceAttrs; // cached identity, more on this below 10unlock(); // released BEFORE any encoding or network I/O 11 12// Build the OTLP payload from the copies and POST it. However long the 13// TLS handshake takes, core 0 never waits on us. 14... 15}Copy command
結果として生じる不変条件:共有状態を保護するミューテックスは、ブロックするネットワーク呼び出しを横断して保持されることはありません。プロデューサー側でも同じ原則です。完了したショットスパンは、固定サイズのFreeRTOSキューに置かれ、エクスポートタスクがそれをドレインします。キューがいっぱいの場合、抽出スレッドを待たせないように、スパンはドロップされます。
1if (xQueueSend(spanQueue, &span, 0) != pdTRUE) { 2delete span; // queue full; drop rather