Auto-Scaling EC2 Instances for WMS Endpoints: Incident Response & State Recovery
When a Web Map Service (WMS) auto-scaling group starts shedding 503 Service Unavailable responses during a scale-out, the root cause is almost always that the fleet scaled faster than GeoServer could become render-ready, or that the infrastructure-as-code state no longer matches the live cloud graph. This guide is a focused incident-response runbook for restoring a healthy, codified WMS tier and is part of the Compute Node Orchestration workflow within the broader Geospatial Resource Provisioning domain. WMS workloads carry distinct scaling signatures — JVM-heavy startup latency, raster tile cache initialization, and connection-pool saturation against the backing spatial database — so the remediation steps below differ sharply from those for a stateless web app, and they assume the data tier from PostGIS Cluster Provisioning is already healthy.
Symptom Identification and Triage
WMS auto-scaling incidents surface through three observable failure modes, each with a distinct signal you can confirm before touching the infrastructure code.
- Target-group deregistration storms. New instances flap between
initialandunhealthyinaws elbv2 describe-target-health, and clients see intermittent503s. This means health checks validate TCP connectivity rather than an HTTPGetCapabilitiesresponse, so traffic routes to a JVM that has not finished warming up. - Connection-pool exhaustion on rapid scale-out. Instances pass health checks but tile requests stall with
504gateway timeouts, and PostGIS logs showFATAL: remaining connection slots are reserved. The auto-scaling group expanded past the database connection ceiling because no pooling intermediary (PgBouncer or RDS Proxy) sits between the fleet and the PostGIS database. - Orchestration state drift.
terraform planreports changes you did not author, scaling policies reference a deprecated launch-template version, or instances hang inPending:Wait. This is the signature of out-of-band console edits, ad-hoc security-group changes, or lifecycle hooks whose heartbeat timeout is shorter than the JVM-plus-cache hydration window.
Walk the triage path from the externally observable HTTP status, to the target-group health state, to the auto-scaling activity history, and finally to the IaC plan diff — only the last of these confirms drift.
Prerequisites and Environment Assumptions
This runbook assumes the following provider versions, backend, and permissions are in place before you begin:
- Terraform
>= 1.5with thehashicorp/awsprovider>= 5.0, or Pulumi with@pulumi/aws >= 6.0. Note that@pulumi/awsis used directly here; the higher-level@pulumi/awsxpackage does not expose anautoscaling.AutoScalingGroupconstruct. - A remote, lockable state backend (S3 + DynamoDB lock table, or Pulumi Cloud) so concurrent applies cannot race during recovery — see State Backend Selection for the locking rationale.
- IAM permissions for
autoscaling:*,elasticloadbalancing:*,ec2:DescribeInstances,ec2:*LaunchTemplate*, and read access to the state bucket. Scope these against the least-privilege boundaries defined in IAM Role Mapping for GIS. - A reachable WMS
GetCapabilitiesendpoint on each node (default:8080/geoserver/ows) and CLI access to query CloudWatch and the load balancer.
Step-by-Step Remediation
Recover the fleet by prioritising infrastructure consistency over immediate capacity, so you do not amplify the incident by terminating healthy nodes that are still serving tiles.
-
Freeze scale-in. Suspend the
TerminateandReplaceUnhealthyprocesses so reconciliation cannot kill nodes mid-request:aws autoscaling suspend-processes \ --auto-scaling-group-name wms-prod-asg \ --scaling-processes Terminate ReplaceUnhealthy -
Lock and refresh state. Reconcile the IaC backend with the live AWS inventory to expose drift before you change anything. A plain refresh-only plan never mutates resources:
terraform plan -refresh-only -lock=true -out=drift.tfplan -
Re-converge the launch template and health check. Apply the corrected configuration below. The decisive geospatial fix is the
ELBhealth-check type pointed at a realGetCapabilitiesrequest plus ahealth_check_grace_periodlong enough to absorb JVM warm-up and tile-cache hydration — a TCP check would mark a cold GeoServer “healthy” and route tiles to it prematurely.terraform { required_version = ">= 1.5" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0" } } } resource "aws_autoscaling_group" "wms_asg" { name = "wms-prod-asg" vpc_zone_identifier = var.private_subnet_ids min_size = 2 max_size = 12 desired_capacity = 3 health_check_type = "ELB" health_check_grace_period = 300 # Absorbs JVM warmup & raster cache init wait_for_capacity_timeout = "10m" launch_template { id = aws_launch_template.wms_lt.id version = "$Latest" } lifecycle { ignore_changes = [desired_capacity] # let scaling policy own capacity } # Rolling replace so active tile requests are never severed abruptly instance_refresh { strategy = "Rolling" preferences { min_healthy_percentage = 75 instance_warmup = 300 } } } resource "aws_lb_target_group" "wms_tg" { name = "wms-prod-tg" port = 8080 protocol = "HTTP" vpc_id = var.vpc_id health_check { path = "/geoserver/ows?service=WMS&version=1.3.0&request=GetCapabilities" port = "8080" protocol = "HTTP" healthy_threshold = 3 unhealthy_threshold = 3 timeout = 10 interval = 30 matcher = "200" } deregistration_delay = 60 # let in-flight map requests drain }terraform apply -target=aws_lb_target_group.wms_tg \ -target=aws_autoscaling_group.wms_asg -lock=true -
Verify lifecycle-hook timeouts. Confirm every hook’s heartbeat exceeds the maximum JVM-plus-cache window so instances no longer strand in
Pending:Wait; extend the hook rather than shortening the grace period. -
Resume normal scaling. Once targets report
healthy, lift the suspension:aws autoscaling resume-processes --auto-scaling-group-name wms-prod-asg
For teams running Pulumi, the equivalent stack enforces identical constraints with stack-level type safety and integrates with the same Compute Node Orchestration workflow:
import * as aws from "@pulumi/aws"; // pin @pulumi/aws >= 6.0 in package.json
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
const vpcId = config.require("vpcId");
const amiId = config.require("amiId");
const wmsTargetGroup = new aws.lb.TargetGroup("wms-tg", {
port: 8080,
protocol: "HTTP",
vpcId: vpcId,
healthCheck: {
path: "/geoserver/ows?service=WMS&version=1.3.0&request=GetCapabilities",
port: "8080",
protocol: "HTTP",
healthyThreshold: 3,
unhealthyThreshold: 3,
timeout: 10,
interval: 30,
matcher: "200",
},
deregistrationDelay: 60,
});
const wmsLaunchTemplate = new aws.ec2.LaunchTemplate("wms-lt", {
imageId: amiId,
instanceType: "c6i.2xlarge",
userData: Buffer.from(`#!/bin/bash
systemctl enable geoserver
systemctl start geoserver`).toString("base64"),
iamInstanceProfile: { name: wmsInstanceProfile.name },
vpcSecurityGroupIds: [wmsSecurityGroup.id],
});
const wmsASG = new aws.autoscaling.Group("wms-asg", {
vpcZoneIdentifiers: privateSubnetIds,
desiredCapacity: 3,
minSize: 2,
maxSize: 12,
healthCheckType: "ELB",
healthCheckGracePeriod: 300,
launchTemplate: {
id: wmsLaunchTemplate.id,
version: "$Latest",
},
targetGroupArns: [wmsTargetGroup.arn],
instanceRefresh: {
strategy: "Rolling",
preferences: {
minHealthyPercentage: 75,
instanceWarmup: "300",
},
},
});
The pattern guarantees an instance only receives tile traffic once GeoServer can answer a real WMS request, not merely accept a TCP connection:
Verification
Confirm the fix is live from both the orchestration layer and the map endpoint itself, not just the AWS console:
-
Target health is application-aware. Every registered target should report
healthy, and the reason should reflect the HTTP probe, not a raw TCP accept:aws elbv2 describe-target-health \ --target-group-arn "$(terraform output -raw wms_tg_arn)" \ --query 'TargetHealthDescriptions[].TargetHealth.State' -
The endpoint answers a real WMS request. A
200with aWMS_Capabilitiesbody — not just an open socket — proves the node is render-ready:curl -s -o /dev/null -w '%{http_code}\n' \ "https://maps.example.org/geoserver/ows?service=WMS&version=1.3.0&request=GetCapabilities" -
State is clean. A follow-up
terraform plan -refresh-onlyreportsNo changes, confirming the live graph matches code and the drift is resolved. -
The database is no longer saturated. PostGIS no longer logs
remaining connection slots are reserved, confirming the pooled endpoint absorbed the scale-out.
Preventing Recurrence
Encode the fix so the failure modes cannot reappear through console edits or a future module change:
- Policy-as-code gate. Add an OPA/Conftest or Sentinel rule that rejects any
aws_autoscaling_groupwhosehealth_check_typeis notELB, or whosehealth_check_grace_periodis below the measured JVM warm-up floor, and run it in the pull-request pipeline alongside the checks described in Module Design Patterns. - Scheduled drift detection. Run
terraform plan -refresh-only -detailed-exitcodeon a cron cadence and alert on a non-zero diff so out-of-band changes are caught before the next incident. - Pin the pooled data path. Require the launch template to point GeoServer at a PgBouncer or RDS Proxy endpoint rather than the raw cluster, so a scale-out can never exhaust PostGIS connection slots.
- Lock down the runtime image. Build AMIs through automated Packer pipelines that validate JVM heap allocation and connection-pool limits against the GeoServer Deployment Patterns baseline before promotion, and keep style and dataset access scoped to Object Storage for Raster/Vector Workloads via least-privilege instance profiles.
For deeper background, the official AWS Auto Scaling health checks documentation covers ELB integration, and the GeoServer production hardening guide covers garbage-collection tuning, tile-cache eviction, and thread-pool sizing.
Frequently Asked Questions
Why do new WMS instances pass health checks but still return 503?
Because the health check validates TCP connectivity instead of an HTTP GetCapabilities response. Switch the target group to an HTTP check against /geoserver/ows?...request=GetCapabilities with a 200 matcher so a node only enters service once GeoServer can render.
How long should the health-check grace period be?
Long enough to cover the slowest observed JVM start plus raster tile-cache hydration — commonly 240-360 seconds. Measure it on a cold instance and set health_check_grace_period (and instance_warmup) above that floor rather than guessing.
How do I stop scale-out from exhausting PostGIS connections?
Route every GeoServer node through a PgBouncer or RDS Proxy endpoint and cap per-instance pool sizes, so the maximum fleet size multiplied by the pool size stays below the PostGIS connection ceiling.
Related
- Compute Node Orchestration — parent workflow for the ephemeral execution layer.
- GeoServer Deployment Patterns — the publishing tier these instances run.
- PostGIS Cluster Provisioning — the backing data tier and its connection limits.
- VPC Routing for Tile Servers — network paths and private connectivity for the WMS fleet.