t4g.nanoのAmazon Linux 2023でyumが止まる問題を、インスタンスタイプを上げずにswapfileで解決してみた
こんにちは、製造ビジネステクノロジー部のはすとです。
先日、t4g.nano(メモリ418MB)でAmazon Linux 2023のEC2を起動したところ、ユーザーデータに書いたyum installが途中で強制終了され、いくつかのパッケージが入りませんでした。
カーネルのログを見ると、OOM Killerがyumを止めていて、同タイミングには、Amazon InspectorのSSM関連付けによって、別のyumも動いていました。
ユーザーデータのyumが失敗する事例は、過去にこちらの記事でも紹介されていますが、そちらの原因はロック競合で、今回の原因はメモリ不足でした。
結論から言うと、ユーザーデータの先頭でswapfileを1GB足すことで、すべてのパッケージが入るようになりました。
結論だけを見たい方はこちらからどうぞ。
本記事では、OOMが起きた瞬間のプロセス一覧を見ながら、なぜメモリが足りなくなったのか、そしてなぜswapfileを足すと止まらなくなるのかを見ていきます。
OOM Killerとは
Linuxカーネルの機能で、メモリが足りなくなったときに動きます。
メモリもswapも使い切り、空きを作れなくなると、カーネルはプロセスを1つ選んで強制終了(SIGKILL)します。
そのプロセスが使っていたメモリを空けて、システム全体が止まるのを防ぐためです。
止めるプロセスは、主にメモリの使用量をもとに選ばれます。
使用量が大きいプロセスほど選ばれやすく、プロセスごとにoom_score_adjという値で選ばれやすさを調整できます。
OOM Killerがプロセスを止めると、カーネルのログに次のような行が出ます。
Out of memory: Killed process 1633 (yum) total-vm:1203732kB, anon-rss:149584kB, ...
止められたコマンドの終了コードは137(128+SIGKILLの番号9)になります。
検証した環境
- インスタンスタイプ:t4g.nano(メモリ418MB)
- AMI:
al2023-ami-2023.12.20260930.0-kernel-6.1-arm64(東京リージョンではami-00a205e9e967454f6) - Amazon Inspector:EC2スキャンと、Linuxのディープインスペクションを有効化済み(スキャンモードは
EC2_HYBRID) - インスタンスプロファイル:
AmazonSSMManagedInstanceCoreだけを付けたロール
失敗した状態を再現する
まずは、最初に失敗したUserDataと同じように、パッケージを1つずつyum installするEC2を起動しました。以降、これをパターンAと呼びます。
入れるパッケージは、lftp、telnet、vsftpd、nodejs、cronie、mount-s3の6つです。
# パッケージを1つずつ yum install する
for p in lftp telnet vsftpd nodejs cronie https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm; do
yum install -y "$p"
done
結果は、6つのうち4つが入りませんでした。
ユーザーデータのログで、パッケージごとの終了コード(rc)を見ると、lftp、telnet、vsftpd、nodejsが137(OOM Killerに止められた)になっています。
※ 本記事の時刻は、すべてUTCです。
rc[lftp]=137 02:27:41
rc[telnet]=137 02:28:00
rc[vsftpd]=137 02:28:28
rc[nodejs]=137 02:28:45
rc[cronie]=0 02:29:01
rc[https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm]=0 02:29:09
※ 実際のUserDataには、このほかにメモリ使用量や終了コードを記録する行を入れています。
実際のUserData
vmstatでメモリとswapの使用量を、procs.logでyumのプロセスとoom_score_adjを1秒ごとに記録しました。
#!/bin/bash
# 計測: vmstat と yum/dnf プロセスの oom_score_adj を 1 秒ごとに記録
# ログの置き場所を作る
mkdir -p /var/log/oomtest
# メモリとswapの使用量を、1秒ごとに vmstat.log へ記録する(バックグラウンドで実行)
nohup vmstat -t 1 > /var/log/oomtest/vmstat.log 2>&1 &
# 1秒ごとに yum/dnf のプロセスを探し、PID・親のPID・oom_score_adj・メモリ使用量(RSS)・コマンドを procs.log へ記録する
nohup bash -c 'while true; do for p in $(pgrep -x "yum|dnf|dnf-3|python3"); do c=$(tr "\0" " " < /proc/$p/cmdline 2>/dev/null) || continue; case "$c" in *yum*|*dnf*) echo "$(date +%T) pid=$p ppid=$(ps -o ppid= -p $p) adj=$(cat /proc/$p/oom_score_adj) rss_kb=$(ps -o rss= -p $p) cmd=$c";; esac; done; sleep 1; done' > /var/log/oomtest/procs.log 2>&1 &
# 以降の出力を、画面と userdata.log の両方に書き出す
exec > >(tee -a /var/log/oomtest/userdata.log) 2>&1
# 開始時刻と、swap(zram)の状態を記録する
echo "start $(date +%T)"; swapon --show; zramctl
# パッケージを1つずつ yum install し、パッケージごとに終了コード(rc)と時刻を記録する
for p in lftp telnet vsftpd nodejs cronie https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm; do
echo "== $(date +%T) yum install $p"; yum install -y "$p"; echo "rc[$p]=$? $(date +%T)"
done
# 終了時刻、yumのキャッシュの大きさ、カーネルログのOOMの行を記録する
echo "done $(date +%T)"; du -sh /var/cache/dnf; journalctl -k | grep -iE "killed process|oom"
なぜメモリが足りなくなるのか
zram swapがあっても足りない
Amazon Linux 2023は、メモリが800MB以下のインスタンスでzram swapを自動で有効にするため、設定ファイルには、しきい値が書かれています。
grep -v "^#" /usr/lib/systemd/zram-generator.conf
[zram0]
zram-size = min(ram, 8192)
host-memory-limit=800
compression-algorithm=lzo-rle
t4g.nanoでも、メモリと同じ418MBのzramが作られていました。
swapon --show
NAME TYPE SIZE USED PRIO
/dev/zram0 partition 418M 0B 100
zramは、メモリの中に圧縮したデータを置く仕組みで、ディスクに書き出すswapとは違い、圧縮後のデータもメモリを使います。
OOMが起きた時点のカーネルログには、Free swap = 0kBと出ていました。
これはつまり、zramを使い切っても、まだメモリが足りなかったということです。
ちなみに、yumは1回で480〜510MBほどのメモリを使うので、zramだけでは受け止めきれません。
zramの仕組み
zramは、メモリの一部を「圧縮して使うswap置き場」にするLinuxカーネルの機能です。
/dev/zram0というディスクのように見える装置を作りますが、実体はメモリの中にあります。
ここに書き出されたページは、圧縮して保存されます。
ディスクを使わないので、ディスク上のswapより読み書きが速く、圧縮する分、同じメモリに多くのページを置けます。
パターンAのユーザーデータが終わった後にzramctlを見ると、26.3MB分のページを9.2MBのメモリで持っていました。
NAME ALGORITHM DISKSIZE DATA COMPR TOTAL STREAMS MOUNTPOINT
/dev/zram0 lzo-rle 418M 26.3M 6.5M 9.2M 2 [SWAP]
ただし、置けるページの量には上限があります。
DISKSIZEの418MBは圧縮前のサイズで、設定のzram-size = min(ram, 8192)により、メモリと同じ大きさになっています。
yumはこれを超える量のメモリを使ったので、zramが満杯になりました。
Amazon Linux 2023では、zram-generatorが起動時にzramを作ります。
起動直後に動くInspectorのyum
EC2の起動直後には、ユーザーデータとは別に、Amazon Inspectorによるyumも動いていました。
Inspectorを有効にしたアカウントでは、SSMのステートマネージャーに次のような関連付けが登録されます。
ターゲットはInstanceIds: *にしているので、SSMに登録されたインスタンスにはすべて適用されます。
aws ssm list-associations --query 'Associations[].[Name,ScheduleExpression]' --output text
AmazonInspector2-ConfigureInspectorSsmPlugin rate(12 hours)
AmazonInspector2-ConfigureInspectorSsmPluginLinux rate(13 hours)
AmazonInspector2-InvokeInspectorSsmPlugin rate(6 hours)
AmazonInspector2-InvokeInspectorSsmPluginLinux rate(6 hours)
AWS-GatherSoftwareInventory rate(30 minutes)
SSMの関連付けとInspectorの関係
関連付けは、Systems Managerのステートマネージャーの機能です。
「どのSSMドキュメントを、どのインスタンスに、どの間隔で実行するか」を登録しておくと、対象のインスタンスで自動的に実行されます。
作成した直後にも1回実行され、その後は登録した間隔で実行されます。
Amazon InspectorのEC2スキャンには、SSM Agentを使う方法(エージェントベース)と、EBSスナップショットを使う方法(エージェントレス)があります。
エージェントベースでは、Inspectorが自分のアカウントに関連付けを作り、インスタンスのソフトウェア一覧を集めます。
今回のアカウントにあった関連付けは、それぞれ次の役割を持っています。
| 関連付けの名前 | ドキュメント | 役割 |
|---|---|---|
| InspectorInventoryCollection-do-not-delete | AWS-GatherSoftwareInventory | ソフトウェアの一覧を集める |
| InspectorLinuxDistributor-do-not-delete | AmazonInspector2-ConfigureInspectorSsmPluginLinux | LinuxインスタンスにInspectorのSSMプラグインをインストールする |
| InvokeInspectorLinuxSsmPlugin-do-not-delete | AmazonInspector2-InvokeInspectorSsmPluginLinux | プラグインを使ってスキャンを始める |
| InspectorDistributor-do-not-delete、InvokeInspectorSsmPlugin-do-not-delete | 上の2つのWindows版 | Linuxではスキップされる |
公式ドキュメントでは、Linux向けの2つはディープインスペクション(プログラミング言語のパッケージまで調べる機能)用と説明されています。
名前に「do-not-delete」とあるとおり、削除してもInspectorが次のスキャンのタイミングで作り直します。
このうちAmazonInspector2-ConfigureInspectorSsmPluginLinuxは、DistributorパッケージAmazonInspector2-InspectorSsmPluginLinuxをインストールする関連付けです。
インストールにはaws:configurePackageを使います。
今回、13時間ごとのスケジュールとは別に、新しく起動したインスタンスにもすぐ適用されていました。
検証用のインスタンスでは、起動から約1分後にyum -y localinstall inspectorssmplugin.rpmが動きました。
フリートマネージャーでインスタンスの関連付けを開くと、成功した時刻を確認できます。

OOMが起きた瞬間に、どのプロセスがメモリを使っていたか
OOM Killerが動くと、カーネルはその時点のプロセス一覧をログに出すので、journalctl -kで確認してみます。
journalctl -k -o short-precise | grep -A60 "Tasks state"
パターンAで起きた4回のOOMについて、メモリの使用量が大きいプロセスを抜き出しました。
使用量は、プロセス一覧のrss(メモリに置かれている量)とswapents(swapに書き出された量)を足して、MBに換算した値です。
どちらも4KB単位の値なので、4KBを掛けて換算しています。
| OOM | 時刻 | ユーザーデータのyum | Inspectorのyum(oom_score_adj -900) | killされた側 |
|---|---|---|---|---|
| 1回目(lftp) | 02:27:41 | 477MB | 記録に現れていない | ユーザーデータ |
| 2回目(telnet) | 02:28:00 | 490MB | 記録に現れていない | ユーザーデータ |
| 3回目(vsftpd) | 02:28:28 | 471MB | 31MB | ユーザーデータ |
| 4回目(nodejs) | 02:28:45 | 30MB | 472MB | ユーザーデータ |
1回目と2回目のOOMでは、大きなメモリを使っていたのはユーザーデータのyumだけでした。
ただ、4回目は様子が異なりました。
カーネルログのプロセス一覧から、該当する2行とkillの行をそのまま抜き出します。
Oct 05 02:28:45.084249 kernel: [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
Oct 05 02:28:45.087207 kernel: [ 2244] 0 2244 191989 42293 1171456 78469 -900 yum
Oct 05 02:28:45.087249 kernel: [ 2497] 0 2497 78564 28 237568 7789 0 yum
Oct 05 02:28:45.087960 kernel: Out of memory: Killed process 2497 (yum) total-vm:314256kB, anon-rss:112kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:232kB oom_score_adj:0
pid 2244がInspectorのyum、pid 2497がユーザーデータのyum(nodejs)です。
メモリを使っていたのはInspectorのyumで、ユーザーデータのyumは30MBしか使っていません。
それでもkillされたのはユーザーデータ側でした。
どちらを止めるかは、oom_score_adjの値に左右されるため、OOM Killerは、この値が小さいプロセスほど止めにくくなります。
SSM Agent(amazon-ssm-agent)は-900で動いていて、そこから起動したInspectorのyumも-900を引き継いでいました。
02:28:10 pid=2244 ppid= 2243 adj=-900 rss_kb=55008 cmd=/usr/bin/python3 /usr/bin/yum -y localinstall inspectorssmplugin.rpm
ユーザーデータのyumは0なので、2つのyumが重なると、Inspector側が残されやすくなります。
ただし3回目のように、ユーザーデータのyum(471MB)がInspectorのyum(31MB)よりずっと大きいときは、ユーザーデータ側が止められます。
Inspectorを止めると何が変わるか
Inspectorの関連付けが動かない状態でパターンAを1回試すと、killされたのは最初のlftpだけで、残りの5つは入りました。Inspectorありでは、4つが入らなかったため、Inspectorのyumと重なることで失敗が増えていたと見られます。
Inspectorなしで試した手順と結果
SSMで接続できないので、パターンAのUserDataの最後に、結果をコンソール出力にも書き出す1行を足しました。
grep -E "^(start|rc|done)" /var/log/oomtest/userdata.log | sed "s/^/OOMTEST /" > /dev/console
結果はget-console-outputで確認しています。
aws ec2 get-console-output --instance-id <インスタンスID> --latest --output text | grep -E "OOMTEST|Killed process"
実行結果です。
Out of memory: Killed process 1629 (yum) total-vm:1236004kB, anon-rss:167288kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:1276kB oom_score_adj:0
OOMTEST start 02:48:25
OOMTEST rc[lftp]=137 02:48:47
OOMTEST rc[telnet]=0 02:49:18
OOMTEST rc[vsftpd]=0 02:49:22
OOMTEST rc[nodejs]=0 02:49:30
OOMTEST rc[cronie]=0 02:49:33
OOMTEST rc[https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm]=0 02:49:40
OOMTEST done 02:49:40
EC2コンソールの「システムログを取得」でも、同じ結果を確認できます。

対策を試す
原因が分かったところで、パッケージの入れ方を変えたパターンを2つ試しました。
| パターン | 入れ方 | ねらい |
|---|---|---|
| B | すべてのパッケージを1回のyum installにまとめる |
yumの回数を減らす |
| C | 先にswapfileを1GB作ってから、Aと同じように入れる | swapの置き場所を増やす |
# すべてのパッケージを1回の yum install で入れる
yum install -y lftp telnet vsftpd nodejs cronie https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm
# 1GBのswapfileを作って有効にする
fallocate -l 1G /swapfile # 1GBのファイルを作る
chmod 600 /swapfile # root以外が読み書きできないようにする
mkswap /swapfile # swapとして使える形式にする
swapon /swapfile # swapとして使い始める
# パッケージを1つずつ yum install する(パターンAと同じ)
for p in lftp telnet vsftpd nodejs cronie https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm; do
yum install -y "$p"
done
実際のUserData(記録用の行を含む全文)
#!/bin/bash
# 計測: vmstat と yum/dnf プロセスの oom_score_adj を 1 秒ごとに記録
# ログの置き場所を作る
mkdir -p /var/log/oomtest
# メモリとswapの使用量を、1秒ごとに vmstat.log へ記録する(バックグラウンドで実行)
nohup vmstat -t 1 > /var/log/oomtest/vmstat.log 2>&1 &
# 1秒ごとに yum/dnf のプロセスを探し、PID・親のPID・oom_score_adj・メモリ使用量(RSS)・コマンドを procs.log へ記録する
nohup bash -c 'while true; do for p in $(pgrep -x "yum|dnf|dnf-3|python3"); do c=$(tr "\0" " " < /proc/$p/cmdline 2>/dev/null) || continue; case "$c" in *yum*|*dnf*) echo "$(date +%T) pid=$p ppid=$(ps -o ppid= -p $p) adj=$(cat /proc/$p/oom_score_adj) rss_kb=$(ps -o rss= -p $p) cmd=$c";; esac; done; sleep 1; done' > /var/log/oomtest/procs.log 2>&1 &
# 以降の出力を、画面と userdata.log の両方に書き出す
exec > >(tee -a /var/log/oomtest/userdata.log) 2>&1
# 開始時刻と、swap(zram)の状態を記録する
echo "start $(date +%T)"; swapon --show; zramctl
# すべてのパッケージを1回の yum install で入れ、終了コード(rc)と時刻を記録する
echo "== $(date +%T) yum install all"
yum install -y lftp telnet vsftpd nodejs cronie https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm; echo "rc=$? $(date +%T)"
# 終了時刻、yumのキャッシュの大きさ、カーネルログのOOMの行を記録する
echo "done $(date +%T)"; du -sh /var/cache/dnf; journalctl -k | grep -iE "killed process|oom"
#!/bin/bash
# 計測: vmstat と yum/dnf プロセスの oom_score_adj を 1 秒ごとに記録
# ログの置き場所を作る
mkdir -p /var/log/oomtest
# メモリとswapの使用量を、1秒ごとに vmstat.log へ記録する(バックグラウンドで実行)
nohup vmstat -t 1 > /var/log/oomtest/vmstat.log 2>&1 &
# 1秒ごとに yum/dnf のプロセスを探し、PID・親のPID・oom_score_adj・メモリ使用量(RSS)・コマンドを procs.log へ記録する
nohup bash -c 'while true; do for p in $(pgrep -x "yum|dnf|dnf-3|python3"); do c=$(tr "\0" " " < /proc/$p/cmdline 2>/dev/null) || continue; case "$c" in *yum*|*dnf*) echo "$(date +%T) pid=$p ppid=$(ps -o ppid= -p $p) adj=$(cat /proc/$p/oom_score_adj) rss_kb=$(ps -o rss= -p $p) cmd=$c";; esac; done; sleep 1; done' > /var/log/oomtest/procs.log 2>&1 &
# 以降の出力を、画面と userdata.log の両方に書き出す
exec > >(tee -a /var/log/oomtest/userdata.log) 2>&1
# 開始時刻と、swap(zram)の状態を記録する
echo "start $(date +%T)"; swapon --show; zramctl
# 1GBのswapfileを作って有効にし、swaponの終了コードとswapの状態を記録する
fallocate -l 1G /swapfile; chmod 600 /swapfile; mkswap /swapfile; swapon /swapfile; echo "swapon rc=$?"; swapon --show
# パッケージを1つずつ yum install し、パッケージごとに終了コード(rc)と時刻を記録する
for p in lftp telnet vsftpd nodejs cronie https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm; do
echo "== $(date +%T) yum install $p"; yum install -y "$p"; echo "rc[$p]=$? $(date +%T)"
done
# 終了時刻、yumのキャッシュの大きさ、カーネルログのOOMの行を記録する
echo "done $(date +%T)"; du -sh /var/cache/dnf; journalctl -k | grep -iE "killed process|oom"
結果
| パターン | 結果 | killされたyum | swap使用量の最大 |
|---|---|---|---|
| A:1つずつ | ❌ lftp、telnet、vsftpd、nodejsが入らず | 4回 | 418MB(zramを使い切り) |
| B:1回にまとめる | ❌ 1つも入らず | 1回 | zramを使い切り(OOM時のログでFree swap = 0kB) |
| C:swapfile 1GBを追加(2回実行) | ✅ すべて入る | 0回 | 約495MB/約508MB |
1回にまとめるBの場合、yumの回数が減るため、Aより有利になるかなと思いきや、Bが一番悪く、一つも入りませんでした。
結果、成功したのは、swapfileを使ったパターンCだけでした。
念の為、2回実行しましたが、どちらもkillは0回です。
swaponの終了コードは0で、swapon --showにはzramとswapfileが並びました。
NAME TYPE SIZE USED PRIO
/dev/zram0 partition 418M 24.1M 100
/swapfile file 1024M 2M -2
なぜ、swapfileで余裕が出たのか
swapは、メモリ(RAM)に置ききれないデータを、一時的に別の場所へ移すことができます。
移すときは、4KBごとのまとまり(ページ)単位です。
動きとしては、メモリに空きが足りなくなると、カーネルはしばらく使われていないページをswapへ書き出し、空いた場所を新しいデータとして割り当てます。
書き出したページが再び必要になると、カーネルがswapから読み戻します。
zramとswapfileはどちらもswapですが、ページの書き出し先が違います。
zramの書き出し先は、メモリの中で、swapfileの書き出し先は、ディスク(EBS)です。
swapon --showのPRIOは優先度で、値が大きいzramから先に使われます。
パターンAでは、zramの418MBを使い切った時点(Free swap = 0kB)で、OOM Killerがyumを止めました。
パターンCでは、swapの使用量が最大で約495MB/約508MBになりました。
zramの418MBを超えた分は、swapfileに書き出されたことになります。
swapfileへの読み書きはメモリより遅くなりますが、yumが一時的にメモリを多く使う間を乗り切れれば、最後まで処理が進みます。
まとめ
結論、t4g.nanoのAmazon Linux 2023で、ユーザーデータのyumがメモリ不足で止まってしまう問題は、ユーザーデータの先頭でswapfileを作ることで解決できました。
メモリ不足と分かったときは、まずインスタンスタイプを上げることを考えましたが、インスタンスタイプを上げるとコストも上がってしまいます。
コストは上げたくないという依頼を受け、なんとか別の方法がないかと探した結果、swapfileにたどり着きました。
私と同じように、小さいEC2インスタンスでユーザーデータのyumが止まってしまった方の参考になれば嬉しいです。





