SSM・CloudWatch Agentの自動更新とエラー発生時のメール配信

クマ松です。

皆さん、AWS Systems Managerはお使いでしょうか。
AWS Systems ManagerはAWS環境のEC2やオンプレのサーバを効率よく運用することが出来るサービスです。
サーバ構成のインベントリをコンソールから確認したり、決められた時間にパッチを適用したり等、よくある定型作業の自動化を実現してくれます。

今回はこのSystems Managerの機能の1つである「State Manager」を用いて、EC2にインストールしたSystems Manager AgentとCloudWatch Agentを定期的にアップデートしてくれる構成を作ろうと思います。

![アラートメール配信アーキテクト.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307047/78dd37f5-0a5d-52cf-22d3-09e12469605f.jpeg)


#前提知識
##State Managerについて
###Black Beltの説明
AWSのBlack Beltの文言を借りると、State Managerは「サーバの状態を**定義された状態**に保つ／**是正**するプロセスを自動化」するサービスです。

[AWS Black Belt Online Seminar AWS Systems Manager 資料及び QA 公開](https://aws.amazon.com/jp/blogs/news/webinar-bb-aws-systems-manager-2020/)

###「定義された状態」と「是正」
「定義された状態」と「是正」を説明する為に一つ例を挙げます。

AさんがEC2の初期設定でプロキシのサーバとポートを10.0.0.0:8080等と設定したとします。
そしてEC2をBさんに引き渡したとしましょう。

Bさんはインフラに不慣れで、勉強の為のコマンド実行によりプロキシのサーバとポートをリセット（設定されていない状態）してしまいました。
BさんはEC2からプロキシを通って社内のサービスにアクセスできなくなってしまい、何が何だかわからずAさんに問い合わせをすることになりました。

Aさんは再度プロキシのサーバとポートの設定を行いました。

上記の例とState Managerの紹介文とを照らし合わせると、**定義された状態**が**「プロキシのサーバとポートの設定」**に当たります。
そして**是正**が**「Aさんによる再設定」**です。

###State Managerで何が出来るか
プロキシのサーバとポートの設定が変更されたかされていないか、人の目で定期的にチェックするのは現実的ではありません。
Systems ManagerとStateManagerを使うことで、**変更されたくない状態を定義**し、**設定を定期的にチェックし、変更があった場合に元に戻す***ことが出来ます。


##State ManagerとSSMドキュメント
###SSMドキュメントとは
ここまでStateManagerの話をしていましたが、少しSSMドキュメントについて触れなくてはなりません。
「変更したくない状態を定義」する為のAWSサービスがSSMドキュメントです。

SSMドキュメントとは、定期的に実行したいコマンドを定義・管理することが出来るもので、JSONかYAMLで定義されます。
例えば先ほどのプロキシサーバとポートの例で言うと、プロキシサーバを設定するコマンドを定義したカスタムドキュメントを作成できます。

```json
{
  "schemaVersion": "2.2",
  "description": "Linux Process Monitoring",
  "parameters": {
    "Service": {
      "type": "String",
      "default": "",
      "description": "(Required) Input Linux Process Name."
    }
  },
  "mainSteps": [
    {
      "action": "aws:runShellScript",
      "name": "resetProxy",
      "inputs": {
        "runCommand": [
          "$env:http_proxy=\"http://10.0.0.0:8080\"",
          "",
          "$env:https_proxy=\"http://10.0.0.0:8080\"",
        ]
      }
    }
  ]
}
```

SSMドキュメントはあくまで**コマンドが定義できるだけ**です。
作成したSSMドキュメントは、Systems Managerの他の機能と組み合わせることで実行できます。

###SSMドキュメントと他のSystems Managerのサービスの関連
Systems ManagerにはPatch ManagerやDistributor、SSM Inventory等様々なサービスがあります。
これらの実態は「SSMドキュメントを実行する為のフレームワーク」です。
「ボタン数クリックで出来たけど、裏ではSSMドキュメントが実行されている」というものもあります。

SSMドキュメントだけでは定義しきれない設定や、ドキュメントに渡すパラメータの指定、ドキュメントを実行するタイミングを指定することが出来ます。

今回の構成では
AWSが事前に作ってくれた**AWS-ConfigureAWSPackage**と**AWS-UpdateSSMAgent**というSSMドキュメントを使い
State Managerで定義したスケジュールに従い
Systems Managerのコマンドを実行する為の機能である**RunCommand**が実行されます。

#設計
##State Manager
###実行頻度
SSM Agentはリリースノートが2週間に1度という頻度で更新されるので、なるべく頻繁にアップデートをした方が良いです。
State Managerのスケジュールでは「2週間に1度」は定義できないので、毎週火曜日の日本時間14時にしましょう。

CloudWatch Agentはそこまでリリース頻度は多くないので、30日に1度（State Managerで指定できる頻度のMAX）とします。

###アップデートするEC2の対象
対象をEC2のタグで絞ることにします。
Key：Valueの組み合わせがope-type：autoのものに絞ります。

###アップデート失敗時に手オペに切り替えたい
SSM AgentやCloudWatch Agentのアップデートに失敗した場合、迅速に気付く必要があります。
でないと気づかないうちにSession ManagerでSSH出来なくなったり、CloudWatchにメトリクスが送信されなくなったりという事態が発生します。
なので特定のドキュメントを実行し、失敗した際に検知できるようEvent Bridge・Lambda・SNSを使います。

#構築
##Lambda用IAMロール
###CloudFormationテンプレート

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'IAMロール'
Resources:
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: role-lambda
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: /
      Policies:
      - PolicyName: policy-lambda
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - sns:Publish
            - ssm:GetParameters
            - logs:CreateLogGroup
            - logs:CreateLogStream
            - logs:PutLogEvents
            Resource: '*'

```

##運用担当者用SNS
###CloudFormationテンプレート

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: SNS Sample Stack

Resources:
  NotifyMailSNSTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: OPE
      Subscription:
        - Endpoint: xxx+sample@hoge.jp
          Protocol: email
```


##StateManager
###CloudFormationテンプレート
複数サーバが対象の場合、MaxConcurrencyを100%にすることで同時にアップデートを実行する事が出来ます。
またMaxErrorsを0%にすることで、1台でも失敗した場合はアラートメールを配信することが出来ます。

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Description:
  "State Manager for MiddleWare Auto Update"
Resources:
# ------------------------------------------------------------#
# State Manager for SSM Agent
# ------------------------------------------------------------#
  TaggedInstancesAssociation:
    Type: AWS::SSM::Association
    Properties:
      AssociationName: UpdateSSMAgent
      Name: AWS-UpdateSSMAgent
      ScheduleExpression: cron(0 5 ? * TUE *)
      Targets:
      - Key: tag:ope-type
        Values:
        - auto
      WaitForSuccessTimeoutSeconds: 300
      MaxConcurrency: 100%
      MaxErrors: 0%
  CloudWatchAgentAssociation:
    Type: AWS::SSM::Association
    Properties:
      AssociationName: UpdateCloudWatchAgent
      Name: AWS-ConfigureAWSPackage
      ScheduleExpression: rate(30 days)
      Targets:
      - Key: tag:ope-type
        Values:
         - auto
      Parameters:
        action: 
         - Install
        installationType: 
         - "Uninstall and reinstall"
        name: 
         - AmazonCloudWatchAgent
      WaitForSuccessTimeoutSeconds: 300
      MaxConcurrency: 100%
      MaxErrors: 0%
```

##SSM Parameter Store
AWSアカウントの和名をパラメータストアで指定します。
これでどこのAWSアカウントからメールが配信されたかを識別します。

![SSMパラメータ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307047/63e329d8-886c-1ac4-0bb7-5298b3615356.png)


##EventBridge・Lambda・SNS
###CloudFormationテンプレート(SAMバージョン)
```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: メール送信用Lambda（RunCommandの失敗）
Resources:
  SendSNSRunCommandFailure:
    Type: 'AWS::Serverless::Function'
    Properties:
      FunctionName: SendSNSRunCommandFailure
      Handler: lambda_function.lambda_handler
      Runtime: python3.8
      CodeUri: #適宜修正
      Description: メール送信用Lambda（RunCommandの失敗）
      MemorySize: 128
      Timeout: 300
      Role: !Sub 'arn:aws:iam::${AWS::AccountId}:role/role-lambda' #IAM Roleは事前に作る
      Events:
        CloudWatchEvent1:
          Type: CloudWatchEvent
          Properties:
            Pattern:
              detail-type:
                - EC2 Command Invocation Status-change Notification
              source:
                - aws.ssm
              detail:
                document-name:
                  - AWS-UpdateSSMAgent
                  - AWS-ConfigureAWSPackage
                status:
                  - Failed
        SNS1:
          Type: SNS
          Properties:
            Topic:
              Ref: SNSTopic1
  SNSTopic1:
    Type: 'AWS::SNS::Topic'

```

###CloudFormationテンプレート(not SAMバージョン)
Lambdaを手で構築した場合は、SAMバージョンではないCloudFormationを使ってEventBridgeだけ構築します。
こちらのテンプレートにLambdaを含めてデプロイすることも可能ですが、コードが長いので今回は省略しています。

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Description:
  "not SAM version"
Resources:
# ------------------------------------------------------------#
# EventBridgeとLambdaを呼ぶためのPermission
# ------------------------------------------------------------#
  EventRule:
    Type: 'AWS::Events::Rule'
    Properties:
      Description: EventRule
      EventPattern:
        detail-type:
          - EC2 Command Invocation Status-change Notification
        source:
          - aws.ssm
        detail:
          document-name:
            - AWS-UpdateSSMAgent
            - AWS-ConfigureAWSPackage
          status:
            - Failed
      State: ENABLED
      Targets:
        - Arn: #作成したLambdaのARN
          Id: lambda
  PermissionForEventsToInvokeLambda:
    Type: 'AWS::Lambda::Permission'
    Properties:
      FunctionName: SendSNSRunCommandFailure
      Action: 'lambda:InvokeFunction'
      Principal: events.amazonaws.com
      SourceArn: !GetAtt 
        - EventRule
        - Arn

```

###Lambdaの構成
英語が苦手な人の為に、日本語のメールが配信されるようにテンプレートを用意しました。
またLambda自体が何らかの理由で異常終了したにもメールを配信するようにします。
SendSNSRunCommandFailure
　┣lambda_function.py
　┗template
　　┣mail_context.txt　－－－RunCommandメールのテンプレ
　　┗error_mail_context.txt　－－－Lambda自体が異常終了した際に送信するメールのテンプレ

###mail_context.txtの中身
RunCommandが失敗した時刻、コマンドID、インスタンスIDをメールに表示します。

英語が苦手な人は、SNSのunsubscription URLを訳もわからず押してしまうこともあるかもしれません。
優しい文言を加えておきます。

```text
運用チーム各位

以下のアラートを検知しました。

------------------------------------------

発生日時　　　　：ver_incident_date
コマンドID　　　：ver_command_id
ドキュメント　　：ver_document
インスタンスID　：ver_instance_id

------------------------------------------

RunCommandが異常終了しました。
下記リンクからコマンド履歴を確認の上調査をお願い致します
https://ap-northeast-1.console.aws.amazon.com/systems-manager/run-command/complete-commands?region=ap-northeast-1

******************************************************

本メールは自動送信です。
以下のリンク（※）をクリックしますとアラートメールが発報されなくなりますのでご注意ください。
※https://sns.ap-northeast-1.amazonaws.com/unsubscribe.html~

******************************************************


```

###error_mail_context.txtの中身
Lambda自体が何らかの理由で異常終了した際のメールのテンプレートです。

```text
運用チーム各位

下記のLambdaが異常終了しました。
ご確認をお願い致します。

【発生時刻】ver_error_date
【Lambda名称】ver_lambda
【エラー内容】
 ver_error

******************************************************

本メールは自動送信です。
以下のリンク（※）をクリックしますとアラートメールが発報されなくなりますのでご注意ください。
※https://sns.ap-northeast-1.amazonaws.com/unsubscribe.html~

******************************************************
```

###Lambdaのコード
Lambda

```python
# coding: UTF-8

import boto3
import json
import dateutil.parser
import datetime
from dateutil.relativedelta import relativedelta

mail_template = './template/mail_context.txt'
error_mail_template = './template/error_mail_context.txt'

ssm = boto3.client('ssm')
sns = boto3.client('sns')

ope_team_sns_topic = 'OPE'

def lambda_handler(event, context):
    """
    処理内容：最初に呼ばれる関数
    
    Parameters
    --------
    event : dict
        from StepFunction
    context : dict
        builtin args of Lambda

    """  
    global account_id
    account_id = event['account']
    
    command_id = event['detail']['command-id']
    instance_id = event['detail']['instance-id']
    document_name = event['detail']['document-name']
    time = event['time']
    
    try:
        send_sns(time, command_id, instance_id, document_name)
    except Exception as e:
        send_error_sns(str(e), context.function_name)
        raise e


def send_sns(time, command_id, instance_id, document_name):
    """
    処理内容：send mail by SNS

    Parameters
    ----------
    time : datetime
        イベント発生時刻
    command_id : string
        Systems MangerのコマンドID
    instance_id : string
        EC2
    document_name : string
        失敗したSSMドキュメント

    """

    # アカウント名（和名取得）
    account_name = get_account_name()

    #メール件名の指定
    subject = '[' + account_name + ']' + '['+ document_name + ']' + 'ERROR'

    #メール本文の指定
    sns_body = create_text(time, command_id, instance_id, document_name)
    
    #送信先SNSトピックの指定
    ope_topic_arn = 'arn:aws:sns:ap-northeast-1:'+ account_id + ':' + ope_team_sns_topic

    #SNSへのパブリッシュ
    response = sns.publish(
        TopicArn = ope_topic_arn,
        Subject = subject,
        Message = json.dumps(sns_body, ensure_ascii=False),
        MessageStructure='json'
    )


def get_account_name():
    """
    処理内容：パラメータストアからアカウント名（和名）を取得

    Returns
    --------
    account_name : string
        アカウント名（和名）

    """
    
    ssm_response = ssm.get_parameters(
      Names = [
        "AccountName_ja"
      ],
      WithDecryption=True
    )
    
    return ssm_response['Parameters'][0]['Value']

    
def create_text(time, command_id, instance_id, document_name):
    """
    処理内容：本文を作る

    Parameters
    ----------
    time : datetime
        イベント発生時刻
    command_id : string
        Systems MangerのコマンドID
    instance_id : string
        EC2
    document_name : string
        失敗したSSMドキュメント

    """

    #時刻の整形(UTC→JST)
    date_before = dateutil.parser.parse(time) + datetime.timedelta(hours=9)
    tmp_incident_date = date_before.strftime('%Y/%m/%d %H:%M:%S')

    with open(mail_template) as f:
        mail_context = f.read()
    
    # アラート発報時刻
    mail_context = mail_context.replace('ver_incident_date', tmp_incident_date)
    # 異常終了したコマンド
    mail_context = mail_context.replace('ver_command_id', command_id)
    # 異常終了したインスタンス
    mail_context = mail_context.replace('ver_instance_id', instance_id)
    # 異常終了したインスタンス
    mail_context = mail_context.replace('ver_document', document_name)
    
    #メール文の整形
    tmp_sns_body = {}
    tmp_sns_body["default"] = mail_context + "\n"

    return tmp_sns_body


def send_error_sns(error, lambda_name):
    """
    Lambdaが異常終了した際にSNSメールを送信

    Parameters
    ----------
    error : string
        error message when error occurs
    lambda_name : string

    """
    now = datetime.datetime.now() + datetime.timedelta(hours=9)
    now = now.strftime('%Y/%m/%d %H:%M:%S')

    with open(error_mail_template) as f:
        data_lines = f.read()
    
    data_lines = data_lines.replace('ver_error_date', now)
    data_lines = data_lines.replace('ver_lambda', lambda_name)
    data_lines = data_lines.replace('ver_error', error)
    
    #メール文の整形
    error_sns_body = {}
    error_sns_body["default"] = data_lines + "\n"

    #送信先SNSトピックの指定
    topic = 'arn:aws:sns:ap-northeast-1:'+ account_id + ':' + ope_team_sns_topic
    #メール件名の指定
    subject = '[Lambda Error] ['+ account_id +'][' + lambda_name +']' 

    #SNSへのパブリッシュ
    try:
        response = sns.publish(
            TopicArn = topic,
            Message = json.dumps(error_sns_body, ensure_ascii=False),
            Subject = subject,
            MessageStructure='json'
        )
    except Exception as e:
        print(str(e))
        raise e
```

#テスト
##RunCommandを失敗させる
StateManagerの関連付けが適用されると、SSM Agent・CloudWatch Agentがアップデートされます。
![systemsmanager.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307047/20157ca1-3afc-1fe8-2d54-8cc13559b485.png)

Agentのアップデートが失敗したらメールが配信されるのですが、StateManagerを失敗させるのは面倒です。
AWS-UpdateSSMAgent・AWS-ConfigureAWSPackageの失敗だけでなくAWS-RunShellScriptというSSMドキュメントもEventBridge Ruleの監視対象にして、敢えて失敗するRunCommandを実行してみます。

![runcommand.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307047/330ad03d-872c-8671-a0a2-20484ba56530.png)

###成功時
![lambda正常終了.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307047/4b21d80f-d030-66f6-91ef-5961742a197a.png)


###失敗時
![lambda異常終了.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307047/1b7b1187-ff6e-faf4-65c9-75b698f81192.png)