Systems Manager Patch Managerの高速セットアップでデプロイされる、8つのState Managerの関連付けの各機能を解説

2023.09.01

はじめに

Patch Managerの高速セットアップを利用すると、CloudFormationスタックが作成され、Patch Managerの詳細画面から、下記の画像の通り8つのステートマネージャーの関連付けが確認できます(スキャンのみの場合は7つ)。

ステートマネージャー は、インスタンスを定義(=関連付け)した状態に保つ機能です。 関連付けは、主に以下の4つ設定をします。

  • 関連付け名
  • 実行するSSMドキュメント
  • 対象のインスタンス
  • 実行頻度

各関連付けがどのような役割を果たしているのか、ドキュメントにも詳細が記載されていなかったため、本記事にまとめます。

各関連付けは、設定したSSMドキュメントを実行しますので、8つの関連付けで設定された各SSMドキュメントを読み解きながら調査しました。

関連付けで設定したSSMドキュメントは、下記のようにステートマネージャーの関連IDから確認できます。

関連付けの対象インスタンスにするためには、SSMで管理できるマネージドインスタンスにする必要があります。

マネージドインスタンスには、EC2インスタンスと非EC2インスタンス(オンプレミスのサーバーなど)があります。

非EC2インスタンスは、以下が挙げられます。

  • 自社構築サーバー (オンプレミスサーバー)
  • AWS IoT Greengrass コアデバイス
  • AWS IoT および非 AWS エッジデバイス
  • 他のクラウド環境内の VM を含む仮想マシン (VM)

以下の8つの関連付けを1つずつ解説します。

  • AWS-QuickSetup-PatchPolicy-AttachIAMToEc2Instance
  • AWS-QuickSetup-PatchPolicy-AttachIAMToHybridInstance
  • AWS-QuickSetup-PatchPolicy-AddRemoveNameTag
  • AWS-QuickSetup-PatchPolicy-NameTagRemediation
  • AWS-QuickSetup-PatchPolicy-EnableExplorer
  • AWS-QuickSetup-PatchPolicy-BaselineRemediation
  • AWS-QuickSetup-PatchPolicy-ScanForPatches
  • AWS-QuickSetup-PatchPolicy-InstallPatches

AWS-QuickSetup-PatchPolicy-AttachIAMToEc2Instance

  • 関連付け名:AWS-QuickSetup-PatchPolicy-AttachIAMToEc2Instance-ID
    • IDは、高速セットアップIDのことです。
  • SSMドキュメント名:AWSQuickSetup-CreateAndAttachIAMToEc2Node-ID
  • 対象:EC2インスタンス
  • 頻度:30日

この関連付けは、EC2インスタンスにSSMの機能が利用できるように、IAMロールを作成・アタッチする役割があります。

IAMロールがアタッチされていないEC2インスタンスに対しては、AmazonSSMRoleForInstancesQuickSetupというIAMロールをアタッチします。

IAMポリシーは以下の2つです。

  • AmazonSSMManagedInstanceCore
  • aws-quicksetup-patchpolicy-baselineoverrides-s3

すでにEC2インスタンスにIAMロールがアタッチ済みの場合、先程の2つのIAMポリシーがIAMロールにアタッチされます。

また、IAMロールには、以下のタグが追加されます

Key Value
QSConfigld-高速セットアップID 高速セットアップID

ちなみに、高速セットアップを削除しても、タグは削除されません。

AWS-QuickSetup-PatchPolicy-AttachIAMToHybridInstance

  • 関連付け名:AWS-QuickSetup-PatchPolicy-AttachIAMToHybridInstance-ID
  • SSMドキュメント名:AWSQuickSetup-CreateAndAttachIAMToHybridNode-ID
  • 対象:非EC2インスタンス
  • 頻度:30日

この関連付けは、非EC2インスタンスにSSMの機能が利用できるように、IAMロールを作成・アタッチする役割があります。

先程解説しましたAWS-QuickSetup-PatchPolicy-AttachIAMToEc2Instanceとは、対象が非EC2インスタンスという点を除き、機能は同じです。

AWS-QuickSetup-PatchPolicy-AddRemoveNameTag

  • 関連付け名:AWS-QuickSetup-PatchPolicy-AddRemoveNameTag-ID
  • SSMドキュメント名:AWSQuickSetup-AddOrRemoveTag-PatchPolicy-ID
  • 対象:EC2インスタンスと非EC2インスタンス
  • 頻度:30日

この関連付けは、EC2インスタンスや非EC2インスタンスのタグを追加または削除する役割があります。

高速セットアップの対象のマネージドインスタンスにタグを追加し、別の高速セットアップですでにタグ付けされていた場合、タグを削除してから追加します。

インスタンスに追加されるタグは以下です

Key Value
QSConfigName-高速セットアップID 高速セットアップ名

別の高速セットアップを利用していた場合、タグが削除されますので、注意しましょう。

AWS-QuickSetup-PatchPolicy-NameTagRemediation

  • 関連付け名:AWS-QuickSetup-PatchPolicy-NameTagRemediation-ID
  • SSMドキュメント名:AWSQuickSetup-NameTagRemediation-ID
  • 対象:EC2インスタンスや非EC2インスタンス
  • 頻度:1日

この関連付けは、AWSアカウント内のすべてのインスタンスから特定のタグを削除する役割があります。

高速セットアップのパッチポリシーターゲットから外れたインスタンスが、引き続きそのパッチポリシーに対応するタグを持っている場合、そのタグを自動的に削除します。

AWS-QuickSetup-PatchPolicy-EnableExplorer

  • 関連付け名:AWS-QuickSetup-PatchPolicy-EnableExplorer-ID
  • SSMドキュメント名:AWS-EnableExplorer
  • 対象:EC2インスタンスと非EC2インスタンス
  • 頻度:-(初回のみ)

この関連付けは、SSM Explorerを有効にする役割があります。

Explorerは、AWSリソースに関する情報を一元化した運用ダッシュボードを提供します。

異なるAWSアカウントとリージョンにわたるAWSリソースの運用データを統合して、リソースの健全性やパフォーマンスの情報を可視化します。

Explorerの詳細は下記をご参考ください。

Explorerの有効化は、一回限りです。

したがって、一度実行されるとスケジュールされた再実行は不要のため、実行頻度が「-」(スケジュールなし)と表示されます。

AWS-QuickSetup-PatchPolicy-BaselineRemediation

  • 関連付け名:AWS-QuickSetup-PatchPolicy-BaselineRemediation-ID
  • SSMドキュメント名:QuickSetup-Remediation-AutomationDocument-ID
  • 対象:Lambda
  • 頻度:毎時間00分に実行

この関連付けは、高速セットのCloudformationで作成されたLambda関数を起動するための役割があります。

Lambdaは、S3バケットを作成し、特定のパッチベースライン情報を取得して、その情報をバケットに格納し、必要に応じてバケットを削除します。

コンソールからリソースを確認すると、Lambdaは、「baseline-overrides-bbfd-ID」という名前で作成され、コードは下記の通りでした。

baseline-overrides-bbfd-IDのLambdaのコード (クリックすると展開します)

index.py

# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 (the 'License'). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#     http://aws.amazon.com/apache2.0/
# or in the 'license' file accompanying this file. This file is
# distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
#
# This handler performs CRUD operations on an S3 object.
# This handler also adds a Quick Setup specific bucket policy to the bucket
# to enable target nodes to pull down the S3 object during patching operations.


import boto3
import json
import urllib3
import time
import os


SUCCESS = 'SUCCESS'
FAILED = 'FAILED'

# Events sent in by CloudFormation
CREATE = 'Create'
UPDATE = 'Update'
DELETE = 'Delete'

# Event sent in by Automation
REMEDIATE = 'Remediate'

DEFAULT_REGION = 'us-east-1'

region = os.environ['REGION']
s3_client = boto3.client('s3', region_name=region)
ssm_client = boto3.client('ssm', region_name=region)
s3_resource = boto3.resource('s3', region_name=region)
http = urllib3.PoolManager()


def create_bucket(bucket_name):
    bucket_creation_params = {
        'ACL': 'private',
        'Bucket': bucket_name,
        'CreateBucketConfiguration': {
            'LocationConstraint': region
        },
        'ObjectOwnership': 'BucketOwnerEnforced'
    }

    if region == DEFAULT_REGION:
        del bucket_creation_params['CreateBucketConfiguration']
        print('Creating a bucket in', DEFAULT_REGION, '...', '\n')
    else:
        print('Creating a bucket in', region, '...', '\n')

    s3_client.create_bucket(**bucket_creation_params)
    waiter = s3_client.get_waiter('bucket_exists')
    waiter.wait(Bucket=bucket_name)
    print('Successfully created the bucket:', bucket_name, '\n')


def put_bucket_versioning(bucket_name):
    print('Enabling bucket versioning... \n')
    s3_client.put_bucket_versioning(
        Bucket=bucket_name,
        VersioningConfiguration={
            'MFADelete': 'Disabled',
            'Status': 'Enabled'
        }
    )
    print('Bucket versioning enabled \n')


def put_bucket_encryption(bucket_name):
    print('Applying server side encryption to the bucket... \n')
    s3_client.put_bucket_encryption(
        Bucket=bucket_name,
        ServerSideEncryptionConfiguration={
            'Rules': [
                {
                    'ApplyServerSideEncryptionByDefault': {
                        'SSEAlgorithm': 'AES256'
                    }
                }
            ]
        }
    )
    print('Encryption applied to the bucket \n')


def put_public_access_block(bucket_name):
    print('Turning on public access block for the bucket... \n')
    s3_client.put_public_access_block(
        Bucket=bucket_name,
        PublicAccessBlockConfiguration={
            'BlockPublicAcls': True,
            'IgnorePublicAcls': True,
            'BlockPublicPolicy': True,
            'RestrictPublicBuckets': True
        }
    )
    print('Public access block turned on for the bucket \n')


def put_bucket_lifecycle_configuration(bucket_name):
    print('Applying lifecycle configuration to the bucket... \n')
    s3_client.put_bucket_lifecycle_configuration(
        Bucket=bucket_name,
        LifecycleConfiguration={
            'Rules': [
                {
                    'ID': 'DeleteVersionsOlderThan90Days',
                    'Filter': {
                        'Prefix': 'baseline_overrides.json'
                    },
                    'Status': 'Enabled',
                    'NoncurrentVersionExpiration': {
                        'NoncurrentDays': 90
                    }
                }
            ]
        }
    )
    print('Lifecycle configuration applied to the bucket \n')


def put_bucket_policy(bucket_name, resource_properties):
    print('Constructing and applying bucket policy... \n')
    partition = resource_properties['Partition']
    baseline_overrides_json = f'arn:{partition}:s3:::{bucket_name}/baseline_overrides.json'
    qs_configuration_id = resource_properties['QSConfigId']
    target_entities = resource_properties['TargetEntities']
    organizational_units = resource_properties['OrgUnits']
    principal_org_id = resource_properties['PrincipalOrgId']
    account_id = resource_properties['AccountId']

    bucket_policy = {
        'Version': '2012-10-17',
        'Statement': [
            {
                'Sid': 'DenyInsecureTransport',
                'Effect': 'Deny',
                'Principal': '*',
                'Action': 's3:*',
                'Resource': [
                    f'arn:{partition}:s3:::{bucket_name}/*'
                ],
                'Condition': {
                    'Bool': {
                        'aws:SecureTransport': 'false'
                    }
                }
            },
            {
                'Sid': 'DenyAllButPrincipalsWithTag',
                'Effect': 'Deny',
                'Principal': {
                    'AWS': '*'
                },
                'Action': 's3:GetObject',
                'Resource': [
                    baseline_overrides_json
                ],
                'Condition': {
                    'StringNotEquals': {
                        f'aws:PrincipalTag/QSConfigId-{qs_configuration_id}': f'{qs_configuration_id}'
                    }
                }
            }
        ]
    }

    target_statement = {
        'Sid': 'Target',
        'Effect': 'Allow',
        'Action': 's3:GetObject',
        'Resource': baseline_overrides_json
    }

    if target_entities.upper() == 'OU':
        if len(organizational_units) == 0:
            raise ValueError('Was expecting at least one OU')

        principal_org_paths = [
            f'{principal_org_id}/*/{ou}/*' for ou in organizational_units if ou.startswith('ou-')]

        if len(principal_org_paths) == 0:
            raise ValueError('Was expecting at least one OU')

        target_statement['Principal'] = '*'
        target_statement['Condition'] = {
            'ForAnyValue:StringLike': {
                'aws:PrincipalOrgPaths': principal_org_paths
            }
        }
    elif target_entities.upper() == 'ENTIRE_ORG':
        target_statement['Principal'] = '*'
        target_statement['Condition'] = {
            'StringEquals': {
                'aws:PrincipalOrgID': [
                    f'{principal_org_id}'
                ]
            }
        }
    elif target_entities.upper() == 'LOCAL':
        target_statement['Principal'] = {"AWS": account_id}
    else:
        raise ValueError(
            'Got an unexpected value for target entities; was expecting ENTIRE_ORG, LOCAL, or OU')

    bucket_policy['Statement'].append(target_statement)

    s3_client.put_bucket_policy(
        Bucket=bucket_name,
        Policy=json.dumps(bucket_policy)
    )
    print('Bucket policy applied \n')


def put_bucket_logging(bucket_name, access_log_bucket_name):
    print('Enabling logging for the bucket... \n')
    s3_client.put_bucket_logging(
        Bucket=bucket_name,
        BucketLoggingStatus={
            'LoggingEnabled': {
                'TargetBucket': access_log_bucket_name,
                'TargetPrefix': ''
            }
        }
    )
    print('Logging enabled for the bucket \n')


def get_patch_baselines(patch_baseline_ids, request_type) -> dict:
    print('Retrieving patch baselines... \n')
    patch_baselines = []
    non_existent_baseline_ids = []

    if request_type in (CREATE, UPDATE):
        try:
            for baseline_id in patch_baseline_ids:
                baseline = ssm_client.get_patch_baseline(
                    BaselineId=baseline_id
                )
                patch_baselines.append(baseline)

            print('Patch baselines retrieved \n')
            return {
                'PatchBaselines': json.dumps(patch_baselines, default=str),
                'NonExistentBaselineIds': non_existent_baseline_ids
            }
        except ssm_client.exceptions.DoesNotExistException as err:
            print(f'Baseline id {baseline_id} does not exist')
            print(err, '\n')
            raise err

    elif request_type == REMEDIATE:  # Different behavior for Remediate by design
        for baseline_id in patch_baseline_ids:
            try:
                baseline = ssm_client.get_patch_baseline(
                    BaselineId=baseline_id
                )
                patch_baselines.append(baseline)
            except ssm_client.exceptions.DoesNotExistException:
                non_existent_baseline_ids.append(baseline_id)

        print('Patch baselines retrieved \n')
        return {
            'PatchBaselines': json.dumps(patch_baselines, default=str),
            'NonExistentBaselineIds': non_existent_baseline_ids
        }


def place_baselines_into_bucket(bucket_name, baselines):
    print('Loading the baselines... \n')
    s3_client.put_object(
        Body=baselines['PatchBaselines'],
        Bucket=bucket_name,
        Key='baseline_overrides.json',
    )
    print('Baselines loaded \n')

    if baselines['NonExistentBaselineIds']:
        print('The following baseline ids could not be found:',
              baselines['NonExistentBaselineIds'], '\n')
        raise ValueError(
            f'The following baseline ids could not be found: {baselines["NonExistentBaselineIds"]}')


def permanently_delete_all_objects(bucket_name):
    print('Deleting all objects in the bucket permanently... \n')
    bucket = s3_resource.Bucket(bucket_name)
    bucket.object_versions.all().delete()
    time.sleep(2)
    print('Bucket has been emptied \n')


def delete_bucket(bucket_name):
    print('Deleting the bucket... \n')
    s3_client.delete_bucket(
        Bucket=bucket_name
    )
    waiter = s3_client.get_waiter('bucket_not_exists')
    waiter.wait(
        Bucket=bucket_name
    )
    print('Bucket deleted successfully \n')


def empty_and_delete_bucket(bucket_name):
    try:
        s3_client.head_bucket(
            Bucket=bucket_name
        )
        permanently_delete_all_objects(bucket_name)
        delete_bucket(bucket_name)
    except Exception as err:
        # Bucket does not exist or is not owned by the account
        if err.response['Error']['Code'] == '404':
            return
        else:
            raise err


def send(event, context, responseStatus, responseData=None, physicalResourceId=None, noEcho=False, reason=None):
    request_type = event.get('RequestType')
    if not request_type in (CREATE, UPDATE, DELETE):
        return

    print('Preparing response to CloudFormation... \n')

    responseUrl = event['ResponseURL']
    responseBody = {
        'Status': responseStatus,
        'Reason': reason or f'See the details in CloudWatch Log Stream: {context.log_stream_name}',
        'PhysicalResourceId': physicalResourceId or context.log_stream_name,
        'StackId': event['StackId'],
        'RequestId': event['RequestId'],
        'LogicalResourceId': event['LogicalResourceId'],
        'NoEcho': noEcho,
        'Data': responseData
    }

    print('Response body:', responseBody, '\n')
    json_responseBody = json.dumps(responseBody)

    headers = {
        'content-type': '',
        'content-length': str(len(json_responseBody))
    }

    try:
        print('Sending response to CloudFormation via http request... \n')
        response = http.request(
            'PUT', responseUrl, headers=headers, body=json_responseBody, retries=5)
        print('Status code:', response.status, '\n')

    # If this actually happens, the stack could get stuck for an hour
    # waiting for a response from this custom resource.
    # There is a manual way to send a response using curl
    except Exception as err:
        print('Send failed executing http.request:')
        print(err, '\n')
        raise err


def lambda_handler(event, context):
    request_type = event.get('RequestType')

    # In case of Remediate, ResourceProperties only has BucketName and PatchBaselineIds
    resource_properties = event['ResourceProperties']

    bucket_name = resource_properties['BucketName']
    patch_baseline_ids = [baseline.get('value') for baseline in json.loads(resource_properties['PatchBaselines']).values()]
    access_log_bucket_name = resource_properties.get('AccessLogBucketName')

    print('Event:', event, '\n')

    try:
        if request_type == CREATE:
            create_bucket(bucket_name)
            put_bucket_versioning(bucket_name)
            put_bucket_encryption(bucket_name)
            put_public_access_block(bucket_name)
            put_bucket_lifecycle_configuration(bucket_name)
            put_bucket_policy(bucket_name, resource_properties)
            put_bucket_logging(bucket_name, access_log_bucket_name)
            place_baselines_into_bucket(
                bucket_name, get_patch_baselines(patch_baseline_ids, request_type))
            send(event, context, SUCCESS, physicalResourceId=bucket_name)

        elif request_type == UPDATE:
            # We are making an assumption that Update event will never cause creation of another bucket.
            # Bucket name is dynamically constructed using AccountId and QSConfigId
            put_bucket_policy(bucket_name, resource_properties)
            place_baselines_into_bucket(
                bucket_name, get_patch_baselines(patch_baseline_ids, request_type))
            send(event, context, SUCCESS, physicalResourceId=bucket_name)

        elif request_type == DELETE:
            empty_and_delete_bucket(bucket_name)
            send(event, context, SUCCESS, physicalResourceId=bucket_name)

        elif request_type == REMEDIATE:
            print('Starting remediation... \n')
            place_baselines_into_bucket(
                bucket_name, get_patch_baselines(patch_baseline_ids, request_type))
            print('Remediation completed \n')

        else:
            print('Unexpected request type received:', request_type)
            raise ValueError(
                'A valid RequestType is Create, Update, Delete, or Remediate')

        return SUCCESS
    except Exception as err:
        print(err, '\n')
        print('You can review the log for the Lambda function for details \n')
        send(event, context, FAILED, reason=str(err), physicalResourceId=bucket_name)
        raise err  # To send signal to Automation Document of failure

この関連付けによって、QuickSetup-Remediation-AutomationDocument-IDドキュメントが実行すると、Lambdaに「Remediate」のリクエストを送信し、Lambdaが以下の内容を実行します。

  • 修復(Remediate)
    • SSMから最新のパッチベースラインを取得し、既存のバケットに上書きされます。

SSMドキュメントは「Remediate」をリクエストしますが、高速セットアップのCloudformationスタックは「Create」、「Update」、「Delete」のリクエストを送信し、Lambdaが以下の内容を実行します。

  • バケットの作成(Create)
    • 新規S3バケットを作成し、バケットに対して下記の設定が行われます。
      • バージョニングの有効化、サーバーサイドでの暗号化、パブリックアクセスのブロック、ライフサイクルの設定、バケットポリシーの設定、バケットのログ記録
  • パッチベースラインの取得と保存(CreateとUpdate)
    • SSMから指定されたパッチベースラインを取得し、S3バケットに保存します。
  • バケットの削除(Delete)
    • バケット内のすべてのオブジェクトが削除され、その後バケット自体が削除されます。

S3バケットは「aws-quicksetup-patchpolicy-アカウント-ID」という名前で作成されています。

S3バケット内には、パッチベースラインの「baseline_overrides.json」が保存されています

AWS-QuickSetup-PatchPolicy-ScanForPatches

  • 関連付け名:AWS-QuickSetup-PatchPolicy-ScanForPatches-LA-ID
  • SSMドキュメント名:AWS-RunPatchBaseline
  • 対象:EC2インスタンスと非EC2インスタンス
  • 頻度:高速セットアップ時に指定した頻度

この関連付けは、特定のOSに対してスキャンする役割があります。

AWS-RunPatchBaselineには、パラメータとして、Scanを渡すため、スキャンのみを行います。

AWS-QuickSetup-PatchPolicy-ScanForPatchesという関連付けは、OSをスキャンのみを行い、パッチ適用はしません。

AWS-QuickSetup-PatchPolicy-InstallPatches

  • 関連付け名:AWS-QuickSetup-PatchPolicy-InstallPatches-LA-ID
  • SSMドキュメント名: AWS-RunPatchBaseline
  • 対象:EC2インスタンスと非EC2インスタンス
  • 頻度:(日本時間)毎週日曜日の11時00分に実行

この関連付けは、特定のOSに対してスキャンとパッチ適用する役割があります。

AWS-RunPatchBaselineには、パラメータとして、Installを渡すため、パッチ適用を行います。

また、インストール後インスタンスを再起動するかどうか高速セットアップの設定時に選択できます。

最後に

今回は、Patch Managerの高速セットアップ時に作成される8つの関連付けの役割を解説しました。

関連付けのSSMドキュメントによっては、Lambdaを実行していたり、IAMロールやインスタンスにタグがアタッチされていたりすることが分かりました。

参考になれば幸いです。

参考