Verified18 commandsAI-assisted

Compute: EC2, ECS & EKS

.md

Verified against aws-cli/2.33.6, flags verified via `aws <cmd> help` run locally, plus current · official docs

What it is and where it fits#

EC2, ECS, and EKS represent three different points on AWS's "how much do you want to manage yourself" spectrum for running compute: EC2 gives you a virtual machine and everything above the hypervisor is your problem; ECS is AWS's own container orchestrator, opinionated and tightly integrated with the rest of AWS; EKS is managed Kubernetes, the same API surface you'd get from any Kubernetes cluster, with AWS running the control plane. All three are covered here rather than split apart because in practice most infrastructure/platform engineers move between them in the same debugging session — an ECS task that won't start is often an EC2 capacity or IAM problem underneath, and an EKS node group is still, underneath, an EC2 Auto Scaling group. This page assumes the profile/credential setup from the companion Configuration & IAM page is already working.

Listing and inspecting EC2 instances#

aws ec2 describe-instances
aws ec2 describe-instances --instance-ids i-0123456789abcdef0
aws ec2 describe-instances --filters "Name=instance-state-name,Values=running"
aws ec2 describe-instances --query 'Reservations[].Instances[].[InstanceId,State.Name,PublicIpAddress]' --output table

describe-instances returns nested Reservations[].Instances[] — a single call can group multiple instances under one reservation (historically tied to how a batch run-instances call was issued), so most useful --query expressions need to drill through both levels, not just Instances[] alone.

Launching, starting, and stopping instances#

aws ec2 run-instances \
  --image-id ami-0123456789abcdef0 \
  --instance-type t3.micro \
  --key-name my-key \
  --security-group-ids sg-0123456789abcdef0 \
  --subnet-id subnet-0123456789abcdef0 \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=my-instance}]'

aws ec2 start-instances --instance-ids i-0123456789abcdef0
aws ec2 stop-instances --instance-ids i-0123456789abcdef0
aws ec2 terminate-instances --instance-ids i-0123456789abcdef0

stop-instances preserves the instance (EBS-backed data survives, you keep paying for attached storage); terminate-instances deletes it permanently along with any EBS volumes set to delete-on-termination. Confusing the two in a script is one of the more expensive mistakes to make twice — a stop you meant as terminate just leaves a bill running; a terminate you meant as stop can destroy data with no undo.

Waiting for state transitions instead of polling by hand#

aws ec2 run-instances --image-id ami-0123456789abcdef0 --instance-type t3.micro --count 1 --query 'Instances[0].InstanceId' --output text > /tmp/iid
aws ec2 wait instance-running --instance-ids "$(cat /tmp/iid)"
aws ec2 wait instance-status-ok --instance-ids "$(cat /tmp/iid)"    # waits for status checks too, not just "running"

aws ec2 wait subcommands (instance-running, instance-status-ok, instance-terminated, and more) poll the underlying describe-* API on a fixed interval until the condition is met or a timeout is hit, and exit non-zero on timeout — this is the correct building block for a deploy script that needs to block until an instance is actually usable, instead of hand-rolling a sleep-and-describe loop. instance-running only means the instance has started booting; instance-status-ok additionally waits for both system and instance status checks to pass, which is the stronger, usually-more-correct signal that the instance is actually ready to receive traffic.

Security groups#

aws ec2 describe-security-groups --group-ids sg-0123456789abcdef0
aws ec2 describe-security-groups --filters "Name=vpc-id,Values=vpc-0123456789abcdef0"
aws ec2 authorize-security-group-ingress --group-id sg-0123456789abcdef0 --protocol tcp --port 443 --cidr 0.0.0.0/0

See the companion Networking page for the full rule-authoring reference (CIDR vs. security-group sources, egress rules, and the VPC/subnet context these attach to).

Building and managing AMIs#

aws ec2 create-image --instance-id i-0123456789abcdef0 --name "my-app-2026-08-21" --no-reboot
aws ec2 describe-images --owners self --filters "Name=name,Values=my-app-*"
aws ec2 copy-image --source-image-id ami-0123456789abcdef0 --source-region us-east-1 --name "my-app-dr-copy"
aws ec2 deregister-image --image-id ami-0123456789abcdef0 --delete-associated-snapshots

--no-reboot skips the default reboot-before-snapshot step for a faster, non-disruptive image build — but it's only safe if the instance's on-disk state is already consistent (nothing mid-write when the snapshot is taken); an application with in-flight writes to its own disk can produce a corrupted AMI this way. deregister-image removes the AMI but leaves its backing EBS snapshot behind by default — pass --delete-associated-snapshots to avoid quietly accumulating orphaned, still-billed snapshots every time an old AMI is cleaned up.

Spot instances#

aws ec2 describe-spot-price-history --instance-types t3.micro --product-descriptions "Linux/UNIX" --start-time 2026-08-20T00:00:00Z
aws ec2 request-spot-instances --instance-count 1 --type "persistent" --launch-specification file://spot-spec.json
aws ec2 describe-spot-instance-requests --filters "Name=state,Values=active"
aws ec2 cancel-spot-instance-requests --spot-instance-request-ids sir-0123456789abcdef0

--type "persistent" re-requests a new spot instance automatically after an interruption reclaims the previous one; "one-time" (the default) does not. Cancelling a spot request does not terminate an already-running instance created from it — that's a separate terminate-instances call, a distinction that produces a real, ongoing bill if missed in a cleanup script.

Launch templates#

aws ec2 create-launch-template --launch-template-name my-template --launch-template-data '{"ImageId":"ami-0123456789abcdef0","InstanceType":"t3.micro","KeyName":"my-key"}'
aws ec2 describe-launch-templates --launch-template-names my-template
aws ec2 run-instances --launch-template LaunchTemplateName=my-template,Version='$Latest' --min-count 1 --max-count 1

A launch template can hold multiple versions — create-launch-template-version adds one without re-specifying every field. Version='$Latest' and Version='$Default' are both valid magic values in run-instances, distinct from a specific version number: $Default only moves when you explicitly call modify-launch-template --default-version, so pinning an Auto Scaling group to $Default rather than $Latest is a deliberate way to stage a new template version before it goes live everywhere.

How ECS and EKS actually differ operationally#

Diagram

The practical difference: aws ecs commands are the whole interface — there's no second control-plane API to learn. EKS instead hands you a real Kubernetes API server, so aws eks commands mostly exist to get you access to that API (credentials, kubeconfig, node/Fargate capacity) — the actual workload management afterward happens through kubectl, not the AWS CLI.

ECS clusters, services, and tasks#

aws ecs list-clusters
aws ecs describe-clusters --clusters my-cluster
aws ecs list-services --cluster my-cluster
aws ecs update-service --cluster my-cluster --service my-service --desired-count 3
aws ecs update-service --cluster my-cluster --service my-service --force-new-deployment

--force-new-deployment (no argument, a flag) is the standard way to roll a service onto new task instances without changing the task definition — useful for picking up a new container image tagged :latest without bumping a revision, though pinning image tags to an immutable digest is the safer long-term practice (see the pitfalls section).

Registering an ECS task definition#

aws ecs register-task-definition \
  --family my-task \
  --container-definitions '[{"name":"app","image":"myrepo/app:latest","memory":512,"cpu":256}]' \
  --requires-compatibilities FARGATE \
  --network-mode awsvpc \
  --cpu "256" --memory "512"

aws ecs describe-task-definition --task-definition my-task:5

Each register-task-definition call creates a new revision under the same family — it never edits an existing one in place, which is exactly what makes ECS deployments safely reversible: rolling back is just pointing the service at an older revision number, no rebuild required.

ECS task inspection and one-off runs#

aws ecs list-tasks --cluster my-cluster --service-name my-service
aws ecs describe-tasks --cluster my-cluster --tasks <task-arn>
aws ecs run-task --cluster my-cluster --task-definition my-task:5 --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-0123456789abcdef0],securityGroups=[sg-0123456789abcdef0],assignPublicIp=ENABLED}"

run-task launches a task outside of any service's desired-count management — the right tool for a one-off migration job or a manually-triggered batch task, not for anything that should stay running and self-heal (that's what a service is for).

Debugging a running ECS task with execute-command#

aws ecs execute-command \
  --cluster my-cluster --task <task-arn> --container app \
  --interactive --command "/bin/sh"

execute-command opens an interactive shell (or runs a one-off command) inside a running container — the ECS equivalent of kubectl exec. It requires three things set up ahead of time that are easy to miss: the task's IAM execution role needs the SSM Session Manager permissions, enableExecuteCommand must be set on the service/task (it's not on by default), and the SSM Session Manager plugin must be installed locally alongside the CLI. AccessDeniedException here almost always means one of those three, not an actual permissions problem with the container's own IAM role.

EKS cluster access and node groups#

aws eks list-clusters
aws eks describe-cluster --name my-cluster
aws eks update-kubeconfig --name my-cluster --alias my-cluster    # writes/merges kubeconfig context for kubectl
aws eks list-nodegroups --cluster-name my-cluster
aws eks create-nodegroup \
  --cluster-name my-cluster --nodegroup-name workers \
  --node-role arn:aws:iam::111122223333:role/EksNodeRole \
  --subnets subnet-0123456789abcdef0 \
  --scaling-config minSize=1,maxSize=5,desiredSize=2

eks update-kubeconfig is the bridge between the AWS CLI and kubectl — it doesn't create anything in the cluster, it just writes an entry into your local kubeconfig so kubectl can authenticate against that cluster using your AWS credentials (via the aws eks get-token credential process under the hood, invoked automatically).

EKS Fargate profiles — running pods with no managed nodes at all#

aws eks create-fargate-profile \
  --cluster-name my-cluster --fargate-profile-name default \
  --pod-execution-role-arn arn:aws:iam::111122223333:role/EksFargatePodRole \
  --subnets subnet-0123456789abcdef0 \
  --selectors namespace=default

A Fargate profile is a set of selectors (namespace + optional labels) — any pod matching one runs on Fargate with no underlying EC2 node to patch or scale, at the cost of losing DaemonSets and some node-level customization. Mixing Fargate profiles and managed node groups in the same cluster is common: system/logging DaemonSets on managed nodes, application workloads on Fargate.

EKS cluster access — access entries (current) vs. the aws-auth ConfigMap (legacy)#

aws eks create-access-entry \
  --cluster-name my-cluster \
  --principal-arn arn:aws:iam::111122223333:role/PlatformTeamRole \
  --type STANDARD

aws eks associate-access-policy \
  --cluster-name my-cluster \
  --principal-arn arn:aws:iam::111122223333:role/PlatformTeamRole \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy \
  --access-scope type=cluster

aws eks list-access-entries --cluster-name my-cluster

Important

EKS access entries are now the recommended way to grant cluster access — not hand-editing the aws-auth ConfigMap. Older clusters and tutorials still reference editing a ConfigMap in kube-system to map an IAM principal to Kubernetes RBAC groups; that mechanism still works but is no longer the recommended path. Access entries manage the same IAM-to-Kubernetes-RBAC mapping through the EKS API itself (auditable in CloudTrail, no risk of a malformed YAML edit locking everyone out of the cluster) and let you attach AWS-managed access policies (AmazonEKSAdminPolicy, AmazonEKSViewPolicy, ...) instead of hand-authoring RBAC bindings for common cases. A cluster must have its authentication mode set to API or API_AND_CONFIG_MAP (checked via aws eks describe-cluster) for access entries to take effect at all.

Real-world scenario: rolling out a new container image without downtime#

# 1. Register the new revision (never mutates the old one — instant rollback target)
aws ecs register-task-definition --cli-input-json file://task-def-v6.json

# 2. Point the service at it and let ECS handle the rolling replacement
aws ecs update-service --cluster prod --service checkout-svc --task-definition checkout-task:6

# 3. Watch it settle before declaring victory
aws ecs wait services-stable --cluster prod --services checkout-svc

services-stable is the ECS analogue of ec2 wait instance-status-ok — it blocks until the service's running count matches its desired count with no in-progress deployment, which is the actual signal a deploy succeeded (a desiredCount that was merely accepted by the API is not evidence the new tasks are healthy).

Real-world scenario: EKS node group won't scale up — where to look#

aws eks describe-nodegroup --cluster-name my-cluster --nodegroup-name workers \
  --query 'nodegroup.[status,scalingConfig,health]'
aws ec2 describe-instances --filters "Name=tag:eks:nodegroup-name,Values=workers" \
  --query 'Reservations[].Instances[].[InstanceId,State.Name]'

A managed node group is, underneath, a standard EC2 Auto Scaling group — when pods stay Pending and the node group "won't scale," describe-nodegroup's health field surfaces EKS-level issues (IAM role problems, subnet capacity), but a genuine EC2 capacity shortfall in that Availability Zone won't show up there at all; cross-check describe-instances for the actual instance states and, if needed, the underlying ASG's own activity history.

Real-world scenario: emergency access to a locked-out EKS cluster#

A cluster migrated to access-entry–only authentication, and the one IAM role with admin access was accidentally deleted along with its access entry:

# From an account-level IAM identity with eks:CreateAccessEntry permission (not cluster-level access)
aws eks create-access-entry \
  --cluster-name prod-cluster \
  --principal-arn arn:aws:iam::111122223333:role/BreakGlassAdmin \
  --type STANDARD
aws eks associate-access-policy \
  --cluster-name prod-cluster \
  --principal-arn arn:aws:iam::111122223333:role/BreakGlassAdmin \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \
  --access-scope type=cluster

Tip

Access entries are managed entirely through the EKS AWS API, using standard IAM permissions on the eks:* actions — not through cluster-internal RBAC. This is precisely why they're a real improvement over the aws-auth ConfigMap for break-glass scenarios: recovering access never requires already having kubectl access to the cluster, only IAM permission on the EKS API, which an account owner always retains.

CI/CD recipe: GitHub Actions deploying to ECS#

# .github/workflows/deploy.yml
name: Deploy to ECS
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/GitHubActionsDeployRole
          aws-region: us-east-1
      - run: |
          aws ecs register-task-definition --cli-input-json file://task-def.json
          aws ecs update-service --cluster prod --service checkout-svc \
            --task-definition checkout-task --force-new-deployment
      - run: aws ecs wait services-stable --cluster prod --services checkout-svc

The final wait step matters as much as the deploy itself — without it, the workflow reports success the instant the API accepts the update request, before ECS has actually finished replacing tasks, which silently turns every deploy-failure into something only discovered later, out of band.

Common pitfalls#

  • Confusing stop-instances and terminate-instances — see the note above; the former is reversible and keeps billing for storage, the latter destroys the instance and (by default) any delete-on-termination EBS volumes.
  • Deploying :latest-tagged images and relying on --force-new-deployment alone — this makes every rollback ambiguous ("which build was actually running before?"). Pin to an immutable digest or a versioned tag and roll back by task-definition revision instead.
  • execute-command failing with AccessDeniedException for reasons that have nothing to do with the container's own permissions — check enableExecuteCommand, the execution role's SSM permissions, and the local Session Manager plugin install before assuming an IAM policy problem.
  • Assuming eks update-kubeconfig grants cluster access — it only configures kubectl's connection details; actual authorization still comes from an access entry (or a legacy aws-auth mapping) for that IAM principal.
  • Not running the matching wait subcommand after a mutating callrun-instances, update-service, and create-nodegroup all return before the resource is actually ready; scripting the next step immediately after is a common source of flaky automation.

Exit codes / when to reach for something else#

The CLI returns 0 on a successfully accepted API call and non-zero on a rejected one — for EC2/ECS it does not mean the requested end state is reached (see the wait subcommands above for that). Prefer Terraform/CloudFormation over hand-run aws ec2 run-instances/aws ecs register-task-definition for anything meant to be a long-lived, reviewable part of your infrastructure; reach for these commands directly for operational debugging, one-off tasks, and CI/CD deploy steps that intentionally mutate an already-provisioned service (a task definition update, a rolling deployment) rather than provisioning new infrastructure from scratch.