怖くない CircleCI の config.yml の書き方

この記事は [Classi Advent Calendar 2019](https://qiita.com/advent-calendar/2019/classi) 9 日目の記事になります。フロントエンドエンジニアの @kasaharu です。今年はフロントエンドエンジニア以外にも読んでほしい CircleCI についての記事になります。

CircleCI 使ってますか？設定ファイルの変更していますか？もしかしたら Yaml ファイルが大きく、読むのを諦めて雰囲気で修正している人もいるかもしれません。
CI の設定ファイルは毎日見るものではないので、たまに見たときにできるだけ時間を取られないように書き方をしたいものです。
今回は default の config.yml からできるだけメンテナンスしやすいような config.yml を作っていく手順を説明します。


# はじめに
アプリケーションがフロントエンド前提で書いてあるが他の言語でも考え方は同じはず！
最終的な Yaml ファイルだけ見たい場合は一番下を見ればいいよ:eyes: 

# どこから書くか？
あまり config を最初から書く機会はないかもしれないが、もし書くことになったらまずは CircleCI 上でプロジェクトの追加をするときに雛形として表示される Sample.yml をコピーする。
<img width="1345" alt="default-config.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28573/e858d446-aaed-73dd-e433-4b7100ebe0bb.png">
例えば "Set Up Project" の画面で Node.js を選択すると下記のような Sample が表示される。

```yml:.circleci/config.yml
version: 2
jobs:
  build:
    docker:
      - image: circleci/node:7.10
    working_directory: ~/repo
    steps:
      - checkout
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            - v1-dependencies-
      - run: yarn install
      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}
      - run: yarn test
```

Sample なので設定はシンプルだが、以下の状態になっている

- CircleCI のバージョンは 2
- 指定された job は `build` の一つ
- Docker コンテナは Node.js 7.10 の入ったイメージを使う
- working_directory は以降の step を実行するディレクトリで最初は `~/repo` が指定されている(デフォルトは `~/project`)
- build job は以下の step で実行される
    1. ソースコードを working_directory にチェックアウト
    2. キャッシュの restore(4. で保存するキャッシュを restore)
    3. パッケージインストール
    4. インストール済みのパッケージをキャッシュ

# 自分のプロジェクトでやりたいことを整理
Sample では `test` の実行のみであるが、昨今のフロントエンドは build をしたり lint をかけたり、もちろん test もあるので CI にやってほしいことを整理する。
この記事では lint, build, test, e2e を CI で実行することを目指す。

# config.yml をカスタマイズ
## 使用する Docker image を変更
まずは Node.js のバージョンを上げたいので image を変更する。
circleci/node のバージョンは [Docker Hub](https://hub.docker.com/r/circleci/node) で探せる。ここでは v12.13 に変更する。

```diff
 jobs:
   build:
     docker:
-      - image: circleci/node:7.10
+      - image: circleci/node:12.13-stretch-browsers
     working_directory: ~/repo
     steps:
       - checkout
```

## node_modules の cache の key を変更
インストールされるパッケージは `package.json` が変わらなくても lock file が変わると変更されるので cache の key も lock file の checksum に変更しておく。

```diff
       - checkout
       - restore_cache:
           keys:
-            - v1-dependencies-{{ checksum "package.json" }}
+            - v1-dependencies-{{ checksum "yarn.lock" }}
             - v1-dependencies-
       - run: yarn install
       - save_cache:
           paths:
             - node_modules
-          key: v1-dependencies-{{ checksum "package.json" }}
+          key: v1-dependencies-{{ checksum "yarn.lock" }}
       - run: yarn test
```

## test 以外の step を追加
ここで test 以外の lint, build, e2e も CI に追加する

```diff
           paths:
             - node_modules
           key: v1-dependencies-{{ checksum "yarn.lock" }}
+      - run: yarn lint
+      - run: yarn build
       - run: yarn test
+      - run: yarn e2e
```

## job の分割と Workflow を使った並列実行
この時点で build という job の中で lint, build, test, e2e の 4 つのことをやるように設定した。
開発が進み build や test の step に時間がかかるほど CI の時間も遅くなっていくし、仮に job が失敗した場合一つの job でいろんなことをやっていると何に失敗したのかわかりにくくなってしまう。そのため役割に応じて job の分割をしておく。
また Workflow を使った並列実行も可能なので合わせて設定する。

### job の分割
ここでは単純に 4 つの job を用意するだけ。

```diff
 version: 2
 jobs:
-  build:
+  lint:
     docker:
       - image: circleci/node:12.13-stretch-browsers
     working_directory: ~/repo
@@ -16,6 +16,51 @@ jobs:
             - node_modules
           key: v1-dependencies-{{ checksum "yarn.lock" }}
       - run: yarn lint
+  build:
+    docker:
+      - image: circleci/node:12.13-stretch-browsers
+    working_directory: ~/repo
+    steps:
+      - checkout
+      - restore_cache:
+          keys:
+            - v1-dependencies-{{ checksum "yarn.lock" }}
+            - v1-dependencies-
+      - run: yarn install
+      - save_cache:
+          paths:
+            - node_modules
+          key: v1-dependencies-{{ checksum "yarn.lock" }}
       - run: yarn build
+  test:
+    docker:
+      - image: circleci/node:12.13-stretch-browsers
+    working_directory: ~/repo
+    steps:
+      - checkout
+      - restore_cache:
+          keys:
+            - v1-dependencies-{{ checksum "yarn.lock" }}
+            - v1-dependencies-
+      - run: yarn install
+      - save_cache:
+          paths:
+            - node_modules
+          key: v1-dependencies-{{ checksum "yarn.lock" }}
       - run: yarn test
+  e2e:
+    docker:
+      - image: circleci/node:12.13-stretch-browsers
+    working_directory: ~/repo
+    steps:
+      - checkout
+      - restore_cache:
+          keys:
+            - v1-dependencies-{{ checksum "yarn.lock" }}
+            - v1-dependencies-
+      - run: yarn install
+      - save_cache:
+          paths:
+            - node_modules
+          key: v1-dependencies-{{ checksum "yarn.lock" }}
       - run: yarn e2e
```

### Workflow を使った並列実行
job を分けたので、各 job をどのような順番で実行するかを決める。今回は lint の job を最初に実行し lint job 終了後に build, test, e2e を並列で実行するようにする。

```diff
 version: 2
+
+workflows:
+  version: 2
+  all-check:
+    jobs:
+      - lint
+      - build:
+          requires:
+            - lint
+      - test:
+          requires:
+            - lint
+      - e2e:
+          requires:
+            - lint
+
 jobs:
   lint:
     docker:
```

## アンカーとエイリアスを使った cache 処理の一元管理
それぞれチェックしたい関心事に job をわけることができた。しかし、すべての job で cache の restore と save が必要でかつインデントも多くファイルの可読性を下げている。
Yaml にはアンカー(`&`)とエイリアス(`*`)の機能がある。これを使用し cache の save, restore 処理を一箇所にまとめる。

### アンカーの定義
```diff
 version: 2
 
+references:
+  save_node_modules: &save_node_modules
+    save_cache:
+      paths:
+        - node_modules
+      key: v1-dependencies-{{ checksum "yarn.lock" }}
+  restore_node_modules: &restore_node_modules
+    restore_cache:
+      keys:
+        - v1-dependencies-{{ checksum "yarn.lock" }}
+        - v1-dependencies-
+
 workflows:
   version: 2
   all-check:
```

### エイリアスによる参照
```diff
@@ -34,15 +34,9 @@ jobs:
     working_directory: ~/repo
     steps:
       - checkout
-      - restore_cache:
-          keys:
-            - v1-dependencies-{{ checksum "yarn.lock" }}
-            - v1-dependencies-
+      - *restore_node_modules
       - run: yarn install
-      - save_cache:
-          paths:
-            - node_modules
-          key: v1-dependencies-{{ checksum "yarn.lock" }}
+      - *save_node_modules
       - run: yarn lint
   build:
     docker:
@@ -50,15 +44,9 @@ jobs:
     working_directory: ~/repo
     steps:
       - checkout
-      - restore_cache:
-          keys:
-            - v1-dependencies-{{ checksum "yarn.lock" }}
-            - v1-dependencies-
+      - *restore_node_modules
       - run: yarn install
-      - save_cache:
-          paths:
-            - node_modules
-          key: v1-dependencies-{{ checksum "yarn.lock" }}
+      - *save_node_modules
       - run: yarn build
   test:
     docker:
@@ -66,15 +54,9 @@ jobs:
     working_directory: ~/repo
     steps:
       - checkout
-      - restore_cache:
-          keys:
-            - v1-dependencies-{{ checksum "yarn.lock" }}
-            - v1-dependencies-
+      - *restore_node_modules
       - run: yarn install
-      - save_cache:
-          paths:
-            - node_modules
-          key: v1-dependencies-{{ checksum "yarn.lock" }}
+      - *save_node_modules
       - run: yarn test
   e2e:
     docker:
@@ -82,13 +64,7 @@ jobs:
     working_directory: ~/repo
     steps:
       - checkout
-      - restore_cache:
-          keys:
-            - v1-dependencies-{{ checksum "yarn.lock" }}
-            - v1-dependencies-
+      - *restore_node_modules
       - run: yarn install
-      - save_cache:
-          paths:
-            - node_modules
-          key: v1-dependencies-{{ checksum "yarn.lock" }}
+      - *save_node_modules
       - run: yarn e2e
```

ついでに cache の key も typo 防止のためにアンカーで定義しておく。

```diff
@@ -1,15 +1,16 @@
 version: 2
 
 references:
+  cache_key: &cache_key v1-dependencies-{{ checksum "yarn.lock" }}
   save_node_modules: &save_node_modules
     save_cache:
       paths:
         - node_modules
-      key: v1-dependencies-{{ checksum "yarn.lock" }}
+      key: *cache_key
   restore_node_modules: &restore_node_modules
     restore_cache:
       keys:
-        - v1-dependencies-{{ checksum "yarn.lock" }}
+        - *cache_key
         - v1-dependencies-
 
 workflows:
```

## 冗長なパッケージインストールの廃止
パッケージインストールは 1 Workflow で 1 回やればいいので冗長な step を削除する。
本来はパッケージインストールだけでひとつの job にしてもいいが今回は lint job と一緒にすることにする。

```diff
@@ -17,19 +17,19 @@ workflows:
   version: 2
   all-check:
     jobs:
-      - lint
+      - package-install-and-lint
       - build:
           requires:
-            - lint
+            - package-install-and-lint
       - test:
           requires:
-            - lint
+            - package-install-and-lint
       - e2e:
           requires:
-            - lint
+            - package-install-and-lint
 
 jobs:
-  lint:
+  package-install-and-lint:
     docker:
       - image: circleci/node:12.13-stretch-browsers
     working_directory: ~/repo
@@ -46,7 +46,6 @@ jobs:
     steps:
       - checkout
       - *restore_node_modules
-      - run: yarn install
       - *save_node_modules
       - run: yarn build
   test:
@@ -56,7 +55,6 @@ jobs:
     steps:
       - checkout
       - *restore_node_modules
-      - run: yarn install
       - *save_node_modules
       - run: yarn test
   e2e:
@@ -66,6 +64,5 @@ jobs:
     steps:
       - checkout
       - *restore_node_modules
-      - run: yarn install
       - *save_node_modules
       - run: yarn e2e
```

## Artifact による job 成果物の保存
job の中には成果物が出力される場合があり、それを保存しておきたいことも出てくる。その場合 Artifact 機能が有用である。
例として Artifact 機能を使って test job 実行後に生成されるコードカバレッジのレポートを保存する。

```diff
@@ -57,6 +57,8 @@ jobs:
       - *restore_node_modules
       - *save_node_modules
       - run: yarn test
+      - store_artifacts:
+          path: coverage
   e2e:
     docker:
       - image: circleci/node:12.13-stretch-browsers
```
Artifact については https://qiita.com/kasaharu/items/0fa892a2100a413e6145 にも書いている。


## Executor を定義して実行環境の一元管理
だいぶスッキリかけてきたが、もう一箇所だけメンテンス性に欠ける部分がある。各 job で使用する Docker image の設定部分である。
このままだと例えば Node.js のバージョンを上げる必要が出たときにある job だけ変更を忘れて使う image が揃わなくなる、という問題が起こる可能性がある。
そこで Executor を定義して実行環境の一元管理する。この機能を使うには version 2.1 が必要なので version も上げておく。

### Executor の定義
```diff
@@ -1,4 +1,9 @@
-version: 2
+version: 2.1
+
+executors:
+  frontend-executor:
+    docker:
+      - image: circleci/node:12.13-stretch-browsers
 
 references:
   cache_key: &cache_key v1-dependencies-{{ checksum "yarn.lock" }}
```

### 各 job で Executor を使用
```diff
@@ -35,8 +35,7 @@ workflows:
 
 jobs:
   package-install-and-lint:
-    docker:
-      - image: circleci/node:12.13-stretch-browsers
+    executor: frontend-executor
     working_directory: ~/repo
     steps:
       - checkout
@@ -45,8 +44,7 @@ jobs:
       - *save_node_modules
       - run: yarn lint
   build:
-    docker:
-      - image: circleci/node:12.13-stretch-browsers
+    executor: frontend-executor
     working_directory: ~/repo
     steps:
       - checkout
@@ -54,8 +52,7 @@ jobs:
       - *save_node_modules
       - run: yarn build
   test:
-    docker:
-      - image: circleci/node:12.13-stretch-browsers
+    executor: frontend-executor
     working_directory: ~/repo
     steps:
       - checkout
@@ -65,8 +62,7 @@ jobs:
       - store_artifacts:
           path: coverage
   e2e:
-    docker:
-      - image: circleci/node:12.13-stretch-browsers
+    executor: frontend-executor
     working_directory: ~/repo
     steps:
       - checkout
```

## working_directory の指定をやめる
これはおまけだが working_directory の名前にこだわりがなければここは指定する必要がない。時間が経ったときにこの設定がなんだったか思い出すくらいなら、指定しないほうがいいので消しておく。
削除のタイミングによっては working_directory の変更で cache が期待通り restore できなくなるので、その場合は cache の key を (`v2-` などに) 変更する。

```diff
@@ -36,7 +36,6 @@ workflows:
 jobs:
   package-install-and-lint:
     executor: frontend-executor
-    working_directory: ~/repo
     steps:
       - checkout
       - *restore_node_modules
@@ -45,7 +44,6 @@ jobs:
       - run: yarn lint
   build:
     executor: frontend-executor
-    working_directory: ~/repo
     steps:
       - checkout
       - *restore_node_modules
@@ -53,7 +51,6 @@ jobs:
       - run: yarn build
   test:
     executor: frontend-executor
-    working_directory: ~/repo
     steps:
       - checkout
       - *restore_node_modules
@@ -63,7 +60,6 @@ jobs:
           path: coverage
   e2e:
     executor: frontend-executor
-    working_directory: ~/repo
     steps:
       - checkout
       - *restore_node_modules
```

# まとめ
各 job に書いてあることが 4 ~ 5 個の step だけになった。
実行環境は executors に step の詳細は references に持っていったりしたので、各 job の見通しはかなりよくなったはず。
最終的な config.yml は下記。

```yml:.circleci/config.yml
version: 2.1

executors:
  frontend-executor:
    docker:
      - image: circleci/node:12.13-stretch-browsers

references:
  cache_key: &cache_key v1-dependencies-{{ checksum "yarn.lock" }}
  save_node_modules: &save_node_modules
    save_cache:
      paths:
        - node_modules
      key: *cache_key
  restore_node_modules: &restore_node_modules
    restore_cache:
      keys:
        - *cache_key
        - v1-dependencies-

workflows:
  version: 2
  all-check:
    jobs:
      - package-install-and-lint
      - build:
          requires:
            - package-install-and-lint
      - test:
          requires:
            - package-install-and-lint
      - e2e:
          requires:
            - package-install-and-lint

jobs:
  package-install-and-lint:
    executor: frontend-executor
    steps:
      - checkout
      - *restore_node_modules
      - run: yarn install
      - *save_node_modules
      - run: yarn lint
  build:
    executor: frontend-executor
    steps:
      - checkout
      - *restore_node_modules
      - *save_node_modules
      - run: yarn build
  test:
    executor: frontend-executor
    steps:
      - checkout
      - *restore_node_modules
      - *save_node_modules
      - run: yarn test
      - store_artifacts:
          path: coverage
  e2e:
    executor: frontend-executor
    steps:
      - checkout
      - *restore_node_modules
      - *save_node_modules
      - run: yarn e2e
```

# まとめ
かなりすっきりした config.yml が書けたのではないかと思います。
例えば時間が経って format check の job を追加したいとなっても簡単に追加できるはずです！
Let's CI !!!

明日は @fusho-takahashi さんです。お楽しみに〜