CI/CDのスクリプトを開発／テスト／不具合対応しやすくする方法を考えてみる

# 3行で
- CI/CD のスクリプトを YAML に埋め込むと開発・検証しづらい点があるよ
- スクリプトを個別のファイルに外部化することで、Linter や 単体テストツール を活用できるけど、注意点があるよ
- CI/CDの不具合時の対応を見据えた工夫もしておくと便利かも

# 背景
一般的に、CI/CD パイプラインの定義は YAML で記述することが多く、パイプラインで実行する処理（主にシェルスクリプト）も YAML 内に埋め込む形式で書く方法が基本形です。

例えば、GitLab の場合は以下のように書くことができます。`build` ジョブ内の `script` キーワード内で `echo` を実行しています。
```yaml
image: ubuntu:20.04
build:
  stage: build
  tags:
    - saas-linux-small-amd64
  script:
    - echo "Hello, world."
```

このように YAML 内にスクリプトを埋め込む形式は、例えば以下のメリットがあります。
- 素早くパイプラインを構成して動かし始められる。
- パイプラインで実行される処理の全容が把握しやすい。
- パイプラインの変更はすべて YAML の変更となるため、改修の影響が把握しやすい。
- ジョブ実行時のログに、処理のステップが残る。（GitLab の場合 script: 配列に記載された文字列が出力される。）

しかし、YAML はあくまで構造化データを記述することを目的としたフォーマットであるため、以下のようなデメリットが生じます。
- **スクリプトだけを実行、テストすることが難しい。**
- **スクリプト開発時にIDE等での補完機能が適用しづらい。**
- **スクリプト部分にその言語の Linter 等を適用することが難しい。（例：shellcheck)**
- **YAML の制約を守りながらスクリプトを書く必要があり、インデントが煩雑になる。**
  - heredoc や for文を使う例
      ```yaml
      script:
        - |
          for path in $(find / -maxdepth 1); do
            echo ${path}
          done
          cat << EOS
            foo
              bar
          EOS
      ```
これらのデメリットは、小規模なプロジェクトでシンプルなパイプラインを構成する上では些末な問題かもしれませんが、大規模なプロジェクト向けにパイプラインを開発し保守運用していく上では、のちのちの痛みに繋がります。

そこで、本記事では上記のデメリットを解消し、CI/CD のスクリプトを開発・検証しやすくする方法を考えていきます。

# 環境構成
本記事は以下の環境で動作した結果を示しています。
- Ubuntu 20.04
- GitLab.com (GitLab Enterprise Edition 15.7.0-pre b837a1ea452)
- Visual Studio Code 1.73.1
- [vscode-shellcheck](https://github.com/vscode-shellcheck/vscode-shellcheck) v0.28.2
- [shunit2](https://github.com/kward/shunit2) 2.1.8

# スクリプトのみのファイルに外部化する
（そりゃそうだろ、と言われそうですが）まずはスクリプト部分を別のファイルに外部化してみましょう。
適当なスクリプトを `sample.sh` として用意します。**`sample.sh` に実行権限をつけてコミットすることで、スクリプト実行前に都度 `chmod +x` を実行せずに済みます。** セキュリティ要件に応じて検討してください。

```:構成
.
├── .gitlab-ci.yml
└── script
    └── sample.sh
```

```sh:script/sample.sh
#!/bin/bash

VAR="Hello, world."
echo $VAR

cd some_dir
TXT=`cat test.txt`

echo "end"
```

ジョブからは、外部化したスクリプトを実行するように記載します。

```yaml:.gitlab-ci.yml
image: ubuntu:20.04
build:
  stage: build
  tags:
    - saas-linux-small-amd64
  script:
    - script/sample.sh
```

スクリプトだけのファイルになったので、YAMLのインデント等は気にせずに実装ができますね。

## 終了ステータスをハンドリングする

さて、このままでもジョブは正しく起動し、外部化したスクリプトは実行されるのですが、１つ問題があります。

一般的に、CIツールのジョブでは実行したコマンドの終了ステータス（exit code） が 0 以外だとその時点でジョブが失敗となり、処理が中断します。しかし、スクリプトを外部化した場合、CIツールはスクリプトファイルを実行しているだけなので、**スクリプト中のコマンドの 終了ステータス はハンドリングされません。** （例外として、スクリプト内で `exit` が省略された場合は、`スクリプト内で最後に実行されたコマンドの終了ステータス` が `スクリプトの終了ステータス`となります。）

したがって、もともと yaml 上に記載していた処理をそのまま外部化するだけでは、異常終了時の振る舞いが変わってしまう可能性があります。
必ず**スクリプト内の各コマンドの終了ステータスを元に、スクリプトとしての異常終了を定義**するようにしましょう。

先の `sample.sh` の修正例を以下に示します。
コマンド が 0 以外を返したときに exit させるため、shebang に `-e` を設定します。 これで、`cd` 先のディレクトリが存在しない場合や、`cat` 対象のファイルが存在しない場合にスクリプトを中断し、ジョブを失敗させることができます。[^1]

[^1]: コマンド実行時で中断するのではなく、スクリプトを最後まで実行してから exit したい場合は `$EXIT_CODE` のような適当な変数で終了ステータスを保持し、最後に 正常終了／異常終了の判定をして、exit します。

```sh:script/sample.sh
#!/bin/bash -e

VAR="Hello, world."
echo $VAR

cd some_dir
TXT=`cat test.txt`

echo "end"
```

## スクリプトのステップを出力する

CIツールのジョブ実行ログには、スクリプトの内容が1行毎に出力されるため、何が実行され、どこで失敗したか追えるようになっていることが多いと思います。

スクリプトを外部化すると、スクリプトを呼び出したログのみが出力されるため、スクリプト内で実行されるステップは出力されません。
![Screenshot from 2022-12-09 09-31-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/fbdf9d76-f521-1258-e701-d50591491b46.png)
シェルスクリプトの場合は、シンプルな解決策として shebang に `-v` や `-x` をつけておく（`#!/bin/bash -v`, `#!/bin/bash -x`） という方法があります。
上記の例と同じスクリプトで、 `-x` をつけた場合のジョブ実行ログは次のようになります。
![Screenshot from 2022-12-09 09-38-30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/655a4cb5-6cd6-e9cc-ae8b-2b4e0c4f631c.png)

shebang で指定した場合は、スクリプト内のステップが一律出力されてしまうので、**秘匿情報を含む環境変数を扱っている場合** は `set -x`, `set +x` を適宜書くか、ステップが追えるような `echo` を書いておくようにしましょう。

# ジョブをローカル実行する
CIツールによっては、ローカルでのパイプライン実行をサポートしているものがあります。

- CircleCI CLI の `build` サブコマンド: https://circleci.com/docs/ja/how-to-use-the-circleci-local-cli/#run-a-job-in-a-container-on-your-machine
- GitLab Runner の `exec` サブコマンド: https://docs.gitlab.com/runner/commands/#gitlab-runner-exec 
- GitHub Actions のローカル実行ツール `act`: https://github.com/nektos/act

これらを活用することで、パイプライン定義をプッシュせずに動作確認できるようになり、ビルド環境のリソースにも影響を与えずに済みます。

また、スクリプトファイルを外部化することで、上記のようなツールを介すことなくスクリプト単体で動作確認ができるようになります。（`curl`や`wget`, `docker` コマンド等、外部サービスとの通信が必要な処理がある場合は、必ずしも正常に動くわけではないですが･･･。）

ジョブやスクリプトをローカル実行する際は、パイプライン実行環境でのみ定義されている環境変数を利用できないことがあるので注意しましょう。実行前に、CIツールのAPIを実行するなどして、環境変数を取得・設定してからローカル実行するようなスクリプトを用意しておくと良いかもしれません。

# Linter を実行する
スクリプトだけを外部化することで、shellcheck のような Linter が実行しやすくなります。
vscode プラグインを利用する場合、以下のように指摘が表示されます。便利ですね。
![Screenshot from 2022-12-08 03-50-36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/3faab56b-d9f2-3a5f-147e-d4441c5203d5.png)
YAML に直接スクリプトを記述した場合、当然 YAML ファイルとして各種プラグインが機能するので、指摘は表示されません。
![Screenshot from 2022-12-08 03-51-46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/d349f16e-dc46-c176-191b-60b907d5d8ea.png)
 
# 補完機能を活用する
bash でスクリプトを書く場合は、例えば VSCode プラグインの [bash IDE](https://marketplace.visualstudio.com/items?itemName=mads-hartmann.bash-ide-vscode) を使うことで、コード補完や関数宣言へのジャンプなどを活用できます。

python の場合は、VSCode であれば [Python プラグイン](https://marketplace.visualstudio.com/items?itemName=ms-python.python)、他のIDEであれば IntelliJ, PyCharm 等を使うことで、効率良い開発ができそうです。（パイプラインで利用するスクリプトの開発に、わざわざそこまで使うか、という話はありそうですが。）

# 関数化と単体テスト
## ケーススタディ
タグ作成時にデプロイをするような処理を書く場合、タグ名からデプロイ先環境名などの文字列を切り出す処理が必要になったりします。

GitLab では、タグ作成時に起動するパイプラインにおいて 環境変数 `CI_COMMIT_TAG` にタグ名が入ります。
タグの命名規則が `環境名-任意文字列` だった場合、それぞれを `awk` で切り出す処理は、このように書くことができます。

```sh:タグから環境名を切り出す
ENV_NAME=$(echo "${CI_COMMIT_TAG}" | awk -F'-' '{ print $1 }')
echo "${ENV_NAME}"    # CI_COMMIT_TAG=itg-foobar の場合 itg が出力される
```
開発者によっては、別の実装をしているかもしれません。`sed` や、`cut` でも実装できますね。
```sh:タグから環境名を切り出す（別の書き方）
ENV_NAME=$(echo "${CI_COMMIT_TAG}" | sed -e 's/-.*$//')
ENV_NAME=$(echo "${CI_COMMIT_TAG}" | cut -d'-' -f 1)
```

さて、プロジェクト規模が大きくなった結果、リージョン別にデプロイする要件が出て、タグの命名規則が `リージョン-環境名-任意文字列` に変更される場合を考えましょう。以前は 1フィールド目は `環境名` でしたが、今回の変更で `リージョン` になってしまいました。このとき、上記のような切り出し処理がパイプラインの様々な箇所で自由に書かれていた場合、影響調査と修正が大変になりがちです。[^2]

[^2]: 現実的には、修正しなくて済むように`環境名-リージョン-任意文字列` に変更するのも手ですね。

## 関数化する
ということで、一般的なソフトウェア開発のお作法に則り、関数化してみましょう。タグから環境名を切り出したい場合は、`get_env_from_tag` 関数 を呼び出すようにします。こうすることで、先述した修正はこの関数を修正するだけで済みます。
```sh:タグから環境名を切り出す関数
get_env_from_tag () {
    # よりシンプルには、変数展開を利用して ENV_NAME="${CI_COMMIT_TAG%%-*}" と表すことができます。
    echo "${CI_COMMIT_TAG}" | awk -F'-' '{ print $1 }'  # ここを修正する
    return 0
}
```

せっかくなので、エラーハンドリングもつけましょう。
```sh:タグから環境名を切り出す関数 with エラーハンドリング
get_env_from_tag () {
    if [ -z "${CI_COMMIT_TAG}" ]; then
        echo "タグが定義されていません。処理を中断します。"
        return 1
    fi
    echo "${CI_COMMIT_TAG}" | awk -F'-' '{ print $1 }'
    return 0
}
```

このように、CI/CDで頻出する処理を関数化することで、変更に強くしたり、エラーハンドリングしやすくしたり、可読性を上げたりなど、CI/CDを保守・運用していく上でのメリットを得ることができます。

## 関数を利用する
先ほど作成した関数を `script/get_env_from_tag.sh` として保存し、これを利用した `deploy` ジョブを作成します。

```sh:script/get_env_from_tag.sh
get_env_from_tag () {
    if [ -z "${CI_COMMIT_TAG}" ]; then
        echo "タグが定義されていません。処理を中断します。" 1>&2
        return 1
    fi
    echo "${CI_COMMIT_TAG}" | awk -F'-' '{ print $1 }'
    return 0 
}
```

```yml: .gitlab-ci.yml
image: ubuntu:20.04

default:
  tags:
    - saas-linux-small-amd64

stages:
  - build
  - deploy

sample:
  stage: build
  rules:
    - if: $CI_COMMIT_BRANCH
  script:
    - script/sample.sh

deploy:
  stage: deploy
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - script/deploy.sh
```

ジョブから呼び出すスクリプト `script/deploy.sh` では、先頭で source コマンドを使って関数を定義しておきます。 [^3] [^4]
`get_env_from_tag` 呼び出し時も、通常のコマンド呼び出しと同様に終了ステータスをハンドリングしましょう。

```sh:script/deploy.sh
#!/bin/bash -e
source "script/get_env_from_tag.sh"

ENV=$(get_env_from_tag)
echo "${ENV} にデプロイする。"
```

タグ `itg-foobar123` を作成した際のジョブのログは次のとおりです。`itg` が切り出せていますね。
![Screenshot from 2022-12-07 21-33-31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/2402118e-ec2f-8da5-b88e-784c5545e79b.png)

[^3]: PATH を通す、でもOKです。
[^4]: GitLab の場合はプロジェクトのルートディレクトリを `${CI_PROJECT_DIR}` で取得できます。`${CI_PROJECT_DIR}/script/get_env_from_tag.sh` と書いたほうが厳密です。

## 単体テスト

シェルスクリプトの場合、例えば[shunit2](https://github.com/kward/shunit2) のようなフレームワークを使うことで、単体テストを実行することができます。

### 参考：shunit2 の利用方法
ここでは、shunit2の詳細な説明はしませんが、利用方法のみ示しておきます。
1. https://github.com/kward/shunit2/releases からソースコードのアーカイブをダウンロードする。
2. アーカイブを展開し、ファイル `shunit2` を実行できるようにする。（実行権限を付与する、PATHを通すなど）
3. シェルスクリプトに `test` から始まる関数を作成し、テストケースを実装する。
   - 関数等を定義した外部のシェルスクリプトを読み込む場合は、`oneTimeSetup` 関数で読み込んでおく。
4. そのスクリプトファイルの中で `shunit2` を呼び出す。

### テストケースを実装する
先の `script/get_env_from_tag.sh` のテストケースを shunit2 で実装します。
ここでは、わかりやすさのため `shunit2` を プロジェクト内に格納しています。

```構成
.
├── .gitlab-ci.yml
└── script
    ├── deploy.sh
    ├── get_env_from_tag.sh
    ├── sample.sh
    ├── shunit2
    └── unittest.sh
```

テストケースを3つ書いてみました。最初の2つが正常系ケースで、`CI_COMMIT_TAG` に値が設定されていた場合に切り出される文字列を検証しています。3つ目が異常系ケースで、`CI_COMMIT_TAG` が空の場合に終了ステータスが1になり標準エラー出力が出ることを検証しています。
```sh:unittest.sh
#!/bin/bash
SCRIPT_DIR="$(dirname "$0")"

# 正常系
test_get_env_from_tag_case01 () {
    export CI_COMMIT_TAG=envname-sometext
    local result
    result=$(get_env_from_tag)
    assertEquals "envname" "${result}" 
    unset CI_COMMIT_TAG
}

# 正常系
test_get_env_from_tag_case02 () {
    export CI_COMMIT_TAG=envname-sometext-sometext
    local result
    result=$(get_env_from_tag)
    assertEquals "envname" "${result}" 
    unset CI_COMMIT_TAG
}

# 異常系
# CI_COMMIT_TAG が空
test_get_env_from_tag_case03 () {
    local result
    get_env_from_tag 1>case03_stdout 2>case03_stderr
    
    # 終了ステータス の検証
    assertEquals "1" "$?"

    # stdout, stderr の検証
    assertEquals "" "$(cat case03_stdout)"
    assertEquals "タグが定義されていません。処理を中断します。" "$(cat case03_stderr)"
}

oneTimeSetUp () {
    source "${SCRIPT_DIR}"/get_env_from_tag.sh
}

. "${SCRIPT_DIR}"/shunit2
```

テストの実行結果は次のとおりです。

```bash: 実行結果
$ ./script/unittest.sh 
test_get_env_from_tag_case01
test_get_env_from_tag_case02
test_get_env_from_tag_case03

Ran 3 tests.

OK
```

[ケーススタディ](#ケーススタディ)での改修要件について、テスト駆動開発をしてみましょう。
`test_get_env_from_tag_case01` と `test_get_env_from_tag_case02` の `CI_COMMIT_TAG` の先頭に `region-` を追加します。
これで、1､ 2ケース目が失敗するはずです。

<details><summary>修正後の unittest.sh</summary>

```sh: unittest.sh
#!/bin/bash
SCRIPT_DIR="$(dirname "$0")"

# 正常系
test_get_env_from_tag_case01 () {
    export CI_COMMIT_TAG=region-envname-sometext
    local result
    result=$(get_env_from_tag)
    assertEquals "envname" "${result}" 
    unset CI_COMMIT_TAG
}

# 正常系
test_get_env_from_tag_case02 () {
    export CI_COMMIT_TAG=region-envname-sometext-sometext
    local result
    result=$(get_env_from_tag)
    assertEquals "envname" "${result}" 
    unset CI_COMMIT_TAG
}

# 異常系
# CI_COMMIT_TAG が空
test_get_env_from_tag_case03 () {
    local result
    get_env_from_tag 1>case03_stdout 2>case03_stderr
    
    # 終了ステータス の検証
    assertEquals "1" "$?"

    # stdout, stderr の検証
    assertEquals "" "$(cat case03_stdout)"
    assertEquals "タグが定義されていません。処理を中断します。" "$(cat case03_stderr)"
}

oneTimeSetUp () {
    source "${SCRIPT_DIR}"/get_env_from_tag.sh
}

. "${SCRIPT_DIR}"/shunit2
```
</details>

テストの実行結果は次のとおりです。予想通り 1､ 2ケース目が失敗となりました。この結果を元に、関数 `get_env_from_tag` を修正していけばよいですね。

```bash:実行結果（実際には一部カラー出力されます。）
$ ./script/unittest.sh 
test_get_env_from_tag_case01
ASSERT:expected:<envname> but was:<region>
test_get_env_from_tag_case02
ASSERT:expected:<envname> but was:<region>
test_get_env_from_tag_case03

Ran 3 tests.

FAILED (failures=2)
```

このように、CI/CD のスクリプトを外部化することで、単体テストツールを活用した開発ができるようになりました。

### パイプラインのテストをCI化する
アプリのテストはCIで実行するのに、パイプラインのスクリプトはローカルで手動実行している、というのは虚しいですよね。
せっかくなので、上記のテストケースを パイプラインで実行できるようにしましょう。

通常のアプリ開発の際に毎回パイプライン用のスクリプトが単体テストされるのは煩わしいので、`rules:changes` キーワードを利用して、`script` ディレクトリ配下に修正が入った場合にのみテストが実行されるようにします。

`.gitlab-ci.yml` はこのようになります。

```yml:.gitlab-ci.yml
image: ubuntu:20.04

default:
  tags:
    - saas-linux-small-amd64

stages:
  - pipeline-test
  - build
  - deploy

pipeline-test:
  stage: pipeline-test
  rules:
    - if: $CI_COMMIT_BRANCH
      changes: 
        - script/*
  script:
    - script/unittest.sh

sample:
  stage: build
  rules:
    - if: $CI_COMMIT_BRANCH
  script:
    - script/sample.sh

deploy:
  stage: deploy
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - script/deploy.sh
```
リポジトリへの操作と起動するジョブの対応は、下表のようになります。

|リポジトリへの操作|起動するジョブ|
|--- |---|
|アプリのソースコードのpush|`sample`ジョブが起動する|
|パイプライン用スクリプトのpush|`pipeline-test`ジョブが起動する|
|アプリのソースコードと、パイプライン用スクリプトのpush|`sample`、`-test`ジョブが起動する|
|タグの作成（push）|`deploy` ジョブが起動する|


# デバッグしやすくする工夫
## ジョブの単独実行
パイプラインの開発や不具合調査をする中で、特定のジョブだけ動作確認したいケースがあります。よくある対応として、都度 yaml ファイル内のジョブをコメントアウトしてコミットしてからパイプラインを動かすことがありますが、正直面倒くさいですよね。

環境変数を利用して、特定のジョブだけを動かせるように起動条件を修正してみましょう。
環境変数 `DEBUG_JOB` が設定されている場合、`DEBUG_JOB` に一致するジョブだけ起動し、それ以外のジョブは起動しない条件にしてみます。

（以下の正規表現は間に合わせの例なので、実際に活用する際は厳密なパターンとしてください。）

```yml:.gitlab-ci.yml
image: ubuntu:20.04

default:
  tags:
    - saas-linux-small-amd64

stages:
  - pipeline-test
  - build
  - deploy

pipeline-test:
  stage: pipeline-test
  rules:
    - if: '$DEBUG_JOB && $DEBUG_JOB =~ /pipeline-test/'
    - if: '$DEBUG_JOB && $DEBUG_JOB !~ /pipeline-test/'
      when: never
    - if: $CI_COMMIT_BRANCH
      changes: 
        - script/*
  script:
    - script/unittest.sh

sample:
  stage: build
  rules:
    - if: '$DEBUG_JOB && $DEBUG_JOB =~ /sample/'
    - if: '$DEBUG_JOB && $DEBUG_JOB !~ /sample/'
      when: never
    - if: $CI_COMMIT_BRANCH
  script:
    - script/sample.sh

deploy:
  stage: deploy
  rules:
    - if: '$DEBUG_JOB && $DEBUG_JOB =~ /deploy/'
    - if: '$DEBUG_JOB && $DEBUG_JOB !~ /deploy/'
      when: never
    - if: $CI_COMMIT_TAG
  script:
    - script/deploy.sh
```

GitLab には 「Run Pipeline」というパイプラインを手動起動できる仕組みがあり、そこで環境変数を設定することができます。
上記の例では、環境変数 `DEBUG_JOB` に `pipeline-test` を設定して起動することで、`pipeline-test` ジョブだけを起動することができます。

![Screenshot from 2022-12-09 08-57-25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/988c062f-7777-24ff-2050-496239bf4549.png)


## オプションの変数化

不具合調査時に、スクリプト内のコマンドを少し修正してコミット、プッシュしてパイプラインを回し・・・という対応はあるあるだと思います。
例えば、不具合対応用のブランチを切って、スクリプト内のコマンドに `-v` オプションをつけてコミットして動作確認する、というイメージです。

オプションをつける程度であれば、例えば `${CURL_OPTS}`、`${GIT_OPTS}`、`${WGET_OPTS}` のような変数展開をコマンドに付与しておき、デバッグ用環境変数（例えば `CI_DEBUG`）の true/false に応じて、一括でそれらの変数にオプションを抜き差しすることが可能です。 一般的なCIツールであれば、パイプライン実行時に環境変数を設定できたり、リポジトリ自体にCI/CD用の環境変数を設定できたりします。そこにデバッグ用環境変数を設定することで、パイプラインのソースに手を加えることなく、すぐに不具合調査ができるようになります。

# まとめ

本記事では、CI/CD パイプラインのスクリプトを外部化することを中心として、開発／テスト／不具合対応 しやすくなる方法を考えてみました。
CI/CD のスクリプトは**間に合わせ的に書いても動いてしまう**ことが多いため、プロジェクトが大きくなった後や、ある程度時間が経った後、体制変更があった後に、いざ改修しようとして大変になることが多いです。

本記事で紹介した方法を参考に、皆さんのプロジェクトの CI/CD 開発体験がより良くなれば幸いです。

自分のプロジェクトではこんな工夫をしているよ！などあれば、ぜひコメントください！