Amazon Web Services ブログ
Amazon ECS deployment circuit breaker のご紹介
※日本語字幕の表示には、設定 → 字幕 → 自動翻訳 → 日本語をご選択ください
EC2 および Fargate コンピュートタイプ用の Amazon ECS deployment circuit breaker をパブリックプレビューで発表しました。この機能により、Amazon ECS をご利用のお客様は、手動での作業を行うことなく、不健全なサービスデプロイを自動的にロールバックできるようになります。これにより、お客様は失敗したデプロイを迅速に発見できるようになり、失敗したタスクのためにリソースが消費されたり、デプロイが無期限に遅延したりすることを心配する必要がなくなります。
以前は、Amazon ECS でデプロイメントタイプにローリングアップデートを使用しているときに、サービスが健全な状態にならない場合、スケジューラは サービススロットリングロジック により永続的にデプロイを再試行していました。このデプロイの失敗を迅速に検知するには、追加でモニタリングの仕組みが必要であり、これは AWS CloudFormation を使用して Amazon ECS サービスをデプロイしているお客様にとっても厄介事の1つでした。
デプロイが失敗する理由はいくつかあり、例えば、コードやサービス構成に破壊的変更を加えた場合、希望のタスク数を立ち上げるために必要なリソースが不足している場合、コンテナやロードバランサのヘルスチェックに失敗した場合などです。ここで紹介したデプロイ失敗のシナリオは一部にしか過ぎず、deployment circuit breaker がどのような場面で役立つかを理解していただくための具体例として取り上げています。このブログの後半では、コンテナのヘルスチェックが失敗した場合のシナリオを例に deployment circuit breaker のデモを行います。
何をデプロイするか?
デプロイするデモアプリケーションは Python Flask Web サーバを実行しており、ECS サービスを介してデプロイされたタスク定義の現在のバージョンを表示します。このサービスの作成、デプロイ、更新には AWS CLI を使用します。まずはコード、Dockerfile、タスク定義を見て、これからデプロイされる内容の理解を深めることから始めてみましょう。
この Flask アプリケーションは、タスクメタデータエンドポイントからタスクのバージョンを取得しており、ウェブサイトにアクセスすることで、ECS サービスが実行しているタスク定義のバージョンが表示されます。このアプリケーションは circuit breaker の機能を紹介し、ロールバック機能をお見せするためのものであり、本来はフロントエンドからバージョン変更を見せるべきではありません。以下の flask コードは flask_app.py の内容です。
#!/usr/bin/env python3
from flask import Flask
from os import getenv
from requests import get
import json
app = Flask(__name__)
def get_service_version():
#https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v4.html
metadata_endpoint = getenv('ECS_CONTAINER_METADATA_URI_V4', None)
if metadata_endpoint is None:
return "Metadata endpoint not available"
else:
response = get("{}/task".format(metadata_endpoint)).text
json_response = json.loads(response)
return "{}:{}".format(json_response['Family'], json_response['Revision'])
@app.route('/')
def hello():
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Circuit Breaker Demo!</title>
</head>
<body>
<h1> My Amazon ECS Application Demo </h1>
<p> Current version of the service {} </p>
</body>
</html>
""".format(get_service_version())
if __name__ == '__main__':
app().run(host='0.0.0.0')
Python のベースイメージを使って、Flaskのインストール、イメージへのコードのコピー、そして、コンテナ起動時のアプリケーションの実行方法を定義しています。
前提条件:
- AWS CLI
- AWS CDK
- コンテナイメージの build や push を行うための Docker
デモ:
まず最初に、必要な vpcやネットワーク、ECR リポジトリ、そして、Fargate サービスが ECR イメージを Pull するためのタスク実行 IAM ロールと共に、ECS クラスタを作成します。環境を定義し、デプロイするために CDK を使用しています。ここでは Python を利用し、コードは app.py というファイル名で保存しています。AWS CDKの詳細については、ドキュメントをご覧ください。
#!/usr/bin/env python3
from aws_cdk import core, aws_ecs, aws_ecr, aws_iam, aws_ec2
class CircuitBreakerDemo(core.Stack):
def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
# The code that defines your stack goes here
ecs_cluster = aws_ecs.Cluster(
self, "DemoCluster",
cluster_name="CB-Demo"
)
# ECR Image Repo
ecr_repo = aws_ecr.Repository(self, "ECRRepo", repository_name="flask-cb-demo")
# IAM Policy
iam_policy = aws_iam.PolicyDocument(
statements = [
aws_iam.PolicyStatement(
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage"
],
resources = [ ecr_repo.repository_arn ]
),
aws_iam.PolicyStatement(
actions = [
"ecr:GetAuthorizationToken"
],
resources = [ "*" ]
),
]
)
# IAM Task Role
task_execution_role = aws_iam.Role(
self, "TaskExecutionRole",
role_name="CircuitBreakerDemoRole",
assumed_by=aws_iam.ServicePrincipal(service="ecs-tasks.amazonaws.com"),
inline_policies = [
iam_policy
]
)
security_group = aws_ec2.SecurityGroup(
self, "WebSecGrp",
vpc=ecs_cluster.vpc
)
security_group.connections.allow_from_any_ipv4(
port_range=aws_ec2.Port(
protocol=aws_ec2.Protocol.TCP,
string_representation="Web Inbound",
from_port=5000,
to_port=5000
),
description="Web ingress"
)
core.CfnOutput(
self, "IAMRoleArn",
value=task_execution_role.role_arn,
export_name="IAMRoleArn"
)
core.CfnOutput(
self, "PublicSubnets",
value=",".join([x.subnet_id for x in ecs_cluster.vpc.public_subnets]),
export_name="PublicSubnets"
)
core.CfnOutput(
self, "SecurityGroupId",
value=security_group.security_group_id,
export_name="SecurityGroupId"
)
core.CfnOutput(
self, "EcrRepoUri",
value=ecr_repo.repository_uri,
export_name="EcrRepoUri"
)
app = core.App()
CircuitBreakerDemo(app, "circuit-breaker-demo")
app.synth()
環境をデプロイするために、次のコマンドを実行します。
cdk deploy --require-approval never --app "python3 app.py"
次に、Docker イメージ を build し、ECR へ push します。そして、タスク定義を作成し、ECS サービスをデプロイします。
export region=$(curl 169.254.169.254/latest/meta-data/placement/region/)
export account_id=$(aws sts get-caller-identity --output text --query Account)
export ECR_REPO=$(aws cloudformation describe-stacks --stack-name circuit-breaker-demo --query 'Stacks[].Outputs[?ExportName == `EcrRepoUri`].OutputValue' --output text)
export ECR_IMAGE="${ECR_REPO}:working"
export EXECUTIONROLEARN=$(aws cloudformation describe-stacks --stack-name circuit-breaker-demo --query 'Stacks[].Outputs[?ExportName == `IAMRoleArn`].OutputValue' --output text)
export SUBNETS=$(aws cloudformation describe-stacks --stack-name circuit-breaker-demo --query 'Stacks[].Outputs[?ExportName == `PublicSubnets`].OutputValue' --output text)
export SECGRP=$(aws cloudformation describe-stacks --stack-name circuit-breaker-demo --query 'Stacks[].Outputs[?ExportName == `SecurityGroupId`].OutputValue' --output text)
# Login to ECR and build/push docker image
aws ecr get-login-password \
--region $region \
| docker login \
--username AWS \
--password-stdin $account_id.dkr.ecr.$region.amazonaws.com
docker build -t ${ECR_IMAGE} . && docker push ${ECR_IMAGE}
最後に、タスク定義を作成し、ECS サービスを作成し、先ほどの Docker イメージをデプロイします。
# Create task definition
echo '{
"containerDefinitions": [
{
"name": "cb-demo",
"image": "$ECR_IMAGE",
"essential": true,
"portMappings": [
{
"containerPort": 5000,
"hostPort": 5000,
"protocol": "tcp"
}
]
}
],
"executionRoleArn": "$EXECUTIONROLEARN",
"family": "circuit-breaker",
"requiresCompatibilities": [
"FARGATE"
],
"networkMode": "awsvpc",
"cpu": "256",
"memory": "512"
}' | envsubst > task_definition.json
# Register task definition
aws ecs register-task-definition --cli-input-json file://task_definition.json
# Create the service
aws ecs create-service \
--service-name circuit-breaker-demo \
--cluster CB-Demo \
--task-definition circuit-breaker \
--desired-count 5 \
--deployment-controller type=ECS \
--deployment-configuration "maximumPercent=200,minimumHealthyPercent=100,deploymentCircuitBreaker={enable=true,rollback=true}" \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SECGRP],assignPublicIp=ENABLED}" \
--launch-type FARGATE \
--platform-version 1.4.0
create-service
コマンドを見てみましょう。 --deployment-configuratio
パラメータで値を指定している箇所で、まさに circuit breaker 機能を有効にしています。
deploymentCircuitBreaker {enable=true,rollback=true}
この設定により、ECS では circuit breaker が有効になり、デプロイ失敗時は、デプロイ前の健全なサービスのバージョンに自動的にロールバックされます。お客様自身でデプロイの失敗を(手動自動問わず)対処したい場合もあるため、この deployment circuit breaker はデフォルトで無効になっています。 Circuit breaker を有効にする際は、enable と rollback パラメータの両方を設定に含める必要があります。
ローリングデプロイメントの設定を次のように指定しています。
maximumPercent=200,minimumHealthyPercent=100
これにより、新しいタスクのデプロイをデプロイしている間、サービスが希望した健全なタスク数を維持するようにスケジューラは動きます。簡単に言えば、希望したタスク数が常に満たされるように、デプロイ時のタスク数を 2 倍にしています。パーセンテージは、アプリケーションの要件によって異なる場合があります。
デプロイが完了したら、Fargate のタスクの 1 つからパブリック IP アドレスを取得してみましょう。
SERVICE_IP=$(aws ecs list-tasks --cluster CB-Demo --query taskArns[0] --output text | xargs -I {} aws ecs describe-tasks --cluster CB-Demo --tasks {} --query 'tasks[].attachments[].details[?name == `networkInterfaceId`].value[]' --output text | xargs -I {} aws ec2 describe-network-interfaces --network-interface-ids {} --query 'NetworkInterfaces[].Association.PublicIp' --output text)
echo "http://$SERVICE_IP:5000"
ブラウザでその IP アドレスを開いた後、期待通りにデモアプリケーションが起動して実行されていることがわかります。UI は、サービスが circuit-breaker タスク定義の最初のリビジョンである circuit-breaker:1 を実行していることを示しています。 それでは、楽しく失敗するデプロイをしてみましょう。
ここで説明するユースケースは、ECS サービスへの破壊的変更のデプロイに関連しています。これは、サービス定義の設定ミスやアプリケーションコードの変更によって引き起こされる可能性があります。ここでは、Dockerfile に破壊的変更を行います。Dockerfile の CMD 命令を修正して、起動時にタスクがエラーコード 2 で終了するようにしてみましょう。
#CMD ["flask", "run", "--host", "0.0.0.0"]
CMD ["exit", "2"]
Dockerイメージを rebuildし、ECR に push し、壊れたイメージを指すようにタスク定義を更新し、最後に ECS で起動しているサービスに最新の変更をデプロイします。
export ECR_IMAGE="${ECR_REPO}:broken"
docker build -t ${ECR_IMAGE} . && docker push ${ECR_IMAGE}
# Create task definition
echo '{
"containerDefinitions": [
{
"name": "cb-demo",
"image": "$ECR_IMAGE",
"essential": true,
"portMappings": [
{
"containerPort": 5000,
"hostPort": 5000,
"protocol": "tcp"
}
]
}
],
"executionRoleArn": "$EXECUTIONROLEARN",
"family": "circuit-breaker",
"requiresCompatibilities": [
"FARGATE"
],
"networkMode": "awsvpc",
"cpu": "256",
"memory": "512"
}' | envsubst > task_definition.json
# Register task definition
aws ecs register-task-definition --cli-input-json file://task_definition.json
# Update the service and trigger a deployment
aws ecs update-service \
--service circuit-breaker-demo \
--cluster CB-Demo \
--task-definition circuit-breaker \
--deployment-configuration "maximumPercent=200,minimumHealthyPercent=100,deploymentCircuitBreaker={enable=true,rollback=true}" \
--desired-count 5
最新のデプロイメントでは、このサービスは circuit-breaker
タスク定義の2つ目のリビジョンを指しています。ここでは、circuit breaker が動作するのを見ながら、表示される出力について説明します。まず、以下のコマンドを実行して、デプロイメントがどのようになっているか見てみましょう。
aws ecs describe-services --services circuit-breaker-demo --cluster CB-Demo --query services[]
このローンチの一環として、デプロイメントの状態変化を示す新しいサービスイベントと、新しいパラメータである rolloutState を導入しました。このパラメータには、3 つのデプロイメントの状態があります。IN_PROGRESS、COMPLETED、FAILED です。上の出力では、デプロイメント配列の 2 番目のデプロイメントを見ると、前回のデプロイメントの rolloutState は COMPLETED で、rolloutStateReason はデプロイが正常に完了したことを示しています。そして、一番目のデプロイメントは、rolloutState パラメータに IN_PROGRESS と表示されているので、進行中のデプロイメントがあることがわかります。これが先ほどトリガーしたデプロイメントです。また、各デプロイメントのタスク定義の違いにも注目してください。これは、デプロイメントのライフサイクルを追跡するために重要です。
スケジューラがタスクを起動しようとするとタスクは失敗し、failedTasks
パラメータの値が増えていきます (下図を参照)。これは壊れたコンテナイメージをデプロイしたために予想される状態です。
より多くのタスクが失敗すると、circuit breaker のロジックが起動し、デプロイが FAILED としてマークされます。ここから、circuit breakerの機能、そして、どのように rolloutState が FAILED になるのかを見ていきましょう。
(Update-service API または Create-service API を介して) サービスデプロイメントがトリガーされると、スケジューラはタスクの起動に失敗した数を追跡し、実行中のタスク数を維持しようとします。。 circuit breaker は 2 つのステージで構成されており、それぞれに成功と失敗の基準があります。まず、これらの基準をどのように定義しているかを説明します。
- 成功:成功し、rolloutState が COMPLETED へ移行する可能性のあるデプロイメントを表しています。
- 失敗:問題の兆候があり、rolloutState が FAILED に達する可能性のあるデプロイメントを表しています。
成功と失敗の定義が理解できたところで、次はステージを見ていきましょう。
- ステージ 1: このステージは、デプロイしたタスクが RUNNING 状態に移行するまで監視を行います。
- 成功::スケジューラは、RUNNING 状態に移行したタスク(1 以上)をチェックします。現在のデプロイメントのタスクのいずれかが RUNNING 状態にある場合、以下の失敗条件のチェックはスキップされ、circuit breaker は次のステージに進みます。
- 失敗:連続して失敗したタスクの起動数をチェックします。これには、RUNNING 状態への移行に失敗したすべてのタスクが含まれます。閾値に達すると、デプロイメントは FAILED とマークされます。閾値の決定方法については後ほど説明します。
- ステージ 2: このステージは、ステージ 1 のチェックで現在のデプロイメント内に 1 つ以上 RUNNING 状態のタスクある場合のみトリガーされます。Circuit breaker は、現在のデプロイメント内のタスクに対応するヘルスチェックを検証します。検証するヘルスチェックは、Elastic Load Balancing のヘルスチェック、AWS Cloud Map サービスのヘルスチェック、コンテナヘルスチェックです。
- 成功: RUNNING 状態のタスクがあれば、すべての依存するヘルスチェックに合格していることを示します。
- 失敗: ヘルスチェックに失敗したために置き換えられたタスクの数をチェックします。このカウントは、circuit breaker が定義した閾値と照合されます。
成功か失敗かを判断するための明確な道筋が circuit breaker にはあることが分かりました。最後にお伝えするのが、circuit breaker がサービスデプロイメントの失敗の閾値をどのように判断するかについてです。計算式は簡単で、min <= Desired Count * 0.5 <= max で、minは 10、maxは 200 となります。簡単に言えば、計算式が最小値よりも低い数値の場合、失敗の閾値は 10 となり、逆に計算式が最大値よりも高い数値の場合、失敗の閾値は 200 に設定されます。これは、機能改善に伴い、変更される可能性があります。以下の表は、希望のタスク数 (Service Desired Count) に基づいて失敗の閾値がどのようになるかの例を示したものです。
ここまでで、何が起こっているのか理解しましたので、デモサービスのデプロイに戻りましょう。
サービスを作成したときに、自動ロールバックを有効にしました。以下の出力では、スケジューラが失敗したデプロイメントをキャッチし、前回デプロイに成功したサービスバージョンへのロールバックデプロイメントをトリガしていることが分かります。ロールバックデプロイメントの rolloutState は IN_PROGRESS で、前回のデプロイメントの rolloutState は FAILED であることが分かります。
ロールバックが完了すると、rolloutState が COMPLETED に移行し、5つのタスクが実行され、失敗したタスクはゼロになります。
これで終わりです。このデモでは、健全なサービスをデプロイした後、わざと失敗するデプロイを行い、スケジューラが自動的に前のバージョンへロールバックを行ったことを確認しました。まだ稼働していることを確認するために、タスクの パブリック IP アドレスを取得して、デプロイされているバージョンを確認してみましょう。サービスが circuit-breaker:1 タスク定義にロールバックされていることが分かります。
この機能はパブリックプレビューとして公開されており、AWS CLI と AWS SDK から有効にすることができます。そして、AWS CloudFormation もまもなくサポートする予定です。常に、私たちはお客様からのフィードバックを大切にしており、この機能のフィードバックも教えてください。問題や質問があれば、GitHub のコンテナのパブリックロードマップに投稿してください。
翻訳はソリューションアーキテクトの原田 和則が担当しました。原文はこちらです。