3行で
- CI/CD のスクリプトを YAML に埋め込むと開発・検証しづらい点があるよ
- スクリプトを個別のファイルに外部化することで、Linter や 単体テストツール を活用できるけど、注意点があるよ
- CI/CDの不具合時の対応を見据えた工夫もしておくと便利かも
背景
一般的に、CI/CD パイプラインの定義は YAML で記述することが多く、パイプラインで実行する処理(主にシェルスクリプト)も YAML 内に埋め込む形式で書く方法が基本形です。
例えば、GitLab の場合は以下のように書くことができます。build
ジョブ内の script
キーワード内で echo
を実行しています。
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文を使う例
script: - | for path in $(find / -maxdepth 1); do echo ${path} done cat << EOS foo bar EOS
- heredoc や for文を使う例
これらのデメリットは、小規模なプロジェクトでシンプルなパイプラインを構成する上では些末な問題かもしれませんが、大規模なプロジェクト向けにパイプラインを開発し保守運用していく上では、のちのちの痛みに繋がります。
そこで、本記事では上記のデメリットを解消し、CI/CD のスクリプトを開発・検証しやすくする方法を考えていきます。
環境構成
本記事は以下の環境で動作した結果を示しています。
- Ubuntu 20.04
- GitLab.com (GitLab Enterprise Edition 15.7.0-pre b837a1ea452)
- Visual Studio Code 1.73.1
- vscode-shellcheck v0.28.2
- shunit2 2.1.8
スクリプトのみのファイルに外部化する
(そりゃそうだろ、と言われそうですが)まずはスクリプト部分を別のファイルに外部化してみましょう。
適当なスクリプトを sample.sh
として用意します。sample.sh
に実行権限をつけてコミットすることで、スクリプト実行前に都度 chmod +x
を実行せずに済みます。 セキュリティ要件に応じて検討してください。
.
├── .gitlab-ci.yml
└── script
└── sample.sh
#!/bin/bash
VAR="Hello, world."
echo $VAR
cd some_dir
TXT=`cat test.txt`
echo "end"
ジョブからは、外部化したスクリプトを実行するように記載します。
image: ubuntu:20.04
build:
stage: build
tags:
- saas-linux-small-amd64
script:
- script/sample.sh
スクリプトだけのファイルになったので、YAMLのインデント等は気にせずに実装ができますね。
終了ステータスをハンドリングする
さて、このままでもジョブは正しく起動し、外部化したスクリプトは実行されるのですが、1つ問題があります。
一般的に、CIツールのジョブでは実行したコマンドの終了ステータス(exit code) が 0 以外だとその時点でジョブが失敗となり、処理が中断します。しかし、スクリプトを外部化した場合、CIツールはスクリプトファイルを実行しているだけなので、スクリプト中のコマンドの 終了ステータス はハンドリングされません。 (例外として、スクリプト内で exit
が省略された場合は、スクリプト内で最後に実行されたコマンドの終了ステータス
が スクリプトの終了ステータス
となります。)
したがって、もともと yaml 上に記載していた処理をそのまま外部化するだけでは、異常終了時の振る舞いが変わってしまう可能性があります。
必ずスクリプト内の各コマンドの終了ステータスを元に、スクリプトとしての異常終了を定義するようにしましょう。
先の sample.sh
の修正例を以下に示します。
コマンド が 0 以外を返したときに exit させるため、shebang に -e
を設定します。 これで、cd
先のディレクトリが存在しない場合や、cat
対象のファイルが存在しない場合にスクリプトを中断し、ジョブを失敗させることができます。1
#!/bin/bash -e
VAR="Hello, world."
echo $VAR
cd some_dir
TXT=`cat test.txt`
echo "end"
スクリプトのステップを出力する
CIツールのジョブ実行ログには、スクリプトの内容が1行毎に出力されるため、何が実行され、どこで失敗したか追えるようになっていることが多いと思います。
スクリプトを外部化すると、スクリプトを呼び出したログのみが出力されるため、スクリプト内で実行されるステップは出力されません。
シェルスクリプトの場合は、シンプルな解決策として shebang に -v
や -x
をつけておく(#!/bin/bash -v
, #!/bin/bash -x
) という方法があります。
上記の例と同じスクリプトで、 -x
をつけた場合のジョブ実行ログは次のようになります。
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 プラグインを利用する場合、以下のように指摘が表示されます。便利ですね。
YAML に直接スクリプトを記述した場合、当然 YAML ファイルとして各種プラグインが機能するので、指摘は表示されません。
補完機能を活用する
bash でスクリプトを書く場合は、例えば VSCode プラグインの bash IDE を使うことで、コード補完や関数宣言へのジャンプなどを活用できます。
python の場合は、VSCode であれば Python プラグイン、他のIDEであれば IntelliJ, PyCharm 等を使うことで、効率良い開発ができそうです。(パイプラインで利用するスクリプトの開発に、わざわざそこまで使うか、という話はありそうですが。)
関数化と単体テスト
ケーススタディ
タグ作成時にデプロイをするような処理を書く場合、タグ名からデプロイ先環境名などの文字列を切り出す処理が必要になったりします。
GitLab では、タグ作成時に起動するパイプラインにおいて 環境変数 CI_COMMIT_TAG
にタグ名が入ります。
タグの命名規則が 環境名-任意文字列
だった場合、それぞれを awk
で切り出す処理は、このように書くことができます。
ENV_NAME=$(echo "${CI_COMMIT_TAG}" | awk -F'-' '{ print $1 }')
echo "${ENV_NAME}" # CI_COMMIT_TAG=itg-foobar の場合 itg が出力される
開発者によっては、別の実装をしているかもしれません。sed
や、cut
でも実装できますね。
ENV_NAME=$(echo "${CI_COMMIT_TAG}" | sed -e 's/-.*$//')
ENV_NAME=$(echo "${CI_COMMIT_TAG}" | cut -d'-' -f 1)
さて、プロジェクト規模が大きくなった結果、リージョン別にデプロイする要件が出て、タグの命名規則が リージョン-環境名-任意文字列
に変更される場合を考えましょう。以前は 1フィールド目は 環境名
でしたが、今回の変更で リージョン
になってしまいました。このとき、上記のような切り出し処理がパイプラインの様々な箇所で自由に書かれていた場合、影響調査と修正が大変になりがちです。2
関数化する
ということで、一般的なソフトウェア開発のお作法に則り、関数化してみましょう。タグから環境名を切り出したい場合は、get_env_from_tag
関数 を呼び出すようにします。こうすることで、先述した修正はこの関数を修正するだけで済みます。
get_env_from_tag () {
# よりシンプルには、変数展開を利用して ENV_NAME="${CI_COMMIT_TAG%%-*}" と表すことができます。
echo "${CI_COMMIT_TAG}" | awk -F'-' '{ print $1 }' # ここを修正する
return 0
}
せっかくなので、エラーハンドリングもつけましょう。
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
ジョブを作成します。
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
}
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
呼び出し時も、通常のコマンド呼び出しと同様に終了ステータスをハンドリングしましょう。
#!/bin/bash -e
source "script/get_env_from_tag.sh"
ENV=$(get_env_from_tag)
echo "${ENV} にデプロイする。"
タグ itg-foobar123
を作成した際のジョブのログは次のとおりです。itg
が切り出せていますね。
単体テスト
シェルスクリプトの場合、例えばshunit2 のようなフレームワークを使うことで、単体テストを実行することができます。
参考:shunit2 の利用方法
ここでは、shunit2の詳細な説明はしませんが、利用方法のみ示しておきます。
- https://github.com/kward/shunit2/releases からソースコードのアーカイブをダウンロードする。
- アーカイブを展開し、ファイル
shunit2
を実行できるようにする。(実行権限を付与する、PATHを通すなど) - シェルスクリプトに
test
から始まる関数を作成し、テストケースを実装する。- 関数等を定義した外部のシェルスクリプトを読み込む場合は、
oneTimeSetup
関数で読み込んでおく。
- 関数等を定義した外部のシェルスクリプトを読み込む場合は、
- そのスクリプトファイルの中で
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になり標準エラー出力が出ることを検証しています。
#!/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
テストの実行結果は次のとおりです。
$ ./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ケース目が失敗するはずです。
修正後の 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
テストの実行結果は次のとおりです。予想通り 1、 2ケース目が失敗となりました。この結果を元に、関数 get_env_from_tag
を修正していけばよいですね。
$ ./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
はこのようになります。
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
に一致するジョブだけ起動し、それ以外のジョブは起動しない条件にしてみます。
(以下の正規表現は間に合わせの例なので、実際に活用する際は厳密なパターンとしてください。)
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
ジョブだけを起動することができます。
オプションの変数化
不具合調査時に、スクリプト内のコマンドを少し修正してコミット、プッシュしてパイプラインを回し・・・という対応はあるあるだと思います。
例えば、不具合対応用のブランチを切って、スクリプト内のコマンドに -v
オプションをつけてコミットして動作確認する、というイメージです。
オプションをつける程度であれば、例えば ${CURL_OPTS}
、${GIT_OPTS}
、${WGET_OPTS}
のような変数展開をコマンドに付与しておき、デバッグ用環境変数(例えば CI_DEBUG
)の true/false に応じて、一括でそれらの変数にオプションを抜き差しすることが可能です。 一般的なCIツールであれば、パイプライン実行時に環境変数を設定できたり、リポジトリ自体にCI/CD用の環境変数を設定できたりします。そこにデバッグ用環境変数を設定することで、パイプラインのソースに手を加えることなく、すぐに不具合調査ができるようになります。
まとめ
本記事では、CI/CD パイプラインのスクリプトを外部化することを中心として、開発/テスト/不具合対応 しやすくなる方法を考えてみました。
CI/CD のスクリプトは間に合わせ的に書いても動いてしまうことが多いため、プロジェクトが大きくなった後や、ある程度時間が経った後、体制変更があった後に、いざ改修しようとして大変になることが多いです。
本記事で紹介した方法を参考に、皆さんのプロジェクトの CI/CD 開発体験がより良くなれば幸いです。
自分のプロジェクトではこんな工夫をしているよ!などあれば、ぜひコメントください!