タグ: Next.js

  • 速くしたら、壊れた — 表示1.7秒を0.03秒にするまでと、その代償

    速くしたら、壊れた — 表示1.7秒を0.03秒にするまでと、その代償

    サイトの表示が遅い、という話から始まった作業です。結果として最初の1バイトが返るまでの時間は約1.7秒から0.03秒になりました。50倍以上です。

    ただしこの記事の本題は、その数字ではありません。速くしたことで、別の場所が壊れたという話です。デプロイが失敗し、画像がぼやけ、一覧が読めなくなりました。どれも高速化そのものが引き起こしたものです。

    この記事で扱うこと

    計測から原因を特定する手順、Next.jsのキャッシュがどこに効いてどこに効かないか、事前生成とISR、そして「速くするための選択が壊れやすさを生む」という構造。最後に、自分の計測を自分で壊した失敗も書きます。

    まず測る

    「遅い気がする」から始まる改善は、たいてい見当違いの場所を触って終わります。数字が無いと、直したかどうかも判断できません。

    使ったのは curl だけです。

    最初の1バイトが返るまでの時間を測る
    curl -s -o /dev/null \
      -w "合計:%{time_total}s  最初の1バイト:%{time_starttransfer}s\n" \
      https://www.kisaku.site/

    見るべきは time_starttransfer、最初の1バイトが返るまでの時間です。体感の遅さはほぼここで決まります。この値が大きいと、画面が真っ白なまま待たされます。逆にここが小さければ、多少ファイルが大きくても「表示され始めた」感覚は得られます。

    フロントとCMSの両方を測りました。

    対象 最初の1バイトまで
    フロント(1回目) 3.25秒
    フロント(2回目) 1.85秒
    フロント(3回目) 1.72秒
    CMSのGraphQL 2.29秒

    3回繰り返しているのには理由があります。1回目は暗号化通信の確立が含まれるので必ず遅くなります。1回だけ測って「3.2秒だ」と判断すると、実力値を1.5秒以上見誤ります。

    この表から読み取れることは明確でした。フロントの実力値が約1.7秒、CMSが約2.3秒。つまりフロントの待ち時間は、ほぼそのままCMSの応答時間です。Vercel自体の処理はごくわずかで、大半は共用サーバーのWordPressがPHPとMySQLを動かし終わるのを待っている時間でした。

    犯人は自分が書いた3文字だった

    原因はGraphQLクライアントのこの指定でした。

    src/lib/graphql-client.ts(修正前)
    export const graphqlClient = new GraphQLClient(endpoint, {
      fetch: (input, init) =>
        fetch(input, {
          ...init,
          cache: "no-store",   // ← これ
        }),
    });

    cache: "no-store" は「結果を一切キャッシュしない」という指定です。Next.jsはこれを見つけると、そのページを事前に生成できないと判断します。訪問者が来るたびに、その場でCMSへ問い合わせて組み立てる方式になります。

    ビルド結果にも、はっきり出ていました。

    修正前のビルド結果
    Route (app)
    ┌ ƒ /
    ├ ƒ /blog
    ├ ƒ /blog/[slug]
    ├ ƒ /profile
    ├ ƒ /projects
    └ ƒ /skills
    
    ƒ  (Dynamic)  server-rendered on demand

    全ページに ƒ の印。すべてが「訪問時に組み立てる」状態でした。この情報は毎回のビルドで表示されていたのに、意味を確認していませんでした。

    この指定を入れたのは「記事を公開したらすぐ反映されるように」という意図でした。狙い自体は正しかったのですが、その代償として全ページが毎回CMS待ちになることを見落としていました。

    キャッシュは、思ったところに効かない

    直し方は単純に見えました。no-store を外して、代わりに「5分間キャッシュする」と書けばいい。

    ところが、ここで一段深い問題に当たりました。

    Next.jsのfetchキャッシュはGETにしか効かない

    Next.jsには fetch の結果を自動でキャッシュする仕組みがありますが、対象はGETリクエストだけです。GraphQLは通常POSTでクエリを送ります。つまり fetch に revalidate を指定しても、素通りして何も起きません。

    気づかずに進めていたら、「設定したのに速くならない」という状態で長く悩んだはずです。

    解決には unstable_cache を使いました。これは fetch に限らず、関数の戻り値そのものをキャッシュできる仕組みです。

    src/lib/data.ts
    import { unstable_cache } from "next/cache";
    
    export const getPosts = unstable_cache(
      async () => {
        const data = await graphqlClient.request(GET_POSTS);
        return data.posts?.nodes ?? [];
      },
      ["posts"],                                    // キャッシュキーの一部
      { revalidate: 300, tags: ["wordpress"] },     // 300秒保持
    );

    引数も自動的にキャッシュキーに含まれるので、getPostBySlug("a")getPostBySlug("b") は別物として扱われます。

    名前に unstable_ と付いていますが、これは「将来APIが変わるかもしれない」という意味で、動作が不安定という意味ではありません。

    tags を付けているのは将来のためです。WordPressで記事を公開したときにVercelへ通知を送る仕組みを足せば、5分待たずに反映できます。今は使っていませんが、後から足せるように印だけ置いています。

    ページ自体も事前に作る

    あわせて、各ページに1行足しました。

    各 page.tsx の先頭
    export const revalidate = 300;

    これでNext.jsはページを事前に生成し、300秒ごとに裏側で作り直します。訪問者はCMSの応答を待ちません。

    ただし記事ページは、これだけでは足りませんでした。/blog/[slug] は「どんなスラッグが存在するか」をNext.jsが知らないため、事前に作りようがないからです。そこで一覧を教えます。

    src/app/blog/[slug]/page.tsx
    export async function generateStaticParams() {
      const posts = await getPosts();
    
      return posts
        .map((post) => post.slug)
        .filter((slug): slug is string => Boolean(slug))
        .map((slug) => ({ slug }));
    }

    結果

    ページ 改善前 改善後 状態
    トップ 約1,720ms 25〜35ms キャッシュ命中
    記事ページ 約1,720ms 26〜29ms キャッシュ命中
    実績 約1,720ms 23ms キャッシュ命中
    ブログ一覧 約1,720ms 242ms 毎回生成

    ブログ一覧だけが動的なままです。?category= で内容が変わるページは事前に作れません。それでも1.7秒から242msになりました。CMSへの問い合わせが消え、残っているのは画面を組み立てる時間だけだからです。

    自分の計測を、自分で壊した

    ここで恥ずかしい失敗をしました。

    デプロイ後に測り直したところ、全ページが1.4〜2.6秒。しかも配信網の状態を示すヘッダが、どれも MISS(キャッシュに無い)でした。一瞬「効いていないのか」と思いました。

    原因は私が測り方に入れたキャッシュ除けのパラメータでした。

    やってしまった測り方
    // 毎回ちがうURLになる
    await fetch('/blog?nc=' + Date.now());

    ブラウザのキャッシュを避けるつもりで付けたものですが、配信網から見れば毎回まったく新しいURLです。キャッシュに無いのは当たり前でした。パラメータを外して測り直したのが、上の表の数字です。

    教訓としては、測定の道具が測定対象に影響していないかを疑うということになります。結果がおかしいとき、まずシステムを疑いたくなりますが、測り方のほうが壊れていることは珍しくありません。

    速くした代償で、デプロイが止まった

    ここからが本題です。

    サムネイル画像を記事に紐づけ、一覧のクエリに画像の情報を足して本番に送ったところ、デプロイが失敗しました

    Vercelのビルドログ
    GraphQL Error (Code: 500)
    <title>500 Internal Server Error</title>
    <p>Please contact the server administrator,
     webmaster@gmoserver.jp ...</p>
    
    > Build error occurred
    Error: Failed to collect page data for /blog/[slug]

    エラーの中身は、共用サーバーが返したApacheの標準エラーページでした。WordPressが処理しきれず、サーバー側で落ちていました。

    ここで重要なのは、なぜ今回から落ちるようになったのかです。

    以前は全ページが動的だったので、ビルド中にCMSへ問い合わせることはありませんでした。CMSが500を返しても、影響は「その1回の表示が失敗する」だけです。

    事前生成に切り替えたことで、ビルド時にCMSへ問い合わせる経路が生まれました。そこへ画像の情報を足したため処理が重くなり、共用サーバーが応答しきれなくなりました。

    構造として理解しておきたいこと

    速くするための最適化は、たいてい依存関係を増やします。事前生成は「ビルド時点でデータが取れる」ことを前提にしています。前提が増えれば、壊れる条件も増えます。速さと壊れにくさは、多くの場合トレードオフの関係にあります。

    2つの対策

    ひとつは、5xxエラーのときに自動で投げ直すことです。

    src/lib/graphql-client.ts
    const MAX_ATTEMPTS = 3;
    const RETRY_DELAY_MS = [1000, 3000];   // 1秒後、3秒後
    
    async function fetchWithRetry(input, init) {
      for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
        if (attempt > 0) await wait(RETRY_DELAY_MS[attempt - 1]);
    
        const response = await fetch(input, init);
    
        // 5xx はサーバー側の一時的な不調の可能性が高いので投げ直す。
        // 4xx はこちらの要求が間違っているので、何度試しても同じ。
        if (response.status >= 500 && attempt < MAX_ATTEMPTS - 1) continue;
    
        return response;
      }
    }

    すぐ投げ直さず間隔を空けているのは、混んでいる相手にすぐ投げ直しても意味がないからです。むしろ負荷を増やします。また、再試行するのは5xxだけにしました。4xxを何度試しても結果は変わらず、ただの遅延になります。

    もうひとつは、事前生成の失敗でビルドを止めないことです。

    src/app/blog/[slug]/page.tsx
    export async function generateStaticParams() {
      try {
        const posts = await getPosts();
        return posts.map(/* ... */);
      } catch (error) {
        console.warn("記事一覧を取得できなかったため、事前生成をスキップします", error);
        return [];   // 空を返す = 訪問時に生成する方式へ切り替わる
      }
    }

    判断の根拠は、この処理の役割が「どのページを先に作っておくか」を決めることでしかないという点です。失敗しても致命的ではありません。最初の1人が待つ代わりに、デプロイは通ります。

    なお、デプロイが失敗しても本番サイトは無事でした。Vercelは失敗時に切り替えを行わず、前回成功した内容を配信し続けます。失敗したデプロイは「何も起きない」で済むという設計です。

    画像を足したら、今度は画像が壊れた

    デプロイが通り、サムネイルが表示されるようになりました。しかし画面を見ると、2つ問題がありました。

    1. 画像が大きすぎた

    ブログ一覧は1列なので、カード幅いっぱいに1200×630の画像を置くと高さが454pxになり、1件で画面の半分近くを占めていました

    一覧の役割は「複数の記事を見比べて選ぶ」ことです。1件ずつしか見えない一覧は、一覧として機能していません。画像を左、文字を右の横並びに変え、画像の幅を256pxに収めました。

    2. 画像がぼやけていた

    調べると、配信されていた画像は450px幅なのに、864pxに引き伸ばして表示されていました。原因は自分が書いた指定です。

    間違っていた指定
    <Image
      src={...}
      fill
      sizes="(min-width: 768px) 720px, 100vw"   // 実際は864pxだった
    />

    sizes は「この画像は画面上でどれくらいの幅で表示されるか」をブラウザに伝えるものです。ブラウザはこの申告を信じて、取ってくる画像の大きさを決めます。申告が小さすぎれば拡大されてぼやけ、大きすぎれば無駄に重い画像を取ります。

    横並びに変更して表示幅が256pxで確定したので、申告もそれに合わせました。実寸と申告が一致していることが重要です。

    今回の学び

    まず、測ってから直す。今回は最初に測ったおかげで、原因が「Vercelの処理」ではなく「CMSの応答待ち」だと即座に分かりました。測らずに始めていたら、画像の圧縮やJavaScriptの削減といった、効果のない場所を触っていたはずです。

    次に、キャッシュは「効いているつもり」が最も危ない。POSTには fetch キャッシュが効かない、という一点を知らなければ、設定したのに変わらない状態で長く悩んでいました。仕組みがどこに効いてどこに効かないかは、思い込みではなく確認する必要があります。

    そして、最適化は依存関係を増やす。事前生成は「ビルド時にデータが取れる」という前提の上に成り立っています。前提が増えれば壊れる条件も増えます。速くするときは、同時に「壊れたときにどうなるか」を決めておくべきでした。今回は壊れてから決めることになりました。

    最後に、ブラウザに嘘をつかないsizes の件は、こちらの申告をブラウザが信じて動く仕組みでした。人間が見て分かる間違いではなく、画面がぼやけるという形でしか現れません。自動で最適化してくれる仕組みほど、渡す情報の正しさが結果を決めます

  • 本番公開でつまずいた11のこと — 仕組みから解説

    本番公開でつまずいた11のこと — 仕組みから解説

    前回、ローカルで作ったサイトを本番公開するまでの記録を書きました。今回はその中で出てきた仕組みを、初めて触る人向けに整理して解説します。

    作業記録だけだと「こう直した」で終わってしまい、次に似た場面で応用が効きません。なぜそうなるのかが分かっていれば、症状が違っても同じ考え方でたどり着けます。前回の記事を読んでいなくても分かるように書きますが、実例は前回のものを使います。

    1. URLの「実体」と「転送先」は別物

    まず、いちばん基本的で、いちばん引っかかりやすい話から。

    ブラウザで https://example.com/admin/ を開いて、ページが表示されたとします。このとき「/admin/ にページがある」とは限りません

    Webサーバーにはリダイレクト(転送)という仕組みがあります。「そのURLは別の場所に移ったので、こちらへどうぞ」とブラウザに指示するものです。指示を受けたブラウザは自動的に転送先へアクセスし直します。

    あなた: /admin/ をください
    サーバー: それは /wp/admin/ にあります(301 転送)
    ブラウザ: では /wp/admin/ をください(自動)
    サーバー: どうぞ(200 OK)
         ↓
    画面には「表示された」としか見えない

    この自動追従は普段は便利です。しかし構成を調べる目的では邪魔になります。私は実際にこれで誤解し、「WordPressはルートフォルダにある」と思い込んだまま作業を進めてしまいました。

    調べ方: 追従を切る

    ブラウザの fetch には、転送を追いかけない設定があります。

    await fetch('/admin/',     { redirect: 'manual' })  // → status 0(転送された)
    await fetch('/wp/admin/',  { redirect: 'manual' })  // → status 200(実体がある)

    redirect: 'manual' を指定すると、転送は「ステータス0」という特殊な状態で返ってきます。本物の200と、転送された先の200を区別できるわけです。コマンドラインの curl なら、追従オプション -L を付けないのが同じ意味になります。

    覚えておくとよい原則: 「調べるための通信」と「使うための通信」では、適切な設定が違います。使うときは自動で追ってくれたほうが楽ですが、調べるときは加工されていない生の応答が必要です。

    2. WordPressには基準となるURLが2つある

    これはWordPressを使う人だけの話ですが、はまると長引くので書いておきます。

    WordPressの「設定 → 一般」には、よく似た2つの項目があります。

    WordPress アドレス (siteurl) … WordPress本体のファイルが置いてある場所
    サイトアドレス     (home)    … 訪問者がアクセスするURL

    普通のインストールでは両方が同じ値になるので、違いを意識することはありません。ところが「WordPressを専用フォルダに入れる」構成にすると、この2つが分かれます。

    siteurl = https://cms.example.com/wp    ← 本体は /wp/ の中
    home    = https://cms.example.com       ← 見せるURLはすっきり

    この構成では、トップに置いた小さな中継ファイルが /wp/ の中身を呼び出します。訪問者に見えるURLはきれいなまま、本体のファイル群はフォルダにまとめられる、という利点があります。

    ここからが重要です。 WordPressのプラグインやテーマは、URLを組み立てるときに2つの関数を使い分けます。

    home_url()  → home    を基準にする
    site_url()  → siteurl を基準にする

    どちらを使うかは機能ごとの実装しだいです。実際、2つのAPIで判断が分かれていました。

    REST API  … home_url() 基準 → https://cms.example.com/wp-json/
    GraphQL   … site_url() 基準 → https://cms.example.com/wp/graphql

    私は「REST APIがルートに出ているのだから、GraphQLもルートだろう」と考えました。同じサイトの、同じようなAPIなのだから揃っているはずだ、と。揃っていませんでした。

    この構成を使うときは、「このURLは home と siteurl のどちらから作られるのか」を機能ごとに確認するのが正しい態度です。アップロード画像のURLも同じ理屈で影響を受けるので、画像が表示されない不具合にもつながります。

    3. エラーは「包まれている」ことがある

    これはWeb開発全般で役立つ話です。

    サーバー側のJavaScript(Node.js)で通信に失敗すると、こういうエラーが出ます。

    TypeError: fetch failed

    これだけです。 情報がほぼありません。しかも、原因が何であってもこの同じメッセージになります。

    名前解決に失敗した          → fetch failed
    接続を拒否された            → fetch failed
    証明書が一致しなかった      → fetch failed
    時間内に応答がなかった      → fetch failed

    本当の原因は cause というプロパティの中に入っています。

    try {
      await fetch(url);
    } catch (e) {
      console.error(e.message);   // "fetch failed" ← 情報ゼロ
      console.error(e.cause);     // ← ここに本体がある
    }

    実際に開けてみたら、こう書かれていました。

    Error [ERR_TLS_CERT_ALTNAME_INVALID]:
      Hostname/IP does not match certificate's altnames:
      Host: cms.example.com is not in the cert's altnames:
      DNS:*.gmoserver.jp

    一目で原因が分かります。証明書に書かれた名前が違う、と。

    これはエラーのラッピング(包装)と呼ばれる設計です。低レベルの細かいエラーを、上位の統一されたエラーで包む。使う側が実装の違いを気にしなくて済む利点がありますが、原因を調べるときは包みを開ける必要があります。

    cause はJavaScriptの標準仕様に入っている機能です。名前は環境によって違いますが(innerException__cause__Unwrap() など)、包む設計はどの言語にもあります

    覚えておくとよい原則: エラーメッセージが妙に短くて抽象的なときは、その下にもう一段あります。

    4. HTTPSは「暗号化」の前に「本人確認」をしている

    HTTPSの「S」はSecure(安全)で、通信を暗号化します。ただし、暗号化の前に相手が本物かを確認する手続きがあります。

    これをTLSハンドシェイクといいます。TLSは暗号化の規格名、ハンドシェイクは握手のことです。

    1. こちらが「example.com と話したい」と伝える
    2. サーバーが、その名前に対応する証明書を返す
    3. こちらが「証明書の名前」と「アクセス先の名前」が一致するか確認する
    4. 一致すれば暗号化通信を開始、しなければ接続を中断

    証明書は身分証明書のようなものです。「このサーバーは確かに example.com です」と第三者機関が保証したデータで、そこには対象のホスト名が書かれています。

    ここで押さえておきたいのは、この確認はデータを送る前に行われるという点です。前回の失敗では、サーバーのログに「外部への通信が1本もない」と出ていました。リクエストを送って失敗したのではなく、送る前の握手で決裂していたのです。

    証明書に書かれる名前のルール

    証明書には複数の名前を書けます。*.example.com のようにワイルドカード(なんでも当てはまる記号)を使うこともできますが、ワイルドカードは1階層ぶんしかカバーしません

    *.example.com は…
      shop.example.com     → カバーする
      a.b.example.com      → カバーしない(階層が1つ多い)
      example.com          → カバーしない(サブドメイン部分が無い)

    前回のケースでは、共用サーバーの既定の証明書 *.gmoserver.jp が返ってきていました。当然 cms.kisaku.site は含まれません。

    5. 「自分の環境から動く」は「どこからでも動く」ではない

    前回いちばん学びが大きかったのが、この点です。

    証明書の問題が見つけにくかったのは、ブラウザからは正常にアクセスできていたからでした。鍵マークも付き、データも返ってくる。それでもサーバーからは失敗する。

    なぜ結果が違うのか。通信する経路が違うからです。

    従来のサイト: ブラウザ ──────────────→ サーバー
    今回の構成  : ブラウザ → Next.js(Vercel) → WordPress
                                ↑
                    ここの通信は、ブラウザからは見えない

    従来のWordPressサイトなら、ブラウザがWordPressに直接アクセスするので、ブラウザで確認すれば十分でした。しかしヘッドレス構成ではサーバーがサーバーを呼ぶ経路が主役になります。

    この経路は、ブラウザとは別のソフトが、別の場所から、別の設定で通信しています。証明書の検証も、ブラウザとNode.jsでは厳密さが違うことがあります。

    覚えておくとよい原則: 検証は、実際に通信する経路で行わないと意味がありません。手元で動くことは、他の場所から動くことを保証しません。

    6. エラーを握りつぶす設計の功罪

    前回、データが取れていないのにビルドが「成功」し、画面も表示されてしまいました。原因はコードの書き方です。

    const results = await Promise.allSettled([
      getProjects(), getSkills(), getPosts(),
    ]);
    
    const projects = results[0].status === "fulfilled" ? results[0].value : [];

    複数の非同期処理をまとめる方法は主に2つあります。

    Promise.all        … 1つでも失敗したら、その時点で全体が失敗になる
    Promise.allSettled … 全部の結果を待って、成功・失敗を個別に返す(例外を投げない)

    allSettled を選んだ理由は「プロフィールページをまだ作っていない」といった部分的な欠損でページ全体が真っ白になるのを防ぐためでした。妥当な判断です。

    ところが今回は4つ全部が失敗しました。それでも設計どおり空の配列に置き換えられ、「まだ登録されていません」という平和な画面が出ます。

    想定していた「一部が欠ける」ではなく「全部が落ちる」が起きたとき、この防御は問題を隠す方向に働きました。

    ただし、これは allSettled が悪いという話ではありません。次の2行があれば済んでいました。

    if (results[0].status === "rejected") {
      console.error("[getProjects] failed:", results[0].reason);
    }

    覚えておくとよい原則: 「壊さない」と「気付ける」は別の要求で、両立できます。エラーを飲み込むなら、飲み込んだ記録は残す。

    7. 環境変数は「いつ」読まれるのか

    環境変数は、コードの外側から値を渡す仕組みです。「開発中はローカル、本番は本番」のように環境ごとに変わる値を扱うのに使います。

    Next.jsでは、変数名の頭に NEXT_PUBLIC_ を付けるかどうかで挙動が変わります。付けた場合、その値はビルド時にコードへ直接埋め込まれます

    【ビルド前】process.env.NEXT_PUBLIC_API_URL
    【ビルド後】"https://cms.example.com/wp/graphql"   ← 値そのものに置き換わる

    埋め込まれたコードはブラウザに配信されるので、この値は原理的に秘密にできません。「公開してよい値にだけ付ける」というルールがあり、パスワードやAPIキーに付けてはいけないのもこのためです。

    実務上の帰結が2つあります。

    ひとつは、変更したらビルドし直さないと反映されないこと。管理画面で値を書き換えても、既存のデプロイには影響しません。

    もうひとつは、フォールバックの書き方に注意が要ることです。

    const endpoint = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/graphql";

    ?? は「左が空なら右を使う」という意味です。便利ですが、本番で設定漏れがあってもエラーにならず、黙ってローカル用のURLで動き続けます。前回まさにこれで、原因の特定が遅れました。

    本番では明示的に落とす、という書き方も選択肢です。

    const endpoint = process.env.NEXT_PUBLIC_API_URL;
    if (!endpoint) throw new Error("NEXT_PUBLIC_API_URL is not set");

    覚えておくとよい原則: 親切なフォールバックは、設定ミスを見えなくします。6節と同じ構図です。

    8. DNSは2階層でできている

    DNSは、example.com のような名前と 216.198.79.1 のような数字(IPアドレス)を対応づける仕組みです。インターネットの電話帳のようなものだと考えてください。

    重要なのは、DNSが2つの階層でできていることです。

    【上位】ネームサーバー … 「このドメインの答えは、どのサーバーが持っているか」
    【下位】レコード       … 「そのサーバーが持っている、実際の答え」

    ドメインを買った会社の管理画面で「DNS設定」を開いても、そこが実際に使われているゾーンとは限りません。ネームサーバーが別の会社を向いていれば、答えを持っているのはそちらです。

    前回、まさにこれで事故を起こしかけました。開いた画面のレコード一覧が空だったので、そこに新しく設定して「ネームサーバーも変更する」にチェックを入れていたら——

    変更前: example.com → A社のDNS(3件のレコードが登録済み)
    変更後: example.com → B社のDNS(レコードは空)

    ぶら下がるすべての名前が一斉に消えます。 「このレコードには触らない」と決めていたサブドメインも、1行も触らないまま巻き添えになります。

    覚えておくとよい原則: 「レコードを触らなければ安全」は成り立ちません。上位を切り替えれば下位は丸ごと入れ替わります。そして「あるはずのものが無い」は、たいてい自分が別の場所を見ている合図です。

    9. ルートドメインにCNAMEが置けない理由

    DNSのレコードには種類があります。よく使うのは次の2つです。

    Aレコード    … この名前は、このIPアドレスです
    CNAMEレコード … この名前は、別の名前の別名です

    Vercelのようなサービスは、たいていCNAMEを推奨します。接続先のIPが変わっても自動で追従できるからです。Aレコードで直接IPを書くと、相手のインフラ変更のたびに手で直す必要が出ます。

    ところがルートドメイン(www などが付かない、ドメインそのもの)にはCNAMEを置けません。理由はこうです。

    CNAMEは「この名前は別名です」という宣言なので、そのホスト名に他のレコードを共存させられません。「別名だ」と言っているのに他の情報も持っていたら矛盾するからです。

    ところがルートドメインには、DNSの仕組み上どうしても必要なレコードがあります。

    NS  … このドメインを管理するネームサーバー(必須)
    SOA … ゾーンの管理情報(必須)
    MX  … メールの宛先(メールを使うなら必要)

    これらと共存できないため、置けないわけです。だからVercelはルートにはAレコード、www にはCNAMEを指定してきます。

    「ルートを www に転送して、www を本体にする」という構成がよく使われるのも、この事情と関係があります。実体を柔軟に追従できる側に置いておく、という設計です。

    10. 「反映されない」の9割はキャッシュ

    DNSを変更した直後に確認すると、まだ古い値が返ってきます。ここで「保存できていないのでは」と設定を触り直すと、事態が混乱します。

    DNSの答えにはTTL(Time To Live)という有効期限が付いています。世界中のDNSサーバーは、一度取得した答えをこの秒数のあいだ保存(キャッシュ)し、その間は問い合わせ直しません。毎回問い合わせていたら、世界中のDNSが処理しきれないからです。

    つまり変更直後に古い値が返るのは正常です。問題は「保存されていない」のか「キャッシュを見ている」のかをどう区別するかでした。

    役に立つのがTTLの残り秒数です。

    example.com  A 1.1.1.1  ttl600   ← 取得したばかりのキャッシュ
    example.com  A 1.1.1.1  ttl218   ← 382秒前に取得された答えを見ている
    example.com  A 2.2.2.2  ttl600   ← 期限切れ後に取り直した結果

    TTLはキャッシュの残り寿命なので、600から218に減っていれば「これは382秒前の答えだ」と分かります。いま見ている情報がいつ時点のものかを読み取れるわけです。

    さらに、パソコンとブラウザも独自にキャッシュを持っています。「サーバー側は切り替わっているのに自分のブラウザだけ古い」という状態はよく起こります。シークレットウィンドウで開くか、スマホの回線から見ると切り分けられます。

    11. 原因が分からないときに何をするか

    最後に、いちばん学びが大きかった進め方の話です。

    前回、原因を2回外しました。「環境変数の設定が悪いのでは」「ファイアウォールが遮断しているのでは」——どれも筋は通っていますが、確かめずに設定をいじり、結果が変わらず、当たったのか外れたのかも判然としない、という時間を重ねました。

    打開したのは、原因を推測するのをやめて、エラーを表示させるためだけのコードを書いたことでした。書くのに10分もかかっていません。そして一発で答えが出ました。

    この経験から言えることが2つあります。

    ひとつは、推測で1回試すコストと、確実に答えを得るコストは、思っているほど差がないということ。「調べるのは面倒だから、とりあえず怪しいところを直してみよう」と考えがちですが、外したときの時間と、外したかどうかすら分からない不確かさを考えると割に合いません。

    もうひとつは、観測できない対象を推測で潰しても意味がないということ。値が見えない状態のまま「値が間違っているかも」と考えても、直したかどうかを確認する手段がありません。まずやるべきは見えるようにすることでした。

    この記事で扱った話は、実は同じ構造を持っています。

    cause を開ける              … 隠れているエラーを見えるようにする
    redirect: 'manual' で調べる … 隠れている転送を見えるようにする
    TTLの残り秒数を読む         … 情報の鮮度を見えるようにする
    診断用のコードを書く        … 内部の状態を見えるようにする

    デバッグとは、推理することではなく、観測できる範囲を広げていく作業なのだと思います。 見えていないものについて考えている時間が長いと感じたら、それは考えを進めるタイミングではなく、見る手段を用意するタイミングです。

    まとめ

    次に似た場面で思い出せるよう、要点だけ並べておきます。

    URLを調べるときは転送の自動追従を切ること。WordPressには homesiteurl の2つの基準があり、機能によってどちらを使うかが違うこと。fetch failedcause を開けること。HTTPSは暗号化の前に本人確認をしていて、その段階で切れると通信は1バイトも発生しないこと。検証は実際に通信する経路で行うこと。エラーを飲み込むなら記録を残すこと。NEXT_PUBLIC_ の値はビルド時に埋め込まれるので、変更後は再ビルドが要ること。DNSは2階層で、上位を切り替えると下位は丸ごと入れ替わること。ルートドメインにCNAMEは置けないこと。「反映されない」はたいていキャッシュで、TTLがいつ時点の情報かを教えてくれること。

    そして、原因が分からないときは推測を重ねるより、観測できる範囲を広げること。これがいちばんの学びでした。

  • なぜこの構成にしたのか — ヘッドレスWordPress + Next.js の技術選定

    なぜこの構成にしたのか — ヘッドレスWordPress + Next.js の技術選定

    ポートフォリオサイトを、素のHTMLで書かれた静的サイトから「ヘッドレスWordPress + Next.js」という構成に作り直しました。前回までの記事では、実際にやった作業と、そこでつまずいた内容を書いてきました。

    今回は少し引いた視点で、そもそもなぜこの構成にしたのかを書きます。技術選定の話です。

    結論から言うと、この構成は万人向けではありません。個人のポートフォリオという規模に対しては、明らかに過剰な部分があります。それでも選んだ理由と、その代わりに何を失ったのかまで含めて残しておきます。選定の記事は、良い点だけ並べても参考にならないと思うからです。

    まず全体像

    完成した構成はこうなっています。

    訪問者のブラウザ

    ↓  https://www.kisaku.site
    Vercel
    Next.js(App Router / TypeScript)
    ページの見た目を作って返す

    ↓  GraphQL(サーバー間の通信)
    お名前.com 共用サーバー
    https://cms.kisaku.site
    WordPress(管理画面 + データベース)
    WPGraphQL / ACF

    記事を書くのはWordPressの管理画面。表示するのはNext.js。両者はGraphQLというやり取りの決まりでつながっています。

    用語

    ヘッドレスとは「頭がない」という意味で、この場合の”頭”は表示部分を指します。WordPressから表示機能を切り離し、データを保管して受け渡すことに専念させる構成のことです。

    なぜヘッドレスにしたのか

    元のサイトは素のHTML・CSS・JavaScriptで書かれた静的サイトでした。表示は速く、構成もシンプル。悪くありません。

    ただ、実績を1件追加するのにHTMLを直接編集する必要がありました。文章の言い回しを直したいだけなのに、エディタでタグを探して、間違えないように書き換えて、アップロードし直す。これを何度か繰り返すうちに、更新のしやすさが欲しくなりました。

    ここで普通に考えると、選択肢は2つあります。

    内容 更新のしやすさ 表示の自由度
    A 普通にWordPressテーマを作る △(PHPのテーマの作法に従う)
    B ヘッドレス構成にする ◎(フロントは完全に自由)

    案Aで十分だったと思います。実際、更新のしやすさだけが目的ならそれが正解です。

    それでも案Bを選んだ理由は2つあります。

    ひとつは表示側を自由に作りたかったこと。WordPressのテーマを作る場合、PHPのテンプレート階層という作法に沿うことになります。慣れれば速いのですが、今回は「もとのサイトの見た目を保ちつつ、新しい書き方で作り直す」のが目的だったので、表示側は白紙から書きたかった。

    もうひとつが正直なところ大きいのですが、学習目的です。普段の実務ではヘッドレスCMSやGraphQL、Next.jsのApp Routerに触れる機会がありません。自分のポートフォリオという、壊れても誰も困らない題材で試してみたかった、というのが本音です。

    注意

    技術選定において「学習目的」は立派な理由になりますが、それが理由であることを自覚しておくのは大事だと思います。仕事で同じ判断をするなら、案Aのほうが妥当な場面は多いはずです。

    なぜWordPressを残したのか

    ヘッドレス構成にするなら、データを持つ側はWordPressである必要はありません。microCMSやContentfulのような最初からヘッドレス前提で作られたCMSもあります。

    それでもWordPressを選んだ理由は3つです。

    1. 管理画面の完成度

    記事を書く画面、画像をアップロードする画面、カテゴリを整理する画面——WordPressのそれらは20年かけて磨かれています。自分で書くつもりなら、この使い勝手は無視できません。

    2. ACFによる構造化

    今回のサイトには「実績」と「スキル」という、普通の記事とは形の違うデータがあります。

    実績: タイトル / 説明 / 役割 / 期間 / 使用技術 / カテゴリ / 画像
    スキル: 名前 / 習熟度(10段階) / 説明 / カテゴリ

    これを本文にベタ書きすると、あとで「使用技術だけ一覧したい」といった扱いができません。ACF(Advanced Custom Fields)というプラグインを使うと、こうした項目を管理画面に専用の入力欄として追加できます。

    入力する側から見ると「役割」「期間」という欄が並ぶだけですが、データとしてはきちんと項目に分かれているので、表示側で自由に組み替えられます。

    3. 自分が慣れている

    実務でWordPressのテーマ制作をしてきたので、管理画面の構造もカスタム投稿タイプの考え方も分かっています。新しいことを1つ試すときは、他をなるべく既知のもので固める——今回はフロントエンド側で新しいことをたくさんやるので、CMS側は慣れたもので押さえておきたかった、という判断です。

    補足

    「学習目的だから全部新しくする」は失敗しやすいやり方です。問題が起きたとき、原因の候補が多すぎて切り分けられなくなります。実際この作業でも、原因を2回外して数時間を溶かしました。既知の部分が多いほど、その切り分けは楽になります。

    なぜNext.jsなのか

    表示側にはNext.jsを選びました。ReactというUIライブラリをベースにしたフレームワークです。

    選定理由は、サーバー側でデータを取ってHTMLを組み立てられる点にあります。

    Reactだけで作ると、ブラウザ側でJavaScriptが動いてからデータを取りに行く形が基本になります。この場合こういう流れです。

    【ブラウザ側で取得する場合】
    1. ブラウザが空っぽのHTMLを受け取る
    2. JavaScriptを読み込んで実行する
    3. そこからAPIにデータを取りに行く
    4. 返ってきたデータで画面を描く
       → 4に到達するまで、画面には何も出ない
       → 検索エンジンにも中身が伝わりにくい

    Next.jsのサーバーコンポーネントを使うと、この流れが変わります。

    【サーバー側で取得する場合】
    1. サーバーがWordPressからデータを取る
    2. サーバーが中身の入ったHTMLを組み立てる
    3. ブラウザは完成したHTMLを受け取る
       → 最初から中身が見える
       → 検索エンジンにも内容が伝わる

    ポートフォリオは検索から見つけてもらうことに意味があるサイトなので、この違いは重要でした。

    ちなみにこの選択が、後の大トラブルの前提にもなっています。サーバーがサーバーを呼ぶという通信経路ができるため、ブラウザから確認しても分からない問題が起きうるのです。実際、SSL証明書の不一致でデータが一切取れないという事態に陥りました。詳細は前回の記事に書いています。

    なぜGraphQLなのか(RESTではなく)

    WordPressには標準でREST APIという仕組みが備わっています。追加のプラグインなしで使えます。それでもGraphQLを選びました。

    RESTの場合に起きること

    REST APIは「URLごとに決まった内容が返ってくる」方式です。実績一覧が欲しければ、こう叩きます。

    GET /wp-json/wp/v2/project

    返ってくるのは、その投稿タイプが持つほぼ全部の項目です。使うのがタイトルと画像とカテゴリだけでも、更新日時も編集履歴のIDもコメント設定も付いてきます。

    さらに困るのが、関連するデータが別のリクエストになることです。

    1. 実績一覧を取る          → GET /wp/v2/project
    2. 各実績のカテゴリ名を取る  → GET /wp/v2/project_category?include=...
    3. 各実績の画像URLを取る     → GET /wp/v2/media?include=...
       → 画面ひとつ描くのに3往復

    GraphQLの場合

    GraphQLは「欲しい項目を書いて送ると、その形で返ってくる」方式です。

    query GetProjects {
      projects(first: 100) {
        nodes {
          title
          uri
          featuredImage { node { sourceUrl altText } }
          projectCategories { nodes { name slug } }
          projectDetail { role period toolsused }
        }
      }
    }

    これで1回のリクエストです。画像もカテゴリもACFの項目も、まとめて必要なぶんだけ返ってきます。

    決め手は型の自動生成だった

    ただ、往復回数の削減だけならRESTでも工夫でどうにかなります。決め手になったのは別の点でした。

    GraphQLにはスキーマという「どんなデータがどんな形で存在するか」の定義があり、これを機械が読み取れます。この性質を利用したのが GraphQL Codegen というツールです。

    npm run codegen

    これを実行すると、書いたクエリを読み取ってTypeScriptの型定義を自動生成してくれます。結果として、エディタ上でこうなります。

    project.  ← ここまで打つと、使える項目が候補に出る
    project.titel  ← 打ち間違えると即座に警告が出る

    ACFで項目を追加してWordPress側の構造が変わっても、コマンド1つで型が追従します。手で型を書き直す必要がありません。

    選定の勘所

    GraphQLの利点として真っ先に挙げられるのは「必要な項目だけ取れる」ことですが、個人サイト規模ではその差は体感できません。実際に効いたのは型の自動生成でした。 よく語られる利点と、自分の状況で効く利点は違うことがあります。

    なぜVercelなのか

    Next.jsを公開する場所として、Vercelを選びました。Next.jsを作っている会社が運営しているサービスです。

    理由は3つあります。

    1. GitHubに置くだけで公開される

    GitHubにコードをpushすると、Vercelが自動で検知してビルドし、公開まで済ませます。手作業のアップロードが要りません。今回の作業でも、修正をpushするたびに自動で本番へ反映されました。

    2. 設定がほぼ不要

    Next.jsのプロジェクトだと自動で判別され、必要な設定はほぼありません。実際に指定したのは2つだけでした。

    Root Directory : frontend
    環境変数        : NEXT_PUBLIC_WORDPRESS_API_URL

    3. 個人利用なら無料

    ポートフォリオ程度のアクセス数なら無料枠に収まります。独自ドメインの接続もSSL証明書の発行・更新も無料で自動です。

    SSL証明書は通常90日で期限が切れ、更新が必要です。これを忘れるとサイト全体が「安全でない接続」と表示されて事実上使えなくなります。Vercelはこれを自動でやってくれるので、運用の手間がひとつ消えます。

    なぜCMSは共用サーバーのままなのか

    ここは技術選定というより、現実的な事情です。

    WordPressをVercelに置くことはできません。PHPとデータベースが必要だからです。置き場所は別に必要になります。

    選択肢はいくつかありましたが、もともとお名前.comの共用サーバーを契約していて、旧サイトがそこで動いていました。新しく契約すれば月額が増えます。既存の契約で足りるなら、それでいい。

    ただし、この判断には代償がありました。

    実際に起きたこと

    共用サーバーは設定の自由度が低く、次の問題に当たりました。SSL証明書がホスト名と一致せずVercelから接続できない、PHPの設定ファイルがディレクトリごとにしか効かない、WordPressが /wp/ という予期しない場所にインストールされる、クエリ文字列付きのアクセスがWAFに遮断される。安く済ませたぶん、原因調査に時間を使いました。

    とはいえ、これらは調べれば分かる範囲の問題でした。学習目的という文脈では、むしろ良い題材だったとも思っています。

    この構成で払った代償

    ここが、この記事でいちばん書きたかった部分です。

    1. 管理する場所が2つになった

    静的サイトなら、置き場所は1つでした。いまは2つです。それぞれにドメイン、SSL証明書、更新、バックアップの管理が発生します。

    【静的サイト】 1箇所
    【今の構成】   Vercel + 共用サーバー = 2箇所
                   + GitHub(コードの置き場所)= 実質3箇所

    2. CMSが落ちるとサイトが空になる

    これがいちばん大きい代償です。表示側と、データを持つ側が分かれているということは、データ側が落ちると表示側は空を返すということです。

    実際、SSL証明書の問題でWordPressに接続できなかったとき、サイトは正常に表示されるのに実績もスキルも記事も1件も出ない状態になりました。しかもHTTPステータスは200なので、普通の死活監視では検知できません。

    静的サイトなら、こんなことは起きません。HTMLに中身が書いてあるからです。

    3. 分業ゆえの複雑さ

    ヘッドレス構成では「サーバーがサーバーを呼ぶ」経路ができます。この経路はブラウザからは見えません

    従来:ブラウザ → WordPress
    今回:ブラウザ → Next.js(Vercel) → WordPress
    ※ 後半の「Next.js → WordPress」はサーバー同士の通信なので、ブラウザの開発者ツールからは確認できない。

    今回、ブラウザからは正常にアクセスできるのにサーバーからは失敗する、という状況で数時間はまりました。確認方法そのものを間違えていたわけです。この落とし穴は、構成を分けたことの直接の帰結です。

    4. ビルドという工程が増えた

    静的サイトはファイルを置けば公開できました。いまはビルドが必要で、そこが失敗すると公開できません。実際、最初のデプロイは自動生成ファイルの不足で失敗しています。

    静的サイト 今の構成
    更新のしやすさ ×(HTML直接編集) ◎(管理画面から)
    表示側の自由度
    管理する場所 1つ 3つ
    壊れにくさ △(CMSに依存)
    公開までの手数 ファイルを置くだけ ビルドが必要
    学べること 少ない 多い

    選ばなかった選択肢

    静的サイトジェネレータ + Markdown

    AstroやHugoなどで、記事をMarkdownファイルとして書く方式です。CMSが不要になるので、構成は圧倒的にシンプルになります。管理場所も減り、CMSが落ちる心配もありません。

    選ばなかったのは、記事を書く体験を落としたくなかったからです。画像の挿入、下書きの管理、公開日の設定——このあたりはWordPressの管理画面のほうが圧倒的に楽です。文章を書くこと自体のハードルは下げておきたい、と考えました。

    ヘッドセスCMS(microCMS / Contentful など)

    最初からヘッドレス前提のCMSです。サーバーの管理が不要になるのが大きな利点で、今回はまったSSL証明書やPHP設定の問題は最初から発生しません。

    選ばなかった理由は、既にWordPressの資産(記事・画像・構造)があったこと、そして自分が慣れていることです。ゼロから始めるなら、こちらのほうが素直だと思います。

    WordPressのテーマとして普通に作る

    冒頭に書いた案Aです。この規模では、いちばん妥当な選択だったと思います。管理場所は1つ、ビルド不要、CMSが落ちればサイトも落ちるという単純明快な構造。

    選ばなかったのは学習目的が大きいのですが、正直に言えば「新しい構成を試したかった」という動機が最初にあり、理屈は後から整理した部分もあります。

    この構成が向いている人

    一通りやってみて、こう思っています。

    向いている

    フロントエンドの表示を完全に自分で制御したい人。React/Next.jsを実践的に学びたい人。すでにWordPressの資産と運用の慣れがある人。サイトが一時的に壊れても困らない立場の人。

    向いていない

    とにかく早く公開したい人。運用に手をかけられない人。止まると業務に影響が出るサイトを作る人。管理場所が増えることを許容できない人。

    最後の点が重要だと思っています。今回の構成は部品が増えたぶん、壊れる箇所も増えました。実際、公開までにSSL証明書、DNS、環境変数、ビルド設定と、それぞれ別の理由で止まっています。

    そのすべてが学びになったのは、これが止まっても誰も困らない自分のサイトだったからです。同じ構成を仕事で採用するなら、この「壊れる箇所の多さ」を運用体制で引き受けられるかどうかが判断の分かれ目になります。

    技術選定に唯一の正解はありませんが、何を得て、代わりに何を失ったのかを説明できる状態にはしておきたい——今回それを整理してみて、そう感じました。