😶‍🌫️

Cloudflare Workers でメンテナンスページを用意する

2024/08/29に公開

動機

Misskeyサーバーを運営していて、アップデートを実施するときにサイトにアクセスできなくなってしまうので、カスタムメンテナンスページを用意したくなりました。

メンテページ実現の選択肢

(😞不採用)NginxやApacheの設定でメンテページを返す

  • メンテナンスモードの場合はサーバー上の静的ページを返す、という昔ながらのやり方。
  • 1台のWebサーバーで運営しているので、サーバー再起動などしているタイミングだとそもそもメンテページすらアクセスできなくなる。

(🥰採用)Cloudflare Workers を使う

  • エッジで実行されるので、Webサーバーが落ちていてもユーザーにはページを返し続けてくれる。
  • ダッシュボード上で編集して即デプロイできるので楽しい。
  • KVに環境変数を設定することでメンテモードの切り替えも楽ちん。

Cloudflare Workers の基本

Cloudflare Workers は、エッジで動作するサーバーレスプラットフォームです。リクエストをインターセプトし、カスタムロジックを実行できます。

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  // カスタムロジックをここに実装
}

Workersの作成

Cloudflareのダッシュボードの「Workers&Pages」からWorkersを選択して作成しましょう。
私は「maintenance-page」という名前で作りました。

ちょっと長いですがコード全部載せます。

コードを表示
  addEventListener('fetch', event => {
    event.respondWith(handleRequest(event.request))
  })
  
  async function handleRequest(request) {
    const maintenanceMode = await MAINTENANCE_KV.get('maintenance_mode')
    
    if (maintenanceMode === 'true') {
      const startTime = new Date('2024-08-29T15:15:00+09:00');
      const endTime = new Date('2024-08-29T16:45:00+09:00'); 
  
      const htmlContent = maintenanceHTML
        .replace('{{startTime}}', formatJST(startTime))
        .replace('{{endTime}}', formatJST(endTime))
        .replace('{{startTimestamp}}', startTime.getTime())
        .replace('{{endTimestamp}}', endTime.getTime());
  
      return new Response(htmlContent, {
        headers: { 'Content-Type': 'text/html' },
        status: 503
      })
    }
    
    return fetch(request)
  }
  
  function formatJST(date) {
    return date.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' });
  }
  
  const maintenanceHTML = `
  <!DOCTYPE html>
  <html lang="ja">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>メンテナンス中</title>
      <style>
          body {
              font-family: Arial, sans-serif;
              margin: 0;
              padding: 0;
              display: flex;
              justify-content: center;
              align-items: center;
              min-height: 100vh;
              background: linear-gradient(to bottom, #f0fff4, #c6f6d5);
          }
          .container {
              background-color: white;
              padding: 2rem;
              border-radius: 1.5rem;
              box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
              text-align: center;
              max-width: 90%;
              width: 400px;
              position: relative;
              overflow: hidden;
          }
          h1 {
              color: #2d3748;
              margin-bottom: 1rem;
          }
          p {
              color: #4a5568;
              margin-bottom: 0.5rem;
          }
          .countdown {
              font-size: 1.5rem;
              font-weight: bold;
              color: #38a169;
              background-color: #f0fff4;
              border-radius: 9999px;
              padding: 0.5rem 1rem;
              display: inline-block;
              margin: 1rem 0;
          }
          #wave {
              position: absolute;
              bottom: 0;
              left: 0;
              width: 100%;
              height: 100%;
              z-index: 0;
          }
          .content {
              position: relative;
              z-index: 1;
          }
      </style>
  </head>
  <body>
      <div class="container">
          <svg id="wave" preserveAspectRatio="none" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
              <defs>
                  <linearGradient id="water-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
                      <stop offset="0%" stop-color="rgba(74, 222, 128, 0.6)" />
                      <stop offset="100%" stop-color="rgba(74, 222, 128, 0.2)" />
                  </linearGradient>
              </defs>
              <path id="wavePath" fill="url(#water-gradient)">
              <animate
                  attributeName="d"
                  values=""
                  dur="10s"
                  repeatCount="indefinite"
                  begin="0s"
              />
              </path>
          </svg>
          <div class="content">
              <h1>🌙メンテナンス中です</h1>
              <p>いつもちゃすきーを見にきてくれてありがとう🍵</p>
              <p>現在サーバーアップデートなどの事情により、ご利用いただけません。ご不便をおかけしてすみません。</p>
              <p>開始時刻: <span id="startTime">{{startTime}}</span></p>
              <p>終了予定: <span id="endTime">{{endTime}}</span></p>
              <div class="countdown" id="countdown"></div>
              <p>ご理解とご協力をお願いいたします。<br>お気に入りのお茶でも飲んでお待ちください。</p>
          </div>
      </div>
      <script>
      const startTime = new Date({{startTimestamp}});
      const endTime = new Date({{endTimestamp}});
      
      function getJSTDate() {
          return new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Tokyo' }));
      }
  
      function updateWavePath(waterLevel) {
        const wavePath = document.getElementById('wavePath');
        const d = \`
            M 0 \${400 - waterLevel * 4}
            Q 100 \${380 - waterLevel * 4} 200 \${400 - waterLevel * 4}
            T 400 \${400 - waterLevel * 4}
            V 400
            H 0
            Z
        \`;
        wavePath.setAttribute('d', d);

        const animateTag = wavePath.querySelector('animate');
        animateTag.setAttribute('values', \`
            \${d};
            M 0 \${400 - waterLevel * 4 + 20}
            Q 100 \${400 - waterLevel * 4 + 20} 200 \${400 - waterLevel * 4 + 20}
            T 400 \${400 - waterLevel * 4 + 20}
            V 400
            H 0
            Z;
            \${d}
        \`);
    }

      function updateCountdown() {
        try {
            let waterLevel;
            const now = getJSTDate();
            const diff = endTime - now;
            
            if (diff > 0) {
                const minutes = Math.floor(diff / (1000 * 60));
                const seconds = Math.floor((diff % (1000 * 60)) / 1000);
                document.getElementById('countdown').textContent = \`残り時間: \${minutes}分 \${seconds}秒\`;
                
                const progress = Math.min(Math.max(((now - startTime) / (endTime - startTime)) * 100, 0), 100);
                const minWaterLevel = 20;
                const waterLevel = minWaterLevel + (progress * (100 - minWaterLevel) / 100);
                
                updateWavePath(waterLevel);
            } else {
                document.getElementById('countdown').textContent = 'メンテナンス完了';
                waterLevel = 100;
                updateWavePath(waterLevel);
            }
    
        } catch (error) {
            console.error('更新中にエラーが発生しました:', error);
        }
    }    
      
      // ページロード時に即座に更新
      updateCountdown();
      
      // 1秒ごとに更新
      setInterval(updateCountdown, 1000);
  
      console.log('スクリプトが正常に読み込まれました');
  </script>
  </body>
  </html>
  `;

このようなページが作成されます。

KVにメンテモードの有効/無効を保持

MAINTENANCE_KVという名前空間を作成して、maintenance_modeというキーに、true or false を値に設定します。

KVのバインド

Workersの
[設定]>[変数]>[KV 名前空間のバインディング]
からKVとWorkers内で参照する変数名のバインドを設定できます。
MAINTENANCE_KVという変数名に、KV名前空間 MAINTENANCE_KV(そのまま)をバインドします。

ルートの設定

どのURLに対してメンテページを表示させるかも柔軟に設定可能です。
Workersの
[設定]>[トリガー]>[ルート]から、
hostname/*
などとするとサイト全体がメンテページにルーティングされます。

おわりに

以上で設定完了です。
ちゃすきーという、お茶好き専用のMisskeyサーバーを運営しているので、興味ある方は新規登録ぜひ!
https://chaskey.net

Discussion