Javaメインで開発してる人間がPython × CodeBuild × ECSを使用したCI/CDを導入した際に気をつけた点

# はじめに
## Java案件でAWS上でCI/CDの運用をスタート
弊社ではここ数年、スケーラビリティの確保が必要なAPIや、必要な時が限られるバッチについて、Amazon Elastic Container Service(ECS)のFargateを使用してコンテナ化して提供しています。

プロトタイプ開発(技術選定)を行っている間は即時反映が可能なAmazon Elastic Compute Cloud(EC2)の方が簡易ですが、システムの方向性が固まり、ダウンタイム影響性やデプロイ作業のコストを考え始めるフェーズになると、CodeBuildとECSを使用してCI/CDを実現していった方が労力が減るので、弊社ではいくつかのJava(Spring)システムで採用を始めました。

## 今までの知見を元にPythonのAPIシステムのCI/CD構築を依頼される
弊社ではオークション情報の分析結果の提供を主業務としていますが、ここ数年そういった情報の正規化に注力しており、社内業務向けにPythonによるAPIを提供しています。

今回、このAPIがある程度形になって来たので、Python向けCI/CD構築実現の話が持ち上がりました。
前述のとおり、Pythonでの構築は初めてでしたが、Springプロジェクトでの知見を元に、CI/CD化を行いました。
今回は導入にあたり調査・検証が必要だったポイントを書かせていただきます。

# システム構成(すでにできているところ、今回のターゲット)
ECRからのDockerイメージ取得～デプロイについては、すでにAWS CodePipelineを使用したブルー/グリーンデプロイの仕組みができていました。
が、イメージ作成までの工程については未だ開発端末上で手動で行っていました(画像下部 紫点線枠)。

本書ではGitHubにタグをpushするとテスト、モジュール作成、ECRへのイメージpushを自動で行うまでの処理(画像下部 赤点線枠)にフォーカスをあてて記載します。
※今回AWS上でのCI/CDを急いだのは、ECSとビルド環境のCPUが同一(ARM)でないとビルドできない(環境保有しているメンバーが限られる)という問題を早急に解消したかったからでもあります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200155/72cdd858-be9d-0456-0dcb-73b284d0bb8d.png)

# CodeBuildでの処理の流れについて

今回、CodeBuildを使用して何を実現するかは以下のとおりです。

1. GitHubからコードを取得する
2. 外部ストレージから学習モデルなど、リリース用イメージに必要なリソースをダウンロードする
    - 今回はS3に事前にアップロードしたファイルをダウンロードすることにします
3. テストを実行する
4. テストレポートをAWSのレポートグループ上に送信する
5. リリースモジュール(whl)の作成、GitHub上へのアップロード
6. リリース用イメージを作成、ECRへpush

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200155/00792f9c-fa3b-634c-9ffb-f58337e41e1d.png)

テストの実行、リリースモジュールの作成については、既存のDockerfileがあったので流用しました。
ただ、今までリリースモジュールのビルドは開発環境用のDockerコンテナ内で実施しており、ビルドするだけであれば不要なリソースもあったので、別途ビルド専用のDockerイメージを作ることにしました。

# 環境構築

前述の実現内容を元に、各部の処理を作成していきます。

## 各処理のコンテナ周りの構築
各処理ともMiniforge3のイメージを使いconda環境を作成していきます。
起動後の処理についてはshを実行する形をとっていますが、この場合 `source` コマンドでcondaの初期化を行わないと実行できないので注意が必要です。

### テスト用環境
テスト用のDockerファイルは、開発端末で自由にテストを回せられるように `docker run` を実行しても全件テスト用のshファイルを起動しないようにしています。
CodeBuild上で実行する際は `docker run` 時に全件テスト用のshを起動するようにします(後述のbuildspec参照)。

```test.yaml
# テスト環境用のconda環境設定ファイル
name: test
channels:
  - conda-forge
dependencies:
  - {テスト時に必要なPython向けパッケージ各種}
```

```test.Dockerfile
# テスト環境用のDockerファイル
FROM condaforge/miniforge3:latest

RUN apt -y update && \
    apt -y upgrade && \
    {テストで必要なパッケージ}

COPY ./test.yaml ./

RUN conda update --all && \
    conda update -n base conda && \
    conda env create -f test.yaml

COPY ./test.sh .
RUN chmod +x test.sh
```

```test.sh
#!/bin/bash
# 全件テスト用shファイル

set -e
source /opt/conda/etc/profile.d/conda.sh
conda activate test
cd /mnt
python -m unittest
```

### ビルド用環境
ビルド用環境もほぼ同様の作りですが、ビルドを行うだけなのでconda環境構築用の設定はPythonの設定をしているだけです。
また、こちらは一貫してモジュールを作るだけのイメージなので、実行時にビルド用shファイルを実行しています。

```build.yaml
# ビルド環境用のconda環境設定ファイル
name: build
channels:
  - conda-forge
dependencies:
  - python=3.8
```

```build.Dockerfile
# ビルド環境用のDockerファイル
FROM condaforge/miniforge3:latest

COPY ./build.yaml ./

RUN conda update --all && \
    conda update -n base conda && \
    conda env create -f build.yaml

COPY ./build.sh .
RUN chmod +x build.sh

CMD ["./build.sh"]
```

```build.sh
#!/bin/bash
# ビルド用shファイル
set -e
source /opt/conda/etc/profile.d/conda.sh
cd /mnt
rm -rf dist
conda activate build
python setup.py clean --all bdist_wheel
```

### リリース用環境

```release.yaml
# リリース用のconda環境設定ファイル
name: release
channels:
  - conda-forge
dependencies:
  - {リリース用環境に必要なPython向けパッケージ各種}
```

```release.Dockerfile
# リリース用のDockerファイル
FROM condaforge/miniforge3:latest

RUN apt -y update && \
    apt -y upgrade && \
    {リリース用環境に必要なパッケージをインストール}

ARG distDir=/usr/src/{プロジェクト名}/dist

COPY ./dist/{プロジェクト名}-*.whl $distDir/
COPY release.yaml $distDir/
COPY {稼働時に必要なリソースファイルをコピー}

WORKDIR $distDir
RUN conda update --all && \
    conda update -n base conda && \
    conda env create -f release.yaml && \
    conda run -n release pip install --no-deps {プロジェクト名}-*.whl

EXPOSE {APIで使用するポート番号}

ENTRYPOINT [ \
    "conda", "run", "--no-capture-output", "-n", "release", \
    :
    :
    :
]
```

## buildspecの作成
buildspecを作成していきます。
今回GitHubプロジェクトへビルドしたモジュールをアップロードする処理が入るので

- GitHub上でタグをpushした際にCodeBuildに通知させるWebhook認証(OAuth認証、CodeBuild作成時に認証できる)
- CodeBuild上でビルドされたモジュールをアップロードする際にAPIを実行するためのトークン

の二つの認証が必要になります。

```buildspec.yml
version: 0.2

env:
  variables:
    GITHUB_REPOS_OWNER: {CodeBuild上からGitHubリポジトリにアクセスするためのアカウント名}
    GITHUB_REPOS_NAME: {GitHubリポジトリ名}

    # 以下の環境変数はCodeBuild上の環境変数に設定する(一部SecretsManager)
    # AWS_DEFAULT_REGION: ap-northeast-1
    # AWS_ACCOUNT_ID: {AWSのアカウントIDを指定する}
    # GITHUB_TOKEN: {GitHubのアクセス用のトークン Releaseを発行できる権限が必要}
    # ECR_IMAGE_REPOS: {ECRにプッシュする対象のリポジトリ指定}
    # MODEL_S3_URI: {学習モデルファイル保管先S3バケット＆キー ex S3://バケット名/キー/ }
phases:
  # ビルド前の処理
  pre_build:
    commands:
      # AWS ECRのプライベートレジストリの認証を行う(ローカルでECRタグを操作するための連携)
      - echo "CODEBUILD_WEBHOOK_TRIGGER=$CODEBUILD_WEBHOOK_TRIGGER"
      - >
        set -e;
        if [[ "$CODEBUILD_WEBHOOK_TRIGGER" == tag/* ]]; then
          echo Tag push trigger;
          IMAGE_TAG=${CODEBUILD_WEBHOOK_TRIGGER#tag/};
        else
          echo Not tag push;
        fi
      - echo "Target tag is [latest, $IMAGE_TAG]"
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
      # 学習モデルのDL
      - aws s3 cp $MODEL_S3_URI $CODEBUILD_SRC_DIR/model --recursive
  build:
    # テストの実行、モジュールの作成、イメージの作成(latest&GitHubのタグ名)
    commands:
      # ～テスト・ECRで必要なリソースの準備～
      #                   :
      #                   :
      #                   :
      # テストの実行
      - docker build -t {プロジェクト名}/test -f $CODEBUILD_SRC_DIR/test.Dockerfile .
      - docker run -v $CODEBUILD_SRC_DIR:/mnt --rm --name test {プロジェクト名}/test test.sh
      # リリース用モジュール作成
      - docker build -t {プロジェクト名}/build -f $CODEBUILD_SRC_DIR/build.Dockerfile .
      - docker run -v $CODEBUILD_SRC_DIR:/mnt --rm --name build {プロジェクト名}/build
      # イメージ作成、タグの作成
      - >
        set -e;
        docker build -t {プロジェクト名}/release -f $CODEBUILD_SRC_DIR/release.Dockerfile .;
        echo "docker tag ${ECR_IMAGE_REPOS}:latest";
        docker tag ${ECR_IMAGE_REPOS}:latest $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/${ECR_IMAGE_REPOS}:latest;
        if [[ -n "${IMAGE_TAG}" ]]; then
          echo "docker tag ${ECR_IMAGE_REPOS}:${IMAGE_TAG}";
          docker tag ${ECR_IMAGE_REPOS}:latest $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/${ECR_IMAGE_REPOS}:${IMAGE_TAG};
        fi;
  post_build:
    # モジュールのアップロード(GitHub)、タグ付与済みイメージをECRへpush
    commands:
      - echo Build completed on $(date)
      # GitHubへwheelをアップロード
      - >
        set -e;
        if [[ -n "$GITHUB_TOKEN" ]]; then
          if [[ -n "${IMAGE_TAG}" ]]; then
            echo Create release for GitHub.;
            response=$(curl -X POST -H "Accept:application/vnd.github.v3+json" -H "Authorization:token $GITHUB_TOKEN" https://api.github.com/repos/${GITHUB_REPOS_OWNER}/${GITHUB_REPOS_NAME}/releases -d "{\"tag_name\":\"${IMAGE_TAG}\", \"prerelease\":true}");
            release_id=$(echo $response | jq '.id');
            echo ${release_id};

            file_path=$(ls ${CODEBUILD_SRC_DIR}/{プロジェクト名}-*-py3-none-any.whl);
            file_name=$(basename $file_path);
            curl -X POST -H "Content-Type:$(file -b --mime-type $file_path)" -H "Authorization:token ${GITHUB_TOKEN}" --data-binary @${file_path} "https://uploads.github.com/repos/${GITHUB_REPOS_OWNER}/${GITHUB_REPOS_NAME}/releases/${release_id}/assets?name=${file_name}";
          fi;
        fi;
      # ECRへイメージ&タグをpush
      - >
        set -e;
        echo Pushing the Docker image...;
        echo ECR push ${ECR_IMAGE_REPOS};
        echo "docker push ${ECR_IMAGE_REPOS}:latest";
        docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/${ECR_IMAGE_REPOS}:latest;
        if [[ -n "${IMAGE_TAG}" ]]; then
          echo "docker push ${ECR_IMAGE_REPOS}:${IMAGE_TAG}";
          docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/${ECR_IMAGE_REPOS}:${IMAGE_TAG};
        fi;
reports:
  test-report:
    files:
      # テストレポートとして送信したいファイルをワイルドカードで指定する(使用したテストフレームワークは後述)
      - 'TEST-*.xml'
    discard-paths: no
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200155/0acedf61-bd54-a4db-8dc8-5f5596ac8620.png)

今回はECRを作成する(デプロイ)過程でテストも行う作りにしているので、GitHub上でプルリクエストを行うたびにテストを行わないようにします。
イベントタイプとしてはプッシュのみで、`HEAD_REF` に `refs/tags/.*` と設定することでタグをプッシュした際にのみビルドが行われるようになります。
(プルリクエスト時にテストだけを実施したければ、ウェブフックイベントフィルタグループを追加し、パラメータによりbuildspec上で処理を分岐することも可能かと思います)

# テストレポートを出す(xmlrunnerの採用)

今回、一つの課題となっていたのがテストレポートの出力でした。
今までは `unittest` を実行し、実行結果を目視確認していましたが、今回の対応に辺りテストレポートを出力するようにしました。
今回はCodeBuild上でサポートしており、Springでも馴染みのあるJunit形式のレポートが出力できる [unittest-xml-reporting(xmlrunner)](https://github.com/xmlrunner/unittest-xml-reporting) を採用しました。

xmlを出力するにはlibxml2とlibxsltも必要だっので、condaの環境設定ファイルに以下のように追加しました。

```test.yaml
# テスト環境用のconda環境設定ファイル
name: test
channels:
  - conda-forge
dependencies:
  - {テスト時に必要なPython向けパッケージ各種}
  - libxml2=2.11
  - libxslt=1.1
  - unittest-xml-reporting=3.2
```

テストはコマンド末尾を `xmlrunner` に変更するだけで可能です。
テスト結果は `TEST-{テスト名}-{yyyymmddhhmmss}.xml` という形式で直下に作成されますので、buildspec上の `reports` でワイルドカードを指定してレポートを送信します。

変更前
```test.sh
python -m unittest
```

変更後
```test.sh
python -m xmlrunner
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200155/106c55fb-781b-5ebc-7dd0-8bb02fd19cf3.png)

実際にビルドが完了するとCodeBuildの画面上からレポートが表示できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200155/850a6aac-dbeb-069e-6f1d-bbb971f86a3a.png)

但し、 `unittest-xml-reporting` は設定を特に行わないと、画像のようにディレクトリ直下にレポートファイルが大量に出力されるので、レポート専用ディレクトリに出力するよう設定する必要があります。
(今回は影響性なしと判断し、gitignoreに追加して誤pushを防止する以外はこのままとなりました)

# 環境構築に時間が掛かる問題の解消(mambaの採用)

もう一つの問題は、環境構築に時間が掛かる問題です。
本プロジェクトではcondaを採用していますが、CodeBuildの処理全体で1時間近くかかっており、その大半はcondaによる環境構築の時間でした。

今回は検証も兼ね、[mamba](https://github.com/mamba-org/mamba)による環境構築に切り替えました。
mambaはC++で作られたパッケージマネージャで、パッケージの並列ダウンロードができるのが特徴です。
Miniforge3イメージを使用しているとmambaへの切替は容易で、各種Dockerfile上のRUNコマンドで発行していた `conda` コマンドを `mamba` に切り替えるだけです。
condaの環境設定ファイルを変更する必要はありません。
これによりcondaでは1時間かかっていたCodeBuildが15分に短縮されました。

```Dockerfile
RUN mamba update --all && \
    mamba update -n base conda && \
    mamba env create -f {各種conda環境設定yamlファイル}
```

# おわりに
今回はPythonにおけるCodeBuildの利用方法について書かせていただきました。
あまりPython周りに詳しくない中での対応だったので、環境構築を行うのに調査を要しましたが、やることはJavaとほぼ変わらないかなという印象で、Python関連のプロジェクトでもシームレスにビルド周りの切替ができる印象でした。