assetlinks.jsonを置いたのに、本番だけ404が続いた話 ─ Next.jsのstandaloneとcp -rの入れ子

当ページのリンクには広告が含まれています。
この記事の結論
ローカルで200を確認したファイルが、本番では404のままでした。原因は、Next.jsが用意したフォルダの上に cp -r でフォルダごと重ねたため、中身が1段深い場所へ入れ子になっていたことです。開発サーバーで動くことと、本番に配る成果物にファイルが入っていることは別物なので、成果物の中身をビルドの時点で検証するようにした話です。

お疲れ様です!IT業界で働くアライグマです!

個人開発している開発関連ニュースのキュレーションサービス「DevPick」のAndroidアプリで、メールのリンクからアプリを開けるようにする対応をしました。必要なのは、Webサイトに1つのJSONファイルを置くことだけです。ところが、置いてデプロイしても本番では404が返り続けました。今回は、その原因がデプロイ手順の cp -r 1行にあった経緯を書きます。

目次

メールのリンクがアプリで開かなかった

DevPickは、プロフィールの復旧やメールアドレスの確認にメールのリンクを使っています。たとえば復旧メールの再送で、先に届いたリンクが無効になる問題を直したときにも出てきた、あのリンクです。Androidアプリも用意しているので、このリンクをスマホで押したときには、ブラウザではなくアプリが開いてほしいところです。

Androidには、特定のURLを押したときに対応するアプリで開く仕組みがあります。Android App Linksと呼ばれるものです。ただし、どのアプリでも好きなURLを横取りできては困るので、「このドメインは、このアプリに開かせてよい」とサイト側が宣言する必要があります。その宣言が、Webサイトの決まった場所に置くJSONファイルです。

  • アプリ側:「https://devpick.it-araiguma.com/recover と /verify-email は自分が開く。起動時に検証してほしい(autoVerify: true)」と設定する
  • サイト側:https://devpick.it-araiguma.com/.well-known/assetlinks.json に、アプリのパッケージ名と署名鍵のフィンガープリントを書いて置く

サイト側に置くファイルは、単純化するとこういう形です。フィンガープリントはGoogle Playのアプリ署名鍵のもので、ここでは伏せています。

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.itaraiguma.devpick",
      "sha256_cert_fingerprints": ["XX:XX:...(アプリ署名鍵のSHA-256)"]
    }
  }
]

アプリ側の設定は以前から入っていました。ところが2026年9月28日に確かめると、サイト側のこのURLが本番で404を返していました。ファイルがそもそも置かれていなかったのです。Androidはこの検証に失敗すると、リンクをアプリに渡さずブラウザで開きます。つまり、アプリの中で復旧や確認を完了させる導線が、メールからは使えない状態でした。

IT女子 アラ美
アプリ側だけ設定しても、サイト側が許可しないと開かない仕組みなのね。

ITアライグマ
はい。アプリとサイトの両側がそろって初めて、リンクがアプリに渡されます。

publicに置いてローカルで200を確認した

まず、404を誰が返しているのかを確認しました。DevPickの本番は、Apacheの後ろでNext.jsのサーバーが動く構成です。Apacheにも /.well-known を振り分ける設定はありますが、対象はHTTPS証明書の更新に使う acme-challenge だけでした。つまり、この404はNext.jsが「そんなファイルは無い」と返していたことになります。

Next.jsでは、public フォルダに置いたファイルがそのままのパスで配信されます。そこで、最初の対応では次のことを行いました。

  • frontend/public/.well-known/assetlinks.json を追加する
  • このファイルの中身をテストで検査する(パッケージ名がアプリの設定と一致すること、フィンガープリントが大文字・コロン区切りであること、autoVerify の対象ホストがこのファイルを配信するホストだけであること)
  • ローカルの開発サーバー(next dev)で、URLの返り方を確かめる

ローカルでの確認は、単純化するとこういう内容です。

$ curl -sI http://localhost:3000/.well-known/assetlinks.json
HTTP/1.1 200 OK
Content-Type: application/json

200で返り、application/json で、リダイレクトもありません。中身もJSONとして正しく読めました。ファイルの内容はテストで守り、配信のされ方も手元で確かめたので、あとはデプロイすれば終わりだと考えていました。

IT女子 アラ美
中身もテストして、ローカルで200も見たなら、普通は安心するわ。

ITアライグマ
私もそう思っていました。ただ、見ていたのは開発サーバーでした。

デプロイしても404のままだった

この変更をマージし、いつものようにGitHub Actionsで本番へデプロイしました。ところが、本番のURLを叩くと404のままでした。ローカルでは200だったファイルが、本番にだけ無いわけです。

ここで効いてくるのが、本番で動いているのは開発サーバーではないという点です。DevPickのフロントエンドは、Next.jsのstandalone出力(output: "standalone")で本番に配っています。これは、next build のときに「サーバーを動かすのに必要なファイルだけ」を .next/standalone フォルダに集めてくれる機能です。node_modules を丸ごと持っていかなくても、このフォルダだけでサーバーを起動できます。

ただし、standaloneフォルダには静的ファイル(.next/static など)が自動では入りません。そのため、DevPickのワークフローでは、ビルドの後に必要なファイルを手でコピーしていました。public フォルダもその対象です。

# 修正前のワークフロー(抜粋)
npm run build
cp -r .next/static .next/standalone/.next/static
cp -r .next/BUILD_ID .next/standalone/.next/BUILD_ID
# (マニフェスト類のコピーは省略)
cp -r public .next/standalone/public

本番に届くのは、このstandaloneフォルダを固めたものです。そこで、デプロイしたコミットのビルド成果物(キャッシュしてあったtar)と、サーバー上に展開されたファイルを直接見に行きました。すると、assetlinks.json は確かに入っていました。ただし、置かれていた場所が1段深かったのです。

IT女子 アラ美
ファイルはあるのに場所がズレてるって、一番気づきにくいやつね。

ITアライグマ
はい。「無い」ではなく「別の場所にある」ので、探し方を変える必要がありました。

原因はnext buildが作るpublicとcp -rの入れ子

原因は2つの挙動の組み合わせでした。

1つ目は、next build の時点で .next/standalone/public がすでに作られていたことです。しかも、中には public のファイルがコピー済みでした。ただし、.well-known のようにドットで始まるディレクトリは入っていません。この記事を書くにあたって、手元でもう一度 next build(Next.js 16.3)をしてみましたが、やはり favicon.ico や app-ads.txt はあるのに、.well-known だけがありませんでした。

2つ目は、cp -r の挙動です。cp -r A B は、B が存在しなければ「A を B という名前でコピー」します。一方、B がすでにフォルダとして存在すると、「B の中に A をコピー」します。同じコマンドでも、コピー先があるかどうかで結果が変わるのです。

この2つが重なると、こうなります。

.next/standalone/public/
├── app-ads.txt            ← next build がコピー済み(配信される)
├── favicon.ico            ← next build がコピー済み(配信される)
└── public/                ← cp -r で入れ子になったフォルダ
    ├── app-ads.txt
    ├── favicon.ico
    └── .well-known/
        └── assetlinks.json   ← 配信されない場所にある

Next.jsが配信するのは standalone/public の直下です。.well-known は入れ子になった public/public/ の中にしか無いため、本番では404になっていました。これも手元で再現でき、cp -r public .next/standalone/public を実行すると、同じ入れ子ができました。

厄介なのは、cp -r が入れ子を作っていても、ほかのファイルは何の問題もなく配信されていたことです。ドットで始まらないファイルは、Next.js自身がすでに正しい場所へ置いていたからです。ワークフローの cp -r public は、実は何の役にも立っていなかったのに、私が気づかないまま動き続けていました。.well-known を置いて初めて、その1行が意図どおりに動いていないことが表に出たわけです。

IT女子 アラ美
今まで効いてなかった1行が、たまたまバレなかっただけってこと?

ITアライグマ
そうです。Next.jsが代わりに置いてくれていたので、壊れていても見えませんでした。

中身をコピーして、揃ったことを確かめてから配る

修正では、次の3つの変更を1回の修正ですべて同時に入れました。

  • public をフォルダごとではなく、中身をコピーする
  • コピーした後、public の全ファイルがstandalone側に揃っているかを確かめ、1つでも欠けていればビルドを失敗させる
  • これまでワークフローの2か所(フロントエンドのビルドジョブと、デプロイ時のフォールバックビルド)に同じ手順が重複していたので、1本のスクリプトにまとめる

スクリプトの要点は次のとおりです(実装から抜粋)。

mkdir -p "$standalone/public"
cp -a public/. "$standalone/public/"

missing=0
while IFS= read -r -d '' file; do
  rel="${file#public/}"
  if [[ ! -f "$standalone/public/$rel" ]]; then
    echo "エラー: standaloneに public/$rel が正しく配置されていません" >&2
    missing=1
  fi
done < <(find public -type f -print0)
exit "$missing"

cp -a public/. 先/ の末尾の /. は「フォルダ自体ではなく、その中身」という意味です。先に mkdir -p でコピー先を用意しておけば、Next.jsがすでに作っていても作っていなくても、中身は同じ場所に重ねてコピーされます。ドットで始まるファイルも含まれます。

もう1つ大事にしたのが、コピーの後の検証です。コピーのやり方を直しただけだと、将来また別の理由でファイルが欠けても、デプロイは成功してしまいます。今回の404も、ビルドもデプロイも成功した状態で起きていました。そこで、public にあるファイルを1つずつ見て、standalone側の同じ場所にあるかを確かめるようにしました。このチェックはビルドの段階で走ります。欠けていればビルドが失敗し、その成果物は本番に配られません。

テストも4件追加しました。Next.jsが standalone/public を先に作った状態を再現して、.well-known が入れ子にならず直下に置かれること。standalone出力が無ければ失敗すること。ファイルを置けないときに黙って成功しないこと。ワークフローの2か所がこのスクリプトを使っていること、です。

マージしてデプロイした後、本番の https://devpick.it-araiguma.com/.well-known/assetlinks.json が200・application/json・リダイレクトなしで返ることを確認しました。GoogleのDigital Asset Links APIでも、サイトとアプリの紐づけが "linked": true と返っています。最後に実機でも試し、メールのリンクを押すとブラウザではなくアプリが開くことを確認できました。

IT女子 アラ美
コピーを直すだけじゃなくて、欠けたらビルドで止まるようにしたのね。

ITアライグマ
はい。成功したのに本番に無い、という状態をもう作りたくありませんでした。

振り返り

今回の対応は、Google Playの審査で否承認されたときの対応と同じく、Androidアプリのリリース準備で残っていた作業の1つでした。ファイルを1つ置くだけのはずが、デプロイ手順の古い1行を掘り起こすことになりました。振り返ると、学びは3つあります。

1つ目は、開発サーバーでの確認は、本番の成果物の確認ではないということです。next dev は public フォルダを直接見て配信するので、コピーの手順は一切通りません。本番はstandaloneフォルダだけで動くので、そこに何が入っているかがすべてです。「ローカルで200だった」は、「ファイルの中身と置き場所が正しい」ことまでしか言えていませんでした。

2つ目は、cp -r の結果はコピー先の状態で変わるということです。同じ1行でも、コピー先のフォルダが無ければ意図どおりに動き、あれば入れ子になります。ビルドツールがコピー先を先に作るかどうかだけで、同じ1行の意味が変わってしまいます。フォルダの中身を重ねたいときは、cp -a src/. dst/ のように「中身を」と明示する書き方のほうが安全です。

3つ目は、意図どおりに動いていない手順は、たまたま表に出ないことがあるということです。今回は、Next.js自身がほとんどのファイルを正しく置いていたため、壊れた1行が表に出ませんでした。デプロイが成功したことは、必要なファイルが本番に届いたことを保証しません。だからこそ、「何が届くべきか」を成果物に対して直接確かめるチェックを、ビルドの段階に置いておく価値があると感じました。

IT女子 アラ美
動いてるように見える手順ほど、疑うきっかけが無いのが怖いわね。

ITアライグマ
本当にそうです。成功の表示ではなく、届いた中身で確かめるようにします。

まとめ

Android App Links用の assetlinks.json を public/.well-known に置き、ローカルで200を確認してデプロイしても、本番では404のままでした。原因は、next build が作ったstandaloneの public(ドットで始まるフォルダは入らない)の上に、ワークフローが cp -r public でフォルダごと重ねたため、中身が public/public/ へ入れ子になっていたことです。

修正では、public の中身をコピーするように変え、コピー後に全ファイルが揃っているかをビルドの段階で確かめ、欠けていればビルドを失敗させるようにしました。重複していた手順も1本のスクリプトにまとめています。デプロイ後は本番で200が返り、実機でもメールのリンクからアプリが開くようになりました。

今の私は、「ローカルで動いた」「デプロイが成功した」だけでは、本番に必要なファイルが届いたとは言えないと考えています。成果物の中身そのものを確かめる仕組みを1つ置いておくだけで、今回のような静かな欠落はビルドの時点で止められます。

IT女子 アラ美
たった cp -r の1行で、ここまで話が広がるとは思わなかったわ。

ITアライグマ
私もです。短い1行ほど、前提を確かめておく価値がありますね。

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次