wan0ri Lab

ECS×EFS: 片方向SGでコンテナが起動しない事象の再現と解消

ECSコンテナが急にEFSへ接続できなくなった件

TL;DR

  • EFS は各 AZ アベイラビリティゾーン。リージョン内の独立区画。 に「マウントターゲット(ENI Elastic Network Interface。仮想NIC。 )」を持つ。
    • 例: ap-northeast-1a/1c にそれぞれ固定プライベート IP を持つ ENI が存在。
  • ECS タスクは、配置された AZ に近い EFS マウントターゲットへ NFS EFS が使うファイル共有プロトコル。標準ポート 2049/TCP。 で接続する。
    • つまり、1a に居れば 1a の ENI、1c に居れば 1c の ENI へ向かうのが基本動作。
  • 今回の原因は「コンテナ側 SG セキュリティグループ。VPC の仮想ファイアウォール(stateful)。 のアウトバウンドが片側の IP/CIDR のみ許可」だったこと。別 AZ の ENI への 2049/TCP がブロックされ、マウント失敗。
    • デプロイ時に「許可されていない側の AZ/IP」への経路になり、NFS 接続不可 → タスク起動失敗。
  • 対策は「SG のアウトバウンドに“抜けていた側のサブネット CIDR”を追加」し、両 AZ のマウントターゲット到達を許可。

概要

参画先で、ECS にデプロイしたコンテナが「EFS に接続できない」エラーで起動に失敗しました。
この記事では、

  • なぜ発生したのか
  • 対策内容
  • 自分の AWS アカウントでの再現と検証

をまとめ、個人環境で事象が実現可能な状態にします。


なぜ起きたのか?

EFS の構造

  • EFS はリージョン冗長だが、VPC 内では各 AZ に 1 つの「マウントターゲット(ENI)」を作成し、固定プライベート IP が割り当てられる。
  • クライアント(ECS タスク等)は通常、自分が動作する AZ のマウントターゲットに接続(最短経路・低レイテンシ)。

DNS の解決と接続先の選択

  • fs-XXXX.efs.ap-northeast-1.amazonaws.com のような EFSDNS 名前解決により接続先 IP を得る仕組み。 を引くと、同一 AZ の ENI の IP に解決される(同一 AZ 優先)。
  • したがって、タスクの配置 AZ によって接続先 IP が変わるのは正常挙動。

SG アウトバウンドの制約が“AZ またぎ”を阻害

  • 送信側 SG のアウトバウンドを片側の CIDR(または IP)だけに絞ったため、別 AZ のマウントターゲット IP への 2049/TCP が拒否。
  • ECS は指定サブネット群のいずれかにタスクを起動するため、「許可していない側」に出たタスクのマウント処理が失敗した。

正しい設計/ベストプラクティス

1. SG は stateful を活かす

  • EFSSG はインバウンド 2049/TCP の送信元に「ECS タスクの SG」を指定する。
  • ECSSG はアウトバウンドを広めに(例: 0.0.0.0/02049/TCP)して、AZ/IP 変動に強くする。
  • 片側 IP のピンポイント許可は避ける。

2. CIDR で絞るなら“両 AZ 分を必ず許可”

  • 両方のサブネット CIDR をアウトバウンドに含め、どちら AZ でも到達可能にする。

3. EFS の SG は“参照ベース”で許可

  • EFS 側のインバウンドに「ソース SG(= ECS タスクの SG)」を指定し、IP/CIDR 管理を不要化。
  • 送信側では SG 参照が使えないため、ECSSG は適切に広げる。

4. NACL を過度に絞りすぎない

  • NACL Network ACL。サブネット境界のステートレスフィルタ。 を厳しくしすぎると、SG が正しくても通信は失敗する。VPC レイヤの整合性を取る。

5. タスク配置方針とネットワーク設計の整合

  • 特定 AZ 固定は高可用性が下がる。HA を優先するなら AZ 横断で許可が成立する設計にする。

同様の事象が発生しやすい AWS サービス

「複数 AZ にエンドポイント(ENI/IP)があり、クライアントが同一 AZ を優先して接続する」タイプは同様のハマり方をしがち。片側 AZ や一部 IP のみ許可は危険。

Amazon EFS(今回)

  • AZ のマウントターゲット ENINFS(2049/TCP) で接続(AZ ごとに到達先 IP が異なる)。

Amazon RDS(特に Multi-AZ / フェイルオーバー)

  • クライアントは RDS のエンドポイント(DNS)へ接続するが、裏側 IP はフェイルオーバーや再配置で変わりうる。
  • 特定 IP 固定は切替に弱いため、CIDR/SG 参照ベースが望ましい。

Network Load Balancer(NLB)/ Gateway Load Balancer(GWLB)

  • 内部 NLB は各 AZENI(IP)を持つ。AZ により指す IP が変わるため、特定 IP のみ許可だと接続不可に。
  • AZ にエンドポイント ENI ができ、その IP へ接続。片側 AZ のみ許可だと別 AZ のタスクは到達不可になり得る。

Amazon OpenSearch Service / ElastiCache / Redshift など

  • クラスター/ノードごとに ENI/IP が存在し、AZ 配置で接続先が変わる/増える。IP 固定許可は運用負荷・切替リスクを高めるため、SG 参照や CIDR ベースが安全。

Application Load Balancer(ALB)

  • ALB は各 AZ にロードバランサーノードを持ち、IP が変動。ターゲット側は「ソースを ALBSG 参照」で許可するのがベスト。IP 固定許可は非推奨。

実践的な再発防止策(チェックリスト)

1. EFS

  • EFSSG: Inbound 2049/TCP Source = 「ECS タスクの SG
  • ECSSG: Outbound 2049/TCP Destination = 0.0.0.0/0(または両 AZ のサブネット CIDR
  • NACL: 双方向で 2049/TCP を許可(過度に絞らない)

2. 共通原則

  • アウトバウンド宛先を「特定 IP 固定」にしない(AZ/フェイルオーバーに弱い)
  • SG の「SG 参照(ソース SG 指定)」を活用し、IP 管理をなくす
  • DNS 名の背後 IP は変わりうる前提で設計(RDS/ALB/NLB/VPC Endpoint 等)

3. 運用設計

  • 新規 AZ 追加やサブネット増設に合わせて SG ルールの棚卸しを定期実施
  • 監視(CloudWatch Logs / VPC Flow Logs)で拒否イベントを即検知
  • IaC(Terraform/CloudFormation)で SG 設定を標準化し、「片側のみ許可」の事故を防止

今回の止血対応は**「ECS タスクの SG に対し両サブネットを許可」**。
今後はエンドユーザーと設計方針をすり合わせる前提です。


自アカウントでの検証

同様の事象を自分の AWS アカウントで再現・改善しました。

検証リポジトリ:

検証の詳細(issue 深掘り)

以下の issue に再現ログと時系列をまとめています:

ここでは、要点を文章で深掘りします。

  • 仮説の立て方: 「EFS の接続先はタスク配置 AZ に依存」→「送信側 SG のアウトバウンドが AZ 片側だけだと、もう一方のマウントターゲットに到達できず失敗する」。
  • 失敗の作り込み: サービスを 1c に配置、送信側 SG1a のサブネット CIDR のみ許可。起動時に stoppedReasonfailed to invoke EFS utils ... timeout ... code: 32 が出力。これは efs-utils EFS のマウントを支援するユーティリティ。DNS 解決や TLS 設定を内部で行う。 2049/TCP で応答を得られずタイムアウトした典型パターン。
  • 切り分け: EFSSG は「インバウンド 2049/TCP のソースにタスク SG」を設定済みであるため、今回は送信側(タスク)のアウトバウンド制約がボトルネック。authorization_config.iam = "ENABLED" かつ Access Point を使っており、IAM 権限不足の典型(Permission denied)ではない点も判断材料。
  • DNS の挙動確認: タスクのある AZ から fs-xxxx.efs.ap-northeast-1.amazonaws.com を引くと、同一 AZ のマウントターゲット IP に解決される。よって、タスクの配置が変わると接続先 IP も変わる。
  • 修正の最小差分: 送信側 SG のアウトバウンドに「もう片方の AZ のサブネット CIDR」を追加。もしくは 2049/TCP だけ 0.0.0.0/0 へ広げる。後者は 設計が単純になり運用耐性が上がる 一方、ポリシーによっては前者(両 AZCIDR 明記)を選ぶケースもあり。
  • 結果: どちらの AZ にタスクが出ても NFS マウントが成功し、RUNNING へ遷移。CloudWatch Logs に /datals 結果が出力されることを確認。
  • 補助的な観測ポイント: VPC Flow Logs を有効化している場合、失敗ケースでは 2049/TCPREJECT を確認可能。成功後は ACCEPT に切り替わる。
補助コマンド(環境に合わせて置換)
# EFS マウントターゲットの IP と AZ を把握
aws efs describe-mount-targets \
  --file-system-id fs-XXXXXXXX \
  --query 'MountTargets[].{AZ:AvailabilityZoneName,Subnet:SubnetId,IP:IpAddress}' --output table

# タスク配置の確認
aws ecs describe-tasks --cluster ecs-validate --tasks <TASK_ARN> \
  --query 'tasks[0].[lastStatus,attachments[0].details[?name==`subnetId`].value|[0]]' --output table

# SG アウトバウンドの見直し例(方針A: 0.0.0.0/0:2049 を許可)
# 方針B は両AZのサブネットCIDRを個別に許可
検証構成のポイント(Terraform 抜粋)
module "security" {
  source = "./modules/security"
  vpc_id = module.network.vpc_id

  lockdown_mode      = true
  allow_both_azs     = false
  allow_https_egress = true
  use_efs_mt_ips     = true
  efs_mt_ips         = module.efs[0].mount_target_ips
}

resource "aws_ecs_task_definition" "this" {
  family                   = var.name
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu = "256"; memory = "512"
  execution_role_arn = var.execution_role_arn
  task_role_arn      = var.task_role_arn

  container_definitions = jsonencode([
    {
      name = "app"
      image = "public.ecr.aws/docker/library/busybox:latest"
      command = ["sh","-c","echo start && ls -al /data && sleep 3600"]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-group = var.log_group_name
          awslogs-region = var.region
          awslogs-stream-prefix = "ecs"
        }
      }
      mountPoints = [{ containerPath = "/data", sourceVolume = "efs_data" }]
    }
  ])

  volume {
    name = "efs_data"
    efs_volume_configuration {
      file_system_id     = var.efs_file_system_id
      transit_encryption = "ENABLED"
      authorization_config {
        access_point_id = var.efs_access_point_id
        iam             = "ENABLED"
      }
    }
  }
}

data "aws_iam_policy_document" "task_efs" {
  statement {
    actions   = ["elasticfilesystem:ClientMount","elasticfilesystem:ClientWrite","elasticfilesystem:ClientRootAccess"]
    resources = ["*"]
    condition {
      test     = "StringEquals"
      variable = "elasticfilesystem:AccessPointArn"
      values   = [var.efs_access_point_arn]
    }
  }
}

resource "aws_iam_role_policy" "task_efs" {
  role   = aws_iam_role.task.id
  policy = data.aws_iam_policy_document.task_efs.json
}
ログ抜粋(失敗→成功)
# 失敗時
stoppedReason: failed to invoke EFS utils ... timeout ... code: 32

# 成功確認
aws ecs describe-tasks --cluster ecs-validate --tasks <TASK_ARN> \
  --query 'tasks[0].[lastStatus,stoppedReason]' --output table
# => RUNNING / None

まとめ

原因は、EFSAZ ごとに異なる接続先 IP(マウントターゲット)を持つ一方、ECSSG のアウトバウンド許可が片側のみで NFS(2049/TCP) が到達不可になったこと。
「IP 固定の許可」ではなく「SG 参照/CIDR ベース/広めのアウトバウンド」を採用すると、AZ 差異・フェイルオーバー・拡張時の変化に強くなる。
同様の事象は RDS(フェイルオーバー時の接続先変動)、NLBAZ ごとに ENI)、InterfaceVPC Endpoint などでも起こり得る。