I tried using ARM64 / FARGATE_SPOT / ECS Exec which are not supported in ECS Express Mode

I tried using ARM64 / FARGATE_SPOT / ECS Exec which are not supported in ECS Express Mode

# ECS Express Mode Limitations Workaround: Two-Stage Deployment Process ## Background ECS Express Mode provides simplified ECS cluster deployment, but it has the following limitations: - Cannot specify ARM64 (Graviton) architecture - Cannot enable Fargate Spot - Cannot enable ECS Exec This article explains a two-stage deployment process that combines CloudFormation and AWS CLI to work around these limitations. --- ## Overview of the Two-Stage Deployment Process ``` Stage 1: Initial deployment with CloudFormation (ECS Express Mode) ↓ Stage 2: Post-deployment updates via AWS CLI - Switch to ARM64 (Graviton) - Enable Fargate Spot - Enable ECS Exec ``` --- ## Stage 1: Initial Deployment with CloudFormation ### Template Structure ```yaml # ecs-express-base.yaml AWSTemplateFormatVersion: '2010-09-09' Description: 'ECS Express Mode Base Deployment' Parameters: AppName: Type: String Default: myapp ContainerImage: Type: String Description: 'ECR Image URI' ContainerPort: Type: Number Default: 8080 TaskCpu: Type: String Default: '512' TaskMemory: Type: String Default: '1024' VpcId: Type: AWS::EC2::VPC::Id SubnetIds: Type: List<AWS::EC2::Subnet::Id> Resources: # ECS Cluster ECSCluster: Type: AWS::ECS::Cluster Properties: ClusterName: !Sub '${AppName}-cluster' CapacityProviders: - FARGATE DefaultCapacityProviderStrategy: - CapacityProvider: FARGATE Weight: 1 ClusterSettings: - Name: containerInsights Value: enabled # CloudWatch Log Group LogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/ecs/${AppName}' RetentionInDays: 30 # IAM Role for Task Execution TaskExecutionRole: Type: AWS::IAM::Role Properties: RoleName: !Sub '${AppName}-task-execution-role' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: ecs-tasks.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy # IAM Role for Task (with ECS Exec permissions) TaskRole: Type: AWS::IAM::Role Properties: RoleName: !Sub '${AppName}-task-role' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: ecs-tasks.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: ECSExecPolicy PolicyDocument: Version: '2012-10-17' Statement: # Permissions required for ECS Exec - Effect: Allow Action: - ssmmessages:CreateControlChannel - ssmmessages:CreateDataChannel - ssmmessages:OpenControlChannel - ssmmessages:OpenDataChannel Resource: '*' # CloudWatch Logs permissions - Effect: Allow Action: - logs:DescribeLogGroups - logs:CreateLogStream - logs:DescribeLogStreams - logs:PutLogEvents Resource: '*' # Security Group ServiceSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: !Sub 'Security group for ${AppName} ECS service' VpcId: !Ref VpcId SecurityGroupIngress: - IpProtocol: tcp FromPort: !Ref ContainerPort ToPort: !Ref ContainerPort CidrIp: '0.0.0.0/0' SecurityGroupEgress: - IpProtocol: '-1' CidrIp: '0.0.0.0/0' # Task Definition (initially x86_64) TaskDefinition: Type: AWS::ECS::TaskDefinition Properties: Family: !Sub '${AppName}-task' Cpu: !Ref TaskCpu Memory: !Ref TaskMemory NetworkMode: awsvpc RequiresCompatibilities: - FARGATE ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn TaskRoleArn: !GetAtt TaskRole.Arn # x86_64 for initial deployment RuntimePlatform: CpuArchitecture: X86_64 OperatingSystemFamily: LINUX ContainerDefinitions: - Name: !Ref AppName Image: !Ref ContainerImage PortMappings: - ContainerPort: !Ref ContainerPort Protocol: tcp LogConfiguration: LogDriver: awslogs Options: awslogs-group: !Ref LogGroup awslogs-region: !Ref AWS::Region awslogs-stream-prefix: ecs Environment: - Name: APP_ENV Value: production Essential: true # ECS Service (initially FARGATE only) ECSService: Type: AWS::ECS::Service Properties: ServiceName: !Sub '${AppName}-service' Cluster: !Ref ECSCluster TaskDefinition: !Ref TaskDefinition DesiredCount: 2 LaunchType: FARGATE # ECS Exec disabled initially EnableExecuteCommand: false NetworkConfiguration: AwsvpcConfiguration: SecurityGroups: - !Ref ServiceSecurityGroup Subnets: !Ref SubnetIds AssignPublicIp: ENABLED DeploymentConfiguration: MaximumPercent: 200 MinimumHealthyPercent: 100 Outputs: ClusterName: Value: !Ref ECSCluster Export: Name: !Sub '${AppName}-cluster-name' ServiceName: Value: !GetAtt ECSService.Name Export: Name: !Sub '${AppName}-service-name' TaskDefinitionFamily: Value: !Sub '${AppName}-task' Export: Name: !Sub '${AppName}-task-family' TaskRoleArn: Value: !GetAtt TaskRole.Arn Export: Name: !Sub '${AppName}-task-role-arn' TaskExecutionRoleArn: Value: !GetAtt TaskExecutionRole.Arn Export: Name: !Sub '${AppName}-task-execution-role-arn' SecurityGroupId: Value: !Ref ServiceSecurityGroup Export: Name: !Sub '${AppName}-sg-id' ``` ### Stage 1 Deployment Script ```bash #!/bin/bash # deploy-stage1.sh set -euo pipefail # Configuration APP_NAME="myapp" STACK_NAME="${APP_NAME}-ecs-stack" REGION="ap-northeast-1" CONTAINER_IMAGE="123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:latest" VPC_ID="vpc-xxxxxxxxxxxxxxxxx" SUBNET_IDS="subnet-xxxxxxxxxxxxxxxxx,subnet-yyyyyyyyyyyyyyyyy" echo "=== Stage 1: CloudFormation Deployment ===" # Stack deployment aws cloudformation deploy \ --template-file ecs-express-base.yaml \ --stack-name "${STACK_NAME}" \ --region "${REGION}" \ --parameter-overrides \ AppName="${APP_NAME}" \ ContainerImage="${CONTAINER_IMAGE}" \ VpcId="${VPC_ID}" \ SubnetIds="${SUBNET_IDS}" \ --capabilities CAPABILITY_NAMED_IAM \ --no-fail-on-empty-changeset echo "Stage 1 deployment complete" # Retrieve output values CLUSTER_NAME=$(aws cloudformation describe-stacks \ --stack-name "${STACK_NAME}" \ --region "${REGION}" \ --query 'Stacks[0].Outputs[?OutputKey==`ClusterName`].OutputValue' \ --output text) SERVICE_NAME=$(aws cloudformation describe-stacks \ --stack-name "${STACK_NAME}" \ --region "${REGION}" \ --query 'Stacks[0].Outputs[?OutputKey==`ServiceName`].OutputValue' \ --output text) echo "Cluster: ${CLUSTER_NAME}" echo "Service: ${SERVICE_NAME}" # Save to environment variables file cat > .env.deployment << EOF APP_NAME=${APP_NAME} STACK_NAME=${STACK_NAME} REGION=${REGION} CLUSTER_NAME=${CLUSTER_NAME} SERVICE_NAME=${SERVICE_NAME} CONTAINER_IMAGE=${CONTAINER_IMAGE} EOF echo "Environment variables saved to .env.deployment" ``` --- ## Stage 2: Post-Deployment Updates via AWS CLI ### Stage 2 Script: ARM64 + Fargate Spot + ECS Exec Enable ```bash #!/bin/bash # deploy-stage2.sh set -euo pipefail # Load environment variables source .env.deployment echo "=== Stage 2: ARM64 / Fargate Spot / ECS Exec Configuration ===" # ───────────────────────────────────────── # Step 2-1: Add Fargate Spot to Cluster # ───────────────────────────────────────── echo "[Step 2-1] Adding FARGATE_SPOT to cluster capacity providers..." aws ecs put-cluster-capacity-providers \ --cluster "${CLUSTER_NAME}" \ --region "${REGION}" \ --capacity-providers FARGATE FARGATE_SPOT \ --default-capacity-provider-strategy \ capacityProvider=FARGATE_SPOT,weight=3,base=0 \ capacityProvider=FARGATE,weight=1,base=1 echo "Fargate Spot capacity provider configured" # ───────────────────────────────────────── # Step 2-2: Create New Task Definition (ARM64) # ───────────────────────────────────────── echo "[Step 2-2] Creating ARM64 task definition..." # Retrieve existing task definition TASK_FAMILY="${APP_NAME}-task" CURRENT_TASK_DEF=$(aws ecs describe-task-definition \ --task-definition "${TASK_FAMILY}" \ --region "${REGION}" \ --query 'taskDefinition') EXECUTION_ROLE_ARN=$(echo "${CURRENT_TASK_DEF}" | jq -r '.executionRoleArn') TASK_ROLE_ARN=$(echo "${CURRENT_TASK_DEF}" | jq -r '.taskRoleArn') CPU=$(echo "${CURRENT_TASK_DEF}" | jq -r '.cpu') MEMORY=$(echo "${CURRENT_TASK_DEF}" | jq -r '.memory') CONTAINER_DEFS=$(echo "${CURRENT_TASK_DEF}" | jq '.containerDefinitions') # Create new task definition with ARM64 NEW_TASK_DEF=$(cat << EOF { "family": "${TASK_FAMILY}", "executionRoleArn": "${EXECUTION_ROLE_ARN}", "taskRoleArn": "${TASK_ROLE_ARN}", "networkMode": "awsvpc", "requiresCompatibilities": ["FARGATE"], "cpu": "${CPU}", "memory": "${MEMORY}", "runtimePlatform": { "cpuArchitecture": "ARM64", "operatingSystemFamily": "LINUX" }, "containerDefinitions": ${CONTAINER_DEFS} } EOF ) # Register task definition NEW_TASK_DEF_ARN=$(aws ecs register-task-definition \ --region "${REGION}" \ --cli-input-json "${NEW_TASK_DEF}" \ --query 'taskDefinition.taskDefinitionArn' \ --output text) echo "ARM64 task definition created: ${NEW_TASK_DEF_ARN}" # ───────────────────────────────────────── # Step 2-3: Update Service # - Switch to ARM64 task definition # - Enable ECS Exec # - Enable Fargate Spot # ───────────────────────────────────────── echo "[Step 2-3] Updating ECS service..." aws ecs update-service \ --cluster "${CLUSTER_NAME}" \ --service "${SERVICE_NAME}" \ --region "${REGION}" \ --task-definition "${NEW_TASK_DEF_ARN}" \ --enable-execute-command \ --capacity-provider-strategy \ capacityProvider=FARGATE_SPOT,weight=3,base=0 \ capacityProvider=FARGATE,weight=1,base=1 \ --deployment-configuration \ maximumPercent=200,minimumHealthyPercent=100 \ --force-new-deployment echo "Service update initiated" # ───────────────────────────────────────── # Step 2-4: Wait for Deployment Stability # ───────────────────────────────────────── echo "[Step 2-4] Waiting for service stability..." aws ecs wait services-stable \ --cluster "${CLUSTER_NAME}" \ --services "${SERVICE_NAME}" \ --region "${REGION}" echo "Service is stable" # ───────────────────────────────────────── # Step 2-5: Verify Configuration # ───────────────────────────────────────── echo "[Step 2-5] Verifying configuration..." # Verify service settings SERVICE_INFO=$(aws ecs describe-services \ --cluster "${CLUSTER_NAME}" \ --services "${SERVICE_NAME}" \ --region "${REGION}" \ --query 'services[0]') EXEC_ENABLED=$(echo "${SERVICE_INFO}" | jq -r '.enableExecuteCommand') CAPACITY_STRATEGY=$(echo "${SERVICE_INFO}" | jq -r '.capacityProviderStrategy') RUNNING_TASK_DEF=$(echo "${SERVICE_INFO}" | jq -r '.taskDefinition') echo "ECS Exec enabled: ${EXEC_ENABLED}" echo "Capacity provider strategy: ${CAPACITY_STRATEGY}" echo "Running task definition: ${RUNNING_TASK_DEF}" # Verify running tasks TASKS=$(aws ecs list-tasks \ --cluster "${CLUSTER_NAME}" \ --service-name "${SERVICE_NAME}" \ --region "${REGION}" \ --query 'taskArns' \ --output json) echo "Running tasks: ${TASKS}" # Verify architecture of each task for TASK_ARN in $(echo "${TASKS}" | jq -r '.[]'); do TASK_INFO=$(aws ecs describe-tasks \ --cluster "${CLUSTER_NAME}" \ --tasks "${TASK_ARN}" \ --region "${REGION}" \ --query 'tasks[0]') LAUNCH_TYPE=$(echo "${TASK_INFO}" | jq -r '.launchType // "CAPACITY_PROVIDER"') CAPACITY_PROVIDER=$(echo "${TASK_INFO}" | jq -r '.capacityProviderName // "N/A"') echo "Task: ${TASK_ARN##*/}" echo " Launch type: ${LAUNCH_TYPE}" echo " Capacity provider: ${CAPACITY_PROVIDER}" done echo "" echo "=== Stage 2 configuration complete ===" ``` --- ## Integrated Deployment Script ```bash #!/bin/bash # deploy-all.sh set -euo pipefail # ───────────────────────────────────────── # Configuration # ───────────────────────────────────────── APP_NAME="${1:-myapp}" REGION="${2:-ap-northeast-1}" CONTAINER_IMAGE="${3}" VPC_ID="${4}" SUBNET_IDS="${5}" # Validate required parameters if [[ -z "${CONTAINER_IMAGE}" || -z "${VPC_ID}" || -z "${SUBNET_IDS}" ]]; then echo "Usage: $0 <app-name> <region> <container-image> <vpc-id> <subnet-ids>" echo "Example: $0 myapp ap-northeast-1 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:latest vpc-xxx subnet-xxx,subnet-yyy" exit 1 fi STACK_NAME="${APP_NAME}-ecs-stack" echo "========================================" echo " ECS Two-Stage Deployment" echo "========================================" echo " App Name : ${APP_NAME}" echo " Region : ${REGION}" echo " Image : ${CONTAINER_IMAGE}" echo " VPC : ${VPC_ID}" echo " Subnets : ${SUBNET_IDS}" echo "========================================" # ───────────────────────────────────────── # Stage 1: CloudFormation Deployment # ───────────────────────────────────────── echo "" echo ">>> Stage 1: CloudFormation Base Deployment" aws cloudformation deploy \ --template-file ecs-express-base.yaml \ --stack-name "${STACK_NAME}" \ --region "${REGION}" \ --parameter-overrides \ AppName="${APP_NAME}" \ ContainerImage="${CONTAINER_IMAGE}" \ VpcId="${VPC_ID}" \ SubnetIds="${SUBNET_IDS}" \ --capabilities CAPABILITY_NAMED_IAM \ --no-fail-on-empty-changeset # Retrieve output values CLUSTER_NAME=$(aws cloudformation describe-stacks \ --stack-name "${STACK_NAME}" \ --region "${REGION}" \ --query 'Stacks[0].Outputs[?OutputKey==`ClusterName`].OutputValue' \ --output text) SERVICE_NAME=$(aws cloudformation describe-stacks \ --stack-name "${STACK_NAME}" \ --region "${REGION}" \ --query 'Stacks[0].Outputs[?OutputKey==`ServiceName`].OutputValue' \ --output text) echo "Stage 1 complete: Cluster=${CLUSTER_NAME}, Service=${SERVICE_NAME}" # ───────────────────────────────────────── # Stage 2: Advanced Configuration # ───────────────────────────────────────── echo "" echo ">>> Stage 2: ARM64 / Fargate Spot / ECS Exec Configuration" # 2-1: Add Fargate Spot capacity provider echo "Adding FARGATE_SPOT capacity provider..." aws ecs put-cluster-capacity-providers \ --cluster "${CLUSTER_NAME}" \ --region "${REGION}" \ --capacity-providers FARGATE FARGATE_SPOT \ --default-capacity-provider-strategy \ capacityProvider=FARGATE_SPOT,weight=3,base=0 \ capacityProvider=FARGATE,weight=1,base=1 # 2-2: Create ARM64 task definition echo "Creating ARM64 task definition..." TASK_FAMILY="${APP_NAME}-task" CURRENT_TASK_DEF=$(aws ecs describe-task-definition \ --task-definition "${TASK_FAMILY}" \ --region "${REGION}" \ --query 'taskDefinition') # Update RuntimePlatform to ARM64 ARM64_TASK_DEF=$(echo "${CURRENT_TASK_DEF}" | jq ' del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .placementConstraints, .compatibilities, .registeredAt, .registeredBy) | .runtimePlatform.cpuArchitecture = "ARM64" ') NEW_TASK_DEF_ARN=$(aws ecs register-task-definition \ --region "${REGION}" \ --cli-input-json "${ARM64_TASK_DEF}" \ --query 'taskDefinition.taskDefinitionArn' \ --output text) echo "ARM64 task definition: ${NEW_TASK_DEF_ARN}" # 2-3: Update service echo "Updating service..." aws ecs update-service \ --cluster "${CLUSTER_NAME}" \ --service "${SERVICE_NAME}" \ --region "${REGION}" \ --task-definition "${NEW_TASK_DEF_ARN}" \ --enable-execute-command \ --capacity-provider-strategy \ capacityProvider=FARGATE_SPOT,weight=3,base=0 \ capacityProvider=FARGATE,weight=1,base=1 \ --force-new-deployment \ > /dev/null # 2-4: Wait for stability echo "Waiting for service to stabilize..." aws ecs wait services-stable \ --cluster "${CLUSTER_NAME}" \ --services "${SERVICE_NAME}" \ --region "${REGION}" # ───────────────────────────────────────── # Final Verification # ───────────────────────────────────────── echo "" echo ">>> Verifying deployment results" SERVICE_INFO=$(aws ecs describe-services \ --cluster "${CLUSTER_NAME}" \ --services "${SERVICE_NAME}" \ --region "${REGION}" \ --query 'services[0]') echo "Service status : $(echo ${SERVICE_INFO} | jq -r '.status')" echo "Running count : $(echo ${SERVICE_INFO} | jq -r '.runningCount')" echo "Desired count : $(echo ${SERVICE_INFO} | jq -r '.desiredCount')" echo "ECS Exec enabled : $(echo ${SERVICE_INFO} | jq -r '.enableExecuteCommand')" echo "Capacity strategy : $(echo ${SERVICE_INFO} | jq -c '.capacityProviderStrategy')" echo "Task definition : $(echo ${SERVICE_INFO} | jq -r '.taskDefinition')" echo "" echo "========================================" echo " Deployment Complete!" echo "========================================" echo "" echo "ECS Exec usage example:" echo " TASK_ARN=\$(aws ecs list-tasks --cluster ${CLUSTER_NAME} --service-name ${SERVICE_NAME} --query 'taskArns[0]' --output text)" echo " aws ecs execute-command --cluster ${CLUSTER_NAME} --task \${TASK_ARN} --container ${APP_NAME} --interactive --command '/bin/sh'" ``` --- ## Troubleshooting ### ECS Exec Not Working ```bash #!/bin/bash # troubleshoot-ecs-exec.sh CLUSTER_NAME="${1}" SERVICE_NAME="${2}" REGION="${3:-ap-northeast-1}" echo "=== ECS Exec Diagnostics ===" # Get task ARN TASK_ARN=$(aws ecs list-tasks \ --cluster "${CLUSTER_NAME}" \ --service-name "${SERVICE_NAME}" \ --region "${REGION}" \ --query 'taskArns[0]' \ --output text) echo "Target task: ${TASK_ARN}" # Check prerequisites with amazon-ecs-exec-checker echo "" echo "Checking prerequisites..." TASK_ID="${TASK_ARN##*/}" # Verify task role TASK_DEF_ARN=$(aws ecs describe-tasks \ --cluster "${CLUSTER_NAME}" \ --tasks "${TASK_ARN}" \ --region "${REGION}" \ --query 'tasks[0].taskDefinitionArn' \ --output text) TASK_ROLE_ARN=$(aws ecs describe-task-definition \ --task-definition "${TASK_DEF_ARN}" \ --region "${REGION}" \ --query 'taskDefinition.taskRoleArn' \ --output text) echo "Task role: ${TASK_ROLE_ARN}" # Check ssmmessages permissions echo "" echo "Checking ssmmessages permissions..." aws iam simulate-principal-policy \ --policy-source-arn "${TASK_ROLE_ARN}" \ --action-names \ ssmmessages:CreateControlChannel \ ssmmessages:CreateDataChannel \ ssmmessages:OpenControlChannel \ ssmmessages:OpenDataChannel \ --query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \ --output table # Verify ECS Exec is enabled EXEC_ENABLED=$(aws ecs describe-services \ --cluster "${CLUSTER_NAME}" \ --services "${SERVICE_NAME}" \ --region "${REGION}" \ --query 'services[0].enableExecuteCommand' \ --output text) echo "" echo "ECS Exec enabled: ${EXEC_ENABLED}" if [[ "${EXEC_ENABLED}" == "false" ]]; then echo "WARNING: ECS Exec is disabled. Enabling..." aws ecs update-service \ --cluster "${CLUSTER_NAME}" \ --service "${SERVICE_NAME}" \ --region "${REGION}" \ --enable-execute-command \ --force-new-deployment fi ``` ### Fargate Spot Interruption Handling ```bash # Monitor Spot interruption events aws events put-rule \ --name "fargate-spot-interruption" \ --event-pattern '{ "source": ["aws.ecs"], "detail-type": ["ECS Task State Change"], "detail": { "stopCode": ["SpotInterruption"] } }' \ --state ENABLED \ --region "${REGION}" ``` --- ## Key Points Summary | Feature | Stage 1 (CloudFormation) | Stage 2 (AWS CLI) | |---|---|---| | ECS Cluster | Created | Capacity provider update | | Task Definition | x86_64 | ARM64 re-registration | | ECS Service | Created (Exec disabled) | Exec enabled | | Fargate Spot | Not configured | Capacity strategy set | | IAM Roles | Created | No change required | By using this two-stage deployment process, you can leverage ECS Express Mode's simplicity while applying advanced configurations such as ARM64, Fargate Spot, and ECS Exec.
2026.02.11

This page has been translated by machine translation. View original

ECS Express Mode is a convenient feature that allows you to quickly build environments with minimal configuration, but features such as ARM64 (Graviton), Fargate Spot, and ECS Exec cannot be configured and used at creation time.

This time, we had an opportunity to attempt enabling these features in a development environment that adopted ECS Express Mode in order to improve costs and debugging efficiency, so we will introduce the steps involved.

https://dev.classmethod.jp/articles/ecs-express-mode-custom-task-definition/

Prerequisites

  • A production container image built with the ARM64 (aarch64) architecture must already be registered in ECR
  • The initial setup stack for ECS Express Mode (TaskExecutionRole, InfrastructureRole, etc.) must already be deployed

Challenge: Express Mode Constraints

ECS Express Mode (AWS::ECS::ExpressGatewayService) is a high-level component that internally auto-generates and manages low-level resources such as task definitions, services, ALBs, and Auto Scaling. Therefore, properties such as runtimePlatform and capacityProviderStrategy that can be specified in regular CloudFormation do not exist in Express Mode templates.

The architecture is fixed to x86_64 and cannot be changed through the update-express-gateway-service API either.

To work around this constraint, we implemented the following two-stage deployment.

  1. Dummy deployment: Create a stack with an x86 dummy image that has the same port and path as production (suppress startup with task count 0).
  2. Architecture conversion: Edit the auto-generated task definition to ARM64 via CLI and apply it with update-service.

Step 1: Initial Stack Creation with CloudFormation

First, we used an x86 dummy image to successfully deploy the resources managed by Express Mode.

Verification Code (CloudFormation Template)

  • To use ECS Exec, IAM for SSM was configured at the time of placing the initial template.
  • MinTaskCount: 0 is specified to prevent task startup with the dummy image. If set to MinTaskCount: 1, the circuit breaker may trigger and cause a rollback loop if the dummy image fails the health check.
Full initial template
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode - For initial setup (x86 dummy)'

Parameters:
  ServiceName:
    Type: String
    Default: 'my-app-dev'
  InitialStackName:
    Type: String
    Default: 'ecs-express-initial'

Resources:
  ECSLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/aws/ecs/default/${ServiceName}-service'
      RetentionInDays: 14

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-task-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ECSExecPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ssmmessages:CreateControlChannel
                  - ssmmessages:CreateDataChannel
                  - ssmmessages:OpenControlChannel
                  - ssmmessages:OpenDataChannel
                Resource: '*'

  ExpressModeService:
    Type: AWS::ECS::ExpressGatewayService
    DependsOn: ECSLogGroup
    Properties:
      ServiceName: !Sub '${ServiceName}-service'
      Cluster: 'default'
      ExecutionRoleArn:
        Fn::ImportValue: !Sub '${InitialStackName}-TaskExecutionRoleArn'
      InfrastructureRoleArn:
        Fn::ImportValue: !Sub '${InitialStackName}-InfrastructureRoleArn'
      TaskRoleArn: !GetAtt TaskRole.Arn
      PrimaryContainer:
        Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/my-app:dummy'
        ContainerPort: 8000
        AwsLogsConfiguration:
          LogGroup: !Sub '/aws/ecs/default/${ServiceName}-service'
          LogStreamPrefix: 'ecs'
      NetworkConfiguration:
        Subnets:
          Fn::Split:
            - ','
            - Fn::ImportValue: !Sub '${InitialStackName}-PrivateSubnets'
      Cpu: '256'
      Memory: '512'
      HealthCheckPath: '/health'
      ScalingTarget:
        MinTaskCount: 0
        MaxTaskCount: 1
        AutoScalingMetric: 'AVERAGE_CPU'
        AutoScalingTargetValue: 70

Outputs:
  ServiceEndpoint:
    Value: !GetAtt ExpressModeService.Endpoint

We deployed this template and confirmed that the task definition's runtimePlatform was generated with the default x86_64. Since MinTaskCount: 0, no tasks start at this point.


Step 1.5: Disabling Bake Time

To shorten the working time before conversion, we set the deployment bake time to 0 in the management console.

  • ECS → Clusters → Services → Deployment configuration → Bake time: 0 minutes

https://dev.classmethod.jp/articles/ecs-experss-reduce-deployment-time/


Step 2: Converting the Task Definition to ARM64 and Applying It

We retrieved the task definition revision auto-generated by Express Mode, replaced cpuArchitecture with ARM64 and the image with the production ARM image, changed the CPU and memory to production specs, and registered it.

SERVICE_NAME="my-app-dev-service"
TD_FAMILY="default-${SERVICE_NAME}"
REGION="ap-northeast-1"
ARM_IMAGE="123456789012.dkr.ecr.$REGION.amazonaws.com/my-app:latest"

# 1. Retrieve the current task definition
LATEST=$(aws ecs list-task-definitions --family-prefix $TD_FAMILY --sort DESC \
    --region $REGION --query 'taskDefinitionArns[0]' --output text)

aws ecs describe-task-definition --task-definition $LATEST \
    --region $REGION --query 'taskDefinition' > /tmp/td.json

# 2. Change to ARM64 + production image (including deletion of metadata)
jq --arg img "$ARM_IMAGE" '
  .runtimePlatform.cpuArchitecture = "ARM64" |
  .containerDefinitions[0].image = $img |
  .cpu = "512" | .memory = "1024" |
  .containerDefinitions[0].cpu = 512 |
  .containerDefinitions[0].memoryReservation = 1024 |
  del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
      .compatibilities, .registeredAt, .registeredBy, .enableFaultInjection)
' /tmp/td.json > /tmp/td_arm.json

# 3. Register the new revision
aws ecs register-task-definition \
    --cli-input-json file:///tmp/td_arm.json \
    --region $REGION

# 4. Update the service
NEW_REV=$(aws ecs list-task-definitions --family-prefix $TD_FAMILY --sort DESC \
    --region $REGION --query 'taskDefinitionArns[0]' --output text)

aws ecs update-service \
    --cluster default \
    --service $SERVICE_NAME \
    --task-definition $NEW_REV \
    --force-new-deployment \
    --region $REGION

Verifying Behavior When Running update-stack

After switching to ARM64 via CLI, we ran CloudFormation update-stack and changed the image in the template to the production ARM64 image. This reduces the risk of exec format error even if a rollback occurs during subsequent CloudFormation operations, since the image referenced by the template will be the ARM64 version.

  • Running update-stack generated a new task definition revision.
  • We confirmed that cpuArchitecture within runtimePlatform was maintained as ARM64 in the new revision as well.
  • We confirmed that the update to the image specified in the template was reflected in the new revision.

We were able to confirm the behavior of inheriting architecture settings from the currently running task definition when updating the Express Mode stack.


Step 3: Enabling FARGATE_SPOT and ECS Exec

After converting to ARM64, we further enabled cost optimization and debugging features.

aws ecs update-service \
    --cluster default \
    --service my-app-dev-service \
    --capacity-provider-strategy capacityProvider=FARGATE_SPOT,base=0,weight=1 \
    --enable-execute-command \
    --force-new-deployment \
    --region ap-northeast-1

Verification

After the settings were applied, we started a task and verified each setting.

aws ecs update-service \
    --cluster default \
    --service my-app-dev-service \
    --desired-count 1 \
    --region ap-northeast-1

After the task started, we confirmed it was running on FARGATE_SPOT.

aws ecs describe-tasks \
    --cluster default \
    --tasks <task-id> \
    --query 'tasks[0].capacityProviderName' \
    --region ap-northeast-1
# => "FARGATE_SPOT"

We logged into the container using ECS Exec and confirmed it was running on ARM64. Since Express Mode automatically sets the container name to Main, we specify --container Main.

aws ecs execute-command \
    --cluster default \
    --task <task-id> \
    --container Main \
    --interactive \
    --command "/bin/sh"

# Run inside the container
$ uname -m
aarch64

aarch64 was displayed, confirming that it is running on ARM64 (Graviton).


Summary

Even with ECS Express Mode, it was possible to use ARM64, Fargate Spot, and ECS Exec by going through a two-stage deployment process. Setting MinTaskCount: 0 in the initial stack to prevent task startup with the dummy image, then changing to the required task count after the ARM64 conversion, is a safe deployment pattern.

However, since these steps are not explicitly documented in the official documentation at this time, there is a possibility that behavior may change due to future updates, interference from changes outside of IaC, or issues such as drift. We recommend limiting use to environments where you can take personal responsibility, such as development and verification environments.

Share this article

AWSのお困り事はクラスメソッドへ