LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

AWS Cron式のlintを行い可読性を上げる

リブセンスインフラエンジニアの中野(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のそれと異なるため、とてもミスしやすいのが悩みどころです。

docs.aws.amazon.com

ざっくりと間違えるポイントを並べると:

  • crontabと比べてフィールドが増えている
  • ワイルドカード(*)と似た意味で疑問符(?)を利用する。しかもフィールドと曜日フィールドの両方で同時に*を指定してはならないという、初見殺しのルールがある
  • フィールド区切り文字がスペースなので、複雑なスケジュールを書くと、どこからどこまでがどのフィールドか判別しにくくなる
  • 曜日フィールドの1が何曜日かわからなくなる

※ ちなみに最後の曜日の問題に関しては、SUN-SAT表記で回避できるので、チームルールに定めるのがおすすめです。

このような事情から、プルリクエストが作られても、レビュアーが間違いに気付かずに承認すること、リリースしてしまうことが増えてきてしまいました。

自動テストしたいですよね。そんな気持ちから書いたGitHub Actionsの紹介です。

実装の紹介

Cron式のチェック

まずはCron式のパーサーを見つけるところからだなって探し始めたら、パーサーより良いものに巡り会いました。

winebarrelさん作のcronplanです。

github.com

Cron式を引数で渡すと、書式が正しいかどうかを教えてくれます。加えて、今後n回分の実行計画まで出力してくれます。

チェックだけでなく可読性の良い出力を得られる。もうこれで充分です。心から感謝。

GitHub Actionsへの組み込み

CIにはGitHub Actions(GHA)を利用することにしました。

今回の記事執筆時点でのCIによるテストは、以下のように設計しています。

  • プルリクエストが作られると実行される
  • 変更対象のファイルに含まれるCron式をチェックし、誤りがあればアノテーションする
  • 変更対象の行に含まれるCron式の実行計画をアノテーションする

実行例を見るとイメージが湧きやすいと思うので、先にスクリーンショットを貼ります。こんな感じのアノテーションを期待しているわけです。

PR作成日が木曜日だったので、初回実行が金曜日からだということが示されています。

実装は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さんに感謝。

github.com

しかし、『変更のあった行』は残念ながら自分で取得するしかありません。加えて、アノテーションするためにはファイル名とアノテーション先の行数の指定が必要です。

その結果、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の記事 も大きな括りではその一環でした。

我々は歴史の長いサービスを運用していますが、その開発体験ではモダン化が進み、アジリティが獲得されて来ています。この記事から、その一端を感じてもらえたら幸いです。