私の考え方を変えた午前2時のアラート
サーバーが初めてSSHブルートフォース攻撃を受けたとき、午前2時に鳴り響いたアラートによるあの沈むような感覚を今でも覚えています。その事件は、私に厳しい教訓を教えてくれました。それは「境界防御は単なる推奨事項に過ぎない」ということです。攻撃者がウェブアプリの脆弱性を突けば、彼らはコンテナ内に侵入します。標準的なDocker設定では、そのコンテナはホストのカーネルを共有しています。もし彼らがコンテナから脱出(エスケープ)すれば、アプリを支配されるだけでなく、インフラ全体が乗っ取られてしまうのです。
ほとんどのチームは、Dockerのデフォルトランタイムであるruncに依存しています。これは非常に高速ですが、マルチテナントの隔離を目的として設計されたものではありません。名前空間(namespaces)やcgroupsを使用して境界を構築していますが、アタックサーフェス(攻撃対象領域)は依然として巨大です。コンテナは依然としてホストカーネルに対して直接システムコール(syscall)を行うことができ、CVE-2019-5736のような脆弱性を突かれる隙を残しています。
根本的な問題:共有カーネル
runcのリスクはそのアーキテクチャに由来します。コンテナ内のプロセスがファイルを開いたり、ネットワークパケットを送信したりする必要があるとき、それはホストのLinuxカーネルと直接通信します。Linuxには300以上のシステムコールがあります。その多くは複雑で、歴史的にバグが多いものです。攻撃者がこれらの呼び出しのいずれかで脆弱性を誘発すれば、権限を昇格させ、コンテナを突き破ることができます。
すべてのユニットが同じ配管や配線を共有しているアパートを想像してみてください。4B号室でパイプが破裂すれば、やがて3B号室の天井が台無しになります。本番環境では、すべてのテナントに、それぞれ独立して隔離された基盤を与える方法が必要なのです。
競合技術の比較:runc vs. Kata vs. gVisor
この隔離のギャップを埋めるために、通常3つの主要な技術が候補に挙がります。
- runc(デフォルト): オーバーヘッドはほぼゼロですが、ホストカーネルを共有します。セキュリティ上の隔離は最小限です。
- Kata Containers: コンテナごとに軽量な仮想マシン(VM)を起動します。優れた隔離性を提供しますが、メモリ消費量が大幅に増え、通常1コンテナあたり25MB〜50MBのオーバーヘッドが発生します。
- gVisor: Goで書かれた「ユーザ空間カーネル」です。ホストに到達する前にシステムコールをインターセプトします。
runcよりも高いセキュリティを提供し、フルVMよりもリソース消費を抑えられるという、中間の選択肢を提供します。
ほとんどの本番ワークロードにとって、gVisorは最適なバランス(スウィートスポット)です。高密度なクラスターでもリソース使用量を管理可能なレベルに保ちつつ、強力な隔離を実現します。
gVisorの仕組み
gVisorはゲストカーネルとして機能します。ユーザ空間でLinuxシステムコールAPIを実装しているため、ホスト上でroot権限で実行されることはありません。アプリがシステムコールを行おうとすると、gVisorのSentryコンポーネントがそれをインターセプトします。リクエストが安全であれば、gVisorが内部で処理するか、フィルタリングされた限定的な呼び出しをホストカーネルに対して行います。Goferと呼ばれる別のコンポーネントがファイルシステム操作を処理します。これにより、コンテナがホストのファイルに直接触れることは決してありません。
ステップ・バイ・ステップ:UbuntuへのgVisorインストール
まずは、runsc(run Sandboxed Container)バイナリをインストールし、Dockerに登録する必要があります。この例では、標準的なUbuntu 22.04のセットアップを使用します。
1. バイナリのインストール
Googleのストレージから最新のgVisorバイナリを直接取得します。これは、標準リポジトリの更新を待つよりも確実な方法です。
(
set -e
ARCH=$(uname -m)
URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}"
wget ${URL}/runsc ${URL}/runsc.sha256
sha256sum -c runsc.sha256
chmod a+rx runsc
sudo mv runsc /usr/local/bin
)
2. Dockerへのランタイム登録
Dockerにrunscが存在することを教える必要があります。daemon.jsonファイルを開くか、存在しない場合は作成してください。
sudo nano /etc/docker/daemon.json
以下の設定を貼り付けます:
{
"runtimes": {
"runsc": {
"path": "/usr/local/bin/runsc"
}
}
}
新しい設定を反映させるためにDockerを再起動します:
sudo systemctl restart docker
3. 設定の確認
Dockerが新しいランタイムを認識しているか確認します:
docker info | grep -i runtime
出力リストにrunscが表示されていれば成功です。
実践してみよう
サンドボックス化されたコンテナの実行は簡単です。--runtime=runscフラグを追加するだけです。カーネルチェックの結果を比較してみましょう。
標準コンテナ (runc)
docker run --rm alpine uname -a
これは、Linux 5.15.0-genericのように、実際のホストカーネルのバージョンを返します。
サンドボックスコンテナ (gVisor)
docker run --rm --runtime=runsc alpine uname -a
これは、おそらくLinux 4.4.0を返します。これはホストのカーネルではありません。アプリケーションを満足させるために、gVisorのSentryが古いLinuxカーネルを装っているのです。これで、アプリは事実上サンドボックス内に閉じ込められました。
Docker Composeを使用したNode.jsアプリの要塞化
既存のワークフローにも簡単に統合できます。以下は、高セキュリティ環境向けに構成されたdocker-compose.ymlのスニペットです。
version: "3.9"
services:
web-app:
image: node:18-slim
runtime: runsc
ports:
- "3000:3000"
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
networks:
- isolated-tier
networks:
isolated-tier:
driver: bridge
パフォーマンスに関する現実的な検討
透明性は重要です。gVisorの導入は「無料」ではありません。すべてのシステムコールをインターセプトするため、通信頻度の高い(チャットな)アプリケーションでは摩擦を感じることになります。I/O負荷の高いワークロードや高頻度のデータベースでは、極端なケースで2倍から3倍遅くなるなど、大幅なパフォーマンス低下が見られることがあります。
しかし、Node.jsやGoのような標準的なウェブサーバーの場合、レイテンシの影響は通常無視できる程度(多くの場合10%未満)です。私の経験則としては、信頼できないユーザーデータを処理するパブリック向けのコンポーネントにはgVisorを使用し、内部の高パフォーマンスなデータベースは厳格な内部ネットワーク内のruncで運用することをお勧めします。
結論
Dockerの要塞化とは、単に不正なイメージレイヤーをスキャンすることだけではありません。「侵害は起こるものだ」と想定することです。gVisorを導入することで、たとえ攻撃者が実行権限を得たとしても、彼らが直面するのはホストカーネルではなくGoコードの壁になります。これにより、壊滅的なエスケープを「何事もなかったかのような出来事」に変えることができるのです。
まずは、ファイルアップロードや信頼できないウェブトラフィックを処理する、最も露出の多いコンテナから始めてください。それらを最初にrunscへ移行させましょう。これは、サーバーが公開インターネットにさらされている間、夜に安心して眠るための最も効果的な方法の一つです。

