こしあん
2025-02-10

CodePipelineでテストを並列実行する


21{icon} {views}


Terraformを用いてCodePipelineを構築し、CodeBuildプロジェクトをrun_orderによって並列実行と依存関係のあるテストの後続実行を実現。GitHubリポジトリのソースコードを取り込み、複数のユニットテストを同時に走らせる構成例を紹介する。

はじめに

  • 以前の記事でCodeBuildで簡単な単体テストを動かす例を紹介した
  • 今回はそれを複数に拡張し、テストを並列に実行することを試してみる
  • 1個1個のテストはCodeBuildのプロジェクトとして定義し、それをCodePipelineのrun_orderを使って制御するという形になる。

アーキテクチャー図

こんなイメージ

  • 各テストはCodeBuildのプロジェクトとして定義
  • run_orderは実行順序。並列化したかったら同一のorderのCodeBuildを複数定義すればそこは並列実行される

CodePipelineの定義

Terraformのリソース定義を見せるのが一番早いのではないかと思う。全体のコードは末尾に

その他気をつけるところは、CodeBuildとCodePipelineにS3の読み書き権限を持たせておく

########################################
# CodePipeline の定義
########################################
resource "aws_codepipeline" "pipeline" {
  name     = "example-pipeline"
  role_arn = aws_iam_role.codepipeline_role.arn

  artifact_store {
    type     = "S3"
    location = aws_s3_bucket.artifacts.bucket
  }

  stage {
    name = "Source"
    action {
      name             = "Source"
      category         = "Source"
      owner            = "ThirdParty"
      provider         = "GitHub"
      version          = "1"
      output_artifacts = ["source_output"]
      configuration = {
        Owner      = var.github_owner
        Repo       = var.github_repo
        Branch     = "master"
        OAuthToken = var.github_token
      }
    }
  }

  stage {
    name = "Test"
    # Test1 と Test2 を同じ run_order (1) で並列実行
    action {
      name             = "Test1"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      version          = "1"
      input_artifacts  = ["source_output"]
      output_artifacts = ["test1_output"]
      run_order        = 1
      configuration = {
        ProjectName = aws_codebuild_project.test1.name
      }
    }
    action {
      name             = "Test2"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      version          = "1"
      input_artifacts  = ["source_output"]
      output_artifacts = ["test2_output"]
      run_order        = 1
      configuration = {
        ProjectName = aws_codebuild_project.test2.name
      }
    }
    # Test3 を run_order 2 で実行(Test1, Test2 両方完了後)
    action {
      name            = "Test3"
      category        = "Build"
      owner           = "AWS"
      provider        = "CodeBuild"
      version         = "1"
      input_artifacts = ["source_output"]
      run_order       = 2
      configuration = {
        ProjectName = aws_codebuild_project.test3.name
      }
    }
  }
}

結果

CodePipelineを実行すると以下のようにテスト1,2が並列実行される

GitHub側

事前準備として、masterブランチに以下のファイルを用意しておく

  • run_unittest1.py
  • run_unittest2.py
  • run_unittest3.py

ユニットテストは適当にGPTで書かせた内容を書いておく(Terraform自体も全部GPTに書かせている)。全部成功するテスト

# run_unittest1.py
import unittest

# メインコード
def process_number(n):
    """
    入力値が奇数なら2乗、偶数なら2倍して返す関数
    """
    if n % 2 == 1:
        return n * n
    else:
        return n * 2

# 単体テスト
class TestProcessNumber(unittest.TestCase):
    def test_odd_number1(self):
        """奇数の場合のテスト: 3 -> 9 (3の2乗)"""
        self.assertEqual(process_number(3), 9)

    def test_odd_number2(self):
        """奇数の場合のテスト: -1 -> 1 (-1の2乗)"""
        self.assertEqual(process_number(-1), 1)

    def test_even_number1(self):
        """偶数の場合のテスト: 4 -> 8 (4の2倍)"""
        self.assertEqual(process_number(4), 8)

    def test_even_number2(self):
        """偶数の場合のテスト: -2 -> -4 (-2の2倍)"""
        self.assertEqual(process_number(-2), -4)

    def test_zero(self):
        """0の場合のテスト: 0は偶数なので0の2倍で0"""
        self.assertEqual(process_number(0), 0)

if __name__ == '__main__':
    unittest.main()
# run_unittest2.py
import unittest

def reverse_string(s):
    """
    入力された文字列を逆順にして返す関数
    """
    return s[::-1]

class TestReverseString(unittest.TestCase):
    def test_regular_string(self):
        """通常の文字列のテスト: 'hello' -> 'olleh'"""
        self.assertEqual(reverse_string('hello'), 'olleh')

    def test_empty_string(self):
        """空文字列のテスト: '' -> ''"""
        self.assertEqual(reverse_string(''), '')

    def test_palindrome(self):
        """回文文字列のテスト: 'radar' -> 'radar'"""
        self.assertEqual(reverse_string('radar'), 'radar')

    def test_with_spaces(self):
        """空白を含む文字列のテスト: 'abc def' -> 'fed cba'"""
        self.assertEqual(reverse_string('abc def'), 'fed cba')

if __name__ == '__main__':
    unittest.main()
# run_unittest3.py
import unittest

def factorial(n):
    """
    nの階乗を返す関数(nは非負整数と仮定)
    """
    if n < 0:
        raise ValueError("n must be a non-negative integer")
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

class TestFactorial(unittest.TestCase):
    def test_factorial_zero(self):
        """0の階乗のテスト: 0! -> 1"""
        self.assertEqual(factorial(0), 1)

    def test_factorial_one(self):
        """1の階乗のテスト: 1! -> 1"""
        self.assertEqual(factorial(1), 1)

    def test_factorial_positive(self):
        """正の整数の階乗のテスト: 4! -> 24"""
        self.assertEqual(factorial(4), 24)

    def test_factorial_large(self):
        """大きな数の階乗のテスト: 6! -> 720"""
        self.assertEqual(factorial(6), 720)

    def test_negative_input(self):
        """負の数の場合はValueErrorを発生させるテスト"""
        with self.assertRaises(ValueError):
            factorial(-5)

if __name__ == '__main__':
    unittest.main()

Terraform

########################################
# 変数定義(例:GitHubのパーソナルアクセストークン)
########################################

variable "github_owner" {
  description = "GitHub リポジトリのオーナー(ユーザーまたは組織)"
  type        = string
}

variable "github_repo" {
  description = "GitHub リポジトリ名"
  type        = string
}

variable "github_token" {
  description = "GitHub のパーソナルアクセストークン"
  type        = string
  sensitive   = true
}

variable "github_url" {
  description = "GitHub リポジトリのクローン用 URL(HTTPS)"
  type        = string
  default = "https://github.com/<github_owner>/<github_repo>.git"
}

variable "codepipeline_bucket_name" {
  description = "CodePipeline 用の S3 バケット名"
  type        = string
}

########################################
# S3 バケット(CodePipeline の artifact_store 用)
########################################
resource "aws_s3_bucket" "artifacts" {
  bucket = var.codepipeline_bucket_name  # ※一意な名前に変更してください
  force_destroy = true
}

########################################
# CodeBuild 用 IAM ロールとポリシー
########################################
resource "aws_iam_role" "codebuild_role" {
  name = "codebuild-service-role"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "codebuild.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "codebuild_policy" {
  name = "codebuild-policy"
  role = aws_iam_role.codebuild_role.id
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": "${aws_s3_bucket.artifacts.arn}/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    }
  ]
}
EOF
}

########################################
# CodePipeline 用 IAM ロールとポリシー
########################################
resource "aws_iam_role" "codepipeline_role" {
  name = "codepipeline-service-role"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "codepipeline.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "codepipeline_policy" {
  name = "codepipeline-policy"
  role = aws_iam_role.codepipeline_role.id
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": "${aws_s3_bucket.artifacts.arn}/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "codebuild:BatchGetBuilds",
        "codebuild:StartBuild"
      ],
      "Resource": "*"
    }
  ]
}
EOF
}

########################################
# CodeBuild プロジェクト (Test1, Test2, Test3)
########################################

# Test1 用プロジェクト
resource "aws_codebuild_project" "test1" {
  name         = "test1-project"
  description  = "CodeBuild project for running Test1"
  service_role = aws_iam_role.codebuild_role.arn

  artifacts {
    type = "NO_ARTIFACTS"
  }

  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/standard:7.0"
    type         = "LINUX_CONTAINER"
  }

  source {
    type     = "GITHUB"
    location = var.github_url
    buildspec = <<EOF
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.12
    commands:
      - echo "Installing dependencies for Test1..."
      - pip install -r requirements.txt
  build:
    commands:
      - echo "Running Test1..."
      - python run_unittest1.py
EOF
  }
}

# Test2 用プロジェクト
resource "aws_codebuild_project" "test2" {
  name         = "test2-project"
  description  = "CodeBuild project for running Test2"
  service_role = aws_iam_role.codebuild_role.arn

  artifacts {
    type = "NO_ARTIFACTS"
  }

  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/standard:7.0"
    type         = "LINUX_CONTAINER"
  }

  source {
    type     = "GITHUB"
    location = var.github_url
    buildspec = <<EOF
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.12
    commands:
      - echo "Installing dependencies for Test2..."
      - pip install -r requirements.txt
  build:
    commands:
      - echo "Running Test2..."
      - python run_unittest2.py
EOF
  }
}

# Test3 用プロジェクト
resource "aws_codebuild_project" "test3" {
  name         = "test3-project"
  description  = "CodeBuild project for running Test3"
  service_role = aws_iam_role.codebuild_role.arn

  artifacts {
    type = "NO_ARTIFACTS"
  }

  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/standard:7.0"
    type         = "LINUX_CONTAINER"
  }

  source {
    type     = "GITHUB"
    location = var.github_url
    buildspec = <<EOF
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.12
    commands:
      - echo "Installing dependencies for Test3..."
      - pip install -r requirements.txt
  build:
    commands:
      - echo "Running Test3..."
      - python run_unittest3.py
EOF
  }
}

########################################
# CodePipeline の定義
########################################
resource "aws_codepipeline" "pipeline" {
  name     = "example-pipeline"
  role_arn = aws_iam_role.codepipeline_role.arn

  artifact_store {
    type     = "S3"
    location = aws_s3_bucket.artifacts.bucket
  }

  stage {
    name = "Source"
    action {
      name             = "Source"
      category         = "Source"
      owner            = "ThirdParty"
      provider         = "GitHub"
      version          = "1"
      output_artifacts = ["source_output"]
      configuration = {
        Owner      = var.github_owner
        Repo       = var.github_repo
        Branch     = "master"
        OAuthToken = var.github_token
      }
    }
  }

  stage {
    name = "Test"
    # Test1 と Test2 を同じ run_order (1) で並列実行
    action {
      name             = "Test1"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      version          = "1"
      input_artifacts  = ["source_output"]
      output_artifacts = ["test1_output"]
      run_order        = 1
      configuration = {
        ProjectName = aws_codebuild_project.test1.name
      }
    }
    action {
      name             = "Test2"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      version          = "1"
      input_artifacts  = ["source_output"]
      output_artifacts = ["test2_output"]
      run_order        = 1
      configuration = {
        ProjectName = aws_codebuild_project.test2.name
      }
    }
    # Test3 を run_order 2 で実行(Test1, Test2 両方完了後)
    action {
      name            = "Test3"
      category        = "Build"
      owner           = "AWS"
      provider        = "CodeBuild"
      version         = "1"
      input_artifacts = ["source_output"]
      run_order       = 2
      configuration = {
        ProjectName = aws_codebuild_project.test3.name
      }
    }
  }
}

所感

  • CodePipelineがStep Functionsみたいになってきて面白い
  • CodeBuildとCodePipelineのIAMポリシーがハマるかもしれない
  • GitHub Actionsでも並列実行のやり方はあるけど、こっちのほうがわかりやすい気はする。ちょっと2つロールがあったりステージごとにプロジェクトを定義したり冗長にはなりがちな気はするけど、buildspec.yaml側いじるみたいな手もあるからいくらでもやり方はありそう。


Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です