EC2 の Nitro Sandboxes を Firecracker で試してみた

EC2 の Nitro Sandboxes を Firecracker で試してみた

EC2 の Nitro Sandboxes で、Firecracker の sandbox を 2 台起動し、メモリ、ストレージ、ネットワークの隔離を確認しました。confidential モードでは、親の root でもゲストのメモリを読めませんでした。ストレージは親から読め、IMDS と VM 間の通信は親の nftables で止めました。
2026.11.12

はじめに

2026年11月9日、Nitro Sandboxes が発表されました。

https://aws.amazon.com/jp/about-aws/whats-new/2026/11/nitro-sandboxes-eks-ecs-ec2/

Nitro Sandboxes では、EKS の Pod、ECS のタスク、EC2 上の VM を、Nitro System の隔離境界を持つ軽量 VM として動かせます。

今回は、EC2 の m9g.large を親インスタンスにして Firecracker で Nitro Sandboxes を試した結果を紹介します。

検証環境

項目 内容
リージョン ap-northeast-1
ネットワーク 既定 VPC、パブリック IP 付与
親 m9g.large(arm64、2 vCPU、メモリ 8192 MiB)
AMI AL2023 kernel 6.18 arm64
親のカーネル 6.18.56-126.188.amzn2023.aarch64
Firecracker v1.18.0
親のインスタンスプロファイルと IMDS ec2-ssm-core、HttpTokens は required、hop limit は 2

親とゲストの起動

親の準備

親は --enclave-options Enabled=true を付けて起動しました。

aws ec2 run-instances \
    --image-id <AL2023 kernel 6.18 arm64 の AMI> \
    --instance-type m9g.large \
    --subnet-id <サブネット ID> \
    --security-group-ids <セキュリティグループ ID> \
    --associate-public-ip-address \
    --iam-instance-profile Name=<インスタンスプロファイル名> \
    --enclave-options Enabled=true \
    --block-device-mappings 'DeviceName=/dev/xvda,Ebs={VolumeSize=30,VolumeType=gp3}'

https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/nitro-sandboxes-run-firecracker.html

公式の手順にある、メモリプールを確保するカーネル引数を追加しました。サイズは 8G から 4G に変えています。

grubby --update-kernel=ALL --args="nitro_enclaves.mempool=4G@0"

親インスタンスを再起動すると、dmesg に CMA プールを予約したログが出て、total が 4 GiB になりました。sandbox を起動する前の in_use は 66 MiB です。

[    0.000000] nitro_enclaves: CMA pool: 4096 MiB reserved in 16 region(s), 4096 MiB requested
mem_pool/free: 4225761280
mem_pool/in_use: 69206016
mem_pool/total: 4294967296
cpu_pool/free: 1
cpu_pool/in_use: 0
cpu_pool/total: 1
pcie_slots: 4
pcie_slots_in_use: 0
pcie_slots_total: 10

Firecracker は公式の手順で、socat と nftables は dnf で導入しました。

今回起動する 2 台の sandbox を sandbox a、sandbox b と呼びます。sandbox a に渡す 1 GiB の追加 EBS は、親と同じ AZ に作成して接続しておきました。

追加 EBS の作成と接続
aws ec2 create-volume --availability-zone <親と同じ AZ> --size 1 --volume-type gp3
aws ec2 attach-volume --volume-id <ボリューム ID> --instance-id <親のインスタンス ID> --device /dev/sdf

接続したボリュームは、親で /dev/nvme1n1 に見えました。

ゲストイメージ

親のカーネル(vmlinuz)と、dracut で作る initrd をゲストイメージにしました。init スクリプトは、公式のものにデータドライブをマウントする処理を足して sandbox-init としました。

ゲストイメージの作成コマンドと sandbox-init

下に載せた sandbox-init を /opt/sbx に置き、root で次のスクリプトを実行します。公式の dracut コマンドに対して、--add-drivers に virtio_blk と ext4 を、--install にゲストで使うコマンドを足しています。

set -e
cd /opt/sbx
chmod +x sandbox-init
cp /boot/vmlinuz-$(uname -r) vmlinuz
dracut --force --no-hostonly --reproducible --no-early-microcode \
    --modules bash \
    --add-drivers "virtio_pci virtio_net vmw_vsock_virtio_transport nsm virtio_blk ext4" \
    --install "mount umount modprobe ip sleep mkdir socat cat ls uname dd grep head tail od curl ping ps dmesg awk sed free df wc sync date id lsblk rm cut tr hostname touch strings" \
    --include "$PWD/sandbox-init" /init \
    --kver $(uname -r) initrd.img
ls -l vmlinuz initrd.img
file vmlinuz 2>/dev/null || true

sandbox-init は、カーネルコマンドラインの sbx_ip と sbx_gw から台ごとの IP とゲートウェイを設定します。最後に親の CID 2、ポート 5000 へ vsock で接続して bash を渡します。

#!/bin/bash
export PATH=/usr/local/bin:/usr/sbin:/usr/bin
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
mkdir -p /tmp /run /data /dev/shm
mount -t tmpfs tmpfs /tmp
mount -t tmpfs tmpfs /dev/shm
modprobe -a virtio_pci virtio_net vmw_vsock_virtio_transport nsm virtio_blk ext4

# カーネルコマンドラインの sbx_ip / sbx_gw を読む(台ごとに IP を変えるため)
for p in $(cat /proc/cmdline); do
  case $p in sbx_*=*) export "$p";; esac
done

# eth0 が現れるまで待つ
for i in 1 2 3 4 5 6 7 8 9 10; do
  [ -e /sys/class/net/eth0 ] && break
  sleep 0.5
done

# eth0 に静的アドレスと、親インスタンス側の TAP をデフォルトゲートウェイとして設定する
ip link set lo up
ip addr add "$sbx_ip" dev eth0
ip link set eth0 up
ip route add default via "$sbx_gw"
echo "nameserver 169.254.169.253" > /etc/resolv.conf

# ドライブがあればマウントする
if [ -b /dev/vda ]; then
  mount /dev/vda /data
fi

# 親へ vsock で逆接続して bash を渡す(CID 2 = 親、ポート 5000)
while true; do
  socat VSOCK-CONNECT:2:5000 EXEC:/bin/bash,stderr
  sleep 1
done

sandbox の起動

TAP の作成、NAT の設定、ドライブへの ext4 の作成は、2 台の sandbox を起動する前に済ませておきました。

親のネットワークとストレージの準備

実行する前に lsblk で、/dev/nvme1n1 が接続した空の 1 GiB ボリュームであることを確かめてください。別のボリュームだと mkfs.ext4 で内容が消えます。

set -e
# TAP 2 本(sandbox a / b 用)
ip tuntap add dev tap0 mode tap
ip addr add 192.0.2.1/24 dev tap0
ip link set tap0 up
ip tuntap add dev tap1 mode tap
ip addr add 198.51.100.1/24 dev tap1
ip link set tap1 up
sysctl -w net.ipv4.ip_forward=1

# NAT(この時点では IMDS の reject なし)
IFACE=$(ip route show default | awk '{print $5; exit}')
nft -f - <<NFT
table ip sandbox {
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        iifname { "tap0", "tap1" } oifname "$IFACE" masquerade
    }
}
NFT
nft list ruleset

# sandbox a: 追加 EBS(/dev/nvme1n1)に ext4 を作る。親ではマウントしない
mkfs.ext4 -q /dev/nvme1n1
lsblk -f /dev/nvme1n1

# sandbox b: 64 MiB の raw イメージファイル
truncate -s 64M /opt/sbx/b-data.img
mkfs.ext4 -q /opt/sbx/b-data.img
ls -l /opt/sbx/b-data.img
mkdir -p /run/sandbox /run/sbx

sandbox a の設定を sandbox-a.json として保存すると、次のコマンドで Firecracker に渡せます。

sudo firecracker --no-api --config-file sandbox-a.json
{
    "boot-source": {
        "kernel_image_path": "/opt/sbx/vmlinuz",
        "initrd_path": "/opt/sbx/initrd.img",
        "boot_args": "panic=1 initcall_blacklist=i8042_init sbx_ip=192.0.2.2/24 sbx_gw=192.0.2.1"
    },
    "machine-config": {
        "vcpu_count": 2,
        "mem_size_mib": 1024
    },
    "drives": [{"drive_id":"data","path_on_host":"/dev/nvme1n1","is_root_device":false,"is_read_only":false}],
    "network-interfaces": [
        {
            "iface_id": "eth0",
            "host_dev_name": "tap0"
        }
    ],
    "vsock": {
        "guest_cid": 3,
        "uds_path": "/run/sandbox/a.sock"
    },
    "enclave": {"cpu_overcommit": true}
}

sandbox b は、ドライブ(64 MiB の raw イメージ)、TAP(tap1)、vsock の UDS が sandbox a と異なります。ゲストの IP は 198.51.100.2/24、ゲートウェイは 198.51.100.1 です。他は同じです。

実際の起動は、このコマンドの代わりに start-sandbox.sh で行いました。このスクリプトは、台ごとの JSON を書き、親側でゲストからの接続を受ける socat の待ち受けを起動し、Firecracker をバックグラウンドで実行します。引数でモード(confidential か direct)を切り替えます。ゲストでのコマンド実行には、自作の gexec を使いました。

start-sandbox.sh と gexec

start-sandbox.sh

#!/bin/bash
# usage: start-sandbox.sh <name> <tap> <guest-ip/prefix> <gateway> <confidential|direct> <drive-path|none>
set -e
N=$1; TAP=$2; IP=$3; GW=$4; MODE=$5; DRIVE=$6
mkdir -p /run/sandbox /run/sbx
rm -f /run/sandbox/$N.sock*

if [ "$MODE" = direct ]; then
  ENC='{"cpu_overcommit": true, "direct_mode": true}'
else
  ENC='{"cpu_overcommit": true}'
fi
if [ "$DRIVE" = none ]; then
  DR='[]'
else
  DR="[{\"drive_id\":\"data\",\"path_on_host\":\"$DRIVE\",\"is_root_device\":false,\"is_read_only\":false}]"
fi

cat > /opt/sbx/sandbox-$N.json <<JSON
{
    "boot-source": {
        "kernel_image_path": "/opt/sbx/vmlinuz",
        "initrd_path": "/opt/sbx/initrd.img",
        "boot_args": "panic=1 initcall_blacklist=i8042_init sbx_ip=$IP sbx_gw=$GW"
    },
    "machine-config": {
        "vcpu_count": 2,
        "mem_size_mib": 1024
    },
    "drives": $DR,
    "network-interfaces": [
        {
            "iface_id": "eth0",
            "host_dev_name": "$TAP"
        }
    ],
    "vsock": {
        "guest_cid": 3,
        "uds_path": "/run/sandbox/$N.sock"
    },
    "enclave": $ENC
}
JSON

# ゲストが逆接続してくる vsock のリスナー(親側)。入力は .in に追記、出力は .out に溜まる
: > /run/sbx/$N.in
: > /run/sbx/$N.out
setsid nohup bash -c "while true; do tail -n0 -f /run/sbx/$N.in | socat UNIX-LISTEN:/run/sandbox/$N.sock_5000,unlink-early STDIO >> /run/sbx/$N.out 2>>/run/sbx/$N.listener.err; sleep 1; done" >/dev/null 2>&1 &

setsid nohup firecracker --no-api --config-file /opt/sbx/sandbox-$N.json > /run/sbx/$N.fc.log 2>&1 &
echo $! > /run/sbx/$N.pid
sleep 1
echo "started $N mode=$MODE pid=$(cat /run/sbx/$N.pid)"

gexec は /opt/sbx/gexec に保存し、ゲストの名前は引数で、スクリプトは標準入力で渡します。待ち受けの入力ファイルへスクリプトを書き、終了マーカーが出力に現れるまで待って結果を返します。

#!/bin/bash
# usage: gexec <name> < script    ゲストの bash でスクリプトを実行し、出力を返す
N=$1
SCRIPT=$(cat)
ID=$RANDOM$RANDOM
START=$(stat -c %s /run/sbx/$N.out)
printf '{\n%s\n} 2>&1\necho "__END_%s__ rc=$?"\n' "$SCRIPT" "$ID" >> /run/sbx/$N.in
for i in $(seq 1 240); do
  if tail -c +$((START+1)) /run/sbx/$N.out | grep -q "__END_${ID}__"; then break; fi
  sleep 0.5
done
tail -c +$((START+1)) /run/sbx/$N.out | sed "s/^__END_${ID}__ rc=/[guest rc=/; /^\[guest rc=/s/\$/]/"

sandbox a、b をどちらも confidential で起動したときのプールの状態です。

項目 起動前 a 起動後 b 起動後
pcie_slots_in_use 0 1 2
mem_pool/free 4225761280 3099590656 1973420032
mem_pool/in_use 69206016 1195376640 2321547264
cpu_pool/free 1 1 1

sandbox a、b は、それぞれ mem_pool を 1074 MiB 消費しました。内訳は、設定した 1024 MiB、sandbox ごとの 2 MiB、デバイス 3 個分の 48 MiB です。cpu_overcommit: true を指定して、2 vCPU の親で 2 vCPU の sandbox を 2 台起動できました。

今回の起動では、Firecracker の起動から約 0.16 秒で sandbox a が起動し、約 1.7 秒で virtio デバイスがすべて有効になりました。

2026-11-11T11:40:56.780136795 [anonymous-instance:main] Running Firecracker v1.18.0
2026-11-11T11:40:56.936840946 [anonymous-instance:main] Enclave started: slot=0x1a1ca693c533d6b cid=16
2026-11-11T11:40:58.471322811 [anonymous-instance:main] DRIVER_OK detected for device uid=1, activating

ゲストメモリの隔離

公式ドキュメントでは、confidential では親がゲストのメモリを読み書きできず、direct ではできると説明されています。これを確かめるため、sandbox a を confidential、sandbox b を direct で起動し、親からゲストメモリを読みました。direct は enclave に direct_mode を加えて指定します。

    "enclave": {"cpu_overcommit": true, "direct_mode": true}

Firecracker はゲストメモリを自分のプロセスにマップし、そのアドレスをログの CMA alloc に出します。親から root で、Firecracker プロセスの /proc/<pid>/mem のその範囲を 1 MiB ずつ読み、次の文字列を数えました。

  • カーネルのバナーと sbx_ip=:起動時にゲストメモリへ載るカーネルと initrd の中身
  • 目印 A と B:起動後に a と b のゲストが /dev/shm に書いた文字列

b は 1010 MiB 付近で読み取りが進まなくなったため、LIMIT_MIB=1000 で先頭 1000 MiB に絞りました。

走査スクリプトとゲストに目印を書くコマンド

/opt/sbx/memscan.py として保存し、a、b の名前を引数に渡して実行します。

#!/usr/bin/env python3
"""Firecracker プロセスにマップされたゲストメモリ領域(fc.log の "CMA alloc: ... host=0x... size=N MiB")を
/proc/<pid>/mem から読み、パターンを数える。
usage: memscan.py <name>   (name = a|b。/run/sbx/<name>.pid と <name>.fc.log を使う)
"""
import re, sys

name = sys.argv[1]
pid = int(open(f"/run/sbx/{name}.pid").read())
log = open(f"/run/sbx/{name}.fc.log").read()
m = re.search(r"CMA alloc: gpa=(0x[0-9a-f]+) host=(0x[0-9a-f]+) size=(\d+) MiB", log)
gpa, host, size_mib = int(m.group(1), 16), int(m.group(2), 16), int(m.group(3))
size = size_mib * 1024 * 1024
import os
if os.environ.get("LIMIT_MIB"):
    size = min(size, int(os.environ["LIMIT_MIB"]) * 1024 * 1024)
patterns = {
    "kernel banner 'Linux version 6.18'": b"Linux version 6.18",
    "initrd marker 'sbx_ip='": b"sbx_ip=",
    "guest tmpfs marker A": b"SBX-MEM-MARKER-A-c04e55",
    "guest tmpfs marker B": b"SBX-MEM-MARKER-B-91aa07",
}
print(f"sandbox={name} pid={pid} region host=0x{host:x} size={size_mib} MiB scanning={size // (1024 * 1024)} MiB")
MAXLEN = max(len(p) for p in patterns.values())
counts = {k: 0 for k in patterns}
ok = err = nonzero = 0
CH = 1024 * 1024
posf = open(f"/run/sbx/{name}.scanpos", "w", buffering=1)
tail = b""
with open(f"/proc/{pid}/mem", "rb", buffering=0) as f:
    off = 0
    while off < size:
        n = min(CH, size - off)
        posf.seek(0); posf.truncate(); posf.write(f"about to read offset={off // CH} MiB of {size_mib} MiB\n")
        try:
            f.seek(host + off)
            buf = f.read(n)
        except OSError as e:
            err += n
            off += n
            tail = b""
            first_err = getattr(sys.modules[__name__], "first_err", None)
            if first_err is None:
                first_err = str(e)
                setattr(sys.modules[__name__], "first_err", first_err)
            continue
        if not buf:
            err += n
            off += n
            continue
        ok += len(buf)
        nonzero += len(buf) - buf.count(0)
        data = tail + buf
        for k, p in patterns.items():
            counts[k] += data.count(p)
        tail = buf[-(MAXLEN - 1):]
        off += n
print(f"bytes read ok={ok} error={err} nonzero_bytes={nonzero}")
if err:
    print("first error:", getattr(sys.modules[__name__], "first_err", ""))
for k, v in counts.items():
    print(f"  {k}: {v} hit(s)")

ゲストの /dev/shm に目印を書くコマンドは、gexec で流しました。

/opt/sbx/gexec a <<'G'
echo "SBX-MEM-MARKER-A-c04e55-SBX-MEM-MARKER-A-c04e55" > /dev/shm/memmarker
G
/opt/sbx/gexec b <<'G'
echo "SBX-MEM-MARKER-B-91aa07-SBX-MEM-MARKER-B-91aa07" > /dev/shm/memmarker
cat /dev/shm/memmarker
G

direct の b は、1000 MiB をすべて読めました。カーネルのバナー、sbx_ip=、目印 B が見つかり、目印 A は 0 件でした。

sandbox=b pid=29053 region host=0xffff5fc00000 size=1024 MiB scanning=1000 MiB
bytes read ok=1048576000 error=0 nonzero_bytes=116800950
  kernel banner 'Linux version 6.18': 3 hit(s)
  initrd marker 'sbx_ip=': 14 hit(s)
  guest tmpfs marker A: 0 hit(s)
  guest tmpfs marker B: 7 hit(s)

confidential の a は、動いている間は先頭の読み取りから進みませんでした。a を停止すると、先頭 1 MiB はすべて 0 で返り、残りはエラーになりました。文字列は 1 件も見つかりませんでした。

sandbox=a pid=28421 region host=0xffff62400000 size=1024 MiB scanning=1024 MiB
bytes read ok=1048576 error=1072693248 nonzero_bytes=0
  kernel banner 'Linux version 6.18': 0 hit(s)
  initrd marker 'sbx_ip=': 0 hit(s)
  guest tmpfs marker A: 0 hit(s)
  guest tmpfs marker B: 0 hit(s)

direct は、公式ドキュメントでネステッド VM と同等の信頼モデルとされています。同じ方法で、direct の b は親から読め、confidential の a は親の root でも読めませんでした。

ストレージ

ゲストから見えるディスク

sandbox a に追加 EBS、sandbox b に raw イメージファイルを drives で渡すと、どちらもゲストでは vda に見えました。ext4 として /data にマウントして書き込めました。出力は sandbox a のものです。

 253        0    1048576 vda
/dev/vda on /data type ext4 (rw,relatime)
/dev/vda        974M   28K  907M   1% /data

親から読めるデータ

sandbox a、b を confidential で起動して書き込み、停止した後、親から EBS と raw イメージを読むと、ゲストが書いた内容を確認できました。

--- raw EBS device (/dev/nvme1n1): strings
SBX-DISK-MARKER-A-7f3a91
--- mount EBS read-only on the parent
-rw-r--r--. 1 root root    25 Nov 11 11:42 marker.txt
SBX-DISK-MARKER-A-7f3a91
--- raw image file for b
SBX-DISK-MARKER-B-2b8d10

公式ドキュメントにも、親は sandbox に渡したブロックデバイスを、暗号化した EBS も含めて読めると書かれています。データを親から守るには、アテステーションを通った sandbox にだけ AWS KMS が渡す鍵で、ゲストの中で暗号化するよう案内されています。

ネットワーク

親に IP フォワーディングと NAT を設定し、sandbox ごとに TAP を作りました。

ゲストの IP と外向き通信

sandbox a のゲストで確認した IP アドレスとルートです。

lo               UNKNOWN        127.0.0.1/8 ::1/128 
eth0             UP             192.0.2.2/24 fe80::f007:2cff:fea6:6591/64 
default via 192.0.2.1 dev eth0 
192.0.2.0/24 dev eth0 proto kernel scope link src 192.0.2.2 

親への ping は届き、ゲストから NAT 経由で checkip.amazonaws.com に接続すると、親のパブリック IP が返りました。パブリック IP は例示値に置き換えています。

2 packets transmitted, 2 received, 0% packet loss, time 1001ms
--- egress
203.0.113.10

IMDS

親の HttpTokens は required、hop limit は 2 でした。この設定で、ゲストは NAT を経由して親の IMDSv2 にアクセスし、インスタンス ID と IAM ロール名を取得できました。インスタンス ID は例示値です。

--- IMDS (no reject)
token length: 56
instance-id: i-0123456789abcdef0
iam role name(s): ec2-ssm-core

公式ドキュメントにも、親が NAT などで中継すると、sandbox は親の IMDS から IAM ロールの認証情報を読めると書かれています。IMDSv2 と hop limit 2 では防げず、親で 169.254.169.254 宛ての通信を reject するよう案内されています。

親で root として、forward チェーンを作り、tap0 と tap1 から 169.254.169.254 宛ての通信を reject するルールを足しました。

nft add chain ip sandbox forward '{ type filter hook forward priority filter; policy accept; }'
nft add rule ip sandbox forward iifname '{ "tap0", "tap1" }' ip daddr 169.254.169.254 reject

ルールを足した後は、トークン取得が curl の exit 7 で即時に失敗しました。DNS の 169.254.169.253 には引き続き届きました。

http_code=000 exit_reason=curl exit=7
elapsed: 0.00 s
--- DNS (169.254.169.253 still reachable?)
http_code=200
curl exit=0

VM 間の通信

親で tap0 と tap1 の間を遮断する前は、sandbox a から sandbox b への ping が届きました。

--- ping guest b (198.51.100.2)
2 packets transmitted, 2 received, 0% packet loss, time 1001ms

forward チェーンに、tap0 と tap1 の間を両方向で drop するルールを足しました。

nft add rule ip sandbox forward iifname "tap0" oifname "tap1" drop
nft add rule ip sandbox forward iifname "tap1" oifname "tap0" drop

ルールを足した後は ping が 100% loss になり、外向き通信は引き続き使えました。

--- ping guest b (198.51.100.2) after drop rule
2 packets transmitted, 0 received, 100% packet loss, time 1066ms
--- egress still ok
203.0.113.10

まとめ

今回は EC2 の Nitro Sandboxes で、基本的な動作と、メモリやストレージ、ネットワークがどう隔離されているかを確認できました。

発表では、AI エージェントがコードを実行する環境やマルチテナントのワークロードの隔離に向くと案内されています。Compute Blog では、エージェントのセッションごとに sandbox を作り、終わったら削除する使い方も紹介されています。今回も、sandbox は Firecracker の起動から約 1.7 秒でデバイスまで有効になりました。

Nitro Sandboxes は ECS と EKS でもサポートされています。こちらも追って評価し、紹介したいと思います。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事