Amazon Linux 2023(以下AL2023)のEC2を100台規模で運用しています。運用しているのは私一人で、全台にSSHで入って手で更新して回るのは現実的ではありませんでした。構成管理はAnsibleで済ませていたのに、OSパッケージの更新だけは仕組みがなく、長いこと当てられていないサーバーがありました。
更新用のPlaybookを書いて、まず稼働中のWebサーバーから当て始めました。全台終わったわけではなく、まだ手を付けていない系統のほうが多いです。この記事は、着手した範囲で分かったことの記録です。
先に結論だけ書いておくと、AL2023はreleaseverごとにリポジトリが固定されるので、dnf check-updateが空に近くても最新とは限りません。古さはreleaseverの差で測ります。それから、一括更新にはディスクの空きが要ります。足りないまま走らせるとinitramfsの生成に失敗し、その状態で再起動すると起動しなくなります。そしてOSを上げると、外部リポジトリのパッケージだけが別の速さで最新に追いつきます。今回いちばん困ったのはこれで、監視が止まりました。
dnf check-update が空になるのはなぜか
AL2023には「リリースバージョン」という概念があって、2023.5.20240701のような日付入りの文字列で表されます。リポジトリのURLにはこの値が埋め込まれていて、指定されたバージョンのリポジトリだけを見に行きます。
つまり、あるサーバーが2023.5系に固定されている限り、2023.12系で提供されている新しいパッケージは視界に入りません。コアパッケージは事実上凍結されます。
dnf check-updateを叩いてもメタデータの期限チェックの行が出るだけで、更新対象のパッケージがほとんど並びません。
これを見て「更新なし=最新」と読んでしまったのが最初の間違いでした。実際には、いちばん古いサーバーは26ヶ月前のリリースバージョンで止まっていました。凍結されている状態と最新である状態は、このコマンドの出力だけでは区別できません。古さを測るにはreleaseverそのものを見る必要があります。
releasever の正しい取り方
releaseverがどこから決まるかは、AWSのドキュメントに優先順位が書いてあります。上から順に、コマンドラインの--releasever、/etc/dnf/vars/releasever、インストール済みのsystem-releaseパッケージのバージョンです。
これを踏まえたうえで、実際に取りに行くときに踏んだ罠がいくつかありました。以下はEC2のフルAMIで運用している前提の話です。最小コンテナイメージでは/etc/dnf/vars/releaseverの扱いが途中から変わっているので、そのままは当てはまりません。
公式の取り方は rpm -q system-release
ロックされているリリースバージョンを見る方法として、AWSのドキュメントが示しているのはsystem-releaseパッケージのバージョンを問い合わせる方法です。
rpm -q system-release
system-release-2023.5.20240701-1.amzn2023.noarch
私は最初、/etc/system-releaseを読む方法で取っていました。中身はこういう文字列です。
Amazon Linux release 2023.5.20240701 (Amazon Linux)
awk '{print $NF}'で最後のフィールドを取れば済むと思っていましたが、末尾の括弧書きに引っかかってLinux)が返ってきます。release の直後に続く数字とドットを取る形なら動きます。
grep -oP 'release \K[0-9.]+' /etc/system-release
ただ、AWSのドキュメントには「/etc/system-releaseは人が読むための文字列で、プログラムからOSやバージョンの判定に使うべきではない」と書かれています。動くには動きますが、書式が変わったら壊れる取り方なので、新しく書くならrpm -q system-releaseのほうを使ってください。
/etc/os-release の VERSION_ID は 2023 しか入らない
/etc/os-releaseのVERSION_IDは2023だけで、日付入りのリリースバージョンは入っていません。最初はこれを見て「壊れているのでは」と思ったのですが、AWSのドキュメントによるとこれは仕様で、VERSION_IDは機械可読なメジャーバージョン(AL2なら2、AL2023なら2023)を入れる欄です。日付入りの文字列はPRETTY_NAMEに入っていますが、こちらは人が読む用の欄なので、やはりプログラムから使うものではありません。
/etc/dnf/vars/releasever は無いホストがある
このファイルがあればそこに固定されますが、無いホストも混在していました。無いからといってlatestに飛ぶわけではなく、system-releaseパッケージのバージョンにフォールバックします。ドキュメントにも、新規インストール直後はこのファイルが存在せず、インストール済みのバージョンにロックされると書かれています。ファイルが無いからといって野放しで最新を追いかけているわけではない、というのは安心材料でした。
dnf check-release-update には既知の不具合があった
新しいリリースがあるかを調べるコマンドですが、マイナーバージョンが9から10に上がるとき、文字列として比較されるために最新ではないリリースを返す問題がありました。この場合、更新を2回走らせないと最新に到達しません。2023.10.20260120で修正されていて、2023.9.20251208以降なら対応は不要です。それより古い環境に当たるときは--releaseverを明示して更新先を自分で決めたほうが確実です。
更新周期をどう見るか
AL2023は標準サポート期間中(2027年6月末まで)、四半期ごとのマイナーリリースと随時のセキュリティ更新という周期で動いています。マイナーリリースは累積なので、飛ばした分もまとめて入ります。この周期を把握してからは、OSの世代ごと毎週上げるような運用はうちには過剰だと整理できました。セキュリティ更新が残っていないかの確認は別で、これは後述するupdateinfoで見ています。
古さを「何ヶ月分の遅れか」で言えるようになったのもこの時点からです。社内の説明でも「未適用パッケージ400件」より「26ヶ月分の遅れ」のほうが伝わりました。
何が上がって、何が上がらないのか
AL2023のパッケージ名はバージョン付き(php8.2-*のような形)なので、OS更新でPHPのメジャーバージョンが勝手に上がることはありません。実機でも8.2系が維持されることを確認しました。ここは安心して進められる部分です。
問題は外部リポジトリです。
監視エージェントやログ収集エージェント、DBクライアントなどを外部リポジトリから入れている場合、それらはreleaseverの固定対象外です。OS更新のついでに、常にその時点の最新版へ追いつきます。OSは2年分まとめて上がるが、外部リポジトリのパッケージは今日の最新に飛ぶ、という速さの違う2つの更新が同時に起きます。
据え置きたいパッケージがある場合はdnfの--excludeで除外します。私のPlaybookでは、実行時に除外したいパッケージ名のリストを渡せるよう独自の変数を1つ用意して、内部でこのオプションに展開しています。あとで書くログ収集エージェントの件は、まさにこの除外が必要になったケースでした。
もう1つ、うちの環境固有の話ですが、DBクライアント用に入れている外部リポジトリの設定が$releaseverをそのままURLに使う書き方になっていて、el/2023.12.20260831/という存在しないパスを見に行って404になります。dnfは無視して処理を続けるので影響はありませんが、そのリポジトリからの更新は効いていない状態です。外部リポジトリのreleaseverの扱いは、事前に設定ファイルを見ておく価値があります。
ディスク容量で起動不能になりかけた話
検証用のインスタンスで、ルートの空きが数百MBの状態で一括更新を走らせたところ、途中でディスクが埋まりました。
このとき怖いのはdnfが途中で失敗すること自体ではなく、initramfsの再生成が失敗した状態で再起動すると起動しなくなることです。このときはEBSを16GBから24GBへ拡張し、initramfsを生成し直して復旧しました。検証機だったので落ち着いて対処できましたが、本番で同じことをやっていたらと思うとぞっとします。
手元で測った値だと、10ヶ月分・264パッケージの更新で、定常的な消費に加えて400〜500MB程度が必要でした。ダウンロードと展開が重なるタイミングを含めると1GB近く要ります。そこでPlaybookの先頭に空き容量のチェックを入れ、閾値は測った値の倍程度を余裕とみて2048MBにしました。
足りない場合のEBS拡張は次の手順です。AL2023のデフォルトのファイルシステムはxfsなのでxfs_growfsを使います(ext4ならresize2fs)。デバイス名はNitro世代の前提なので、それ以外では/dev/xvdaに読み替えてください。
# コンソールでボリュームサイズを変更したあと
lsblk # 新しいサイズが見えるまで待つ
sudo growpart /dev/nvme0n1 1
sudo xfs_growfs /
lsblkで新サイズが見える前にgrowpartを叩くとNOCHANGEで何も起きません。ここで一度つまずきました。もう1つ、AWSのドキュメントによるとgrowpartは一時ディレクトリを作るので、ディスクが完全に埋まっているとNo space left on deviceで失敗します。容量が尽きてから拡張しようとする場面ではこの順番も問題になるので、先に何か消して空きを作る必要があります。
Playbookに入れた安全装置
最終的にPlaybookへ入れた機能です。
-
releaseverの固定: 実行時に更新先を明示できるようにしました。デフォルトはlatestにしていますが、AWSは本番でlatestを使うことを推奨していません。本番で初めてテストすることになりやすいので、テスト済みの特定リリースを指定すべきという立場です(Updating AL2023)。実際、後述する理由で複数台を揃えるときは必ず明示するようになりました。デフォルト値のほうを特定バージョンにしておくのが本来は筋がいいと思っています - 段階展開:
serialで同時に触る台数を絞ります - 再起動の分離: 更新と再起動を別のフラグで制御します。デフォルトでは再起動しません。
dnf needs-restarting -rの結果で再起動要否を判定します(AL2023ではdnfのプラグインとして標準で入っています) - 事前の差分記録: 更新前に
check-updateの結果を保存しておき、何が上がったのかを後から追えるようにしました - 空き容量の事前チェック:
/と/bootをdf --output=availで見て、閾値を下回ればassertで早期に止めます - ロードバランサの切り離しと復帰: ターゲットグループを自動検出して解除し、ドレイン完了を待って更新、再登録してhealthyを待ちます
- 既存のrpmnew警告: 前回の更新で放置された
.rpmnewが残っていれば先に警告します - 報告書の生成: ホストごとに更新前後のバージョン、適用パッケージ数、再起動の有無を書き出します。30日を超えたものは自動で削除します
埋め込んでしまった冪等性のバグ
更新タスクのchanged_whenを、出力にComplete!が含まれるかどうかで判定していました。
changed_when: "'Complete!' in dnf_result.stdout"
これは常にtrueになります。更新対象がなくてもComplete!は出力されるからです。正しくは、リターンコードが0であることとNothing to doが含まれないことの組み合わせで判定します。
changed_when:
- dnf_result.rc == 0
- "'Nothing to do' not in dnf_result.stdout"
一見それらしく見えるのがまずいところで、Complete!という文字列はいかにも「完了したときだけ出る」ように読めます。しかも実行は成功するのでエラーでは気づけません。毎回changedになるので、本当に何か変わったのかが判定できなくなっていました。
再起動後のサービス確認はリトライする
ansible.builtin.rebootはSSHが復帰した時点で完了とみなします。しかしPHP-FPMのようにワーカーを大量に起動する設定だと、SSHが戻った直後はまだ起動中です。
verify_servicesにはnginxやphp-fpmのように、そのホストで動いていてほしいサービス名を並べています。ホストによって入っていないサービスもあるので、先にservice_factsで存在するunitを集めておき、存在するものだけを確認対象にしています。Ansibleのドキュメントによると、systemdはインストールされていないunitもnot-foundという状態で知っていることがあるので、キーがあるかどうかだけでは足りず、statusがnot-foundでないことまで見る必要があります。
- name: Gather service facts
ansible.builtin.service_facts:
- name: Verify services
ansible.builtin.command: "systemctl is-active {{ item }}"
register: svc
changed_when: false
until: svc.stdout == 'active'
retries: 12
delay: 5
loop: "{{ verify_services }}"
when: (ansible_facts.services[item ~ '.service']['status'] | default('not-found')) != 'not-found'
untilを付けたタスクは、終了コードが0以外でも条件が満たされるまでリトライされ、回数を使い切ると失敗します。systemctl is-activeは起動中だと0以外を返しますが、それで止まることはありません。成否を決めているのは終了コードではなくstdoutの値です。changed_when: falseは、状態を見るだけのタスクが毎回changedと報告されるのを防ぐためのものです。
白状すると、初稿では「存在しないサービスはabsentとして許容する」という判定をuntilの条件に書いていました。この記事を書くために読み直して気づいたのですが、systemctl is-activeは存在しないunitに対してもinactiveを返すので、absentという値は一度も出てきません。あの判定は最初から機能していませんでした。存在チェックは別のタスクでやる必要があります。
設定ファイルの .rpmnew をどうするか
更新のたびに3〜4件の.rpmnewが出ます。毎回悩むのは無駄なので、一度きちんと差分を読んで方針を固定しました。
私の環境ではsshd_config、ssh_config、postfixのmain.cf、nginxのnginx.confが出ます。結論はすべて現行維持です。
特にnginxは危険でした。新版はディストリビューション標準の初期設定なので、取り込むとログフォーマットの指定、worker_connections、ヘルスチェック用のserverブロック、メトリクス用のエンドポイント、ヘッダの隠蔽設定などが丸ごと消えます。取り込んだら間違いなく落ちます。
一度判定してしまえば、以降の更新で出た同じファイルは中身を見ずに削除できます。判定済みのリストをドキュメントに残しておくと作業時間がかなり縮みます。
本番展開のやりかた
Webサーバーは負荷対策用に停止中の予備機があったので、次の順で進めました。
- 停止中の予備機を先に更新する(サービス影響ゼロで、大規模一括更新とリモート再起動を実地で試せる)
- 更新済みの予備機を起動してターゲットグループに追加し、台数を増やす
- 稼働中の機体を1台ずつ、ターゲットグループから外して更新して戻す
- 完了後、予備機をターゲットグループから外して平常構成に戻す
予備機を先に更新しておくと、稼働機を1台外しても台数が減らないので、更新中の負荷を気にせず進められます。いちばん古い機体で、26ヶ月分・約400パッケージの一括更新になりました。
バージョンは明示したほうがいい
ここで1つ学びがありました。作業日をまたぐとlatestの指す先が変わります。
数日かけて展開している最中に新しいリリースが出て、後半に更新した機体だけが新しい世代になりました。パッケージの実体は大半が同じでしたが、dnf updateinfoで残っているアドバイザリを数えると、片方の群にはカーネルとOpenSSLを含むセキュリティ更新が20件以上残っていました。「影響なし」と一度判断しかけましたが、これは間違いで、追い付き適用が必要でした。世代が揃っているかはreleaseverで、セキュリティ更新が残っていないかはupdateinfoで、別々に確認する必要があります。
複数台を揃えたいときは-e target_releasever=2023.12.xxxxxxxxのようにバージョンを明示します。デフォルト値をlatestにしたのは筋が悪かったと前に書いたのは、この経験があったからです。
予備機を投入するときの前提
停止していた機体をロードバランサに入れるときは、OSだけ最新化しても足りません。アプリケーションのコードとサーバー設定が最新である必要があります。
これを怠って、古いコードのまま予備機を本番に投入してしまったことがあります。切替期間中だけ、直近のリリースで入った機能が無い状態にデグレしていました。データを確認して影響は無しでしたが、危ないところでした。OS更新の話として書いていますが、実際には「停止中の機体を起動して本番に入れる」ときの一般的な注意点です。
まだ終わっていない範囲
Webサーバーは予備機も含めて同じ世代に揃いました。ただし残りがあります。
同じ構成のサーバーが複数台ある形になっていない系統は、Webのように「1台外して更新して戻す」ができないので、再起動がそのままサービス停止時間になります。同じ手順が使えないため、時間帯の調整か構成そのものを変えるかの判断が先に必要で、まだ着手できていません。
台数でいえばまだ一部しか終わっていない、というのが正直なところです。ただ、外部に面していて台数の多いWebから片付けたので、リスクの大きい順には進められました。
更新のあとで動かなくなっていたもの2つ
OS更新そのものは成功しても、その後ろでエラーも出さずに止まるものがあります。
1. 再起動のたびに落ちるexporter
nginxのメトリクスを取るexporterが、再起動のたびにfailedになっていました。タイミング依存ではなく毎回再現します。
ログにはStart request repeated too quicklyとだけ出ています。unitを読むと原因がわかりました。
-
After=もWants=も書かれておらず、nginxと並列で起動する - nginxがまだメトリクス用のポートを開いていないので、接続に失敗して終了する
-
Restart=alwaysはあるがRestartSecが未指定なので、デフォルトの100ms間隔で再試行する -
StartLimitもデフォルト(10秒に5回)なので、1秒足らずで上限に達して諦める
Afterを足すだけでは足りません。nginxがactiveになってからポートがacceptするまでの差があるので、再試行の間隔と上限をセットで緩める必要がありました。
[Unit]
After=nginx.service
Wants=nginx.service
StartLimitIntervalSec=300
StartLimitBurst=30
[Service]
RestartSec=5
StartLimitIntervalSecとStartLimitBurstは[Unit]側、RestartSecは[Service]側です。うちはunitをAnsibleのテンプレートで配布していたのでテンプレート自体を直しましたが、パッケージ同梱のunitを触りたくない場合は/etc/systemd/system/<unit名>.d/override.confにドロップインとして置いても同じです。
これで再起動後に自動でactiveになることを確認できました。あわせて、Playbookのサービス確認リストにこのexporterを追加しました。それまでnginxとPHP-FPMしか見ていなかったので、Playbookは正常終了しているのに監視だけ落ちている状態を見逃していたのです。
2. プロセスは動いているのにログを1行も読んでいない
こちらのほうが深刻でした。
ログ収集エージェントを外部リポジトリから入れていたため、OS更新に巻き込まれてバージョンが上がりました。新しいバージョンでは、設定ファイル内の環境変数の展開がデフォルトで無効になっていました。
その結果、監視対象のファイルパスを${LOG_PATH}のような形で書いていた部分が、展開されずに文字列のまま扱われました。存在しないパスなので、監視対象を1つも掴んでいません。
困るのは、プロセスはactiveでヘルスチェックも通ることです。systemctl statusはグリーンです。エラーも出ません。ログが1行も流れてこないだけです。
気づいたのは、別の作業でSlackへのアラートが来ないことを不審に思ったときでした。判定の材料になったのは次の2つです。
- 出力先のファイルが作られていない
- ジャーナルに「ファイルの監視を開始した」という趣旨のログが出ていない
同じホストで、バージョンを戻すと実パスが展開され、上げると${...}のままになる、という決定的な差分を確認して確定しました。
対処としては一旦バージョンを戻しました。そのうえで、次のOS更新で再発しないよう、前述の除外リストにこのパッケージを入れています。新しいバージョンに上げる場合は、明示的に環境変数展開を許可するオプションを付けるか、シークレット管理の仕組みに移行する必要があります。
この件で得た教訓は、「プロセスが生きていること」と「仕事をしていること」は別に確認しないといけない、という当たり前の話です。監視エージェントの死活監視はしていましたが、それは前者しか見ていませんでした。
叩き台はAIに書かせた。レビューで拾えたバグと、拾えなかったバグ
技術の本筋からは外れますが、このPlaybookをどう作ったかも書いておきます。
段階展開とロードバランサ操作と事前チェックと報告書生成を全部入れたPlaybookをゼロから起こす時間はなかったので、Claude Codeに叩き台を書かせて、自分でレビューと実機での確認を回す進め方にしました。
要件を日本語で書き下していくと、構造は勝手に立ち上がってきます。ターゲットグループの自動検出、登録解除とドレイン待ち、再登録してhealthyになるまで待つ処理、報告書のテンプレート出力あたりは、自分で書けば半日かかるものが数十分で形になりました。書く時間が減ったのは間違いないです。
ただ、この記事で書いた2つのバグは、どちらも自分がレビューを始めた時点では存在していたものです。changed_whenをComplete!の有無で判定するコードと、systemctl is-activeが返すはずのないabsentを待つ条件です。どちらも実行すれば通るし、エラーも出ないし、Playbookの出力を眺めている限りはきれいなので、間違っていることに気づくきっかけがありません。
このうちchanged_whenのほうは自己レビューの途中で拾えました。Complete!で判定しているのを見て「Nothing to doのときもComplete!って出なかったっけ」と引っかかったのが最初です。absentのほうは拾えませんでした。上に書いたとおり、その時点では引っかからず、この記事のために読み直すまで気づいていません。同じレビューを同じ人がやっていて、読んですぐ引っかかる箇所と、何周読んでも気づかず通してしまう箇所があった。この2件でそれがかなりはっきり出たと思っています。
結局、自己レビューを2周して、そのたびに実機でフル実行し直しました。試すにはバックアップから復元した使い捨てのインスタンスを使っています。壊れても捨てればいいので誰の承認も要らず、ステージングを止めずに何度でも試せます。この環境があったから、レビューを回す気力が続きました。
書く時間は減りましたが、レビューと実機で試す量は減っていなくて、読めば動きそうに見えるコードが増えた分、どう確かめるかを考える比重のほうがむしろ上がった感覚があります。インフラを触るコードについてはそれでちょうどいいと思っています。
最後に
振り返ると、この作業でいちばん価値があったのは、Playbookを書いたことよりも「うちのサーバーは何ヶ月分遅れているのか」を測れる形にしたことでした。測れるようになってはじめて、遅れが縮んでいることも説明できます。
同時に、OS更新は「パッケージを上げる作業」ではなく「上げたあとに何が変わるかを見る作業」だとも思うようになりました。今回いちばんヒヤリとしたのは、更新自体の失敗ではなく、更新が成功したあとに何も言わずに止まっていたログ監視のほうです。
残っている範囲のほうがまだ多いので、続きは進めながら書き足していきます。