くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログです

くらマのオンライン決済を支えるデプロイ技術

こんにちは。 バックエンドエンジニア / SRE のまのめです。

くらしのマーケットではマイクロサービスアーキテクチャを一部採用しており、決済サービスも一つのマイクロサービスとして運用しています。
決済サービスは ECS で管理しており、コンテナは Fargate でコンピューティングしています。

f:id:curama-tech:20201110145722p:plain
構成の概略

ECS でのデプロイは、何も設定しないと Rolling update (徐々にリクエストを新しいインスタンスに流す) という戦略になります。
しかしデプロイ中に新旧のインスタンスが同時に存在すると、DB 構造やロジックの違いなどからデータが不整合を起こし、予期せぬ障害を招く恐れがあります。
そのため、新しいインスタンスに対してリクエストを一気に 100% 切り替える必要がありました。

さて、これを解決する方法は単純で、 Blue/Green デプロイができればリクエストを 100% 切り替える戦略が取れますね。
ECS でもこの戦略を取ることができ、インスタンスの切り替え方に様々な戦略を設定することが可能です。
この戦略は CodeDeploy で管理することができ、デフォルトで用意されている設定も数多くあります。
今回は、デフォルトで用意されている戦略の CodeDeployDefault.ECSAllAtOnce を利用して、Blue/Green デプロイを実装していきます。

実装

決済サービスをリリースする前から ECS を利用したサービスがいくつかあり、これらのデプロイは boto3 を利用しています。
なので、既存の仕組みに沿って実装していきます。
実はサービスとして CodeDeploy を使うのは初です。

注意点として、CodeDeploy による Blue/Green デプロイを実装するためには、Target Group を 2 つ用意する必要があります。
これは、古いインスタンスとは別の Target Group を新しいインスタンスに紐づけて healthcheck を行い、問題なければデプロイを実行して切り替える、という流れになるためです。

class CodeDeployManager:
    def __init__(self, cluster: str, service: str):
        session = Session(
            aws_access_key_id="YOUR_AWS_ACCESS_KEY_ID",
            aws_secret_access_key="YOUR_AWS_SECRET_ACCESS_KEY",
            region_name="ap-northeast-1")
        self.client = session.client("codedeploy")
        self.waiter = self.client.get_waiter('deployment_successful')
        self.application_name = "AppEcs-{0}-{1}".format(cluster, service)

        self.application = dict()
        self.application["applicationName"] = self.application_name
        self.application["computePlatform"] = "ECS"

        self.deployment_group = dict()

    def get_or_create_application(self):
        """
        :return response: dict
        """
        get_param = dict()
        get_param["applicationName"] = self.application_name

        try:
            self.application = self.client.get_application(**get_param)
        except (self.client.exceptions.ApplicationDoesNotExistException):
            self.application = self.client.create_application(**self.application)

    def get_or_create_deployment_group(self, **kwargs):
        """
        :param kwargs: dict

        require:
          - deploymentGroupName
          - serviceRoleArn
          - deploymentStyle
          - ecsServices
          - loadBalancerInfo
        """

        param = dict()
        param["applicationName"] = self.application_name
        param["deploymentGroupName"] = kwargs["deploymentGroupName"]

        try:
            self.deployment_group = self.client.get_deployment_group(**param)
        except (self.client.exceptions.DeploymentGroupDoesNotExistException):
            self.deployment_group = param
            self.deployment_group["applicationName"] = self.application_name
            self.deployment_group["deploymentGroupName"] = kwargs["deploymentGroupName"]
            self.deployment_group["serviceRoleArn"] = kwargs["serviceRoleArn"]
            self.deployment_group["deploymentStyle"] = kwargs["deploymentStyle"]
            self.deployment_group["blueGreenDeploymentConfiguration"] = {
                "terminateBlueInstancesOnDeploymentSuccess": {
                    "action": "TERMINATE",
                    "terminationWaitTimeInMinutes": 2
                },
                "deploymentReadyOption": {
                    "actionOnTimeout": "CONTINUE_DEPLOYMENT",
                    "waitTimeInMinutes": 0
                }
            }
            self.deployment_group["ecsServices"] = kwargs["ecsServices"]
            self.deployment_group["loadBalancerInfo"] = kwargs["loadBalancerInfo"]
            self.client.create_deployment_group(**self.deployment_group)

    def create_deployment(self, **kwargs):
        """
        :param kwargs: dict

        require:
        - deploymentGroupName
        - revision
        """
        param = dict()
        param["applicationName"] = self.application_name
        param["deploymentGroupName"] = kwargs["deploymentGroupName"]
        param["deploymentConfigName"] = "CodeDeployDefault.ECSAllAtOnce"
        param["revision"] = kwargs["revision"]

        deployment = self.client.create_deployment(**param)
        self.waiter.wait(deploymentId=deployment["deploymentId"])

今回の場合、要件として以下のように設定しています。

  • Blue/Green デプロイ時に、100% リクエストを切り替える
param["deploymentConfigName"] = "CodeDeployDefault.ECSAllAtOnce"
self.deployment_group["blueGreenDeploymentConfiguration"] = {
    "terminateBlueInstancesOnDeploymentSuccess": {
        "action": "TERMINATE",
        "terminationWaitTimeInMinutes": 2
    },
}
  • 全てのデプロイが完了するまで待機する (Slack 通知などのため)
deployment = self.client.create_deployment(**param)
self.waiter.wait(deploymentId=deployment["deploymentId"])

細かくは端折りますが、あとはこの CodeDeployManager を使って、デプロイの流れを作ります。

class DeployManager():
    def __init__(self):
        self.ecs = EcsManager()          # ECS の操作・設定
        self.params = ParameterManager() # デプロイの詳細設定

    def deploy(self):
        # 実装は書かないが、ここで ALB, Task, Role などの詳細設定を済ませておく

        self.code_deploy = CodeDeployManager(self.ecs.["cluster"], self.ecs["service"])
        self.code_deploy.get_or_create_application()
        self.code_deploy.get_or_create_deployment_group(**self.params.deployment_group)
        
        if service:
            # update service のときは、deployment を作ることで update できる
            self.code_deploy.create_deployment(**self.params.deployment)
        else:
            # create service のときは、deployment と deployment group を作るだけ
            self.ecs.create_service(**self.params.ecs)

デプロイを開始すると、CodeDeploy 上に Deployment が作成され、CodeDeploy による Update Service が始まります。

f:id:curama-tech:20201110145917p:plain
デプロイ中の様子

ECS の画面でも、状態が確認できます。

f:id:curama-tech:20201110145727p:plain
ECS での Deployment の表示

感想

思っていたよりも遥かに簡単に実装できました。
もちろん、より細かい要件を定めている場合は Deployment Group の設定などが複雑になったり、場合によっては Deployment config を自作するということもあるかと思います。
デフォルトで用意されている config だけでも十分な実装が可能ですので、 ECS でトラフィック管理をしっかりやりたい場合は CodeDeploy によるデプロイ戦略を作るのが重要だなと感じました。

今後も決済に関するアップデートは行われていきます。
アップデートをより安全にデプロイしていくために、デプロイ戦略もアップデートできたらいいなと思います!