GitHub Actions を使って GitHub モノレポ + マイクロサービスの CD（継続的デリバリー）を実現する

# はじめに

モノレポ + マイクロサービスの構成の際に GitHub Actions を使った CD（Continuous Delivery；継続的デリバリー）をどのように実現するのかについて検討したのでまとめてみます。

# 前提条件

前提条件として言語とディレクトリー構成について記載します。

## 言語

リポジトリーの中のサービスは TypeScript で記述されており、Yarn Workspaces で workspace として管理されており、またコンテナ化されていることを想定して記載していますが、サービスごとに別の言語で記載されていても同じ仕組みで動作するかと思います。

## ディレクトリー構成

以下のようなディレクトリー構成を想定しています。直下に services ディレクトリーがあり、その中に各サービスがディレクトリーを分けて配置されています。

AWS などのインフラを管理する IaC も services/infrastructure として同じリポジトリーで管理しているものとします。

```text
./
├── .github/
│   └── workflows/
├── package.json
└── serivces/
    ├── infrastructure/
    ├── service-a/
    └── service-b/
```

# 課題

このような構成にした際に、デプロイのスコープが問題となります。リポジトリーで利用するワークフローは .github/workflows に共通リソースとして管理され、そこに service-a, service-b のデプロイワークフローを記述すると、service-a のみを更新した際にも、service-b が一緒にデプロイされてしまいます。これによって、ワークフローの実行時間がかかる、また、構成によっては不要なデプロイ操作が行われてしまうことが懸念されます。

# 解決策

そこで変更があったサービスのみをデプロイする方法について考えてみます。

## 解決策1（paths フィルター）

まず、解決策として考えられるのが、パスによる包含および除外の機能を利用する方法です。

https://docs.github.com/ja/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore

以下のように対象となるパスを定義しておき、サービスごとのワークフローを準備する方法が考えられます。

例えば、service-a のみをデプロイする場合は以下のようになるかと思います。

```yaml
name: Deploy service-a

on:
  push:
    paths:
      - 'serivces/service-a/**'

jobs:
  # service-a をデプロイする処理を記載
```

こちらのデメリットとしては、同じデプロイ方法で良い場合でもサービスごとにワークフローファイルを作成しなければならないため、ワークフローファイルの管理が煩雑になってしまうこと、サービスごとの依存関係がある場合にサポートされないことが挙げられます。

## 解決策2（git diff）

もう1つ考えられるのが、Git の機能を利用して変更があったサービスのみがデプロイされるようにする方法です。

前回との差分を取得するコマンドで対象のサービスに変更があったかを検知し、変更されたサービスのみデプロイします。

今回はこちらの方針で検討してみます。

# アーキテクチャ

ワークフローは手動実行（workflow_dispatch）で記載しますが、push による自動トリガーにすることもできます。

IaC で管理されている全体インフラに変更がある場合、各サービスデプロイより前にデプロイしたいので、デプロイの依存関係も考慮します。サービス間で依存がある場合にも、同じ仕組みで表現できるようにします。

全体をデプロイするワークフロー内で以下のジョブを実装します。

1. 変更検知ジョブ（prepare）
1. インフラデプロイジョブ
1. 各サービスデプロイジョブ

```mermaid
graph TD;
    prepare-->infrastructure;
    infrastructure-->service-a;
    infrastructure-->service-b;
```

また、全体をデプロイするワークフローとは別にサービスごとに単体でデプロイできた方が便利かと思いますので、サービス単体をデプロイするワークフローも同時に実現できるようにします。

# 具体的なワークフロー

## 変更検知ジョブ（prepare）

変更検知は以下のように行います。

```yaml:.github/workflows/deploy.yaml
jobs:
  prepare:
    outputs:
      infrastructure-diff-count: ${{ steps.infrastructure_changes.outputs.diff-count }}
      service-a-diff-count: ${{ steps.service_a_changes.outputs.diff-count }}
      service-b-diff-count: ${{ steps.service_b_changes.outputs.diff-count }}

    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 2
      - id: infrastructure_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/infrastructure | wc -l` >> $GITHUB_OUTPUT
      - id: service_a_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/service-a | wc -l` >> $GITHUB_OUTPUT
      - id: service_b_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/service-b | wc -l` >> $GITHUB_OUTPUT
```

注意点としては以下となります。

:::note warn
**注意**

**actions/checkout@v3 で fetch-depth を 2 に設定する**


`git diff HEAD~` で前回のコミットとの差分を取得しますが、fetch-depth を 2 に設定しておかないと、前回のコミットが取得できず、差分比較ができなくなってしまいます。
:::


## インフラデプロイジョブ

インフラのデプロイコードは記載しませんが、前のジョブである変更検知ジョブと関連する部分をメインに記載します。

インフラ単体のデプロイワークフローを定義します。workflow_dispatch を定義することで、インフラ単体のワークフローを実行することができます。また、全体のワークフローから呼び出せるように、workflow_call も定義しておきます。

```yaml:.github/workflows/deploy-infrastructure.yaml
on:
  workflow_dispatch:
    inputs:
      stage:
        required: true
        type: string
  workflow_call:
    inputs:
      stage:
        required: true
        type: string

jobs:
  deploy:
    # setup, install, deploy など
    # AWS CDK を利用している場合は、 yarn workspace infrastructure cdk deploy --all --require-approval=never などでデプロイする
```

全体デプロイのワークフローにインフラ単体をデプロイするワークフローを追加します。

```yaml:.github/workflows/deploy.yaml
jobs: # インデントを合わせるために記載していますが、prepare と同じ job です
  infrastructure_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.infrastructure-diff-count > 0 }}
    needs:
      - prepare
    uses: '.github/workflows/deploy-infrastructure.yaml'
    with:
      stage: ${{ inputs.stage }}
    secrets: inherit
```

ポイントとしては以下の点になります。

:::note info
**ポイント1**

**if 文でジョブの実行要否を判定する**

`needs.prepare.outputs.infrastructure-diff-count > 0` で前のジョブで取得した変更ファイル数を取得し、変更がある場合のみ実行するようにします。
:::

:::note info
**ポイント2**

**needs で依存関係を示す**

```
needs: 
  - prepare
```
で prepare ジョブに依存していることが示せます。複数指定することも可能で、インフラのデプロイや依存している別のサービスがデプロイされるまで待つことを表現できます。
:::

:::note info
**ポイント3**

**uses でインフラ単体のワークフローを呼び出す**

`uses: workflow のファイルパス` で workflow_call が設定された別のワークフローを呼び出すことができます。
secrets が必要な場合は、呼び出す際に渡すことができます。inherit を指定するとまとめて渡すことができますが、個別に指定することも可能です。
:::

## 各サービスデプロイジョブ

各サービスのデプロイジョブもインフラのデプロイジョブとほぼ同じため、ポイントのみ記載します。

各サービス単体のデプロイワークフローを定義します。同じワークフローでデプロイできるサービスは同じワークフローを再利用できるように、入力でサービス名を受け取ります。

```yaml:.github/workflows/deploy-service.yaml
on:
  workflow_dispatch:
    inputs:
      stage:
        required: true
        type: string
      service:
       required: true
       type: choice
       options:
         - service-a
         - service-b
  workflow_call:
    inputs:
      stage:
        required: true
        type: string
      service:
       required: true
       type: string

jobs:
  deploy:
    # setup, install, deploy など
    # inputs.service によって対象のサービスを切り替えることで、ワークフローを再利用します
```

全体デプロイのワークフローにインフラ単体をデプロイするワークフローを追加します。

```yaml:.github/workflows/deploy.yaml
jobs: # インデントを合わせるために記載していますが、prepare と同じ job です
  service_a_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.service-a-diff-count > 0 }}
    needs:
      - prepare
      - infrastructure_deploy
    uses: '.github/workflows/deploy-service.yaml'
    with:
      stage: ${{ inputs.stage }}
      service: service-a
    secrets: inherit

  service_b_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.service-b-diff-count > 0 }}
    needs:
      - prepare
      - infrastructure_deploy
      # - service_a_deploy
      # service-a に依存している場合は、上記のように記載することで service-a のデプロイ完了を待ちます
    uses: '.github/workflows/deploy-service.yaml'
    with:
      stage: ${{ inputs.stage }}
      service: service-b
    secrets: inherit
```

ポイントとしては以下の点になります。

:::note info
**ポイント1**

**if 文でジョブの実行要否を判定する際に cancelled(), failure() を追加する**

if 文の条件に `!cancelled() && !failure() && ` を追加します。これは、ワークフローがキャンセルされた場合に停止するようにするため、また、依存しているジョブがスキップされた場合に、このジョブがスキップされないようにするためです。
:::

# まとめ

GitHub モノレポ + マイクロサービスで変更があったサービスのみをデプロイすることができました。
ワークフローのトリガーや変更検知の条件などは各プロジェクトにあわせて変更いただければと思います。