【CI/CD】複数リポジトリでCircleCIのコードを共有する

# はじめに
皆さんは、CI/CDのコードを実装している時に、「このコード他のリポジトリのものとやってることはほぼ同じなんだよな」や、実装の変更時に「同じ変更を他の全てのリポジトリでやることが億劫だな」と思ったことはありませんか？

この記事では、CircleCiのコードを複数リポジトリにまたがって、**共通化**する方法について述べています。通常であれば、各リポジトリごとに実装しなくてはならないCI/CDのコードを一括管理することによって、変更容易性を増し、保守運用をしやすくすることが目的です。
こちらの記事と同様の実装を行えば、GitHub Actionsでも同じようなことができると思います(未検証)

## 背景
ここでは、筆者がどのような課題に直面し、この方法を取り入れることにしたのかを話しています。結論のみをお急ぎの方は読み飛ばしてください。
### 弊チームのCI/CD事情
#### 弊チームのサービス構成
私が所属しているチームでは下記５つのリポジトリを管理しています。
|       リポジトリ名      |            使用技術            |
|:-------------------:|:----------------------------:|
|     Dockerリポジトリ    |            Docker            |
| Admin/Serviceリポジトリ |         PHP(7.3), JS         |
|   Service Aリポジトリ   |          TypeScript          |
|   Service Bリポジトリ   |          Vue.js, JS          |
|   Service Cリポジトリ   | PHP(8.1), Vue.js, TypeScript |

動作環境として、下記の3つがありました。
* 本番環境
* ステージング環境
* 海外チームのステージング環境

### 元のCircleCiの実装
サービスの構成をもとに実装したCircleCiの構成が下記です。
![スクリーンショット 2023-12-30 13.22.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2859039/d673e87c-38d3-9cf2-5a10-3c9c6c94f891.png)
こちらの実装の詳細についての説明は省きますが、まず環境ごとのYAMLファイル間で実装が酷似すること、それぞれのリポジトリで技術スタックがかなり似ているため、必然的にCDのコードもほぼ同じようなものが作られます。
* JS, Vue, TypeScriptのbuild(buildコマンドは統一されている)
* PHPのリリース関連コード
* 全てのリリース後に行われる共通のキャッシュ削除
* など。。。

さらに、実際にCDを動かし始めてから発見されるバグや軽微な修正等もほぼ全てのリポジトリで同様の修正をしなくてはならないなど、さまざまな悩みがありました。

### 解決策
上記の問題に対峙した時、真っ先に思い浮かんだのが実装の共通化でした。しかし、これを叶えるためには下記問題がありました。
1. 実行されているYAMLファイルから他のファイルを参照することはできない
1. １つのpipelineで呼び出すことのできるファイルは最大2ファイル
    1. `circleci/continuation` orbを使用することによって、「setup」と「メインプロセス」の2ファイルに分けることができる

今回この問題を解決しつつ実装の共通化を行った方法は、「**setup内で条件に合った最適なメインプロセスYAMLファイルを構築し、それを後続の処理として呼び出す**」という方法です。

# CircleCiの実装の共通化の仕組み
この章より、主題の複数リポジトリ間でCircleCiのコードを共有する内容について説明していきます。
## どう共通化のするのか
CircleCiは主に6つのトップインデントから成り立っています。
|     名前    |               役割               |
|:----------:|:--------------------------------:|
|   version  |       CircleCiのバージョン指定       |
| parameters |      pipeline実行時の引数の定義     |
|     job    |           処理の流れを定義          |
|  workflow  |           jobの順番を定義          |
|  executers |         jobの実行環境を定義         |
|  commands  | 処理を関数として定義、jobから呼び出される |

![スクリーンショット 2023-12-31 12.23.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2859039/bcb603c3-d744-5a4d-3f1e-0363fb5cba1a.png)

`commands`で関数を共通化することができるので、`job`, `workflow`を各リポジトリ、環境ごとに定義することによって、実装の共通化ができるのではないかと考えました。

## 全体の構造
上記を踏まえた、全体の構造が下記です。
![スクリーンショット 2023-12-31 15.38.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2859039/05da0643-50ff-6800-be0a-340bb6985725.png)

どのようなフローで動作するかというと
1. UserがCircleCiの管理画面からデプロイ先の環境を引数としてpipelineを実行する
1. `config.yml`内で、次に実行するメインプロセスである`next.yml`をリポジトリ名、デプロイ先の環境をもとに構築する
    1. CircleCiリポジトリをCircleCiのコンテナ内にpull
    1. リポジトリ情報や、引数のデプロイ先の環境をもとに各インデントファイルを選ぶ。
    1. 各要素を結合することによって、メインプロセスである`next.yml`を構築
1. `config.yml`から、`next.yml`を実行する
1. `next.yml`でデプロイを行う

以上のプロセスによって、CircleCiの実装は共通化され、リポジトリ、環境ごとの最適なworkflowが構築されます。
次の章からは、実際の実装内容に触れながら、より詳細について説明していきます。

## CircleCiリポジトリの詳細
[全体の構造](#全体の構造)の画像にあったように、CircleCiリポジトリは`header.yml`, `executer.yml`, `commands.yml`, `workflow`ディレクトリ, `job`ディレクトリで構成されています。

`header.yml`, `executer.yml`, `commands.yml`はどのリポジトリでも共通のファイルを使用しており、`workflow`, `job` がリポジトリ、環境によって使用されるファイルが変わっていきます。
### header.ymlについて
このファイルには、`version`と`parameters`が定義されています。
* `version`は、CircleCiのバージョンを表しており、これをpipelineごとに変更する理由はないので、最新の`2.1`を指定しています。
* `parameters`に関しては、`config.yml`, `next.yml` ともに同じparameterを定義しないと、CircleCiの仕様上動作しなくなってしまうので、全てのpipelineで共通化しています。

```yaml:header.yml
version: 2.1

parameters:
  env:
    type: enum
    enum: ["", prod, stg, stgOversea]
    default: ""
  repo-name:
    type: string
    default: ""
```
parameterの`repo-name`に関しては後述しますが、　関数の中でもリポジトリによって条件分岐する部分があったため、そのために受け取れるようにしています

### executer.ymlについて
このファイルでは、`job`を動かす環境（コンテナ）を定義します。ここで定義することによって、`job`の中では単にここに定義されている環境を参照するだけで良くなります。

```yaml:executer.yml
executors:
  node12:
    docker:
      - image: cimg/node:12.19.0
        user: root
  node16:
    docker:
      - image: cimg/node:16.13.0
        user: root
  basic:
    docker:
      - image: cimg/base:stable
        user: root
```
### commands.ymlについて
このファイル内で、全てのリポジトリで使用される関数を全て定義します。
```yaml:commands.yml
commands:
  greeting:
    description: "Standard output for incoming greetings"
    parameters:
      message:
        type:
        default: "Hello world!"
    steps:
      - run:
          name: "export greeting"
          command: echo << parameters.message >>
  deploy:
    ...
```
### workflow/${リポジトリ名}/${環境名}.ymlについて
ここでは、各リポジトリごと、各環境ごとにファイルを書いていきます。どのレベルまで分割するかについては、workflowでやりたいことに依存するかとは思うのですが、弊チームでは環境レベルまで分割しなくてはいけなかったため、そのパターンで説明していきます。

* リポジトリ単位で分割しなくてはいけなかった理由
    * `job`がリポジトリごとに変わるため、その引数等はjobによって変化するため
* 環境単位に分割しなくてはいけなかった理由
    * デプロイ先をworkflow側で管理しているため、stagingへのリリースか本番へのリリースかを分けたかったため

```yaml:workflow/docker/prod.yml
workflows:
  deploy-to-prod-all:
    jobs:
      - deploy:
          name: deploy-to-production01
          server: "production01"
      - deploy:
          name: deploy-to-production02
          requires:
            - deploy-to-production01
          server: "production02"

```
### job/${リポジトリ名}.ymlについて
このファイルでは、処理の流れを記載していきます。
`commands.yml`で関数は全て定義されているため、それを参照する形で関数の呼ぶ順番、引数のみを定義します。
```yaml:job/docker.yml
jobs:
  deploy:
    executor: basic
    working_directory: /root/workspace
    parameters:
      server:
        description: "deploy server"
        type: string
        default: ""
      main-dir:
        description: "directory where repository exists in the server"
        type: string
        default: "/home/ubuntu/docker"
    steps:
      - checkout
      - setup-container
      - when:
          condition:
            equal: ["prod", << pipeline.parameters.env >>]
          steps:
            - server-status-check:
                server: << parameters.server >>
      - container-down:
          server: << parameters.server >>
      - deploy-codes:
          server: << parameters.server >>
          main-dir: << parameters.main-dir >>
      - container-setup:
          server: << parameters.server >>
      - check-container-status:
          server: << parameters.server >>
```

ここで`executor: basic`に関してはexecuterで定義したubuntumコンテナであるbasicを参照しています。
`steps`配下に関しても、`commands.yml`　のサンプルコード内では紹介していませんが、全て`commands.yml`内で定義された関数を呼び出して処理を行なっています。

## テストについて
ここまで、YAMLファイルをインデントごとにファイル分割する方法について説明してきました。ただ、YAMLファイルはもともと外部ファイルを参照しながら実装していくものではないため、IDEによる定義ジャンプなどが使えません。
また、インデントが一つずれるだけで、YAMLファイルでの意味が変わってきてしまうため、十分な注意が必要です。

ただ、これらを実装者やレビュワーにの注意によって全て防ぐことは難しいです。そのため、このCircleCiリポジトリのファイルたちが正しいCircleCiの実行ファイルを構築できるかをテストする方法についても述べてたいと思います。CI/CDのコードをCIでテストするということですね（ややこしい...）

テスト方法についてですが、[CircleCi公式が提供しているCLI](https://circleci.com/docs/ja/local-cli/#next-steps)を用いて行います。
このCLIではCircleCiのデフォルト実行ファイルである`config.yml`が正しく書かれているかを確認してくれるコマンドが存在しています。

そこで、テストフローとして、
1. `config.yml`から`static-analysis-test.yml`を呼び出す
    1. CircleCiのCLIでテストできるのは、`config.yml`という名前のファイルのみのため、`static-analysis-test.yml`で処理を進めつつ、`config.yml`を上書きしてCLIを実行するというフローを辿る必要があります。
1. 全リポジトリ×全環境の`config.yml`を作成
1. 各ファイルに対して、CLIでチェックを走らせる。

このようなフローでテストを行うことによって、実装を自動でテストすることができます。
```yaml:circleci/comfig.yml
version: 2.1

setup: true

orbs:
  continuation: circleci/continuation@0.3.1

jobs:
  setup:
    executor: continuation/default
    steps:
      - checkout
      - continuation/continue:
          configuration_path: .circleci/static-analysis-test.yml

workflows:
  setup-test:
    jobs:
      - setup
```
```yaml:.circleci/static-analysis-test.yml
version: 2.1

executors:
  base:
    docker:
      - image: cimg/base:stable
        user: root

commands:
  create-config-to-test:
    description: "create config.yaml to test"
    parameters:
      repo-name:
        description: "Repository name"
        type: enum
        enum: ["docker","adminService", "serviceA", "serviceB", "serviceC"]
      env: 
        description: "environment name"
        type: enum
        enum: ["prod", "stg", "stgOverseas"]
    steps:
      - run: 
          name: "delete config.yml if it exists"
          command: |
            #!/bin/bash

            file="/root/workspace/.circleci/config.yml"
            if [ -e ${file} ]; then
              rm -rf ${file}
            fi
      - run: 
          name: "create config.yaml to test"
          command: |
            #!/bin/bash
            
            header="header.yml"
            jobs="jobs/<< parameters.repo-name >>.yml"
            workflow="workflow/<< parameters.repo-name >>/<< parameters.env >>.yml"
            commands="commands.yml"
            executors="executors.yml"

            cd /root/workspace/
            cat ${header} ${jobs} ${workflow} ${commands} ${executors} > /root/workspace/.circleci/config.yml
      - run: 
          name: "Check the contents of the created config.yml"
          command: |
            #!/bin/bash

            file=".circleci/config.yml"
            cd /root/workspace/
            cat ${file}
  install-circleci-cli:
    description: "install CircleCi CLI to execute static analysis test"
    steps:
      - run:
          name: "install CircleCi CLI by using curl"
          command: curl -fLSs https://circle.ci/cli | bash
  execute-static-analysis-test:
    description: "execute static analysis test by using command of CircleCi CLI"
    steps: 
      - run:
          name: "execute command"
          command: circleci config validate

jobs:
  test:
    executor: base
    working_directory: /root/workspace
    parameters: 
      repo-name:
        description: "Repository name"
        type: enum
        enum: ["docker","adminService", "serviceA", "serviceB", "serviceC"]
      env: 
        description: "environment name"
        type: enum
        enum: ["prod", "stg","stgOversea"]
    steps:
      - checkout
      - create-config-to-test:
          repo-name: << parameters.repo-name >>
          env: << parameters.env >>
      - install-circleci-cli
      - execute-static-analysis-test

workflows:
  docker-test:
    jobs:
      - test:
          name: "prod test"
          repo-name: "docker"
          env: "prod"
      - test:
          name: "staging test"
          repo-name: "docker"
          env: "stg"
      - test:
          name: "oversea staging test"
          repo-name: "docker"
          env: "stgOversea"
  adminService-test:
    jobs:
      - test:
          name: "prod test"
          repo-name: "adminService"
          env: "prod"
      - test:
          name: "staging test"
          repo-name: "adminService"
          env: "stg"
      - test:
          name: "oversea staging test"
          repo-name: "adminService"
          env: "stgOversea"
  serviceA-test:
    jobs:
      ...
```
## Circleciリポジトリのまとめ
この章では、CircleCiリポジトリがどのように全体像の説明から、各ファイルについて説明、最後にそのテストについて説明してきました。
改めてこの章で話したことのまとめとしては、
* CircleCiのトップインデントごとにファイルを分割する
    * `version`, `parameters`は全ファイル共通で定義する
    * `executers`では、jobの動作環境を定義する
    * `commands`では全jobの関数を定義する
    * `workflow`, `job`はリポジトリごと、必要であれば　環境ごとに定義する
* 構築されたメインプロセスが正しいかどうかを確認するために、CircleCiのCLIを用いて自動テストを行う。

次の章では、各リポジトリでどのようにメインプロセスのYAMLファイルを構築し、呼び出すのかについて説明します。

# 各リポジトリの実装 
ここからは、各リポジトリで実装する内容について説明していきます。
各リポジトリで行わなくてはいけないことは下記3点になります。
1. CircleCiリポジトリのpull
1. メインプロセスである`next.yml`の構築
1. `next.yml`を後続の処理として実行

## ディレクトリ構成
全てのリポジトリでディレクトリ構成は一緒になります。
```
.
├── config
│   └── ssh_config
├── config.yml
└── next.yml
```
* ssh_congfig
    * CircleCiリポジトリはGitHubからpullしてくるため、Githubへ接続するための`ssh`の設定をここに書きます
* config.yml
    * ここで全ての処理を定義します
* next.yml
    * CirclrCiのjob内でファイルを作成、後続の処理としての呼び出しができなかったため、ファイルだけ作成しています。

## 各プロセス詳細
[各リポジトリの実装](#各リポジトリの実装)にて触れましたが、`config.yml`では「CircleCiリポジトリのpull」, 「メインプロセスである`next.yml`の構築」, 「`next.yml`を後続の処理として実行」の３つのプロセスがあります。

### CircleCiリポジトリのpull
まずは、CircleCiリポジトリのpullの説明から始めます。
GitHubのリポジトリをpullするには、まずGitHubと接続できる状態にする必要があるので、[CircleCiの設定ページ](https://circleci.com/docs/ja/add-ssh-key/)でGitHubに接続する用のSSHキーを登録します。

その後、`config/ssh_config`ファイル内でGitHubと接続するためのSSHの設定を行います
```:ssh_config
Host github.com
    HostName ssh.github.com
    User git
    port 443
    identityFile /root/.ssh/id_rsa_${finger_print}
    Compression yes
    TCPKeepAlive yes
    IdentitiesOnly yes
    StrictHostKeyChecking no
```
${finger_print}は登録されたSSHキーの下に表示されている英数字の":"を除いた連続した英数字です。

実際にPullをする`command`は
```yaml:config.yml
...
setup-repository-of-b-hack-circleci:
    description: "Set up the CircleCi repository to use"
    steps:
      - add_ssh_keys:
          fingerprints: "${finger_prints}"
      - run:
          name: "set ssh config"
          command: |
            #!/bin/bash

            cd ${CIRCLE_WORKING_DIRECTORY}/.circleci/config
            cat ssh_config \>> /root/.ssh/config
      - run:
          name: "git pull the CircleCi repository"
          command: |
            #!/bin/bash

            cd ${CIRCLE_WORKING_DIRECTORY}/.circleci
            git clone git@github.com:${リポジトリ URL}
...
```
となります。
これでCircleCiリポジトリを実行環境に落としてくることができたので、次の章で構築、実行します。

## `next.yml`の構築と実行
`next.yml`の構築に関してですが、ここに関しては全く難しいことはしていません(むしろもう少し工夫すべき部分かもしれませんが...)。
```yaml:config.yml
  create-executing-config:
    description: "Create property config.yml file depending env"
    parameters:
      env:
        description: "environment name"
        type: enum
        enum: [ "", "prod", "stg", "stgOversea" ]
      config-name:
        description: "The file name which is created."
        type: string
    steps:
      - run:
          name: "create release_config.yml"
          command: |
            #!/bin/bash

            header="header.yml"
            jobs="jobs/docker.yml"
            workflow="workflow/docker/<< parameters.env >>.yml"
            commands="commands.yml"
            executors="executors.yml"

            cd ${CIRCLE_WORKING_DIRECTORY}/.circleci/B-Hack_CircleCi
            cat ${header} ${jobs} ${workflow} ${commands} ${executors} > ${CIRCLE_WORKING_DIRECTORY}/.circleci/<< parameters.config-name >>.yml
      - run:
          name: "Check the contents of the created config.yml"
          command: cat ${CIRCLE_WORKING_DIRECTORY}/.circleci/<< parameters.config-name >>.yml
      - run:
          name: "Change authentication"
          command: chown -R circleci:circleci /tmp
```
まず、`next.yml`を作成します。
job, workflowのファイルはリポジトリ名、引数を用いてファイルを指定します。`cat`コマンドで、各ファイルを繋げて読み込み、`next.yml`に書き込みます。
そして、`next.yml`にcircleciユーザー権限を与えることで、CircleCiから`next.yml`を読み込めるようにします。

`next.yml`の実行方法としては、`continuation` Orbを用いて実行します
```yaml:config.yml
- continuation/continue:
    configuration_path: /tmp/workspace/.circleci/release_config.yml
    parameters: |
      { "repo-name": "${リポジトリ名}"}
```
`config.yml` のparametersに記載されている引数以外の引数を追加したい場合はJSON形式でparametersに設定することで、`next.yml`で受け取ることができます

最後に下記が実装全体のサンプルになります。
```yaml:config.yml
version: 2.1

setup: true

orbs:
  continuation: circleci/continuation@0.3.1

parameters:
  env:
    type: enum
    enum: ["", prod, stg, stgOversea]
    default: ""

executors:
  base:
    docker:
      - image: cimg/base:stable
        user: root

jobs:
  setup-container:
    executor: base
    working_directory: /tmp/workspace
    environment:
      CONFIG_NAME: "release_config"
    steps:
      - checkout
      - setup-repository-of-b-hack-circleci:
          circleci-branch: << pipeline.parameters.circleci-branch >>
      - create-executing-config:
          env: << pipeline.parameters.env >>
          config-name: ${CONFIG_NAME}
      - persist_to_workspace:
          root: /tmp/workspace
          paths:
            - .circleci/*
  continue-release:
    executor: continuation/default
    working_directory: /tmp/workspace
    steps:
      - attach_workspace:
          at: /tmp/workspace
      - run:
          name: "check"
          command: |
            ls -al /tmp/workspace
            ls -al /tmp/workspace/.circleci
            cat /tmp/workspace/.circleci/release_config.yml
      - continuation/continue:
          configuration_path: /tmp/workspace/.circleci/release_config.yml
          parameters: |
            { "repo-name": "${リポジトリ名}"}

workflows:
  setup-and-release:
    jobs:
      - setup-container
      - continue-release:
          requires:
            - setup-container

commands:
  setup-repository-of-b-hack-circleci:
    description: "Set up the B-Hack_CircleCi repository to use"
    parameters:
      circleci-branch:
        description: "Branch name of B-Hack_CircleCi"
        type: string
        default: ""
    steps:
      - add_ssh_keys:
          fingerprints: "03:6e:7f:4b:10:7a:f3:7c:83:db:d4:ac:dc:1f:3f:45"
      - run:
          name: "set ssh config"
          command: |
            #!/bin/bash

            cd ${CIRCLE_WORKING_DIRECTORY}/.circleci/config
            cat ssh_config \>> /root/.ssh/config
      - run:
          name: "git pull the B-Hack_CircleCi repository"
          command: |
            #!/bin/bash

            cd ${CIRCLE_WORKING_DIRECTORY}/.circleci
            git clone git@github.com:FullSpeedInc/B-Hack_CircleCi.git

  create-executing-config:
    description: "Create property config.yml file depending env"
    parameters:
      env:
        description: "environment name"
        type: enum
        enum: [ "", "prod", "stg", "stgOversea" ]
      config-name:
        description: "The file name which is created."
        type: string
    steps:
      - run:
          name: "create release_config.yml"
          command: |
            #!/bin/bash

            header="header.yml"
            jobs="jobs/docker.yml"
            workflow="workflow/docker/<< parameters.env >>.yml"
            commands="commands.yml"
            executors="executors.yml"

            cd ${CIRCLE_WORKING_DIRECTORY}/.circleci/B-Hack_CircleCi
            cat ${header} ${jobs} ${workflow} ${commands} ${executors} > ${CIRCLE_WORKING_DIRECTORY}/.circleci/<< parameters.config-name >>.yml
      - run:
          name: "Check the contents of the created config.yml"
          command: cat ${CIRCLE_WORKING_DIRECTORY}/.circleci/<< parameters.config-name >>.yml
      - run:
          name: "Change authentication"
          command: chown -R circleci:circleci /tmp
```

# まとめ
ここまでで、CircleCiの実装の共通化の仕方について解説してきました。
CI/CDの実装を共通化したいと思った際に参考にしていただけますと幸いです。

ここまで読んでいただきありがとうございました。