Azure Web Apps for Linuxの定期実行としてcronを使いたい


tl;dr

まず、クラウドに合ったやり方があり、そちらに従うと良いです。本来イベントドリブンな作業を定期実行でカバーしている場合もあるので、その場合は適したサービスを採用すると相応のメリットもあります。

ただ、Azure Web Apps for LinuxではWebjobsが使えなかったり、選択した言語プラットフォームの都合や要件で採用しにくかったり、私みたいに新しいサービスを組み込むのは不安なので馴染み深いcronで済ませたいという時があります。

今回はAzure WebApps for Linuxの「スタートアップコマンド」を利用してcronを入れる例を見つけたので試しました、という記事です。

ただ、この方法はいくつか課題はあると思っており、個人的にはあまりお勧めはできないと思っています。

AppServiceの特有の課題を挙げてみます。他にもあるかもしれません。

  • スケールアウト時の動作、複数インスタンス時の同時実行による影響がないかもしくは排他制御の要否を確認する必要があります
    • WebApps for linuxではマウントされた共有ディスクはファイルロックができません
    • Blobのリースや、DBやRedisのロック等で代替するなど考える必要がありそうです
  • cronでスクリプト実行中でも停止があれば停止します
    • App Serviceはアップデートのためインスタンスが停止する可能性があります
    • WebAppsの停止で試した感じでは、シグナルのトラップはできなさそうなのでGracefulな終了ができません
  • デプロイスロットを使う場合はその配慮が必要です
    • スロットをスワップする仕組み上、一時的に本番環境が2つ存在する状態になります。これが影響しないか確認は必要です
    • スロット設定時は環境変数 WEBSITE_SLOT_NAME が設定されるので、この値をみて本番だけ動かすといったことはできます
  • デフォルトではcronのログが残りません。(syslogデーモンがいないので)

cronなのでcron特有の課題もあります。

  • リソースもAppService共通なので、アプリもしくはcronの実行で高負荷になったりすると互いに影響が出る可能性があります
    • 時間をずらしたり夜間帯に実行するようにして負荷のピークを分散させるバッドノウハウ...
  • 実行時間超過による多重起動

許容できない場合は、最初のドキュメントを見て別のピタゴラスイッチ装置を作るか、Wordpressのwp-cron.phpに倣ってみるか、VMに帰る等々、別のプランを立てる必要があります。

背景

WebAppsが提供しているWebjobsはバックグラウンド実行(≒定期実行)のための仕組みです。(C#であればAzure Webjobs SDKもありますが、これはWebAppsのWebjobsとはまた別ものです。)

しかし、このWebjobsはWebApps for Windowsのみの機能で、WebApps for LinuxWebApps for Containerでは使えません。
フィードバックでも要望はあがっていたものの「Azure Functionsを使いましょう」と却下されています。

色々思うところはありますが、できないものはできないので別のプランを探す必要があります。

ところで、Azureでは「クラウドサービス使ってアプリを作るならこうするとよい」というドキュメントを出しております。

しかし、どれも管理するリソースが増えたり、アプリと密に連携する要件には使いにくそうな感じがします(私のクラウドに対する苦手意識もあります)

他にないかなと調べていたところ、以下のようなBlogを見かけました。

これによると、Webapps for Linuxではcronを入れることができるようです。nodejs,pythonの話にみえますが、PHPも同様でした。

ためす

まずSSH接続してcronを入れられるか試す

ポータル上のWebAppsのメニューに「SSH」とあり、そこからブラウザ経由SSHにアクセスできます。Ctrl-Wを拾ってくれなかったりと操作性に難はありますが。

ログやps -efで見るとわかりますが、Webapps for Linuxは現在dockerコンテナ上で動いています。root権限が与えられているのでコンテナ内であれば比較的自由に行えます。

試しにSSHコンソール経由でコンテナにcronを入れてみましょう。毎分現在時刻を共有ディレクトリのhello-cron.txtに書き出します。ちなみに、debian(ubuntu?)なのでaptを使います。

apt-get install -y cron
service cron start
crontab << '__CRON__'
* * * * * date > /home/hello-cron.txt
__CRON__

1分もすると cronでコマンドが実行され /home/hello-cron.txt に日時が出力されます(cron自体の実行ログはsyslogがないせいか捨てられます)

cat /home/hello-cron.txt

cronが動くことが確認できました。

しかしコンテナなのでこのインストールの操作は、再起動したりすると失われますし、インスタンスのスケールアウト時に追加されたインスタンスにも反映されません。

なんとかしてコンテナが起動したタイミングでcronを入れた状態にする必要があります。コンテナのイメージはWebApps for linuxで管理されているものを使う必要があるため利用できません。

「スタートアップコマンド」の設定してためす

AppService for Linuxでは「スタートアップ コマンド」というものを設定できます。これはインスタンス起動時に実行するコマンドを指定できる機能になっています。

言語によって指定すべきコマンドが少し違うので注意です。PHPの場合はapache httpdを起動する前に実行するコマンドを書けますが、他はHTTPサーバを起動するコマンドを書いたりする必要があります。なんでこうなっているか、要約するとAppServiceの都合で言語毎に仕組みが違うからっぽいからですが、仕組みを少し調べたので後ろのほうにメモしておきます

これでセットアップ用にphp.iniを書き換えたりapache httpdの設定を書き換えたり等ができるのですが、今回はこれをCRONのインストールのために使います。

こんな感じでスタートアップコマンド用のスクリプトを共有ストレージ(/home以下)に書き出しておきます。今回は/home/startup.shとします。

#!/bin/sh

# CRONインストール
apt-get install -y cron
# CRONサービス起動
service cron start
# CRONの設定
crontab << '__CRON__'
* * * * * date > /home/hello-cron.txt
__CRON__

もちろん、処理に時間がかかればapacheの起動が遅れるのでアクセスできるようになるまでは時間がかかるのであまり重い処理は書かないほうがいいでしょう。

WebAppsの設定で「カスタムスタートアップ」スクリプトを実行するよう変更

WebApssの設定→構成→全般設定にある「スタートアップ コマンド」に上記で書き換えた /bin/sh /home/startup.sh と指定します。

そのあと、WebAppsを停止→起動してSSH接続してみて、別のインスタンスに切り替わっていることとcronが動いていることを確認してみましょう。インスタンスを増やしても増やしたインスタンスにcronが入り実行されるはずです。

その他の留意事項

Blogには触れられてなかったんですが、実際は色々課題があります。

気づいた範囲

  • 「常時接続(Always On)」を有効(ON)にしておく必要があります
    • プランの制約があります。Basic以上?
    • AppServiceの仕様でしばらくリクエストがないインスタンスはスタンバイ状態になり、cronも実行されなくなります
    • スケールアウト時もリクエストがないとインスタンスが実行されないようなので、同様にcronも実行されません
  • 複数インスタンス時に変動する環境変数は「WEBSITE_INSTANCE_ID」「HOSTNAME」「COMPUTERNAME」で、いずれもインスタンスが再生成されるたびにランダムで変化します。特定のインスタンスだけで実行するようなことはこのあたりの情報ではできません。
  • CRON実行中にWebAppsを停止した場合、シグナルを受けずに途中で終わります
  • cronでは環境変数がほとんど設定されないので、環境変数の情報を使う場合は別途考える必要があります。
    • 上記のstartup.shの中で env > /tmp/env した後に cronで実行するスクリプトのなかで /tmp/env を読み込んだり、crontabの環境変数に埋め込んだり等
  • デプロイスロットを使う場合はcronの動作にも注意したほうがよいです
    • システムで使うファイルを書き込むような実装は、おそらくデプロイスロットをスワップしたときに意図しない動作になるためです。
    • 本番とステージングで同じファイルシステムをマウントすることもできますが、なら別ですが、事故ると思います
    • 詳しくはスロットについてのドキュメントを読んだり試して挙動を理解しておく必要があります

PaaS特有の留意したほうがよさそうな点(挙動を確認したわけではないので妄想です)

結構課題が多いので、実際に採用するかどうかはよく考えたほうがよいでしょう。

その他

WebApps for Linuxの「スタートアップコマンド」の仕組み

まずAppServiceで使われているDockerイメージは Azure-App-Service/ImageBuilder というもので作られているようで、GitHub上で各ランタイム毎に公開されており、Dockerfileや使われているスクリプトなどが確認できます。

この時、内部では以下のステップを踏んでます。

  1. 各言語のエントリポイントのスクリプト(ImageBuilderが持つ各言語毎のinit_container.sh)」にスタートアップコマンドを引数で渡して実行
  2. その中でさらにoryxを使い、各言語に合わせたスクリプトを作成し実行

二重管理感はありますが、AppServiceの都合をImageBuilderで巻き取っており、コンテナ実行に必要なものをoryxで巻き取っているという感じに見えます。

  • PHP
    • ImageBuilder側
    • oryx側
    • => HTTPサーバ(Apache)を起動する前に実行したいコマンドを書く(sh /home/startup.shsed 's/xxx/yyy/' filepath等でもよさそう)
  • Node
    • ImageBuilder側
    • oryx側
    • => HTTPサーバを待ち受けるコマンドを書く(最終的にnpm run serve等でlistenするプログラムを実行する必要がある)

他の言語は調査していないですが、多分PHPが特殊で他はnodeと同じ感じだと思います。

(pythonならuwsgiやgunicorn等のhttpserver実装があるのでそれを使いたいとかあるはず)

複数インスタンス時のcronの同時実行制御をどうにかしたい

cronでよくあるのは1個のサーバで実行することを保証したい場合です。

案a. インスタンス間で共有しているストレージに対してflockは?→NG

共有ファイルストレージではファイルロックが正しく機能しないようで、flockを使った方法はダメでした。
同一インスタンス上で2プロセス動かす分には機能しますが、複数インスタンスになるとダメです。
(ちなみにWebApps for WindowsのWebJobsはどうやっているんだろうと調べたところ、普通に共有ストレージでロックを取っていそうです...linuxのcifs-utilsの問題かもしれない)

試しに書いたコードです。ロックファイルをfd=10で開きつつ、ロック獲得後に開始と終了のログを出力するだけですが、処理を長引かせたいので30秒のsleepをいれています。

/home/my-cron-script.sh
#!/bin/bash

function trap_exit(){
        RET=$?
        echo "trap_exit ($RET)"
        exit RET
}

trap 'trap_exit' EXIT

(
  flock --timeout 30 -e 10 || exit
  echo $(date --iso-8601=ns) $(hostname) START
  sleep 30
  echo $(date --iso-8601=ns) $(hostname) END
) 10> "${0}.lock"

CRONの設定はこのような感じにします。cronのログは出力されないので、ファイルに書き出しておきます。
同じ共有ストレージ先の同じファイルを出力先にすると壊れましたので、hostnameを足すなどしてユニークにするとよいです。

* * * * * /bin/bash /home/my-cron-script.sh >> /home/my-cron-script.log.$(hostname)

ログファイルが生成された後、tailで複数のファイルを監視して様子をみてみます。

ls -ltr /home/my-cron-script.log.*
tail -F /home/my-cron-script.log.*

排他制御されていれば、1分に1度だけSTART,ENDが出るはずです(30秒のタイムアウトでexitで終了するはず)

しかし、試しに3インスタンスで試したところ、時間的に同時実行されているのでダメと判断しました。

==> my-cron-script.log.2333bfed9cd0 <==
2021-06-29T05:29:01,239315133+00:00 2333bfed9cd0 START

==> my-cron-script.log.29fe18333fd2 <==
2021-06-29T05:29:01,115816353+00:00 29fe18333fd2 START

==> my-cron-script.log.65c2a6b8d6fb <==
2021-06-29T05:29:01,155938740+00:00 65c2a6b8d6fb START

==> my-cron-script.log.2333bfed9cd0 <==
2021-06-29T05:29:31,269394161+00:00 2333bfed9cd0 END
trap_exit (0)

==> my-cron-script.log.29fe18333fd2 <==
2021-06-29T05:29:31,131939267+00:00 29fe18333fd2 END
trap_exit (0)

==> my-cron-script.log.65c2a6b8d6fb <==
2021-06-29T05:29:31,171948745+00:00 65c2a6b8d6fb END
trap_exit (0)

案B. Blobのリースを使う → OK?

次に案としてBlobのリースを使って試してみます。ざっくりな流れは以下です。

  • ロック用のBlobをPut(対象がないとリースを取得できないため)
  • ロック用のBlobのリースを取得
  • ロック用のBlobのリースを使いPut(成否を確認)
    • 失敗したら誰かが先に実行したので終了
  • ここまで来たら排他制御できたとして好きな処理を実行

リース期間(15~60秒)内の排他制御ならこれでよいかもしれませんが、実際は最後に実行してから数分経過していたら実行したいといった場合もあり、その場合は最後に実行した時間を記録したファイル(状態を記録するファイル)を別途用意する必要があるでしょう。
ただ厄介なのはBlobがないとリースを取得できない点です。ロックに使うBlobとデータを持たせるBlobは分けたほうが良いかと思います。

試しに、マネージドIDを有効にし対象のストレージアカウント対して適切な権限がある状態にしてある前提で「リースを獲得できたら最後に実行した日時をチェックし、指定された時間経過していたら引数のコマンドを実行する」という感じのロック用のスクリプトを書きました。(VMでの動作は未確認)
https://gist.github.com/fukasawah/49d77ef6074d7c7eb8e7c241183ad44d

雑な実装で、ストレージアカウントも決め打ちになっているところは環境変数から取るとか、リース期間中に状態ファイルの取得と更新が1回のミスもなくできるというのが前提なので実際はリトライとか入れたほうが良いとか、多分したほうがいいです。

このロック用のスクリプトも共有ストレージ(/home/lock-and-run.sh)に置いておきます。

cronに一部の環境変数を渡したり、ロック用のスクリプトを実行するように書き換える必要があるため、startup.shのcrontabの書き出しの内容も書き換えます。

/home/startup.sh
crontab << __EOF__
IDENTITY_ENDPOINT=$IDENTITY_ENDPOINT
IDENTITY_HEADER=$IDENTITY_HEADER

* * * * * /bin/bash /home/lock-and-run.sh /bin/bash /home/my-cron-script.sh >> /home/my-cron-script.log.\$(hostname) 2>&1
__EOF__

3インスタンスにして試すとこんな感じになります。3台中2台はリースを使ってロック用のBlobを書き込めなかったのでエラーになり実行できなくなっています(タイムスタンプが出てないのでわかりにくいですが。。)

==> my-cron-script.log.cdc3fe7c82c0 <==
2021-06-30T04:00:02,346113519+00:00 cdc3fe7c82c0 START

==> my-cron-script.log.86f66e29c101 <==
LOCK FAILED(lease)

==> my-cron-script.log.ac8a744e1976 <==
LOCK FAILED(lease)

==> my-cron-script.log.cdc3fe7c82c0 <==
2021-06-30T04:00:32,368547939+00:00 cdc3fe7c82c0 END
trap_exit (0)

ちなみにAzure WebJobs SDK(Azure Functionsの内部でも使用している)では、定期実行の仕組みとしてTimerTriggerがあり、複数インスタンス下でも1インスタンスの実行を保証する仕組みがあります。

これもロック用のBlobのリースを得て1インスタンスの実行を保証しつつ、前回の実行時の情報を別のBlobで記録する、というやり方をとっています。上記のgistに書いたコードはこれをマネました。参考にしたコードはこのあたり。