SSM Automation1年運用して気づいた、CloudFormationとの使い分け

SSM Automationを本番で1年使ってわかったこと。CloudFormationだけではカバーできない「運用フェーズの自動化」を実装パターンで公開します。

Systems Manager Automationと向き合った1年間

先日プロジェクトで、Systems Manager Automation(以下SSM Automation)を本格導入して1年が経った。正直、最初は「CloudFormationで事足りるんじゃないの?」くらいの気持ちだったんだけど、使い続けるとこいつ単体では動かないんだ。むしろIaCと組み合わせることで初めて力を発揮する。今日はそこまでの試行錯誤を書いておく。

SSM Automationって何が嬉しいのか、本当のところ

うちのチームがSSM Automationを導入した背景は、オペレーション作業の標準化だった。EC2のパッチ当て、バックアップ検証、セキュリティグループの一括更新——こういう「ルーティン化した運用」を手動でやってたんだ。CloudFormationで作るまでは良いんだけど、作った後の「保守」の部分がモダンじゃなかった。

SSM Automationが活躍するのは、CloudFormationで 構築済みのリソースに対して、繰り返し実行できる処理 を標準化したいときなんだ。例えば:

  • 営業時間外のEC2自動シャットダウン
  • 定期的なAMIスナップショット作成と古い世代の削除
  • セキュリティグループの変更をチケット化して承認ワークフロー化
  • Lambda関数のメモリ調整とパフォーマンステスト

構築と運用は別物だってことに気づくまで、結構時間かかったんだよね。「リソースを作ったら終わり」じゃなくて、その後何年も保守し続けるんだから、そこを自動化できると効果は大きいんだ。

実装してみた設計パターン

うちが本番で回してるシステムを図にするとこんな感じ。

graph TB
    subgraph "AWS Account"
        subgraph "VPC"
            subgraph "Application Tier"
                EC2_App["EC2 Instance<br/>app-prod-001"]
                EC2_App2["EC2 Instance<br/>app-prod-002"]
            end
            subgraph "Data Tier"
                RDS[("RDS Aurora<br/>prod-cluster")]
            end
        end
        subgraph "Management Layer"
            CloudFormation["CloudFormation<br/>Stack"]
            SSMAutomation["SSM Automation<br/>Document"]
            EventBridge["EventBridge<br/>Scheduler"]
            SNS["SNS Notification"]
        end
        subgraph "Monitoring"
            CloudWatch["CloudWatch<br/>Logs/Metrics"]
            Dashboard["Cost Anomaly<br/>Detection"]
        end
    end
    
    CloudFormation -->|初期構築| EC2_App
    CloudFormation -->|初期構築| RDS
    EventBridge -->|定期実行| SSMAutomation
    SSMAutomation -->|操作| EC2_App
    SSMAutomation -->|バックアップ| RDS
    SSMAutomation -->|結果通知| SNS
    CloudWatch -->|ログ収集| SSMAutomation
    Dashboard -->|異常検知| SNS

構築(CloudFormation)と運用(SSM Automation)を別層として考えると、責任が明確になるんだ。CloudFormationは「この環境を作る」という一度きりの仕事、SSM Automationは「作った後、毎日毎週どう管理するか」という繰り返しの仕事ってわけだ。

実装コード:本当にうまくいったパターン

うちが1年運用して「これ続きそう」って感じたのは、SSM Automationドキュメントをコード化して、CDKで管理することにしてからだ。

# cdk/ssm_automation_stack.py
from aws_cdk import (
    aws_ssm as ssm,
    aws_iam as iam,
    aws_lambda as lambda_,
    aws_events as events,
    aws_events_targets as targets,
    core,
)
import json

class SSMAutomationStack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, **kwargs):
        super().__init__(scope, id, **kwargs)
        
        # IAMロール: SSM Automationに必要な権限
        automation_role = iam.Role(
            self, "AutomationRole",
            assumed_by=iam.ServicePrincipal("ssm.amazonaws.com"),
        )
        
        # EC2操作権限
        automation_role.add_to_policy(
            iam.PolicyStatement(
                actions=[
                    "ec2:StartInstances",
                    "ec2:StopInstances",
                    "ec2:DescribeInstances",
                    "ec2:CreateImage",
                    "ec2:DescribeImages",
                    "ec2:DeregisterImage",
                ],
                resources=["*"],
            )
        )
        
        # RDS操作権限
        automation_role.add_to_policy(
            iam.PolicyStatement(
                actions=[
                    "rds:CreateDBClusterSnapshot",
                    "rds:DeleteDBClusterSnapshot",
                    "rds:DescribeDBClusterSnapshots",
                ],
                resources=["*"],
            )
        )
        
        # SNS通知権限
        automation_role.add_to_policy(
            iam.PolicyStatement(
                actions=["sns:Publish"],
                resources=[f"arn:aws:sns:*:{self.account}:*"],
            )
        )
        
        # SSM Automationドキュメント: EC2シャットダウンと通知
        automation_doc = ssm.CfnDocument(
            self, "ShutdownAutomation",
            content={
                "schemaVersion": "0.3",
                "description": "Stop EC2 instances and notify via SNS",
                "parameters": {
                    "InstanceIds": {
                        "type": "StringList",
                        "description": "List of instance IDs to stop",
                    },
                    "SNSTopicArn": {
                        "type": "String",
                        "description": "SNS Topic ARN for notifications",
                    },
                },
                "mainSteps": [
                    {
                        "name": "StopInstances",
                        "action": "aws:executeAwsApi",
                        "onFailure": "Continue",
                        "inputs": {
                            "Service": "ec2",
                            "Api": "StopInstances",
                            "InstanceIds": "{{ InstanceIds }}",
                        },
                        "outputs": [
                            {
                                "Name": "StoppingInstances",
                                "Selector": "$.StoppingInstances[0].InstanceId",
                                "Type": "String",
                            }
                        ],
                    },
                    {
                        "name": "PublishNotification",
                        "action": "aws:executeScript",
                        "inputs": {
                            "Runtime": "python3.9",
                            "Handler": "notify_handler",
                            "Script": '''def notify_handler(events, context):
    import boto3, json
    sns = boto3.client("sns")
    
    message = {
        "action": "EC2 Shutdown",
        "instances": "{{ InstanceIds }}",
        "timestamp": "{{ global:DATE_TIME }}",
        "status": "STOPPED",
    }
    
    sns.publish(
        TopicArn="{{ SNSTopicArn }}",
        Subject="Automation: EC2 Instances Stopped",
        Message=json.dumps(message, indent=2),
    )
    return {"statusCode": 200}
''',
                            "InputPayload": {
                                "instances": "{{ InstanceIds }}",
                            },
                        },
                    },
                ],
            },
            document_type="Automation",
            name="ShutdownAutomationDoc",
        )
        
        # Lambda: 実行前の検証ロジック
        validation_lambda = lambda_.Function(
            self, "PreExecutionValidation",
            runtime=lambda_.Runtime.PYTHON_3_9,
            handler="index.lambda_handler",
            code=lambda_.Code.from_inline(
                '''import boto3, json, re
ec2 = boto3.client("ec2")

def lambda_handler(event, context):
    instance_ids = event.get("InstanceIds", [])
    
    # 本番環境のインスタンスのみを許可
    response = ec2.describe_instances(InstanceIds=instance_ids)
    
    for reservation in response["Reservations"]:
        for instance in reservation["Instances"]:
            tags = {tag["Key"]: tag["Value"] for tag in instance.get("Tags", [])}
            if tags.get("Environment") != "production":
                raise Exception(f"Cannot stop non-production instance: {instance['InstanceId']}")
            
            # CriticalityがHighのインスタンスは停止禁止
            if tags.get("Criticality") == "High":
                raise Exception(f"Cannot stop critical instance: {instance['InstanceId']}")
    
    return {
        "statusCode": 200,
        "validationPassed": True,
    }
'''
            ),
        )
        
        # EventBridge: スケジュール実行(平日夜間のみ)
        rule = events.Rule(
            self, "ShutdownScheduleRule",
            schedule=events.Schedule.cron(
                hour="18",  # 18:00 UTC = 3:00 JST
                minute="0",
                week_day="MON-FRI",  # 平日のみ
            ),
        )
        
        # SNS トピック
        from aws_cdk import aws_sns as sns
        notification_topic = sns.Topic(
            self, "AutomationNotifications",
            display_name="SSM Automation Notifications",
        )
        
        # EventBridge → Lambda (検証) → SSM Automation
        rule.add_target(
            targets.LambdaTarget(
                handler=validation_lambda,
                dead_letter_queue=None,
            )
        )

正直、このやり方に至るまで3回は失敗してる。最初はドキュメントをJSONベタ書きしてたんだけど、バージョン管理が地獄になるんだ。変更したのか、誰が何を変えたのか、ドキュメント内部に全部書かれちゃって履歴が追えない。CDKで管理することで、コード化・テスト・デプロイが標準化された。「このドキュメントは〇〇のコミットで変更された」ってGitで追跡できるようになったんだ。

本番で実際に起きた落とし穴

1年運用してわかったのは、設計より「実行権限」と「ロールバック」の仕組みが全てだってことだ。

落とし穴1:IAMロールの過剰付与

最初、自動化を急いでたからAutomationロールに ec2:* をつけちゃったんだ。これやばい。本番で誤った自動化ドキュメントが実行されたときに、取り返しのつかない操作ができちゃう。インスタンスの強制削除とか、セキュリティグループの全削除とか。今は最小権限の原則で、操作ごとにIAM権限を分割してる。

# ❌ 危険: 過剰権限
automation_role.add_to_policy(
    iam.PolicyStatement(
        actions=["ec2:*"],
        resources=["*"],
    )
)

# ✅ 正解: 最小権限
automation_role.add_to_policy(
    iam.PolicyStatement(
        actions=["ec2:StopInstances"],  # 明確に限定
        resources=[
            f"arn:aws:ec2:*:{self.account}:instance/app-prod-*",
        ],
        conditions={
            "StringEquals": {
                "aws:ResourceTag/AutomationAllowed": "true",
            }
        },
    )
)

このやり方にしてからは、誤った自動化ドキュメントが作られても、できる操作が限定される。地味だけど本当に重要な工夫だ。

落とし穴2:実行履歴の追跡

SSM Automationが実行されたけど、「何が起きたのか」を追跡するのが大変だった。CloudWatch Logsとの連携を意識的に設定しないと、トラブル時に「あの時何が起きた?」ってなるんだ。SSM Automation側の実行ログだけだと、タイムスタンプと「成功」「失敗」くらいしか書かれない。詳細がない。

# CloudWatch Logs設定
automation_doc = ssm.CfnDocument(
    self, "ShutdownAutomationWithLogging",
    content={
        "schemaVersion": "0.3",
        "parameters": {
            "InstanceIds": {"type": "StringList"},
            "LogGroupName": {"type": "String"},
        },
        "mainSteps": [
            {
                "name": "LogStartEvent",
                "action": "aws:executeScript",
                "inputs": {
                    "Runtime": "python3.9",
                    "Handler": "log_handler",
                    "Script": '''def log_handler(events, context):
    import boto3, json, time
    logs = boto3.client("logs")
    
    logs.put_log_events(
        logGroupName="{{ LogGroupName }}",
        logStreamName=f"automation/{int(time.time())}",
        logEvents=[{
            "timestamp": int(time.time() * 1000),
            "message": json.dumps({
                "action": "EC2_SHUTDOWN_START",
                "instances": "{{ InstanceIds }}",
                "executor": "{{ aws:USERNAME }}",
            }),
        }],
    )
    return {"success": True}
''',
                },
            },
            # ... その他のステップ
        ],
    },
)

CloudWatch Logsに詳細ログを吐かせることで、後から「あの時のAutomationはどんなパラメータで実行されたのか」「誰が実行した?」「どのEC2インスタンスが対象?」といった質問に答えられるようになった。これがあるとないとで、トラブル対応の速度が全然違うんだ。

落とし穴3:テストの難しさ

本番環境で初めてSSM Automationが実行されるのは怖い。だから、検証環境で同じドキュメントを実行してテストすることにしたんだけど、「環境差分」が課題になった。EC2のタグが違うとか、RDSのスナップショット管理ポリシーが違うとか、IAM権限すら違うこともあった。

今は、ドキュメント内でパラメータ化することで、環境差分を吸収してる。

# CDKデプロイ時に環境別パラメータを注入
for env_name in ["dev", "staging", "production"]:
    SSMAutomationStack(
        app,
        f"SSMAutomation-{env_name}",
        environment_name=env_name,
        target_instances_tag=f"Environment={env_name}",
        sns_topic_arn=f"arn:aws:sns:*:{account}:automation-{env_name}",
    )

同じドキュメント定義でも、デプロイ時に環境変数を注入することで、dev環境ではdev用、production環境ではproduction用に動く。「prod環境でしかテストできない」という悪夢から解放された。

IaCとの組み合わせで何が変わったか

うちがCloudFormation + CDK + SSM Automationの三層構造にした理由は、責任分離とテスト性が劇的に上がるからだ。

  • CloudFormation層: リソースの初期構築
  • SSM Automation層: リソースのライフサイクル管理(起動・停止・バックアップ・削除)
  • CDK層: インフラの動的な構成と依存関係の管理

これまでは「CloudFormationで全部」ってスタイルだったんだけど、この分割で何が変わったかというと:

テスト性が上がった。SSMドキュメントを単体テストできるようになった。「このドキュメントを検証環境で実行したら、ちゃんと古いスナップショットが削除されるか」をテストしてからプロダクトに上げられる。

再利用性が増した。SSMドキュメントを複数スタック間で共有できる。「バックアップドキュメント」を本体プロジェクトとは独立させておいて、必要な全環境から呼び出す、みたいなことができる。

運用コストが下がった。自動化ドキュメントの更新がコード変更と同期される。「新しいバージョンのドキュメントをデプロイしたのに、誰かが古い方を実行しちゃった」という悲劇が起きなくなった。

トレーサビリティが確保された。Git履歴にIaCの変更とSSM変更が全部残る。1年前のドキュメントがどんな状態だったのか、Gitのログで確認できる。

今はこの構成で、新しい環境を作ったらSSM Automationが自動で統合される。環境差分による手作業が減ったんだ。CloudFormationだけのときは、「新環境作ったから、バックアップドキュメントもマニュアルで構成し直して」みたいなことをやってた。今は完全に自動だ。

実運用での工夫と現実

ここからが本音なんだけど、正直SSM Automationってドキュメント設計がすべてなんだ。よく設計されてれば魔法のように便利だけど、テキトーに作ると運用の足かせになる。複雑なドキュメントが残ると、誰も手を出せなくなる。

うちのチームで「これ続きそう」って感じるようになったのは、以下を意識してからだった:

ドキュメントはシンプルに。複雑なロジック(条件分岐が多い、複数サービスを組み合わせるなど)はLambdaに出す。ドキュメントは「Lambdaを呼び出す → 結果を通知する」くらいのシンプルさ。そうすると、1年後に読んでも「あ、こういう流れね」と一目で分かる。

エラーハンドリングを手厚く。失敗時の通知と自動ロールバックを組み込む。「Automationが失敗しました」で終わると、本番が放置されちゃう。Slackに通知が来て、Opsgenie経由でオンコールが呼ばれる、くらいの手厚さが必要だ。

監査ログを残す。CloudTrail + CloudWatch Logsで完全追跡可能に。「誰が何を実行した」「どのリソースが対象」「成功したか失敗したか」「失敗なら何がエラーだったか」。これらが全部Logsに吐き出されることで、事後対応がマジで楽になる。

定期的な動作検証。月1回は本当に動くか確認する。自動化は放置されると劣化するんだ。「このドキュメント、最後に実行されたのいつだ?」って状態になると、いざ使おうとしたときにバグが発覚する。だから月1回の定期実行テストを習慣化した。

チーム内で責任者を決める。「このドキュメントの責任者は誰か」を明確にしておくこと。複数人が触ると、誰も責任を取らなくなる。「このドキュメントはXさんが管理してる。修正が必要なら相談してね」くらいの明確さがいる。

あと個人的には、Automationドキュメント内で外部システムとの連携(Slack通知とか、外部APIへのリクエストとか)は避けた。理由は、外部システムのダウンタイムでAutomationが失敗したら困るから。通知はAWS内のSNSに留めて、SlackやPagerDutyへの通知はLambdaを別で用意した。疎結合にすることで、本体のAutomationはシンプルに保った。

まとめ

  1. SSM Automationはリソース構築ではなく、構築後の運用を自動化するもの——CloudFormationの次のレイヤー。構築と保守は別の関心事だ。

  2. IAM権限は最小限に、CloudWatch Logsで全実行を記録——トラブル時の追跡可能性が全て。過剰な権限は絶対に避ける。

  3. CDKで管理することで、IaCとして自動化の変更をGitで追跡可能に——人手による運用が減る。ドキュメント管理のバージョン戦争から解放される。

  4. 複雑なロジックはSSM内に書かず、Lambdaに処理を分ける——ドキュメント設計の複雑さを防ぐ。1年後に読んでも分かるドキュメントを心がける。

  5. 本番運用は最小権限 + 実行前検証 + ロールバック可能な設計——自動化の失敗を最小化する。自動化が本番を壊さないことが大前提。

次のステップとしては、本格的な監視設計と組み合わせて、本番障害の自動復旧をSSM Automationで実装するのが目標。今のところは検証環境で動いてる。本番で実行するとなると、さらに慎重な設計が必要だ。

SSM Automationを導入検討してるなら、最初は「1つの運用タスク」だけを自動化することをお勧めする。複数タスク同時はトラブル対応が大変になるんだ。1年運用してそれが身に沁みてる。EC2の定期停止だけとか、AMI作成だけとか、シンプルなものから始めて、安定したら拡張する。その方が本当に続く自動化になる。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事