LCL Engineers' Blog

バス比較なび・格安移動・バスとりっぷを運営する LCLの開発者ブログ

カスタムイメージでCodeBuildをバッチジョブ実行基盤として使う

はじめに

この記事はLCL Advent Calendar 2021 - 2日目です。

qiita.com

バックエンドエンジニアの星野です。アドベントカレンダーを4日連続にしたことについて2日目ですでにギリギリです。

昨日の記事ではバッチジョブ実行基盤のパターンを紹介しましたので今日はその続きになります。

techblog.lclco.com

最初はCodeBuildのカスタムイメージです。 Fargateと比較した際のポイントやバッチジョブをTerraformから作成しやすくするための工夫について解説していきます。

CodeBuildをカスタムイメージで利用する

CodeBuildはAWSから提供されているDockerイメージの他にPublic RegistryやECRから任意のDockerイメージをビルド環境として使うことができます。 LCLが利用しているフレームワークであるRuby on Railsですとgemをインストール済みのDockerイメージを指定することでrails runnerやrakeタスクをすぐに実行することができます。 また、VPCをサポートしているためデータベースなどVPC内のリソースに接続するなどアプリケーションのビルドに囚われずなんでもできます。

CodeBuild VS Fargate

この用途ですとFargateを利用して単発のタスクを実行するの大きくは変わらないように聞こえます。 単発の実行においてCodeBuildがFargateよりも扱いやすいポイントを挙げていきます。

1. 実行コマンドの指定

CodeBuild: buildspec.ymlに記載

Fargate: Dockerfileに記載したentrypointやcommandまたはタスク定義によるそれらの上書き

Fargateの場合はDockerの作法にしたがってentrypointやcommandに実行コマンドを指定しますが1回の起動で複数のコマンドを実行する必要がある場合は別途シェルスクリプトなどを用意することがあるでしょう。 一方CodeBuildはbuildspec.ymlによって実行コマンドを連続して記載できるので追加ファイルなど不要で設定することができます。

2. コンテナの起動(コンソール)

CodeBuild: Start Buildボタン

Fargate: タスク定義の画面からDeployボタンを押下した後にクラスタやVPCの選択が必要

FargateはECSの性質上コンテナを起動する場合はタスク定義からクラスタやVPCの設定がする必要がある一方CodeBuildはボタンで一発です。 FargateでもStep Functionsから実行するなど工夫次第で解決することはできますがCodeBuildのみで設定が完結するのは大きいです。

f:id:hosht:20211201121703p:plain
CodeBuildはボタンを押すだけ

f:id:hosht:20211201121739p:plain
ECSは追加でネットワークなどを指定する

3. コンテナの起動(CLI)

CodeBuild: aws codebuild start-build--project-nameをオプションで渡す

Fargate: aws ecs run-task--task-definitionに加えて--network-configurationをオプションで渡す

run-taskのドキュメントには--network-configurationが必須オプションではない記述ですがFargateはネットワークモードがawsvpc固定なので必須のオプションになります

コンソールと同様に1度作成してしまえばCodeBuildの方が少ないステップで起動まで進むことができます。

4. 実行履歴とログ出力

CodeBuild: 実行履歴は永続化される。ログはCloudWatch LogsかS3を選択可能でコンソール画面から見やすい

Fargate: 実行履歴は最低1時間保持された後に削除対象。ログはCloudWatch LogsまたはFireLensで柔軟に設定可能。コンソールの出来は新バージョンも後一歩な印象

実行履歴がFaragateは1時間以上経過すると削除されてしまうのに対しCodeBuildはデフォルトでは無制限に保存されます。 ログ出力は両者ともCloudWatch Logsが使えて、CodeBuildは他にS3のみ、FargateはFireLensで柔軟に設定可能なためここはFaragateが優れているポイントです。 ただしコンソールから参照する場合はCodeBuildはリアルタイムで表示できたりUIがこなれているためCodeBuildの方がデフォルトで扱いやすい印象です。 Fargateの場合は外部SaaSやAthenaなど多少の作り込みが必要なため要件やコストとのトレードオフなると思われます。

f:id:hosht:20211201122405p:plain
CodeBuildは実行履歴を遡って参照しやすい

f:id:hosht:20211201122435p:plain
CodeBuildは実行単位でログ出力を参照しやすい

5. バッチビルド

CodeBuild: 指定したビルドの完了後に次のビルドを起動するバッチビルドを定義可能

Fargate: Step Functionsなど他のサービスと連携が必要

CodeBuildは単体でバッチビルドができます。

docs.aws.amazon.com

バッチビルドにより簡単なワークフローを作成することができます。Fargateは単体ではこのような機能は提供されていません。

6. EventBridgeに通知するイベント

CodeBuild: イベントにCloudWatch Logsのリンクを含む*1

Fargate: イベントにCloudWatch Logsのリンクは含まれない*2

細かいところですがCodeBuildは状態変更イベントをEventBridgeに通知する際にCloudWatch Logsのリンクを含めてくれます。 これはSlackに通知する際に役に立ちます。

7. 最小マシンスペックと価格

CodeBuild: 2vCPU/メモリ3GiB $0.3/hour*3

Fargate: 0.25vCPU/メモリ0.5GiB 約$0.014/hour + Savings Plansの対象 + Spotで最大70%オフ*4

いずれも記事執筆時点での東京リージョンの価格

CodeBuildは最小のスペックのオプションが大きめでスケールアップの選択肢が少なめかつ割引のオプションもないのでコスト面ではサイズを柔軟に選択可能なFargateの圧勝です。 ちなみに最大スペックはFargateが4vCPU/メモリ30GiBに対しCodeBuildは72vCPU/メモリ144GiBなので上限は勝利しています。

1~6までの付加価値とのトレードオフで適切に選択しましょう。

Terraformでバッチジョブを登録する

LCLではTerraformによるコード化を推進しているためバッチジョブの登録もTerraformから行います。 ここではTerraformに不慣れな開発者でも登録をしやすくする工夫を解説します。

resource "aws_codebuild_project" "batch_job" {
  for_each = local.batch_jobs_codebuild

  name          = "batch-job-${each.value.name}"
  description   = each.value.description
  build_timeout = each.value.build_timeout != null ? each.value.build_timeout : 60
  service_role  = aws_iam_role.codebuild.arn

  artifacts {
    type = "NO_ARTIFACTS"
  }

  environment {
    compute_type                = each.value.compute_type != null ? each.value.compute_type : "BUILD_GENERAL1_SMALL"
    image                       = each.value.default_image != true ? "ecr_repository_url:prod" : "aws/codebuild/standard:5.0"
    type                        = "LINUX_CONTAINER"
    image_pull_credentials_type = "CODEBUILD"
    privileged_mode             = true
  }

  logs_config {
    cloudwatch_logs {
      group_name  = aws_cloudwatch_log_group.batch_job[each.key].name
      stream_name = "job"
    }
  }

  source {
    type = "NO_SOURCE"
    buildspec = templatefile(each.value.default_image != true ? "buildspec.yml.tpl" : each.value.template, {
      account_id = data.aws_caller_identity.current.account_id
      region     = data.aws_region.current.name
      task       = each.value.task
    })
  }

  vpc_config {
    vpc_id             = "vpc-id"
    subnets            = ["subnet-id"]
    security_group_ids = ["security-group-id"]
  }
}

resource "aws_cloudwatch_log_group" "batch_job" {
  for_each = local.batch_jobs_codebuild

  name              = "job-${each.value.name}"
  retention_in_days = 0
}

TerraformでCodeBuildプロジェクトを作成する定義はこのようになります。

最初のポイントはsourceのtypeを"NO_SOURCE"にしてbuildspecをtemplatefile関数で渡すところです。 このようにすることでterraformのrootモジュールに含めているbuildspec.yml.tplをテンプレートファイルとしてバッチジョブ毎に専用のbuildspec.ymlを生成します。

version: 0.2

env:
  shell: bash
  variables:
    RAILS_ENV: production
    RELEASE_STAGE: prod
    HOME: /app
  parameter-store:
    DATABASE_PASSWORD: /awesome-service/prod/app/rails/DATABASE_PASSWORD
    DATABASE_HOST: /awesome-service/prod/app/rails/DATABASE_HOST

phases:
  pre_build:
    commands:
    - cd $ROOT_PATH
  build:
    run-as: rails
    commands:
    - ${task}

buildspec.yml.tplはバッチジョブ毎に${task}を上書きします。 後述するvariablesでrails runnerやrakeタスクのコマンドを渡します。

variable "awesome_job" {
  type = map(object({
    name          = string
    description   = string
    task          = string
    schedule      = string
    enabled       = bool
    build_timeout = optional(number)
    compute_type  = optional(string)
    default_image = optional(bool)
    template      = optional(string)
  }))
  default = {
    awesome-job = {
      name        = "awesome-job"
      description = "すごいバッチジョブ"
      task        = "rake awesome_job"
      schedule    = "cron(0 0 * * ? *)"
      enabled     = true
    }
  }
}

variable "great_job_with_addtional_config" {
  type = map(object({
    name          = string
    description   = string
    task          = string
    schedule      = string
    enabled       = bool
    build_timeout = optional(number)
    compute_type  = optional(string)
    default_image = optional(bool)
    template      = optional(string)
  }))
  default = {
    great-job-with-addtional-config = {
      name          = "great-job-with-addtional-config"
      description   = "すごい追加設定のジョブ"
      task          = "rails runner Tasks::Great::Job"
      schedule      = "cron(0 0 * * ? *)"
      enabled       = true
      build_timeout = 480
      compute_type  = "BUILD_GENERAL1_2XLARGE"
    }
  }
}

Terraformの1つのvariableに対して1つのジョブを対応させます。*5ポイントとしてOptional Object Type Attributesを利用しています。 これはterraform 0.14から実験的機能として使えるようになった設定で、文字通り任意で追加できるオプションのパラメータとして定義できます。

www.terraform.io

Optional Object Type Attributesを利用することでデフォルト値を上書きしたい場合だけattributeに追加します。

compute_type = each.value.compute_type != null ? each.value.compute_type : "BUILD_GENERAL1_SMALL"

例えばcompute_typeをこのように書くことでcompute_typeを明示的に指定した場合はその値を、無ければデフォルト"BUILD_GENERAL1_SMALL"を設定できます。CodeBuildではdefault_imageをtrueにすることでカスタムイメージではなくAWSマネージドなイメージで起動できるようにしたり、templateに専用のbuildspec.ymlのファイルを渡すことで特別な対応が必要な場合の設定を扱いやすくしています。

Optional Object Type Attributesは実験的機能で今後インターフェースが変更される可能性があり利用については注意しましょう。

locals {
  batch_jobs_codebuild = merge(
    var.awesome_job,
    var.great_job_with_addtional_config
  )
}

最後に複数のvariableをlocalsで一つのMap型にまとめてfor_eachで回します。 この一連のサンプルコードではvariableのscheduleとenabled attributeを利用していませんがEventBridgeから定期実行するリソースもCodeBuildプロジェクト毎に作成しています。

駆け足での説明になりましたが、このように定義することで開発者がTerraformのことに詳しくなくてもvariableとlocalsをコピペして追加していくだけでバッチジョブを量産できる体制にしています。

まとめ

CodeBuildをバッチジョブの実行基盤として使う場合のポイントを見ていきました。 CodeBuildの運用体験は強力なものがあるので財布と相談しながら是非一度試してみてください。

採用情報

LCLでは開発メンバーを募集しております!

もし興味をお持ちになりましたらお気軽に応募してください。

www.lclco.com

*1:https://docs.aws.amazon.com/ja_jp/codebuild/latest/userguide/sample-build-notifications.html#sample-build-notifications-ref

*2:https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs_cwe_events.html#ecs_task_events

*3:https://aws.amazon.com/codebuild/pricing/

*4:https://aws.amazon.com/fargate/pricing/

*5:TerraformはString型はダブルクォート必須なのでtaskの文字列をエスケープしなくてもいいようにRubyの場合は呼び出すコマンドの引数はキーワードで取るようにしておくと扱いやすいです