gVisorのインストールと使い方でコンテナを保護する:独立したKernelでDockerを実行する

Virtualization tutorial - IT technology blog
Virtualization tutorial - IT technology blog

コンテナはホストのカーネルで直接動いている——それが問題の本質

Dockerを使っていて「コンテナが脱獄してホストを攻撃する可能性」を一度も考えたことがないなら、この記事はあなたのためにあります。

Linuxコンテナの本質はカーネルをホストと共有することです。ハイパーバイザーで完全に分離するVMとは異なり、コンテナはnamespaceとcgroupだけで隔離しています。つまり、攻撃者がコンテナ内の危険なsyscallを悪用できれば、ホストへの権限昇格が可能になります。

この種の攻撃をContainer Escapeと呼びます。runc CVE-2019-5736やDirty Pipe CVE-2022-0847といった有名なCVEも、コンテナ内からカーネルをexploitするという同じメカニズムを利用しています。

私はProxmox VEで12台のVMとコンテナを管理するホームラボを運用しています。本番環境に持ち込む前にあらゆることをテストするための実験場です。別の会社のKubernetes本番クラスターでのContainer Escapeのポストモーテムを読んだのをきっかけに、--read-onlyやケイパビリティのドロップだけに頼るのをやめて、本格的なハードニングを始めました。

その過程で見つけた解決策がgVisorです。

gVisorとは何か、そして通常の隔離方法と何が違うのか

gVisorはGoogleが開発してオープンソース化した、コンテナ向けのサンドボックスランタイムです。コンテナがホストカーネルに直接syscallを発行する代わりに、gVisorはSentryと呼ばれる中間レイヤーを挟みます。

SentryはGoで書かれたカーネルで、ユーザー空間で動作します。コンテナ内のアプリがopen()read()execve()を呼び出すと、Sentryがそれらのsyscallをインターセプトしてサンドボックス内で処理します。本当に必要なものだけが、非常に限られたsyscallセットを通じてホストカーネルに到達します。

イメージとしてはこうです:

  • 通常のDocker:アプリ → syscall → ホストのLinuxカーネル(直接)
  • gVisor:アプリ → syscall → Sentry(ユーザー空間の仮想カーネル)→ 安全な少数のsyscall → ホストのLinuxカーネル

結果として、ホストカーネルのアタックサーフェスが大幅に縮小します。攻撃者がコンテナアプリの脆弱性を悪用できたとしても、到達できるのはSentryまでであり、本物のホストカーネルには届きません。

gVisorは2つのプラットフォームをサポートしています:

  • ptrace:どこでも動作しますが、速度は遅め
  • KVM:仮想化対応CPUが必要ですが、大幅に高速(ホームラボではこちらを使用)

Ubuntu/DebianへのgVisorのインストール

ステップ1:リポジトリの追加とgVisorのインストール

# gVisorの公式GPGキーとリポジトリを追加する
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" \
  | sudo tee /etc/apt/sources.list.d/gvisor.list

sudo apt-get update && sudo apt-get install -y runsc

インストール後、バージョンを確認します:

runsc --version
# runsc version release-20240401.0

ステップ2:gVisorランタイムを使用するようDockerを設定する

/etc/docker/daemon.jsonを開くか作成します:

sudo nano /etc/docker/daemon.json

以下の内容を追加します(ファイルに既存の内容がある場合は上書きせずにマージしてください):

{
  "runtimes": {
    "runsc": {
      "path": "/usr/bin/runsc"
    }
  }
}

設定を反映するためDockerを再起動します:

sudo systemctl restart docker

ランタイムが認識されていることを確認します:

docker info | grep -i runtime
# Runtimes: io.containerd.runc.v2 runsc runc

ステップ3:gVisorでコンテナを実行する

通常のDockerコマンドに--runtime=runscフラグを追加するだけです:

# 通常のコンテナ(runcを使用、ホストカーネル)
docker run --rm ubuntu uname -r

# gVisorを使ったコンテナ(runscを使用、SentryカーネL)
docker run --rm --runtime=runsc ubuntu uname -r

興味深いのは、gVisorコンテナ内でのuname -rがホストとはまったく異なるカーネルバージョンを返す点です。それはSentryのカーネルであり、マシンの本物のカーネルではありません。

# 出力例
# ホストカーネル:    6.8.0-87-generic
# gVisorカーネル:  4.4.0
# (Sentryは互換性のために古いカーネルバージョンをエミュレートする)

ステップ4:Docker ComposeでgVisorを使う

docker-compose.ymlで保護したいサービスにruntimeを追加します:

version: '3.8'
services:
  webapp:
    image: nginx:alpine
    runtime: runsc
    ports:
      - "8080:80"

  database:
    image: postgres:15
    runtime: runsc
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

通常通り起動します:

docker compose up -d

ステップ5:gVisorをデフォルトランタイムに設定する(オプション)

特に指定しない限り、すべてのコンテナをgVisor経由にしたい場合はdaemon.jsonを更新します:

{
  "default-runtime": "runsc",
  "runtimes": {
    "runsc": {
      "path": "/usr/bin/runsc"
    },
    "runc": {
      "path": "/usr/bin/runc"
    }
  }
}

元のランタイムを使いたい場合は--runtime=runcを指定します。

gVisorが本当に隔離できているか検証する

セットアップ後にまず試すテストがこれです。サンドボックスが動作していることを確認しつつ、違いを視覚的に体感できます:

# gVisorコンテナ内で /proc/self/status を確認する
docker run --rm --runtime=runsc ubuntu cat /proc/self/status
# CapEff は通常のコンテナとは異なる値になる

# カーネル情報を読み取る
docker run --rm --runtime=runsc ubuntu cat /proc/version
# Linux version 4.4.0 (#1 SMP ...) — これはSentryであり、本物のカーネルではない

# サンドボックスのホスト名を確認する
docker run --rm --runtime=runsc ubuntu hostname
# 各コンテナは独立したサンドボックスを持つ

syscall制限のテストも追加で試します。ptrace(exploit手法でよく悪用されるsyscall)の呼び出しを試みます:

docker run --rm --runtime=runsc ubuntu bash -c \
  'strace -e trace=ptrace ls 2>&1 | head -5'
# エラーまたはsyscallがブロックされる——これが期待される動作

パフォーマンスと制限事項について

gVisorは万能薬ではありません。本番環境にデプロイする前に、いくつかのトレードオフを把握しておく必要があります:

  • syscallオーバーヘッド:すべてのsyscallがSentryを経由するためrunc単体よりレイテンシが高くなります。実際のベンチマークでは、I/Oが多いワークロード(データベースの継続的な書き込み、ファイル処理)は20〜40%遅くなる場合があり、CPU中心のワークロード(圧縮、暗号化)は通常2〜5%の増加にとどまります。
  • 互換性:SentryはLinuxのすべてのsyscallを実装しているわけではありません。あまり一般的でないsyscallや新しいカーネル機能を使うアプリは動作しない可能性があります。本番環境に投入する前に十分なテストを行ってください。
  • ボリュームマウント:バインドマウントのI/Oは通常のコンテナより遅くなります。可能であれば、スループットが必要なディレクトリには名前付きボリュームやtmpfsを使用してください。
  • VMではない:gVisorはVMよりはるかに軽量で、起動時間はミリ秒単位です。ただし、ハードウェアレベルの完全な隔離が必要な場合は、KVMを使ったVMが適切なツールです。

ホームラボでは、外部からコードを受け取るCIランナーや公開サービスのコンテナにgVisorを使っています。書き込み頻度の高い内部データベースは引き続きruncで動かしています。そこでの20〜40%のオーバーヘッドは、得られる効果と釣り合いが取れないと判断しているためです。

まとめ

ホームラボでgVisorを数ヶ月使ってみて、Container Escapeを懸念しているなら追加する価値のあるセキュリティレイヤーだと感じています。DockerfileやビルドプロセスをいじることなくContainer Escapeが気になる方に、--runtime=runscを指定するだけでコンテナが独自カーネルを持つサンドボックス内で動作します。

簡単なまとめ:

  • gVisorはSentryを通じてsyscallをインターセプト——ホストカーネルが直接露出することはない
  • インストールは5分で完了、イメージやDockerfileの変更は不要
  • 信頼できないワークロード、公開サービス、CI/CD環境に最適
  • パフォーマンスのトレードオフがある——I/O負荷の高いアプリは事前にベンチマークを取ること

Kubernetesを使っている場合、gVisorはRuntimeClass経由でもサポートされており、クラスター全体に影響を与えずPodごとに適用できます。インフラがオーケストレーション層に達しているなら、それが次のステップになります。

Share: