AWS Systems Manager State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知を実装してみた

AWS Systems Manager State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知を実装してみました。SSM State Managerで処理が完了したイベントはEventBridgeで検知可能なので、EventBridgeからLambda関数を呼び出し、SNSトピックをパブリッシュしてメール通知を行いました。
2021.11.06

Systems Manager Agentの自動更新結果をメール通知させたい

こんにちは、のんピ(@non____97)です。

皆さんは、Systems Manager Agent(以降SSM Agent)の自動更新は行なっていますか? 私は不要になったEC2インスタンスはすぐさま削除派なのでしていません。

ただし、SSM Agentなどの各種ソフトウェアを本番運用するとなると、そのソフトウェアのバージョン管理が必要となります。バージョン管理を行い、定期的にバージョンアップをしなければ、脆弱性やバグが放置されたままとなってしまいます。また、環境によってバージョンが混在していたりすると、トラブルシューティングに時間がかかる原因となります。

SSM Agentではバージョン管理が簡単にできるように、ワンクリックで SSM Agent の自動更新ができるようになっています。

こちらの記事でも触れていますが、実際の運用で気になるところと言えば、「正しくSSM Agentがバージョンアップされたか」だと思います。

ワンクリックで SSM Agent の自動更新をすると、SSM State Managerの関連付けが自動で作成されます。そこで、SSM State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知を実装してみようと思います。

いきなりまとめ

  • SSM State Managerで処理が完了したタイミングでEventBridgeで検知可能
  • SSM State ManagerのログはS3バケットに出力可能
    • CloudWatch Logsに出力することはできない
    • S3バケットにログ出力するためには、EC2インスタンスにログ出力先のS3バケットにオブジェクトをPUTする権限が必要
  • SSM State Managerで関連付けを行ったタイミングで起動していないEC2インスタンスについては、そもそも処理が実行されない

検証の環境

今回の検証の構成は以下の通りです。

構成図

EC2インスタンス2台に対して、SSM Agentの自動更新を行い、正しくバージョンアップされるかを確認します。

単純に自動更新されるのを確認するのも面白くないので、以下パターンでも検証してみます。

  • EC2インスタンスを1台停止してみた状態で関連付けを実行した場合、実行結果はどのようになるのか
    • 1台は成功で1台は失敗?
    • 1台成功で1台はそもそも実行されない?
  • バージョンアップ先に存在しないバージョンを指定して関連付けを実行した場合、実行結果はどのようになるのか
    • 正しく失敗のメール通知が届く?

やってみた

各種リソースのデプロイ

最近AWS CDKを触っていない気がしたので、AWS CDKで各種リソースをデプロイします。

ただし、SSM Agentの自動更新はデプロイ後にマネージメントコンソールからワンクリックで有効化します。

なお、AWS CDKのコードはかなり長くなってしまったので、以下に折りたたみます。

AWS CDK関連の情報

AWS CDKのディレクトリ構成

> tree
.
├── .gitignore
├── .npmignore
├── README.md
├── bin
│   └── app.ts
├── cdk.json
├── jest.config.js
├── lib
│   ├── ec2-instances-stack.ts         # EC2インスタンスと、SSM State Managerのログの出力先のS3バケットを作成するスタック
│   ├── email-notifications-stack.ts   # メール通知用のSNSトピック、SNSサブスクリプションを作成するスタック
│   └── lambda-functions-stack.ts      # SSM State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知をするLambda関数とEventBridgeの設定をするスタック
├── package-lock.json
├── package.json
├── src
│   └── lambda
│       └── functions
│           └── email-nortifications.ts # メール通知用のLambda関数
├── test
│   └── app.test.ts
└── tsconfig.json

6 directories, 14 files

./bin/app.ts

#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "@aws-cdk/core";
import { Ec2InstancesStack } from "../lib/ec2-instances-stack";
import { LambdaFunctionsStack } from "../lib/lambda-functions-stack";
import { EmailNotificationsStack } from "../lib/email-notifications-stack";

const app = new cdk.App();
const ec2InstancesStack = new Ec2InstancesStack(app, "Ec2InstancesStack");
const emailNotificationsStack = new EmailNotificationsStack(
  app,
  "EmailNotificationsStack"
);
const lambdaFunctionsStack = new LambdaFunctionsStack(
  app,
  "LambdaFunctionsStack",
  {
    emailSnsTopic: emailNotificationsStack.emailSnsTopic,
  }
);

./lib/ec2-instances-stack.ts

import * as cdk from "@aws-cdk/core";
import * as s3 from "@aws-cdk/aws-s3";
import * as iam from "@aws-cdk/aws-iam";
import * as ec2 from "@aws-cdk/aws-ec2";

export class Ec2InstancesStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Create S3 Bucket for State Manager log
    const stateManagerLogsBucket = new s3.Bucket(
      this,
      "StateManagerLogsBucket",
      {
        encryption: s3.BucketEncryption.S3_MANAGED,
        blockPublicAccess: new s3.BlockPublicAccess({
          blockPublicAcls: true,
          blockPublicPolicy: true,
          ignorePublicAcls: true,
          restrictPublicBuckets: true,
        }),
      }
    );

    // Create SSM IAM role
    const ssmIamRole = new iam.Role(this, "SsmIamRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonSSMManagedInstanceCore"
        ),
      ],
    });

    // Create IAM Policy for SSM State Manager Logs
    const exportStateManagerLogsBucketIamPolicy = new iam.Policy(
      this,
      "ExportStateManagerLogsBucketIamPolicy",
      {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["s3:PutObject"],
            resources: [`${stateManagerLogsBucket.bucketArn}/*`],
          }),
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["s3:GetEncryptionConfiguration"],
            resources: [stateManagerLogsBucket.bucketArn],
          }),
        ],
      }
    );

    // Atach SSM State Manager Logs IAM Policy to SSM IAM Role
    ssmIamRole.attachInlinePolicy(exportStateManagerLogsBucketIamPolicy);

    // Create a VPC
    const vpc = new ec2.Vpc(this, "Vpc", {
      cidr: "10.0.0.0/24",
      enableDnsHostnames: true,
      enableDnsSupport: true,
      maxAzs: 2,
      subnetConfiguration: [
        {
          name: "Public",
          subnetType: ec2.SubnetType.PUBLIC,
          cidrMask: 27,
        },
      ],
    });

    // Create EC2 instance
    // AmazonLinux 2
    vpc
      .selectSubnets({ subnetGroupName: "Public" })
      .subnets.forEach((subnet, index) => {
        new ec2.Instance(this, `Ec2Instance${index}`, {
          machineImage: ec2.MachineImage.latestAmazonLinux({
            generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
          }),
          instanceType: new ec2.InstanceType("t3.micro"),
          vpc: vpc,
          role: ssmIamRole,
          vpcSubnets: vpc.selectSubnets({
            subnetGroupName: "Public",
            availabilityZones: [vpc.availabilityZones[index]],
          }),
        });
      });
  }
}

./lib/email-notifications-stack.ts

import * as cdk from "@aws-cdk/core";
import * as sns from "@aws-cdk/aws-sns";
import * as snsSubscriptions from "@aws-cdk/aws-sns-subscriptions";

export class EmailNotificationsStack extends cdk.Stack {
  public readonly emailSnsTopic: sns.Topic;

  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const emailAddress: string = this.node.tryGetContext("email-address");

    // Create SNS Topic for Email
    this.emailSnsTopic = new sns.Topic(this, "EmailSNS");
    this.emailSnsTopic.addSubscription(
      new snsSubscriptions.EmailSubscription(emailAddress)
    );
  }
}

./lib/lambda-functions-stack.ts

import * as cdk from "@aws-cdk/core";
import * as iam from "@aws-cdk/aws-iam";
import * as lambda from "@aws-cdk/aws-lambda";
import * as nodejs from "@aws-cdk/aws-lambda-nodejs";
import * as sns from "@aws-cdk/aws-sns";
import * as events from "@aws-cdk/aws-events";
import * as eventsTargets from "@aws-cdk/aws-events-targets";

interface LambdaFunctionsStackProps extends cdk.StackProps {
  emailSnsTopic: sns.Topic;
}

export class LambdaFunctionsStack extends cdk.Stack {
  public readonly emailNortificationsFunction: nodejs.NodejsFunction;

  constructor(
    scope: cdk.Construct,
    id: string,
    props: LambdaFunctionsStackProps
  ) {
    super(scope, id, props);

    // Declare AWS account ID and region.
    const { accountId, region } = new cdk.ScopedAws(this);

    // Create IAM role for Lambda functions for email nortifications
    const emailNortificationsIamRole = new iam.Role(
      this,
      "EmailNortificationsIamRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName(
            "service-role/AWSLambdaBasicExecutionRole"
          ),
        ],
      }
    );

    // Create IAM Policy for sns publish
    const emailNortificationsIamPolicy = new iam.Policy(
      this,
      "EmailNortificationsIamPolicy",
      {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["sns:Publish"],
            resources: [props.emailSnsTopic.topicArn],
          }),
        ],
      }
    );

    // Atach IAM Policy for sns publish to Lambda functions to email nortifications IAM Role
    emailNortificationsIamRole.attachInlinePolicy(emailNortificationsIamPolicy);

    // Lambda function for email nortifications
    const emailNortificationsFunction = new nodejs.NodejsFunction(
      this,
      "EmailNortificationsFunction",
      {
        entry: "src/lambda/functions/email-nortifications.ts",
        runtime: lambda.Runtime.NODEJS_14_X,
        bundling: {
          minify: true,
        },
        environment: {
          ACCOUNT_ID: accountId,
          REGION: region,
          SNS_TOPIC: props.emailSnsTopic.topicArn,
        },
        role: emailNortificationsIamRole,
      }
    );

    // Create EventBridge Rule
    new events.Rule(this, "EventBriedgeRule", {
      eventPattern: {
        source: ["aws.ssm"],
        detailType: ["EC2 State Manager Instance Association State Change"],
        detail: {
          status: ["Success", "Failed"],
        },
        resources: [
          `arn:aws:ssm:${region}:${accountId}:document/AWS-UpdateSSMAgent`,
        ],
      },
      targets: [
        new eventsTargets.LambdaFunction(emailNortificationsFunction, {
          retryAttempts: 3,
        }),
      ],
    });
  }
}

./src/lambda/functions/email-nortifications.ts

import {
  SNSClient,
  PublishCommand,
  PublishCommandInput,
} from "@aws-sdk/client-sns";
import { Context, Callback } from "aws-lambda";

// ref : https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-events.html
interface EventPattern {
  version: string;
  id: string;
  "detail-type": string;
  source: string;
  account: string;
  time: string;
  region: string;
  resources: string;
  detail: any;
}

const REGION = process.env.REGION;
const SNS_TOPIC = process.env.SNS_TOPIC;

const snsClient = new SNSClient({ region: REGION });

exports.handler = async (
  event: EventPattern,
  context: Context,
  callback: Callback
) => {
  const textParams: PublishCommandInput = {
    Subject: event["detail-type"],
    Message: JSON.stringify(event, null, 2),
    TargetArn: SNS_TOPIC,
  };
  await snsClient
    .send(new PublishCommand(textParams))
    .then(() => {
      console.log("Message sent");
      console.log(textParams);
    })
    .catch((error) => {
      console.log("Error, message not sent ", error);
    });
};

./package.json

{
  "name": "app",
  "version": "0.1.0",
  "bin": {
    "app": "bin/app.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk"
  },
  "devDependencies": {
    "@aws-cdk/assert": "1.130.0",
    "@types/aws-lambda": "^8.10.85",
    "@types/jest": "^26.0.10",
    "@types/node": "10.17.27",
    "aws-cdk": "1.130.0",
    "jest": "^27.3.1",
    "ts-jest": "^27.0.7",
    "ts-node": "^9.0.0",
    "typescript": "~3.9.7"
  },
  "dependencies": {
    "@aws-cdk/aws-ec2": "^1.130.0",
    "@aws-cdk/aws-events": "^1.130.0",
    "@aws-cdk/aws-events-targets": "^1.130.0",
    "@aws-cdk/aws-iam": "^1.130.0",
    "@aws-cdk/aws-lambda-nodejs": "^1.130.0",
    "@aws-cdk/aws-s3": "^1.130.0",
    "@aws-cdk/aws-sns": "^1.130.0",
    "@aws-cdk/aws-sns-subscriptions": "^1.130.0",
    "@aws-cdk/core": "1.130.0",
    "@aws-sdk/client-sns": "^3.39.0",
    "aws-lambda": "^1.0.6",
    "source-map-support": "^0.5.16"
  }
}

npx cdk deployで、AWS CDKで定義したリソースをデプロイすると、EC2インスタンスや、Lambda関数、EventBridgeルール、SNSトピックなど各種リソースが作成されていることが確認できます。

EC2インスタンス

EC2インスタンス一覧

Lambda関数

Lambda関数

EventBridgeルール

EventBridgeルール

SNSトピック

SNSトピック

なお、SNSサブスクリプションを作成したタイミングでAWS Notification - Subscription Confirmationという件名のメールが来るので、そのメールに記載のリンクをクリックして、サブスクリプションを確認しています。

SSM Agentの自動更新の有効化

それでは、SSM Agentの自動更新の有効化を行います。

SSM Agentの自動更新をワンクリックで有効化する際は、フリートマネージャーからアカウント管理 - SSM エージェントの自動更新をクリックします。

SSM エージェントの自動更新

確認のポップアップが表示されるので、SSM エージェントの自動更新をクリックします。

SSM エージェントの自動更新の確認

SSM エージェントの自動更新をクリックした後、State Managerを確認すると、SystemAssociationForSsmAgentUpdateという名前の関連付けが作成されていることが確認できます。

SystemAssociationForSsmAgentUpdate

リソースのステータス数Pending:2となっていることから、自動更新を有効化した瞬間に、現在起動中のEC2インスタンスに対してSSM Agentのバージョンアップを行なっていることがわかります。

しばらく待つと、EC2 State Manager Instance Association State Changeという件名のメールが2件届きました。メール本文のjsonを確認すると、statusSuccessとなっていることから、恐らくSSM Agentの自動更新に成功したのだと推測できます。

メール通知の確認1回目

それでは、実行履歴を確認して、本当にバージョンアップされたかを確認します。

マネージメントコンソールからState Managerを確認すると、先ほどリソースのステータス数Pending:2となっていた関連付けが、Success:2になっていることが分かります。

自動更新後のState Managerの状態

こちらの関連付けは裏側でAWS-UpdateSSMAgentというドキュメントをSSM Run Commandで実行しています。その証拠にRun Commandのコマンド履歴を確認すると、コマンドドキュメントがAWS-UpdateSSMAgentとなっている履歴がありました。こちらのステータスも成功となっています。

AWS-UpdateSSMAgentのRun Commandのコマンド履歴確認

インスタンスIDのリンクをクリックすると、詳細なログを確認できるのでクリックします。Outputを確認するとamazon-ssm-agent updated successfully to 3.1.459.0とあるので、正常にバージョンアップできたことが確認できます。

SSM Agentバージョンアップ成功ログ

SSM Agentバージョンアップ成功時のログ

Successfully downloaded manifest
Successfully downloaded updater version 3.1.459.0
Updating amazon-ssm-agent from 3.0.1124.0 to 3.1.459.0
Successfully downloaded https://s3.us-east-1.amazonaws.com/amazon-ssm-us-east-1/amazon-ssm-agent/3.0.1124.0/amazon-ssm-agent-linux-amd64.tar.gz
Successfully downloaded https://s3.us-east-1.amazonaws.com/amazon-ssm-us-east-1/amazon-ssm-agent/3.1.459.0/amazon-ssm-agent-linux-amd64.tar.gz
Initiating amazon-ssm-agent update to 3.1.459.0
amazon-ssm-agent updated successfully to 3.1.459.0

SSM Agentの自動更新結果のS3バケットへの出力

SSM Run Commandのコマンド履歴は1ヶ月程度しか確認できません。もし、1ヶ月以上前のログを確認したい場合は、S3バケットにログを出力する必要があります。

S3バケットにログを出力する設定も数クリックで完了します。

State Managerから関連付けの編集をクリックします。

関連付けの編集

出力オプションのS3 への出力の書き込みを有効にしますをクリックします。すると、ログの出力先となるS3バケット名と、キープレフィックスを入力できるテキストボックスが表示されます。ログの出力先となるS3バケット名とキープレフィックスを入力したら、変更内容を保存をクリックします。

出力オプションの変更

すると関連付けのステータス保留中となります。

保留中の関連付け

そのまましばらく待つと、リソースのステータス数Success:2となります。

関連付けの成功確認

SSM Run Commandのコマンド履歴を確認すると、update skippedと、前回アップデートしたためバージョンアップがスキップされたことが分かります。

SSM Agentバージョンアップスキップログ

SSM Agentバージョンアップスキップ時のログ

Successfully downloaded manifest
Successfully downloaded updater version 3.1.459.0
Updating amazon-ssm-agent from 3.1.459.0 to 3.1.459.0
amazon-ssm-agent 3.1.459.0 has already been installed
update skipped

ログの出力先として指定したS3バケットを確認すると、正しくログが出力されているようでした。

S3バケットに出力したログの確認

stdoutオブジェクトを開くと、SSM Run Commandのコマンド履歴と同じ内容が表示されました。ログ出力も問題なく出来ていますね。

stdoutオブジェクトの確認

メール通知も正常に届いていました。

メール通知の確認2回目

なお、S3バケットに実行結果を出力する際の注意点は、以下AWS公式ドキュメントに記載がある通り、「S3バケットにログ出力するためには、EC2インスタンスにログ出力先のS3バケットにオブジェクトをPUTする権限が必要」な点です。

S3 バケットにデータを書き込む機能を許可する S3 アクセス権限は、このタスクを実行する IAM ユーザのものではなく、インスタンスに割り当てられたインスタンスプロファイルのものです。詳細については「Systems Manager の IAM インスタンスプロファイルを作成する」を参照してください。さらに、指定された S3 バケットが別の AWS アカウント にある場合は、インスタンスに関連付けられたインスタンスプロファイルに、そのバケットへの書き込みに必要なアクセス許可があることを確認してください。

関連付けの編集と新しいバージョンの作成 - 関連付けを編集する (コンソール)

そのため、今回EC2インスタンスにアタッチしているIAMロールは、AmazonSSMManagedInstanceCoreとは別に以下のインラインポリシーをアタッチしています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::ec2instancesstack-statemanagerlogsbucket0a400980-1g7q2lc0h72hc/*",
            "Effect": "Allow"
        },
        {
            "Action": "s3:GetEncryptionConfiguration",
            "Resource": "arn:aws:s3:::ec2instancesstack-statemanagerlogsbucket0a400980-1g7q2lc0h72hc",
            "Effect": "Allow"
        }
    ]
}

EC2インスタンスを1台停止してみた状態で関連付けを実行

EC2インスタンスを1台停止してみた状態で関連付けを実行した場合も検証してみます。

1台は成功で1台は失敗するのか。それとも、1台成功で1台はそもそも実行されないのかが判断つかなかったので、その検証になります。

まず、1台EC2インスタンスを停止させました。

EC2インスタンスの停止

この状態でState Managerから関連付けを今すぐ適用をクリックしました。

しばらく待つと、リソースのステータス数Success:1になっていることが分かります。どうやら1台成功で1台はそもそも実行されないようですね。

EC2インスタンスを1台停止した状態での関連付けの実行結果

Run Commandのコマンド履歴やS3バケットに出力されたログを見ても、1台分のログしか記録されていませんでした。

Run Commandのコマンド履歴 EC2インスタンスを1台停止した状態での関連付けの実行結果 (Run Commandのコマンド履歴)

S3バケットに出力されたログ EC2インスタンスを1台停止した状態での関連付けの実行結果 (S3バケットに出力されたログ)

メールも成功通知が一件のみ届いていました。

メール通知の確認3回目

そのため、停止しているEC2インスタンスがあるからといって、停止中のEC2インスタンス分失敗通知が送られる訳ではないことが分かりました。

バージョンアップ先に存在しないバージョンを指定して関連付けを実行

最後に、バージョンアップ先に存在しないバージョンを指定して関連付けを実行した場合に正しく失敗通知が届くのかを確認します。

State Managerから関連付けの編集をクリックし、パラーメーターのVersion100000と存在しないバージョンを指定して、変更内容を保存をクリックします。

しばらく待つと、リソースのステータス数Failed:1になっていることが分かります。どうやら意図した通り、バージョンアップに失敗しているようですね。

バージョンアップ先に存在しないバージョンを指定して関連付けを実行した結果

Run Commandのコマンド履歴を確認すると、Failed to update amazon-ssm-agent to 100000とあるので、確かにバージョンアップに失敗したことが分かります。

SSM Agentバージョンアップ失敗ログ

SSM Agentバージョンアップ失敗時のログ

Successfully downloaded manifest
Successfully downloaded updater version 3.1.459.0
Updating amazon-ssm-agent from 3.1.459.0 to 100000
amazon-ssm-agent target version 100000 is unsupported on current platform
Failed to update amazon-ssm-agent to 100000
No rollback needed

次に、S3バケットに出力されたログを確認しましたが、stderrオブジェクトは空のようでした。これはエラーメッセージが標準エラー出力に出力されていないためです。そのため、SSM Agentの自動更新の場合は、stderrオブジェクトがPUTされたイベントを検知して、失敗通知を行うといったことは出来ないと考えます。

バージョンアップ先に存在しないバージョンを指定して関連付けを実行結果 (S3バケットに出力されたログ)

メールは失敗通知が一件のみ届いていました。

メール通知の確認4回目

自動更新系の仕組みを導入する場合は更新処理が失敗したパターンも考えよう

AWS Systems Manager State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知を実装してみました。

更新に失敗したまま放置されていて、気づいた時には不具合を踏んでいた」みたいなことがないように、自動更新系の仕組みを導入する場合は更新処理が失敗したパターンも考える必要があると思います。

また、本番運用では通知を受けとって終わりではありません。失敗通知を受けとった場合の対応手順などを用意しておくと、実際に失敗通知が届いた時も慌てずに対応できると考えます。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!