モダンオペレーティングシステム 第5版 上

第3章後半(3.6〜3.8)。実装上の課題、セグメンテーション、そしてCPU脆弱性とOSの関係。


3.6 実装上の課題

OSがページングに関わるタイミング

ページング処理はいつ動くか。

【プロセス生成時】
→ 新しいページテーブルを作る
→ ディスクからプログラムをロードする準備

【プロセス実行時】
→ TLBミス → ページテーブルをたどる
→ ページフォルト → ページを物理メモリに載せる

【プロセス終了時】
→ ページテーブルを解放
→ 物理フレームを解放
→ ディスク上のスワップ領域を解放

【コンテキストスイッチ時】
→ ページテーブルの切り替え
→ TLBフラッシュ or ASID切り替え

k3sでPodが終了した時:

コンテナプロセス終了
→ カーネルがページテーブル解放
→ 物理フレーム解放
→ 次のPodがそのフレームを使える

命令の再実行問題

ページフォルトが起きた時、フォルトした命令を再実行する。でもここに厄介な問題がある。

メモリのコピー命令(100番地→200番地にコピー)
実行途中の150番地でページフォルト
        ↓
100〜149はすでにコピー済み
        ↓
命令を再実行
        ↓
100〜149を二重にコピー → 壊れる

対処法

方法1: 命令実行前に全ページが存在するか先読みチェック
→ なければ先にページフォルトを起こしておく
→ 全部揃ってから命令実行(x86寄りの方式)

方法2: CPUが内部状態を保存
→ どこまで実行したか記録
→ 再開時はその続きから(一部のRISCの方式)

ページのロック(ピン留め)

I/O処理中のページは絶対に追い出せない。

DMA転送中
→ デバイスが直接メモリに書き込んでる
→ このページを追い出したら
→ デバイスが存在しないメモリに書き込む
→ 最悪システムクラッシュ

だからI/O中のページには**ロック(ピン留め)**をかける。置き換えアルゴリズムの対象外になり、I/O完了後にロック解除。

Proxmoxとの絡み

VMのディスクI/O中
→ そのI/Oバッファのページはロックされてる
→ ホストのメモリが逼迫しても追い出せない
→ VM数が増えるとI/Oが詰まりやすい理由の一つ

ページングとI/Oの連鎖

ページフォルトとI/Oが組み合わさると厄介な連鎖が起きる。

ページフォルト発生
→ ディスクからページを読み込む(I/O)
→ I/O待ちの間プロセスはブロック
→ 別のプロセスにCPUを渡す
→ そのプロセスもページフォルト
→ さらにI/O待ち
→ I/O待ちのプロセスが積み重なる
→ スラッシング

k3sでの対処:

PodのmemoryRequestを正しく設定
→ ワーキングセットをメモリに収める
→ ページフォルトを最小化
→ I/O待ちの連鎖を防ぐ

kubectl top podでメモリ使用量がrequestを常に超えているPodはスラッシング候補。


マップトファイル(Memory-mapped file)

ファイルを仮想アドレス空間に直接マッピングする仕組み。

通常のread()
ディスク → カーネルバッファ(コピー1回)→ ユーザーバッファ(コピー1回)

mmap()
ディスク → 仮想アドレス空間に直接見える(コピーなし)

アクセスした瞬間にページフォルトが発生して、その部分だけディスクから読む。デマンドページングと同じ仕組み。

tmpfsとの違い

mmap()  → ディスクのファイルをメモリに見せる(ディスクが実体)
tmpfs   → メモリをディスクに見せる(メモリが実体)

方向が逆。docker run --tmpfs /tmpや、k3sのemptyDir: medium: Memoryがtmpfs。Node再起動で消える。

DBのバッファプールとの違い

mmap()
→ OSがページ単位で管理
→ どのページをメモリに乗せるかOSが決める
→ DBの文脈(このテーブルはフルスキャンだからキャッシュ不要等)を知らない

バッファプール(MySQLのinnodb_buffer_pool等)
→ DBが自前でLRU管理
→ トランザクションの文脈を知っている
→ パフォーマンスが予測可能

MySQLもPostgreSQLも自前バッファプール派。SQLiteはmmap()派。用途の違い。


3.7 セグメンテーション

発想

ページングは4KB単位でメモリを管理する。でもプログラマから見たメモリは4KB単位じゃない。

プログラムの構造
├── コード(テキスト)セグメント → 実行命令が入る
├── データセグメント           → グローバル変数等
├── スタックセグメント         → 関数呼び出しの管理
└── ヒープセグメント           → malloc()で動的確保

これを全部4KBのページに押し込めるのは不自然。「意味のある単位で分けて管理したい」という発想がセグメンテーション。


仕組み

各セグメントはベースアドレスとリミットを持つ。

セグメントテーブル
┌────────┬──────────┬────────┐
│セグメント│ ベース    │ リミット │
├────────┼──────────┼────────┤
│ コード  │ 0x000000 │ 10KB   │
│ データ  │ 0x010000 │ 5KB    │
│ スタック │ 0x020000 │ 8KB    │
└────────┴──────────┴────────┘

アドレス変換:

仮想アドレス = セグメント番号 + オフセット
        ↓
セグメントテーブルでベースアドレスを引く
        ↓
物理アドレス = ベース + オフセット
        ↓
リミットを超えてないか確認(超えたらセグフォ)

ページングとの違い

ページング
→ 固定サイズ(4KB)で分割
→ プログラマはページを意識しない
→ ハードウェア寄りの管理

セグメンテーション
→ 意味のある単位で分割(可変長)
→ セグメントごとに別々の保護属性をつけられる
→ プログラマ寄りの管理

セグメントごとに保護属性を設定できるのが強み。

コードセグメント → 実行可能・書き込み禁止
データセグメント → 書き込み可能・実行禁止

断片化の整理

ここで断片化の種類を整理しておく。

外部断片化
→ プロセス間の「穴」問題
→ セグメンテーション時代に発生
→ ページングで解決(固定サイズなので穴が生まれない)

内部断片化
→ ページ内の「余り」問題
→ ページングが生む副作用
→ 1バイトしか使わなくても4KB丸ごと占有してしまう

セグメンテーションは可変長なので、ページングが解決した外部断片化が復活する

セグメントA(10KB)終了 → 10KBの穴
セグメントB(5KB)終了  → 5KBの穴

[空き10KB][使用中][空き5KB][使用中]

15KBのセグメントを入れたい
→ 連続した15KBの空きがない → 外部断片化

ページドセグメンテーション

両方の弱点を補う組み合わせ。

セグメント単位で意味的に分割(保護属性も設定)
        ↓
各セグメントをページに分割
        ↓
ページ単位で物理メモリに配置
        ↓
外部断片化なし(ページ固定サイズのおかげ)
+ セグメントの保護属性あり
※ 内部断片化は引き続き許容する

現代x86_64での扱い

x86は歴史的にセグメンテーションとページングの両方を持っていた。

x86(32bit時代) → セグメンテーション + ページングの両方を使用
x86_64(64bit)  → セグメンテーションをほぼ廃止、ページングだけ

セグメンテーションの役割はページの保護属性(NX bit、書き込み禁止)が吸収した

# セグメントの痕跡を確認
cat /proc/$(pgrep nginx)/maps

# こんな感じで見える
7f1234000000-7f1234001000 r-xp  # コード(実行可能・書き込み禁止)
7f1234001000-7f1234002000 rw-p  # データ(書き込み可能・実行禁止)
7fff00000000-7fff00001000 rwxp  # スタック

r-xprw-pがページの保護属性。セグメンテーションの役割をページ属性で代替している。


「セグメントを意識してプログラムを書け」の意味

教科書に「セグメントはプログラマが意識する必要がある」と書いてある。現代での意味はこれ。

普段は意識しなくていい(コンパイラが自動でやる)

でも以下の場面では知らないと詰む
├── スタックオーバーフロー(スタックセグメントの限界を超えた)
├── NX bit違反(データ領域のコードを実行しようとした)
└── バッファオーバーフロー(セグメントの境界を超えた書き込み)

「普段は意識しなくていいけど、バグった時に知らないと原因がわからない」が正確。

基本情報技術者試験でページングはガッツリ出てセグメンテーションは概念だけなのも同じ理由。現代のOSで実際に使われているのはページングで、セグメンテーションはほぼ廃止されているから。


3.8 メモリ管理の研究:メルトダウンとスペクター

CPUの脆弱性をOSが対処した話

2018年に発覚した脆弱性。CPUの投機的実行(先読み最適化)を突いた攻撃で、OSのページテーブル管理と直結している。


投機的実行とは

CPUの先読み最適化
→ 「たぶんこの命令を実行するだろう」と先読みして実行
→ 外れたら結果を捨てる
→ 当たれば速い

これ自体は正常な最適化。問題はその副作用にある。


メルトダウン

投機的実行中にカーネルのメモリを読む
→ 本来アクセス禁止のはず
→ でもCPUが先読みでキャッシュに乗せてしまう
→ 後から「権限ないじゃん」と気づく
→ 結果は捨てる
→ でもキャッシュには残ってる ← ここが問題

キャッシュに残ったデータを直接読もうとしても権限チェックが入って読めない。でもキャッシュのタイミング差は読める。

攻撃の流れ(サイドチャネル攻撃)
投機的実行で秘密データをキャッシュに乗せる
        ↓
「アドレスAへのアクセスが速いか遅いか」を測定
        ↓
速い → キャッシュに乗ってる → そのbitは1
遅い → キャッシュに乗ってない → そのbitは0
        ↓
1bitずつ秘密データを復元

直接読むんじゃなくてタイミングを観測して間接的に推測する。「横から覗く」のでサイドチャネル攻撃と呼ぶ。

これによりユーザープロセスからカーネルのパスワードや暗号鍵が読める。Proxmox上では別VMのメモリまで読めてしまう。


OSの対処:KPTI(カーネルページテーブル分離)

対処前
→ カーネルとユーザーが同じページテーブルを共有
→ カーネルのアドレスがユーザーから見えてしまう

対処後(KPTI)
→ カーネル用とユーザー用でページテーブルを完全分離
→ ユーザーモードではカーネルのアドレスが見えない
→ 投機的実行でキャッシュに乗せられない

代償

KPTIを有効にする
→ コンテキストスイッチのたびにページテーブルを切り替える
→ TLBフラッシュが発生
→ パフォーマンスが5〜30%低下
# KPTIの状態確認
cat /sys/devices/system/cpu/vulnerabilities/meltdown

スペクター

メルトダウンより巧妙。

メルトダウン
→ カーネルとユーザーの境界を突破
→ 権限の壁を越える攻撃
→ KPTIで対処できた

スペクター
→ 分岐予測を意図的に誤らせる
→ 同じ権限レベルの中での攻撃
→ ブラウザのJavaScriptから同じプロセス内の別メモリを読む
→ サンドボックスの壁を越える攻撃

分岐予測を騙す

通常
→ CPUが「たぶんこっちの分岐だろう」と予測して先読み

スペクター
→ 攻撃者が意図的に分岐予測を誤らせる
→ 「自分が読みたいメモリにアクセスする分岐」を先読みさせる
→ キャッシュに乗ったらサイドチャネルで読む

2つの攻撃の共通構造

共通の経路(サイドチャネル攻撃)
投機的実行でキャッシュに乗せる
        ↓
キャッシュのタイミング差を観測する
        ↓
メモリの中身を推測する

入口の違い
メルトダウン → 投機的実行中に権限チェックをスキップ
スペクター   → 分岐予測を騙して意図した場所を先読みさせる

CPUの設計判断のツケ

投機的実行が実装されたのは1990年代
→ 「権限チェックより先に先読みした方が速い」
→ 「どうせ結果を捨てるから安全だろう」
→ 「キャッシュのタイミング差が観測されるとは思わなかった」

2018年に発覚
→ 約30年間誰も気づかなかった

セキュリティよりパフォーマンスを優先した設計のツケが30年後に回ってきた。

対処が最悪だった理由

ソフトウェアで完全に直せない
→ CPUのハードウェアレベルの問題
→ OSのKPTIは「被害を減らす」だけ
→ 根本解決はCPUを作り直すしかない

新しいCPU世代でやっと対処
→ Intelは何世代かかけて段階的に修正
→ 古いCPUは永遠に脆弱なまま
# Proxmoxで脆弱性の状態確認
cat /sys/devices/system/cpu/vulnerabilities/meltdown
cat /sys/devices/system/cpu/vulnerabilities/spectre_v2
# Vulnerable と出たら未対処

今日やった内容が全部繋がる

メルトダウン/スペクターの話は、第3章で学んだ内容の総決算になっている。

ページテーブル
→ カーネルとユーザーのアドレス空間を分離している
→ KPTIはこの分離をさらに強化した

TLB
→ KPTIでページテーブルを切り替えるたびにTLBフラッシュが必要
→ これがパフォーマンス低下の原因

コンテキストスイッチ
→ KPTIにより切り替えコストが増大
→ k3sで大量のPodが動くと影響が出る

投機的実行(先読み最適化)
→ CPUの「妥協の塔」における速度最適化
→ セキュリティとのトレードオフが30年後に爆発した

まとめ

第3章後半
├── 3.6 実装上の課題
│   ├── OSがページングに関わるタイミング(生成・実行・終了・スイッチ)
│   ├── 命令の再実行問題(途中でフォルトしたら二重実行の危険)
│   ├── ページのロック(I/O中は絶対に追い出せない)
│   ├── ページングとI/Oの連鎖(スラッシングの発生メカニズム)
│   └── マップトファイル(コピーなしでファイルを仮想空間に直接マッピング)
│
├── 3.7 セグメンテーション
│   ├── 意味のある単位(コード・データ・スタック)でメモリを分割
│   ├── 各セグメントに保護属性をつけられる
│   ├── 外部断片化が発生する(ページングで解決)
│   ├── ページドセグメンテーション(両方組み合わせ)
│   └── 現代x86_64ではほぼ廃止、ページの保護属性で代替
│
└── 3.8 メルトダウン・スペクター
    ├── 投機的実行(先読み最適化)の副作用を突いた攻撃
    ├── サイドチャネル攻撃(タイミング差でキャッシュの中身を推測)
    ├── メルトダウン(権限の壁を越える)→ KPTIで対処(5〜30%低下)
    ├── スペクター(分岐予測を騙す)→ 完全対処は困難
    └── CPUの30年前の設計判断のツケ、OSが割を食った

第3章完了。次回は第4章(ファイルシステム)。