ブログ

AWS CodeDeploy でロールバック時に削除されないリソースを自動削除するように実装してみた

どうも、ターン・アンド・フロンティアでクラウドエンジニアをしている小寺です。
皆様、アプリケーションデプロイの自動化( CI/CD )に取り組まれていますか??

開発速度が早い昨今、導入が必須レベルになっているように感じます。今回はその中でも Continuous Deploy の代名詞、AWS CodeDeploy について小ネタを書こうと思います。

AWS CodeDeploy の機能とデプロイ可能対象

まずは CodeDeploy で何ができるのかを簡単に紹介します。

まず 1 つ目が「アプリケーションデプロイの自動化」です。今まではアプリケーション更新のための手順書を用意したり、ミスがないように二人一組で作業したりと色々大変でしたが、デプロイの手順を appspec.yaml ファイル(今回は解説しません)に書くことで、自動的にデプロイしてくれます。

2 つ目が「簡単なロールバックの実現」です。CodeDeploy にはデプロイプロセスを一元的に管理できるコンソール画面があり、そこでデプロイの進捗状況や失敗時の簡易的なログなどが参照できるようになっており、ロールバックもボタンをクリックするだけで実行可能です。

3 つ目が「ダウンタイムの最小化」です。CodeDeploy のデプロイ方式には「ローリングアップデート」と「 Blue/Green デプロイ」の 2 種類があります。特に Blue/Green デプロイについて詳しく説明します。Blue/Green デプロイではロードバランサーとオートスケーリンググループが必須となります。まず本番環境のオートスケーリンググループを別にコピーしてアプリケーションを更新します。その後、ロードバランサーのトラフィックを新オートスケーリンググループに向かせます。このデプロイ方法は、一定のコストがかかる反面、、ダウンタイムをほぼゼロに抑えることができます。、そのため、重要なアプリケーションの場合は採用の検討をするかと思います。

CodeDeploy のデプロイ対象としては以下があります。

  • Amazon EC2
  • オンプレミスサーバ
  • AWS Lambda
  • Amazon ECS

最近は ECS や Lambda への自動デプロイする目的で使われる方も徐々に増えてきてますが、EC2 へのデプロイもまだまだ需要があると思います。そのため、今回は EC2 の CodeDeploy 時を取り上げます。

AWS CodeDeploy のデプロイ先が EC2 の場合に生じる難点

弊社でも他の Code 兄弟と共に CodeDeploy を使って EC2 へのアプリケーションデプロイを実装することがあり、その際にちょっと困ることがあります。

まずは下記の構成図を使って、構成とデプロイのプロセスを説明します。

構成としてはよくある Code シリーズを組み合わせたデプロイ自動化の例で、Blue/Green デプロイ方式を採用しています。また、デプロイ時のログは Cloudwatch Logs に出力するようにしています。

次にデプロイプロセスをざっくりと説明します。

① ロードバランサーに紐づいたオートスケーリンググループがコピーされる。

② コピーされた EC2 にアプリケーションがデプロイされる。

この時、デプロイ自体が失敗したり手動でロールバックする場合があります。その際、トラフィックが既にコピーされたオートスケーリングに流れていたなら、トラフィックを切り戻すことでデプロイプロセスは完了します。さて、ここで問題となるのはコピーされたオートスケーリングで、なんと EC2 もろとも残ってしまうのです。

デプロイ時のログはコピーされた EC2 に残っており、自動的に削除してしまうとログごと消えてしまうことになるのでその配慮だと思われますが、CodeDeploy にもステップごとの簡易的なログは残りますし、Cloudwatch Logs に出力している場合は不要かなと思います。

そのため、今回は自動削除を行う仕組みを考えて実装してみました!

要件と実装サービスの整理

要件は、デプロイ失敗時または手動ロールバックを検知し、コピーされた余分なリソース(オートスケーリンググループ)を削除することです。また、何らかの形で削除が失敗したり、思わぬ異常なステータスになったりした場合は同じく検知・連絡がほしいです。

それではまずトリガーからの連携を考えます。当然 CodeDeploy がトリガーになりますが、CodeDeploy は SNS と連携できますのでこれを利用します。以下の画面キャプチャのように、トリガーとなるイベントとして[デプロイの失敗]と[デプロイのロールバック]がありました。ちなみに SNS に引き渡される内容は JSON 形式のメッセージです。

次に SNS から受け取った JSON を元に内容を解析し、アクションを起こすサービスが必要です。考慮点としては処理時間がどれぐらい掛かるかということですが、15 分以内には終わるだろうということで、Lambda を採用します。

あとは削除処理がなんらかの理由で失敗した場合の通知先が欲しいので、これにはまた SNS に働いてもらうことにします。

想定するエラーとしては下記 3 点ぐらいです。

① リターンコードが正常でない( 200 以外)

② 長時間走行している( 15 分以上)

③ CodeDeploy から引き渡される JSON が変わった 

今回の構成

上記を踏まえた構成図が以下になります。特に難しい要素はなく、わりとシンプルになったと思います。次項以降では実装内容を解説しますが、Code シリーズや ALB などのリソースの実装は省きます。あくまでオートスケーリンググループの自動削除の実装のみです。

SNS 実装

まずは Lambda 処理が異常であった場合の SNS を作成します。SNS 作成方法の詳細については割愛しますが、Standard タイプで作成し、サブスクリプションにメールアドレスを登録しました。メールでの承認も行っています。

続いて Lambda 起動用の SNS を作成します。こちらも同じように作成します。サブスクリプションは Lambda にてトリガー設定をすることで作成されます。

SNS の作業は以上です。

AWS CodeDeploy のトリガー実装

Code シリーズは解説しないと言いながら、やはりトリガー部分だけは説明が必要ですので解説します。CodeDeploy ではトリガーの設定がありますので、以下のように設定します。

トリガー名:任意

イベント:デプロイの失敗 デプロイのロールバック

SNS トピック:前段で作成した Lambda キック用の SNS 

CodeDeploy の作業は以上です。

AWS Lambda 実装

Lambda を作成する前に、Lambda 用の IAM ロールを作成しておきます。今回は以下の AWS 管理ポリシーをアタッチしました。

  • AmazonEC2FullAccess
  • CloudWatchLogsFullAccess
  • AmazonSNSFullAccess

IAM ロールが作成できたら、Lambda を作成します。今回は Python3.9 で作成しています。他にはタイムアウトを 15 分に設定し、トリガーを作成しているくらいです。

まずはタイムアウト設定を行います。

次にトリガー作成を行います。

最後にコードですが、こちらは画面キャプチャではなくコードだけ貼り付けておきます。内容がすごく長いので掻い摘んで解説します。

################################################################################
#import library
################################################################################
from __future__ import print_function
import boto3
import json
import time
################################################################################
#import library end
################################################################################

################################################################################
#lambda start
################################################################################
def lambda_handler(event, context):
    # recieve sns data and fetch Status and DeploymentID
    rawdata = event['Records']
    data = json.loads(rawdata[0]['Sns']['Message'])
    accountid = data['accountId']
    region = data['region']
    snsinfoname = 'send-ticket-rollback'
    print("AccountID : " + accountid)
    print("region : " + region)
    print("Message :")
    print(data)

    # Create instance
    client = boto3.client("autoscaling")
    sns = boto3.client('sns')

    ################################################################################
    #common function
    ################################################################################
    def common_func():
        print("Status is " + Status + ". searching for tag: " + DEPID)
        response = client.describe_auto_scaling_groups(
            Filters = [
                {
                    'Name': 'tag:CodeDeployProvisioningDeploymentId',
                    'Values': [DEPID]
                }
            ]
        )

        ASGName = response['AutoScalingGroups'][0]['AutoScalingGroupName']
        print("ASG to be deleted is " + ASGName)
        response = client.delete_auto_scaling_group(
            AutoScalingGroupName=ASGName,
            ForceDelete=True
            )
        print(response)

        if response['ResponseMetadata']['HTTPStatusCode'] == 200:
            print("deletion has successfully started")
            max_wait_time = 780
            start_time = time.time()
            ASGNameList = []
            ASGNameList.append(ASGName)
            while True:
                elapsed_time = time.time() - start_time
                response = client.describe_auto_scaling_groups(
                    AutoScalingGroupNames=ASGNameList
                )
                if not response['AutoScalingGroups']:
                    print(f'Auto Scaling group {ASGName} deleted successfully.')
                    break
                if elapsed_time > max_wait_time:
                    print(f'Error: Timed out waiting for Auto Scaling group {ASGName} to be deleted.')
                    httpresponse = json.dumps(response['ResponseMetadata'])
                    subject = '[CodeDeploy : ASG Deletion Timeout] Deletion of ASG is taking too much time.'
                    body = 'Check the CodeDeploy Deployment of below Account.\n' + 'AccountID : ' + accountid + '\nRegion : ' + region + '\nResponse : \n' + httpresponse + '\nAutoScalingGroupName : ' + ASGName + '\nTriggering Deployment ID : ' + DEPID
                    snsresponse = sns.publish(
                        TopicArn='arn:aws:sns:' + region + ':' + accountid + ':' + snsinfoname,
                        Message=body,
                        Subject=subject
                    )
                    print(snsresponse)
                    break
                time.sleep(10)
        else:
            print("something went wrong.check with administrators about the states.")
            httpresponse = json.dumps(response['ResponseMetadata'])
            subject = '[CodeDeploy : ASG Deletion Error] Deletion API responded with Status Code other than 200.'
            body = 'Check the CodeDeploy Deployment of below Account.\n' + 'AccountID : ' + accountid + '\nRegion : ' + region + '\nResponse : \n' + httpresponse + '\nAutoScalingGroupName : ' + ASGName + '\nTriggering Deployment ID : ' + DEPID
            snsresponse = sns.publish(
                TopicArn='arn:aws:sns:' + region + ':' + accountid + ':' + snsinfoname,
                Message=body,
                Subject=subject
            )
            print(snsresponse)
    ##################################################################################
    #common function end
    ##################################################################################

    ##################################################################################
    #main
    ##################################################################################
    Status = data["status"]
    if Status == "FAILED":
        DEPID = data["deploymentId"]
        common_func()

    elif Status == "SUCCEEDED":
        RolebackInfo = json.loads(data['rollbackInformation'])
        DEPID = RolebackInfo['RollbackTriggeringDeploymentId']
        common_func()

    elif Status == "CREATED" or Status == "IN_PROGRESS":
        print("can ignore these messages, not critical.")

    else:
        print('irregular value, check with administrators')
        subject = '[CodeDeploy : unknown status] could not get value or unknown value.'
        body = 'Unknown CodeDeploy Status : ' + Status + '\nAccountID : ' + accountid + '\nRegion : ' + region + '\nMessage : \n' + json.dumps(data)
        snsresponse = sns.publish(
            TopicArn='arn:aws:sns:' + region + ':' + accountid + ':' + snsinfoname,
            Message=body,
            Subject=subject
        )
        print(snsresponse)
    ##################################################################################
    #main end
    ##################################################################################
################################################################################
#lambda end
################################################################################

まず下記の部分では CodeDeploy から受け取った JSON の中でメッセージ部分だけを“data”に格納し、SNS で通知するために必要な情報を抜き取って変数に格納しています。

def lambda_handler(event, context):
    # recieve sns data and fetch Status and DeploymentID
    rawdata = event['Records']
    data = json.loads(rawdata[0]['Sns']['Message'])
    accountid = data['accountId']
    region = data['region']
    snsinfoname = 'send-ticket-rollback'
    print("AccountID : " + accountid)
    print("region : " + region)
    print("Message :")
    print(data)

実処理を行う関数を定義していますが、ポイントとしてはオートスケーリンググループの探し方です。CodeDeploy がオートスケーリンググループをコピーする際、デプロイメント ID を付加しますので、それをキーにして対象のオートスケーリンググループ名を探しています。

その後はオートスケーリンググループを削除し、その結果を見て正常終了か異常かを判断しています。

    def common_func():
        print("Status is " + Status + ". searching for tag: " + DEPID)
        response = client.describe_auto_scaling_groups(
            Filters = [
                {
                    'Name': 'tag:CodeDeployProvisioningDeploymentId',
                    'Values': [DEPID]
                }
            ]
        )

あとはメイン処理で JSON 内のステータスコードを確認し、結果によって処理をしています。if 条件に“SUCCEEDED”があるのが不思議に思われるかもしれませんが、これはデプロイ失敗によって返却される JSON の内容と、手動でロールバックした際に返却される JSON の内容が違うからなのです。そもそもデプロイが失敗しているので“SUCCEEDED”だから OK ではありません。

また、“CREATED”や“IN_PROGRESS”については、CodeDeploy がロールバックを実行中にも JSON を返却するため、処理途中のものなので無視していいものとなります。

  • ステータスが“FAILED”であれば関数を呼び出してオートスケーリンググループ削除処理
    ⇛ デプロイ失敗時
  • ステータスが“SUCCEEDED”であれば関数を呼び出してオートスケーリンググループ削除処理
    ⇛ 手動でロールバック時
  • ステータスが“CREATED”または“IN_PROGRESS”であれば無視する
  • それ以外であれば未知のエラーとして SNS 通知

 

    Status = data["status"]
    if Status == "FAILED":
        DEPID = data["deploymentId"]
        common_func()

    elif Status == "SUCCEEDED":
        RolebackInfo = json.loads(data['rollbackInformation'])
        DEPID = RolebackInfo['RollbackTriggeringDeploymentId']
        common_func()

    elif Status == "CREATED" or Status == "IN_PROGRESS":
        print("can ignore these messages, not critical.")

    else:
        print('irregular value, check with administrators')
        subject = '[CodeDeploy : unknown status] could not get value or unknown value.'
        body = 'Unknown CodeDeploy Status : ' + Status + '\nAccountID : ' + accountid + '\nRegion : ' + region + '\nMessage : \n' + json.dumps(data)
        snsresponse = sns.publish(
            TopicArn='arn:aws:sns:' + region + ':' + accountid + ':' + snsinfoname,
            Message=body,
            Subject=subject
        )
        print(snsresponse)

AWS CodeDeploy まとめ

いかがだったでしょうか。CodeDeploy 自体は便利なサービスですが、“こう動いたらいいのに”という部分はどうしても出てきます。その際はちょっとプログラミングしてあげると、痒いところに手が届くシステムが組めますし、そのためのサービスのインテグレーション( CodeDeploy ⇛ SNS ⇛ Lambda )が備わっているのが AWS のエコシステムだからこそできる素晴らしい点だと思います。

実装するのに何回もトライアルアンドエラーをして大変でしたが、一度実装すると後が楽になるため、やってよかったなと思っています。

以上になります!

元記事発行日: 2024年03月11日、最終更新日: 2024年03月19日