🚰

同一インスタンスに複数の GPU コンテナをデプロイする【ECS on EC2】

2024/12/27に公開

はじめに

Amazon ECS(AWS Fargate ではなく EC2 で稼働) 上に GPU コンテナをデプロイしようとすると、「タスクが 1 インスタンスに 1 つしか立ち上がらない」問題にぶつかりました。コンテナを複数個起動してGPUリソースを共有したいのに、これじゃあECSにしてる意味ないじゃん!ってことで、EC2 上で GPU リソースを最大限に活用するための ECS コンテナ配置方法を解説したいと思います。

前提: なぜ 1 インスタンスに 1 タスクしか起動しないのか?

考えられる理由は以下の通りです。

  1. ECSサービスの「タスク配置」が複数のアベイラビリティーゾーンにタスクを分散する設定になっている
  2. 不要なGPUリソース予約を定義してしまっている
  3. ネットワークモード(awsvpc or bridge)による制約

それではやっていきましょう!

1. ECS サービスの「タスク配置」を設定する

ECS on EC2では、タスクをどこのアベイラビリティーゾーン(AZ)に配置するかの設定があるのですが、これがデフォルトで、AZ全体にタスクを分散する設定になっているので、それを修正します。

まず、ECSサービスの「アベイラビリティゾーンの再調整」をオフにします。

「アベイラビリティゾーンの再調整」は、ECS 側の自動判定で別 AZ にタスクを移動させる機能です。これをオフにしないと、後述の「タスク配置」で「ビンパック」は指定できません。

次に、ECSサービスには「タスク配置」という項目があるので、「ビンパック」を選択します。

ビンパックはCPU やメモリをできるだけ詰め込むように配置されるため、インスタンス数を最小限に抑えることができます。

ただし、EC2インスタンスの数が多少増えるのを許容して可用性を維持したい場合は、「AZバランススプレッド」「AZバランスビンパック」の使用がおすすめです。

またカスタムで、制約を追加することができ、インスタンスのタイプやアベイラビリティゾーンを指定することもできます。

詳しくは、以下の記事を参考にしてください。

https://qiita.com/kimuni-i/items/e86363d42094d4dab05b

Terraformのコード

resource "aws_ecs_service" "this" {

~~~省略~~~

  placement_constraints {
    # インスタンスごとに複数のタスクを配置します。起動するインスタンスのタイプをg4dn.xlargeに固定します。
    type       = "memberOf"
    expression = "attribute:ecs.instance-type ==  g4dn.xlarge"
  }

  ordered_placement_strategy {
    # PUやメモリなどのリソース使用量が最小になるようにタスクを配置します。これにより、使用するインスタンス数を最小限に抑えることができます。
    type  = "binpack"
    field = "memory"
  }
  
~~~省略~~~

2. タスク定義で GPU リソースを指定しない

ECS タスク定義の リソース割当て制限 で GPU の設定項目で、「GPU使うから1つ設定しよ〜」と入力するかと思いますが、これは罠です
GPU を 1 つしか持っていないインスタンス上では 1 タスクしか立てられなくなります。そのため、あえて ECS タスク定義には GPU 指定せずに空欄にして、EC2 側の Docker ランタイムに nvidia を使う設定を組み込みます。

Terraformコード
resource "aws_ecs_task_definition" "this" {

~~~省略~~~

  container_definitions = jsonencode([
    {
      name      = "container_name"
      image     = "${var.ecr_repository_url}:latest"
      essential = true

      # GPUを指定すると、1インスタンスに1つのタスクしか配置できなくなってしまうので、コメントアウト
      # resourceRequirements = [
      #   {
      #     type  = "GPU"
      #     value = "1"
      #   }
      # ]
~~~省略~~~

ここからの設定は、ECS on EC2に使用するEC2の起動テンプレートに関する設定です。

AMI の選択

AWS には GPU 用の最適化 AMI がいくつか用意されています。

  • amzn2-ami-ecs-gpu-hvm-*-x86_64-ebs
  • bottlerocket-aws-ecs-1-nvidia-aarch64-*** など

Amazon Linux や Bottlerocket などお好みに合わせて使用します。bottlerocket が以下の記事によるといいらしいです。

参考:
https://zenn.dev/optfit_tech/articles/2ced3867c85c40

Docker ランタイムの設定

EC2 起動テンプレートのユーザーデータを以下のような設定をすることで、Docker がデフォルトランタイムとして nvidia を使うよう設定します。これにより、タスク定義で GPU リソースを指定しなくてもGPUがコンテナで使えるようになります。

EC2>起動テンプレート>高度な詳細の一番下にユーザーデータの入力欄があります。

bash
コードをコピーする
# 例: /etc/sysconfig/docker を上書きする
sudo rm /etc/sysconfig/docker
echo DAEMON_MAXFILES=1048576 | sudo tee -a /etc/sysconfig/docker
echo OPTIONS=\"--default-ulimit nofile=32768:65536 --default-runtime nvidia\" | sudo tee -a /etc/sysconfig/docker
echo DAEMON_PIDFILE_TIMEOUT=10 | sudo tee -a /etc/sysconfig/docker

# Docker を再起動
sudo systemctl restart docker

これで、Docker コンテナ起動時に --runtime=nvidia オプションが自動的に適用されるようになります。

参考:
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs-gpu.html#share-gpu

4. ネットワークモードと ALB の組み合わせ

ECS on EC2では、awsvpcbridgeのネットワークモードを使用します。ネットワークモードはタスク定義で設定できます。ネットワークモードの違いによって、ALBやセキュリティーグループ周りの構築に違いが出てくるので解説します。

awsvpc モードとbridge モードのどちらを使用するべきか?

awsvpc モード

  • 本番運用でセキュリティをより厳格にしたい

bridge モード

  • ステージング環境や低コスト運用が最優先
  • EC2がパブリックサブネットでも問題ない

awsvpc モードを使う場合

プライベートIPがタスクごとにアタッチされます。コンテナが外部インターネットの通信を必要としている場合は、NATGatewayの使用が必須になります。

設定例:

  • タスク定義のネットワークモードをawsvpcにする

  • ターゲットグループのターゲットタイプをipにする。

    もしターゲットタイプが 異なる場合はターゲットグループの再作成が必要になります。

  • NATGatewayや必要なエンドポイントを追加

    外部インターネットと通信したい場合はNATGatewayは必須です。Fargateには「パブリックIPアドレスを付与する」という項目があるのですが、ECS on EC2の場合は、パブリックIPアドレスは付与されません。

注意点:

ECSインスタンス(EC2)のENI数に上限があり、同一インスタンス内に建てられるコンテナは実質14個までになります。

参考:
https://dev.classmethod.jp/articles/dynamic-port-mapping-unhealty-check-list/
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/networking-networkmode-bridge.html

terraformのコード
ecs.tf
resource "aws_ecs_task_definition" "this" {
  family                   = "${var.project}-${var.env}-ecs-task-definition-${local.service_name}"
  requires_compatibilities = ["EC2"]
  network_mode             = "awsvpc" //変更
  cpu                      = 1024
  memory                   = 4096
  execution_role_arn       = var.task_execution_role_arn
  task_role_arn            = var.task_execution_role_arn
  
~~~省略~~~
alb.tf
resource "aws_lb_target_group" "this" {
  name        = "${var.project}-${var.env}-tg-sbv2"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip" // 変更
  
~~~省略~~~
vpc.tf
# NAT Gateway用のElastic IP
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name    = "${var.project}-eip-nat"
    Project = var.project
  }
}

# NAT Gateway(パブリックサブネットに配置)
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id # 最初のパブリックサブネットに配置

  tags = {
    Name    = "${var.project}-nat"
    Project = var.project
  }
}

~~~ルートテーブルの記述は省略~~~

# プライベートルートテーブル1 (AZ-a)にNATゲートウェイのルートを追加
resource "aws_route" "private1_nat" {
  route_table_id         = aws_route_table.private1.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.main.id
}

# プライベートルートテーブル2 (AZ-d)にNATゲートウェイのルートを追加
resource "aws_route" "private2_nat" {
  route_table_id         = aws_route_table.private2.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.main.id
}
    

bridge モードを使う場合

bridgeモードとALBの動的ポートと組み合わせて使用することで、ランダムなポートにコンテナを配置しつつ、ターゲットグループでそのポートを把握することができます。

設定例

  • タスク定義のネットワークモードをbridgeにする

  • タスク定義の「解放するホストポート」を0に設定
    • タスク定義のポートマッピングでコンテナポートを指定し、ホストポートを 0 にすると、ECS がランダムポートを割り当てます。
    • これにより、同じコンテナを複数並列で起動してもポート競合を起こしません。

  • EC2をパブリックサブネットに配置する

  • ターゲットグループのターゲットタイプを「インスタンス」にする。

    もしターゲットタイプが 異なる場合はターゲットグループの再作成が必要になります。

  • EC2 側のセキュリティグループに「ALB のセキュリティグループ」からの「全てのトラフィック」を許可

    Dynamic Port Mapping でランダムに割り当てられるポートに対して ALB からのアクセスを受信することができます。

terraformのコード
ecs.tf
resource "aws_ecs_task_definition" "this" {
  family                   = "${var.project}-${var.env}-ecs-task-definition-${local.service_name}"
  requires_compatibilities = ["EC2"]
  network_mode             = "bridge" //変更
  cpu                      = 1024
  memory                   = 4096
  execution_role_arn       = var.task_execution_role_arn
  task_role_arn            = var.task_execution_role_arn
  
~~~省略~~~

resource "aws_security_group" "ecs" {
  name        = "${var.project}-${var.env}-ecs-sg-${local.service_name}"
  description = "Security group for ECS instances"
  vpc_id      = var.vpc_id

// 全てのトラフィックを、albのセキュリティーグループに対して許可
  ingress {
    from_port       = 0
    to_port         = 0
    protocol        = "-1"
    security_groups = [var.alb_security_group_id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

alb.tf
resource "aws_lb_target_group" "this" {
  name        = "${var.project}-${var.env}-tg-sbv2"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "instance" // 変更
  
~~~省略~~~

ネットワークモードのさらに詳細な解説が知りたい方は以下の記事を見てください。

https://dev.classmethod.jp/articles/ecs-networking-mode/

結果

試しにGPUが必要なコンテナのサービスを2つデプロイしてみましょう。

ECS クラスターのコンテナインスタンスのタブで、一つのインスタンスに複数のタスクがデプロイできてたら完了です!

採用情報

架電特化のAI電話SaaSを展開しているnocall.ai株式会社では、エンジニアを積極採用中です!
ジュニア層のエンジニアからリーダー職まで幅広く募集しています。

https://passionategeniushq.notion.site/cec8d81619524d218643e34d5e7431c6?pvs=4

https://passionategenius.com/

Discussion