第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-xpやrw-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章(ファイルシステム)。