- Oxideのネットワークスイッチのファームウェアが、電源シーケンシング変更のテスト後に起動しなくなり、原因はHubrisカーネルのIPCメモリ貸与チェックが新しいメモリ配置方式と衝突したバグだった
- HubrisはMPUでタスクを分離する組み込みOSであり、IPCで他タスクにメモリを貸し出す際、そのメモリが実際にアクセス可能な領域かをカーネルが確認する
- 最近導入されたタスクパッキングは、一部のファームウェアイメージでRAMを30%回収したが、従来のチェックは貸与メモリが単一のMPU領域内にあると仮定していたため失敗した
sequencerタスクは、0x801bffdアドレスを含むメモリをI2Cドライバに貸し出そうとしてsynthetic memory faultで停止し、humility tasksには115回の再起動とmem fault... in syscallの状態が残っていた
- 修正は、隣接する複数のMPU領域をまたぐ貸与を許可するようチェックアルゴリズムを変更する形で行われ、障害の発見からカーネルバグ修正まで約3時間で完了した
起動しないネットワークスイッチ
- OxideのArjen Roodselaarは、ネットワークスイッチのファームウェアで電源シーケンシングとクロック設定の変更をテストしていた際、一見ささいに見える変更の後でスイッチが起動しなくなる問題に遭遇した
- ファームウェアの一部は問い合わせに応答していたが、電源供給シーケンサを担当する重要な部分は停止したように見えた
- 電源シーケンシングのエラーは実際にハードウェアを損傷させる可能性があるため、スイッチが死んだのか、単に応答していないだけなのかをまず確認する必要があった
Hubrisと限られたメモリ
- Hubrisは、キーボード内蔵コントローラのようなディープエンベデッドシステム向けのOSで、Oxide Rackの大きなプロセッサを起動するために必要な処理を担う目的で作られた
- Hubrisベースのファームウェアは、個別にコンパイルされる複数のプログラムであるタスク(task) で構成される
- 各タスクは、必要な標準ライブラリコードなどをそれぞれ自身で持つ
- タスク同士はハードウェアMPUによって分離され、互いをクラッシュさせたりメモリを破壊したりしないよう設計されている
- 主に利用しているARM Cortex-M系のARMv7-Mでは、保護メモリ領域は2のべき乗サイズである必要があり、そのサイズに合わせてアラインされていなければならない
- たとえば1024バイト領域に1バイト追加で必要になると、1025バイトではなく2048バイト領域になる
タスクパッキングが生んだ新たな境界
- 初期のHubrisは、タスクRAMに1領域、flashに1領域を使う単純な方式を採っていたが、タスク間に使えない空き領域が生じ、メモリが無駄になっていた
- Matt Keeterは、ビルドシステムが可能な場合に複数のpower-of-two領域を組み合わせてタスクを配置するよう改善した
- ハードウェアはタスクごとに最大8領域までしか許可しない
- 一部のファームウェアイメージではRAMを**30%**回収できた
- 最小クラスのデバイスは、毎回最適化が必要なほど逼迫していた状態から余裕を持てるようになった
- この変更により、タスクのflashとRAMの途中に予測しにくいMPU領域境界が生じうるようになった
humility tasksが残した手がかり
- ArjenはHubrisデバッガであるHumilityを使って失敗したスイッチを調査し、電源シーケンシングを担当するサービスプロセッサは生きて動作していたため、ハードウェア障害の可能性は低そうに見えた
humility tasksの出力では、sequencerタスクは次の状態を示していた
mem fault (precise: 0x801bffd) in syscall (was: wait: reply from i2c_driver/gen0)
- 同じタスクは115回再起動しており、Hubrisではほぼ常にクラッシュへの反応としてタスクを再起動する
- 状態文字列の意味は次の通り
mem fault: メモリ処理ルール違反
precise: 0x801bffd: 問題となった具体的なアドレスが分かる
in syscall: タスクは実行中ではなくシステムコール中だった
was: wait: reply from i2c_driver/gen0: I2Cドライバに送ったメッセージの応答を待っている最中だった
gen0は、i2c_driverがまだ一度もクラッシュしていないことを意味し、sequencerはgeneration 115だった
Hubris IPCとメモリ貸与
- HubrisタスクはIPCメッセージで通信し、メッセージは関数呼び出しのように動作する
- メッセージを送るタスクは停止する
- 受信側タスクがCPUの制御権を得る
- 結果が返ると送信側タスクが再び起きる
- IPCはRustの所有権モデルとうまく合うよう設計されており、タスクは自分のメモリの一部をIPCメッセージとともに他タスクへ貸し出せる
- I2Cデバイスと相互作用するタスクは、自身のメモリ区間をI2Cバスドライバに貸し出し、ドライバはその区間をその場で読み書きする
- バスドライバが別個のバッファプールを持つ必要が減る
- データコピー回数が減る
- 誤った実装だとセキュリティホールになりうるため、Hubrisカーネルはタスクが実際には所有またはアクセスできないメモリを貸し出すことを禁止している
- サーバはエラーコードを受け取る
- クライアントはfaultを受け、常に終了する
- これはバグ、破損、エクスプロイト可能性を示すアクセス違反として扱われる
synthetic faultと実際の原因
- Hubrisはfaultをreal faultとsynthetic faultに分けている
- real faultは、null pointer逆参照やコード領域への書き込みのようなハードウェアルール違反
- synthetic faultは、IPCやメモリ貸与のようにHubrisが追加したソフトウェアルール違反
sequencerのfaultは、IPCを通じてI2Cドライバにメモリを貸し出す過程で発生したsynthetic faultだった
- 問題のアドレス
0x801bffdは有効なflashアドレスだったが、2のべき乗境界より3バイト下にあり、不自然なパターンに見えた
humility memの出力は、sequencerタスクに属する2つのflash領域が0x801c000で接していることを示していた
LOW HIGH SIZE ATTR ID TASK
0x08018000 - 0x0801bfff 16kiB r-x--- 17 sequencer
0x0801c000 - 0x0801dfff 8kiB r-x--- 17 sequencer
- 2つの領域は同じタスクに属しているため、通常のプログラム実行ではハードウェアMPUが問題なくアクセスを許可できたが、カーネルのIPCメモリ貸与チェックは別の前提を置いていた
古い単純化がバグになった地点
- 従来のカーネルチェックは、貸し出そうとするメモリslice全体がタスクの単一領域の中に完全に収まっているかだけを確認していた
self.region_table().iter().any(|region| {
region.covers(slice)
&& region.attributes.contains(desired)
&& !region.attributes.intersects(forbidden)
})
- このコードは、作成当時の設計、つまりタスクごとにRAM領域1つとflash領域1つという前提に合っていた
- タスクパッキングが導入されると、同じタスクのメモリが隣接する複数のMPU領域に分割されうるようになり、従来の前提はもはや成り立たなくなった
- 通常のメモリアクセスはハードウェアMPUが直接チェックするため影響を受けず、そのメモリをIPCで貸し出そうとしたときだけ問題が表面化した
2つの機能が一緒に作った障害
- タスクパッキングは日和見的に動作する
- タスクごとに最大8領域という制限がある
- ハードウェアドライバタスクは、メモリマップドレジスタのために一部の領域をすでに使っている
- 空き領域スロットがあるときだけ、より賢い配置を試みる
- その結果、領域境界はタスク作成者にとって予測しづらい場所に現れる
- タスクAの小さなサイズ変更が、無関係なタスクBのMPU領域境界の位置を変えることがある
- デバッグコードを追加するだけで、配置の決定と領域境界が変わり、クラッシュが消える可能性がある状況だった
- Mattはすぐにビルドシステムでタスクパッキングを無効化し、Arjenが動作するファームウェアイメージを作れるようにすると同時に、カーネルバグの分析と修正を進めた
カーネル修正の方法
- 修正の核心は、貸し出そうとするメモリが正確に隣接した複数のMPU領域をまたいでも許可するよう、メモリアクセスチェックアルゴリズムを変更することだった
- 新しいアルゴリズムは、領域テーブルを1回だけ走査するよう設計された
- Hubrisは、タスクが制御できる時間計算量を持つ演算を露出しないようにしている
- 性能は貸し出すメモリサイズではなく、固定サイズの領域テーブルにのみ依存すべきだった
- 領域テーブルのサイズは8個で固定されている
- そのために、ビルドシステムはタスク領域をアドレス昇順でソートするよう変更された
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);
- 修正コミットでは、カーネルがこの並び順の性質を利用して、より低コストなアクセスチェックを行えるようにしている
- 複雑化したコードはHubrisカーネルの中核部から切り離され、より移植性の高いcrateへ移され、重要なcorner caseに対する単体テストが追加された
- 新しいコードによりタスクパッキングを再び有効化できるようになり、タスク開発者に予測しづらいクラッシュを残さずに済むようになった
障害が大きく広がらなかった理由
- 全体の流れは、ネットワークスイッチが起動しない状態から始まり、約3時間でカーネルバグ修正まで到達した
- fault isolationのおかげで、23個の分離タスクで構成されたスイッチファームウェアのうち、繰り返し停止していたのは
sequencerだけで、他の多くの構成要素は動作を続けていた
- ファームウェア更新システム
- 管理・制御インターフェース用のIPネットワークスタック
echoプロトコル実装からラック制御プレーンインターフェースまでの各種ネットワークサービス
- センサー、ファン、その他システム状態監視用のI2C、SMBus、PMBus
- 前面の32個のQSFP 100Gトランシーバドライバ
- Hubris IPCは、他タスクが失敗しうることを前提に設計されており、冪等としてマークされた処理は透過的に再試行できる
- 既存のメモリアクセスチェックのバグは、正しいプログラムのアクセスを妨げる形であり、不正または悪意あるアクセスを許可する形ではなかったため、セキュリティ影響はなかった
sequencerとI2Cドライバが事実上メモリを共有する瞬間にsequencerは停止したが、I2Cドライバは破損リスクなく動作を続けていた
デバッグ基盤とチーム運用
- HumilityはHubrisカーネルとともに発展してきたデバッガであり、Arjenは数分以内にクラッシュしたコード位置を行番号レベルまで特定し、サービスプロセッサの独立したスナップショットを共有できた
- Hubrisは、クラッシュしたタスクの圧縮コアダンプをRAMに記録し、ネットワーク経由で回収できる
- 書き込み可能な永続ストレージがなくてもクラッシュダンプを取得できる
- クラッシュダンプ機能はカーネルではなく別タスクにある
- これらのプロセッサは顧客ワークロードデータを扱わず、システム管理トラフィックだけを処理し、クラッシュレポートも自動アップロードされない
- Hubrisカーネルのアーキテクチャ非依存部分はコード1,789行とコメント1,192行規模で、ARMv6-M・ARMv7-M・ARMv8-Mサポートはコード1,075行とコメント534行が追加される
- カーネルの概念とIPCがシンプルなため、faultがIPCを指しているときに確認すべき場所は多くなかった
1件のコメント
Hacker News のコメント
Hubrisは本当に良い。カーネルコードを30分ほど読んでみたが、以前見たような
ifdefマクロだらけ、2文字の変数名を好む、コメント不足のCコードとはほど遠く、とても明快によく書かれている。寝る前の読み物としても良いし、一度ざっと見てみることを勧める: https://github.com/oxidecomputer/hubris/blob/b44e677fb39cde8...
ソースコードのディスク容量は40年前から大きな問題ではなかったのに、いまだに変数名にはけちけちしている。
AIが特定のコーダーの好みや癖を正確に学習したからだ。
良い記事だが、下のコメントの位置は惜しい。
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);の上にある「アドレス昇順にソートする必要があり、カーネルはこの性質を利用してアクセス検査を安く行う」というコメントは、この関数の詳細というより、すべての書き手が守るべきで、すべての読み手が活用できるフィールド不変条件だ。だから
TaskDesc::regionsのドキュメント文字列に入れるほうが正しそうに見える: https://github.com/oxidecomputer/hubris/commit/b44e677fb39cd...おそらく最善の方法は、
TaskDescにコンストラクタメソッドを作って領域をソートさせ、不変条件を強制することだ。コードが時間とともに複雑になっていくのが見えるので、そろそろ複雑さをメソッドの中に包み込むことに少し時間を使う価値がありそうだ。これまで見た中でも指折りの良い求人広告だ。自然に文化の話へ移り、最後に「ちなみに採用中」と付く流れが良い。
本当に素晴らしい事後分析記事で、アプリケーション層の開発者である私にも追うことができた。ちょうど今『Rust in Action』を読んでいるところなので、こうした内容により備えができていたというのもある。
コードにたくさんコメントを書く人を見るのもいつも楽しい。文芸的プログラミングは有効だ。
前編はここで確認できるようだ。
「チームの緊密な非階層的統合」という部分が目を引く。Hubrisの機能そのものではないが、Hubrisとそれを作ったチームを切り離すのは難しく、Oxideのエンジニアリングチームには事実上、内部サイロがないという説明が印象的だ。
オープンさ、好奇心、コミュニケーションを奨励し、防御的姿勢、帝国づくり、ゲートキーピングを抑制する文化を、なぜ作ったのか、具体的にどう実装したのかをもっと聞きたい。こうした文化を組織内で育てる際に欠点はないのかも気になる。
より厳格な階層型システムを選ぶところもあり、組織図は戦略的に決めるべき場合もあるので、トレードオフがよくつかめない。
その構造が明示されていなければ、公開性が低く、意図的に選ばれたものでもなく、特に社会的相互作用が得意でない人には理解しにくい。そのため影のような性質ゆえに、より病的な行動を許してしまうことがあり、そこまでひどくならなくても調整をはるかに難しくし得る。
こうしたことを複数の会社で経験した。ある大手コンサルティング会社には公式な権力構造があったものの、実際にはあまり守られておらず、プロジェクトに入る方法も公式チャネルというより、営業や管理をしている人たちと親しくなることに近かった。必要な社会的ネットワークをうまく作れればよかったが、そうでなければうまく機能しなかった。
似た例として「The Tyranny of Structurelessness」がある。階層を家父長的なものと見なして拒んでいた組織内で同じことが起きるのを見たフェミニストの講演で、内部構造が明確でないValveでも似た議論がある。オープンソースプロジェクトも同じ問題を抱えることがあり、Rust周辺の一部の対立も似た問題に由来していると思う。
とはいえ、明示的な権力構造が必ずしも階層的である必要はない。伝統的なビジネス組織は階層型だが、Oxideの構造は明示的でありながら非階層的であり得る。こうした方式は通常、規模が小さいほどよく機能し、先に述べたコンサルティング会社は、自由型に近く運営されている会社として私が知る最大の例だったが、それでもある程度支える足場はあった。
これは二分法ではなくスペクトラムだ。紙の上では最も硬直した権力構造であっても、その下にはより複雑な暗黙の構造があり、それが人間集団の本性だ。
明示的な構造が常に暗黙的な構造より良いと信じているわけではない。あまり明示的でない組織で見られる欠点を述べただけで、より明示的な権力構造にもそれ自体の問題がある。関連して「seeing like a state」や可読性の問題もある。
複雑な問題をデバッグする過程を深く見せてくれる素晴らしい記事だ。残りのシステムが安定して保たれていたという事実は、Oxideチームのエンジニアリング品質をよく示している。
個人的にもかなり刺激を受け、似た手法を日常業務に適用してみるつもりだ。
そのハードウェアをソフトウェア補充TLBのように扱えば、8個より多い領域もサポートできる。
最後の手段でなければ絶対にやらないだろう。仮想ページングは厄介で、疑念を残したくない。
Oxideがやっていることは本当に驚きだ。
Oxideの人たちがやることは何でも気に入っているし、これもその一つだ。
OSの名前をHubrisにしたって? ああ、それは……とても口にできない。
正気の誰が今どき新しいOSを書くのか? 答えは、すべてのOSが見て見ぬふりをしている問題、つまりOSが制御していないし制御もできない、マザーボードと拡張カード上のコントローラの問題を解こうとする人だ。