はじめに
皆さんは、CI/CDのコードを実装している時に、「このコード他のリポジトリのものとやってることはほぼ同じなんだよな」や、実装の変更時に「同じ変更を他の全てのリポジトリでやることが億劫だな」と思ったことはありませんか?
この記事では、CircleCiのコードを複数リポジトリにまたがって、共通化する方法について述べています。通常であれば、各リポジトリごとに実装しなくてはならないCI/CDのコードを一括管理することによって、変更容易性を増し、保守運用をしやすくすることが目的です。
こちらの記事と同様の実装を行えば、GitHub Actionsでも同じようなことができると思います(未検証)
背景
ここでは、筆者がどのような課題に直面し、この方法を取り入れることにしたのかを話しています。結論のみをお急ぎの方は読み飛ばしてください。
弊チームのCI/CD事情
弊チームのサービス構成
私が所属しているチームでは下記5つのリポジトリを管理しています。
リポジトリ名 | 使用技術 |
---|---|
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の構成が下記です。
こちらの実装の詳細についての説明は省きますが、まず環境ごとのYAMLファイル間で実装が酷似すること、それぞれのリポジトリで技術スタックがかなり似ているため、必然的にCDのコードもほぼ同じようなものが作られます。
- JS, Vue, TypeScriptのbuild(buildコマンドは統一されている)
- PHPのリリース関連コード
- 全てのリリース後に行われる共通のキャッシュ削除
- など。。。
さらに、実際にCDを動かし始めてから発見されるバグや軽微な修正等もほぼ全てのリポジトリで同様の修正をしなくてはならないなど、さまざまな悩みがありました。
解決策
上記の問題に対峙した時、真っ先に思い浮かんだのが実装の共通化でした。しかし、これを叶えるためには下記問題がありました。
- 実行されているYAMLファイルから他のファイルを参照することはできない
- 1つのpipelineで呼び出すことのできるファイルは最大2ファイル
-
circleci/continuation
orbを使用することによって、「setup」と「メインプロセス」の2ファイルに分けることができる
-
今回この問題を解決しつつ実装の共通化を行った方法は、「setup内で条件に合った最適なメインプロセスYAMLファイルを構築し、それを後続の処理として呼び出す」という方法です。
CircleCiの実装の共通化の仕組み
この章より、主題の複数リポジトリ間でCircleCiのコードを共有する内容について説明していきます。
どう共通化のするのか
CircleCiは主に6つのトップインデントから成り立っています。
名前 | 役割 |
---|---|
version | CircleCiのバージョン指定 |
parameters | pipeline実行時の引数の定義 |
job | 処理の流れを定義 |
workflow | jobの順番を定義 |
executers | jobの実行環境を定義 |
commands | 処理を関数として定義、jobから呼び出される |
commands
で関数を共通化することができるので、job
, workflow
を各リポジトリ、環境ごとに定義することによって、実装の共通化ができるのではないかと考えました。
全体の構造
どのようなフローで動作するかというと
- UserがCircleCiの管理画面からデプロイ先の環境を引数としてpipelineを実行する
-
config.yml
内で、次に実行するメインプロセスであるnext.yml
をリポジトリ名、デプロイ先の環境をもとに構築する- CircleCiリポジトリをCircleCiのコンテナ内にpull
- リポジトリ情報や、引数のデプロイ先の環境をもとに各インデントファイルを選ぶ。
- 各要素を結合することによって、メインプロセスである
next.yml
を構築
-
config.yml
から、next.yml
を実行する -
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で共通化しています。
version: 2.1
parameters:
env:
type: enum
enum: ["", prod, stg, stgOversea]
default: ""
repo-name:
type: string
default: ""
parameterのrepo-name
に関しては後述しますが、 関数の中でもリポジトリによって条件分岐する部分があったため、そのために受け取れるようにしています
executer.ymlについて
このファイルでは、job
を動かす環境(コンテナ)を定義します。ここで定義することによって、job
の中では単にここに定義されている環境を参照するだけで良くなります。
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について
このファイル内で、全てのリポジトリで使用される関数を全て定義します。
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へのリリースか本番へのリリースかを分けたかったため
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
で関数は全て定義されているため、それを参照する形で関数の呼ぶ順番、引数のみを定義します。
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を用いて行います。
このCLIではCircleCiのデフォルト実行ファイルであるconfig.yml
が正しく書かれているかを確認してくれるコマンドが存在しています。
そこで、テストフローとして、
-
config.yml
からstatic-analysis-test.yml
を呼び出す- CircleCiのCLIでテストできるのは、
config.yml
という名前のファイルのみのため、static-analysis-test.yml
で処理を進めつつ、config.yml
を上書きしてCLIを実行するというフローを辿る必要があります。
- CircleCiのCLIでテストできるのは、
- 全リポジトリ×全環境の
config.yml
を作成 - 各ファイルに対して、CLIでチェックを走らせる。
このようなフローでテストを行うことによって、実装を自動でテストすることができます。
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
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点になります。
- CircleCiリポジトリのpull
- メインプロセスである
next.yml
の構築 -
next.yml
を後続の処理として実行
ディレクトリ構成
全てのリポジトリでディレクトリ構成は一緒になります。
.
├── config
│ └── ssh_config
├── config.yml
└── next.yml
- ssh_congfig
- CircleCiリポジトリはGitHubからpullしてくるため、Githubへ接続するための
ssh
の設定をここに書きます
- CircleCiリポジトリはGitHubからpullしてくるため、Githubへ接続するための
- config.yml
- ここで全ての処理を定義します
- next.yml
- CirclrCiのjob内でファイルを作成、後続の処理としての呼び出しができなかったため、ファイルだけ作成しています。
各プロセス詳細
各リポジトリの実装にて触れましたが、config.yml
では「CircleCiリポジトリのpull」, 「メインプロセスであるnext.yml
の構築」, 「next.yml
を後続の処理として実行」の3つのプロセスがあります。
CircleCiリポジトリのpull
まずは、CircleCiリポジトリのpullの説明から始めます。
GitHubのリポジトリをpullするには、まずGitHubと接続できる状態にする必要があるので、CircleCiの設定ページでGitHubに接続する用のSSHキーを登録します。
その後、config/ssh_config
ファイル内でGitHubと接続するためのSSHの設定を行います
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
は
...
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
の構築に関してですが、ここに関しては全く難しいことはしていません(むしろもう少し工夫すべき部分かもしれませんが...)。
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を用いて実行します
- continuation/continue:
configuration_path: /tmp/workspace/.circleci/release_config.yml
parameters: |
{ "repo-name": "${リポジトリ名}"}
config.yml
のparametersに記載されている引数以外の引数を追加したい場合はJSON形式でparametersに設定することで、next.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の実装を共通化したいと思った際に参考にしていただけますと幸いです。
ここまで読んでいただきありがとうございました。