Lambda MicroVMsのSHELL_INGRESSでブラウザーからシェルアクセスを試してみた

Lambda MicroVMsのSHELL_INGRESSでブラウザーからシェルアクセスを試してみた

Lambda MicroVMsのSHELL_INGRESS機能を使い、ブラウザーからMicroVM内のシェルをインタラクティブに操作してみました。WebSocketの接続仕様を確認し、xterm.jsベースのWebShellを構築しました。内部はNeoverse V2(Graviton4相当)上で動作するFirecracker VMで、一部制約はあるもののLinux環境として操作できました。
2026.06.23

はじめに

前回の記事では、Lambda MicroVMsにHTTP_INGRESSでFlaskアプリをデプロイし、サスペンド/レジュームの挙動を確認しました。

https://dev.classmethod.jp/articles/aws-lambda-microvms-flask-suspend-resume/

今回は同じLambda MicroVMsのもうひとつのインターフェース、SHELL_INGRESS に焦点を当てます。

https://aws.amazon.com/jp/about-aws/whats-new/2026/06/aws-lambda-microvms/

機能 HTTP_INGRESS SHELL_INGRESS
プロトコル HTTP/HTTPS WebSocket
用途 ウェブアプリ・API デバッグ・管理・ターミナル
認証 IAM / カスタム create-microvm-shell-auth-token
ユーザー実装 アプリケーション(Flask等) 不要(プラットフォーム提供)

この記事ではSHELL_INGRESSへの接続手順を確認し、リレーサーバー経由でブラウザーから操作できるWebShellを構築しました。あわせてMicroVM内部のOS情報を見ていきます。

シェル接続の確立手順

Dockerfile

SHELL_INGRESSを試すだけであればアプリケーションコードは不要です。最小限のイメージを用意しました。

FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal

RUN dnf install -y procps-ng iproute util-linux curl bash coreutils && \
    dnf clean all

CMD ["sleep", "infinity"]

sleep infinity でMicroVMを起動したまま待機させ、SHELL_INGRESS経由でシェルに入ります。

イメージ作成と MicroVM 起動

# イメージ作成
aws lambda-microvms create-microvm-image \
  --image-name shell-demo \
  --dockerfile-path ./Dockerfile \
  --region ap-northeast-1

# MicroVM 起動(SHELL_INGRESS を指定)
aws lambda-microvms create-microvm \
  --image-name shell-demo \
  --ingress-types '["SHELL_INGRESS"]' \
  --internet-access INTERNET_EGRESS \
  --region ap-northeast-1

シェルトークンの取得

create-microvm-shell-auth-token でトークンを発行します。このトークンはWebSocket接続時の認証に使います。

TOKEN=$(aws lambda-microvms create-microvm-shell-auth-token \
  --microvm-identifier <MICROVM_ID> \
  --expiration-in-minutes 60 \
  --region ap-northeast-1 \
  --query 'authToken."X-aws-proxy-auth"' --output text)

WebSocket 接続とプロトコル

Pythonの websocket-client で接続し、プロトコルの挙動を確認しました。

import websocket

ws = websocket.create_connection(
    "wss://<ENDPOINT>/shell",
    header={"X-aws-proxy-auth": TOKEN},
)
# 初回メッセージ: session_init
print(ws.recv())  # {"type":"session_init","session_id":"..."}

# コマンド送信
ws.send(b"uname -a\n", opcode=websocket.ABNF.OPCODE_BINARY)
print(ws.recv())  # Linux localhost 6.1.166-...
ws.close()

確認できたフレーム形式をまとめます(公式に文書化された仕様ではなく、本検証での観測結果です)。

項目 内容
接続パス wss://<endpoint>/shell
認証ヘッダー X-aws-proxy-auth: <token>
初回応答 テキストフレーム {"type":"session_init","session_id":"..."}
入力(クライアント→VM) バイナリフレーム(生テキスト)
出力(VM→クライアント) バイナリフレーム(生PTY出力)
リサイズ テキストフレーム {"type":"resize","cols":N,"rows":N}

入出力がバイナリフレームのため、バイナリ出力を含むコマンドも正しく転送されます。

ブラウザーアクセス(リレーサーバー)

ブラウザーのWebSocket APIはカスタムヘッダーを送れないため、リレーサーバーを挟みます。

ブラウザ (xterm.js)
    ↕ ws://localhost:3000/ws
ローカル relay (aiohttp)
    ↕ wss://<endpoint>/shell + X-aws-proxy-auth ヘッダー
Lambda MicroVM (SHELL_INGRESS)

relay.py

relay.py / index.html(クリックで展開)

relay.py

"""
Lambda MicroVM Shell relay server.
Browser (xterm.js) ←→ This relay (localhost:3000) ←→ MicroVM /shell endpoint

Usage:
  export MICROVM_ENDPOINT=wss://xxxx.lambda-microvm.ap-northeast-1.on.aws/shell
  export MICROVM_SHELL_TOKEN=eyJ...
  python3 relay.py
"""

import asyncio
import json
import os
import ssl

import aiohttp
from aiohttp import web

ENDPOINT = os.environ["MICROVM_ENDPOINT"]
TOKEN = os.environ["MICROVM_SHELL_TOKEN"]
PORT = int(os.environ.get("PORT", "3000"))

async def index(request):
    return web.FileResponse("static/index.html")

async def websocket_handler(request):
    client_ws = web.WebSocketResponse()
    await client_ws.prepare(request)

    # Connect to MicroVM shell
    ssl_ctx = ssl.create_default_context()
    session = aiohttp.ClientSession()
    try:
        agent_ws = await session.ws_connect(
            ENDPOINT,
            headers={"X-aws-proxy-auth": TOKEN},
            ssl=ssl_ctx,
        )
    except Exception as e:
        await client_ws.send_str(json.dumps({"type": "error", "message": str(e)}))
        await client_ws.close()
        await session.close()
        return client_ws

    # Relay: agent → client
    async def agent_to_client():
        try:
            async for msg in agent_ws:
                if msg.type == aiohttp.WSMsgType.TEXT:
                    await client_ws.send_str(msg.data)
                elif msg.type == aiohttp.WSMsgType.BINARY:
                    await client_ws.send_bytes(msg.data)
                elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
                    break
        except Exception:
            pass

    relay_task = asyncio.create_task(agent_to_client())

    # Relay: client → agent
    try:
        async for msg in client_ws:
            if msg.type == aiohttp.WSMsgType.TEXT:
                await agent_ws.send_str(msg.data)
            elif msg.type == aiohttp.WSMsgType.BINARY:
                await agent_ws.send_bytes(msg.data)
            elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
                break
    finally:
        relay_task.cancel()
        await agent_ws.close()
        await session.close()

    return client_ws

app = web.Application()
app.router.add_get("/", index)
app.router.add_get("/ws", websocket_handler)

if __name__ == "__main__":
    print(f"Relay listening on http://127.0.0.1:{PORT}")
    print(f"Target: {ENDPOINT}")
    web.run_app(app, host="127.0.0.1", port=PORT)

static/index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Lambda MicroVM WebShell</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>body{margin:0;background:#1e1e1e;display:flex;flex-direction:column;height:100vh}
#terminal{flex:1}#status{color:#888;font:12px monospace;padding:4px 8px}</style>
</head>
<body>
<div id="status">Connecting...</div>
<div id="terminal"></div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script>
const term = new Terminal({ cursorBlink: true, fontSize: 14 });
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById('terminal'));
fit.fit();

const ws = new WebSocket(`ws://${location.host}/ws`);
ws.binaryType = 'arraybuffer';

ws.onopen = () => {
  document.getElementById('status').textContent = 'Connected';
  ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
  term.onData(data => ws.send(new TextEncoder().encode(data)));
  term.onResize(({ cols, rows }) => ws.send(JSON.stringify({ type: 'resize', cols, rows })));
};

ws.onmessage = e => {
  if (typeof e.data === 'string') {
    try {
      const msg = JSON.parse(e.data);
      if (msg.type === 'session_init')
        document.getElementById('status').textContent = 'Shell ready (' + msg.session_id.slice(0,8) + '...)';
    } catch {}
  } else {
    term.write(new Uint8Array(e.data));
  }
};

ws.onclose = e => {
  document.getElementById('status').textContent = `Disconnected (code: ${e.code})`;
  term.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n');
};

window.addEventListener('resize', () => fit.fit());
</script>
</body>
</html>

起動と動作確認

export MICROVM_ENDPOINT="wss://<ENDPOINT>/shell"
export MICROVM_SHELL_TOKEN="$TOKEN"
python3 relay.py

ブラウザーで http://127.0.0.1:3000 を開くと、xterm.jsターミナルが表示されます。接続が成功すると「Shell ready (078a222b...)」とステータスが変わり、bashが操作可能になりました。

WebShellでdmesgを実行した画面

ウィンドウリサイズも resize フレーム経由でリアルタイムに反映されます。

MicroVM 内部探索

WebShellが使えるようになったので、MicroVMの内部を探索してみます。

項目
CPU Neoverse V2 (Graviton4相当) 4 vCPU
メモリ 8GB(Swap なし)
ディスク 8GB (ext4)
OS Amazon Linux 2023.12.20260611
カーネル 6.1.166 (aarch64)
ハイパーバイザー Firecracker (KVM)

以下、各項目の詳細です。

CPU

CPU implementer : 0x41
CPU part        : 0xd4f
CPU revision    : 1
/proc/cpuinfo 全文
processor       : 0
BogoMIPS        : 2000.00
Features        : fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 asimddp sha512 asimdfhm dit uscat ilrcpc flagm sb dcpodp flagm2 frint i8mm bf16 dgh rng bti
CPU implementer : 0x41
CPU architecture: 8
CPU variant     : 0x0
CPU part        : 0xd4f
CPU revision    : 1

processor       : 1
BogoMIPS        : 2000.00
(processor 2, 3 も同一)

CPU implementer 0x41 はARM、CPU part 0xd4f はNeoverse V2です。AWS環境であることを考えるとGraviton4相当と考えられます。4 vCPUが割り当てられていました。

メモリ

λ $ cat /proc/meminfo
MemTotal:        8209056 kB
MemFree:         7651480 kB
MemAvailable:    7842992 kB
Buffers:            9324 kB
Cached:           381660 kB
SwapTotal:             0 kB

約8GBが割り当てられています。Swapは無効です。

ディスク

λ $ df -h
Filesystem               Size  Used Avail Use% Mounted on
/dev/vdc                 7.8G  379M  7.0G   6% /
tmpfs                     64M     0   64M   0% /dev
shm                       64M     0   64M   0% /dev/shm
tmpfs                     64M     0   64M   0% /run
overlayfs:/overlay/root  974M   14M  893M   2% /etc/hosts
tmpfs                    4.0G     0  4.0G   0% /sys/firmware
tmpfs                    4.0G     0  4.0G   0% /proc/scsi
λ $ cat /proc/partitions
major minor  #blocks  name

 254        0     275260 vda
 254       16    1048576 vdb
 254       32    8388608 vdc

3つのvirtioブロックデバイスが存在します。ブートパラメータは root=/dev/vda ro ですが、init=/sbin/overlay-init.sh による構成後の最終的なrootは vdc(ext4, 8GB)でした。vdaが初期起動用、vdbがoverlay用と見られます。

カーネル・OS

λ $ uname -a
Linux localhost 6.1.166-24.303.amzn2023.aarch64 #1 SMP Wed Mar 25 10:39:29 UTC 2026 aarch64 aarch64 aarch64 GNU/Linux
λ $ cat /etc/os-release
NAME="Amazon Linux"
VERSION="2023"
ID="amzn"
PRETTY_NAME="Amazon Linux 2023.12.20260611"
SUPPORT_END="2029-06-30"

Amazon Linux 2023ベースで、カーネルは6.1系(aarch64)です。

ブートパラメータ

λ $ cat /proc/cmdline
console=ttyS0 reboot=k panic=1 overlay_root=vdb init=/sbin/overlay-init.sh transparent_hugepage=madvise swiotlb=noforce systemd.mask=tmp.mount mem.devmem=0 modules_disabled=1 kexec_load_disabled=1 nohibernate damon_reclaim.enabled=Y damon_reclaim.min_age=120000000 damon_reclaim.quota_ms=50 damon_reclaim.quota_reset_interval_ms=1000 damon_reclaim.quota_sz=1073741824 damon_reclaim.wmarks_interval=5000000 damon_reclaim.wmarks_high=950 damon_reclaim.wmarks_mid=900 damon_reclaim.wmarks_low=0 damon_reclaim.sample_interval=5000 damon_reclaim.aggr_interval=500000 damon_reclaim.min_nr_regions=100 damon_reclaim.max_nr_regions=1000 pci=off root=/dev/vda ro earlycon=uart,mmio,0x40002000

注目すべきパラメータを整理します。

パラメータ 意味
overlay_root=vdb vdb をオーバーレイの upper layer に使用
init=/sbin/overlay-init.sh カスタム init で overlay 構成を構築
pci=off PCI バス無効化を意図した指定と見られる。Firecracker は virtio-mmio 中心のため影響は見られなかったが、この環境では dmesg に Unknown option 'off' と出力されていた
modules_disabled=1 カーネルモジュールのロードを禁止
kexec_load_disabled=1 kexec によるカーネル差し替えを禁止
damon_reclaim.enabled=Y DAMON によるメモリ回収が有効

セキュリティ強化(モジュール禁止・kexec禁止)と、メモリ効率のためのDAMON設定が特徴的です。

dmesg(起動ログ)

λ $ dmesg | head -30
[    0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd4f1]
[    0.000000] Linux version 6.1.166-24.303.amzn2023.aarch64 (mockbuild@ip-10-0-62-47) (gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-5), GNU ld version 2.41-50.amzn2023.0.5) #1 SMP Wed Mar 25 10:39:29 UTC 2026
[    0.000000] Machine model: linux,dummy-virt
[    0.000000] PCI: Unknown option `off'
[    0.000000] earlycon: uart0 at MMIO 0x0000000040002000 (options '')
[    0.000000] printk: bootconsole [uart0] enabled
[    0.000000] efi: UEFI not found.
[    0.000000] NUMA: No NUMA configuration found
[    0.000000] NUMA: Faking a node at [mem 0x0000000080200000-0x000000027fffffff]
[    0.000000] NUMA: NODE_DATA [mem 0x27eebd8c0-0x27eee7fff]
[    0.000000] Zone ranges:
[    0.000000]   DMA      [mem 0x0000000080200000-0x00000000ffffffff]
[    0.000000]   DMA32    empty
[    0.000000]   Normal   [mem 0x0000000100000000-0x000000027fffffff]
[    0.000000]   Device   empty
[    0.000000] psci: probing for conduit method from DT.
[    0.000000] psci: PSCIv1.1 detected in firmware.
[    0.000000] psci: Using standard PSCI v0.2 function IDs
[    0.000000] psci: SMC Calling Convention v1.1
[    0.000000] smccc: KVM: hypervisor services detected (0x00000000 0x00000000 0x00000000 0x00000003)
[    0.000000] percpu: Embedded 17 pages/cpu s39976 r0 d29656 u69632
[    0.000000] pcpu-alloc: s39976 r0 d29656 u69632 alloc=17*4096
[    0.000000] pcpu-alloc: [0] 0 [0] 1 [0] 2 [0] 3
[    0.000000] Detected PIPT I-cache on CPU0

Machine model: linux,dummy-virt はFirecrackerのデバイスツリーです。smccc: KVM: hypervisor services detected からKVM上で動作していることが確認できます。メモリ範囲0x80200000〜0x27fffffffは約8GBに対応します。

マウント構成

λ $ mount
/dev/vdc on / type ext4 (rw,relatime)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,size=65536k,mode=755)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666)
shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
sysfs on /sys type sysfs (ro,nosuid,nodev,noexec,relatime)
tmpfs on /run type tmpfs (rw,nosuid,size=65536k,mode=755)
overlayfs:/overlay/root on /etc/resolv.conf type overlay (ro,noatime,lowerdir=/,upperdir=/overlay/root,workdir=/overlay/work)
overlayfs:/overlay/root on /etc/hosts type overlay (ro,noatime,lowerdir=/,upperdir=/overlay/root,workdir=/overlay/work)
tmpfs on /proc/kcore type tmpfs (rw,nosuid,size=65536k,mode=755)
tmpfs on /proc/keys type tmpfs (rw,nosuid,size=65536k,mode=755)
tmpfs on /proc/timer_list type tmpfs (rw,nosuid,size=65536k,mode=755)
tmpfs on /sys/firmware type tmpfs (ro,relatime)
tmpfs on /proc/scsi type tmpfs (ro,relatime)

/proc/kcore 等のtmpfs隠蔽や /sys のroマウントなど、コンテナランタイムでも見られる制限が確認できます。また、/etc/resolv.conf/etc/hosts がoverlayfsでマウントされており、DNS設定やホスト情報をプラットフォーム側から差し込むための構成に見えます。

デバイス

λ $ ls -la /dev/
total 4
drwxr-xr-x  5 root root     400 Jun 23 03:16 .
drwxr-xr-x 18 root root    4096 Jan  1  1970 ..
lrwxrwxrwx  1 root root      11 Jun 23 03:16 core -> /proc/kcore
lrwxrwxrwx  1 root root      13 Jun 23 03:16 fd -> /proc/self/fd
crw-rw-rw-  1 root root  1,   7 Jun 23 03:16 full
crw-rw-rw-  1 root root 10, 229 Jun 23 03:16 fuse
crw-rw----  1 root root 10, 237 Jun 23 03:16 loop-control
drwxrwxrwt  2 root root      40 Jun 23 03:16 mqueue
crw-rw-rw-  1 root root  1,   3 Jun 23 03:16 null
lrwxrwxrwx  1 root root       8 Jun 23 03:16 ptmx -> pts/ptmx
drwxr-xr-x  2 root root       0 Jun 23 03:16 pts
crw-rw-rw-  1 root root  1,   8 Jun 23 03:16 random
drwxrwxrwt  2 root root      40 Jun 23 03:16 shm
lrwxrwxrwx  1 root root      15 Jun 23 03:16 stderr -> /proc/self/fd/2
lrwxrwxrwx  1 root root      15 Jun 23 03:16 stdin -> /proc/self/fd/0
lrwxrwxrwx  1 root root      15 Jun 23 03:16 stdout -> /proc/self/fd/1
cr--r--r--  1 root root 10, 125 Jun 23 03:16 sysgenid
crw-rw-rw-  1 root root  5,   0 Jun 23 03:16 tty
crw-rw-rw-  1 root root  1,   9 Jun 23 03:16 urandom
crw-rw-rw-  1 root root  1,   5 Jun 23 03:16 zero

sysgenid(minor 125)はVMスナップショットのレジュームやクローンを検出するためのデバイスです。世代番号の変化により、乱数生成器の再シードや状態のリセットを行えます。

プロセス

λ $ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0   5580  1152 ?        SNs  03:16   0:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep infinity
root          98  0.0  0.0      0     0 ?        ZN   03:20   0:00 [gpg] <defunct>
root         100  0.0  0.0      0     0 ?        ZN   03:20   0:00 [gpg] <defunct>
root         102  0.0  0.0      0     0 ?        ZN   03:20   0:00 [gpg] <defunct>
root         236  0.0  0.0   4348  3280 pts/1    SNs+ 03:24   0:00 /bin/bash
root         282  0.0  0.0   4348  3332 pts/3    SNs  03:34   0:00 /bin/bash
root         297  0.0  0.0   5352  2264 pts/3    RN+  03:34   0:00 ps aux

Dockerfileで指定した通り、PID 1は sleep infinity です。SHELL_INGRESS経由で接続するたびに新しいPTY(pts/1, pts/3等)でbashセッションが生成されます。

[gpg] <defunct> のゾンビプロセスは、PID 1が sleepwait() しないために残っているもので、今回の検証では動作への影響は見られませんでした。

ネットワーク

λ $ cat /proc/net/if_inet6
2406da140a010914xxxxxxxxxxxxxxxx 02 80 00 80     eth0
00000000000000000000000000000001 01 80 10 80       lo

λ $ curl -s ifconfig.me
<外部IP>

eth0にはグローバルIPv6アドレスが割り当てられています。INTERNET_EGRESS を有効にして起動したため、外部通信が可能でした。

まとめ

SHELL_INGRESSを使うことで、SSHサーバーを用意せずにMicroVMへインタラクティブにシェルアクセスできました。WebSocketプロトコルはシンプルで、リレーサーバーとxterm.jsを組み合わせることで、ブラウザーから操作できるWebShellも実装できます。

HTTP_INGRESSでAPIを整備する前段階の環境確認や、開発中アプリケーションのログ調査・再現確認など、SHELL_INGRESSはデバッグ用途で使いやすいインターフェースだと感じました。

MicroVM内部にはコンテナ的な制約がありつつも、Dockerfileで追加したツール群は期待通り動作し、root権限でユーザー空間を調査できる環境でした。

この記事をシェアする

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

関連記事