要約
- 個別テナントEC2のUserDataは初回起動時にしか実行されず、設定変更のたびにインスタンスを作り直す羽目になっていた
- 定石とされる
cfn-init+cfn-hupをまず検討したが、即時性・ログ集約・追加IAM権限・追加プロセスの4点で見送った - 代わりにAWS Systems Manager State Manager(
AWS::SSM::Document+AWS::SSM::Association)を採用し、スタック更新だけで即座に既存EC2へ反映できるようにした - 実装時に「CloudWatch Logs書き込み権限が意外と付いていない」など、いくつか誤算もあった
以下、経緯と設計を詳しく書きます。
はじめに
マルチテナントSaaSには、顧客ごとに専用のEC2環境をCloudFormationで構築する「個別テナント」層があります。最初のうちはこれで何も問題ありませんでした。
しかし顧客数が増えるにつれ、ある問題がじわじわ効いてきます。UserDataを直してupdate-stackしても、すでに稼働しているEC2には何も反映されないという問題です。
この記事では、この課題にどう向き合ったかを書きます。最初は「定石」とされる方式を検討したのですが、最終的には別のアプローチを選びました。なぜ定石を採らなかったのか、実際どう設計したのかをまとめます。
何に困っていたか
対象は1顧客1EC2の構成です。1つのUserDataに、次の処理が全部詰め込まれていました。
- EBSマウント
- DBコンテナ(PostgreSQL)の起動
- ECSクラスターへの登録
- TLS証明書取得
- nginx設定
原因はシンプルです。UserDataはEC2の初回起動時にしか実行されません。 つまり、テンプレートを書き換えても、すでに動いているインスタンスの中身は何も変わらないのです。
これが実務でどう痛いかというと、たとえば「nginxの設定を1行だけ直したい」という小さな変更でも、選択肢は次の2つしかありませんでした。
- インスタンスを作り直す → DB停止・ECS再登録・証明書再取得を伴う、確実なダウンタイム
- 手動でSSHして直接設定を書き換える → 動くには動くが、テンプレート上の定義と実機の状態がズレていく
どちらも辛い。ここから、UserDataに頼らずに設定変更を反映する仕組みを探すことになりました。
第一候補:cfn-init + cfn-hup
最初に検討したのは、AWSの定石とされる方式です。
-
AWS::CloudFormation::Init:CloudFormationのMetadataに「あるべき構成」を宣言する -
cfn-hup:EC2内で常駐し、Metadataの変化をポーリングで検知してcfn-initを再実行する
この2つを組み合わせれば、テンプレートを更新するだけで既存EC2に設定を反映できます。実績も豊富で、AWS公式ドキュメントにも数多く登場する方式です。
しかし、実際に採用を検討していく中で、いくつか引っかかる点が出てきました。
反映が即時ではない。 cfn-hupはポーリング方式です。間隔は既定でも数分単位なので、スタック更新が終わってもすぐには反映されません。
ログがEC2の中に閉じてしまう。 実行ログは/var/log/cfn-init.logや/var/log/cfn-hup.logといった、EC2内のファイルに残るだけです。何か障害が起きるたびにEC2へ接続してログを見に行く必要があります。
追加のIAM権限が必要になる。 cloudformation:DescribeStackResourceという権限を新たに付与しなければなりません。
EC2内の管理対象が1つ増える。 cfn-hupという常駐プロセスを、新しく監視・運用する対象として抱えることになります。
これらは致命的な欠陥というわけではなく、「定石なりの妥当なトレードオフ」ではあります。ただ、このプロジェクトには決め手になる事情がもう一つありました。
決め手:すでにSSMを使っていた
このプロジェクトでは、証明書配布用のLambda(タグ付きの全EC2に一斉実行する仕組み)で、すでにSSM Run Commandを使っていました。そのためInstanceRoleにはAmazonSSMManagedInstanceCoreというポリシーがすでに付与されており、SSM Agentを動かすための基本権限も揃っていました。
つまり、「新しい仕組みを1つ足す」(cfn-hup)のか、「すでにある基盤の上に乗せる」(SSM)のか、という選択でした。後者のほうがIAM権限体系も運用ノウハウも一本化できると判断し、SSM State Managerでの設計に切り替えました。
採用した方式:SSM State Manager
登場人物
| 要素 | 役割 |
|---|---|
AWS::SSM::Document |
「あるべき構成」の宣言。中身はシェルスクリプト |
AWS::SSM::Association |
Documentをどのインスタンスに・いつ適用するかを結びつける |
| SSM Agent | EC2上にすでに常駐しているプロセス。新規追加は不要 |
| CloudWatch Logs | 実行結果(stdout/stderr)の送信先 |
cfn-init方式との一番の違いは、「変更検知」と「実行」の両方をAWS SSMサービス自身が担うことです。EC2内に監視用の常駐プロセスを新たに足す必要がありません。しかもAssociationはCFnの通常のリソースとして存在するので、update-stackのときの差分検知の仕組みがそのまま使えます。
なぜ即時に反映されるのか
State Managerには、「Associationは新規作成時・更新時に即座に実行される」という仕様があります。つまり、CFnの更新でAssociationのプロパティが変われば、ポーリング待ちなしにEC2へ新しい内容が届くということです。
ここで一つ、設計上の懸念が浮かびました。「Documentの中身だけ書き換えても、Associationのプロパティ自体は変わらないのでは? だとしたら更新イベントが発生せず、結局反映されないのでは?」という懸念です。
これを防ぐため、Documentに渡す実際の値(ドメイン名やポート番号など)を、必ずAssociationのParameters経由で渡すという設計にしました。こうしておけば、CFnパラメータが変わるたびにDocumentの内容も変わり、それに連動してAssociationのParametersも必ず変わります。結果として、CloudFormationは確実に更新を検知してくれます。
初回専用の処理はこれまで通りUserDataに残す
考え方自体はcfn-init方式のときと変わりません。Document化した処理は、Associationが実行されるたびに走り得ます。逆に言えば、初回起動時にしか実行してはいけない処理はDocument化せず、UserDataに残す必要があります。
今回の切り分けは次の通りです。
| 処理 | Document化 | 理由 |
|---|---|---|
| nginx設定 | する | 変更頻度が高く、無停止で反映したい |
| 証明書取得 | する | 同上 |
| ECSクラスター登録 | しない | 初回起動時のみ必要。デッドロック回避の制約があり繰り返し実行に不向き |
| EBSマウント | しない | 初回アタッチ完了を待つ特殊な処理のため |
| DBコンテナ起動 | しない(将来のPhaseで再検討) | 再起動を伴う操作を毎回走らせると影響が大きい |
実装イメージ
nginxの設定には多くのCFnパラメータが絡んでいました。そこで、次のようにハイブリッドな方式を取りました。
- ドメインやポート番号のような単純な値 → SSM独自の
{{ }}記法でDocumentのparametersに切り出す - 複数行になる
serverブロックのような部分 → これまで通りCFnのFn::SubでContentに直接埋め込む
NginxConfigDocument:
Type: AWS::SSM::Document
Properties:
DocumentType: Command
Content:
schemaVersion: '2.2'
parameters:
domain: { type: String }
appPort: { type: String }
mainSteps:
- action: aws:runShellScript
inputs:
runCommand:
- |
cat > /etc/nginx/nginx.conf <<'NGINXCONF'
server {
server_name {{ domain }};
location / {
proxy_pass http://127.0.0.1:{{ appPort }};
}
}
NGINXCONF
nginx -t
systemctl reload nginx
NginxConfigAssociation:
Type: AWS::SSM::Association
Properties:
Name: !Ref NginxConfigDocument
Targets:
- Key: InstanceIds
Values: [!Ref Ec2Instance]
Parameters:
domain: [!Ref DomainName]
appPort: [!Ref NaviInternalPort]
OutputLocation:
CloudWatchOutputConfig:
CloudWatchLogGroupName: !Ref SsmAssociationLogGroup
CloudWatchOutputEnabled: true
このスクリプトにはnginx -tによる文法チェックを残してあります。チェックが通ったときだけreloadするので、壊れた設定がそのまま本番に反映される心配はありません。チェックに失敗した場合はコマンドが非ゼロ終了し、実行結果は「Failed」として記録される仕組みです。
対象EC2の指定方法にも触れておきます。タグではなくInstanceIdsで1台を明示的に指定しています。1顧客1EC2の構成なので、タグ指定にしてしまうと他社のEC2を誤って巻き込むリスクがあるためです。
証明書取得のDocumentには、もう一段仕掛けがあります。共有シークレットの更新にも追従したいため、スタック更新に連動した実行に加えて、ScheduleExpression: 'rate(1 day)'で日次の定期実行も併用しています。新旧の証明書をcmp -sで比較し、差分がある場合だけreloadする冪等性チェックは、既存のスクリプトからそのまま流用しました。
実装してみて分かった誤算
設計段階では「AmazonSSMManagedInstanceCoreさえあればSSM周りは万事OK」だと思っていました。ところが実装してみると、CloudWatch Logsへの書き込み権限(logs:CreateLogStreamなど)はこのポリシーに含まれていないことが分かりました。実行結果をCloudWatch Logsに送るには、ログ出力先のロググループに限定した権限を個別に追加する必要があります。
もう一つ気づいたのは、ログの置き場所についてです。SSM用のロググループを単体で新規作成すると、既存のアプリログが使っている「CloudWatch Logs → Firehose → S3」というアーカイブ経路から孤立してしまいます。そこで、既存と同じパターンでSSMの実行ログもS3側に退避されるよう、構成を追加しました。
段階移行の進め方
全部を一気にDocument化するのはリスクが高いと判断し、影響範囲の小さいものから順に進める計画にしています。
- Phase1(今回の範囲):nginx設定・証明書取得をDocument化
- Phase2:DBコンテナ起動のDocument化を検討。再起動を伴うため、瞬断が許容範囲かを確認しながら進める
- Phase3:ECSクラスター登録のDocument化を検討し、全体の移行を完了させる
まとめ
- UserDataは「起動時に一度だけ実行される」仕組みです。繰り返し変更したい設定までそこに詰め込むと、変更のたびにインスタンス再作成か手動オペレーションかの二択になり、運用が破綻します
- 「定石」とされる方式が、必ずしも自分たちの状況にベストとは限りません。すでにSSMを使っている環境なら、cfn-init+cfn-hupよりもState Managerのほうが理にかなっているケースがあります
- DocumentとAssociationの
Parametersを連動させておくと、「中身は変わったのに更新イベントが発火しない」という事故を構造的に防げます - マネージドポリシーに頼りきらず、実装した機能に本当に必要な権限は個別に確認したほうがいい、という教訓も得ました
「みんなが使っているから」で定石を選ぶ前に、自分たちの環境ですでに何を使っているかを整理してみると、意外と別の選択肢のほうが合っていることもある。そんな話でした。