デプロイをwwwrootの全消しに切り替える前に消えるものを数えた
Azure App Service へのデプロイを、毎回 wwwroot を空にしてから展開し直す形に変えました。
切り替える前にやったのは、消える側に何があるのかを数えることでした。wwwroot の下にあるのはアプリのコードだけではありません。数え終えてみると、消えても困らないものと、消えたら二度と戻らないものが同じディレクトリに同居していました。
前提
App Service は Linux で、zip を Kudu の publish API に投げて /home/site/wwwroot に展開する方式です。Kudu は App Service のデプロイと管理を担う裏側のサイトで、az webapp deploy もこのAPIを叩いています。データベースは別リソースにあります。
wwwrootはローカルディスクではない
もう1つ前提があります。この記事の判断はほとんどここから出ているので、先に置きます。
The file system of your application is a mounted network share. This enables scale out scenarios where your code needs to be run across multiple hosts.
(アプリケーションのファイルシステムはマウントされたネットワーク共有です。これによって、コードを複数のホストで動かすスケールアウトが可能になります)
— Azure「Azure App Service on Linux FAQ」
/home の実体は Azure Files で、SMBでマウントされています。Linux側からは cifs として見えます。インスタンスを3台に増やしても、3台が同じ1枚の共有を見ます。ローカルディスクに各ノードのコピーが置かれるのではありません。
デプロイする側から見ると、これは楽な性質です。zip を投げる先は1つで、インスタンスが何台になっても配布や同期の段取りは要りません。
裏返すと、削除も1回で全インスタンスに効きます。1台ずつ入れ替えるような段階を作れないので、--clean true が走っている間、稼働中の全インスタンスが同時に空の wwwroot を見ます。あとで出てくる原子性——デプロイの途中の状態が外から見えないこと——の話は、ここが根拠です。
速度にも効きます。読み書きがSMB越しなので、ファイル数がそのまま所要時間になります。毎回すべてを展開し直す clean デプロイと、この性質の相性は良くありません。所要時間の実測はあとで出します。
増分デプロイは消えたファイルを消さない
切り替えた理由は、既定の増分デプロイの仕様にあります。
Files in the ZIP package are copied only if their timestamps don’t match what’s already deployed.
(zipパッケージ内のファイルは、既にデプロイされているものとタイムスタンプが一致しない場合にのみコピーされます)
— Azure「Deploy files to Azure App Service」
書かれているのはコピーの条件だけで、削除には触れていません。zip に入っていないファイルは、そのまま残ります。リポジトリから消したはずのコントローラが本番で生き続け、ファイル名をリネームすれば旧名のファイルも並んで残る。すぐには壊れないぶん、気づくのは何かがおかしくなってからです。
削除させる口は publish API のクエリパラメータにあります。
Specifies whether to clean (delete) the target deployment before deploying the artifact there.
(アーティファクトを配置する前に、配置先を clean する(削除する)かどうかを指定します)
— Azure「Deploy files to Azure App Service — Kudu publish API reference」
az webapp deploy --clean true がこれに当たります。配置先は wwwroot なので、消える対象は wwwroot 配下の全部です。
消える範囲を2つの軸で数える
「wwwroot 配下の全部」が何を指すのかを並べます。判断に要る軸は2つだと思っていました。消えたあと作り直せるか。そして、そもそも wwwroot の内側にあるか。
| 対象 | 置き場所 | wwwrootの内側か | 消えたあと |
|---|---|---|---|
| ビルド成果物 | bootstrap/cache, storage/framework/views |
内側 | 作り直せる |
アプリケーションキャッシュ (database / redis) |
DB / Redis | 外側 | そもそも消えない |
アプリケーションキャッシュ (file) |
storage/framework/cache/data |
内側 | 作り直せないものが混ざる |
セッション (file) |
storage/framework/sessions |
内側 | 作り直せない |
1行目は問題になりません。ビルド成果物というのは config:cache や view:cache が吐くファイルのことで、中身はソースから決まります。消えてもブートで packages.php と services.php が戻り、デプロイ後に optimize を打てば残りも揃います。
その optimize が1回で済むのも、wwwroot が共有だからです。前日の記事ではビルド成果物はノードローカルなので全ノードで打つと書きましたが、App Service では1回で全台に行き渡ります。
危ないのは、アプリケーションキャッシュの行が2つに割れているところです。Cache ファサードの書き込み先は CACHE_STORE の値で決まり、database ならDBの cache テーブル、file なら storage/framework/cache/data の下になります。前者は wwwroot の外なので --clean true と無関係で、後者は内側なのでデプロイのたびに消えます。同じコマンドの影響範囲が、設定値ひとつで変わります。
しかもここに入っているのは、アプリが Cache::remember() で置いたデータだけではありません。レートリミッタのカウンタや、onOneServer() と WithoutOverlapping が握る排他ロックも同じ場所です。データが消えるのは再取得すれば済みますが、ロックが消えると同じ処理が二重に走ります。同居しているものの内訳は前日の記事に書きました。
セッションも形は同じで、SESSION_DRIVER=file なら storage/framework/sessions に置かれるので、デプロイのたびに全員がログアウトします。
どの行に当たるかは、config/cache.php と config/session.php を読んでも決まりません。env('CACHE_STORE', 'database') の第2引数は、環境変数が入っていない環境での値です。App Settings に CACHE_STORE や SESSION_DRIVER が入っていればそちらが勝つので、実機で config('cache.default') と config('session.driver') を出すまで行は確定しません。自分は既定値だけを見て「clean デプロイでセッションが消えるからDBへ移す」と書き、あとになって実機がとっくに database だったことに気づきました。
消えたら戻らないものが内側にあった
表の下2行も内側にあって作り直せませんが、失うのはログイン状態やロックであって、業務のデータではありません。3つ目の軸が要りました。失われるのが状態か、実データか。
出力ファイルを溜めている共有ストレージを Azure Files でマウントしているのですが、そのマウント先がここでした。
/home/site/wwwroot/storage/app/exports
wwwroot の内側です。パスを見た瞬間に手が止まりました。
中身はビルド成果物でも再取得できるキャッシュでもなく、消えたら終わりの実データです。しかもバックアップで拾えません。自動バックアップに何が含まれるかの表で「復元されるか」が No になっているのは2行しかなく、その1つがこれです。
Content from any custom-mounted Azure storage, such as from an Azure Files share.
(カスタムマウントしたAzureストレージ、たとえばAzure Filesの共有にあるコンテンツ)
— Azure「Back up an app in Azure App Service — What’s included in an automatic backup?」
--clean true が wwwroot 配下を再帰的に消しに行くとき、削除処理からこのマウントが見えていれば、CIFS 越しに共有の実データを消します。
実際に消えるのかは確かめていません。確かめるコストと、外れたときのコストが釣り合わないからです。試さずに移設しました。
移設と言っても、ポータルのパスのマッピングで書き換えるだけで済みました。マウントを削除して作り直す必要はなく、設定を変えても共有側のデータは消えません。アプリからの参照は環境変数でパスを外に出す形にして、値を差し替えました。
- /home/site/wwwroot/storage/app/exports
+ /mounts/exports
移設後に共有を数えて32件、移設前と同じでした。
全消しは毎回すべてを書き直す
代償は素直に出ました。増分と clean で1回ずつデプロイした実測です。
| 方式 | zipの格納ファイル数 | Kudu側の所要 |
|---|---|---|
| 増分 | 9,418 | 111秒 |
| clean | 9,562 | 297秒 |
ファイル数はほぼ同じで、所要が2.7倍になりました。増分は変更分しか書かないのに対し、clean は毎回すべてを Azure Files へ展開し直すからです。9,562 のうち 8,232 は vendor で、これは中身が変わらなくても毎回書き直されます。
2回の条件は完全に揃っていません。増分の回は共通アクションの azure/webapps-deploy、clean の回は自前で書いた az webapp deploy --clean true で、投げる口が違います。zip の作り方も違って、共通アクションが zip の前に並べていた rm -rf をやめ、かわりに node_modules を除きました。差し引き144ファイル増えたのがその結果です。2.7倍の全部が clean のぶんとは言えません。
所要が伸びると、別の壁にも当たります。Linux の App Service はHTTP接続を4分ほどで切るので、同期で待つ書き方だとデプロイが完走していてもCIだけが赤くなります。そこは主題が変わるので別に書きます。
原子性も諦めました。wwwroot は共有の1枚なので、削除から展開が終わるまでの間、稼働中のインスタンスが虫食いの wwwroot を見ます。スロットとswapを使っていない構成なのでそこは受け入れましたが、本番へ同じものを持ち込むときは先にここを埋める必要があると思います。
展開しないという選択肢
残骸も原子性も、まとめて消える方式があります。zip を展開せず、zip そのものを wwwroot としてマウントする Run From Package です。
In contrast, when you run directly from a ZIP package, the files in the package aren’t copied to the wwwroot directory. Instead, the ZIP package itself gets mounted directly as the read-only wwwroot directory.
(これに対して、zipパッケージから直接実行する場合、パッケージ内のファイルは wwwroot ディレクトリにコピーされません。かわりに、zipパッケージそのものが読み取り専用の wwwroot ディレクトリとして直接マウントされます)
— Azure「Run your app from a ZIP package」
展開しないので、前回の残骸という概念がそもそもありません。前の節で諦めた原子性も、同じドキュメントが利点として挙げています。
- Eliminates file lock conflicts between deployment and runtime.
- Ensures only full-deployed apps are running at any time.
(デプロイと実行時のあいだのファイルロックの競合をなくす / 完全にデプロイされたアプリだけが常に動いていることを保証する)
— Azure「Run your app from a ZIP package」
3つの方式を並べると、上2つの欠点を3つ目が両方とも持っていません。
| 方式 | 前回の残骸 | デプロイ中に見えるもの | wwwrootへの書き込み |
|---|---|---|---|
| 増分 | 残る | 一部だけ新しい wwwroot | できる |
| clean | 残らない | 空から埋まっていく wwwroot | できる |
| Run From Package | 概念として無い | 切り替わる前か後のどちらか | できない |
表に入れていない差もあります。さきほどの自動バックアップの表で「復元されるか」が No だった2行のうち、まだ触れていないもう1行が、この方式のパッケージの中身です。コードはリポジトリにあるので実害は薄いものの、wwwroot の中身がバックアップから外れる点は頭に置く話です。
採らなかった理由は、表の右端の列です。
読み取り専用のwwwrootでLaravelが困ること
Running directly from a package makes
wwwrootread-only. Your app will receive an error if it tries to write files to this directory.(パッケージから直接実行すると wwwroot は読み取り専用になります。このディレクトリにファイルを書こうとすると、アプリはエラーを受け取ります)
Laravel が実行時に wwwroot 配下へ書きたがるものを並べます。
| 書くもの | 既定の置き場所 | 逃がす手段 |
|---|---|---|
| パッケージ自動検出の結果 | bootstrap/cache/packages.php, services.php |
zipに焼き込む。または APP_PACKAGES_CACHE / APP_SERVICES_CACHE |
| コンパイル済みビュー | storage/framework/views |
VIEW_COMPILED_PATH |
| ログ | storage/logs |
stderr チャンネルに寄せる |
セッションとキャッシュ (file) |
storage/framework/ の下 |
database / redis へ移す |
| アップロード | storage/app |
オブジェクトストレージへ移す |
1行目が一番きついです。パッケージ自動検出の結果はブート時に書かれるので、bootstrap/cache を chmod 500 にすると artisan が1つも通らなくなります。
In PackageManifest.php line 179:
The /path/to/project/bootstrap/cache directory must be present and writable.
調査の足場ごと無くなるのは、さすがに避けたい。救いは、書き込みが起きるのがファイルの無いときだけという点です。先に packages.php と services.php を焼いて zip に入れておけば、読み取り専用のままでも通ります。
2行目のコンパイル済みビューは、同じ手が使えません。ファイル名が元ファイルの絶対パスのハッシュで決まるので、CIのチェックアウト先と実行時のパスが違えば1枚もヒットしません。ヒットしなければその場でコンパイルしようとして、読み取り専用なので落ちます。VIEW_COMPILED_PATH で /tmp に逃がせば落ちませんが、インスタンスが増えるたびに全ビューをコンパイルし直します。
同じドキュメントは、起動時にアプリディレクトリへ書き込む Java の組み込みランタイムを非対応として挙げています。Laravel が名指しされているわけではありませんが、条件のほうはそのまま当てはまります。
WebJob も置き場所を見直すことになります。zip に同梱すれば /home/site/wwwroot/app_data/jobs/ のままで動きますが、読み取り専用になるので disable.job の作成のような操作は失敗します。ドキュメントは逃がし先も書いていて、読み取り専用にならない /home/site/jobs/ へ置けばよく、両方に置いた WebJob はどちらも走ります。zip から外して別に配置する形になるので、片付ける項目がもう1つ増えます。
今回は採りませんでした。この移行の主題はデプロイの残骸をなくすことで、それは --clean true で足ります。Run From Package に寄せると、表の5行とWebJobを全部片付けるまで動かないので、1つの移行としては大きすぎます。原子性が欲しくなった時点で、まとめて向き合う話だと思います。
まとめ
- App Service の wwwroot はローカルディスクではなく、全インスタンスが同じ1枚を見るネットワーク共有。デプロイも削除も1回で全台に効き、SMB越しなのでファイル数がそのまま所要時間になる
- 増分デプロイはタイムスタンプが違うファイルをコピーするだけで、zipに入っていないファイルを消さない。消したはずのファイルが残り続ける
--clean trueが消すのは配置先である wwwroot 配下。数える軸は「作り直せるか」「wwwroot の内側か」「失うのが状態か実データか」の3つ- ビルド成果物は内側にあるが作り直せる。アプリケーションキャッシュとセッションは
CACHE_STOREとSESSION_DRIVER次第で、databaseなら外側なので無傷、fileならstorage/framework/の下なのでレートリミッタもロックも毎デプロイで消える - どの行に当たるかは config の既定値からは分からない。
env()の第2引数は環境変数が入っていない環境の値なので、実機でconfig()を出す - wwwroot の内側にマウントした共有ストレージは、作り直せないうえにバックアップにも入らない。clean 化の前に外へ出す
- 残骸も原子性もまとめて解決するのは Run From Package だが、wwwroot が読み取り専用になる。Laravel はブート時に
bootstrap/cacheへ書くので、焼き込みかパスの逃がしを先に済ませないとartisanすら通らない
数えてみると、危なかったのは Laravel の側ではなく、Laravel が置かれているディレクトリの側でした。