朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

AWS LambdaとAmazon EventBridgeを利用してAWSリソース削除/停止を自動で定期実行する

はじめまして、サービス基盤部のmokzkmです。今回はAWSについての内容です。

私が取り組んでいる、「AWSリソース削除/停止の自動定期実行」についてご紹介します。 今回はAWSについてあまり馴染みのない方でもわかるように参考資料も載せているので、そちらも是非ご参照ください。

背景

朝日ネットでは業務システムを刷新するプロジェクトに取り組んでおり、クラウド基盤としてAWSを採用しています。検証用のAWSアカウント(以下「sandbox」といいます)が複数用意されていて、開発者たちはこのアカウントを使ってAWSのサービスを試用・実験・検証しています。 また、これらのsandboxの管理用アカウントが1つ用意されていて、全てのsandboxに共通の設定を一括で入れる時などに利用しています。

課題と対策

AWSのサービスの大半は従量制料金が適用されていて、使用した分だけコストが発生します。朝日ネットではコスト削減のため、原則終業前にはsandbox内のリソースを削除/停止しなければなりません。しかし、(懸念していたことではありましたが)誰かがついうっかりリソースを消し忘れてしまうといったことは何度かありました。終業間際に手動でリソースをチェックするのは骨が折れるし、どうしてもチェック忘れやチェック漏れが起きてしまうようです。

そこで、サーバレスでコードを実行できる「AWS Lambda」と、AWSのイベントバスサービスである「Amazon EventBridge」を利用して、毎日決まった時間にsandbox内のリソースを自動で削除/停止する仕組みを実装しました。ここからはその詳細について説明していきます。

Lambda関数がリソースを削除/停止する仕組み

具体的には、各sandboxアカウント内の各種リソースを削除/停止するLambda関数*1をsandbox管理用アカウントに構築し、 EventBridgeを用いてこのLambda関数を自動で定期実行する仕組みを実装しました。(まだまだ変更や修正をしたい部分があるので、リリースはしていません。)

IAMロールを利用したLambda関数へのクロスアカウントアクセス権限付与

Lambda関数が異なるアカウントのリソースにアクセス(クロスアカウントアクセス)するためには、必要なアクセス許可が付与されたIAMロールを委任する(IAMロールを切り替える)必要があります。*2

アカウントAにあるLambda関数がアカウントBにあるリソースにアクセスしたい場合、以下のようなIAMロールの設定でアクセスできます。

  • アカウントAのLambda関数にAssumeRole(IAMロールを引き受けるためのアクション)の権限を付与したIAMロールAを付与します。
  • 必要なアクセス許可を付与したIAMロールBをアカウントBに作成し、信頼関係にIAMロールAを設定する。(信頼関係とその設定に関してはこちらをご参照ください。)

この両方が設定されて初めて、Lambda関数はアカウントAの認証情報を利用してIAMロールBの一時クレデンシャルを取得し、アカウントBのリソースにアクセスすることができるようになります。言い換えると、Lambda関数にAssumeRole権限を設定したIAMロールAが付与されていなければもちろんのこと、付与されていてもIAMロールBが明示的にIAMロールAを信頼しなければ、Lambda関数はIAMロールBに切り替えることができません。

補足になりますが、AWSはデフォルトでは全てのリクエストは拒否されます。明示的な許可はこのデフォルト設定より優先され、明示的な拒否は全ての許可より優先されます。権限を設定する際は最低限のアクセス許可から開始し、必要に応じて追加のアクセス許可を付与するのがベストプラクティスです。詳細はIAM でのセキュリティのベストプラクティスをご参照ください。

以上のことをふまえて、まず最初にsandbox管理用アカウントのLambda関数が各sandboxのリソースにアクセスできるように、以下のようにIAMロールの設定をしました。

  • sandbox管理用アカウントのLambda関数にAssumeRole権限を付与したIAMロール(lambda-role)を付与します。
  • あらかじめ各sandboxにおいて、リソース削除/停止の権限を付与したIAMロール(delete-resource-role)を作成し、信頼関係にlambda-roleを設定する。

さらに、lambda-roleには信頼関係にLambdaをサービスプリンシパルとして設定し、Lambdaだけがこのロールを使えるようにしました。 これでLambda関数がdelete-resource-roleに切り替わり、各sandbox内のリソースを削除/停止することができるようになります。

リソースを削除/停止する流れ

権限の設定方法に続いて、Lambda関数がリソースを削除/停止する仕組みの流れについて以下の図を使って説明します。

Lambda関数がリソースを削除/停止する仕組み

① EventBridgeによりLambda関数が起動する

EventBridgeにより、Lambda関数が定期的に起動します。1日に複数回、決まった時間に起動するように設定していて、リソースごとに削除/停止する時間を決定できます。

② 各sandboxアカウント内のIAMロールに切り替える

Lambda関数が各sandboxで事前に作成したdelete-resource-roleに切り替わり、そのsandbox内のリソースを削除/停止することができるようになります。実際にIAMロールを切り替える処理はLambda関数のコードの中で実装するので、後ほど紹介します。

③「deletetime」タグがついたリソースを削除/停止する

Lambda関数は「deletetime」タグがついたリソースを抽出して削除/停止します。(タグとは、ユーザー定義可能な、キーと値で構成されるラベルです。タグキーに「deletetime」、タグ値に「削除/停止する時間(18時なら「18」というように)」を設定しておきます。このタグを使用するかどうかは各sandboxユーザー次第です。) ①で説明したように、Lambda関数はEventBridgeにより、1日に複数回、決まった時間に自動で起動します。「deletetime」タグがついたリソースはそれに合わせて、タグ値に設定した時間になるとLambda関数により削除/停止されます。

長々と説明してしまいましたが短くまとめると、sandbox管理用アカウントのLambda関数が自動で決まった時間に起動し、全sandboxの全リージョンのリソースをチェックし、「deletetime」タグがついたリソースがあれば削除/停止する、という流れになります。上の図の中でも紹介していますが、現時点までで以下のAWSリソースの削除/停止処理が実装できました。

コード紹介

続いて、作成したLambda関数のコードの中身について、少しご紹介したいと思います。

実装にはPythonを使いました。今回は「AWS SDK for Python(Boto3)」というAWSの公式ライブラリを利用して、IAMロールの切り替え・タグの抽出・各種サービスの削除/停止といった関数を作成しました。その一部をご紹介します。(実際のコードはログの出力部分などもあり、もっとボリュームがあるのですが、ここでは簡単のため適宜省略します。)

IAMロールの切り替え

Lambda関数がlambda-roleからdelete-resource-roleに切り替えるために使う関数です。

def sts_assume_role(role_arn):
    response = boto3.client("sts").assume_role(
        RoleArn=role_arn, RoleSessionName="delete_resource"
    )
    return Session(
        aws_access_key_id=response["Credentials"]["AccessKeyId"],
        aws_secret_access_key=response["Credentials"]["SecretAccessKey"],
        aws_session_token=response["Credentials"]["SessionToken"],
    )

assume_roleメソッドのRoleArn引数にlambda-roleを信頼したIAMロールを与えることで、そのIAMロールの一時的な認証情報のセットが得られます。この一時的な認証情報は、アクセスキー ID、シークレットアクセスキー、およびセキュリティトークンで構成されます。この構成状態をSessionクラスに格納し、後ほどサービスクライアントとリソースの作成に利用します。

リソース削除/停止:パターン①

次に、メインとなるリソース削除/停止用に作成した関数をご紹介します。

これらの関数には sessionregiondeletetimesの3つの引数を指定する必要があります。 sessionにはdelete-resource-roleのクレデンシャル情報を含むセッションを指定します。これは上記のsts_assume_roleを利用して取得します。regiondeletetimesにはそれぞれリソース削除/停止するリージョン名、時間のリストを指定します。

以下はEC2インスタンスを停止する関数です。regionで指定したリージョンにおいて「deletetime」タグが付き、かつタグ値に設定した時間がdeletetimesに含まれているEC2インスタンスを抽出して停止します。

def stop_ec2(session, region, deletetimes):
    client = session.client('ec2',region_name=region)
    filters = [{"Name": "tag:deletetime", "Values": deletetimes}]
    
    for reservation in client.describe_instances(Filters=filters)['Reservations']:
        for instance in reservation['Instances']:
            ec2_id = instance.get('InstanceId')
            try:
                client.stop_instances(InstanceIds=[ec2_id])
            except ClientError as e:
                print(".... except in: %s" % e)

describe_instancesメソッドのFilters引数にfilters(「deletetime」タグのdict(辞書型オブジェクト))を与えることで、「deletetime」タグが付与されているEC2インスタンスの情報を抽出できます。getメソッドを利用してその情報からEC2のインスタンスIDを取得し、stop_instancesメソッドのInstanceIds引数に与えることで、そのインスタンスIDを持つEC2インスタンスを停止することができます。

リソース削除/停止:パターン②

上記のstop_ec2を見てわかる通り、EC2にはFilters引数でリソースをフィルタリングできるメソッドが用意されているので、そのメソッドを利用して「deletetime」タグがついているリソースを抽出できます。しかし、このFilters引数を設定できるメソッドが用意されていない場合もあります。(実際に私が実装した処理はほとんどFilters引数が使えませんでした・・・)そこで今度は、Filters引数を利用せずにリソースを抽出する方法を考えました。

以下はタグのキーと値のdictを作成する関数です。tags引数に与えたリストに含まれるTags(タグの一覧)をdictに変換し、Tagsが含まれていない場合は空のdictを返します。

def get_tag_dict(tags):
    if 'Tags' in tags:
        return dict([(tag['Key'], tag['Value']) for tag in tags['Tags']])
    else:
        return dict()

上記のget_tag_dictを使って「deletetime」タグがついているリソースを抽出します。以下はALBを削除する関数です。

def delete_alb(session, region, deletetimes):
    client = session.client('elbv2',region_name=region)
    for alb in client.describe_load_balancers()['LoadBalancers']:
        tags = client.describe_tags(ResourceArns=[alb['LoadBalancerArn']])['TagDescriptions']
        for tag in tags:
            if get_tag_dict(tag).get("deletetime", "") in deletetimes:
                LoadBalancerArn=alb['LoadBalancerArn']
                try:
                    client.delete_load_balancer(LoadBalancerArn=LoadBalancerArn)
                except ClientError as e:
                    print(".... except in: %s" % e)

describe_load_balancersメソッドでALBの情報を取得し、その情報に含まれるARNdescribe_tagsメソッドのResourceArns引数に与えることで、そのARNを持つALBに付与されたタグ一覧(tags)を取得できます。tagsの中に「deletetime」タグが存在し、かつタグの値がdeletetimesに含まれているかどうかを、get_tag_dictgetメソッドを利用して判定します。両者が成立する場合、delete_load_balancerメソッドのLoadBalancerArn引数にARN(ResourceArns引数に与えたのと同じ)を与えることで、そのARNを持つALBを削除します。

以上、コードの紹介でした。Boto3の詳細に関しては以下の公式リファレンスをご参照ください。

Lambda関数の実行時間を短縮する方法

Lambda関数の構築が一段落したところで、試しに1つのsandboxアカウントで、これまでにリソース削除/停止機能を実装した全てのAWSリソースを1つずつ起動し、Lambda関数により削除/停止してみました。すると、全部の処理を終えるのに約2分30秒かかりました。仮に他のsandboxも同じ状況だった場合、sandboxが6個以上になると実行時間は15分を超えてしまいそうです。Lambda関数には実行時間に制限があり、15分以上の時間がかかる処理を実行することができません。というわけで少し怖いので、対策を考えました。

EventBridgeで複数のLambda関数を同時起動

最初の方で「EventBridgeによりLambda関数が起動する」と説明しましたが、これはEventBridgeでLambda関数の「ルール」というものを作成して実現しています。このルールごとにLambda関数に渡すパラメータを設定でき、最初はここで全てのsandboxアカウントを処理するように設定していました。しかし、1回の実行で全てのsandoxを処理しようとすると制限時間を超えそうなので、sandboxを複数のグループに分け、それぞれのグループに対してLambda関数を実行するようにしました。例として18時に起動するルールについて説明すると、「18時に全sandboxを処理する」を「18時にsandbox001〜003を処理する」「18時にsandbox004〜006を処理する」…という風にルールを細分化し、同じLambda関数を複数同時に実行して並列処理を実現しました。これで、今後sandboxが増えても対応できそうです。

変更前
変更後

以上、AWSリソースの削除/停止を自動で定期実行する方法についての紹介でした。リリースは先になりそうですが、これで無駄なコストが少しでも削減できると嬉しく思います。

この記事を最後まで読んでくださり、ありがとうございました。

*1:Lambdaでは「関数」という単位でプログラムコードの管理と処理の実行を行います。

*2:S3やSQSなど一部のサービスでは、リソース側でアクセスポリシーを設定することもできます