DROBEプロダクト開発ブログ

DROBEのプロダクト開発周辺の知見や考え方の共有をしていきます

ECS で GPU を使った ML 系 Task の実行環境をセットアップする

この記事を書いた人

DROBE の都筑 (@tsuzukit2) です

簡単なプロフィールはこちらをご覧ください

はじめに

機械学習系の機能を開発していると、GPU を利用してトレーニングを行いたいケースが多々あると思います。

この記事では、ECS で GPU を使った ML 系 Task の実行環境のセットアップについて記載します。

作りたいもの

作りたいものの概要はこのようなものです。

ECS で構築する GPU を利用した Task の実行環境

GPU は高価なので、常時起動しているインスタンスは 0 としておきつつ、Task が作られたらインスタンスを起動、Task を実行、Task の実行が終わったらインスタンスを落とし 0 に戻す、という環境をセットアップします。

ECS の capacity provider と autoscaling group を紐付け、ECS Task が起動 / 終了のタイミングで必要なインスタンスが変更される構成です。

ECS Task は goofys を利用して S3 をマウントする事とします。S3 をマウントするのは、画像系機械学習モデルのトレーニングに大量の画像が必要であり、それを S3 にマウントする事でコードで S3 の API などを意識せずに使えるようにするためです。

goofys については、こちらの repo を参考にしてください。

ECS で実行するタスクの定義と実行

ECS で実行するタスク定義は事前に Terraform で作っておく事としました。Task 定義の中で実際に training を行うコンテナは latest tag のものを実行するように指定しておきます。 Task を実行する際には、GitHub Actions にてコンテナをビルドし ECR に push (ここで latest tag のイメージが更新されます) し、AWS CLI を使って Task を起動するという流れです。

GitHub Actions は以下のようなものになります。

name: invoke ml training

on:
  workflow_dispatch:

jobs:
  build:
    name: build container image
    runs-on: ubuntu-latest

    steps:
    - name: source checkout
      uses: actions/checkout@v3

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v2
      with:
        aws-access-key-id: ${{ AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build and push to ecr
      run: |
        make build # 自作のコンテナを build するためのコマンド
        docker tag trainer:latest xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/trainer:latest
        docker push xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/trainer:latest:latest
        docker tag trainer:latest xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/trainer:${GITHUB_SHA}
        docker push xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/trainer:${GITHUB_SHA}

  run:
    name: run ecs task
    runs-on: ubuntu-latest
    needs: [build]

    steps:

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v2
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1

    - name: run-task
      env:
        CLUSTER_NAME: ml-train # terraform で定義した cluster 名に合わせる
        FAMILY_NAME: trainer # terraform で定義した task 定義に合わせる
      run: |
        TASK_DEF_ARN=$(aws ecs list-task-definitions --family-prefix "${FAMILY_NAME}" --query "reverse(taskDefinitionArns)[0]" --output text)
        echo "${TASK_DEF_ARN}"
        TASK_ARN=$(aws ecs run-task --cluster ${CLUSTER_NAME} --task-definition ${TASK_DEF_ARN} --query tasks[0].taskArn --output text)
        TASK_ID=$(echo "${TASK_ARN}" | grep -oE "[^/]+$")

Terraform

全体の構成が決まったので Terraform の設定を書いていきます。 (注 公開するために命名などを修正しており動作未検証なのでコピペでの使用は避けてください)

まずは VPC などネットワーク周りの設定を書きます。

# VPC
resource "aws_vpc" "vpc_name" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "vpc_name"
  }
}

# Public subnet
resource "aws_subnet" "subnet" {
  vpc_id                  = aws_vpc.vpc_name.id
  availability_zone       = "ap-northeast-1a"
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
}

resource "aws_internet_gateway" "ig" {
  vpc_id = aws_vpc.vpc_name.id
}

resource "aws_route_table" "rt" {
  vpc_id = aws_vpc.vpc_name.id
}

resource "aws_route" "route" {
  route_table_id         = aws_route_table.rt.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.ig.id
}

resource "aws_route_table_association" "rta" {
  subnet_id      = aws_subnet.subnet.id
  route_table_id = aws_route_table.rt.id
}

resource "aws_security_group" "sg" {
  name   = "sg"
  vpc_id = aws_vpc.vpc_name.id
  depends_on = [
    aws_vpc.vpc_name
  ]

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

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

次に ECR などを書いていきます

# ECR
resource "aws_ecr_repository" "ecr_repo" {
  name     = "trainer"
}

# ECR Lifecycle policy 
resource "aws_ecr_lifecycle_policy" "lsp" {
  repository = each.value.ecr_repo

  policy = <<EOF
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep last 2 images",
            "selection": {
                "tagStatus": "tagged",
                "tagPrefixList": ["v"],
                "countType": "imageCountMoreThan",
                "countNumber": 2
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
EOF
}

続いて Task 定義を作ります

ここで goofys や GPU を使うための設定を行います

# Task 定義
resource "aws_ecs_task_definition" "task" {

  family                   = "trainer"
  requires_compatibilities = ["EC2"]
  network_mode             = "bridge"
  cpu                      = 2048
  memory                   = 8192

  task_role_arn      = aws_iam_role.iam.arn
  execution_role_arn = aws_iam_role.iam.arn

  container_definitions = jsonencode([
    {
      image     = "xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/${aws_ecr_repository.ecr_repo.name}:latest" # ここで latest を指定する
      essential = true,
      name      = "trainer"
      cpu       = 2048,
      memory    = 8192,
      logConfiguration = {
        logDriver = "awslogs",
        options = {
          awslogs-group         = aws_cloudwatch_log_group.ml_image_recognition_train.name,
          awslogs-region        = "ap-northeast-1",
          awslogs-stream-prefix = "ml_image_recognition"
        }
      },
      linuxParameters = { # goofys を使うための設定
        capabilities = {
          add = [
            "MKNOD",
            "SYS_ADMIN"
          ]
        },
        "devices" : [
          {
            "hostPath" : "/dev/fuse",
            "containerPath" : "/dev/fuse",
            "permissions" : [
              "read",
              "write",
              "mknod"
            ]
          }
        ]
      },
      environment = [
        {
          name  = "NVIDIA_DRIVER_CAPABILITIES",
          value = "all"
        }
      ],
      resourceRequirements = [ # GPU を使うための設定
        {
          type  = "GPU",
          value = "1"
        }
      ]
    }
  ])
}

resource "aws_iam_role" "iam" {
  name = "ecs-iam"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Effect": "Allow"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "iam_policy" {
  role = aws_iam_role.iam.id

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams"
      ],
      "Effect": "Allow",
      "Resource": "${aws_cloudwatch_log_group.lg.arn}"
    },
    {
      "Action": [
        "s3:ListBucket",
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Effect": "Allow",
      "Resource": [
        "*" # goofys でマウントしたいバケットを指定する
      ]
    }
  ]
}
EOF
}

resource "aws_cloudwatch_log_group" "trainer" {
  name              = "/ecs/logs/prod/trainer"
  retention_in_days = 14
}

最後に cluster の定義や Autoscaling Group の設定を書いていきます。aws_laumch_template で使いたいインスタンスタイプを指定します。ここでは g4dn.xlarge を指定しています。

# ecs cluster
resource "aws_ecs_cluster" "cluster" {
  name = "ml-train"
}

resource "aws_ecs_cluster_capacity_providers" "trainer" {
  cluster_name = aws_ecs_cluster.cluster.name

  capacity_providers = [aws_ecs_capacity_provider.trainer.name]

  default_capacity_provider_strategy {
    base              = 0
    weight            = 1
    capacity_provider = aws_ecs_capacity_provider.trainer.name
  }

}

resource "aws_ecs_capacity_provider" "trainer" {
  name = "trainer"

  auto_scaling_group_provider {
    auto_scaling_group_arn         = aws_autoscaling_group.trainer.arn
    managed_termination_protection = "ENABLED"
    managed_scaling {
      maximum_scaling_step_size = 10
      minimum_scaling_step_size = 1
      status                    = "ENABLED"
      target_capacity           = 100
    }
  }
}

resource "aws_autoscaling_group" "trainer" {
  name                      = "trainer"
  max_size                  = 1
  min_size                  = 0
  health_check_grace_period = 0
  health_check_type         = "EC2"
  desired_capacity          = 0
  vpc_zone_identifier       = [aws_subnet.trainer.id]

  launch_template {
    id      = aws_launch_template.trainer.id
    version = "$Latest"
  }

  tag {
    # ECSにスケーリングをお願いするために必要なタグ
    # https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/cluster-auto-scaling.html#update-ecs-resources-cas
    key                 = "AmazonECSManaged"
    value               = ""
    propagate_at_launch = true
  }

  lifecycle {
    ignore_changes = [
      desired_capacity,
    ]
  }

}

locals {
  node_group_user_data = <<-EOF
  #!/bin/bash
  set -o xtrace
  echo ECS_CLUSTER=${aws_ecs_cluster.ml_image_recognition_train.name} >> /etc/ecs/ecs.config;
  EOF
}

resource "aws_launch_template" "trainer" {
  name = "trainer"

  # https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/retrieve-ecs-optimized_AMI.html
  image_id      = "ami-087da40e7559e6193"
  instance_type = "g4dn.xlarge"
  key_name      = "xxxxx"

  vpc_security_group_ids = [aws_security_group.trainer.id]

  block_device_mappings {
    device_name = "/dev/xvda"

    ebs {
      volume_size = 200
    }
  }

  instance_market_options {
    market_type = "spot" # spot の方が金額は安いが長時間の学習では spot だと止まってしまう事があった
  }

  iam_instance_profile {
    arn = aws_iam_instance_profile.trainer.arn
  }

  user_data = base64encode(format(local.node_group_user_data))
}

data "aws_iam_policy" "AmazonEC2ContainerServiceforEC2Role" {
  arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}

resource "aws_iam_role_policy_attachment" "trainer" {
  for_each = toset([
    "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
    "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
  ])
  role       = aws_iam_role.trainer.name
  policy_arn = each.value
}

data "aws_iam_policy" "AmazonEC2ContainerServiceforEC2Role" {
  arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}

resource "aws_iam_role" "trainer" {
  name               = "trainer"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "trainer" {
  policy_arn = data.aws_iam_policy.AmazonEC2ContainerServiceforEC2Role.arn
  role       = aws_iam_role.trainer.name
}

resource "aws_iam_instance_profile" "trainer" {
  role = aws_iam_role.trainer.name
}

CapacityProviderReservation について

ECS のインスタンス数を増減させる仕組みとして CapacityProvider を使います。それらの説明は公式のブログに詳しいので一読ください。

Amazon ECS クラスターの Auto Scaling を深く探る | Amazon Web Services

CapacityProvider を利用した Autoscaling においては、クラスターが必要とするインスタンス数と実際に稼働しているインスタンスの数を比率で表した CapacityProviderReservation という値をあらかじめ設定しておき、その値になるようにインスタンス数を ASG が自動で調節します。若干解り伝いのですが、公式の説明を読むとイメージが掴めます。

CAS の中心的な責任は、ASG に割り当てられたタスクの必要を満たす上で「適切な」数のインスタンスが ASG で実行されるようにすることです。これには、既に実行されているタスクと、既存のインスタンスには収まらない、顧客が実行しようとしているタスクの両方が含まれます。その数を M としましょう。また、既に実行されている ASG 内の現在のインスタンス数を N とします。この記事の残りの部分では M と N が繰り返し出てくるので、これらをどう考えたらよいかを十分明確に理解しておくことが重要です。  今のところ、M をいくらにしたらよいかを知る方法は説明していませんが、議論の目的上、M は必要数であるとだけ仮定します。この仮定の下で、もし N = M であるとするなら、スケールアウトは必要ではなく、スケールインは不可能です。一方、N < M なら、十分なインスタンスがないことになるので、スケールアウトが必要です。  最後に N > M なら、スケールインが可能です (ただし必要だとは限りません)。自分の ECS タスクすべてを実行するのに必要な数よりも多くのインスタンスが存在しているからです。また、後ほど見るように、N と M に基づく新しい CloudWatch メトリクスを定義し、それをCapacityProviderReservation と呼ぶことにします。N と M が与えられたときのこのメトリクスの定義は非常にシンプルです。

簡単に説明するなら、このメトリクスは、ASG の実際の大きさと必要な大きさとの比を、パーセント単位で表したものです。

terraform ではこの CapacityProviderReservation を設定する形になります

resource "aws_ecs_capacity_provider" "trainer" {
  name = "trainer"

  auto_scaling_group_provider {
    auto_scaling_group_arn         = aws_autoscaling_group.trainer.arn
    managed_termination_protection = "ENABLED"
    managed_scaling {
      maximum_scaling_step_size = 10
      minimum_scaling_step_size = 1
      status                    = "ENABLED"
      target_capacity           = 100 # <- ここ
    }
  }
}

ここで 100 を指定するという事は、「タスクを実行するのにちょうど必要な数のインスタンスを準備してください」ということになります。

例えばここで 200 を指定しておくと、「タスクを実行するのに必要なインスタンスの数の 2 倍のインスタンスを準備してください」ということです。

CapacityProviderReservation を 100 にしておくことで、タスクのリクエストが無い時はインスタンスの数が 0 になるように出来ます。(GPU インスタンスは非常に高いので使っていない時に 0 に出来るという事はメリットが大きいはずです)

Trouble Shooting

ここからは、環境を構築するにあたって実際にハマってしまったポイントを 2 つ紹介します。

インスタンスが起動はするが ECS に参加しない

ECS クラスターが作られ EC2 インスタンスも建っているのに、training が始まらない場合は EC2 が ECS クラスターに参加できているかを確認してみてください。

EC2 インスタンスを ECS クラスターに参加させるためには、EC2 の /etc/ecs/ecs.configECS_CLUSTER 環境変数をセットする必要がありました。

terraform では以下の辺りになります。

locals {
  node_group_user_data = <<-EOF
  #!/bin/bash
  set -o xtrace
  echo ECS_CLUSTER=${aws_ecs_cluster.trainer.name} >> /etc/ecs/ecs.config;
  EOF
}

resource "aws_launch_template" "trainer" {
  name = "trainer"

  ...省略
  
  user_data = base64encode(format(local.node_group_user_data))
}

Training が 20 時間程度で終わってしまう

環境を構築してから何回か training を実行しましたが、どうしても 20 時間程度で勝手に training が終了してしまうという現象に遭遇しました。

最初はメモリーなどを疑いましたが試しに無限に sleep をし続ける training task を作って実行しても同様の結果になってしまいました。

調査した結果、どうやらスポットインスタンスを使っている事が原因だったようです。

スポットインスタンスの設定を削除した結果数日に及ぶようなトレーニングでも実行を行える事が確認できました。

terraform ではこの辺りになります。

resource "aws_launch_template" "trainer" {
  
  ... 省略

  instance_market_options {
    market_type = "spot" # spot の方が金額は安いが長時間の学習では spot だと止まってしまう事があった
  }

}

おわりに

ML をやっていると GPU を使いたくなる事は多々あり、かつ本番環境への Deploy などを考えると自動のパイプラインとして GPU によるトレーニングを組みたくなる事は多いと思います。

一方で GPU インスタンスは非常に高価であるため、使っていない時は落としておきたいと思うのが人情だと思います。

この記事では ECS で GPU を使った ML 系 Task の実行環境のセットアップについて解説しました。特に必要のない時にはインスタンス数を 0 にしておける設定なので、特にコストにシビアなスタートアップの方などに少しでも参考になれば嬉しいです。


DROBE開発組織の紹介
組織情報ポータル