Amazon ECS Express Mode を利用して WebSocket アプリをALBで公開してみた
2025年11月21日、Amazon ECS Express Mode がリリースされました。
本番環境ですぐに使えるコンテナアプリケーションを、数クリック、または1つのコマンドで構築できるというこの機能を、早速試す機会がありましたので紹介します。
事前準備
コンテナイメージ作成、登録
ECS Express Mode にデプロイする コンテナイメージを用意しました。
今回、WebSocketエコーサーバを含む、Nginxイメージを作成。 ECRにPushしました。
Nginxテストイメージの作成、登録スクリプト
#!/bin/bash
set -e
REGION="ap-northeast-1"
ACCOUNT_ID="********"
REPO_NAME="websocket-nginx"
IMAGE_URI="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${REPO_NAME}:latest"
echo "=== 1. WebSocketサーバーを作成 ==="
cat > ws-server.js << 'EOF'
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', ws => {
ws.on('message', (msg, isBinary) => {
const data = isBinary ? msg : msg.toString();
ws.send(data);
});
});
EOF
echo "=== 2. package.jsonを作成 ==="
cat > package.json << 'EOF'
{"dependencies":{"ws":"^8.14.2"}}
EOF
echo "=== 3. Nginx設定を作成 ==="
cat > nginx.conf << 'EOF'
events {
worker_connections 1024;
}
http {
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
server {
listen 8081;
location / {
return 200 'WebSocket Echo\n';
add_header Content-Type text/plain;
}
}
}
EOF
echo "=== 4. 起動スクリプトを作成 ==="
cat > start.sh << 'EOF'
#!/bin/sh
node /app/ws-server.js &
nginx -g 'daemon off;'
EOF
echo "=== 5. HTMLテストページを作成 ==="
cat > index.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
#messages { border: 1px solid #ccc; height: 300px; overflow-y: auto; padding: 10px; margin: 10px 0; }
input, button { padding: 10px; margin: 5px 0; }
input { width: 70%; }
button { width: 25%; }
.sent { color: blue; }
.received { color: green; }
.status { color: red; }
</style>
</head>
<body>
<h1>WebSocket Echo Test</h1>
<div id="status" class="status">Disconnected</div>
<div id="messages"></div>
<input id="input" type="text" placeholder="Type message..." disabled>
<button id="send" disabled>Send</button>
<button id="connect">Connect</button>
<script>
let ws;
const messages = document.getElementById('messages');
const input = document.getElementById('input');
const status = document.getElementById('status');
const sendBtn = document.getElementById('send');
const connectBtn = document.getElementById('connect');
function log(msg, className) {
const div = document.createElement('div');
div.className = className;
div.textContent = msg;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
connectBtn.onclick = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
status.textContent = 'Connected';
status.style.color = 'green';
input.disabled = false;
sendBtn.disabled = false;
connectBtn.disabled = true;
log('Connected to ' + wsUrl, 'status');
};
ws.onmessage = (e) => {
log('← ' + e.data, 'received');
};
ws.onclose = () => {
status.textContent = 'Disconnected';
status.style.color = 'red';
input.disabled = true;
sendBtn.disabled = true;
connectBtn.disabled = false;
log('Disconnected', 'status');
};
ws.onerror = (e) => {
log('Error: ' + e, 'status');
};
};
sendBtn.onclick = () => {
if (ws && input.value) {
ws.send(input.value);
log('→ ' + input.value, 'sent');
input.value = '';
}
};
input.onkeypress = (e) => {
if (e.key === 'Enter') sendBtn.click();
};
</script>
</body>
</html>
EOF
echo "=== 6. Dockerfileを作成 ==="
cat > Dockerfile << 'EOF'
FROM node:alpine
RUN apk add --no-cache nginx && mkdir -p /usr/share/nginx/html
WORKDIR /app
COPY package.json .
RUN npm install
COPY ws-server.js .
COPY nginx.conf /etc/nginx/nginx.conf
COPY index.html /usr/share/nginx/html/
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 80
CMD ["/start.sh"]
EOF
echo "=== 7. Dockerイメージをビルド ==="
docker build -t ${REPO_NAME} .
echo "=== 8. ECRにログイン ==="
aws ecr get-login-password --region ${REGION} | docker login --username AWS --password-stdin ${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com
echo "=== 9. イメージにタグを付ける ==="
docker tag ${REPO_NAME}:latest ${IMAGE_URI}
echo "=== 10. ECRにプッシュ ==="
docker push ${IMAGE_URI}
echo "=== 完了 ==="
echo "イメージURI: ${IMAGE_URI}"
IAMロール
Express Mode のコンソールウィザード内にある「新しいロールの作成」を利用して、以下のロールを作成しました。
- タスク実行ロール: ecsTaskExecutionRole

- インフラストラクチャロール: ecsInfrastructureRoleForExpressServices

セットアップ
Express モードのコンソールから「作成」を実施しました。
事前に準備した ECRイメージURIを指定し、ロールを選択するだけの非常にシンプルな手順でした。

CPUやメモリなどのオプション設定はデフォルトのままとしました。

進捗
デプロイが開始すると、リソースタブで進捗が確認できます。

8分程度で全てのリソースが「アクティブ」となり、デプロイ完了となりました。

動作確認
アプリケーション動作
アプリケーション URLにブラウザでアクセスを試みました。
今回の検証用アプリは WebSocket を使用したデモですが、以下の点が問題なく動作することを確認しました。
- HTTP 接続: 静的 HTML フォームの取得
- WebSocket 接続: メッセージの送受信

Express Mode で自動構築された ALB が、特別な設定なしで WebSocket 通信(Upgrade ヘッダー)を正しく通していることが確認できました。
デプロイの確認
「サービスの更新」から、アプリケーションのデプロイ挙動を確認しました。

新しいバージョンの ECR イメージを指定して「更新」を実施すると、バックグラウンドでデプロイが開始されました。

イメージの選択画面は以下の通りです。

更新開始後、10分程度でデプロイ完了となりました。

デフォルトのデプロイ方式はローリングアップデートでした。 新しいタスクのヘルスチェック成功後、一定の並行稼働期間を経て古いタスクが停止される、安全な挙動が確認できました。

ダッシュボード
Express サービスの概要ページに表示されているタブを確認してみました。
オブザーバビリティ
CPU、メモリ使用率と、ロードバランサのメトリクスが確認できました。 グラフにデプロイ実施のタイミングが反映されているため、デプロイに起因する変化に気づきやすい表示でした。

ログ
ECS タスクのアプリケーションログの確認ができました。

リソース
Express サービスが利用する AWS サービス(ECS や ALB)のダッシュボードへの導線となる一覧が表示されました。

まとめ
App Runner のような手軽さで、中身は標準的な ECS を利用開始することが可能になりました。
ロードバランサーとして通常の ALB を複数のサービスで利用することができるため、固定コストを 1 台の ELB 料金に抑制しつつ、ALB のリスナールール、メトリクス、ログなど高度な機能を活用できます。
シンプルなワークロードであれば、まず Express Mode で立ち上げ、必要に応じて詳細設定を実施。 さらにカスタマイズが必要となった段階で、通常の ECS 運用へシームレスに移行(卒業)できるため、将来的なロックインの心配もありません。
CloudFormation などの IaC サポートや、Express Mode で作成されたリソースのカスタマイズ、監視用のサイドカーコンテナの追加などについても、引き続き確認してみたいと思います。






