Publishを2回押しても、公開は1回になりました
同じアプリへの公開リクエスト3件が、84ミリ秒の間隔でAPIに届き、それぞれがそのアプリのデプロイ全体を開始しました。それらは同じホスティング基盤で競合し、先に別の公開が到達していたため、生成されたリリースのいくつかは失敗しました。目に見える唯一の兆候は「リリースに失敗しました」というメールでした。アプリ自体は、最終的には正常に動いていました。今では、同じアプリの公開が実行中に届いた場合、2つ目のビルドは始まりません。どのリクエストが通るかはデータベースが決め、ほかのリクエストには、公開が既に実行中であるという事実が伝えられます。
何が起きていたか
Mythexでアプリを公開すると、APIはそのアプリのデプロイを記録し、ビルドとリリースを行うオーケストレーター(ビルドを実行するサービス)に依頼します。公開は、公開ボタン、別のブラウザタブ、またはエージェントに公開を頼んだときに開始できます。ダブルクリック、2つのタブ、2回届いたエージェントのリクエストなど、どれもほぼ同時に同じアプリに対する複数の公開を生むことがあります。
この件では、3件が84ミリ秒以内に届きました。それぞれが完全なデプロイを実行しました。ホスティングプラットフォーム上では、同じアプリのリリースが同じ1秒の中で作られ、ほかのリリースがすでに進行中だったため、そのうち3つが失敗しました。後から作られたリリースの1つは止まったままになりましたが、アプリはその後動きました。
そのため、問題は見落とされやすいものでした。利用者には、正常に動くアプリが1つ見えていました。失敗は、リリース失敗のメールとしてしか現れなかったのです。
なぜ何も止めなかったのか
公開コードには、そのプロジェクトの進行中のデプロイを確認するステップが実はありました。しかし、そのステップは、開始前に進行中のデプロイをすべて失敗としてマークしていました。「新しい公開に置き換えられました」という理由です。目的は、途中でプロセスがクラッシュして残ったビルドを片付けることでした。
実際には、ビルドの途中に2つ目の公開が届くと、1つ目の公開は死んだものとされ、2つ目が自分のビルドを開始していました。両方とも、同じアプリに対して最後まで走りました。ロックはありませんでした。あったのは、競合のための道をむしろ積極的に空けてしまうステップでした。
アプリの初回の公開では、別の奇妙な症状もありました。再利用できるデプロイ記録がまだないため、同時に来た2つの初回公開が、どちらも1件の記録を挿入しようとしました。データベースには、アプリのリンク名(スラッグ。your-app.mythex.aiのyour-appのような部分)に対する一意のインデックスがあるので、2つ目の挿入は失敗しました。APIはそれを「そのリンク名はすでに使われています」と訳し、その時点で同じ名前で正常に公開されているアプリの名前を変えるように、利用者に勧めてしまっていたのです。
修正:クレームそのものがロック
デプロイ記録の取得は、今では1つの条件付きのデータベース更新になっています。「誰も公開中でない場合に限り、この行を取り、ビルド中としてマークする」というものです。1つの文なので、同じミリ秒に届いたリクエストも、データベースが順序を決めます。ちょうど1つのリクエストだけが行を変更します。ほかのリクエストは、変更された行が0件だと分かると止まり、オーケストレーターには決して届きません。
ロックには、保持者が死んだときに解放される仕組みも必要です。APIが公開の途中で再起動すると、行は「ビルド中」のまま残り、アプリは再公開できなくなってしまいます。そこで、所有者がハートビートを送らなくなった行(実行中の公開は30秒ごとに行を更新し、5分間沈黙したら死んだものとみなします)は、再び取得できるようにしました。その閾値は、ビルド時間の推測からではなく、ハートビートから決まります。利用者自身のDockerfileのビルドは、正当に長くかかることがあります。ハートビートを送り続けている遅いビルドは、そのクレームを保持し続けます。
進行中のデプロイをすべて失敗としてマークしていたステップは、公開の前には実行されなくなりました。プロセスが死んで残した行だけが片付けられます。
負けたリクエストには、正直に伝える
負けたリクエストは、明確な答えを受け取るようになりました。それはpublish_in_progressで、実行中のデプロイのIDも含まれます。公開を開始できる各場所は、これを処理します。
- ワークスペースは、エラーを表示するのではなく、すでに実行中のビルドを追います。
- エージェントには、このアプリの公開が既に実行中で、それを待つように伝えられます。以前は「失敗しました」と伝えられていたため、エージェントがDockerfileを「直そう」として再試行してしまうことがありました。
- 初回公開でリンク名が衝突した場合も確認されます。名前を持っている行が、このプロジェクト自身の実行中の公開であれば、答えは「すでに公開中」です。「その名前は使われています」ではありません。別のプロジェクトとの本当の衝突の場合は、今でも名前を変えるように伝えます。
検証方法
実際のデータベースとスタブのオーケストレーターを使い、同じアプリの公開を3つ同時に実行して、オーケストレーターに何回デプロイを依頼したかを数える再現スクリプトを書きました。対象は、初回公開と、公開中アプリの再公開の両方です。修正前はデプロイが2回、修正後は1回でした。
このスクリプトには、回避策のケースも含まれています。アプリの行を、ハートビートがなく10分間ビルド中のままにしておき、その後で再び公開し、新しい公開がその行を取得して実行されることを確認します。
単体テストは、ロックが果たすべき2つのことを確認します。同時に届いた再公開3件のうち、通るのは1件だけで、残り2件には公開が実行中だと伝えられること。そして、所有者が死んだビルドは取得できることです。もう1つのテストでは、新しい試行が前の試行のエラーメッセージを引き継がないことを確認しています。
この件から学んだこと
- 片付けのコードは、偽装された競合になりえます。 「進行中のものはすべて失敗にしてから開始する」は、衛生管理のように見えます。しかし、呼び出し元が2つあると、それぞれがお互いをキャンセルし合うことになります。
- データベースに判定させてください。 読み取りのあとに書き込みを行うと、別のリクエストが入り込む隙間が生まれます。1つの条件付き更新なら、その隙間はありません。
- すべてのロックには、生存状態に結びついた期限が必要です。 なければ、クラッシュがアプリの停止に変わってしまいます。
- エラーメッセージは指示です。 「その名前は使われています」は、人を間違った行動に導いていました。正しいメッセージは、より単純で、正確なものでした。
公開について詳しくは、ドキュメントの公開の方法をご覧ください。関連する修正として、公開済みのアプリが再公開中も稼働し続ける仕組みもあります。