はじめに
コスト削減のためにNAT GatewayではなくNAT Instanceを使っている方、それなりにいるのではないでしょうか。
私もその一人で、CDKのec2.NatProvider.instanceV2を使ってt4g.nanoでNAT Instanceを立てていました。
なぜNAT Instanceを選んだか
東京リージョン(ap-northeast-1)の料金を比較すると、こうなります。
| 時間単価 | 月額(730時間) | データ処理料 | |
|---|---|---|---|
| NAT Gateway | $0.062(約9.3円)/ 時 | $45.26(約6,800円) | $0.062(約9.3円)/ GB |
| NAT Instance(t4g.nano) | $0.0054(約0.8円)/ 時 | $3.94(約590円) | なし(通信料のみ) |
| NAT Instance(t4g.micro) | $0.0108(約1.6円)/ 時 | $7.88(約1,180円) | なし(通信料のみ) |
※ AWS Price List APIで取得した2026年9月時点のオンデマンド価格です
※ 円換算は1ドル150円で計算しています
月$45(約6,800円)と月$4(約590円)、10倍以上の差があります。
さらにNAT Gatewayはデータ処理料($0.062/GB=約9.3円/GB)が別途かかるので、通信量が多いと差はもっと開きます。可用性やスケーラビリティを考えればNAT Gatewayが正解なのですが、個人開発や社内ツールであればt4g.nanoで十分だと思っていました。
ところが先日、コードを何も変えていないのに本番のAPIが全停止するという事態に遭遇しました。
調べてみると、CDKが自動生成するNAT Instanceの初期化スクリプトがt4g.nanoのメモリに収まらなくなっていたことが原因でした。
しかもこれ、私の環境固有の話ではありません。実際に同じ条件で8台起動して検証したところ、7台が同じ失敗をしました。
同じ構成の方の参考になればと思い、調査の流れと解決策を書いておきます。
TL;DR
- CDKの
ec2.NatProvider.instanceV2が自動生成する既定のuser-dataは、起動時にyum install iptables-servicesを実行する -
t4g.nano(512MB)では、メモリ不足によりOOM Killerが発動し、yumプロセスが強制終了する。同じ条件で8台起動して検証したところ、7台が失敗しました(87.5%) - 既定スクリプトには
set -eが無いため、失敗してもインスタンスはrunning、ステータスチェックもokのまま。NATの機能だけが無い状態で「正常起動」に見えます - 引き金はAMIの更新。コードを一行も変えていなくてもデプロイのたびにAMIが変わるので、ある日突然踏みます
- 対策は
userDataを差し替えてyumの前にswapを1GB確保すること。t4g.nanoのまま、追加コストなしで解決できます
const natProvider = ec2.NatProvider.instanceV2({
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.NANO),
defaultAllowedTraffic: ec2.NatTrafficDirection.OUTBOUND_ONLY,
userData: createNatUserData(), // ← 既定のuser-dataを差し替える
})
createNatUserData()の中身は解決策はこれに載せています。
何が起きたか
症状 - フロントエンドは見えるがAPIが502
デプロイ後に動作確認をしたところ、こんな状態でした。
=== フロントエンド(CloudFront) ===
HTTP 200 / 0.150639s
=== API ヘルスチェック ===
HTTP 502
Internal Server Error
フロントエンドは正常に返ってくるのに、APIだけが502。しかも29秒ほど待たされてからエラーになります。
Lambdaのログを見ると
APIはLambdaで動かしているので、CloudWatch Logsを確認しました。
EXTENSION Name: lambda-adapter State: Ready Events: []
INIT_REPORT Init Duration: 10000.21 ms Phase: init Status: timeout
INIT_REPORT Init Duration: 29001.09 ms Phase: invoke Status: timeout
REPORT RequestId: ... Duration: 29000.00 ms Status: timeout
アプリケーションのログが一行も出ていません。
EIC API server running on port 3001のような起動ログすら出ないまま、初期化がタイムアウトしています。
デプロイ前は正常だった
念のため、デプロイ前のログを確認しました。
EIC API server running on port 3001
START RequestId: ...
REPORT ... Duration: 166.35 ms Init Duration: 981.10 ms
コールドスタートが981msで完了していて、リクエストも正常に返っています。
つまり、このデプロイでAPIが壊れてしまったことになります。
アプリのコードは変更していない
「デプロイでAPIが壊れた」となるとアプリケーション側を疑いたくなりますが、調べてみるとsrc/server.tsもDockerfileも前回のデプロイから一行も変わっていませんでした。
さらに、Dockerイメージをローカルでビルドして起動してみると
EIC API server running on port 3001
Migration warning (will retry on next request): Error: connect ECONNREFUSED 127.0.0.1:5432
普通に起動します。DBに繋がらないのはローカルなので当然として、アプリは問題なく立ち上がっています。
アプリは悪くない。Lambda環境側で何かが起きている。
原因を追う
なぜ起動時にハングするのか
server.tsの起動処理はこうなっていました。
async function main() {
await loadSecretsFromAws() // ← Secrets ManagerからDBパスワードなどを取得
const config = createConfig()
const app = createApp()
app.listen(config.port, () => {
process.stdout.write(`EIC API server running on port ${config.port}\n`)
})
...
}
app.listen()の前にSecrets Managerを呼んでいます。
ここで応答が返ってこなければ、当然listenまで到達せず、起動ログも出ません。ログが一行も出ていなかったことと一致します。
このLambdaはプライベートサブネットに置いているので、Secrets Managerに到達するにはNAT経由で外に出る必要があります。
↓
NATが怪しい。
NATの設定を確認していく
一つずつ潰していきました。
| 確認項目 | 結果 |
|---|---|
| NAT Gatewayの有無 | 存在しない(NAT Instanceを使っているので当然) |
| VPCエンドポイント | 存在しない |
ルートテーブルの0.0.0.0/0
|
NAT Instanceに向いている |
| NAT Instanceの状態 | running |
| ステータスチェック | ok / ok |
| SourceDestCheck |
False(NATには必須。正しい) |
| セキュリティグループ | VPC内からのinbound許可、outbound全許可。正しい |
全部正常です。
ルーティングもSGもインスタンスの状態も問題ない。なのに通信が通らない。
ここで気になったのが、NAT Instanceの起動時刻でした。
i-01c67bf366ba1f815 running t4g.nano 2026-09-15T04:54:16+00:00
デプロイ中に作り直されていました。
コンソール出力で原因が判明
NAT InstanceにはSSHもSSMも設定していなかったので、中に入って調べることができません。
唯一見られるのがEC2のコンソール出力だったので、そこを確認しました。
aws ec2 get-console-output --instance-id i-xxxxx --output text
そしてこれが出てきました。
/var/lib/cloud/instance/scripts/part-001: line 2: 1619 Killed yum install iptables-services -y
Failed to enable unit: Unit file iptables.service does not exist.
Failed to start iptables.service: Unit iptables.service not found.
sudo: /sbin/iptables: command not found
sudo: /sbin/iptables: command not found
yum installがKilledされています。
もう少し遡ると、はっきり書いてありました。
yum invoked oom-killer
Out of memory: Killed process 1619 (yum) total-vm:1240172kB, anon-rss:171920kB
OOM(メモリ不足)でyumが強制終了されていました。
原因はこれだった
CDKが生成するuser-dataの中身
ec2.NatProvider.instanceV2を使うと、CDKが以下のuser-dataを自動生成してNAT Instanceに渡します。
実際に動いていたインスタンスから取り出したものがこちらです。
#!/bin/bash
yum install iptables-services -y
systemctl enable iptables
systemctl start iptables
echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/custom-ip-forwarding.conf
sudo sysctl -p /etc/sysctl.d/custom-ip-forwarding.conf
sudo /sbin/iptables -t nat -A POSTROUTING -o $(route | awk '/^default/{print $NF}') -j MASQUERADE
sudo /sbin/iptables -F FORWARD
sudo service iptables save
1行目のyum install iptables-servicesでiptablesを入れて、最後の方のiptables -t nat -A POSTROUTING ... -j MASQUERADEでNATの設定をしています。
このMASQUERADEの行が、「プライベートサブネットから来たパケットの送信元IPを自分のIPに書き換えて外に出す」というNATの本体です。
yumが死んだことで、それ以降のコマンドが全部command not foundになり、NATの設定が一切行われていませんでした。
なぜ急に足りなくなったのか
不思議だったのが、「なぜ今まで動いていたのか」でした。
インスタンスタイプはt4g.nanoのままですし、コードも変えていません。
調べてみると、AMIが変わっていました。
| AMI | ビルド日 | |
|---|---|---|
| 旧(正常) | al2023-ami-2023.12.20260831.0-kernel-6.1-arm64 |
2026-08-26 |
| 新(障害) | al2023-ami-2023.12.20260914.0-kernel-6.1-arm64 |
2026-09-10 |
どちらも同じAmazon Linux 2023です。OSが変わったわけではなく、2週間新しいビルドになっただけ。
CDKのNAT Instanceは machineImage を指定しなければ最新のAMIを使うので、デプロイのたびにAMIが勝手に新しくなります。そしてAMIのIDが変わるとCloudFormationはインスタンスを作り直すため、そのタイミングで初めて新しいAMI上でuser-dataが実行されます。
つまり、
- 旧AMIでは512MBにぎりぎり収まっていた
- 新AMIで収まらなくなった
ということでした。運任せで通っていた状態が切れた、というのが実態だと思います。
t4g.nanoのメモリは512MBですが、yumは仮想メモリで1.2GBを要求します。zram swapが428MBほど最初から入っているのですが、これは圧縮メモリなので実容量は増えず、助けになりませんでした。
本当に毎回落ちるのか、検証してみた
ここで気になったのが「これは必ず起きるのか、それともたまたまか」でした。
記事で「必ず落ちます」と書くなら裏を取っておきたかったので、実際に検証しました。
やったことはシンプルで、CDKが生成する既定のuser-dataをそのまま渡したt4g.nanoを、最新AMIで8台起動してコンソール出力を確認しただけです。
aws ec2 run-instances \
--image-id ami-0a3c87128f77ec946 \
--instance-type t4g.nano --count 8 \
--user-data file://cdk-default-userdata.sh \
...
結果がこちらです。
| インスタンス | 結果 |
|---|---|
| i-053d947a7ed947962 | ✅ yum成功 |
| i-02143c3c0eb770e6c | ❌ OOM |
| i-05ce222d6c07371d3 | ❌ OOM |
| i-008345fcfca9f8640 | ❌ OOM |
| i-0c19410e1097495d8 | ❌ OOM |
| i-067460ebbe625a87f | ❌ OOM |
| i-046f6fed9bf4cd68b | ❌ OOM |
| i-093cb75dd9e825b03 | ❌ OOM |
8台中7台が失敗(87.5%)、1台だけ成功という結果になりました。
失敗した7台はいずれも同じメッセージです。
Out of memory: Killed process 1622 (yum) total-vm:1234912kB, anon-rss:167288kB
つまり「必ず落ちる」ではなく「高確率で落ちる」が正確でした。
個人的には、この「たまに成功する」というのが一番たちが悪いと思っています。
- デプロイしてたまたま成功すると、問題に気づかない
- その後まったく別の理由でインスタンスが作り直されたときに、今度は失敗する
- しかも失敗しても表面上は正常に見える(後述)
旧AMIで動いていたのも、おそらく成功率がもう少し高かっただけで、元から運任せだったのだと思います。
なお、成功した1台ではこのようにインストールが完走していました。
iptables-services noarch 1.8.8-3.amzn2023.0.2 amazonlinux 18 k
iptables-libs aarch64 1.8.8-3.amzn2023.0.2 amazonlinux 424 k
iptables-nft aarch64 1.8.8-3.amzn2023.0.2 amazonlinux 185 k
一番つらかったのは「静かに壊れる」こと
個人的に一番の問題だと感じたのが、CDKの既定のスクリプトにset -eが無いことです。
そのため、
- yumが死んでも後続のコマンドが走り続ける
- 全部失敗しても、cloud-initとしては「正常終了」扱い
- インスタンスは
running、ステータスチェックもok / ok - ルートテーブルもSGも正常
という状態になります。
どこを見ても正常なのに、NATの機能だけが無い。
監視していても気づけませんし、実際に私も502を踏むまで分かりませんでした。
解決策はこれ
やったこと - yumの前にswapを確保する
結論としては、t4g.nanoのまま解決できました。
インスタンスタイプをt4g.microに上げれば直りますが、それだと月$3.94→$7.88(約590円→約1,180円)と倍になります。せっかくコスト削減のためにNAT Instanceを使っているのに本末転倒だなと思ったので、swapで凌ぐことにしました。
CDKのNatProvider.instanceV2はuserDataを差し替えられるので、そこを置き換えます。
const natProvider = ec2.NatProvider.instanceV2({
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.NANO),
defaultAllowedTraffic: ec2.NatTrafficDirection.OUTBOUND_ONLY,
userData: createNatUserData(), // ← ここを追加
})
function createNatUserData(): ec2.UserData {
const userData = ec2.UserData.forLinux()
userData.addCommands(
'set -euxo pipefail',
// SSH も SSM も無いため、コンソール出力が唯一の手がかりになる
'exec > >(tee /dev/console) 2>&1',
'echo "=== NAT setup start ==="',
// zram だけでは yum に足りない。EBS 上に確保してインスタンスタイプを上げずに凌ぐ
'if [ ! -f /swapfile ]; then',
' dd if=/dev/zero of=/swapfile bs=1M count=1024',
' chmod 600 /swapfile',
' mkswap /swapfile',
' swapon /swapfile',
' echo "/swapfile none swap sw 0 0" >> /etc/fstab',
'fi',
'free -m',
'yum install -y iptables-services',
'systemctl enable iptables',
'systemctl start iptables',
'echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/custom-ip-forwarding.conf',
'sysctl -p /etc/sysctl.d/custom-ip-forwarding.conf',
// 既定ルートのインターフェース名は世代によって変わる(eth0 / ens5)
'IFACE=$(ip route show default | awk \'{print $5}\')',
'echo "NAT interface: ${IFACE}"',
// 送信元 IP を書き換えて外へ出す(NAT の本体)
'iptables -t nat -A POSTROUTING -o "${IFACE}" -j MASQUERADE',
'iptables -F FORWARD',
'service iptables save',
'iptables -t nat -L POSTROUTING -n',
'echo "=== NAT setup done ==="',
)
return userData
}
既定のスクリプトから変えたのは3点です。
1. yumの前にEBS上へswapを1GB確保する
dd if=/dev/zero of=/swapfile bs=1M count=1024
mkswap /swapfile && swapon /swapfile
swapは処理を速くするものではなく、メモリに乗り切らないデータを一時的にディスクへ退避する仕組みです。
swapが無いと、置き場所が無くなった時点でOSがプロセスごと終了します(これがOOM killです)。swapがあれば、しばらく使っていないデータをディスクに逃がして処理を続行できます。遅くはなりますが、最後までやり切れるようになります。
今回メモリを食うのは起動時のyumの1回だけで、NATの転送処理自体はカーネル内で完結してメモリをほとんど使いません。なので定常運用ではswapに触れることもなく、遅くなる心配は不要です。
EBSの空き領域を使うだけなので、追加課金もありません。
2. set -euxo pipefailを付ける
失敗したらそこで止まるようにしました。
「壊れたまま正常に見える」状態が今回の一番の問題だったので、ここは必須だと思っています。
3. 経過をコンソールに出す
exec > >(tee /dev/console) 2>&1
NAT InstanceにはSSHもSSMも入れていないことが多いと思うので、コンソール出力が唯一の手がかりになります。
実際、今回の原因特定も全部この経路で行いました。
検証結果
実際にデプロイして、インスタンスが作り直された状態のコンソール出力がこちらです。
=== NAT setup start ===
Swap: 1441 0 1441 ← swap 確保成功
Installed: ... ← yum 完走(OOM なし)
NAT interface: ens5
+ iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE
MASQUERADE all -- 0.0.0.0/0 0.0.0.0/0 ← NAT ルール適用済み
=== NAT setup done ===
swapが1441MB(zram 417MB + ファイル1024MB)確保され、yumが完走し、MASQUERADEも設定されています。
APIも復旧しました。
試行1: HTTP 200 (2.025s) → {"success":true,"data":{"status":"healthy",...}}
試行2: HTTP 200 (0.107s)
試行3: HTTP 200 (0.272s)
試したけどダメだった案
いくつか回り道をしたので、参考までに残しておきます。
nftablesを使ってyumを回避する案
「AL2023にはnftablesが標準で入っているはずだから、それを使えばインストール自体が不要になるのでは」と考えて試しました。
結果は
/var/lib/cloud/instance/scripts/part-001: line 9: nft: command not found
入っていませんでした。
AL2023はiptablesもnftも同梱していないので、どちらにせよインストールは避けられません。
nftが入っている別のAMIを探す案
AMIを変えれば解決するかと思い、AWSが公開しているarm64のAMIを一通り調べました。
| AMI | 状況 |
|---|---|
| AL2023(kernel 6.1 / 6.12 / 6.18 / minimal) | 同じuserlandなのでいずれも無し |
| Amazon Linux 2 |
iptablesが入っている可能性あり(ただし旧世代) |
NAT専用AMI(amzn-ami-vpc-nat-*) |
提供終了(検索してもヒットしない) |
かつてはiptables設定済みのNAT専用AMIが配布されていたようですが、今は無くなっています。
Amazon Linux 2まで戻れば入っていそうですが、旧世代に移行するのはそれはそれで管理コストが増えるので、今回は見送りました。
旧AMIに固定する案
machineImageで旧AMIを指定すれば動くには動きますが、OSのセキュリティ更新が止まるのでやめました。
NAT Instanceはパブリックサブネットに置いてグローバルIPを持つので、ここを固定するのはリスクが大きいと思います。
タイトルの問いへの回答
「今後t4g.nanoでは起動できなくなるのか?」について、私の理解を書いておきます。
t4g.nanoのスペック自体は、NATとして全く問題ありません。
NATのパケット転送はカーネル内のnetfilterが行うもので、メモリをほとんど使いません。実際、今回の修正後もt4g.nanoのまま安定して動いています。
問題なのは「起動のたびにyumでパッケージを取りに行く」という初期化方式の方です。yum単体で1.2GBを要求するので、512MBでは収まりません。
検証結果(8台中7台が失敗)と合わせると、こう整理できると思います。
- CDKの既定user-dataのまま
t4g.nanoを使う → 約9割の確率で失敗する。しかも残り1割で成功してしまうため気づきにくい - 起動時のインストールが通るようにする(swapを足す等)→
t4g.nanoのままで問題なく動く
タイトルの問いに答えるなら、「t4g.nanoが使えなくなるのではなく、CDKの既定の初期化方式がt4g.nanoでは通らなくなった」というのが正確なところです。
まとめ
- CDKの
ec2.NatProvider.instanceV2が生成するuser-dataはyum installを実行するが、t4g.nano(512MB)ではyum自体がOOMで死ぬ。同条件で8台検証したところ7台が失敗(87.5%) - 引き金はAMIの更新。コードを変えていなくてもデプロイのたびにAMIが変わるので、ある日突然踏む
- しかも1割ほどは成功してしまうため、デプロイが通っても安心できない
- 既定スクリプトには
set -eが無いため、失敗しても「正常起動」に見える。インスタンスはrunning、ステータスチェックもokのままNATだけが動かない -
userDataを差し替えてyumの前にswapを確保すれば、t4g.nanoのまま・追加コストなしで解決できる - あわせて
set -euxo pipefailとコンソール出力を入れておくと、次に何かあったときに確実に気づけるようになる
NAT Instanceは安くて便利なのですが、NAT Gatewayと違ってこういう初期化まわりの面倒を自分で見る必要があるんだなと改めて感じました。
同じ構成を使っている方は、一度コンソール出力を覗いてiptablesがちゃんと入っているか確認してみてください!