Live Patching

このページの目次

関数のエントリポイントが切り替わっても、実行中の古いスタックフレームが新しい実装にジャンプすることはありません。Livepatch の核心は、⁠遷移期間中の新旧ロジックの互換性であり、単なるエントリポイントのリダイレクトではありません。

これは、特別に設計・検証されたパッチに適用されるものであり、汎用のオンラインバージョンアップツールではありません。パッチは依然として、対象のビルドと設定に適合している必要があります。

参照目標読解箇所
いつタスクの切り替えを許可するか関数エントリ、タスク整合性、遷移
状態をどのように互換性を持たせるか累積パッチ、状態のライフサイクル
どのように停止し検証するか無効化とアンロード、PREEMPT_RT の境界

関数エントリとタスク整合性

ホットパッチはタスクを一貫したコードバージョンに移行する必要がある 新しいパッチの準備とエントリポイントの置換 対象関数を解析し、制御されたリダイレクトをインストール 1 各タスクが安全に切り替え可能な地点に到達 信頼性の高いスタックチェック、ユーザー空間境界などのメカニズム 2 遷移期間中、バージョンの一貫性を維持 タスクは一時的に異なるパッチ状態にある可能性がある 3 すべての変換が完了 その後、対応する古いパッチと状態のライフサイクルを処理 4 force は、より速い安全モードを待つことではありません。整合性チェックをスキップすると、正しさとアンロード条件が壊れる可能性があります。

一般的な実装は、動的な ftrace を使用して関数エントリでリダイレクトを行い、livepatch ハンドラはタスクのパッチ状態などに基づいて適切なバージョンを選択します。古い関数を実行中のスタックフレームは、エントリポイントを変更しただけでは自動的に新しい実装の途中にジャンプしません。

カーネルは、いつタスクを変換できるかを判断するために整合性モデルを使用します。信頼性の高いスタックチェックは、まだ影響を受ける呼び出しチェーン内にあるかどうかを識別でき、ユーザー空間への戻りなどの安全な境界も状態の進行を助けます。長時間カーネル内で留まるスレッドや、信頼性の高いスタック情報を持たないパスは、完了を妨げる可能性があります。Livepatch

実装を架空の IDLE、FORCE、IMMEDIATE という 3 つの汎用列挙モードに一般化することはできません。実際には、enabled、transition、force などのインターフェース状態と具体的なカーネルモデルを中心に理解する必要があります。

遷移完了以前、新旧バージョンは共存する可能性がある

遷移状態パッチは互換性を保つ必要があるもの
タスク A が新しいバージョンに切り替え済み新しいロジックが共有状態にアクセス
タスク B がまだ古い呼び出しチェーン内古いレイアウト、古いロック順序がまだ使用される可能性がある
関連するすべてのタスクが遷移を完了完了条件に従って次のフェーズを処理可能

共有データの形式と関数間の不変条件は、共存フェーズをカバーする必要があり、最終状態のみを対象に設計してはいけません。

1 つのタスクが長期間遷移を妨げる場合、そのスタックと待機理由を分析する必要があります。補助的なウェイクアップやシグナルは、実行中の任意の関数を安全に撤退させることができることを意味しません。force は対応する安全保障をバイパスし、その後のアンロード能力に影響を与えるため、通常のタイムアウト再試行ボタンとして使用してはいけません。

同じエントリポイントなのに、なぜ A と B が一時的に異なるバージョンを選択するのか

パッチが update と lookup の両方を同時に更新すると仮定します。A はスリープ中にスタック内に古い update を残しており、B の信頼性の高いスリープスタックにはこれらの関数が含まれていません。A は一時的に古いパッチ状態を保持し、B は先に切り替えることができます。その後の呼び出しリダイレクトでも、それぞれのタスクの状態に基づいて選択する必要があります。さもないと、A は古い呼び出しチェーン内で突然新しい lookup を呼び出し、両方が維持している規約を破壊する可能性があります。

遷移中はタスクごとにバージョンを選択し、古いスタックフレームの完了も必要 タスク A:スタック内に古い update が残っている v0 を一時的に保持し、その後の lookup も v0 を選択 タスク B:信頼性の高いスタックには影響を受ける関数がない v1 に切り替え可能、その後の lookup は v1 を選択 A が古い呼び出しチェーンを離れ、セキュリティチェックを通過してから、v1 に追いつくことができる エントリポイントのリダイレクトは、実行中の古いスタックフレームを上書きしません。2 つのタスクは依然として共有データに同時にアクセスする可能性があります。

以下のモデルは、信頼性の高いスリープスタックチェックとユーザー空間への戻り境界のみを示しています。これは、affected に一緒に置換する必要がある関数が完全にリストされていると仮定しており、ftrace、プリエンプション、スタック展開、またはカーネル状態ビットはシミュレートしません。

from dataclasses import dataclass

@dataclass
class Task:
    version: int
    stack: tuple
    sleeping: bool = True
    reliable: bool = True

    def try_stack_switch(self, affected, target):
        if not self.sleeping or not self.reliable:
            return False
        if any(function in affected for function in self.stack):
            return False
        self.version = target
        return True

    def return_to_user(self, target):
        self.stack = ()
        self.version = target  # このメソッドは、すでにユーザー空間への戻り境界に到達したことを表す

    def implementation(self, name):
        return f"{name}_v{self.version}"

affected = {"update", "lookup"}
a = Task(0, ("request", "update"))
b = Task(0, ("unrelated_wait",))
c = Task(0, (), reliable=False)
assert not a.try_stack_switch(affected, 1)
assert b.try_stack_switch(affected, 1)
assert not c.try_stack_switch(affected, 1)
assert a.implementation("lookup") == "lookup_v0"
assert b.implementation("lookup") == "lookup_v1"
assert not all(t.version == 1 for t in (a, b, c))
a.stack = ("request",)  # 古い update はすでに戻っており、この例では他のスタックフレームは影響を受けない
assert a.try_stack_switch(affected, 1)
c.return_to_user(1)
assert all(t.version == 1 for t in (a, b, c))
assert not Task(0, (), sleeping=False).try_stack_switch(affected, 1)

c のスタックリストは空ですが、「信頼性の高い展開で対象関数が見えなかった」からといって、「対象関数が存在しないことを信頼性高く証明した」わけではありません。したがって、モデルはこの結果のみに基づいて切り替えを拒否し、別の既知の安全な境界を待ちます。実際のプロトコルでは、チェックとタスクの継続的な実行の間に競合状態が発生しないように防止する必要があり、ユーザー空間が /proc/PID/stack を一度読み取ってからタスクのパッチ状態を自行で書き換えることはできません。Livepatch 整合性モデル

このようなタスクごとのルールでは、タスク間の共有データの互換性も自動的に解決されません。パッチ作者は、A の古いロジックと B の新しいロジックが同時に実行されたときに、ロック順序、オブジェクトの解釈、呼び出し契約を満たしているかどうかをさらに確認する必要があります。遷移の完了は必要な状態情報ですが、これらのセマンティック条件に代わるものではありません。

2 つのタスクをそれぞれ古い関数内または安全な位置に留めさせてから、パッチを有効にします。すでにスタック上にある古い呼び出しは、瞬時に書き換えられません。逆方向の切り替えとアンロードも、対応する境界を待つ必要があります。

図を準備しています
ターゲットバージョンが変わっても、古いスタックフレームはすぐにジャンプするのか?

エントリポイントのリダイレクトは、すでに実行中のスタックフレームを上書きしません。遷移期間中は新旧のタスクバージョンが共存可能。無効化は逆方向の遷移を完了してからでないと、パッチコードのアンロードについて議論できません。

累積パッチと atomic replace

複数の独立したパッチを重ね合わせると、順序と互換性の複雑さが増します。累積パッチには、既存の修正のセットを含めることができ、atomic replace を使用して、整合性のある遷移の中で以前のパッチセットを置き換えることができます。

ここで言う atomic は、すべての CPU が同時にすべての命令を書き換えることを意味するものではなく、データベーストランザクションのような任意のデータのロールバックを提供するものでもありません。それはパッチセットの置換に関連するメカニズムを指しており、タスクの変換を完了し、システム状態を維持する必要があります。Livepatch ドキュメントインデックス

新しい累積パッチに g が含まれていないということは?

元のカーネルが f、g を提供していると仮定します。P1 は f を修正し、P2 は g を修正します。今、replace セマンティクスを持つ P3 で古いセットを置き換えますが、P3 には f の新しい実装のみが含まれています。遷移が完了した後、g は P2 の修正を自動的に保持するのではなく、元の戻り値に戻ります。

完了した構成f は何を使用するかg は何を使用するか
P1 と P2 が有効P1 の fP2 の g
P3 は f のみを含み、古いセットを置換P3 の f元の g
P3 が必要な f、g の修正を同時に含むP3 の fP3 が持つ g
base = {"f": "base_f", "g": "base_g"}
p1 = {"f": "fix_f1"}
p2 = {"g": "fix_g1"}
p3 = {"f": "fix_f2"}
old_active = base | p1 | p2
replacement = base | p3
assert old_active["g"] == "fix_g1"
assert replacement == {"f": "fix_f2", "g": "base_g"}
p3_cumulative = {"f": "fix_f2", "g": "fix_g1"}
assert (base | p3_cumulative)["g"] == "fix_g1"
# 遷移やコールバックはシミュレートせず、完了後の関数セットのみを比較する。
disabled_replacement = base.copy()
assert disabled_replacement != old_active

したがって、累積パッチには、依然として保持する必要があるすべての修正を含める必要があります。新しいパッチを無効化しても、古いパッチセットが自動的に原子復元されるわけではありません。ロールバックする必要がある場合は、現在の状態と互換性のある置換準備が必要です。

さらに、atomic replace は主に新しい累積パッチの関連コールバックを実行し、想像される順序で置き換えられた各パッチのクリーンアップを実行するわけではありません。新しいパッチは必要な共有状態を継承する必要があります。古いモジュールが退出する際、新しいパッチがまだ使用している shadow variables を盲目的に解放してはいけません。Atomic Replace と累積パッチ

状態を持つ修正には明示的なライフサイクル設計が必要

使用中の構造体レイアウトを任意の新しいレイアウトに直接置き換えることはできません。古いオブジェクトは依然として元のレイアウトで存在します。しかし、「すべての状態を持つパッチが不可能である」というのも正確ではありません。shadow variables、コールバック、system-state tracking は、設計された状態変化をサポートできます。

パッチは、初回有効化、古いパッチの置換、遷取りの取り消し、無効化、および失敗時のクリーンアップを処理する必要があります。klp_state の id/version は互換性のある状態を追跡するために使用されますが、ビジネスオブジェクトがどのように変換されるかを自動的に理解するわけではありません。システム状態の変化

フィールドの幅が変わらなくても、状態移行が必要になる場合がある

古いオブジェクトの timeout=100 が 100 ms を表し、新しいコードが同じ整数を μs 単位に変更したいと仮定します。構造体のサイズ、フィールドのオフセット、および関数シグネチャがすべて変わらなくても、2 つのコードセットは同じビットパターンを異なる方法で解釈します:

オブジェクトの内容と読み取り側実際の解釈元の 100 ms との違い
古いオブジェクト値 100、新しいコードが μs で読み取る0.1 ms1000 分の 1 に短縮
新しいコードが 100000 を書き込み、古いコードが ms で読み取る100 s1000 倍に延長

すべてのタスクがコードの遷移を完了するのを待つだけでは、既存のオブジェクト内の 100 が自動的に 100000 になるわけではありません。遷移期間中、古いフィールドを共通の権威ある表現として保持し、新しいコードを境界で変換させることができます。永続的な表現を変更する必要がある場合は、互換性のあるバージョン識別子、オブジェクト更新プロトコル、および適切な変換タイミングが必要です。追加の状態ツールは、保存と追跡のメカニズムを提供しますが、変換順序やロールバックセマンティクスを作者に代わって決定するものではありません。Livepatch システム状態の変化

関数を置換する際、呼び出し側の契約も保持される必要がある

関数シグネチャと呼び出し規約は、保持される呼び出し側と互換性がある必要があります。修正がパラメータ、オブジェクトのライフサイクル、またはモジュール間の呼び出しに関与する場合、範囲は単なる 1 つの関数の置換をはるかに超える可能性があります。リダイレクト可能だからといって安全であると見なすことはできません。

無効化とアンロードは同じステップではない

通常の修正では、無効化を要求して逆方向の遷移を完了し、条件を満たしてから関連モジュールをアンロードできます。「livepatch モジュールは永遠にアンロードできない」というのは過度な一般化です。使用中であること、遷移が完了していないこと、強制遷移などの状態はアンロードを制限するため、実際のインターフェース条件に従う必要があります。

読み取り専用の観察は、/sys/kernel/livepatch 内の各パッチディレクトリの enabled および transition 属性から始めることができます。このディレクトリやエントリが存在しない場合は、サポートが有効化されていないかパッチがロードされていない可能性があります。これに基づいて、システムが特定の脆弱性を修正しているかどうかを推測することはできません。ディストリビューションは、通常のカーネル更新を通じて修正を行う場合もあります。

PREEMPT_RT は別の問題である

PREEMPT_RT は主に、非プリエンプティブな区間などによるスケジューリングレイテンシを低減するものであり、スレッド化された割り込み、スリープ可能ロック、優先度継承などのメカニズムに関与します。それは livepatch の一種のモードではなく、すべてのプリエンプション無効化操作を rt_mutex への置換として一般化することもできません。リアルタイムカーネルメカニズム

現代のメインラインには PREEMPT_RT の設定能力が提供されており、アーキテクチャのサポートとドライバの制約は、現在のターゲットと照合する必要があります。「6.12 でマージが完了に近づいている」といった期限切れの時間記述を残さないようにし、出典なく特定のマイナーバージョンが livepatch/RT の相互作用のすべてを解決したと約束しないでください。

ホットパッチの検証には、対象のワークロード、遷移の完了、無効化、および状態の互換性パスをカバーする必要があります。ビルドの成功や sysfs での enabled の表示は、機能の回帰テストに代わるものではありません。関連する方法については、デバッグとテスト を参照してください。