Approach- Keep Terraform responsible for ASG creation, lifecycle, launch template, scaling policies, tags, health checks, and bounds (min/max). Let an external autoscaler (K8s HPA, custom controller) be the single source of truth for desired_capacity.- Prevent Terraform from clobbering desired_capacity and avoid races by: (1) lifecycle ignore_changes for desired_capacity, (2) using an ownership/lease signal to coordinate who can set desired_capacity, (3) using remote state locking to prevent concurrent applies.HCL pseudocode (concise, illustrative)hcl
terraform {
backend "s3" {
bucket = "tf-state-prod"
key = "asg/module.tfstate"
region = "us-east-1"
dynamodb_table = "tf-state-lock" # remote state locking
encrypt = true
}
}
variable "asg_name" {}
variable "min_size" { default = 1 }
variable "max_size" { default = 10 }
# We DO NOT accept desired_capacity as a managed input
resource "aws_launch_template" "lt" {
name_prefix = "${var.asg_name}-lt-"
# ... launch configuration ...
}
resource "aws_autoscaling_group" "asg" {
name = var.asg_name
launch_template {
id = aws_launch_template.lt.id
version = "$Latest"
}
min_size = var.min_size
max_size = var.max_size
# Important: ignore external autoscaler's desired_capacity changes
lifecycle {
ignore_changes = [
desired_capacity
]
prevent_destroy = false
}
tag {
key = "managed_by"
value = "terraform"
propagate_at_launch = true
}
tag {
key = "desired_owner"
value = "external-autoscaler"
propagate_at_launch = true
}
}
# Optional: a lightweight ownership/lease resource (SSM parameter or DynamoDB item)
resource "aws_ssm_parameter" "asg_lease" {
name = "/leases/${var.asg_name}"
type = "String"
value = jsonencode({
owner = "terraform"
timestamp = timestamp()
expires_at = timeadd(timestamp(), "1h")
})
lifecycle { prevent_destroy = false }
}
Design notes / reasoning- lifecycle.ignore_changes(desired_capacity): ensures Terraform will not attempt to write desired_capacity when it detects a different value set by the external autoscaler. This keeps Terraform idempotent: it will reconcile all managed attributes while ignoring externally-controlled field.- Managing min_size/max_size: keep bounds in Terraform so you can enforce safe limits. External autoscaler modifies desired_capacity but cannot exceed configured min/max if you want that guarantee — or you can allow autoscaler to manage bounds too, in which case add them to ignore_changes as well.- Ownership signals: use tags (managed_by, desired_owner) and/or a small lease resource (SSM parameter or DynamoDB item) to advertise intent. The external autoscaler should respect these signals; when temporarily wanting to hand control back to Terraform, it can clear or update the lease.- Lease pattern: terraform creates/updates a lease item with owner="terraform". An external autoscaler can check lease and yield control by setting owner="external-autoscaler" or eviction flag. Alternatively, the external autoscaler can acquire a short-lived lease before making changes; Terraform should read that lease (data source) and error or pause apply if lease indicates external ownership.- Remote state locking: configure S3 backend with DynamoDB table as lock. This prevents concurrent terraform applies which might race with each other. It does not prevent the external autoscaler from changing desired_capacity, so combine with ignore_changes and ownership signals.- Avoiding races: do not make Terraform read-modify-write desired_capacity. Avoid data-dependent updates to desired_capacity. If Terraform must set desired_capacity on first create, set desired_capacity only during create (use lifecycle create_before_destroy or a local-exec to initialize), then add ignore_changes so subsequent runs don't override external changes.- Reconciliation and drift: run periodic scans to detect drift in ignored fields; if you need to reconcile (e.g., emergency restore), implement an explicit workflow: external autoscaler yields lease, operator runs terraform apply to set capacity, then returns lease to autoscaler.- Edge cases: - Initial create: ASG may need a sensible initial desired_capacity; set it in resource creation, then immediately mark desired_capacity in ignore_changes so external autoscaler can take over. - Deletion: if external autoscaler prevents safe teardown, ensure lifecycle.prevent_destroy and clear ownership signals during destroy flows. - Concurrent external writers: prefer external autoscaler to be single-writer; use its own locking (leader election) or a lease in DynamoDB/SSM to coordinate.Operational practices- Document ownership in README and enforce via CI checks: e.g., terraform plan fails if lease indicates external ownership.- Monitoring: emit metrics when lease ownership changes; alert on unexpected desired_capacity divergence from autoscaler intent.- Testing: simulate external autoscaler writes in staging and verify terraform respects ignore_changes and lease semantics.This pattern keeps Terraform authoritative for infrastructure configuration and safe bounds, while allowing an external autoscaler to control runtime scaling without Terraform fighting it.