0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Fargate Spot で月$42K が $21K になった話 - イベント駆動型フェイルオーバー実装

0
Last updated at Posted at 2026-01-24

導入

背景

ECS上で稼働しているIoTプラットフォームのデータ分析基盤運用にて、月額コストが$3.8K以上を超え、コストが最適化必要という課題がありました。
本記事では、コスト削減の一環でFargate Spot Provoiderの導入中に、イベント駆動型自動フェイルオーバーによりサービス可用性担保部分を解説します。

本記事の目的

  • ECSで稼働しているシステムのコスト削減策(Fargate Spot Provoider導入)を紹介します。
  • イベント駆動型フェイルオーバーの活用でFargate Spot Providerの可用性向上設計を解説します。

対象読者

  • AWS サーバレスアーキテクチャの運用知識が持つ方が対象と想定しています。
  • 高可用性とコスト削減両立を検討している方が対象と想定しています。

アーキテクチャ概要

本ソリューションのアーキテクチャは、ECS Standard ProviderとECS Spot Providerのスタンバイ構成でサービスを構築します。
通常時にSpot Providerのサービスが稼働し、Spot Providercapacity不足エラーやサービス負荷が過大の場合、
検知イベントによりStandard Providerサービスのリソースを起動し、サービス可用性を担保できます。
Spot Providercapacity不足エラーが解除された場合、もしくはサービス負荷が安定された時に、カスタマイズの検知イベントにより
Standard Providerサービスのリソースを解放し、サービスコストを削減することが実現できます。

通常時:
  Fargate Spot Service (200 tasks) - 実行中
  Fargate Standard Service (0 tasks) - 待機

Spot capacity不足・負荷過大の時:
  1. CloudWatchよりSpot capacity不足・負荷過大を検知する
  2. イベントLambdaでフェイルオーバーを発動する
  3. Standard Serviceを起動する (必要なtasksを起動)
  4. 起動失敗のSpot Serviceタスクを停止する

サービスの負荷が安定した時、Spot capacityエラーが解除した場合:
  1. CloudWatch上記状態を検知する
  2. イベントLambdaよりSpot ServiceにStandard Serviceで実行中のタスク数を追加する
  3. Spot Serviceで追加起動されたタスクが安定したら、Standard Serviceを停止する

技術スタック

該当イベント駆動型フェイルオーバーアーキテクチャが以下の技術スタックで実現されます。

Compute:
  - Amazon ECS Fargate (Spot + Standard)
  - AWS Lambda (Python 3.11)

Event Processing:
  - Amazon EventBridge (イベント駆動)
  - Amazon CloudWatch Events (定期スキャン)

State Management:
  - Amazon DynamoDB (フェイルオーバー状態管理)
  - Amazon SQS (メッセージキュー)

Monitoring & Alerting:
  - Amazon CloudWatch (メトリクス・ログ)
  - Amazon SNS (通知)

IaC:
  - Terraform 1.5+

試行1: 純粋なSpot化の試行と失敗

アプローチ

データ分析基盤がECS Fargate上で稼働されていますため、Saving PlanとSpot Providerがコスト最適化の検討オプションになります。
Spot Providerを利用する場合最大70%割引があり、大幅なコスト削減が期待できます。

料金比較:

Spot (us-east-1):
  vCPU: $0.02049 per vCPU/hour (56% OFF)
  Memory: $0.00225 per GB/hour (56% OFF)

4 vCPU + 16GB の時間単価:
  Standard: $0.2682/hour
  Spot: $0.1180/hour
  差額: $0.1502/hour

ECS Serviceの設定をSpotに変更:

CapacityProviderStrategy:
  - CapacityProvider: FARGATE_SPOT
    Weight: 1
    Base: 0

遭遇した問題

Spot Provider導入のPOC検証した時に、下記の問題が検知されました。

問題: Capacity不足によるタスク起動失敗

ECSタスクを起動する時に、Capacity不足でタスク起動エラーとなり、サービスが繰り返しタスク起動試し状態になってしまいました。

現象:

StoppedReason: "ResourcesNotAvailable: 
  No Fargate capacity available for the requested configuration."

影響:

  • SQSにメッセージが滞留(最大15,000件)
  • 下流サービスでタイムアウトエラーが発生
  • データ処理の遅延が数時間に及ぶ

根本原因分析:
Spot capacityはAWSのキャパシティプールから提供されるため、リージョン全体の需要が高まると枯渇されます。

試行2: イベント駆動型自動フェイルオーバー

コアアイディア

Fargate SpotとStandardの両方のServiceを用意し、通常でSpot側のサービス上データ分析基盤を稼働します。
Spot側でCapacity問題が発生した場合に自動的にStandard側のサービスにフェイルオーバーします。
Spotサービスのcapacity不足が解消したら、自動的にSpotのタスクを起動し、Standardのタスクを停止します。

設計原則:

  1. 通常時はSpotのみ稼働(コスト最小化)
  2. Spot障害時は即座にStandardへ切り替え(可用性確保)
  3. 全プロセスを自動化(運用負荷ゼロ)
  4. 状態を永続化(Lambda実行間での状態共有)

アーキテクチャ

全体構成図

┌─────────────── IoT Data Flow ───────────────┐
│                                              │
│  IoTデバイス → API Gateway → SQS キュー      │
│                                  ↓           │
│                        ┌─────────────────┐   │
│                        │  ECS Cluster    │   │
│                        │                 │   │
│                        │  [Spot Service] │   │
│                        │   desired: 200  │   │
│                        │   running: 200  │   │
│                        │                 │   │
│                        │  [Std Service]  │   │
│                        │   desired: 0    │   │
│                        │   running: 0    │   │
│                        └─────────────────┘   │
└──────────────────────────────────────────────┘

┌────────── Failover Control Plane ───────────┐
│                                              │
│  EventBridge Rules:                          │
│  ├─ Task STOPPED (Spot errors)              │
│  ├─ Task RUNNING (Spot success)             │
│  └─ CloudWatch Events (PENDING scan)        │
│           ↓                                  │
│  ┌──────────────────────────────┐           │
│  │   Lambda Functions           │           │
│  │                              │           │
│  │  ① Error Detector            │           │
│  │     - エラーカウント          │           │
│  │     - 閾値判定               │           │
│  │                              │           │
│  │  ② Failover Executor         │           │
│  │     - Standard起動           │           │
│  │     - Spot停止(エラータスク)   │           │
│  │                              │           │
│  │  ③ Recovery Monitor          │           │
│  │     - Spot成功検知           │           │
│  │     - 安定性評価             │           │
│  │                              │           │
│  │  ④ Cleanup Handler           │           │
│  │     - Spot復帰              │           │
│  │     - Standard停止          │           │
│  └──────────────────────────────┘           │
│           ↓                                  │
│  ┌──────────────────────────────┐           │
│  │   DynamoDB                   │           │
│  │   PK: service_name           │           │
│  │                              │           │
│  │   Attributes:                │           │
│  │   - error_count              │           │
│  │   - last_error_time          │           │
│  │   - failover_active          │           │
│  │   - original_desired_count   │           │
│  │   - failover_timestamp       │           │
│  └──────────────────────────────┘           │
│                                              │
└──────────────────────────────────────────────┘

ステート管理とメッセージフロー

データ分析基盤のフェイルオーバーアーキテクチャを実現するため、
2種類のステート状態で分析対象のファイルを管理しています。。

1. 処理中のステート管理(SQS)
IoTデバイスからのデータはSQSで管理し、タスク障害時の自動リトライを実現します。

メッセージライフサイクル:
1. デバイス → API Gateway → SQSキューへエンキュー
2. Fargateタスクがメッセージ受信
   - SQSはvisibility timeout(30分)を設定
   - この間、他のタスクからは見えなくなる
3. タスクで処理実行
   a. 成功 → SQSからメッセージ削除(完了)
   b. 失敗 → visibility timeout後に自動的に再配信

フェイルオーバー時の動作フロー:

時刻 00:00:00 - Spotタスク群がcapacity不足で起動失敗
時刻 00:00:00 - SQS内の未処理メッセージはvisibility timeout中
              (他のタスクからは見えない状態で保護)
時刻 00:00:05 - エラー検知システムが3回の失敗を検出
時刻 00:00:07 - フェイルオーバー開始、Standard Service起動要求
時刻 00:02:07 - Standardタスク群が起動完了(50%以上)
時刻 00:02:07 - SQSのvisibility timeoutが切れたメッセージから順次処理再開
              ※完全に最初から再処理(冪等性が重要)
時刻 00:02:17 - フェイルオーバー完了、Spot Service停止

中断時間: 約2分17秒(この間はメッセージがSQSで保護される)

冪等性の担保:
リトライ処理で重複処理を回避するため、データ分析処理にて必ず処理の冪等性を実装する必要になります。

def process_iot_data(message):
    """
    IoTデータ処理(冪等実装)
    """
    data = json.loads(message['Body'])
    device_id = data['device_id']
    timestamp = data['timestamp']
    
    # 1. DynamoDBで重複チェック
    existing = dynamodb_table.get_item(
        Key={
            'device_id': device_id,
            'timestamp': timestamp
        }
    )
    
    if existing:
        print(f"Already processed: {device_id}@{timestamp}")
        return  # 既に処理済み、何もしない
    
    # 2. データ分析処理
    result = analyze_data(data)
    
    # 3. 結果を保存(条件付き書き込みで二重書き込み防止)
    dynamodb_table.put_item(
        Item={
            'device_id': device_id,
            'timestamp': timestamp,
            'result': result,
            'processed_at': int(time.time())
        },
        ConditionExpression='attribute_not_exists(device_id)'
    )
    
    # 4. 成功時のみSQSメッセージを削除
    sqs.delete_message(
        QueueUrl=queue_url,
        ReceiptHandle=message['ReceiptHandle']
    )

2. フェイルオーバーステートの管理(DynamoDB)
SpotサービスからStardardサービスへのフェイルオーバーについて、関連の制御情報がDynamoDBで管理します。

DynamoDB Schema:
{
  "service_name": "analytics",           # PK
  "failover_active": true,               # フェイルオーバー中か
  "error_count": 3,                      # 直近のエラー回数
  "last_error_time": 1234567890,         # 最終エラー時刻
  "last_success_time": 1234567800,       # 最終成功時刻
  "original_desired_count": 200,         # 元のタスク数
  "failover_timestamp": 1234567895,      # フェイルオーバー開始時刻
  "failover_reason": "CAPACITY_UNAVAILABLE",
  "ttl": 1234654295                      # 24時間後に自動削除
}

該当フェイルオーバーアーキテクチャの実装中に、下記の4つポイントが一番大切な設計要素になります。

  • 処理データがSQSで管理する: Fargate側に状態を持たない完全ステートレスであること
  • フェイルオーバー制御がDynamoDBで管理する: Lambda実行間での状態共有すること
  • 冪等性の徹底実現: 同じメッセージを複数回処理しても結果が同じこと
  • データ損失ゼロ: SQSのvisibility timeoutとDynamoDBの条件付き書き込みで担保すること

実装詳細

1. エラー検出機構

EventBridge Rule定義:

{
  "source": ["aws.ecs"],
  "detail-type": ["ECS Task State Change"],
  "detail": {
    "clusterArn": [{
      "prefix": "arn:aws:ecs:us-east-1:123456789012:cluster/analytics-cluster"
    }],
    "lastStatus": ["STOPPED"],
    "capacityProviderName": ["FARGATE_SPOT"],
    "stoppedReason": [
      {"prefix": "ResourcesNotAvailable"},
      {"prefix": "SpotInterruption"}
    ]
  }
}

※ポイント: capacityProviderNameでSpotタスクのみをフィルタリングすることを意識しましょう。

Lambda: エラー検出ハンドラ

import boto3
import time
from decimal import Decimal

dynamodb = boto3.resource('dynamodb')
lambda_client = boto3.client('lambda')
table = dynamodb.Table('fargate-failover-state')

def lambda_handler(event, context):
    """
    Spotタスクのエラーを検出し、閾値超過時にフェイルオーバーをトリガー
    """
    detail = event['detail']
    stopped_reason = detail.get('stoppedReason', '')
    
    # サービス名を抽出
    service_name = extract_service_name(detail)
    
    # エラータイプ別の閾値設定
    error_config = get_error_config(stopped_reason)
    if not error_config:
        return {'statusCode': 200, 'message': 'Not a monitored error'}
    
    # DynamoDBでエラーカウント更新
    current_time = int(time.time())
    
    response = table.update_item(
        Key={'service_name': service_name},
        UpdateExpression='''
            ADD error_count :inc
            SET last_error_time = :time,
                error_type = :error_type
        ''',
        ExpressionAttributeValues={
            ':inc': 1,
            ':time': current_time,
            ':error_type': error_config['type']
        },
        ReturnValues='ALL_NEW'
    )
    
    attrs = response['Attributes']
    error_count = int(attrs['error_count'])
    last_error_time = int(attrs['last_error_time'])
    
    # 5分以上前のエラーはカウントリセット
    if current_time - last_error_time > 300:
        table.update_item(
            Key={'service_name': service_name},
            UpdateExpression='SET error_count = :zero',
            ExpressionAttributeValues={':zero': 0}
        )
        error_count = 0
    
    print(f"Error detected: {error_config['type']}")
    print(f"Error count: {error_count}/{error_config['threshold']}")
    
    # 閾値チェック
    if error_count >= error_config['threshold']:
        print(f"Threshold exceeded, triggering failover")
        
        # Failover Executorを非同期実行
        lambda_client.invoke(
            FunctionName='fargate-failover-executor',
            InvocationType='Event',
            Payload=json.dumps({
                'service_name': service_name,
                'reason': f"{error_config['type']}_THRESHOLD_EXCEEDED",
                'error_count': error_count
            })
        )
    
    return {
        'statusCode': 200,
        'error_count': error_count,
        'threshold': error_config['threshold']
    }

def extract_service_name(detail):
    """
    ECSイベントからサービス名を抽出
    group: "service:analytics-spot""analytics"
    """
    group = detail.get('group', '')
    if ':' not in group:
        return 'unknown'
    service_full = group.split(':')[1]
    return service_full.replace('-spot', '').replace('-standard', '')

def get_error_config(stopped_reason):
    """
    エラータイプに応じた設定を返す
    """
    if 'ResourcesNotAvailable' in stopped_reason:
        return {
            'type': 'CAPACITY_UNAVAILABLE',
            'threshold': 3,  # 3回で即フェイルオーバー
            'severity': 'CRITICAL'
        }
    elif 'SpotInterruption' in stopped_reason:
        return {
            'type': 'SPOT_INTERRUPTION',
            'threshold': 5,  # 5回で判断
            'severity': 'HIGH'
        }
    return None

設計判断:
フェイルオーバー用のエラー検出について、誤判断を回避するため、
エラー発生時にすぐフェイルオーバーではなく、メトリックで連続3回エラーが検知された場合だけが発動ししましょう。

  • ResourcesNotAvailable: capacity完全枯渇のため、閾値3回で即座に切り替え
  • SpotInterruption: 一時的な可能性があるため、閾値5回で様子見

2. PENDING長時間検出

長時間PENDING状態を継続する場合、分析対象メッセージの処理待ち時間が段々増加してしまう可能性があるため、
CloudWatch Eventsによる定期スキャンを実装し、なるべく早めにPENDING状態を検知できるよう対策を追加しました。
ここはサービス可用性担保が一番大優先のため、ある程度コストが掛かっても仕方ないことでしょう。

CloudWatch Events Rule (1分毎実行):

def scan_pending_tasks(event, context):
    """
    定期的にPENDINGタスクをスキャンし、長時間停滞を検出
    """
    ecs = boto3.client('ecs')
    lambda_client = boto3.client('lambda')
    
    cluster_name = 'analytics-cluster'
    service_name = 'analytics-spot'
    
    # Spotサービスのタスク一覧取得
    task_arns = ecs.list_tasks(
        cluster=cluster_name,
        serviceName=service_name,
        desiredStatus='RUNNING'  # PENDINGも含む
    ).get('taskArns', [])
    
    if not task_arns:
        return {'statusCode': 200, 'message': 'No tasks found'}
    
    # タスク詳細取得
    tasks = ecs.describe_tasks(
        cluster=cluster_name,
        tasks=task_arns
    )['tasks']
    
    pending_tasks = [t for t in tasks if t['lastStatus'] == 'PENDING']
    
    if not pending_tasks:
        return {'statusCode': 200, 'message': 'No pending tasks'}
    
    current_time = datetime.now(timezone.utc)
    long_pending_detected = False
    
    for task in pending_tasks:
        created_at = task['createdAt']
        pending_duration = (current_time - created_at).total_seconds()
        
        print(f"Task {task['taskArn'].split('/')[-1]}: "
              f"PENDING for {pending_duration:.0f}s")
        
        # 180秒(3分)超過で異常と判断
        if pending_duration >= 180:
            print(f"Long PENDING detected: {pending_duration:.0f}s")
            long_pending_detected = True
            break
    
    if long_pending_detected:
        # Failover実行
        lambda_client.invoke(
            FunctionName='fargate-failover-executor',
            InvocationType='Event',
            Payload=json.dumps({
                'service_name': service_name.replace('-spot', ''),
                'reason': 'PENDING_TIMEOUT',
                'pending_duration': int(pending_duration)
            })
        )
    
    return {
        'statusCode': 200,
        'pending_count': len(pending_tasks),
        'long_pending_detected': long_pending_detected
    }

なぜか3分という閾値を設定するのか:
Fargateから新しいタスクの正常起動時間が2分ぐらいため、30%のバッファを考慮すると3分に設定しました。

3. フェイルオーバー実行

続いて、一番重要な機能[Fargate SpotサービスからStandardサービスのフェイルオーバー]を実現しましょう。
ここにて重要な実装ポイントが以下になります。

  1. Standardを先に起動: サービス空白期間を最小化
  2. 50%起動で次ステップへ: 全タスクの起動を待つ必要はない
  3. 状態の永続化: Lambda実行間で状態を共有するためDynamoDBを使用
import boto3
import time
from datetime import datetime

ecs = boto3.client('ecs')
sns = boto3.client('sns')
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('fargate-failover-state')

def lambda_handler(event, context):
    """
    Spot → Standardへのフェイルオーバーを実行
    """
    service_name = event['service_name']
    reason = event['reason']
    
    cluster_name = 'analytics-cluster'
    spot_service = f"{service_name}-spot"
    standard_service = f"{service_name}-standard"
    
    start_time = time.time()
    
    print(f"Failover initiated for {service_name}")
    print(f"Reason: {reason}")
    
    # 1. Spotサービスの現在状態を取得
    spot_desc = ecs.describe_services(
        cluster=cluster_name,
        services=[spot_service]
    )['services'][0]
    
    desired_count = spot_desc['desiredCount']
    
    if desired_count == 0:
        print("Spot service already at 0, aborting")
        return {'statusCode': 200, 'message': 'Already failed over'}
    
    print(f"Current Spot desired count: {desired_count}")
    
    # 2. Standardサービスを起動
    print(f"Scaling up Standard service to {desired_count} tasks")
    
    ecs.update_service(
        cluster=cluster_name,
        service=standard_service,
        desiredCount=desired_count
    )
    
    # 3. Standardサービスが最低50%起動するまで待機
    print("Waiting for Standard service to reach 50% capacity")
    
    wait_for_service_ready(
        cluster_name,
        standard_service,
        desired_count,
        min_healthy_percent=50,
        timeout_seconds=600
    )
    
    # 4. Spotサービスを停止
    print("Scaling down Spot service to 0")
    
    ecs.update_service(
        cluster=cluster_name,
        service=spot_service,
        desiredCount=0
    )
    
    # 5. 状態をDynamoDBに保存
    table.put_item(Item={
        'service_name': service_name,
        'failover_active': True,
        'failover_timestamp': int(time.time()),
        'original_desired_count': desired_count,
        'failover_reason': reason,
        'ttl': int(time.time()) + 86400
    })
    
    # 6. SNS通知
    elapsed = time.time() - start_time
    
    sns.publish(
        TopicArn='arn:aws:sns:us-east-1:123456789012:fargate-failover',
        Subject=f'Failover Executed: {service_name}',
        Message=f'''
Fargate Spotフェイルオーバーを実行しました。

Service: {service_name}
Reason: {reason}
Task Count: {desired_count}
Elapsed Time: {elapsed:.1f}s

Current State:
- Spot Service: 0 tasks
- Standard Service: {desired_count} tasks

自動復旧監視を開始します。
        '''
    )
    
    print(f"Failover completed in {elapsed:.1f}s")
    
    return {
        'statusCode': 200,
        'elapsed_time': elapsed,
        'desired_count': desired_count
    }

def wait_for_service_ready(cluster, service, desired, min_healthy_percent, timeout_seconds):
    """
    サービスが指定パーセンテージまで起動するのを待つ
    """
    start = time.time()
    
    while True:
        if time.time() - start > timeout_seconds:
            raise TimeoutError(f"Service {service} did not reach {min_healthy_percent}% "
                             f"within {timeout_seconds}s")
        
        desc = ecs.describe_services(
            cluster=cluster,
            services=[service]
        )['services'][0]
        
        running = desc['runningCount']
        healthy_percent = (running / desired * 100) if desired > 0 else 0
        
        print(f"  {running}/{desired} tasks running ({healthy_percent:.0f}%)")
        
        if healthy_percent >= min_healthy_percent:
            print(f"Service ready: {healthy_percent:.0f}% >= {min_healthy_percent}%")
            return
        
        time.sleep(10)

4. 安定検出

次、[フェイルオーバー実行状態評価]を実現しましょう。
フェイルオーバーが発動したら、いつかSpotサービスに切り戻す判定が必要となります。
下記の2つポイントが満たす場合、サービス全体が安定の状態であり、切戻しが可能と判定します。

  • 連続成功時間 ≥ 10分
  • 直近20タスクのエラー率 ≤ 0.05%
{
  "source": ["aws.ecs"],
  "detail-type": ["ECS Task State Change"],
  "detail": {
    "lastStatus": ["RUNNING"],
    "capacityProviderName": ["FARGATE_SPOT"]
  }
}

Lambda: 回復監視

def monitor_recovery(event, context):
    """
    Spotタスクの成功を監視し、安定性を評価
    """
    service_name = extract_service_name(event['detail'])
    
    # フェイルオーバー中かチェック
    response = table.get_item(Key={'service_name': service_name})
    
    if 'Item' not in response:
        return {'statusCode': 200, 'message': 'Not in failover state'}
    
    item = response['Item']
    if not item.get('failover_active'):
        return {'statusCode': 200, 'message': 'Failover not active'}
    
    # エラーカウントをリセット
    table.update_item(
        Key={'service_name': service_name},
        UpdateExpression='SET error_count = :zero, last_success_time = :time',
        ExpressionAttributeValues={
            ':zero': 0,
            ':time': int(time.time())
        }
    )
    
    print("Spot task succeeded, error count reset")
    
    # 安定性評価
    if is_spot_stable(service_name):
        print("Spot capacity appears stable, initiating cleanup")
        
        lambda_client.invoke(
            FunctionName='fargate-cleanup-handler',
            InvocationType='Event',
            Payload=json.dumps({'service_name': service_name})
        )
    
    return {'statusCode': 200}

def is_spot_stable(service_name):
    """
    Spotが本当に安定しているか評価
    """
    # 1. 連続成功時間のチェック
    response = table.get_item(Key={'service_name': service_name})
    item = response['Item']
    
    last_success = int(item.get('last_success_time', 0))
    current_time = int(time.time())
    continuous_success_duration = current_time - last_success
    
    # 10分間連続成功が必要
    if continuous_success_duration < 600:
        print(f"Continuous success: {continuous_success_duration}s < 600s")
        return False
    
    # 2. 過去10分間のタスク実行履歴をチェック
    ecs = boto3.client('ecs')
    
    tasks = ecs.list_tasks(
        cluster='analytics-cluster',
        serviceName=f'{service_name}-spot',
        desiredStatus='STOPPED',
        maxResults=100
    )['taskArns']
    
    if not tasks:
        return True  # 履歴がない=問題なし
    
    # 直近のタスク詳細を取得
    recent_tasks = ecs.describe_tasks(
        cluster='analytics-cluster',
        tasks=tasks[:20]  # 最新20件
    )['tasks']
    
    # エラータスクの割合を計算
    error_tasks = [t for t in recent_tasks 
                   if t.get('stopCode') == 'TaskFailedToStart' or
                      'ResourcesNotAvailable' in t.get('stoppedReason', '')]
    
    error_rate = len(error_tasks) / len(recent_tasks) if recent_tasks else 0
    
    print(f"Recent error rate: {error_rate:.1%}")
    
    # エラー率5%以下なら安定
    return error_rate <= 0.05

5. Spotへの自動復帰

最後、[Fargate StandardサービスからSpotサービスへの回復]を実現しましょう。
サービスの安定基準が満たす場合、イベントより自動的にSpotサービスに切り戻します。

def cleanup_failover(event, context):
    """
    Standard → Spotへ復帰
    """
    service_name = event['service_name']
    
    cluster_name = 'analytics-cluster'
    spot_service = f"{service_name}-spot"
    standard_service = f"{service_name}-standard"
    
    print(f"Starting cleanup for {service_name}")
    
    # 元のタスク数を取得
    item = table.get_item(Key={'service_name': service_name})['Item']
    original_count = int(item['original_desired_count'])
    
    # 1. Spotサービスを復元
    print(f"Restoring Spot service to {original_count} tasks")
    
    ecs.update_service(
        cluster=cluster_name,
        service=spot_service,
        desiredCount=original_count
    )
    
    # 2. Spotが50%起動するまで待機
    wait_for_service_ready(
        cluster_name,
        spot_service,
        original_count,
        min_healthy_percent=50,
        timeout_seconds=600
    )
    
    # 3. Standardサービスを停止
    print("Scaling down Standard service to 0")
    
    ecs.update_service(
        cluster=cluster_name,
        service=standard_service,
        desiredCount=0
    )
    
    # 4. 状態をクリア
    table.update_item(
        Key={'service_name': service_name},
        UpdateExpression='SET failover_active = :false',
        ExpressionAttributeValues={':false': False}
    )
    
    print(f"Cleanup completed, back to Spot")
    
    # SNS通知
    sns.publish(
        TopicArn='arn:aws:sns:us-east-1:123456789012:fargate-failover',
        Subject=f'Recovery Completed: {service_name}',
        Message=f'''
Spot capacityが回復し、自動復帰しました。

Service: {service_name}
Task Count: {original_count}

Current State:
- Spot Service: {original_count} tasks
- Standard Service: 0 tasks
        '''
    )
    
    return {'statusCode': 200}

結果

可用性

Fargate Spotエラーのイベント検知およびPending状態の自動検知により、サービス全体的に長時間のエラー状態もしくは起動待機状態が回避できるため、
全体的に99.9%以上な可用性が担保できます。

フェイルオーバー時間の実測(100回の計測):

フェーズ P50 P95 P99
エラー検知 5秒 15秒 30秒
Standard起動要求 2秒 5秒 8秒
Standardタスク起動 120秒 150秒 180秒
Spot停止 10秒 20秒 30秒
合計 137秒 190秒 248秒

サービス中断時間:

最悪ケース(全Spotタスクが同時に起動失敗):

00:00:00 - Spotタスク起動失敗
00:00:05 - エラー検知(3回閾値到達)
00:00:07 - Standard起動開始
00:02:07 - Standard 50%起動完了
00:02:17 - フェイルオーバー完了

実質中断時間: 約2分17秒

月間可用性(直近3ヶ月の実績):

総稼働時間: 43,200分/月

中断時間:
- フェイルオーバー: 平均12回 × 2.3分 = 27.6分
- 計画メンテナンス: 10分

総中断時間: 37.6分

可用性 = (43,200 - 37.6) / 43,200 × 100 = 99.91%

コスト

基本的にFargate Spotサービスでデータ分析を行うため、月額コストが全体的に50%以上削減することが実現できました。

実績データ(3ヶ月平均):

Spot稼働率: 95%
Standard稼働率: 5%

Spot コスト:
  vCPU: 4 × 200 × 684h × $0.02049 = $11,183
  Memory: 16 × 200 × 684h × $0.00225 = $4,903
  小計: $16,086

Standard コスト:
  vCPU: 4 × 200 × 36h × $0.04656 = $1,341
  Memory: 16 × 200 × 36h × $0.00511 = $589
  小計: $1,930

コントロールプレーン:
  Lambda: ~200,000実行 × $0.0000002 = $40
  DynamoDB: オンデマンド = $25
  EventBridge: ルール実行 = $5
  SNS: 通知 = $2
  小計: $72

月額合計: $18,088

削減効果:

Before: $38,587/月
After:  $18,088/月

削減額: $20,499/月 (53.1%)
年間削減: $245,988

実装中に遭遇した問題と解決策

問題1: フェイルオーバー無限ループ

障害現象:
Fargate Spot Capacity不足エラーが検知した後、Standardサービスに切り替えしましたが、
Standardサービス側でエラーもイベントとして検知され、再度Spotへ切り替えを試行し、結果として無限ループが発生してしまいました。

根本原因:
EventBridge Ruleが全てのタスクエラーを捕捉しており、fargate spot capacityProviderの区別をしていませんでした。

解決策:
EventBridge RuleにcapacityProviderNameフィルタを追加:

{
  "detail": {
    "capacityProviderName": ["FARGATE_SPOT"]
  }
}

問題2: Spot回復の誤判定

現象:
1-2タスクが成功しただけで「回復した」と判断し、すぐStandardサービスからSpotサービスに切り替えします。
切戻し処理の直後に再度Spotエラーが発生してしまい、繰り返しでフェイルオーバーを実行していました。

根本原因:
単純な成功検知では、一時的な回復と本当のサービス安定が区別していませでした。

解決策:
サービスの安定性評価ロジックを実装することより解決できました。
基本的にサービス安定基準の全条件を満たしてからSpotサービスへの復帰を発動します。。

まとめ

本実装による利点

このイベント駆動のフェイルオーバーより、ECS Fargate上で稼働しているサービスが全体的に可用性が維持しながら
大幅コストを削減でき、運用手間が増加しないことが実現できます。

適用シナリオ

基本的に下記のユースケースが本方式を一番適用しやすいと考えていますが、他の業務パターンもし利用可能であれば、ぜひご検討しましょう。
1. IoTデータ分析基盤(本記事の実例)
2. 夜間バッチ処理
3. 非同期メディア処理ワーカー
4. データパイプライン・ETL処理

なお、リアルタイム性高い業務ケースにて本方式より業務支障になるので、ご注意ください。
1. リアルタイムAPI・マイクロサービス
2. WebSocket/長期接続サーバー
3. ステートフル処理

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?