Guide · ECS Architecture · Fargate Spot + On-Demand

Mixed On-Demand and Fargate Spot Strategy for MCP Server Monitoring

Running your entire MCP server monitoring fleet on Fargate Spot is risky; running it all On-Demand is wasteful. The right architecture separates the coordinator (stateful, critical, On-Demand) from the probe workers (stateless, interruptible, Spot) and lets Auto Scaling set the worker count. This pattern achieves 65–75% cost savings vs an all-On-Demand fleet while maintaining >99.9% probe coverage continuity — a Spot interruption on any single worker causes at most one missed 60-second probe cycle for that batch, not a monitoring outage. This guide covers the split architecture design, CDK service definitions for both tiers, CloudWatch alarms for high Spot interruption rates, and a Spot-shortage fallback that automatically shifts worker tasks to On-Demand when Spot capacity is constrained. For Fargate Spot pricing see AWS Fargate Spot for MCP Server Probe Workers; for capacity provider CDK setup see ECS Capacity Providers for MCP Server Monitoring; for the SIGTERM handler see Handling Fargate Spot Interruptions.

TL;DR

Split your MCP monitoring stack into two ECS services: a probe-coordinator service (1 task, On-Demand FARGATE, manages the endpoint clock and SQS batch distribution) and a probe-worker service (2–50 tasks, FARGATE_SPOT with FARGATE base=1, does the actual HTTP probes). Add a CloudWatch alarm that fires when SpotTaskCount / RunningTaskCount < 0.5 for 10 minutes — this signals a Spot drought and you can respond by temporarily raising the FARGATE base count. Total cost at 1,000 endpoints: ~$24/mo vs ~$82/mo all-On-Demand (71% savings).

Architecture: coordinator vs worker split

A naive MCP monitoring design runs a single ECS service with a cron-triggered Lambda or a looping process that probes every endpoint every 60 seconds. This works at small scale but becomes a bottleneck at >100 endpoints (sequential probing can't hit a 60-second interval) and is a poor fit for Spot (interrupting the single process loses the entire probe cycle).

The split architecture uses two roles:

RoleResponsibilitiesCapacityWhy
probe-coordinator Maintains the 60-second epoch clock; reads the endpoint registry from DB; distributes endpoint batches to SQS every 60 seconds; tracks which endpoints need immediate re-probe (schema drift, repeated failures) On-Demand FARGATE, 1 task always Coordinator holds the scheduling state and SQS distribution logic. If interrupted, probe batches stop being sent for 30–90 seconds (ECS restart time) → multiple consecutive probe cycles missed. Running On-Demand eliminates this risk for ~$9/mo.
probe-workers Pull endpoint batches from SQS; execute HTTP/SSE probes against MCP endpoints; write probe results to DB; delete SQS messages on success; re-queue on failure FARGATE_SPOT (base 1 On-Demand), 2–50 tasks auto-scaled Workers are completely stateless: each batch is self-contained in the SQS message. An interrupted worker re-queues its batch to SQS; another worker picks it up within 90 seconds (SQS visibility timeout). Safe for Spot.

This separation lets you scale the worker fleet from 2 to 50 tasks based on SQS queue depth (more endpoints = more SQS messages per 60s = more workers needed) while keeping the coordinator stable and cheap.

CDK: coordinator service (On-Demand)

import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export function createCoordinatorService(
  scope: Construct,
  cluster: ecs.Cluster,
  coordinatorTaskDef: ecs.FargateTaskDefinition,
  probeQueue: sqs.IQueue,
): ecs.FargateService {
  const service = new ecs.FargateService(scope, 'ProbeCoordinatorService', {
    cluster,
    taskDefinition: coordinatorTaskDef,
    desiredCount: 1,
    // Explicit On-Demand: no FARGATE_SPOT in the strategy.
    // This is equivalent to launchType: FARGATE but makes the cost decision visible.
    capacityProviderStrategies: [
      { capacityProvider: 'FARGATE', base: 1, weight: 1 },
    ],
    // Coordinator must not have two instances running simultaneously —
    // two coordinators would double-distribute SQS batches → duplicate probe records.
    minHealthyPercent: 0,    // allow ECS to stop the old task before starting new (rolling update)
    maxHealthyPercent: 100,
    // Stop old coordinator before starting new during deployments to avoid dual-coordinator
    deploymentController: { type: ecs.DeploymentControllerType.ECS },
    circuitBreaker: { rollback: true },
  });

  // Coordinator needs SQS SendMessage and DB read access
  probeQueue.grantSendMessages(service.taskDefinition.taskRole);

  return service;
}

The coordinator uses minHealthyPercent: 0 so ECS terminates the old task before starting the new one during a deployment. With minHealthyPercent: 100, ECS would start a second coordinator alongside the first — a brief dual-coordinator window where both are sending SQS batches and probe results are duplicated. For a single-task service, always use minHealthyPercent: 0.

CDK: worker service (Fargate Spot)

import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as applicationautoscaling from 'aws-cdk-lib/aws-applicationautoscaling';
import { Duration, Aws } from 'aws-cdk-lib';

export function createWorkerService(
  scope: Construct,
  cluster: ecs.Cluster,
  workerTaskDef: ecs.FargateTaskDefinition,
  probeQueue: sqs.IQueue,
): ecs.FargateService {
  const service = new ecs.FargateService(scope, 'ProbeWorkerService', {
    cluster,
    taskDefinition: workerTaskDef,
    desiredCount: 4,
    capacityProviderStrategies: [
      // 1 On-Demand task always running: survives total Spot drought
      { capacityProvider: 'FARGATE', base: 1, weight: 1 },
      // Remaining tasks 75% on Spot
      { capacityProvider: 'FARGATE_SPOT', base: 0, weight: 3 },
    ],
    minHealthyPercent: 50,
    maxHealthyPercent: 200,
    circuitBreaker: { rollback: true },
    // Spread across AZs for Spot capacity diversity
    vpcSubnets: { availabilityZones: [`${Aws.REGION}a`, `${Aws.REGION}b`, `${Aws.REGION}c`] },
  });

  // Workers need SQS ReceiveMessage, DeleteMessage, ChangeMessageVisibility
  probeQueue.grantConsumeMessages(service.taskDefinition.taskRole);

  // Auto Scaling: scale workers based on SQS visible messages per task
  const scaling = service.autoScaleTaskCount({ minCapacity: 2, maxCapacity: 50 });
  scaling.scaleOnMetric('SqsDepthPerTask', {
    metric: probeQueue.metricApproximateNumberOfMessagesVisible({
      period: Duration.minutes(1),
      statistic: 'Average',
    }),
    scalingSteps: [
      { upper: 0, change: -2 },
      { lower: 1, upper: 10, change: 0 },
      { lower: 10, upper: 40, change: +3 },
      { lower: 40, change: +8 },
    ],
    adjustmentType: applicationautoscaling.AdjustmentType.CHANGE_IN_CAPACITY,
    cooldown: Duration.minutes(2),
  });

  return service;
}

CloudWatch alarms: detecting Spot droughts

A "Spot drought" occurs when AWS has no available Fargate Spot capacity in your region/AZ and ECS cannot provision new Spot tasks. Existing Spot tasks continue running (they aren't interrupted by capacity shortage — only by AWS reclaiming capacity for On-Demand), but the service can't scale out. If probe load grows and workers can't scale, the SQS queue depth climbs and probes fall behind their 60-second interval.

import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as sns from 'aws-cdk-lib/aws-sns';

export function createSpotAlarms(
  scope: Construct,
  workerService: ecs.FargateService,
  probeQueue: sqs.IQueue,
  alertTopic: sns.ITopic,
): void {
  // Alarm 1: running task count drops below minimum (mass Spot interruption or drought)
  const taskCountAlarm = new cloudwatch.Alarm(scope, 'LowWorkerTaskCount', {
    alarmName: 'mcp-probe-low-worker-count',
    alarmDescription: 'Probe worker count below 2 — possible Spot interruption or drought',
    metric: new cloudwatch.Metric({
      namespace: 'ECS/ContainerInsights',
      metricName: 'RunningTaskCount',
      dimensionsMap: {
        ServiceName: workerService.serviceName,
        ClusterName: workerService.cluster.clusterName,
      },
      period: Duration.minutes(2),
      statistic: 'Minimum',
    }),
    threshold: 2,
    comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
    evaluationPeriods: 3,        // alarm after 3 consecutive 2-minute periods (6 minutes total)
    treatMissingData: cloudwatch.TreatMissingData.BREACHING,
  });
  taskCountAlarm.addAlarmAction(new cloudwatchActions.SnsAction(alertTopic));

  // Alarm 2: SQS queue depth growing despite running workers (workers not keeping up)
  const queueBacklogAlarm = new cloudwatch.Alarm(scope, 'ProbeQueueBacklog', {
    alarmName: 'mcp-probe-queue-backlog',
    alarmDescription: 'Probe SQS queue depth exceeds 100 — workers not keeping up with probe demand',
    metric: probeQueue.metricApproximateNumberOfMessagesVisible({
      period: Duration.minutes(2),
      statistic: 'Average',
    }),
    threshold: 100,
    comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
    evaluationPeriods: 3,
    treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
  });
  queueBacklogAlarm.addAlarmAction(new cloudwatchActions.SnsAction(alertTopic));
}

Spot drought fallback: raising the On-Demand base

When a Spot drought alarm fires, you have two response options:

  1. Automatic fallback (CDK, via Auto Scaling): ECS will fall back to On-Demand automatically using the FARGATE weight in the capacity strategy — it just won't get the Spot discount. No action needed; just accept temporarily higher costs.
  2. Manual base increase (AWS CLI): If Auto Scaling isn't scaling out fast enough during a drought, temporarily increase the FARGATE base to force more On-Demand tasks:
    aws ecs update-service \
      --cluster mcp-probe-cluster \
      --service ProbeWorkerService \
      --capacity-provider-strategy \
        "capacityProvider=FARGATE,base=5,weight=1" \
        "capacityProvider=FARGATE_SPOT,base=0,weight=3" \
      --force-new-deployment
    Revert by re-deploying via CDK once the Spot drought passes.

In practice, Spot droughts for Fargate are rare (typically <1 hour) and affect only specific AZ/instance families. Tasks already running on Spot continue until AWS needs capacity — the drought only affects provisioning new Spot tasks. If your worker service has enough running tasks to handle current probe load, a Spot drought is invisible to your monitoring output.

Cost model: mixed vs all-On-Demand at different scales

ScaleWorkersAll On-Demand (mo)Mixed 75% Spot (mo)Saving
500 endpoints 4 workers + 1 coordinator ~$27 ~$11 ~$16 (59%)
1,000 endpoints 8 workers + 1 coordinator ~$45 ~$16 ~$29 (64%)
5,000 endpoints 20 workers + 1 coordinator ~$99 ~$33 ~$66 (67%)
10,000 endpoints 40 workers + 1 coordinator ~$189 ~$62 ~$127 (67%)

Worker count assumes 0.25 vCPU / 0.5 GB (ARM64 Graviton) with 20 concurrent probes per worker; coordinator is 0.5 vCPU / 1 GB On-Demand. Cost estimates use Fargate On-Demand pricing ~$0.04048/vCPU-hr + $0.004445/GB-hr in us-east-1 and a 66% Spot discount. Actual costs vary by region and current Spot pricing.

Failure modes

SymptomCauseFix
Two coordinator tasks running simultaneously after deployment Coordinator service has minHealthyPercent: 100; ECS starts a new task before stopping the old one Set minHealthyPercent: 0 for single-task coordinator services; or implement distributed locking in the coordinator using DynamoDB conditional writes
Probe coverage drops to 0% for 5–10 minutes after coordinator restart Coordinator rebuilds endpoint registry from DB on startup (slow query on large endpoint table) Cache endpoint registry in DynamoDB with a 60-second TTL; coordinator reads from cache on startup; cold-start latency drops from minutes to seconds
Worker scaling lags behind probe demand spikes Auto Scaling cooldown (2 min) prevents rapid scale-out; ECS task launch time (30–60s) adds additional delay Pre-warm by maintaining a slightly higher base worker count during expected load increases; or use target tracking on SQS queue depth rather than step scaling for smoother responses
SQS queue grows unbounded despite workers running Coordinator sending too many batches (clock drift or restart → duplicate epoch distribution); workers consuming slower than coordinator produces Coordinator should use a distributed mutex (DynamoDB conditional write on the current epoch) before distributing each round; add SQS MessageRetentionPeriod to discard stale probe batches automatically
Spot interruption alarm triggers false-positive during deployment ECS rolling update reduces RunningTaskCount temporarily below the alarm threshold Set alarm evaluationPeriods: 3 (6 minutes total at 2-minute periods) to ride through a 2–3 minute deployment; or suppress alarms during planned deployments via CloudWatch alarm actions