はじめに
個人開発にて、Spring Bootでアプリを作り、Cloud Runでデプロイしました。
しかし、費用を抑えるために最小インスタンス数を「0」にしたところ、コールドスタート時に最大で10~15秒ほどの待ち時間が発生 してしまい、UXが削られてしまっていました。これを何とかするのが今回の目標です。
要件などを検討
Cloud Runのインスタンスが起動していない時点で、そもそもリクエストがアプリまで到達できません。
そのため、アプリ側のソースをどう直してもこの問題は根本的には解決できず、インフラ側での対応が必要になります。
今回の要件と制約は以下の通りです。
- コールドスタート中、ユーザーには軽量なロード画面を表示させたい
- Cloud Runの最小インスタンス数は「0」を維持する
- アプリ側のソースはあまり大掛かりな修正をしたくない
- ドメイン管理およびエッジサーバーとして Cloudflare を使用中
Cloudflareの機能で何か使えるものが無いか探したところ、「Cloudflare Workers」 をリバースプロキシのように挟む構成が要件に合っていそうだと分かりました。
Cloudflare Workersの活用
Cloudflare Workersとは
Cloudflareのエッジネットワーク上で、JavaScriptやTypeScriptを実行できるサーバーレス環境です。ユーザーから一番近いサーバーでプログラムが動くため、非常に高速に処理を返すことができるそうです。
何ができるのか
通常、ユーザーからのリクエストはそのままオリジンサーバー(今回はCloud Run)へ流れますが、Workersを間に挟むことで以下のような操作が可能になります。
- リクエストの内容を見て、処理を分岐させる
- オリジンサーバーへリクエストをプロキシする
- オリジンサーバーの応答を待たずに、Workers側で独自のHTMLを作って即座に返す
- レスポンスヘッダーを書き換える
なぜ選んだのか
最大の理由は 「Workersからの応答が高速で、待ち時間中でも一瞬でHTMLを返せる」 からです。
Cloud Runが起動していなくとも、その手前にいるWorkersは速やかに処理を実行してくれます。
これを利用して、「Cloud Runが起動していないなら、Workersでとりあえずロード画面を返す」という仕組みを作ろうと思います。また、無料枠(1日10万リクエスト)が太っ腹な点も嬉しいポイントでした。
環境
- バックエンド: Cloud Run(最小インスタンス数: 0)
- エッジ/DNS: Cloudflare(Workersを利用)
- アプリの構成: Spring Boot、Thymeleaf、他
実装していく
それでは、実際にCloudflare Workersにデプロイするスクリプトを書いていきましょう。
(記事用に一部省略したり、順不同になっていたりします)
ロード画面のhtmlを作成
ユーザーがアクセスしてきた際、Cloud Runがスリープ中であれば、Workersが即座に以下のHTMLを返します。
このHTML内にはJavaScriptが埋め込まれており、1秒ごとにバックエンドのヘルスチェック用API(/api/health)をポーリングします。Cloud Runが完全に起動して HTTP 200 OK が返ってきたら、本番のページ(/top)へリダイレクトさせる仕組みです。
//HTMLの生成
const loadingHtml = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>起動中...</title>
<meta name="robots" content="noindex,nofollow">
<style>
.spinner { width: 45px; height: 45px; border: 4px solid #334155; border-top: 4px solid #38bdf8; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="spinner"></div>
<p>接続中です。しばらくお待ちください。</p>
<script>
const healthApi = "/api/health";
async function pollBackend() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
//キャッシュを無視してヘルスチェックAPIを叩く
const res = await fetch(healthApi, {
method: "GET",
cache: "no-store",
signal: controller.signal,
headers: { "Cache-Control": "no-store" }
});
clearTimeout(timeoutId);
//200が返ってきたら遷移
if (res.ok) {
window.location.replace("/top");
return;
}
} catch (e) {
clearTimeout(timeoutId);
}
//起動中の場合は1秒後に再確認
setTimeout(pollBackend, 1000);
}
pollBackend();
</script>
</body>
</html>`;
//HTTP 503でロード画面を返す
return new Response(loadingHtml, {
status: 503,
headers: {
"Content-Type": "text/html;charset=UTF-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
"Retry-After": "10",
"X-Robots-Tag": "noindex, nofollow",
},
});
実際には、後述する fetch() 処理の最後でこのレスポンスを返します。
オリジンURLの書き換え
単純にリクエストを転送するだけだと、Spring Boot側が「自分は https://〜.run.app で動いている」と勘違いし、リダイレクト処理などで run.app のドメインをユーザーに返してしまいます。
これではオリジンのURLが露出してしまうため、セキュリティの観点からよろしくありません。
これを防ぐため、リクエストに X-Forwarded-* ヘッダーを付与して独自ドメインの情報をSpring Bootに伝えつつ、レスポンスの Location ヘッダーを独自ドメインに書き換える処理を入れます。
//プロキシ用の共通関数
async function proxyToBackend(request, publicUrl) {
const backendOrigin = new URL("https://GCPのオリジンURL.run.app");
const backendUrl = new URL(request.url);
//転送先をCloud RunのURLへ書き換え
backendUrl.protocol = backendOrigin.protocol;
backendUrl.host = backendOrigin.host;
const headers = new Headers(request.headers);
headers.delete("Host");
//Spring Bootに独自ドメインでアクセスされていることを伝える
headers.set("X-Forwarded-Host", publicUrl.host);
headers.set("X-Forwarded-Proto", publicUrl.protocol.replace(":", ""));
headers.set("X-Forwarded-Port", publicUrl.protocol === "https:" ? "443" : "80");
headers.set("X-Original-Host", publicUrl.host);
const init = {
method: request.method,
headers,
//バックエンドからのリダイレクトをブラウザに直接返す
redirect: "manual",
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = request.body;
}
const originResponse = await fetch(backendUrl.toString(), init);
const responseHeaders = new Headers(originResponse.headers);
//バックエンドが返すLocationドメインを独自ドメインに直す
rewriteLocationHeader(responseHeaders, publicUrl, backendOrigin);
return new Response(originResponse.body, {
status: originResponse.status,
statusText: originResponse.statusText,
headers: responseHeaders,
});
}
function rewriteLocationHeader(headers, publicUrl, backendOrigin) {
const location = headers.get("Location");
if (!location) return;
try {
const targetUrl = new URL(location, publicUrl.origin);
//run.app 宛てのリダイレクトだったら、独自ドメインに書き換え
if (targetUrl.host === backendOrigin.host) {
targetUrl.protocol = publicUrl.protocol;
targetUrl.host = publicUrl.host;
}
headers.set("Location", targetUrl.toString());
} catch (e) {}
}
SEO対策
この構成で一番怖いのは、Googlebotなどのクローラがアクセスしてきた時にロード画面(503エラー)を返してしまうことです。HTTP 503 は一時的な利用不可を示すステータスなので、短時間であれば致命的にはなりませんが、検索エンジンのクローラに対して頻繁にロード画面だけを返してしまうと、インデックス評価に悪影響が出る可能性があります。
そのため、User-Agentを見てBotからのアクセスだと判定した場合は、ロード画面を見せずに正しいページ(/top)へ HTTP 302 Found で流すようにしました。
//クローラからのアクセスの場合はトップページへリダイレクト
const userAgent = request.headers.get("User-Agent") || "";
const isBot = /bot|googlebot|crawler|spider|robot|crawling/i.test(userAgent);
if (isBot) {
return Response.redirect(`${publicUrl.origin}/top`, 302);
}
上記に加え、人間が通常アクセスした場合にCloud Runが起動している場合はロード画面を見せたくありません。
そのため、600ミリ秒だけCloud Runの応答を待つ処理を入れてあげると良いです。インスタンスが起動していればスムーズに画面遷移し、起動していなければロード画面に切り替わります。
Spring Boot側の修正
ポーリング用の軽量なヘルスチェック用のエンドポイントをSpring Boot側に用意します。
@RestController
public class HealthCheckController {
@GetMapping("/api/health")
public ResponseEntity<String> health() {
// Cloud Runが起動していれば HTTP 200 OK を返す
return ResponseEntity.ok("OK");
}
}
上記以外に、当環境ではSpring Securityを導入しているため、該当のパスを認証不要に設定しました。
プロキシヘッダーの有効化
application.properties に以下の設定を追加します。
server.forward-headers-strategy=framework
Spring Bootでは、プロキシやロードバランサーの背後で動かす場合、X-Forwarded-* ヘッダーをアプリケーション側のURL生成に反映させる設定が必要になります。
今回の構成では、Workers側から「本当のアクセス元は独自ドメインで、HTTPS通信です」という情報をヘッダーに載せて送っています。しかし、この設定を入れ忘れると、Spring Bootは「自分はCloud Runの run.app ドメインで動いている」と勘違いしたまま処理を進めてしまいます。
その結果、各種業務ロジックのリダイレクト処理において、独自ドメインではなくオリジンのURLを出力してしまい、オリジンURLが外部に露出してしまうという事故が起きます。
この設定により、Spring側が公開URLのホスト名やHTTPSスキームを正しく解釈できるようになります。
最終的にfetchする処理
const BACKEND_ORIGIN = "https://GCPのオリジンURL.run.app";
const HEALTH_PATH = "/api/health";
export default {
async fetch(request, env, ctx) {
const publicUrl = new URL(request.url);
if (publicUrl.pathname === HEALTH_PATH) {
const response = await proxyToBackend(request, publicUrl);
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
headers.set("X-Robots-Tag", "noindex, nofollow");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
if (request.method !== "GET" && request.method !== "HEAD") {
return proxyToBackend(request, publicUrl);
}
// ロード画面は "/" へのアクセスだけを対象にする
if (publicUrl.pathname !== "/") {
return proxyToBackend(request, publicUrl);
}
// Botアクセスの場合はロード画面を返さずトップページへリダイレクト
const userAgent = request.headers.get("User-Agent") || "";
const isBot = /bot|googlebot|crawler|spider|robot|crawling/i.test(userAgent);
if (isBot) {
return Response.redirect(`${publicUrl.origin}/top`, 302);
}
let timeoutId;
try {
const controller = new AbortController();
timeoutId = setTimeout(() => controller.abort(), 600);
const healthUrl = new URL(HEALTH_PATH, BACKEND_ORIGIN);
const check = await fetch(healthUrl.toString(), {
method: "GET",
signal: controller.signal,
headers: getForwardedHeaders(publicUrl, { "Cache-Control": "no-cache" }),
redirect: "manual",
});
if (check.status === 200) {
return Response.redirect(`${publicUrl.origin}/top`, 302);
}
} catch (e) {
// 600msを超えてタイムアウトorネットワークエラー時はここに来る
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
//ここに先述のHTMLを描画する処理を挿入
}
}
//ヘッダーの書き換えを行う
function getForwardedHeaders(publicUrl, extraHeaders = {}) {
const headers = new Headers(extraHeaders);
headers.set("X-Forwarded-Host", publicUrl.host);
headers.set("X-Forwarded-Proto", publicUrl.protocol.replace(":", ""));
headers.set("X-Forwarded-Port", publicUrl.protocol === "https:" ? "443" : "80");
headers.set("X-Original-Host", publicUrl.host);
return headers;
}
実際にコールドスタート中は、以下のような画面が表示されるようになります。

注意点など
ポーリングの間隔について
今回はポーリング間隔を1秒に設定していますが、コールドスタート中のアクセス数が多い場合はCloud Runへのリクエスト数が増えます。このあたりの間隔は実際のアクセス数を見つつ、調整が必要になります。
/topへの遷移について
今回、将来の拡張性などの都合で / にアクセスすると自動的に /top にリダイレクトするようにアプリ側でも制御しています。
ただし、Workers側でBotを判定した場合は、ロード画面を返さず /top へ一時リダイレクトするだけで十分だと考えたため、この記事では 302 を使っています。恒久的に / を /top に正規化したい場合は、アプリ側やSEO設計も含めて 301 や 308 を検討する形になると思います。
コールドスタートが無くなるわけではない
あくまでも、起動待ちの間にWorkers側で軽量なロード画面を返し、ユーザーが何も表示されない状態を避けるための仕組みです。個人的には読み込み中の状態でずっと放置されるのが嫌いなので、今回このような実装をしました。
まとめ
Cloudflare Workersを活用することで、Cloud Runの最小インスタンス数「0」によるコストカットを維持したまま、コールドスタート時のUXを改善できました!
- Cloud Runの最小インスタンス数は「0」のまま維持
- アクセス直後はCloudflare Workersが速やかに応答し、ロード画面を表示
- 裏でCloud Runを起動し、読み込み後に自動で画面遷移
- Botにはロード画面を見せないことで、SEOへの悪影響を抑える
個人開発でGCPやAWSのコンテナサービスを使う場合、この手法はかなり使い勝手が良いと思いました。
他にも良い方はあると思いますが、アプリ側にほとんど手を加えることなく実装できるところが魅力ですね!
コールドスタートで悩んでいる方の参考になれば幸いです✨