GitHub Actions を利用した､データ基盤における CI/CD について

# はじめに

こんにちは。2022年も終わりに近づいてきましたが、いかがお過ごしでしょうか。
グロービスのデータサイエンスチーム、データエンジニアリングユニットのユニットリーダーをしております､爲岡と申します。
本記事では、グロービスのデータ基盤における､ GitHub Actions を利用した CI/CD についてご紹介できればと思います。

# 背景
我々データサイエンスチーム、データエンジニアリングユニットは、グロービスにおいて、主に下記のような役割を担っています。 (詳細に関しては [GLOBIS データサイエンスチームのご紹介 - Speaker Deck](https://speakerdeck.com/globis_gdp/globis-data-science-team-is-hiring?slide=13) をご覧いただければと思います)

1. 質の高いデータ活用の土台づくり｡
    - サービスや意思決定に活用するためのデータを扱うデータ基盤の開発・運用｡
2. データ専門性を生かしたプロダクト価値の向上｡
    - 主に機械学習技術を利用したプロダクト進化への貢献や新規事業創出｡

上記の役割に従って、 2020 年のチーム発足以来､ユニット内でデータ基盤や機械学習に関わる開発を地道に進めてきました｡その甲斐もあって､ 2022 年に入ってから､最優先で対応すべきビジネス課題には一通り対応できてきています｡
ただ､その一方で､開発規模が大きくなってきたことに伴い､徐々に運用面の課題が目立ってきました。
そこで､それらの課題に対して､できるだけ小さなコストで実現できるアプローチを検討した結果､一部の機能に対して､ GitHub Actions を利用した CI/CD を導入することになりました｡
本記事では、実際に本番環境で利用している設定ファイルの中身をご紹介しつつ、それらの CI/CD をどのように実現しているかご紹介できればと思います。

CI/CD の具体的な導入箇所としては下記になります｡
1. レコメンド系システムの自動テスト
2. Airflow DAG ファイルの自動同期

# 1. レコメンド系システムの自動テスト

## 課題

グロービスでは現在､複数のレコメンドシステムを開発・運用しており､また、それぞれに対してテストコードを導入しています｡しかし､これまではテストコードが自動で実行されるようなシステムが存在せず､手動で実行せざるを得ない状況でした｡
そのため､下記のような問題がありました。

1. リリース前のテストの実行し忘れで､ degrade に気づけないことがある｡
2. 改修するたびにテストコードを手動実行しなければならず、手間である｡

## 解決方法

このような課題を解決するために､レコメンドのロジックを管理しているディレクトリ内で改修があった場合､ GitHub Actions Workflow が自動でテストコードを実行してくれるような設定を行いました｡
各レコメンドシステムに対して､それぞれ GitHub Actions workflow を用意しましたが､どれも似たような内容であるため､一例として､グロービス知見録からグロービス学び放題への誘導導線におけるコンテンツレコメンド (`chikenroku_recommend`という名前で管理しています) の workflow ファイルについてご紹介できればと思います。

実際に利用している workflow ファイルは下記になります｡

```yaml:.github/workflows/chikenroku_recommend_test.yml
name: Run tests for chikenroku recommend

on:
  workflow_dispatch:
  push:
    paths:
      # 対象プロジェクトである chikenroku_recommend/ 下の改修をトリガとする
      - 'chikenroku_recommend/**'

env:
  PROJECT_PATH: chikenroku_recommend
  DOCKER_BUILDX_CACHE_PATH: /tmp/.buildx-cache
  DOCKER_BUILDX_NEW_CACHE_PATH: /tmp/.buildx-cache-new
  COMPOSE_FILE_NAME: docker-compose.yml
  TEST_COMPOSE_FILE_NAME: docker-compose.test.yml
  IMAGE_NAME: chikenroku_recommend
  IMAGE_TAG: test_for_github_actions
  SERVICE_NAME: {service_name}
  PROJECT_ABS_PATH_IN_CONTAINER: {abs_path}

jobs:
  setup-build-test:
    name: Setup, Build and Test
    runs-on: ubuntu-latest
    steps:
      # 1. GitHub Actions で利用されるホストに対してリポジトリを checkout する
      - name: Checkout
        uses: actions/checkout@v2

      # 2. レイヤーキャッシュを利用して Docker image をビルドする
      - name: Set up builder instance
        uses: docker/setup-buildx-action@v1

      - name: Restore layer cache (if available)
        uses: actions/cache@v2
        with:
          path: ${{ env.DOCKER_BUILDX_CACHE_PATH }}
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      - name: Build and load image
        uses: docker/build-push-action@v2
        with:
          cache-from: type=local,src=${{ env.DOCKER_BUILDX_CACHE_PATH }}
          cache-to: type=local,dest=${{ env.DOCKER_BUILDX_NEW_CACHE_PATH }},mode=max
          context: ${{ github.workspace }}/${{ env.PROJECT_PATH }}
          tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
          load: true

      # 3. キャッシュをアップデートする
      - name: Update cache
        run: |-
          rm -rf "${DOCKER_BUILDX_CACHE_PATH}"
          mv "${DOCKER_BUILDX_NEW_CACHE_PATH}" "${DOCKER_BUILDX_CACHE_PATH}"

      # 4. レコメンドロジックに対するテストを実行する
      - name: Run tests
        run: |-
          docker compose \
            -f "${GITHUB_WORKSPACE}/${PROJECT_PATH}/${COMPOSE_FILE_NAME}" \
            -f "${GITHUB_WORKSPACE}/${PROJECT_PATH}/${TEST_COMPOSE_FILE_NAME}" \
            run -T "${SERVICE_NAME}" \
            python -m pytest "${PROJECT_ABS_PATH_IN_CONTAINER}"
```

workflow 全体の処理の流れとしては下記になります｡

1. **GitHub Actions で利用されるホストに対してリポジトリを checkout する**
GitHub が用意している action である [checkout](https://github.com/actions/checkout) を利用して､ GitHub Actions で利用されるホストに対してリポジトリを checkout します｡

1. **レイヤーキャッシュを利用して Docker image をビルドする**
レコメンド系システムの開発には全面的に Docker を採用しているため､テストを実行するために  workflow 内で Docker image をビルドする必要があります｡
Docker image のビルドには時間がかかるため､ [cache](https://github.com/actions/cache) action を利用してレイヤーキャッシュを利用できるようにし､ workflow の実行時間を短縮しています｡
こちらで利用しているコードに関しては､ Docker の公式ドキュメント [Cache - Docker](https://docs.docker.com/build/ci/github-actions/examples/#cache) に記載されている内容を全面的に採用しています｡

1. **キャッシュをアップデートする**
今回ビルドされた image のキャッシュを､次回以降の workflow で利用できる状態にします｡

1. **レコメンドロジックに対するテストを実行する**
テストの実行時においては､docker tag などを固定し､開発に利用している `docker-compose.yml` の改修影響を受けないようにする目的で､ `docker-compose.test.yml` をオーバーライドしています｡
※ `docker compose` コマンドは､実行時に､ `-f` オプションに対して複数のファイルを引数として渡すことで､設定をオーバーライドすることができます｡詳しくは [Multiple Compose files - Docker](https://docs.docker.com/compose/extends/#multiple-compose-files) をご覧いただければと思います｡<br />
また､意外な落とし穴ですが､ GitHub Actions で利用されるホストは TTY を提供しておらず､また､ `docker compose` コマンドは [version 2.2.3 から TTY をデフォルトに設定しています](https://github.com/docker/compose/releases/tag/v2.2.3)｡
そのため､ GitHub Actions workflow 上で `docker compose run` や `docker compose exec` を実行する場合は､こちらの workflow ファイルに記載のとおり､ **TTY を無効にするオプションである** `-T` **をつけて実行しないとエラーが発生します**｡

## 2. Airflow DAG ファイルの自動同期

### 課題

グロービスのデータ基盤では､ GCP をメインで利用しており､  Google Cloud Composer を利用したワークフロー管理を行っています｡ (詳細は [グロービスにおけるデータ基盤のアーキテクチャについて](https://note.com/globis_engineers/n/na886e85b1d46) をご覧いただければと思います)
Cloud Composer では､ワークフローエンジンとして Apache Airflow を利用するため､各ジョブは DAG ファイルを利用して管理することになります｡
また､その DAG ファイルは､指定の [GCS バケットに配置することでデプロイが完了し､ Airflow 上で実行ができるようになる](https://cloud.google.com/composer/docs/how-to/using/managing-dags)､という仕組みになっています｡
上記の仕組みに従って､これまでは､ユニットで管理している GitHub リポジトリ上に､ Airflow DAG 管理用のディレクトリを用意した上で､下記のような手順で改修を行ってきました｡

1. Airflow DAG 管理用ディレクトリ下に対して､ DAG ファイルを新規作成､更新､または削除するための Pull Request を発行する｡
2. テストやレビュー､修正を経て､ Pull Request の差分を `master` ブランチにマージする｡
3. ローカル環境に `master` ブランチの差分を反映する｡
4. `gcloud` コマンドを利用して､改修したファイルをローカルから GCS バケットに転送する｡

これらの作業は､改修担当者が手動で行っていたため､下記のような問題が起こっていました。
1. テスト実行のために一度 GCS バケットにアップロードしたが、結局リリースは見送った DAG ファイルが､削除されず本番環境に残されたままになってしまう状況が発生していた｡
2. 改修したファイルを GCS バケットに転送する作業を忘れてしまう事態が発生していた｡

### 解決方法

このような課題を解決するために､ GitHub 上の Airflow DAG 管理用ディレクトリ下に改修が入るたびに､  GCS バケットとの同期が自動で行われるような仕組みを作れないか､と考えました｡
そこで､検討した結果､改修手順 2. (Pull Request の差分の `master` ブランチへのマージ) をトリガとして､下記のタスクを処理する GitHub Actions Workflow が実行されるようにすることで､自動同期を実現しました｡
1. Airflow が参照している GCS バケット内の DAG をすべて削除する｡
2. GitHub 上の Airflow DAG 管理用のディレクトリの DAG ファイルをすべて GCS バケットに転送する｡

実際に利用している GitHub Actions workflow ファイルは下記になります｡

```yaml:.github/workflows/airflow_deploy.yml
name: Deploy Airflow DAGs to Cloud Composer

on:
  workflow_dispatch:
  push:
    branches:
      # master に merge されたときにのみ、自動で実行されるようにする
      - master
    paths:
      # 対象ディレクトリである airflow/dags/ 下の改修をトリガとする
      - 'airflow/dags/**'

env:
  PROJECT_PATH: airflow
  PROJECT_SUBDIR_PATH: dags
  DESTINATION_GCS_BUCKET: {some-bucket}
  DESTINATION_SUBDIR_PATH: dags
  EXCLUDED_FILE_NAME: airflow_monitoring.py

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    steps:
    # 1. GitHub Actions で利用されるホストに対してリポジトリを checkout する
    - name: Checkout
      uses: actions/checkout@v2

    # 2. GCP プロジェクトに対する認証を行う
    - name: Set up GCP authentication
      id: auth
      uses: google-github-actions/auth@v0
      # 認証のための情報は省略する

    # 3. gsutil コマンドを利用するため､ Google Cloud SDK のセットアップを行う
    - name: Set up Cloud SDK
      uses: google-github-actions/setup-gcloud@v0

    # 4. Airflow DAG 管理用ディレクトリを GCS バケットと同期する
    - name: Sync DAGs with gsutil rsync
      run: |-
        SRC="${GITHUB_WORKSPACE}/${PROJECT_PATH}/${PROJECT_SUBDIR_PATH}"
        DST="gs://${DESTINATION_GCS_BUCKET}/${DESTINATION_SUBDIR_PATH}"
        gsutil -m rsync -d -x "${EXCLUDED_FILE_NAME}" -r "${SRC}" "${DST}"
```

workflow 全体の処理の流れとしては下記になります。

1. **GitHub Actions で利用されるホストに対してリポジトリを checkout する**
[1. レコメンド系システムの自動テスト](#1-レコメンド系システムの自動テスト)と同様に､ GitHub Actions で利用されるホストに対して､リポジトリを checkout します｡

1. **GCP プロジェクトに対する認証を行う**
[google-github-actions/auth](https://github.com/google-github-actions/auth) action を利用して､ Airflow が参照している GCS バケットをコンポーネントとして持つ GCP プロジェクトに対する認証を行います｡
※ 認証のための具体的な情報に関しては､省略させていただきます｡

1. **Google Cloud SDK のセットアップを行う**
Google Cloud SDK をセットアップして､ GCS バケットへの同期を行うための `gsutil` コマンドをインストールします｡

1. **Airflow DAG 管理用ディレクトリを GCS バケットと同期する**
`gsutil rsync` コマンドを利用して､ GitHub 上の Airflow DAG 管理用ディレクトリと Airflow が参照している GCS バケットとの同期を行います｡
今回のコマンド実行時には､ workflow ファイルに記載のとおり､いくつかのオプションを設定していますが､**今回の要件を達成するためには､** `-d` **オプションが重要になります**｡
このオプションをつけると､コマンド実行時に､同期元に存在するファイル以外で､同期先のディレクトリに存在しているファイルはすべて削除されます｡
これにより､同期元 (GitHub 上の Airflow DAG 管理用ディレクトリ) と同期先 (GCS バケット) の内容を全く同じ状態にすることができます｡
また､`-x` オプションは､引数でファイルを指定することで､上記の `-d` オプションの対象から外すことができます｡ Airflow に備え付けの DAG である `airflow_monitoring.py` は､サービス自体のモニタリングの用途で利用され､ GitHub の Airflow DAG 管理用ディレクトリとの同期は不要であるため、同期対象から外して GCS バケットにのみ存在するようにしています｡
さらに､`-m` オプションは､同期元から同期先への並列アップロードを可能にし､効率的なアップロードを実現するものです｡
最後に､`-r` オプションは､ディレクトリやその中身も含めた再帰的な同期を可能にするものです｡

## まとめ

本記事では、データ基盤における GitHub Actions workflow を利用した CI/CD についてご紹介させていただきました。定性的ではありますが､この取り組みの結果として、下記のような所感があります。

- 各コマンドのオプションの調査や認証の設定等、導入に多少手間はかかったものの、基本的には各課題に対して YAML ファイル 1 つ書くだけ、という小さなコストで CI/CD を実現し､課題が解決できた｡
- リリース前後に必要な作業工程が減ったため、実作業のコストだけでなく、ワーキングメモリの節約にもなり、開発が楽になっている実感がある。

今後も引き続き､適切な課題設定を行い､適切な手段で運用課題の解決を行っていきたいと思います｡

今回ご紹介させていただいたとおり、我々は開発だけでなく、運用に関しても､その重要性をユニット内で共通認識として持っており、力を入れています。また､今後はデータ基盤の運用だけでなく､ MLOps 領域に関しても積極的にチャレンジしていきたいと思っています｡

この記事を読んで､グロービスで働くことにご興味をお持ちになった方がいらっしゃいましたら､是非お気軽に[カジュアル面談にお申し込み](https://recruiting-tech-globis.wraptas.site/)いただければと思います。 ([@zettaittenani](https://mobile.twitter.com/zettaittenani) まで直接ご連絡いただいても構いません。)

最後までお読みいただきまして、誠にありがとうございました。

## 参考文献

- Alexandru. “[Fix "panic: provided file is not a console" from Docker Compose in Github Actions.](https://stackoverflow.com/questions/70855915/fix-panic-provided-file-is-not-a-console-from-docker-compose-in-github-action/70856332)” *Stack Overflow*, 25 January 2022. Accessed 12 December 2022.
- Hohner270. “[DockerのTTYって何?](https://zenn.dev/hohner/articles/43a0da20181d34)” *Zenn*, 20 May 2021. Accessed 12 December 2022.
- [rsync - Synchronize content of two buckets/directories - Google Cloud](https://cloud.google.com/storage/docs/gsutil/commands/rsync)
- [docker/compose issue #9035: Do not try to guess when to allocate a TTY and keep it as default - GitHub](https://github.com/docker/compose/pull/9035)
- [docker/compose release tag version 2.2.3 - GitHub](https://github.com/docker/compose/releases/tag/v2.2.3)