Terraformで構築する機械学習ワークロード(Batch on Fargate編)

2023.09.18

こんちには。

データアナリティクス事業本部 インテグレーション部 機械学習チームの中村です。

今回も「Terraformで構築する機械学習ワークロード」ということで、前回の記事ではLambdaを使いましたが、今回はその処理をBatch on Fargateに載せてみたいと思います。

前回記事は以下です。

構成イメージ

構成としては以下のようなものを作成していきます。

前回との違いとしては、まずLambdaの代わりにBatch on Fargateを使う点です。

Fargateのタスク(ジョブ)上のコンテナイメージで物体検出モデルの一つであるYOLOXを動かしていきます。

また、それ以外にもBatchを使用する場合は、S3イベントとBatchの間にEventBridgeが必要となります。

動作環境

Docker、Terraformはインストール済みとします。

Terraformを実行する際のAWSリソースへの権限は、aws-vaultで環境構築をしておきます。

aws-vaultについては以下も参考にされてください。

ホストPCとしてのバージョン情報配下です。

  • OS: Windows 10 バージョン22H2
  • docker: Docker version 24.0.2-rd, build e63f5fa
  • terraform: Terraform v1.4.6 (on windows_amd64)

またコンテナ内のバージョン情報配下となります。

  • Python: 3.10.12 (main, Aug 15 2023, 15:43:05) [GCC 7.3.1 20180712 (Red Hat 7.3.1-15)]
  • PyTorch: 2.0.1+cu117
  • OpenCV: 4.8.0
  • MMEngine: 0.8.4
  • MMDetection: 3.1.0+

成果物

成果物はGitHub上にあげておきましたので、詳細は必要に応じてこちらを参照ください。

フォルダ構成は以下のようになっています。

├─asset
│      yolox_l_8x8_300e_coco_20211126_140236-d3bd2b23.pth
│
├─docker
│  └─batch_fargate
│          push_ecr.sh
│
├─python
│      Dockerfile
│      mmdetect_handler.py
│      run.py
│      s3_handler.py
│
└─terraform
    ├─environments
    │  └─dev
    │          locals.tf
    │          main.tf
    │          variables.tf
    │
    └─modules
        ├─batch
        │      main.tf
        │      outputs.tf
        │      variables.tf
        │
        ├─ecr
        │      main.tf
        │      variables.tf
        │
        ├─event_bridge
        │      main.tf
        │      variables.tf
        │
        ├─iam
        │      main.tf
        │      outputs.tf
        │      variables.tf
        │
        ├─s3
        │      main.tf
        │      variables.tf
        │
        └─vpc
                main.tf
                outputs.tf
                variables.tf

大筋のモデル構成は以下で前回と同じです。

  • asset/ : モデル置き場として使用
  • docker/ : コンテナイメージのビルド時やECRへのpush時に使用
    • Dockerfile自体はpythonフォルダに配置
  • python/ : Dockerfileに加えて、Pythonスクリプトを配置
  • terraform/environments/dev/ : terraform実行時の環境に応じたメイン
  • terraform/modules/ : moduleに分割したtfファイルが配置

コードの説明

ここからはコードを説明します。Lambdaの時と違う点をメインに説明していきます。

コンテナイメージのビルド

まず以下のような内容でpython/Dockerfileを準備しておきます。

FROM public.ecr.aws/lambda/python:3.10

WORKDIR /opt

RUN pip3 install torch

RUN pip install openmim && \
    mim install "mmengine>=0.7.1" "mmcv>=2.0.0rc4"

RUN yum install -y git
RUN git clone https://github.com/open-mmlab/mmdetection.git
WORKDIR /opt/mmdetection
RUN pip3 install --no-cache-dir -e .

RUN yum install -y tar
RUN yum install -y mesa-libGL.x86_64

RUN mkdir -p /opt/mmdetection/checkpoints
RUN mim download mmdet --config yolox_l_8x8_300e_coco --dest /opt/mmdetection/checkpoints

RUN pip3 install boto3

COPY *.py /opt/mmdetection

ENTRYPOINT [""]
CMD ["echo", "hello world"]

ほぼ前回の記事と同じ内容です。

注意点としてはENTRYPOINTを空白にすることで、Lambdaが使用するlambda-entrypoint.shを使用しないようにしています。

これによってdockerの起動時に任意のコマンドが実行できるようにしています。

ENTRYPOINTを上書きしない場合、以下のエラーがログに表示され、うまく実行できないケースがありました。

entrypoint requires the handler name to be the first argument

その他Lambda環境の時と異なり、boto3を明示的に入れる必要がありましたのでpip installで追加しています。

前回記事のものをビルド済みの場合は、そちらをベースイメージとすれば高速にビルドできます。

Pythonファイルについて

Pythonファイルは以下の3種類あります。

│      mmdetect_handler.py
│      run.py
│      s3_handler.py

mmdetec_handler.pys3_handler.pyについては前回と同じため割愛します。

run.py

run.pyは以下のような内容となります。

import os
from mmdetect_handler import collect_env, find_checkpoint, inference
from s3_handler import download_directory, download_file, upload_file

def main(input_bucket_name: str, input_object_key: str):

    bucket_name = os.getenv("BUCKET_NAME")
    input_prefix = os.getenv("OBJECT_INPUT_PREFIX")
    output_prefix = os.getenv("OBJECT_OUTPUT_PREFIX")
    print(f"{bucket_name=}")
    print(f"{input_prefix=}")
    print(f"{output_prefix=}")

    # 処理対象のオブジェクトを取得
    download_file("./input.jpg", input_bucket_name, input_object_key)

    # モデル等をダウンロード
    download_directory(destination_path="./checkpoints",
        bucket_name=bucket_name, prefix="asset/")

    # 環境ログを出力
    for name, val in collect_env().items():
        print(f"{name}: {val}")

    # モデルファイルを探索
    checkpoint_file = find_checkpoint(model_name="yolox_l_8x8_300e_coco",
        checkpoints_dir="./checkpoints")

    # 推論処理
    inference(checkpoint_file=str(checkpoint_file),
        model_name="yolox_l_8x8_300e_coco",
        device="cpu",
        input_image_file="./input.jpg",
        output_image_file="./output.jpg")

    # 結果をS3にupload
    output_object_key = output_prefix + input_object_key[len(input_prefix):]
    print(f"{output_object_key=}")
    upload_file("./output.jpg", bucket_name, output_object_key)

if __name__ == "__main__":

    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--input-bucket-name', required=True, type=str)
    parser.add_argument('--input-object-key', required=True, type=str)

    args = parser.parse_args()

    print(f"{args=}")

    main(**args.__dict__)

Lambdaの時のhandlerと似ていますが、以下の流れで処理をします。

  • 引数としてバケット名とオブジェクトキーが与えられます
  • S3から対象のオブジェクトを./にダウンロード
  • モデルファイルをS3にあらかじめ置いているので./checkpointsにダウンロード
  • ./checkpointsからモデルファイルを探索
  • 推論処理
  • 推論で出力された画像をS3にアップロード

S3関連の処理やMMDetectionに関する処理は、それぞれのハンドラを呼び出して処理をします。

異なる点は、入力に関する情報が異なることと、/tmp以外のフォルダを使用できることです。

特に入力については以下の流れでコンテナ内に渡ってきています。

  • EventBridgeの入力トランスフォーマでバケット名とオブジェクトキーにパース
  • パースされた状態でTargetにあるパラメータで渡ってくる
  • ジョブ定義に書かれたdockerのコマンドに上記のパラメータがプレースホルダとして準備

最後のdockerのコマンドは後で再掲しますが以下のようになっています。

    command = [
      "python", "run.py",
      "--input-bucket-name", "Ref::input_bucket_name",
      "--input-object-key", "Ref::input_object_key"
    ]

このRef::の部分がTargetのパラメータの値に置き換えられます。

tfファイル

tfファイルは以下のようなフォルダ構成となっています。

    ├─environments
    │  └─dev
    │          locals.tf
    │          main.tf
    │          variables.tf
    │
    └─modules
        ├─batch
        │      main.tf
        │      outputs.tf
        │      variables.tf
        │
        ├─ecr
        │      main.tf
        │      variables.tf
        │
        ├─event_bridge
        │      main.tf
        │      variables.tf
        │
        ├─iam
        │      main.tf
        │      outputs.tf
        │      variables.tf
        │
        ├─s3
        │      main.tf
        │      variables.tf
        │
        └─vpc
                main.tf
                outputs.tf
                variables.tf

前回と異なる点の要点は以下です。

  • batchの追加
    • 本記事のメイン部分です
  • event_bridgeの追加
    • S3のイベントをBatchで処理するために必要となります
  • vpcの追加
    • Batch(Fargate)を実行するためにVPC環境が必要なため今回作成しています
    • 今回作成するのはPublicサブネットとなります
  • iamの修正
    • Lambdaのときはその実行ロールのみでしたが多くのロールが必要になります
    • Batchについては、サービスロール、ジョブ実行ロール、ジョブロールが必要です
    • EventBridgeについても実行ロールが必要です
  • s3の修正
    • イベント通知が異なるため少し修正が必要です

また、全体構成としてvariables.tfを各フォルダに配置するなどの修正もしています。

environments/dev/

起点となるのはこちらのフォルダにあるtfファイルです。

以下のようになっています。

  • variables.tf
variable "project_prefix" {}
  • locals.tf
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
  account_id = data.aws_caller_identity.current.account_id
  region     = data.aws_region.current.name
  bucket_name = "${var.project_prefix}-${local.account_id}"
  object_input_prefix = "input/"
  object_output_prefix = "output/"
  ecr_repository_name = var.project_prefix
  ecr_image_uri = "${local.account_id}.dkr.ecr.${local.region}.amazonaws.com/${local.ecr_repository_name}"
  environments = [
    {
      name  = "BUCKET_NAME"
      value = "${local.bucket_name}"
    },
    {
      name  = "OBJECT_INPUT_PREFIX"
      value = "${local.object_input_prefix}"
    },
    {
      name  = "OBJECT_OUTPUT_PREFIX"
      value = "${local.object_output_prefix}"
    }
  ]
}
  • main.tf
provider "aws" {
  default_tags {
    tags = {
      project_prefix = var.project_prefix
    }
  }
}

module vpc {
  source = "../../modules/vpc"
  project_prefix = var.project_prefix
}

module ecr {
  source = "../../modules/ecr"
  project_prefix = var.project_prefix
  ecr_repository_name = local.ecr_repository_name
}

module iam {
  source="../../modules/iam"
  project_prefix = var.project_prefix
  account_id = local.account_id
}

module batch {
  source = "../../modules/batch"
  project_prefix = var.project_prefix
  image_uri = local.ecr_image_uri
  subnet_id = module.vpc.subnet_id
  security_group_id = module.vpc.security_group_id
  job_role_arn = module.iam.job_role_arn
  job_execution_role_arn = module.iam.job_execution_role_arn
  service_role_arn = module.iam.batch_service_role_arn
  environments = local.environments
}

module event_bridge {
  source = "../../modules/event_bridge"
  project_prefix = var.project_prefix
  execution_role_arn = module.iam.events_execution_role_arn
  job_queue_arn = module.batch.job_queue_arn
  job_definition_arn = module.batch.job_definition_arn
  bucket_name = local.bucket_name
  object_input_prefix = local.object_input_prefix
}

module s3 {
  source="../../modules/s3"
  project_prefix = var.project_prefix
  bucket_name=local.bucket_name
  object_input_prefix=local.object_input_prefix
  object_output_prefix=local.object_output_prefix
}

定数の定義とモジュールの呼び出しを行っています。

また実行時にproject_prefixを変数として与えられるようにしています。

modules/batch/

batchについては以下のようになっています。

(ここ以降のmodulesのvariables.tfoutputs.tfの説明については単なる入出力のため割愛します。)

  • main.tf
// ジョブ定義
resource "aws_batch_job_definition" "main" {
  name                 = "${var.project_prefix}-job-definition"
  type                 = "container"
  platform_capabilities = [
    "FARGATE",
  ]
  container_properties = jsonencode({
    command = [
      "python", "run.py",
      "--input-bucket-name", "Ref::input_bucket_name",
      "--input-object-key", "Ref::input_object_key"
    ]
    image = "${var.image_uri}:latest"
    jobRoleArn = "${var.job_role_arn}"
    fargatePlatformConfiguration = {
      platformVersion = "LATEST"
    }
    networkConfiguration = {
      assignPublicIp = "ENABLED"
    }
    resourceRequirements = [
      {
        type = "VCPU"
        value = "1"
      },
      {
        type = "MEMORY"
        value = "2048"
      }
    ]
    executionRoleArn = "${var.job_execution_role_arn}"
    environment = "${var.environments}"
  })
}

// コンピューティング環境
resource "aws_batch_compute_environment" "fargate" {
  compute_environment_name = "${var.project_prefix}-compute-environment"

  compute_resources {
    max_vcpus = 16

    security_group_ids = [
      var.security_group_id
    ]

    subnets = [
      var.subnet_id
    ]

    type = "FARGATE"
  }

  type         = "MANAGED"
  service_role = var.service_role_arn
}

# ジョブキュー
resource "aws_batch_job_queue" "job_queue" {
  name                 = "${var.project_prefix}-job-queue"
  state                = "ENABLED"
  priority             = 0
  compute_environments = [aws_batch_compute_environment.fargate.arn]
}

ジョブ定義とコンピューティング環境、ジョブキューの定義が必要です。

特にジョブ定義について定義に足りない部分があると、EventBridge側でFailedInvocationsとなりデバッグが困難な状況に陥るため注意が必要です。 (私の場合はplatform_capabilitiesfargatePlatformConfigurationが抜けていたためFailedInvocationsからなかなか進めませんでした)

その他ハマった点としては、FargateをPublicサブネットで動かすは以下の記述がないとECRからイメージをpullする際にエラーとなり、こちらもネットワーク設定側を見直してしまったりでハマりましたので記載しておきます。

    networkConfiguration = {
      assignPublicIp = "ENABLED"
    }

この記述をTerraformのドキュメントから見つけられず解決に時間がかかりました。

CloudFormationの方には記載があるので、壁にぶち当たった場合はそちらも見てみることをオススメします。

最後にロールについてです。Batchに関連するロールは3つあります。

  • ジョブ定義
    • ジョブロール
    • ジョブ実行ロール
  • コンピューティング環境
    • サービスロール

ジョブロールはECSで言うところのタスクロール、ジョブ実行ロールはECSで言うところのタスク実行ロールです。

開発者がS3などに指定のポリシー付与を行うためのロールはジョブロールになります。

ジョブ実行ロールはコンテナエージェントのためのロールで、AWS管理のポリシーが以下にあります。(今回はこちらをそのままアタッチしています)

  • arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

またサービスロールはAWS Batchの使用を開始する際にアカウント毎に以下のARNに作成される、サービスリンクロールを指定することができます。

  • arn:aws:iam::{アカウントID}:role/aws-service-role/batch.amazonaws.com/AWSServiceRoleForBatch

こちらは以下のポリシーがアタッチされています。

  • arn:aws:iam::aws:policy/aws-service-role/BatchServiceRolePolicy

このロールまたはポリシーがアタッチされたロールはTerraformなどで自由に作成することはできませんので注意が必要です。

今回はサービスロールもTerraformで作成しましたが、その場合は以下のように別なポリシーを使用して作成することが可能です。

  • arn:aws:iam::aws:policy/service-role/AWSBatchServiceRole

ただしマネジメントコンソールでは以下のようにサービスリンクロールを使う方を推奨する旨が書いてあります。

AWS Batch では、Batch のサービスにリンクされたロールの使用を推奨しますが、AWSBatchServiceRole Identity and Access Management (IAM) ロールを使用して、お客様の下位互換性を維持することができます。これらのロールを使用して作成されたリソースは、今後サービスにリンクされたロールに変更することはできません。

modules/ecr/

ecrは変更がないため割愛します。

modules/event_bridge/

event_bridgeは以下のようになります。

# ルール
resource "aws_cloudwatch_event_rule" "rule" {
  name        = "${var.project_prefix}-event-rule"

  event_pattern = jsonencode({
    "source" : ["aws.s3"],
    "detail-type" : ["Object Created"],
    "detail" : {
      "bucket" : {
        "name" : ["${var.bucket_name}"]
      },
      "object" : {
        "key" : [{
          "prefix" : "${var.object_input_prefix}"
        }]
      }
    }
  })
}

# ターゲット
resource "aws_cloudwatch_event_target" "target" {
  rule = aws_cloudwatch_event_rule.rule.name

  arn = var.job_queue_arn # aws_batch_job_queue.job_queue.arn # ジョブキューのARN

  batch_target {
    job_definition = var.job_definition_arn
    job_name       = "${var.project_prefix}-job"
  }

  role_arn = var.execution_role_arn

  input_transformer {
    input_paths = {
      "input_bucket_name" : "$.detail.bucket.name",
      "input_object_key" : "$.detail.object.key"
    }
    input_template = <<-TEMPLATE
      {"Parameters": {"input_bucket_name":"<input_bucket_name>", "input_object_key":"<input_object_key>"}}
    TEMPLATE
  }
}

event_patternの記法についてはCloudTrailが出力するJSON構造に則っています。EventBridgeのマネジメントコンソールからサンプルのイベントを見ることでも確認が可能です。event_patternはこれらの構造に合うように記載すれば良さそうです。

またその構造に合わせて値や条件文を記載する必要があります。詳細は公式ドキュメントを参照下さい。

今回使用しているprefixなどは以下に記載されています。

例えば今回のようにバケット名とオブジェクトのprefixを条件にする場合は以下のようにします。

  • イベント構造
{
  "detail-type": "Object Created",
  "source": "aws.s3",
  "detail": {
    "bucket": {
      "name": "example-bucket"
    },
    "object": {
      "key": "example-key",
    }
}
  • イベントパターン
{
    "source" : ["aws.s3"],
    "detail-type" : ["Object Created"],
    "detail" : {
      "bucket" : {
        "name" : ["${var.bucket_name}"]
      },
      "object" : {
        "key" : [{
          "prefix" : "${var.object_input_prefix}"
        }]
      }
    }
}

イベント側には四角括弧[]でマッチするものが囲われているのが特徴的です。

またここまでルール側について述べましたが、ターゲット側としてもイベントのJSON構造を考慮した作りで入力トランスフォーマの入力値(input_paths)にアクセスしています。以下の$.detailなどの部分が該当します。

  input_transformer {
    input_paths = {
      "input_bucket_name" : "$.detail.bucket.name",
      "input_object_key" : "$.detail.object.key"
    }
    input_template = <<-TEMPLATE
      {"Parameters": {"input_bucket_name":"<input_bucket_name>", "input_object_key":"<input_object_key>"}}
    TEMPLATE
  }

またそれだけではなく、これはBatchジョブ側に渡す仕組みとしてParametersに値を詰めることで、例えばdockerのコマンドでRef::を使ってParametersの値を参照して置き換えることができます。

最後にターゲット側にはターゲットへ何かしらのAPI(今回の場合はSubmitJob)を呼び出すことで後続処理を動かしますので、ターゲット側に権限としての実行ロールが必要となります。

modules/iam/

iamはここまで説明したようにBatchで3つのロール、EventBridgeで1つのロールが必要ですので、以下のようにそれぞれを定義します。

# 信頼ポリシー
data "aws_iam_policy_document" "trust_ecs_tasks" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

# ジョブロール
resource "aws_iam_role" "job_role" {
  name = "${var.project_prefix}-job-role"
  assume_role_policy = data.aws_iam_policy_document.trust_ecs_tasks.json
}

# IAMポリシーデータ
data "aws_iam_policy_document" "job_role" {
  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = ["*"]
  }

  statement {
    effect = "Allow"
    actions = [
      "s3:*",
      "s3-object-lambda:*"
    ]
    resources = [
      "arn:aws:s3:::${var.project_prefix}-${var.account_id}",
      "arn:aws:s3:::${var.project_prefix}-${var.account_id}/*"
    ]
  }
}

# IAMポリシー
resource "aws_iam_policy" "job_role" {
  name = "${var.project_prefix}-job-role-policy"
  policy = data.aws_iam_policy_document.job_role.json
}

# IAMポリシーのアタッチ
resource "aws_iam_role_policy_attachment" "job_role" {
  role       = aws_iam_role.job_role.name
  policy_arn = aws_iam_policy.job_role.arn
}


# ジョブ実行ロール
resource "aws_iam_role" "job_execution_role" {
  name = "${var.project_prefix}-job-execution-role"
  assume_role_policy = data.aws_iam_policy_document.trust_ecs_tasks.json
  managed_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
  ]
}

# 信頼ポリシー
data "aws_iam_policy_document" "trust_batch" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["batch.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

# サービスロール
resource "aws_iam_role" "batch_service_role" {
  name = "${var.project_prefix}-batch-service-role"
  assume_role_policy = data.aws_iam_policy_document.trust_batch.json
  managed_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AWSBatchServiceRole"
  ]
}

# 信頼ポリシー
data "aws_iam_policy_document" "trust_events" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["events.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

# 実行ロール
resource "aws_iam_role" "events_execution_role" {
  name = "${var.project_prefix}-events-execution-role"
  assume_role_policy = data.aws_iam_policy_document.trust_events.json
}


# IAMポリシーデータ
data "aws_iam_policy_document" "events_execution_role" {
  statement {
    effect = "Allow"
    actions = [
      "batch:SubmitJob"
    ]
    resources = [
      "arn:aws:batch:ap-northeast-1:${var.account_id}:job/${var.project_prefix}-job",
      "arn:aws:batch:ap-northeast-1:${var.account_id}:job-definition/${var.project_prefix}-job-definition:*",
      "arn:aws:batch:ap-northeast-1:${var.account_id}:job-queue/${var.project_prefix}-job-queue"
    ]
  }
}

# IAMポリシー
resource "aws_iam_policy" "events_execution_role" {
  name = "${var.project_prefix}-events-execution-role-policy"
  policy = data.aws_iam_policy_document.events_execution_role.json
}

# IAMポリシーのアタッチ
resource "aws_iam_role_policy_attachment" "events_execution_role" {
  role       = aws_iam_role.events_execution_role.name
  policy_arn = aws_iam_policy.events_execution_role.arn
}

ジョブロールについては実際に処理の実行に必要のあるポリシーを、ジョブ実行ロールとサービスロールはAWS管理ポリシーをそのまま使用しています。

EventBridgeの実行ロールはターゲットを呼び出すために必要なポリシーを記載しています。

また、今回記事を書くにあたってロールにポリシーをアタッチするのに様々な手法があることに気が付きました。

  • 自分でポリシーを書く場合
    • Data Sourceを使ってHCLで記述してResourceに読み込んでアタッチする
    • inline_policyでロールの中に記載
  • AWS管理ポリシーを使う場合
    • managed_policy_arnsで直接ポリシーを指定
    • Data SourceにAWS管理ポリシーを記載してResourceに読み込んでアタッチする(こちらは実際にはカスタムポリシーが作成されるため冗長)

本記事では、自分でポリシーを記載したジョブロールとEventBridgeの実行ロールはHCL形式で記載できる「Data Sourceを使ってHCLで記述してResourceに読み込んでアタッチする」を選択し、AWS管理ポリシーを使ったジョブ実行ロールとサービスロールは記述が短くて済む「managed_policy_arnsで直接ポリシーを指定」を選択しました。

記載方法の違いについては以下も参考となりましたので、ご参照下さい。

modules/s3/

s3のリソース構成は以下のようにしています。

resource "aws_s3_bucket" "main" {
  bucket = var.bucket_name
  force_destroy = true
}

resource "aws_s3_bucket_notification" "bucket_notification" {
  bucket = aws_s3_bucket.main.id
  eventbridge = true
}

resource "aws_s3_object" "object_input" {
  bucket = var.bucket_name
  key    = var.object_input_prefix
  depends_on = [aws_s3_bucket.main]
}

resource "aws_s3_object" "object_output" {
  bucket = var.bucket_name
  key    = var.object_output_prefix
  depends_on = [aws_s3_bucket.main]
}

resource "aws_s3_object" "object_model_file" {
  bucket = var.bucket_name
  key    = "asset/yolox_l_8x8_300e_coco_20211126_140236-d3bd2b23.pth"
  source = "../../../asset/yolox_l_8x8_300e_coco_20211126_140236-d3bd2b23.pth"
  depends_on = [aws_s3_bucket.main]
}

変更をしたのはaws_s3_bucket_notificationのみなのですが、同じリソース名なのは少し以外でした。

Lambdaの時と比較すると以下です。

resource "aws_s3_bucket_notification" "bucket_notification" {
  bucket = aws_s3_bucket.main.id

  lambda_function {
    lambda_function_arn = var.lamba_function_arn
    events              = ["s3:ObjectCreated:*"]
    filter_prefix       = "input/"
    filter_suffix       = ".jpg"
  }

  depends_on = [aws_lambda_permission.permission]
}

modules/vpc/

vpcのリソース構成は以下のようにしています。

// VPC
resource "aws_vpc" "vpc" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Name = var.project_prefix
  }
}

// インターネットゲートウェイ作成
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = var.project_prefix
  }
}

// サブネット作成 (パブリック)
resource "aws_subnet" "public_1a" {
  vpc_id                  = aws_vpc.vpc.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = "ap-northeast-1a"
  map_public_ip_on_launch = true

  tags = {
    Name = var.project_prefix
  }
}

// ルートテーブル作成とインターネットゲートウェイへのルート追加
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = var.project_prefix
  }
}

// ルート
resource "aws_route" "public" {
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}

// サブネットにルートテーブルを関連付け
resource "aws_route_table_association" "route_table_association" {
  subnet_id      = aws_subnet.public_1a.id
  route_table_id = aws_route_table.public.id
}

// セキュリティグループ作成
resource "aws_security_group" "sg" {
  name        = "${var.project_prefix}-sg"
  vpc_id      = aws_vpc.vpc.id

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

名前の見分けが困難になるため、TagsのNameに指定されたプロジェクトプレフィックスを設定するようにしています。

インターネットゲートウェイを作成して、Publicなサブネットを構築しています。

セキュリティグループはegressをすべて許可する設定にしています。

AZ名だけはべた書きとなっていますのでご注意ください。

手順

今回はLambdaと違い、事前にECRだけ単体で構築する必要はないためシンプルになります。

Terraformでリソースを全て構築

最初にリソースをすべて作成します。

# 作業フォルダ: terraform/environments/dev/
aws-vault exec {プロファイル名} -- terraform apply -var 'project_prefix={任意のプレフィックス}' # aws-vault経由で実行

Lambdaの時と異なり、ECRレポジトリにimageがpushされていなくてもBatchのジョブ定義は作成できますので、最初にすべて作ることが可能です。

イメージのビルド

まずdocker/lambda/.envというファイルを作成して、環境変数を入力しておきます。

PROJECT_PREFIX="{任意のプレフィックス}"

{任意のプレフィックス}は、後述のterraform applyの際に指定したものと合致するようにしておいてください。

その後は以下でビルドができます。

# 作業フォルダ: docker/lambda/
docker compose build

ECRへコンテナイメージをpush

push_ecr.shというスクリプトを準備していますのでそちらを実行してください。

.\push_ecr.sh {プロファイル名} {任意のプレフィックス}

{任意のプレフィックス}は、後述のterraform applyの際に指定したものと合致するようにしておいてください。

push_ecr.shの内容は以下です。

ProfileName=$1
ProjectPrefix=$2

REGION=$(aws --profile $ProfileName configure get region)
ACCOUNT_ID=$(aws --profile $ProfileName sts get-caller-identity --query 'Account' --output text )

REPOSITORY_NAME=$ProjectPrefix
ECR_BASE_URL="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"
ECR_IMAGE_URI="${ECR_BASE_URL}/${REPOSITORY_NAME}"

echo "ECR_BASE_URL: ${ECR_BASE_URL}"
echo "ECR_IMAGE_URI: ${ECR_IMAGE_URI}"

# ECRへのログイン
aws --profile $ProfileName ecr get-login-password --region ${REGION} | docker login --username AWS --password-stdin ${ECR_BASE_URL}

# tagの付け替え
docker tag "${REPOSITORY_NAME}:latest" "${ECR_IMAGE_URI}:latest"

# ECRへのpush
docker push "${ECR_IMAGE_URI}:latest"

今回からPowershellではなくGit Bashなどからshellを使う形にしています。

以上で構築の準備が完了しました。

動作確認

AWS CLIでjpgファイルをアップロードしてみます。

前回同様にasset/demo.jpgにサンプル画像を配置してありますので良ければお試しください。

(今回はオブジェクトキーは時刻情報が付与するようにしています)

aws s3 cp asset/demo.jpg s3://{バケット名}/input/$(date "+%Y%m%d-%H%M%S").jpg --profile {プロファイル名}

処理が終わると、output/に結果が配置されます。

aws s3 ls s3://{バケット名}/output/ --profile {プロファイル名}
# 2023-09-16 21:05:52          0
# 2023-09-17 08:54:53      78343 20230917-085109.jpg

マネジメントコンソールで確認するとジョブのログが以下のように確認できます。

ジョブの作成はすぐに行われるのですが、開始まで3分30秒と結構時間が掛かっています。

こちらは感触としてはイメージのサイズなどにも影響されて変わっているようでした。

処理結果については前回と同様ですので割愛いたします。

まとめ

いかがでしたでしょうか。

今回はFargateで機械学習のワークロードを構築する方法を見ていきました。

本記事が皆様のお役に立てば幸いです。