リブセンスインフラエンジニアの中野(etsxxx)です。VPoEという立場でもあります。
最近はルビコン出張が忙しかったのですが、やっと落ち着いたので、徐々にスターフィールドの旅に出ています。相変わらずガラクタばかり集めて売り捌く行商状態です。
はじめに
リブセンスでは多くの事業部で、AWSのリソースをTerraformでコード管理しています。
マッハバイトのAWS移行が進むにつれてECS Scheduled TaskやAWS Backupなどの利用が増え、TerraformにはAWS Cron式によるスケジュールの記載が増えてきました。
TerraformにおけるAWS Cron式の記述例
schedule_expression: cron(55 * ? * 1-2,4-5 *)
ところがこのAWS Cron式。ただでさえCron式は読み書きしにくい代物なのに、AWSの書式(AWS Cron式)はLinuxのcrontabのそれと異なるため、とてもミスしやすいのが悩みどころです。
ざっくりと間違えるポイントを並べると:
- crontabと比べて
年
フィールドが増えている - ワイルドカード(
*
)と似た意味で疑問符(?
)を利用する。しかも日
フィールドと曜日
フィールドの両方で同時に*
を指定してはならないという、初見殺しのルールがある - フィールド区切り文字がスペースなので、複雑なスケジュールを書くと、どこからどこまでがどのフィールドか判別しにくくなる
曜日
フィールドの1
が何曜日かわからなくなる
※ ちなみに最後の曜日の問題に関しては、SUN-SAT表記で回避できるので、チームルールに定めるのがおすすめです。
このような事情から、プルリクエストが作られても、レビュアーが間違いに気付かずに承認すること、リリースしてしまうことが増えてきてしまいました。
自動テストしたいですよね。そんな気持ちから書いたGitHub Actionsの紹介です。
実装の紹介
Cron式のチェック
まずはCron式のパーサーを見つけるところからだなって探し始めたら、パーサーより良いものに巡り会いました。
winebarrelさん作のcronplanです。
Cron式を引数で渡すと、書式が正しいかどうかを教えてくれます。加えて、今後n回分の実行計画まで出力してくれます。
チェックだけでなく可読性の良い出力を得られる。もうこれで充分です。心から感謝。
GitHub Actionsへの組み込み
CIにはGitHub Actions(GHA)を利用することにしました。
今回の記事執筆時点でのCIによるテストは、以下のように設計しています。
- プルリクエストが作られると実行される
- 変更対象のファイルに含まれるCron式をチェックし、誤りがあればアノテーションする
- 変更対象の行に含まれるCron式の実行計画をアノテーションする
実行例を見るとイメージが湧きやすいと思うので、先にスクリーンショットを貼ります。こんな感じのアノテーションを期待しているわけです。
実装はBASHで行いました。以下にコードを示します。
name: "Test - Cron Expression" on: pull_request: paths: - "terraform/resources/**" env: CRONPLAN_TAR_URL: https://github.com/winebarrel/cronplan/releases/download/v1.8.1/cronplan_1.8.1_linux_amd64.tar.gz TZ: Asia/Tokyo jobs: aws-cron-expression-check: name: "Test - Cron Expression" runs-on: ubuntu-latest timeout-minutes: 3 permissions: contents: read pull-requests: write defaults: run: shell: bash steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup cronplan run: | curl -sL ${{ env.CRONPLAN_TAR_URL }} -o /tmp/cronplan.tar.gz mkdir -p /tmp/cronplan tar xzf /tmp/cronplan.tar.gz -C /tmp/cronplan mv /tmp/cronplan/cronplan ./cronplan chmod +x ./cronplan - name: Get changed files id: changed-files uses: tj-actions/changed-files@v39 - name: Check AWS Cron Expression id: check run: | set +e set +o pipefail return_code=0 tmpfile=$(mktemp) for _file in ${{ steps.changed-files.outputs.all_changed_files }}; do # 変更があったファイルからAWS Cron式を含む行を抽出(コメント行は除く) grep -n -E '[\t =]\"?cron\([^\)]+\)' ${_file} | grep -v -E '^[^:]+:\s*#' | perl -pe 's/^([^:]+):.*cron\(([^\)]+)\).*/\1:\2/' > ${tmpfile} if [ ${PIPESTATUS[0]} -ne 0 ]; then continue fi # 抽出した行をcronplanでチェック OLDIFS=$IFS IFS=$'\n' for s in $(cat ${tmpfile}); do _num="$(echo "${s}" | cut -d ":" -f 1)" _str="$(echo "${s}" | cut -d ":" -f 2-)" _msg="$(./cronplan -n 1 "${_str}" 2>&1)" if [ $? -ne 0 ]; then echo "'${_str}' is invalid" # annotationを送る(%0Aは改行文字のエスケープ) echo "::error file=${_file},line=${_num}::$(echo ${_msg} | perl -pe 's/\n/%0A/g')" return_code=1 fi done IFS=${OLDIFS} done exit ${return_code} - name: Annotate human readable message id: add-annotation run: | set +e set +o pipefail tmpfile_01=$(mktemp) tmpfile_02=$(mktemp) tmpfile_03=$(mktemp) git diff -U0 ${BASE_SHA} ${HEAD_SHA} > ${tmpfile_01} OLDIFS=$IFS IFS=$'\n' cat ${tmpfile_01} | while read LINE || [ -n "${LINE}" ]; do if (echo "${LINE}" | grep -q -E '^\+\+\+\s'); then # ファイル名表記の行の場合、ファイル名をパース _file="$(echo ${LINE} | perl -pe 's%^.+\sb/(.+)%\1%')" echo "${_file}" elif (echo "${LINE}" | grep -q -E '^@@'); then # 行数表記の行の場合、行数をパース。行数計算を簡略化するために1を引く _num="$(echo ${LINE} | perl -pe 's/^.+\s\+(\d+).+/\1/')" _num=$(( _num - 1 )) elif (echo "${LINE}" | grep -q -E '^\+'); then # 差分表記の行の場合 _num=$(( _num + 1 )) if (echo ${LINE} | grep -q -E '^\+\s*#'); then # コメント行の場合はスキップ continue fi # Cron式があれば検出してチェックし、アノテーションを送る(%0Aは改行文字のエスケープ) if (echo "${LINE}" | grep -q -E '[\t =]\"?cron\([^\)]+\)'); then _cron="$(echo ${LINE} | perl -pe 's/^.*cron\(([^\)]+)\).*/\1/')" echo "CRON: '${_cron}'" echo "そのまま計算したスケジュールの場合" > ${tmpfile_02} ./cronplan -n 10 "${_cron}" >> ${tmpfile_02} if [ $? -ne 0 ]; then # cron式が不正な場合はスキップ(checkのstepで処理済みのため) continue fi echo "+9時間計算したスケジュールの場合" > ${tmpfile_03} ./cronplan -h 9 -n 10 "${_cron}" >> ${tmpfile_03} _header="今後10回の実行スケジュールは以下 (タイムゾーンは考慮されていないので注意)" echo "::notice file=${_file},line=${_num}::${_header}%0A--%0A$(paste ${tmpfile_02} ${tmpfile_03} | perl -pe 's/\n/%0A/g')" fi fi done IFS=${OLDIFS} exit 0 env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.sha }}
若干シェル芸になってますねw
外部コマンドに多く頼るときは、このくらいの量ならBASHで書いてしまう私。読みにくいのは正規表現部分でしょうか。もう少し分量が多くなったら流石に他の言語を使います。
少し手こずったところ
『変更のあったファイル』はtj-actions/changed-files actionのおかげで簡単に取得できました。jackton1さんに感謝。
しかし、『変更のあった行』は残念ながら自分で取得するしかありません。加えて、アノテーションするためにはファイル名とアノテーション先の行数の指定が必要です。
その結果、git diffコマンドの出力をパースするという必要に駆られてしまいました。
step.id:add-annotation
で書いている処理です。
git diff -U0 ${BASE_SHA} ${HEAD_SHA} > ${tmpfile_01} OLDIFS=$IFS IFS=$'\n' cat ${tmpfile_01} | while read LINE || [ -n "${LINE}" ]; do (パース処理) done IFS=${OLDIFS}
シェル芸っぽいのはこの部分が大半ですね・・・w
ちょっとしたタイムゾーンの工夫
リブセンスのサービスと社員は日本時間を基準に動いており、タイムゾーンは日本(Asia/Tokyo)であることが分かりやすいです。特に曜日が重要なスケジュールや月初n日が大事なスケジュールでは、タイムゾーンが異なるとかなりややこしいCron式になります。
ところが、AWSはタイムゾーンの取り扱いが厄介です。UTC動作のリソースが圧倒的ですが、時々タイムゾーンに対応しているリソースがあり、Cron式は混在しがちです。
そんなややこしい状況ですが、残念ながらAWS Cron式はタイムゾーン指定に非対応です。書式を拡張するならそこを拡張してよ・・・
さて、私はCron式が混在していることに対してどうしたのか。
答は『そのままの実行計画』と『9時間加算した実行計画』の2種類を出力するようにしました。最後は人に頼る。
その実装の抜粋です。
echo "そのまま計算したスケジュールの場合" > ${tmpfile_02} ./cronplan -n 10 "${_cron}" >> ${tmpfile_02} echo "+9時間計算したスケジュールの場合" > ${tmpfile_03} ./cronplan -h 9 -n 10 "${_cron}" >> ${tmpfile_03} _header="今後10回の実行スケジュールは以下 (タイムゾーンは考慮されていないので注意)" echo "::notice file=${_file},line=${_num}::${_header}%0A--%0A$(paste ${tmpfile_02} ${tmpfile_03} | perl -pe 's/\n/%0A/g')"
いやぁ、流石にTerraformのコードをパースしてタイムゾーン指定しているかどうか判定するなんて面倒くさすぎるので・・・ 雑な対応で勘弁してもらいました。
稼働させてみての効果
早速いくつかのプルリクエストで書式エラーを検知してくれました。
曜日が必要なスケジュールに関しても、実行計画が頭で考えていたものと違ったことに気がつき、議論の助けになりました。
何より、Cron式があっているかどうかに気を取られることがなくなりました。実行時間・計画が正しいかどうかだけをレビューすれば良くなったので、レビューの質が一段上がったように思います。確実に人間の助けになっています。
ちなみに、上で呟いているタイムゾーンをJSTにする話は、以下のようにGHAのコードに取り込まれています。
env: TZ: Asia/Tokyo
終わりに
マッハバイトではAWS移行に伴ってコード化とモダン化が進み、それに伴って続々とCI/CDが整っています。
先日の Brakemanの記事 も大きな括りではその一環でした。
我々は歴史の長いサービスを運用していますが、その開発体験ではモダン化が進み、アジリティが獲得されて来ています。この記事から、その一端を感じてもらえたら幸いです。