Amazon Web Services ブログ

Amazon ECS キャパシティープロバイダーを利用した EC2 AMI のローリングアップデート

この記事は、Rolling EC2 AMI updates with capacity providers in Amazon ECS を翻訳したものです。

Amazon Elastic Container Service (Amazon ECS) にコンテナをデプロイするときに、お客様はクラスターのコンピューティング環境をどのレベルで管理するかを選択することができます。まず最初の選択肢として AWS Fargate があります。これはサーバーレスのコンピューティングエンジンで、お客様がサーバーをプロビジョニングしたり管理したりする必要がありません。このアプローチではタスクが実行されるコンピューティング環境を AWS が管理するため、ユーザーエクスペリエンスが簡素化されます。お客様の開発チームはアプリケーションの実行に必要な基盤となるインフラストラクチャに集中するのではなく、アプリケーション開発に集中することができます。少ない管理コストでのアプローチを求めるお客様には Fargate がおすすめです。また、ECS タスクのスケジューリングに使用するコンピューティング環境をより細かく管理したいお客様には、クラスター内のコンピューティング環境に EC2 インスタンスを使用することができます。通常、EC2 を選択するお客様は GPU サポートを必要とするタスクや、Windows または ARM ベースのワークロード、あるいは単にクラスター内のノードを制御したいなどの特定のニーズや理由があります。一般的な経験則として、私は常にお客様に対して、より多くの時間をコアビジネスとなるアプリケーションに費やし、リソースの管理に費やす時間を減らすことができる方法で始めることをおすすめしています。

しかし、もちろんすべてのユースケースは独自の要件を持つユニークなものです。ここでは、ECS クラスターの EC2 インスタンス更新の管理に伴う複雑な作業を軽減する方法を見てみましょう。キャパシティープロバイダーに馴染みのない方は、この機能について詳細な説明をしているこちらの記事をご覧ください。キャパシティープロバイダーはクラスターのコンピューティングレイヤーでタスクをスケジュールするために、よりカスタマイズ可能なインターフェイスを提供します。Fargate、Fargate Spot、EC2 のいずれであっても、キャパシティープロバイダー戦略はより高度な機能を提供します。キャパシティープロバイダーの最も注目すべき機能の 1 つは、EC2 を制御するクラスターの Auto Scaling のビルトインです。クラスター Auto Scaling はキャパシティープロバイダー戦略を使用している限り、サービスやタスクが必要とするキャパシティーに基づいて EC2 インフラストラクチャをスケールイン/アウトします。これにより、従来は EC2 のスケーリングを管理して希望するタスク数に対応していたクラスターの運用者の課題が解決されます。キャパシティープロバイダーを利用したクラスター Auto Scaling の詳細については、ドキュメントをご覧ください。

需要に応じたオートスケーリングは、お客様が EC2 でキャパシティープロバイダーを利用する大きな理由であることは間違いありませんが、あまり知られていないもうひとつの利点は、新しくパッチを当てたインスタンス (AMI) をクラスターにローテーションしたり、古いインスタンスをロールアウトしたりする仕組みとしてキャパシティープロバイダーが利用できることです。サービスやタスクをデプロイする際に、キャパシティープロバイダー戦略を使い分けることができます。新しいインスタンスタイプを持つ新しいキャパシティープロバイダーにタスクを徐々にデプロイするために混合戦略を使用してカナリアデプロイをしたり、最新の EC2 AMI を実行している最新のキャパシティープロバイダーにタスクを一括でデプロイする戦略があります。この記事では、これらを実施する方法についてのデモを行います。AWS Cloud Development Kit (CDK) を使って環境とサービスをコードとして定義し、EC2 で起動するサービスを x86 AMI から Arm ベースの Graviton AMI に移行する方法を紹介します。

アプリケーションのコンテナイメージをビルド & プッシュ

まずは Dockerfile とアプリケーションの作成から始めましょう。このデモでは、uname コマンドで Linux システムのアーキテクチャを返すシンプルな Python ベースの API を書きました。以下にアプリケーションとDockerfileを示します。

アプリケーションコード:

#!/usr/bin/env python3

from flask import Flask
import os

app = Flask(__name__)

@app.route('/')
def index():
    return f"{{ OS Architecture: {os.uname().machine} }}"

if __name__ == '__main__':
    app().run(host='0.0.0.0')

Dockerfile:

FROM public.ecr.aws/bitnami/python:3.7

EXPOSE 5000

WORKDIR /

COPY ./python_app.py /app.py

RUN pip install flask

CMD ["flask", "run", "--host", "0.0.0.0"]

すべての動作確認を行うために Docker イメージをビルドし、ローカルで実行して出力を確認します。

# イメージのビルド
docker build -t osarch:latest .

# Docker イメージをコンテナとして実行!
docker run --rm -d -p 8080:5000 --name osarch osarch:latest

localhost:8080 に対して curl を実行するとアーキテクチャ情報を含む JSON オブジェクトが返ってくるのがわかります。これは x86 アーキテクチャでホストされている Cloud9 インスタンスで実行しているので、レスポンスにその情報が含まれていると期待されます。

アプリケーションはローカルの Docker でいい感じに実行されています。今度はこれを Amazon Elastic Container Registry (Amazon ECR) のパブリックリポジトリにプッシュして全世界にシェアしようと思います それではリポジトリを作成し、ログインし、コンテナイメージをプッシュしてみましょう。

AWS マネジメントコンソールの “Elastic Container Registry” で、Public そして Create repository を選択します。

リポジトリ作成画面では、リポジトリに名前をつけ、その他の項目はデフォルトのままで Create repository を選択します。

リポジトリを作成した後の最後のステップは、コンテナイメージをプッシュすることです。ECR Public にコンテナイメージをビルドしてプッシュする方法をガイドする通知ポップアップが表示されます。それではこれらのコマンドを実行してみましょう。

# Amazon ECR のレジストリに docker login します
aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/f0j5z9b5

#コンテナイメージはビルド済みなので、Public リポジトリのイメージとしてタグ付けします
docker tag osarch:latest public.ecr.aws/f0j5z9b5/osarch:latest

# イメージをプッシュします
docker push public.ecr.aws/f0j5z9b5/osarch:latest

これで環境を構築し、コンテナを ECS サービスとしてデプロイする準備が整いました。

環境構築

先に述べたように、我々は AWS CDK を使って環境を定義し、ECS 上でコンテナを実行するためのサービス定義を行い、Amazon EC2 をタスクをスケジュールするコンピューティング環境として使用します。まずは CDK アプリケーションの初期化から始めます。

cdk init --language python

CDK アプリケーションが初期化されたので、Python を使って環境を定義してみましょう。VPC、EC2 の Auto Scaling グループ、キャパシティープロバイダー、ECS のクラスターとサービスが必要です。ここでは CDK が提供する高レベルのコンストラクトを利用することにより、推奨される優れたアーキテクチャを使用して多くの定型コンポーネントを作成します。AWS CDK やコンストラクトのレベルの仕組みに慣れていない方はドキュメントで詳細を確認してください。

class CPDemo(cdk.Stack):

    def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # Creating a VPC and ECS Cluster
        vpc = ec2.Vpc(self, "VPC")

        cluster = ecs.Cluster(
            self, "ECSCluster",
            vpc=vpc
        )

        # Autoscaling group with x86_64 architecture and associated Capacity Provider
        autoscaling_group = autoscaling.AutoScalingGroup(
            self, "ASG",
            vpc=vpc,
            instance_type=ec2.InstanceType('t3.medium'),
            machine_image=ecs.EcsOptimizedImage.amazon_linux2(
                hardware_type=ecs.AmiHardwareType.STANDARD
            ),
            min_capacity=0,
            max_capacity=100
        )
        
        capacity_provider = ecs.AsgCapacityProvider(
            self, "CapacityProvider",
            auto_scaling_group=autoscaling_group,
        )
        
        cluster.add_asg_capacity_provider(capacity_provider)
               
        # Building out our ECS task definition and service
        task_definition = ecs.Ec2TaskDefinition(self, "TaskDefinition")
        
        task_definition.add_container(
            "DemoApp",
            image=ecs.ContainerImage.from_registry('public.ecr.aws/f0j5z9b5/osarch:latest'),
            cpu=256,
            memory_limit_mib=512
        )
        
        ecs_service = ecs.Ec2Service(
            self, "DemoEC2Service",
            cluster=cluster,
            task_definition=task_definition,
            desired_count=10,
            capacity_provider_strategies=[
                ecs.CapacityProviderStrategy(
                    capacity_provider=capacity_provider.capacity_provider_name,
                    weight=1,
                    base=0
                )
            ]
        )

環境をデプロイするためのリソースはもちろんのこと、私のラップトップ上のコンテナイメージから、Amazon ECS で長時間稼働するサービスへどのように移行しているかを紹介したいと思います。最初にタスク定義を作成します。これは ECS がコンテナの実行に必要なものを定義します。この例ではデフォルト設定を利用していますが、タスク定義はコンテナの実行方法をさらにカスタマイズして調整できる場所です。次にタスク定義にコンテナを追加します。先程 ECR にデプロイしたばかりの Docker イメージと、メモリや CPU などのリソースの仕様が含まれます。これはコンテナを起動するために必要な状態の定義ですが、コンテナ自体の定義であり、コンテナをクラスター上でどのようにスケジューリングするかについては定義していません。ここで ECS サービスの出番です。サービスの定義ではクラスターとタスクの定義、および実行したいタスクの数を定義しています。この値を設定する必要はなく、サービスに Auto Scaling を適用することで、人ではなくアプリケーションが自動的に需要に対応できるようにするのが良い方法です。最後にキャパシティープロバイダー戦略を設定します。これは、スケジューラーがコンピューティングレイヤーでタスクをスケジューリングする方法を定義するものです。キャパシティープロバイダー戦略の詳細については、ドキュメントを参照してください。

ECS タスクを実行するために、さらに多くのアプリケーションコードが使用されています。GitHub リポジトリ をご覧ください。それでは環境とアプリケーションをデプロイしてみましょう。

# python の virtual env の作成とパッケージのインストールpackages
virtual env .venv
source .venv/bin/activate
pip install -r requirements.txt

# 環境とサービスのデプロイ
cdk deploy --require-approval never

デプロイが完了すると、アプリケーションが期待どおりに動作していることを確認するために使用できる CloudFormation の出力がいくつか表示されます。以下のコマンドはアクティブなタスクを取得し、それらのタスクをループして各コンテナに対して curl を実行します。デプロイ後の出力は次のとおりです。

コマンドを実行した後の出力は以下の通りです。

ご覧のとおり Amazon ECS にアプリケーションがデプロイされ、各コンテナが実行されてアーキテクチャを確認することができました!急速に変化する環境の中で、私のチームは Graviton インスタンスを使って Arm アーキテクチャ上で動作させることで、価格とパフォーマンスが大幅に向上することを発見しました。私のチームは Graviton インスタンスを使う場合に、現在のオートスケーリンググループを更新するよりも、キャパシティープロバイダーの柔軟性を活用したいと考えています。それではサービスを x86 から Arm に移行する方法を見てみましょう。

何よりもまず、Arm アーキテクチャをサポートするようにコンテナイメージを再構築する必要があります。そのために x86 マシンで Arm イメージをビルドできる Docker buildx を使用します。ここでは buildx のセットアップ方法については省略し、マルチアーキテクチャイメージのビルドに飛び込んでみたいと思います。

docker buildx build --platform linux/amd64,linux/arm64 -t public.ecr.aws/f0j5z9b5/osarch:latest --push .

コマンドのアウトプット:

デプロイする前に Graviton の EC2 インスタンスを起動して、同じコードが Arm アーキテクチャで動作するかどうかをテストして確認します。

ご覧の通りコンテナは Graviton のテストインスタンス上で動作できているので、Graviton を使用する新しいキャパシティープロバイダーを作成してみましょう。以下のコードを追加して、Graviton インスタンスを使用する新しい Auto Scaling グループを作成します。新しい Auto Scaling グループを利用するキャパシティープロバイダーを作成し、最後にキャパシティープロバイダーをクラスターに関連付けます。

# 新しいインスタンスタイプの Auto Scaling グループに置き換えます。
autoscaling_group_arm = autoscaling.AutoScalingGroup(
    self, "ASGArm",
    vpc=vpc,
    instance_type=ec2.InstanceType('t4g.medium'),
    machine_image=ecs.EcsOptimizedImage.amazon_linux2(
        hardware_type=ecs.AmiHardwareType.ARM
    ),
    min_capacity=0,
    max_capacity=100
)

capacity_provider_arm = ecs.AsgCapacityProvider(
    self, "CapacityProviderArm",
    auto_scaling_group=autoscaling_group_arm,
)

cluster.add_asg_capacity_provider(capacity_provider_arm)

Auto Scaling グループの設定では、AMI に Arm を使用するように設定し、インスタンスタイプに t4g.medium を使用していることに注意してください。次に、サービス定義でキャパシティープロバイダー戦略を更新します。ここはユースケースやアプリケーションの変更にどの程度敏感であるかに応じて、本当にクリエイティブになることができる場所です。ここではより保守的なアプローチをとり、古いAuto Scaling グループと新しい Auto Scaling グループにデプロイを分割することにします。また、古いグループのベース値 (キャパシティープロバイダーで実行するタスクの最小限の数) に 5 を設定し、新しいタスクが期待通りに動作しなかった場合に、古いグループのハードウェアで実行されているタスクのベースセットを確保します。

ecs_service = ecs.Ec2Service(
    self, "DemoEC2Service",
    cluster=cluster,
    task_definition=task_definition,
    desired_count=10,
    capacity_provider_strategies=[
        ecs.CapacityProviderStrategy(
            capacity_provider=capacity_provider.capacity_provider_name,
            weight=0,
            base=5
        ),
        ecs.CapacityProviderStrategy(
            capacity_provider=capacity_provider_arm.capacity_provider_name,
            weight=1
        )
    ]
)

このサービス構成により、次回のデプロイでは以下のことが保証されます。

  • Arm インスタンスにタスクをデプロイする前に、スケジューラは最初の 5 つのタスクを現在のキャパシティープロバイダーの x86 ベースのインスタンスにスケジューリングします。
  • 5 つのベース値が満たされると、スケジューラは残りのタスクを Arm のキャパシティープロバイダーのに設定されているホストにスケジューリングします。

変更した内容を再デプロイし、新しくデプロイしたタスクでコードが実行され、結果を確認することができます。

cdk deploy --require-approval never

デプロイが完了したらコマンドを実行してタスクをキャプチャし、各タスクが出力するアーキテクチャの情報を確認してみましょう。結果は次のようになります。

上の画像からわかるように、デプロイした 10 個のタスクのうち 5 個は x86 ホストで、5 個は Arm ホストで動作しています。この時点で、残りのタスクを新しいキャパシティープロバイダーに移行できると確信しました。まず最初に x86 ノードを実行しているキャパシティープロバイダーをサービス定義から削除します。その上で、cdk deploy を実行してデプロイを再実行してみます。

ecs_service = ecs.Ec2Service(
    self, "DemoEC2Service",
    cluster=cluster,
    task_definition=task_definition,
    desired_count=10,
    capacity_provider_strategies=[
        ecs.CapacityProviderStrategy(
            capacity_provider=capacity_provider_arm.capacity_provider_name,
            weight=1
        )
    ]
)

ここでは、ECS サービスに Arm ベースのキャパシティープロバイダーを使用するように指示し、それをトリガーとしてタスクの再デプロイを開始しています。この時点で、このサービスの ECS タスクを実行しているのは Arm ノードだけになるはずです。ここでは触れていませんが、クラスターは EC2 インスタンスのオートスケーリングを裏で管理しています。新しいキャパシティープロバイダーに多くのタスクがスケジュールされると、ECS はサービスの需要に合わせてインスタンスの数を自動的にスケールアウトします。これとは逆に、x86 ノードを稼働しているキャパシティープロバイダーで何のタスクも実行されていない場合、ECS はこれらの EC2 インスタンスの数をゼロにスケールインします。EC2 インスタンスのスケールインはスケールアウトの動作よりも遅いことに注意してください。一般的に、これは EC2 インスタンスを早期に終了させないための良いプラクティスです。

それでは最後に、タスク ID を取得し、コンテナがどのアーキテクチャで実行されているかを確認します。

お疲れさまでした!この環境のデプロイに使用したコードや詳細については、このリポジトリを参照してください。

まとめ

この記事では ECS キャパシティープロバイダーを使用するメリットを紹介し、ECS サービスとタスクを新しい EC2 インスタンスに移行する方法について説明しました。このデモではタスクを新旧のキャパシティープロバイダーに分割することで、より慎重なアプローチをとりましたが、お客様のユースケースに最も適した柔軟な方法が多数あります。例えば、すべてのタスクを新キャパシティープロバイダーにスケジューリングすることで、新キャパシティープロバイダーに一斉にデプロイし、障害発生時には ECS のサーキットブレーカーでロールバックすることも可能です。

AWS はいつもお客様からのフィードバックをお待ちしております。ロードマップ経由でフィードバックいただくか、twitter (@realadamjkeller) にメッセージをお寄せください。

翻訳はソリューションアーキテクト加治が担当しました。原文はこちらです。