CloudFormation で ECS Express Mode のカスタムタスク定義に readonlyRootFilesystem + tmpfs を設定してみた

CloudFormation で ECS Express Mode のカスタムタスク定義に readonlyRootFilesystem + tmpfs を設定してみた

ECS Express Mode のカスタムタスク定義を CloudFormation からも参照できるようになり、readonlyRootFilesystem や tmpfs を IaC として宣言的に設定しやすくなりました。ARM64 環境で実際にデプロイし、ECS Exec を確認用途として使った検証手順を紹介します。
2026.07.12

はじめに

2026年7月、ECS Express Mode がカスタムタスク定義に対応しました。

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

上記の記事では CLI で Express Mode サービスを作成しました。今回、CloudFormation もカスタムタスク定義をサポートしたことを確認できたので、readonlyRootFilesystem や tmpfs を宣言的に設定する手順を紹介します。

検証内容

検証環境

項目
リージョン us-west-2 (Oregon)
スタック名 express-nginx-readonly-v2
リソースタイプ AWS::ECS::ExpressGatewayService
タスク定義 AWS::ECS::TaskDefinition(カスタム)
CPU / Memory 0.25 vCPU / 512 MiB
アーキテクチャ ARM64
コンテナ nginx:1.27.5-alpine ベースのカスタムイメージ(ECR、8080 で listen・/health 応答)

CloudFormation テンプレート

readonlyRootFilesystem + tmpfs

コンテナ定義で ReadonlyRootFilesystem: true を設定し、書き込みが必要なパスに tmpfs をマウントします。

      ContainerDefinitions:
        - Name: Main
          ReadonlyRootFilesystem: true
          LinuxParameters:
            Tmpfs:
              - ContainerPath: /tmp
                Size: 128
                MountOptions:
                  - noexec
                  - nosuid
              - ContainerPath: /var/lib/amazon/ssm
                Size: 16
                MountOptions:
                  - nosuid
              - ContainerPath: /var/log/amazon/ssm
                Size: 16
                MountOptions:
                  - nosuid

本検証では ECS Exec を確認用途に使うため、SSM core agent の書き込み先として /var/lib/amazon/ssm/var/log/amazon/ssm に tmpfs をマウントしました。なお、公式ドキュメントでは ECS Exec と readonlyRootFilesystem の組み合わせはサポート対象外とされています。ECS Exec を使わない場合、これら 2 パスの tmpfs は不要です。Fargate の tmpfs サポートの詳細については以下の記事を参照してください。

https://dev.classmethod.jp/articles/ecs-fargate-tmpfs-support/

ARM64

Apple シリコンでビルドしたイメージをそのまま利用するため、RuntimePlatform で ARM64 を指定しました。

      RuntimePlatform:
        CpuArchitecture: ARM64
        OperatingSystemFamily: LINUX

IAM ロール 3 種(Execution / Task / Infrastructure)はスタック内に定義しています。詳細はテンプレート全文を参照してください。

カスタムタスク定義の紐付け

AWS::ECS::ExpressGatewayServiceTaskDefinitionArn!Ref CustomTaskDefinition を指定します。

  ExpressModeService:
    Type: AWS::ECS::ExpressGatewayService
    Properties:
      ServiceName: !Sub '${ServiceName}-service'
      Cluster: 'default'
      InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
      TaskDefinitionArn: !Ref CustomTaskDefinition
      HealthCheckPath: '/health'
テンプレート全文(クリックで展開)
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode + readonlyRootFilesystem + tmpfs - 完全自己完結型 ARM64 版'

Parameters:
  ServiceName:
    Type: String
    Default: 'express-nginx-readonly'

Resources:
  # ===== IAM Roles =====

  # ExecutionRole: ECSがイメージpull・ログ送信に使用
  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-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

  # TaskRole: コンテナ内アプリが使用(ECS Exec用)
  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: '*'

  # InfrastructureRole: ECS Express Gatewayサービス管理用
  InfrastructureRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-infra-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowAccessInfrastructureForECSExpressServices
            Effect: Allow
            Principal:
              Service: ecs.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices

  # ===== ECS Resources =====

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

  CustomTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub '${ServiceName}'
      RequiresCompatibilities:
        - FARGATE
      NetworkMode: awsvpc
      Cpu: '256'
      Memory: '512'
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      RuntimePlatform:
        CpuArchitecture: ARM64
        OperatingSystemFamily: LINUX
      ContainerDefinitions:
        - Name: Main
          Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/express-mode-nginx-tmpfs:latest'
          Essential: true
          ReadonlyRootFilesystem: true
          PortMappings:
            - ContainerPort: 8080
              Protocol: tcp
              Name: http
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Sub '/aws/ecs/default/${ServiceName}-service'
              awslogs-region: !Ref 'AWS::Region'
              awslogs-stream-prefix: ecs
          HealthCheck:
            Command:
              - CMD-SHELL
              - 'wget -qO- http://localhost:8080/health || exit 1'
            Interval: 15
            Timeout: 5
            Retries: 3
            StartPeriod: 10
          LinuxParameters:
            Tmpfs:
              - ContainerPath: /tmp
                Size: 128
                MountOptions:
                  - noexec
                  - nosuid
              - ContainerPath: /var/lib/amazon/ssm
                Size: 16
                MountOptions:
                  - nosuid
              - ContainerPath: /var/log/amazon/ssm
                Size: 16
                MountOptions:
                  - nosuid

  ExpressModeService:
    Type: AWS::ECS::ExpressGatewayService
    DependsOn: ECSLogGroup
    Properties:
      ServiceName: !Sub '${ServiceName}-service'
      Cluster: 'default'
      InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
      TaskDefinitionArn: !Ref CustomTaskDefinition
      HealthCheckPath: '/health'
      NetworkConfiguration:
        Subnets:
          - subnet-xxxxxxxxxxxxxxxxx
          - subnet-xxxxxxxxxxxxxxxxx
          - subnet-xxxxxxxxxxxxxxxxx
      ScalingTarget:
        MinTaskCount: 1
        MaxTaskCount: 1
        AutoScalingMetric: 'AVERAGE_CPU'
        AutoScalingTargetValue: 70

Outputs:
  ServiceEndpoint:
    Value: !GetAtt ExpressModeService.Endpoint
  TaskDefinitionArn:
    Value: !Ref CustomTaskDefinition

デプロイ

aws cloudformation deploy \
  --stack-name express-nginx-readonly-v2 \
  --template-file express-nginx-readonly-standalone.yaml \
  --parameter-overrides ServiceName=express-nginx-readonly-v2 \
  --capabilities CAPABILITY_NAMED_IAM \
  --region us-west-2

スタックのステータスが CREATE_COMPLETE になればデプロイ完了です。

設定確認のための準備(CLI)

デプロイされた構成を内部から確認するため、ECS Exec の有効化とベイクタイムの短縮を行いました。いずれもデプロイ済みの設定には影響しません。

ECS Exec の有効化 + ベイクタイムの短縮

コンテナ内部から mountuname で設定を確認するために ECS Exec を有効化します。あわせて、検証の待機時間を短縮するためベイクタイムも変更しました。Express Mode のデフォルトでは Canary ベイクタイム 3 分 + デプロイベイクタイム 3 分が設定されているため、いずれも 0 に設定しています。本番環境では適切なベイクタイムで運用することをおすすめします。

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

aws ecs update-service \
  --cluster default \
  --service express-nginx-readonly-v2-service \
  --enable-execute-command \
  --deployment-configuration '{
    "strategy": "CANARY",
    "bakeTimeInMinutes": 0,
    "canaryConfiguration": {
      "canaryPercent": 5.0,
      "canaryBakeTimeInMinutes": 0
    }
  }' \
  --region us-west-2

この update-service によりデプロイがトリガーされ、新しいタスクが起動します。ECS Exec は既存タスクには反映されないため、新しいタスクが RUNNING になってから execute-command を実行します。

動作確認(ECS Exec)

ヘルスチェック

$ curl -s "https://ex-xxxxxxxx.ecs.us-west-2.on.aws/health"
ok

ルートファイルシステムの読み取り専用確認

$ aws ecs execute-command --cluster default \
  --task <task-id> --container Main \
  --command "touch /test.txt" --interactive

touch: /test.txt: Read-only file system

tmpfs への書き込み確認

$ aws ecs execute-command --cluster default \
  --task <task-id> --container Main \
  --command "sh -c 'echo test > /tmp/test.txt && cat /tmp/test.txt'" --interactive

test

tmpfs のサイズとマウントオプション

$ aws ecs execute-command --cluster default \
  --task <task-id> --container Main \
  --command "df -h /tmp" --interactive

Filesystem                Size      Used Available Use% Mounted on
tmpfs                   128.0M    280.0K    127.7M   0% /tmp
$ aws ecs execute-command --cluster default \
  --task <task-id> --container Main \
  --command "mount | grep tmpfs" --interactive

tmpfs on /tmp type tmpfs (rw,nosuid,noexec,relatime,size=131072k)
tmpfs on /var/lib/amazon/ssm type tmpfs (rw,nosuid,relatime,size=16384k)
tmpfs on /var/log/amazon/ssm type tmpfs (rw,nosuid,relatime,size=16384k)

3 箇所の tmpfs がそれぞれ指定したサイズで構成されています。マウントオプションは /tmpnoexec,nosuid、SSM 用の 2 箇所は nosuid です。

アーキテクチャ確認

$ aws ecs execute-command --cluster default \
  --task <task-id> --container Main \
  --command "uname -m" --interactive

aarch64

uname -maarch64 であり、RuntimePlatform で指定したとおり ARM64 環境で動作していることを確認できました。

結果まとめ

確認項目 結果
ヘルスチェック(/health) ok
ルート書き込み拒否 Read-only file system
tmpfs 書き込み(/tmp) 成功
tmpfs サイズ 128MB / 16MB / 16MB
tmpfs マウントオプション /tmp: noexec,nosuid / SSM 用: nosuid
アーキテクチャ aarch64(ARM64)

SecurityHub 検知への対応

ECS.5(readonlyRootFilesystem)

Security Hub の ECS.5 コントロールは、タスク定義の readonlyRootFilesystemtrue に設定されているかを評価します。本構成ではカスタムタスク定義に ReadonlyRootFilesystem: true を宣言しており、タスク定義上は ECS.5 の評価条件を満たしています。

$ aws ecs describe-task-definition \
  --task-definition express-nginx-readonly-v2 \
  --query 'taskDefinition.containerDefinitions[?name==`Main`].readonlyRootFilesystem' \
  --region us-west-2

[true]

readonlyRootFilesystem を有効化する際は、アプリケーションが書き込みを必要とするパスや必要なリソースを事前に評価することをおすすめします。

ACM.1(証明書の有効期限管理)

Express Mode はサービス作成時に ex-XXX.ecs.<region>.on.aws 形式のドメインと ACM 証明書を自動発行します。この証明書は DNS 検証完了前(PENDING_VALIDATION)の段階では有効期限などのデータが取得できません。その結果、Security Hub の ACM.1 が Certificate data could not be retrieved として FAILED を検知することがあります。

検証環境でクラスタの作成・削除を繰り返すと、短命な証明書がこの状態で評価に当たりやすく、検知が発生します。この証明書は AmazonECSManaged: true タグ付きの AWS 管理証明書で、ユーザー側で更新・制御できないため、リソース削除後は NOT_AVAILABLE へ遷移し、Security Hub の対象から外れますが、検知→通知の仕組みがある場合、ACM.1 の抑制もご検討ください。

まとめ

ECS Express Mode のカスタムタスク定義対応により、CloudFormation で定義した AWS::ECS::TaskDefinitionAWS::ECS::ExpressGatewayService から参照できるようになりました。

これにより、readonlyRootFilesystem、tmpfs、ARM64 などのタスク定義に関する設定を IaC として宣言し、サービス作成時点から反映できます。従来のように、サービス作成後にタスク定義を差し替える必要はありません。IaC 管理外で変更する手順も不要です。

なお、ECS Exec の有効化など CloudFormation リソースにプロパティがない項目については、現時点では CLI による設定が必要ですが、今後のサポートを期待したいと思います。

この記事をシェアする

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

関連記事