0
1

More than 1 year has passed since last update.

Configルールに準拠していないと、自動で修正する関数を2つ作成してみた

Posted at

はじめに

Lambdaの勉強もかねて、表題のような自動化を試してみました。
作成した処理は以下となります。

①特定のタグがアタッチされていないと、自動でそのタグをアタッチする処理
➁特定のIAMロールがアタッチされていないと、自動でそのIAMロールをアタッチする処理

手順

本処理のフローとしては
①Configでルール検知
➁SSM AutomationでLambdaに処理を渡す
③Lambdaで処理を実施

となります。
順番に設定していきます。
※ConfigとSSM Automationについては概要レベルの説明になります。

configの設定

まずConfigの設定をしていきます。
今回はセットアップが終わっている前提で話を進めます。

ルールの設定

Configには、AWSマネージドルールが存在し、今回はそちらを使用することになります。
※下はタグ付けのルールを選択
image.png

パラメータの指定

次にパラメータの指定をします。
タグ付けを例にとると、要はどんなタグが付いていないとルール違反と見なすかを定義します。
下の例ではEnvironment:Prodというタグ付けがされていないと、ルール違反になりますね。
image.png
こちらの写真ではどのインスタンスプロファイルがアタッチされていないと、ルール違反と見なすかを定義しています。
image.png

修正アクションの設定

次に修正アクションの設定をします。
違反していた場合、何をするかをここで設定しています。
つまりルール違反をしていた場合は、Lab-automationという機能に、準拠していないリソースIDを渡すということを以下で表しています。
image.png

ではその次の機能、SSM Automation documentについて確認していきましょう。

SSM Automation documentについて

以下のようなコードを作成し、後述で説明するLambdaを呼び出します。

description: |-
  *Use this SSM automation document to remediate ec2 instance that have not been properly tagged.*  

  ---
  # How does it work?
  This SSM automation doc will invoke the lambda function labFunction that will add tags to instances.
  The lambda function will tag any non-compliant EC2 resources with the Environment:Prod key value pair
  ## Pre-requisites
  1. Make sure to replace <account-id> with the actual account id of your provisioned lab account.
 

  You can create a [link to another webpage](https://aws.amazon.com/).
schemaVersion: '0.3'
parameters:
  instanceId:
    type: 'AWS::EC2::Instance::Id'
mainSteps:
  - name: updatetags
    action: 'aws:invokeLambdaFunction'
    inputs:
      InvocationType: Event
      Payload: '"{{instanceId}}"'
      FunctionName: 'arn:aws:lambda:us-east-1:<account-id>:function:labFunction'

コードを読み解く前に、そもそもSSM Automationとは何か?について確認します。
SSM Automation Documentとは、AWS上での自動化タスクを定義するためのドキュメントになります。

スキーマバージョン、パラメータ、ステップで構成され、詳細の説明は以下のようになります。

スキーマバージョン
→ドキュメントフォーマットがどのように変更されたのかを示すもので、現在使用されているものは0.3となります。

パラメータ
→本ドキュメントが実行される際に、外部から渡される値を受け取るために使用されます。

ステップ
→本ドキュメントが実行する一連の処理を指します。

つまり
①スキーマバージョンで、ドキュメントフォーマットを定義
➁外部からインスタンスIDを受け取り、instanceIdというパラメータに格納
③実際の処理を実行

ということになります。
ステップ③について深堀してみます。

mainSteps:
  - name: updatetags
    action: 'aws:invokeLambdaFunction'
    inputs:
      InvocationType: Event
      Payload: '"{{instanceId}}"'
      FunctionName: 'arn:aws:lambda:us-east-1:<account-id>:function:labFunction'

ここでは、actionで'aws:invokeLambdaFunction'というアクションが定義されています。これは、AWS Lambda関数を呼び出すことを意味します。
その後inputで、actionに対して渡す値を指定しています。

つまり、InvocationTpye,Payload,FunctionNameの三つの値が、actionに渡されています。
※FunctionNameについては、渡すというより指定ですが・・・
ステップを整理すると

①実行するactionを定義(lambdaの呼び出し)
➁action に渡す値を指定

ということになりますね。

コードの概要

今回使用するコードはこちらになります。
自分の勉強もかねて、細かく説明していきます。

import json
import sys
import os
import boto3
import base64
from botocore.exceptions import ClientError
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info(event)
    
    client = boto3.client('sts')
    response_account = client.get_caller_identity()['Account']
    
    instance = event
    resourse_ARN = f"arn:aws:ec2:us-east-1:{response_account}:instance/{instance}"
    logger.info(resourse_ARN)
    
    
    tag_client = boto3.client('resourcegroupstaggingapi')
    try:
        response_tag = tag_client.tag_resources(
                 ResourceARNList=[
                     resourse_ARN,
                 ],
                 Tags={
                    'Environment':'Prod'
                 }
                     )
        print(response_tag)
    except Exception as exp:
        logger.exception(exp)
        
    return {
        "compliance_type": "COMPLIANT",
        "annotation": "This resource is compliant with the rule."
    }

import

ここで今回のコードで必要となる複数のライブラリをインポートしています。

import json
#JSON形式のデータを扱うためのライブラリ
import sys
#Pythonのインタプリタや実行環境に関する機能を提供するモジュール、ここでは使用されてないのでは疑惑あり・・・
import os
#PythonプログラムからOSの機能を利用するためのもの
import boto3
# AWSのサービスを操作するためのPythonのSDK
import base64
#  Base64エンコード・デコードを行うためのライブラリ
from botocore.exceptions import ClientError
# try~exceptionを使用するためモジュール
import logging
# ログ出力を行うためのライブラリ

logger

ログの設定をしています。

logger = logging.getLogger()
logger.setLevel(logging.INFO)

logging.getLogger()は、Pythonの標準のloggingライブラリを使用して、ログ出力を取得するために使用されるメソッドです。これによりログ出力を取得することができます。

logger = logging.getLogger()によって、logger変数にログが割り当てられて、その語logger.setLevel(logging.INFO)で、ログ出力レベルがINFOに設定されています。

以下がログのレベルで、今回はINFO以外のログが出力されないようになっています。

-DEBUG: 詳細なデバッグ情報
-INFO: 正常な処理の流れを示す情報
-WARNING: 警告を示す情報
-ERROR: エラーを示す情報
-CRITICAL: 致命的なエラーを示す情報

今後このloggerは、

logger.info(event)
logger.info(resourse_ARN)
logger.exception(exp)

で登場し、それぞれevent,arn,例外についてのログを出力します。

Lambda handler 前半

次は、関数の前半について解説します。
ざっくり説明すると、arn作成のために以下二つを取得しているというコードになります。
・アカウントID
・インスタンスID

def lambda_handler(event, context):
    logger.info(event)
    
    client = boto3.client('sts')
    response_account = client.get_caller_identity()['Account']
    
    instance = event
    resourse_ARN = f"arn:aws:ec2:us-east-1:{response_account}:instance/{instance}"
    logger.info(resourse_ARN)

そもそもlambda_handler(event,context)とは何か?について解説します。
lambda_handlerは、AWS Lambdaによって呼び出される関数で、引数にevent,contextを持ちます。

・event
→AWSのサービスから渡されるか、外部からAPI Gateway経由で渡されるかします。
例えば、S3にファイルがアップロードされた場合は、バケット名やオブジェクトキーなどがオブジェクトとして渡されます。オブジェクト化については、そのサービスのAPIドキュメントに従って行われます。

・context
→今回は使用されていません!
正体は、Lambda関数の実行環境に関する情報を提供するオブジェクトです。これには、AWS Lambdaが関数を実行している環境に関する情報、そしてLambda関数が実行されているときのリソースに関する情報が含まれます。

ここまでで、イベントから情報をもらって、それをログ出力する。というコードになります。
次に、以下について確認します。

client = boto3.client('sts')
response_account = client.get_caller_identity()['Account']

ほんとこれがややこしいのですが、
一時的なセキュリティトークン作成→現在のアカウント情報を取得という流れです。
そもそもboto3.client()について説明すると
AWSのサービスを操作するためのクライアントオブジェクトを生成するための関数です。
引数に操作対象となるサービス名を指定することで、そのサービスに対するクライアントを生成することが出来ます。

import boto3

# S3サービスに対するクライアントを生成
s3 = boto3.client('s3')

# S3のバケット一覧を取得
response = s3.list_buckets()

# 取得したバケット一覧を表示
for bucket in response['Buckets']:
    print(bucket['Name'])

# EC2を起動
ec2 = boto3.client('ec2')
response = ec2.start_instances(InstanceIds=[instance_id])

今回はboto3.client(sts)としていますので、AWS Security Token Service(STS)を使用するためのクライアントを生成し、後にそのクライアントを使用してアカウント情報を取得しています。
このSTSクライアントは様々なAPIを使用することが出来ます。以下は例です。

assume_role:
→指定したロールに対してアクセス権限を委譲します。

assume_role_with_web_identity:
→ウェブアイデンティティ(例えば、OAuthトークン)を使用して、指定したロールに対してアクセス権限を委譲します。

decode_authorization_message:
→AWS STSのシグネチャを使用して署名された権限委譲要求をデコードします。

get_caller_identity:
→現在のアカウントのID、IAMユーザー名、またはロール名を返します。

get_federation_token:
→指定した名前の仮想IAMアカウントを作成し、それに対して指定した権限を割り当てます。

get_session_token:
→指定した持続時間(最大12時間)のセッショントークンを取得します。

ここでようやくresponse_account = client.get_caller_identity()['Account']
につながります。
なお、get_caller_identityメソッドは、辞書形式でレスポンスを返すので、今回はその中からAccountを取り出して、response_accountに代入していることになります。

まとめると

client = boto3.client('sts')
# STSクライアントを作成し、Clinentに代入
response_account = client.get_caller_identity()['Account']
# STSクライアントのget_caller_identity()メソッドを利用し、その中のAccountを指定し、response_accountに代入

ということになります。
では残りのコードについて解説します。

    instance = event
    resourse_ARN = f"arn:aws:ec2:us-east-1:{response_account}:instance/{instance}"
    logger.info(resourse_ARN)

先ほどeventはAWSサービスから渡されるオブジェクトと説明いたしました。今回はEC2のインスタンスIDが渡されます。なので、instance=SSM Automation documentから渡されたEC2のインスタンスID ということになりますね。

次はいよいよ今までのパーツを集めてarnを完成させます。
f"arn:aws:ec2:us-east-1:{response_account}:instance/{instance}"を分解すると、最初にfがいます。これはフォーマット文字列と呼ばれて、{}の中に変数名を書くことで、その変数の値を埋め込むことが出来ます。

つまり、先ほど取得した
・response_account=アカウントID
・instance=インスタンスID

を文字列の中に組み込み、arnを完成させています。
さっきからarnを連呼していますが、こちらが詳細です。

【構造】arn:partition:service:region:account-id:resource

partition: リソースが属するパーティション(aws、aws-cn、aws-us-govなど)
service: リソースが属するサービス(ec2、s3、iamなど)
region: リソースが属するリージョン(us-east-1、ap-northeast-1など)
account-id: リソースが属するアカウントID
resource: リソースの名前

以上で前半パートは終了です。

Lambda handler 後半

ではいよいよ以下のコードを読み解いていきます。
ざっくり説明すると

①タグ付けクライアントオブジェクトを作成
➁タグ付けの詳細を定義

ということをしています。

    tag_client = boto3.client('resourcegroupstaggingapi')
    try:
        response_tag = tag_client.tag_resources(
                 ResourceARNList=[
                     resourse_ARN,
                 ],
                 Tags={
                    'Environment':'Prod'
                 }
                     )
        print(response_tag)
    except Exception as exp:
        logger.exception(exp)

  return {
        "compliance_type": "COMPLIANT",
        "annotation": "This resource is compliant with the rule."
    }

boto3.client()は、AWS APIを使用するためのクライアントオブジェクトを作成する関数なのでしたね。
resourcegroupstaggingapiは、AWSリソースグループのタグ付けAPIを参照しますので、これにより、AWSリソースにタグを付けたり、タグを一覧表示したりすることができます。

実際のメソッドはtag_resourcesなので、この中に所定の引数を記載します。
ちなみに、中身は以下のようになります。

ResourceARNList:
タグを追加したいリソースのARNのリスト。辞書形式で記載
Tags: 
リソースに追加するタグ

最後にtry-exceptブロックについて説明します。
これはPythonの例外処理を行うためのものです。
tryでは例外が発生する可能性のある処理を書き、exceptブロックでは例外処理を記載します。

以下のように、例外処理を受け取ったら、expという変数に格納しています。

except Exception as exp:

では最後に戻り値の説明に移ります。

  return {
        "compliance_type": "COMPLIANT",
        "annotation": "This resource is compliant with the rule."

このコードは、Lambda関数の最終的な戻り値を返しています。
今回のコード作成の経緯が、AWS configによって評価されることです。

これらの値はAWS Configによって評価されることで、AWSアカウント内のリソースの状態を管理するために使用されます。

"compliance_type" キーが"COMPLIANT"
となっていることで、リソースがルールに準拠していることを示します。

"annotation" は、リソースが準拠している場合に追加の説明を記載することができます。
そのため今回は、 "This resource is compliant with the rule."という説明が追加されることになります。

コード(IAMロール編)

次はIAMロールがアタッチされていない場合に、そのIAMロールを自動でアタッチする処理についてみていきます。

import json
import sys
import os
import boto3
import base64
from botocore.exceptions import ClientError
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info(event)
    client = boto3.client('sts')
    response_account = client.get_caller_identity()['Account']
  
    instance = event
    
    resourse_ARN = f"arn:aws:ec2:us-east-1:{response_account}:instance/{instance}"
    logger.info(resourse_ARN)

    ec2_client = boto3.client('ec2')

    response1 = ec2_client.describe_iam_instance_profile_associations(
        Filters=[
            {
                'Name':'instance-id',
                'Values': [instance]
            }
        ]
        
    )
    logger.info(response1)
    
    #logger.info(response1['IamInstanceProfileAssociations'])
    
    for i in response1['IamInstanceProfileAssociations']:
        assoc_instance = i['InstanceId']
        assoc_id = i['AssociationId']
        if assoc_instance == instance:
            response2 = ec2_client.disassociate_iam_instance_profile(
                AssociationId = assoc_id
            )
            logger.info(response2)
        
        try:
            response = ec2_client.associate_iam_instance_profile(
                IamInstanceProfile={
                    'Name': 'LabInstanceProfile'
                },
                InstanceId=instance
            )    
            logger.info(response)
            return {
                "compliance_type": "COMPLIANT",
                "annotation": "This resource is compliant with the rule."
            }    
        except Exception as exp:
            logger.exception(exp)

コードの深堀にあたって、必要ライブラリのインポート→stsクライアント作成からのarn取得までの流れは省略します。

IAMインスタンスプロファイルアソシエーションの取得

ざっくり説明すると、以下のコードでは
①EC2クライアントを作成
➁describe_iam_instance_profile_associations()メソッドで、アタッチされているIAMロールの必要情報を取得
③該当インスタンスのIDでフィルターし、それをresponse1に格納

ということをしています。

 ec2_client = boto3.client('ec2')

    response1 =  ec2_client.describe_iam_instance_profile_associations(
        Filters=[
            {
                'Name':'instance-id',
                'Values': [instance]
            }
        ]
        
    )

まず、 ec2_client = boto3.client('ec2')でEC2クライアントを作成し、ec2_clientに代入します。
そのあとdescribe_iam_instance_profile_associations()メソッドを使用します。
ここで取得できるものが、IamInstanceProfileAssociationsと呼ばれ、アタッチされているIAMロールの詳細が含まれています。
※IamInstanceProfileAssociations以外にも、ResponseMetadataなといった別の要素も取得されます。

以下具体的に取得できる内容です。
※今回はインスタンスIDがi-12345678だった場合を想定しています。

インスタンスID:"i-12345678"
IAMインスタンスプロファイル名:"my-profile"のインスタンス

{
'IamInstanceProfileAssociations': [
{
'AssociationId': 'eipa-0123456789abcdef0',
'InstanceId': 'i-12345678',
'IamInstanceProfile': {
'Arn': 'arn:aws:iam::123456789012:instance-profile/my-profile',
'Id': 'AIPA0123456789ABCDEF0'
},
'State': 'associated'
}
]
}

ここでのAsociationIdは、アソシエーションを一意に識別するものです。

上記の例では、インスタンスIDがi-12345678だった場合ですが、実際の処理ではeventとして渡されるインスタンスIDで絞り込むことになります。

Filter[]は特定のインスタンスを絞り込むために使用されます。インスタンスID以外で絞り込む場合は、以下の属性を使用することができます。

・availability-zone: インスタンスが配置されているリージョン内の特定のゾーン
・image-id: インスタンスに割り当てられているAMIのID
・instance-type: インスタンスのタイプ(例: t2.micro, m5.largeなど)
・network-interface.addresses.private-ip-address: インスタンスのプライベートIPアドレス
・tag:Key=value: インスタンスに割り当てられているタグのキーと値

例えば、以下のように使用することで、インスタンスタイプがt2.microで、availability-zoneが'us-west-2a'のインスタンスを取得することができます。

Filters=[
{
'Name': 'instance-type',
'Values': ['t2.micro']
},
{
'Name': 'availability-zone',
'Values': ['us-west-2a']
}
]

つまり、ここまでの内容を整理すると
①IamInstanceProfileAssociationsを外部から連携されたインスタンスIDでフィルター
➁response1に格納

ということになります。

条件分岐処理

ではいよいよ必要なIamInstanceProfileAssociationsが取得できたので、IAMロールのアタッチの処理を記入していきます。

 for i in response1['IamInstanceProfileAssociations']:
        assoc_instance = i['InstanceId']
        assoc_id = i['AssociationId']
        if assoc_instance == instance:
            response2 = ec2_client.disassociate_iam_instance_profile(
                AssociationId = assoc_id
            )
            logger.info(response2)
        
        try:
            response = ec2_client.associate_iam_instance_profile(
                IamInstanceProfile={
                    'Name': 'LabInstanceProfile'
                },
                InstanceId=instance
            )    
            logger.info(response)

まずは以下を読み解きます。
ざっくり説明すると、
①responseで取得されているIamInstanceProfileAssociationsをリストで取得
➁一つずつiに代入
③iにアクセスし、そのインスタンスIDとアソシエーションIDをそれぞれ、assoc_instance、assoc_idに代入

ということになります。

 for i in response1['IamInstanceProfileAssociations']:
        assoc_instance = i['InstanceId']
        assoc_id = i['AssociationId']

細かく見ていきます。
for i in response1['IamInstanceProfileAssociations'] ですが、こちらはループ構文ですね。
respose1という変数には、IamInstanceProfileAssociationsで取得した値が入っています。

'IamInstanceProfileAssociations': [
{
'AssociationId': 'eipa-0123456789abcdef0',
'InstanceId': 'i-12345678',
'IamInstanceProfile': {
'Arn': 'arn:aws:iam::123456789012:instance-profile/my-profile',
'Id': 'AIPA0123456789ABCDEF0'
},
'State': 'associated'
}
]

これをiに代入して、その上でInstanceIdとAssociationIdをそれぞれ変数に格納しています。
次はif以下について深堀していきます。

 if assoc_instance == instance:
            response2 = ec2_client.disassociate_iam_instance_profile(
                AssociationId = assoc_id
            )
            logger.info(response2)

ifの条件は、前の処理で取得したassoc_instanceがinstance(ここではAWS SNSから渡されるインスタンスID)が一致した場合に、:以下の処理を走らせることになります。

ec2_client.disassociate_iam_instance_profile メソッドは、パラメータで指定したAccociationIdとインスタンスプロファイルを解除するメソッドです。つまり、引き渡されたインスタンスIDにくっついているインスタンスプロファイルの関連付けを削除することになります。
そのあとはログ出力ですね。

次は以下について確認します。

        try:
            response = ec2_client.associate_iam_instance_profile(
                IamInstanceProfile={
                    'Name': 'LabInstanceProfile'
                },
                InstanceId=instance
            )    
            logger.info(response)

ここでようやく目的のインスタンスプロファイルをEC2インスタンスに紐づけていますね。
associate_iam_instance_profile()は、文字通り引数で指定したIamInstanceProfileを、同様に指定されたInstanceIdに紐づける処理をします。

はい、本コードについての解説は以上になります。

終わりに

以上です。
本記事はCloud QuestというAWSのサービスを参考に作成いたしました。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1