この記事は、みらい翻訳 Advent Calendar 2022 20日目の記事です。
こんな人向けの話です
- AutoScalingグループにspotインスタンスを組み入れて冗長構成としているが、spotインスタンスが枯渇しているとCloudFormationスタック更新ができないので困っている
はじめに
こんにちは、@mitonoです。みらい翻訳でインフラ周りを担当しています。
私の担当しているシステムではGPU搭載のEC2を使った冗長構成を多数実装しているのですが、GPU搭載のインスタンスはどうしても料金が高いため、spotインスタンスを積極的に利用してAWSコストの低減を狙いつつ、安定的な運用を行なう取り組みを続けています。
spotインスタンスとはAWSが余剰分のEC2インスタンスを格安て提供してくれる変わりに、AWS都合でいつ落とされるか分からない様なサービスなのですが、GPU搭載のインスタンスは世界的に需要が高く、spotインスタンスが数時間起動できない事も多くあります。
冗長構成を最低限の2台で組んでいる様な時は1台になってしまっているので、運用上は当然ながらできるだけ早く2台構成に戻したい、spot無理なら一旦OnDemandでも良いから!という事になります。
spotインスタンスを最低限台数の冗長構成に組み入れて安定的に運用する施策として、これまで以下の様な工夫を行ってきました
- 2台の冗長構成であれば、1台はOnDemandインスタンス、もう1台はspotインスタンスの組み合わせとする(さすがに2台ともspotは商用設備として怖くて運用できない)
- spotインスタンスが起動できない時はこれを検知してOnDemandインスタンスが起動して冗長構成を維持させる
- spotインスタンスが買える様になって起動してきた時には、余剰分のOnDemandインスタンスが停止して元の構成に戻る
詳しくは過去に投稿した記事がありますので、読んで頂けると嬉しいです。
この仕組み、概ね上手く行っていたのですが運用を続けるにあたり課題が見えてきました。
今回はこの課題と対策を記事に纏めたいと思います。
課題
運用を続ける事で見えてきた課題というのが
「AMI更新等のCloudFormation変更セット実行時にspotインスタンスが(激しく)枯渇していると、スタック更新が失敗する」
という物でした
AMIを新しく作成してCloudFormation(以下CFn)の変更セットからスタック更新、という事を運用上よくやるのですが、CFnのテンプレートにはspotインスタンスの台数を含めて定義されているので、スタック更新時にspotインスタンスが枯渇していると、起動に失敗し続けて更新に時間がかかる事があります。
CFnのスタック更新はテンプレートに書かれた状態にAWS設備が整わない場合、デフォルトでは1時間でタイムアウトして切り戻しが走ります。今回の場合はspotインスタンスが枯渇しておりAutoScalingグループ(以下ASG)がテンプレート通りにならないため切り戻しが走る訳ですが、切り戻し先の構成も同様にspotを使ったASG構成なので、最悪切り戻しにも失敗する形になります。
私達が実際に商用作業を行なう場合は、スタック更新中に更新対象のASGをAWSコンソール画面から確認し、以下の様に「There is no Spot capacity available」の様なメッセージが出続けている場合は、画面から「オンデマンドベース容量」を2にする若しくは「インスタンスの分散」をOn-Demand100%にする等変更して一時的にspotを利用しない様な設定にして、とりあえずスタック更新を正常終了させた後、また手動で設定を戻していました。
私達の扱うシステムではこの様な冗長構成が沢山あり、AMIの作成からCFnの変更セット実行までの流れを大幅に自動化したりしていたのですが、自動化を進めるにつれて変更セット実行の並列度も多くなり、更新のかかっているASGを見て回るのは心理的な所も含めて負担が高くなってきていました。
この問題のおかげでせっかく自動化を進めてもなんだか商用作業が楽にならない、自動的に更新をかけても手離れせずAWSコンソールを注意深く確認してまわり、状況によっては手作業を行って失敗を防ぐというしんどいオペレーションが残っていました。
構想
再掲するとこの問題はCFnのテンプレートがspotインスタンスの起動台数を含めて定義されている事に起因しています。
であればCFnのテンプレートとしてはspotインスタンスを使わず全てOnDemandインスタンスで構成する形で定義しておき、AutoScaleの条件として徐々にOnDemand=1台、spot=1台という望む構成になる様にすれば良いのではないか、と考えました。
この形が実現できればスタック更新はspot枯渇の影響を受ける事なくOnDemandインスタンスのみを使った構成で完遂でき、その後spotインスタンスが買えたら勝手にspotインスタンスを利用する構成なってくれそうです。
具体的なアイディアは以下の形です。
- ASGの役割を、spot用とOnDemand用に明確に分ける
- spot用ASGのテンプレート定義台数は0台
- OnDemand用ASGのテンプレート定義台数は2台
- AutoScale条件#1
- spot用ASGの台数が0台、かつOnDemand用ASGの台数が1台以上の時、spot用ASGの台数を1台にする
- AutoScale条件#2
- spot用ASGの台数が1台以上の時、OnDemand用ASGの台数を1台減らす(下限1)
- AutoScale条件#3
- spot用ASGの台数が0台の時、OnDemand用ASGの台数を2台にする
ややこしいですね(笑)、絵にもしておきます。
通常運用時にspotインスタンスが落とされ起動できない期間は、AutoScale条件#3が発動して一時的にOnDemandインスタンスによる冗長構成になってくれます。
又、無事spotインスタンスが起動できた際にはAutoScale条件#2が発動して余剰分のOnDemandインスタンスを落としてくれます。
実装
構想にある様に、spot用のASGとOnDemand用のASGがお互いの台数に応じて自分の台数を操作するAutoScale条件の実装がキモになります。
今回はCloudWatchアラームのMetric Math機能を使いAutoScaleの発火条件としてのアラームを作り、ASG側ではアラームの発火に応じて台数調整する様なAutoScale条件を作る事で構想を実現します。
以下CFnテンプレートの内容となりますが、記事掲載のために必要な所だけ抽出して一部手で書き換えた物なので、このまま流せる物ではありません。。。御容赦ください。
ASG定義部分
先ずはspot用のASG定義部分です
ASGspot:
Type: 'AWS::AutoScaling::AutoScalingGroup'
UpdatePolicy:
AutoScalingRollingUpdate:
MaxBatchSize: 1
MinInstancesInService: 0
MinSuccessfulInstancesPercent: 100
PauseTime: PT1M #インスタンス起動完了後の待機時間
SuspendProcesses:
- HealthCheck
- ReplaceUnhealthy
- AZRebalance
- AlarmNotification
- ScheduledActions
WaitOnResourceSignals: false
Properties:
AutoScalingGroupName: "ASG-spot"
AvailabilityZones:
- ap-northeast-1a
- ap-northeast-1c
MinSize: 0
MaxSize: 2
DesiredCapacity: 0 # spotの希望台数を0台で定義する
MixedInstancesPolicy:
InstancesDistribution:
OnDemandAllocationStrategy: prioritized
OnDemandBaseCapacity: 0
OnDemandPercentageAboveBaseCapacity: 0 #OnDemandは起動させない
SpotInstancePools: 1
LaunchTemplate:
LaunchTemplateSpecification:
LaunchTemplateId: !Ref 'LaunchTemplate'
Version: !GetAtt 'LaunchTemplate.LatestVersionNumber'
Overrides:
- InstanceType: "g4dn.2xlarge"
- InstanceType: "g3s.xlarge"
- InstanceType: "g4dn.4xlarge"
- InstanceType: "g3.4xlarge"
- InstanceType: "g4dn.8xlarge"
- InstanceType: "g3.8xlarge"
- InstanceType: "p3.2xlarge"
HealthCheckGracePeriod: '600'
HealthCheckType: ELB
TargetGroupARNs:
- !Ref TargetGroup
Cooldown: '300'
VPCZoneIdentifier:
- !Ref 'SubnetA'
- !Ref 'SubnetC'
MetricsCollection:
- Granularity: 1Minute
DependsOn: ASGOnDemand
次にOnDemand用ASGの定義部分です。spot用の定義との差分は後述します。
ASGOnDemand:
Type: 'AWS::AutoScaling::AutoScalingGroup'
UpdatePolicy:
AutoScalingRollingUpdate:
MaxBatchSize: 1
MinInstancesInService: 1
MinSuccessfulInstancesPercent: 100
PauseTime: PT1M #インスタンス起動完了後の待機時間
SuspendProcesses:
- HealthCheck
- ReplaceUnhealthy
- AZRebalance
- AlarmNotification
- ScheduledActions
WaitOnResourceSignals: false
Properties:
AutoScalingGroupName: "ASG-OnDemand"
AvailabilityZones:
- ap-northeast-1a
- ap-northeast-1c
MinSize: 1
MaxSize: 2
DesiredCapacity: 2 # OnDemandの希望台数は2台
MixedInstancesPolicy:
InstancesDistribution:
OnDemandAllocationStrategy: prioritized
OnDemandBaseCapacity: 0
OnDemandPercentageAboveBaseCapacity: 100 #OnDemand 100%
SpotInstancePools: 1
LaunchTemplate:
LaunchTemplateSpecification:
LaunchTemplateId: !Ref 'LaunchTemplate'
Version: !GetAtt 'LaunchTemplate.LatestVersionNumber'
Overrides:
- InstanceType: "g4dn.2xlarge"
- InstanceType: "g3s.xlarge"
- InstanceType: "g4dn.4xlarge"
- InstanceType: "g3.4xlarge"
- InstanceType: "g4dn.8xlarge"
- InstanceType: "g3.8xlarge"
- InstanceType: "p3.2xlarge"
HealthCheckGracePeriod: '600'
HealthCheckType: ELB
TargetGroupARNs:
- !Ref TargetGroup
Cooldown: '300'
VPCZoneIdentifier:
- !Ref 'SubnetA'
- !Ref 'SubnetC'
MetricsCollection:
- Granularity: 1Minute
両方のASG定義部分のdiffを取ってみました。
% diff -u ASG-spot.txt ASG-OnDemand.txt
--- ASG-spot.txt 2022-12-17 06:00:51.000000000 +0900
+++ ASG-OnDemand.txt 2022-12-17 06:00:34.000000000 +0900
@@ -1,9 +1,9 @@
- ASGspot:
+ ASGOnDemand:
Type: 'AWS::AutoScaling::AutoScalingGroup'
UpdatePolicy:
AutoScalingRollingUpdate:
MaxBatchSize: 1
- MinInstancesInService: 0
+ MinInstancesInService: 1
MinSuccessfulInstancesPercent: 100
PauseTime: PT1M #インスタンス起動完了後の待機時間
SuspendProcesses:
@@ -14,18 +14,18 @@
- ScheduledActions
WaitOnResourceSignals: false
Properties:
- AutoScalingGroupName: "ASG-spot"
+ AutoScalingGroupName: "ASG-OnDemand"
AvailabilityZones:
- ap-northeast-1a
- ap-northeast-1c
- MinSize: 0
+ MinSize: 1
MaxSize: 2
- DesiredCapacity: 0 # spotの希望台数を0台で定義する
+ DesiredCapacity: 2 # OnDemandの希望台数は2台
MixedInstancesPolicy:
InstancesDistribution:
OnDemandAllocationStrategy: prioritized
OnDemandBaseCapacity: 0
- OnDemandPercentageAboveBaseCapacity: 0 #OnDemandは起動させない
+ OnDemandPercentageAboveBaseCapacity: 100 #OnDemand 100%
SpotInstancePools: 1
LaunchTemplate:
LaunchTemplateSpecification:
@@ -49,4 +49,3 @@
- !Ref 'SubnetC'
MetricsCollection:
- Granularity: 1Minute
- DependsOn: ASGOnDemand
差分の内容は以下の通りです(名前部分は省きます)
- spot用はUpdatePolicyのMinInstancesInServiceが0である
- (spot用は希望台数0台にしちゃうので、そもそもUpdatePolicyが不要かもしれません)
- MinSize: spot用は0台、OnDemand用は1台とします
- DesiredCapacity: spot用は0台、OnDemand用は2台です。
- 今回の施策の目的である「CFnの変更セット適用時にspot枯渇の影響を受けない」を実現している部分です
- OnDemandPercentageAboveBaseCapacity: spot用は0%、OnDemand用は100%。
- spot用ASG/OnDemand用ASGの性格付けをしている部分です
- DependsOn: ASGOnDemand
- spot用ASGのみに存在します。変更セット実行時にOnDemand用ASGの更新が完了してから、spot用ASGの更新(0台に落とす)を行わせるために定義しています
- 厳密には不要な定義ですが、spotインスタンスが起動している状態であれば、せっかくなのでOnDemand用ASGの更新が終わるまで残しておいて冗長構成の一助にしよう、という狙いです
AutoScale条件#1
「spot用ASGの台数が0台、かつOnDemand用ASGの台数が1台以上の時、spot用ASGの台数を1台にする」の部分の定義です。
先ずはOnDemand用ASGの台数が1台以上で、かつspot用ASGが0台の時、に発火するアラームの定義
- m1はOnDemand用ASGのGroupDesiredCapacity
- m2はspot用ASGの台数GroupDesiredCapacity
- m1 >=1 AND m2 ==0 を条件として発火
という感じです
ASGSpotCapacityAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
EvaluationPeriods: '3'
Threshold: '0'
AlarmName: "ASGspotCapacity"
AlarmDescription: '[INFO] Ondemand1台以上かつspot0台のときspot1台起動向け'
AlarmActions:
- !Ref ASGspotStepScalingPolicy
ComparisonOperator: GreaterThanOrEqualToThreshold
Metrics:
- Expression: m1 >=1 AND m2 ==0
Id: e2
ReturnData: true
- Id: m1
MetricStat:
Metric:
MetricName: GroupDesiredCapacity
Dimensions:
- Name: AutoScalingGroupName
Value: !Ref ASGOnDemand
Namespace: AWS/AutoScaling
Period: 60
Stat: Average
ReturnData: false
- Id: m2
MetricStat:
Metric:
MetricName: GroupDesiredCapacity
Dimensions:
- Name: AutoScalingGroupName
Value: !Ref ASGspot
Namespace: AWS/AutoScaling
Period: 60
Stat: Average
ReturnData: false
次に上記アラームが発火した時に動くスケール動作の定義です。
spot用ASGの希望台数を1に変更します。
ASGspotStepScalingPolicy:
Type: AWS::AutoScaling::ScalingPolicy
Properties:
AdjustmentType: ExactCapacity
PolicyType: "StepScaling"
AutoScalingGroupName: !Ref ASGspot
MetricAggregationType: "Maximum"
StepAdjustments:
-
MetricIntervalLowerBound: "1"
ScalingAdjustment: "1"
AutoScale条件#2
「spot用ASGの台数が1台以上の時、OnDemand用ASGの台数を1台減らす(下限1)」の部分の定義です。
先ずはspot用ASGの台数が1台以上の時、に発火するアラームの定義。特にMetric Math機能を使わない普通のアラームです。
ただspotインスタンスがきちんと起動して動作している状態を取る必要があるので、spot用ASGの台数のメトリックにはGroupInServiceInstancesを利用します。
ASGOndemandCapacityAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
EvaluationPeriods: '3'
Statistic: 'Maximum'
Threshold: '1'
AlarmName: "ASGOnDemandCapacity"
AlarmDescription: '[INFO] spotが1台起動した場合のOndemand1台削除向け'
Period: '60'
AlarmActions:
- !Ref ASGOnDemandStepScalingPolicy
Namespace: AWS/AutoScaling
Dimensions:
- Name: AutoScalingGroupName
Value: !Ref ASGspot
ComparisonOperator: GreaterThanOrEqualToThreshold
MetricName: GroupInServiceInstances
次に上記アラームが発火した時に動くスケール動作の定義です。
OnDemand用ASGの希望台数を1台減らします。
(1台減らすのではなく、絶対値として1台に変更しても良いのですが、我々の環境だと2台の冗長構成ではなく台数増やして運用していて徐々にOnDemandインスタンスを1台に近づけたい設備もあり、1台減らす形の定義で統一しています。)
ASGOnDemandStepScalingPolicy:
Type: AWS::AutoScaling::ScalingPolicy
Properties:
AdjustmentType: ChangeInCapacity
PolicyType: "StepScaling"
AutoScalingGroupName: !Ref ASGOnDemand
MetricAggregationType: "Maximum"
StepAdjustments:
-
MetricIntervalLowerBound: "0"
ScalingAdjustment: "-1"
AutoScale条件#3
「spot用ASGの台数が0台の時、OnDemand用ASGの台数を2台にする」の部分の定義です。
先ずはspot用ASGの台数が0台の時に発火するアラームです。この条件だけ考えると特にMetric Math機能を使わない普通のアラームで良いのですが、私達の環境では追加の考慮が必要でした。
私達のサービスでは商用環境とは別に、障害の再現確認や導入資材の事前確認を行なうための検証環境があります。この検証環境は普段ASGの希望台数を0にしてインスタンスを落としておく事でコストダウンを図っているのですが、検証環境でこのアラーム条件が発火してしまうとOnDemandのインスタンスが勝手に起動してきてしまいます。
これはこれで困るのですが、かといって検証環境と商用環境のCFnテンプレートに余計な差分は設けたくない、、、、という事で以下のようなスケール条件の実装にしています。
「spot用ASGの台数が0台で、かつspot用ASGの希望台数が0台より大きい時、OnDemand用ASGの台数を2台にする」
改めてspot用ASGの台数が0台で、かつspot用ASGの希望台数が0台より大きい時、に発火するアラームの定義です。
- m1がspot用ASGのGroupDesiredCapacity
- m2はspot用ASGのGroupInServiceInstances
- m1 >1 AND m2 ==0 を条件として発火
という感じです
ASGOndemandCapacityAlarm2:
Type: AWS::CloudWatch::Alarm
Properties:
EvaluationPeriods: '3'
Threshold: '0'
AlarmName: "ASGOnDemandCapacity2"
AlarmDescription: '[INFO] spotインスタンスが起動していない場合のondemand1台追加起動向け'
AlarmActions:
- !Ref ASGOnDemandStepScalingPolicy2
ComparisonOperator: GreaterThanOrEqualToThreshold
Metrics:
- Expression: m1 >0 AND m2 ==0
Id: e2
ReturnData: true
- Id: m1
MetricStat:
Metric:
MetricName: GroupDesiredCapacity
Dimensions:
- Name: AutoScalingGroupName
Value: !Ref ASGspot
Namespace: AWS/AutoScaling
Period: 60
Stat: Average
ReturnData: false
- Id: m2
MetricStat:
Metric:
MetricName: GroupInServiceInstances
Dimensions:
- Name: AutoScalingGroupName
Value: !Ref ASGspot
Namespace: AWS/AutoScaling
Period: 60
Stat: Average
ReturnData: false
次に上記アラームが発火した時に動くスケール動作の定義です。
OnDemand用ASGの希望台数を2台に設定します。
ASGOnDemandStepScalingPolicy2:
Type: AWS::AutoScaling::ScalingPolicy
Properties:
AdjustmentType: ExactCapacity
PolicyType: "StepScaling"
AutoScalingGroupName: !Ref ASGOnDemand
MetricAggregationType: "Maximum"
StepAdjustments:
-
MetricIntervalLowerBound: "1"
ScalingAdjustment: "2"
実装した効果
実際の動作を見てみます。
先ず最初にAutoScale条件#3。通常運用時、spotインスタンスが枯渇してしまって長い間起動できなかった時、OnDemandインスタンスが起動してきて冗長構成を保ってくれる動作です。
黄緑 … spot用ASGの希望台数(Group Desired Capacity) これは常に1です
緑 … spot用ASGの稼働台数(Group InService Instances)
ピンク … OnDemand用ASGの希望台数(Group Desired Capacity)
赤 … OnDemand用ASGの稼働台数(Group InService Instances)
普段はspot用/OnDemand用共に希望台数/稼働台数は双方1で、全体として2台の冗長構成で動作しています。
spotインスタンスが落ちてspot用ASGの稼働台数(緑)が0になった時、OnDemand用ASGの希望台数(ピンク)や稼働台数(赤)が1から2に変わっていますので、足りない台数をOnDemandで補填して冗長構成を維持してくれている事がわかります。
(結構重いアプリケーションの載ったEC2なのでGroup Desired Capacityが2になってからGroup InService Instancesが2に追いつくまで5分程かかっています。)
次にAutoScale条件#1、AutoScale条件#2の動作を確認します。
下記はとあるEC2冗長構成に対してCFnからAMI更新を行った時のspot用ASG、OnDemand用ASGの台数遷移です。
凡例については先のグラフと同じです。
CFnスタック更新前はspot用ASGもOnDemand用ASGも希望/稼働台数は1台。
CFnスタック更新が始まると、OnDemand用ASGの希望台数(ピンク)がCFn定義により2に上がっています。
少し時間をおいてOnDemand用ASGの稼働台数(赤)も2に上がっていますが、この間グラフに変化はありませんがrolling updateにより新規OnDemandインスタンス追加され、このインスタンスがInService状態になった直後に既存のOnDemandインスタンスの入れ替え(既存停止&新規起動)が走っています。
OnDemand用ASGの稼働台数(赤)が2に上がった所でOnDemand用ASGの更新は完了し、spot用ASGの更新に入ります。
spot用ASGの更新は希望台数0台でテンプレート定義していますので、希望台数(黄緑)と稼働台数(緑)が0に落ちた所でCFnスタック更新は完了となりました。
CFnスタック更新が終わって通常運用に入るとすぐ、AutoScale条件#1が発動しspot用ASGの希望台数(黄緑)が0から1に変わっています。5分程後に起動したインスタンスがInService状態になりspot用ASGの稼働台数(緑)も1に追いついています。
その後余剰分のOnDemandインスタンスを停止させるAutoScale条件#2が発動し、OnDemand用ASGの希望台数(ピンク)と稼働台数(赤)が1に落ちて、spot1台/OnDemand1台の期待する構成に落ち着いています。
さいごに
構想している時は本当にこんな仕組みができるのか不安だったのですが、なんとか実現する事ができました。
まだ商用環境での運用を開始したばかりなのですが、CFnスタック更新中にspot枯渇を心配しながら画面を見て回る必要が無いのはすごく安心感があります。AMI作成からCFnスタック更新までを自動化して多数並列実行している事からスタック更新中に手離れする時間的効果も大きく、更なる自動化等に時間を費やせる様になりました。
今回の件に限らずですが、自動化や工夫によって時間を生み出し更に良い仕組みを作る、というスパイラルを続ける事で、どんどん良い物をお客様に届けてゆこうと思います。
明日は@ein_miraiが何か書いてくれるそうです!