ECS内のコンテナに、セキュリティを確保しつつログインできるようにするための構成

はじめに

この記事はプレセナ・ストラテジック・パートナーズアドベントカレンダー2025の2日目の記事です。 インフラチームのにしざわです。ECSコンテナでRails consoleやデバッグ作業を行うためのセキュアなインフラ構成について紹介します。

背景

ECSで動いているコンテナにログインするには、ECS Execすれば良いです。 しかし、コンテナのファイルシステムを書き込み可能にしておく必要があり、セキュリティが犠牲になります。

そこで、セキュリティを担保しつつ、コンテナに手軽に入れるように模索しました。また、もともとHeroku から移行したシステムなので、heroku runコマンドくらい手軽にコンテナに入りたいという希望もありました。

ECSの構成

通常、本番環境で稼働しているECSコンテナは、セキュリティ上の理由から読み取り専用のルートファイルシステム(readonlyRootFilesystem: true)で動作させるのが望ましいです。しかし、ECS Execには制限があり*1readonlyRootFilesystem: trueの環境では使用できません。

そこで、以下の2つのタスク定義を用意する構成を採用しました:

  • 通常のタスク定義: readonlyRootFilesystem: trueでセキュリティを確保
  • メンテナンス用タスク定義: readonlyRootFilesystem: falseでメンテナンス作業を可能にする

メンテナンス用タスクは必要な時だけ手動で起動し、作業終了後は自動的に停止することで、費用を抑えます。

メンテナンス用タスク定義

AWSリソースはterrform管理しており、メンテナンス用のタスク定義は以下のように定義されています:

resource "aws_ecs_task_definition" "maintenance" {
  family                   = "maintenance-${var.stage}"
  cpu                      = "512"
  memory                   = "1024"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  # ...
  container_definitions = jsonencode(
    [
      {
        image                  = "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app-production:v2025.8.0"
        readonlyRootFilesystem = false # シェルに入れるように書き込み可にしておく
        name                   = "app"
        # ...
        # 何もしない
        command = [
          "tail",
          "-f",
          "/dev/null"
        ]
        # ...
      },
      # ...
    ]
  )
  volume {
    name = "tmp-root"
  }
}

ポイントは以下の3点です:

  1. readonlyRootFilesystem = false: ECS Execを利用するために必要な設定。ファイルシステムへの書き込みを許可します
  2. command = ["tail", "-f", "/dev/null"]: コンテナを何もせずに待機状態にします
  3. 本番と同じイメージを使用: 実際の本番環境と同じコードベース・同じバージョンでメンテナンス作業ができます。デプロイ時にメンテナンス用のタスク定義を更新していく必要がありますが、それについては後述します。

ネットワーク設定について

メンテナンス用タスクは、通常のアプリケーションタスクと同じサブネット・セキュリティグループで起動します。これにより、RDSやElastiCacheなど、本番環境と同じリソースにアクセスできます。

メンテナンス用タスク定義を利用したECS Exec

実際にメンテナンスタスクに入るための手順を自動化するためのスクリプト login_to_ecs_container_production.sh を用意しました。

# 1. メンテナンス用タスクを起動
TASK_ID=`aws ecs run-task \
--cluster app-production \
--task-definition "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/maintenance-production" \
--network-configuration "awsvpcConfiguration={subnets=[subnet-0123456789abcdef0],securityGroups=[sg-0123456789abcdef0],assignPublicIp=DISABLED}" \
--launch-type FARGATE \
--enable-execute-command \
| jq -r '.tasks[0].taskArn | split("/") | .[2]'`

# 2. タスクが起動するのを待つ
aws ecs wait tasks-running --cluster "app-production" --tasks $TASK_ID --profile $AWS_PROFILE

# 3. echo 20秒待つ
sleep 20

# 4. コンテナに入る
aws --profile $AWS_PROFILE ecs execute-command \
--region ap-northeast-1 --cluster app-production \
--task $TASK_ID --container app \
--command '/bin/bash' --interactive

# 5. 終了後、ECSタスクを停止する
aws ecs stop-task --profile $AWS_PROFILE --cluster app-production --task $TASK_ID

このスクリプトの実行フローは次のとおりです:

  1. メンテナンス用タスクを一時的に起動(--enable-execute-commandでECS Execを有効化)
  2. タスクが完全に起動するまで待機
  3. 20秒待つ。謎の待機時間に見えるかもしれませんが、コンテナは起動したがセッションマネージャーのエージェントが起動していない時間帯があるようです。秒数を調整した結果、この時間だけ待つことに落ち着いています。
  4. ECS Execでコンテナ内のbashに接続
  5. 作業終了後、自動的にタスクを停止

使い方

事前にAWS CLIの設定(MFA認証情報など)を済ませた上で、以下のコマンドを実行するだけです:

./login_to_ecs_container_production.sh

コンテナに入った後は、通常のシェル操作やRails consoleの実行が可能です:

bundle exec rails console

作業が終わってシェルを抜けると、スクリプトが自動的にタスクを停止してくれます。

メンテナンス用タスク定義が参照するイメージの更新

アプリケーションをデプロイする際、メンテナンス用タスク定義も同じバージョンのイメージを参照するよう更新する必要があります。そうしないと、新たに追加したActiveRecordモデルを読み込めなかったり、古いロジックが動いてしまうなど、不都合が多くあります。

CodeBuildのbuildspec.yamlで、アプリケーションのデプロイと同時にメンテナンス用タスク定義を更新しています:

  post_build:
    commands:
      # $IMAGE_URI_APP, $IMAGE_URI_FLUENT_BIT, $ECS_TASK_DEFINITION_WEB_ARN はcodebuildの環境変数として登録してある
      # imagedefinitions.json は標準ECSデプロイ(worker)で利用
      # imageDetail.json および taskdef.json はblue/greenデプロイ(web)で利用
      - printf '[{"name":"worker","imageUri":"%s:%s"},{"name":"log_router","imageUri":"%s:%s"}]' $IMAGE_URI_APP $TAG_NAME $IMAGE_URI_FLUENT_BIT $TAG_NAME > imagedefinitions.json
      - printf '{"Version":"1.0","ImageURI":"%s:%s"}' $IMAGE_URI_APP $TAG_NAME > imageDetail.json
      - aws ecs describe-task-definition --task-definition $ECS_TASK_DEFINITION_WEB_ARN --query taskDefinition | jq '.containerDefinitions[0].image="<IMAGE1_NAME>"' > taskdef.json
      # メンテナンス用タスク定義のイメージURI更新
      - aws ecs describe-task-definition --task-definition ${MAINTENANCE_ECS_TASK_NAME}
        | jq '.taskDefinition | del(.taskDefinitionArn, .status, .requiresAttributes, .compatibilities, .revision, .registeredBy, .registeredAt)'
        | jq '(.containerDefinitions[] | select(.name == "app")).image = "'${IMAGE_URI_APP}':'${TAG_NAME}'"' > /tmp/maintenance-taskdef.json
      - aws ecs register-task-definition --family ${MAINTENANCE_ECS_TASK_NAME} --cli-input-json file:///tmp/maintenance-taskdef.json

# メンテナンス用タスク定義のイメージURI更新 でやっていることは、以下の通りです。

  1. 最新のタスク定義を取得
  2. イメージのURIを更新。イメージはアプリケーションと共有のもの。
  3. 更新したタスク定義のjsonを登録

少し複雑ですが*2、これで稼働中のアプリケーションのバージョンと一致したメンテナンスコンテナを参照するタスク定義を作れます。

この構成のメリデメ

この構成により、以下のメリットがあります。

  • セキュリティの確保: 通常稼働中のコンテナは読み取り専用のまま維持
  • メンテナンスの容易さ: 必要な時だけ書き込み可能なコンテナで作業できる
  • 自動化: タスクの起動・停止・ログインが完全にスクリプト化され、作業後の停止忘れを防止
  • 監査性: ECS Execのセッションログは自動的にCloudWatch LogsやS3に記録され、誰がいつ何をしたか追跡可能(正直言ってこれは当初目的としていませんでしたが、当記事をChatGPTに壁打ちしながら執筆する中で出てきて、なるほどと思いました)
  • 低コスト: Fargateタスクを必要な時だけ起動するため、コストは数円程度

デメリットは、管理するECSタスク定義が増えることが挙げられます。

代替案との比較

従来の方法として、Session Managerで踏み台EC2経由でコンテナにアクセスする方法もあります。しかし、この方法では、以下の課題があります。

  • EC2のセキュリティパッチ適用などの運用負荷がある
  • コンテナへのアクセス手順が複雑(EC2起動→EC2にログイン→docker execなど)

したがって、メンテナンス用タスク定義を使ったECS Execの方が、よりシンプルかつ低コストで運用できます。

まとめ

この記事では、ECS内のコンテナに、セキュリティを確保しつつログインできるようにするための構成を紹介しました。 ECSでRails consoleなどのメンテナンス作業が必要な場合、このようなメンテナンス用タスク定義を用意する方法をぜひ検討してみてください。

*1:"SSM エージェントは、必要なディレクトリやファイルを作成するために、コンテナのファイルシステムに書き込みができる必要があります。したがって、readonlyRootFilesystemタスク定義パラメータ、またはその他の方法を使ってルートファイルシステムを読み取り専用にすることは、サポートされません。"という記述があります。

*2:もうちょっとスマートな方法がないのか?と思いますが。。。