SSM Session Managerの設定(ロギングなど)をCloudFormationで行う

概要

以下のようなSession Managerの設定をCloudFormationで行います。
また、そもそもどうやってSession Managerの設定をAPIで行うの?といった点も説明します。

結論のCloudFormationテンプレート

以下テンプレートでSession Managerの設定が可能です。
cloudWatchLogGroupNameなどの項目を書き換えて利用します。
CloudFormationスタックを更新することでSession Managerの設定も更新できますが、スタックを削除した時は何も起こりません。(設定が初期値に戻る、とかも無い)

AWSTemplateFormatVersion: "2010-09-09"

Resources:
  SsmDocument:
    Type: Custom::SsmDocument
    Properties:
      ServiceToken: !GetAtt Function.Arn
      DocumentName: SSM-SessionManagerRunShell
      DocumentType: Session
      DocumentFormat: YAML
      DocumentContent: |
        schemaVersion: '1.0'
        inputs:
          cloudWatchEncryptionEnabled: false
          s3EncryptionEnabled: false
          runAsDefaultUser: ''
          s3BucketName: ''
          cloudWatchStreamingEnabled: true
          kmsKeyId: ''
          runAsEnabled: false
          idleSessionTimeout: '15'
          s3KeyPrefix: ''
          shellProfile:
            linux: ''
            windows: ''
          cloudWatchLogGroupName: "/aws/ssm/sessionlog"
        description: Document to hold regional settings for Session Manager
        sessionType: Standard_Stream

  Function:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          from botocore.exceptions import ClientError

          ssm = boto3.client('ssm')

          def lambda_handler(event, context):
              try:
                  document_params = {
                      'Name': event['ResourceProperties']['DocumentName'],
                      'Type': event['ResourceProperties']['DocumentType'],
                      'Format': event['ResourceProperties']['DocumentFormat'],
                      'Content': event['ResourceProperties']['DocumentContent'],
                  }
                  if event['RequestType'] in ['Create', 'Update']:
                      if document_exists(document_params):
                          update_document(document_params)
                      else:
                          create_document(document_params)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              except:
                  cfnresponse.send(event, context, cfnresponse.FAILED, {})

          def document_exists(document_params:dict) -> bool:
              document_identifiers = ssm.list_documents(
                  Filters=[
                      {
                          'Key': 'Name',
                          'Values': [
                              document_params['Name'],
                          ]
                      },
                      {
                          'Key': 'Owner',
                          'Values': [
                              'Self',
                          ]
                      },
                      {
                          'Key': 'DocumentType',
                          'Values': [
                              document_params['Type'],
                          ]
                      },
                  ],
              )['DocumentIdentifiers']
              if len(document_identifiers) == 1:
                  return True
              else:
                  return False

          def create_document(document_params:dict) -> None:
              response = ssm.create_document(
                  Content=document_params['Content'],
                  Name=document_params['Name'],
                  DocumentType=document_params['Type'],
                  DocumentFormat=document_params['Format'],
              )
              print(response)

          def update_document(document_params:dict) -> None:
              try:
                  updated_version = ssm.update_document(
                      Content=document_params['Content'],
                      Name=document_params['Name'],
                      DocumentFormat=document_params['Format'],
                      DocumentVersion='$LATEST',
                  )['DocumentDescription']['DocumentVersion']
              except ClientError as e:
                  if e.response['Error']['Code'] == 'DuplicateDocumentContent':
                      print('no update to the SSM document')
                      return None
              response = ssm.update_document_default_version(
                  Name=document_params['Name'],
                  DocumentVersion=updated_version
              )
              print(response)
      Handler: index.lambda_handler
      MemorySize: 128
      Role: !GetAtt FunctionRole.Arn
      Runtime: python3.7
      Timeout: 120

  FunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
          Action:
          - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: delete-document
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                   - ssm:ListDocuments
                   - ssm:UpdateDocument
                   - ssm:CreateDocument
                   - ssm:UpdateDocumentDefaultVersion
                Resource:
                  - "*"

CloudFormation経由で行う方法を模索

まずCloudFormationで設定を行おうとした場合、ドキュメントを見てもそれらしきリソースが見当たりません。

そこでカスタムリソースを視野に入れてAPI経由で設定を行う方法が無いか探してみると、ドキュメントに以下が記載されていました。

アカウントに Session Manager を設定すると、Session タイプの SSM ドキュメント SSM-SessionManagerRunShell が作成されます。
この SSM ドキュメントには、~するかどうかなど、セッションの設定が保存されます。

つまりSession Managerの設定を行うためにはSSM-SessionManagerRunShellという名前でSSMドキュメントを作成すればよい、ということになります。

CloudFormationでAWS::SSM::Documentリソースを作成してみる

以下のように、AWS::SSM::Documentを利用してSSMドキュメントを作成するのがシンプルで良いと思っていました。
実際、CloudFormationスタックの作成=SSMドキュメントの作成=Session Managerの初回設定まではうまくいきました。

AWSTemplateFormatVersion: "2010-09-09"

Resources:
  SsmDocument:
    Type: AWS::SSM::Document
    Properties:
      Name: SSM-SessionManagerRunShell
      DocumentType: Session
      Content:
        schemaVersion: '1.0'
        description: Document to hold regional settings for Session Manager
        sessionType: Standard_Stream
        inputs:
          s3BucketName: ''
          s3KeyPrefix: ''
          s3EncryptionEnabled: false
          cloudWatchLogGroupName: '/aws/ssm/sessionlog'
          cloudWatchEncryptionEnabled: false
          idleSessionTimeout: '20'
          cloudWatchStreamingEnabled: true
          kmsKeyId: ''
          runAsEnabled: false
          runAsDefaultUser: ''
          shellProfile:
            windows: ''
            linux: ''

ただし、Session Managerの設定(cloudWatchLogGroupNameなど)を書き換えてスタックを更新すると以下エラーが発生します。

CloudFormation cannot update a stack when a custom-named resource requires replacing. Rename SSM-SessionManagerRunShell and update the stack again.

これ自体はよく知られた(?)エラーで、エラーメッセージにある通り SSM-SessionManagerRunShell という名前を変更することで解消されます。
ただし「Session Managerの設定は SSM-SessionManagerRunShell という名前のSSMドキュメントに保存される」という仕様上、今回の場合は名前を変更することは出来ません。
(変更するとSession Managerのデフォルト設定としては利かなくなってしまう)

CloudFormationカスタムリソースでSSMドキュメントを作成してみる

ということで、最終的にこの形に落ち着きました。
カスタムリソース(Lambda)の処理の中で特筆すべき点は、既にドキュメントが存在する場合の update_document を実行する部分です。
update_document 実施後に update_document_default_version を実施することで最新の設定内容をドキュメントのデフォルトバージョンとして登録します。

def update_document(document_params:dict) -> None:
    try:
        updated_version = ssm.update_document(
            Content=document_params['Content'],
            Name=document_params['Name'],
            DocumentFormat=document_params['Format'],
            DocumentVersion='$LATEST',
        )['DocumentDescription']['DocumentVersion']
    except ClientError as e:
        if e.response['Error']['Code'] == 'DuplicateDocumentContent':
            print('no update to the SSM document')
            return None
    response = ssm.update_document_default_version(
        Name=document_params['Name'],
        DocumentVersion=updated_version
    )
    print(response)

ほか、周辺知識

今回設定したのはカスタムセッションドキュメントを利用しない場合のSession Managerの設定です。

Session Manager 設定を作成する (コマンドライン)

この手順を使用して、アカウントレベルの設定を上書きする Session Manager の詳細設定のカスタムセッションドキュメントを作成できます。カスタムセッションドキュメントを作成するときは、name パラメータに SSM-SessionManagerRunShell 以外の値を指定し、必要に応じて入力を変更します。カスタムセッションドキュメントを使用するには、AWS Command Line Interface (AWS CLI) からセッションを開始するときに、–document-name パラメータにカスタムセッションドキュメントの名前を指定する必要があります。コンソールからセッションを開始する場合、カスタムセッションドキュメントを指定することはできません。

つまり、AWSマネジメントコンソールからSession Managerを利用する限りにおいては、今回CloudFormationで組んだSession Managerの設定が必ず活きることになります。