コンテナの中の localhost は届かない ― Laravel 13 + Next.js 16 の Docker 開発環境で決めた 4 つのこと ― 架空サカスタのチケットサイトで学ぶ(2)
はじめに
架空のサッカースタジアム「ペヌソニックスタジアム(通称ペヌスタ)」のチケット販売サイトを、遊び場として作っています。
なぜ作っているのか、全体がどうなっているのかは紹介回に書きました。
ゴール裏年パス勢が、架空スタジアムのチケットサイトを作る理由 ― 架空サカスタのチケットサイトで学ぶ(0)
https://qiita.com/macchi23/items/5b9f5670c841f5c41be8
この記事では、Laravel 13(API)+ Next.js 16 + PostgreSQL 17 を Docker で動かしている開発環境の構成と、そこで決めた 4 つのことを書きます。
- ホストに PHP を入れない
- node_modules をコンテナ専用のボリュームに置く
- API の URL を 2 つ持っていた(今は 1 つ)
- 時刻は UTC で保存する(9 時間ずれた)
実はこの開発環境は、Claude Code に任せて組んでもらいました。
自分で決めたのは、バックエンドは Laravel、フロントエンドは Next.js、DB は PostgreSQL という大枠と、「拡張しやすく、いろいろな技術を試して学べる遊び場にする」という方向性だけで、細部は任せています。
そのため、なぜこの構成になっているのかを自分でも把握しきれていませんでした。
この回は、それを書き出して整理し、自分で理解するための回でもあります。
compose の構成
compose.yaml 1 本で、次のサービスを立てています。
| サービス | イメージ | 役割 | ホストのポート |
|---|---|---|---|
| frontend | node:24-alpine | Next.js の dev サーバー | 3100 |
| web | nginx:alpine | Laravel の入口。PHP は app に FastCGI で渡す | 8080 |
| app | 自前(php:8.4-fpm-alpine ベース) | Laravel(php-fpm) | なし |
| scheduler | app と同じ |
php artisan schedule:work(定期処理) |
なし |
| db | postgres:17-alpine | PostgreSQL | 5432 |
| redis | redis:7-alpine | Redis | 6379 |
ほかに S3 互換ストレージ(RustFS)と DuckDB がありますが、分析を扱う第 2 部で使うものなので、ここでは省きます。
メールの受け口(Mailpit)も立てていますが、今はメールを送る機能がありません。
ホストに公開するポートは、すべて 127.0.0.1:8080:80 のように 127.0.0.1 に限っています。
ポートを "8080:80" とだけ書くと、同じネットワークにいる他の端末からも届いてしまうためです。
リクエストの流れはこうなります。
ブラウザが話す相手は Next.js だけで、Laravel を呼ぶのは Next.js のサーバー側です。
これが判断 3 の話につながります。
判断 1: ホストに PHP を入れない
artisan も composer も、すべてコンテナの中で実行しています。
docker compose exec app php artisan migrate
docker compose exec app composer require ...
これはこのプロジェクトに限った話ではありません。
何かを作るときの開発環境は、AWS で完結するもの(フロントエンドなら Amplify や S3 + CloudFront、バックエンドなら CDK で組む Lambda など)を除いて、基本的に全部 Docker で組んでいます。
このリポジトリでも、AWS の構成を CDK で書いている infra/ だけはホストで動かしています。
- いろいろなものを作っていると、ホストに入れた言語やミドルウェアのバージョンは必ず競合する
- ホストでバージョンを管理するのが面倒
- 同じプロジェクトの中でも、バージョンを上げ下げして試したいときがある
コンテナなら、プロジェクトごとにバージョンを持てて、変えるのも Dockerfile の 1 行で済みます。
ちなみに手元の Mac には昔入れた MAMP がありますが、PHP は 7.4 と古く、管理のしかたも忘れていました(動かしてみたら壊れていて、composer も入っていませんでした)。
PHP のイメージはこれだけです。
FROM php:8.4-fpm-alpine
RUN apk add --no-cache postgresql-dev linux-headers $PHPIZE_DEPS \
&& docker-php-ext-install pdo_pgsql opcache pcntl \
&& apk del $PHPIZE_DEPS
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
- 公式の PHP イメージには mbstring や curl などが最初から入っているので、足すのは PostgreSQL のドライバ(pdo_pgsql)、opcache、pcntl の 3 つだけ
- 拡張のビルドに使うパッケージ(
$PHPIZE_DEPS)は、入れた直後に消す - composer は公式の composer イメージからバイナリだけをコピーする(composer イメージのドキュメントでも勧められている書き方)
結果として、PHP の版と拡張がイメージで固定されます。
ホストの PHP の状態に左右されず、壊れたら作り直せばいいだけになりました。
app と scheduler で同じイメージを使う
app と scheduler は、同じ Dockerfile から同じイメージ名(penusta-ticket-php)で作っています。
app:
build: ./docker/php
# scheduler と同じイメージを使う(別名だとビルド時期の違いで PHP のパッチバージョンがずれる)
image: penusta-ticket-php
scheduler を後から足したとき、最初は別のイメージ名にしていました。
すると scheduler だけが新しくビルドされて PHP 8.4.26 になり、8 月にビルドした app は 8.4.24 のまま、という状態になりました(コミット前のレビューで気づきました)。
ベースイメージのタグ php:8.4-fpm-alpine は、パッチ版が出るたびに中身が変わるためです。
困ること
- コマンドの頭に毎回
docker compose exec appが付く - ホストで PHP を動かす道具(静的解析やデバッガ)を使うなら、コンテナの PHP を指す設定が要る
一方で、backend/ はバインドマウントしているので、コンテナで composer install した vendor/ はホストにも見えます。
エディタの補完やコードジャンプは、そのまま効きます。
判断 2: node_modules をコンテナ専用のボリュームに置く
frontend は、ソースをバインドマウントしたうえで、node_modules だけを名前付きボリュームに置いています。
frontend:
image: node:24-alpine
working_dir: /app
command: sh -c "npm install && npm run dev"
# ports は省略
volumes:
- ./frontend:/app
- frontend-node-modules:/app/node_modules
/app にホストの frontend/ を見せたうえで、/app/node_modules の位置にだけボリュームを重ねています。
ボリュームをマウントすると、その位置にあった中身(ここではホストの frontend/node_modules)は覆われて、コンテナからは見えなくなります。
理由: ホストとコンテナでは入るバイナリが違う
Next.js や Tailwind CSS v4 は、中身の一部が OS と CPU ごとのネイティブバイナリです。
npm は、各パッケージが宣言している os / cpu を見て、今の環境に合うものだけを入れます。
実際に、ホストとコンテナの node_modules を比べるとこうなっていました。
| パッケージ | ホスト(macOS、Apple Silicon) | コンテナ(Alpine Linux、arm64) |
|---|---|---|
| Next.js のコンパイラ(SWC) | @next/swc-darwin-arm64 |
@next/swc-linux-arm64-gnu / -musl
|
| lightningcss(Tailwind v4 が使う CSS 処理) | lightningcss-darwin-arm64 |
lightningcss-linux-arm64-gnu / -musl
|
| Tailwind v4 の oxide | @tailwindcss/oxide-darwin-arm64 |
@tailwindcss/oxide-linux-arm64-gnu / -musl
|
ほかにも、画像処理の sharp や、ESLint が使う部品も同じように OS ごとに分かれています。
ボリュームを分けないと、ホストとコンテナが 1 つの node_modules を取り合うことになります。
片方で npm install すると、もう片方用のバイナリが揃っていない状態になりえます。
ボリュームを分けておけば、ホストとコンテナがそれぞれ自分用のバイナリを持てます。
副作用
- ホストにも
node_modulesが要る。エディタの型補完は、ホスト側のnode_modulesを見るため。ホストでnpm ciしても、コンテナ側には影響しない - パッケージの追加はコンテナで
docker compose exec frontend npm install <pkg>。package.jsonと lock ファイルはバインドマウントなので、ホストにも反映される -
git pullなどでpackage.jsonが変わったら、docker compose restart frontend。起動コマンドのnpm installが流れて、ボリュームが追いつく - ボリュームは
docker compose downでは消えない。作り直すときはdocker volume rm penusta-ticket_frontend-node-modules
ボリュームの大きさは 523MB(docker system df -v)です。
起動のたびに npm install が流れますが、ボリュームが空の初回は 9 秒(365 パッケージ)、中身が残っている 2 回目以降は 4 秒ほどで終わります。Next.js はその後 100〜200ms ほどで Ready になります。
判断 3: API の URL を 2 つ持っていた(今は 1 つ)
コンテナの中の localhost は自分自身
ブラウザから見ると、Laravel は http://localhost:8080 にあります。
ところが同じ URL を Next.js のサーバー側(Server Components や Server Actions)で使うと、届きません。
Next.js のサーバーは frontend コンテナの中で動いています。
コンテナの中の localhost は frontend コンテナ自身なので、8080 番には誰もいません。
compose では、同じプロジェクトのサービス同士はサービス名で名前解決できます。
そこでサーバー側からは http://web(nginx のサービス名)を使います。
ポートは、ホスト用の 8080 ではなく、コンテナ側の 80 です。
environment:
API_URL_INTERNAL: http://web
最初はブラウザ用の URL もあった
作り始めの頃は、座席図のコンポーネント(Client Component)がブラウザから直接 Laravel を呼んでいました。
ブラウザはホストで動くので、こちらは http://localhost:8080 でないと届きません。
そのため、URL を 2 つ持って、サーバーかブラウザかで出し分けていました。
export function apiBase(): string {
if (typeof window === "undefined") {
return (
process.env.API_URL_INTERNAL ??
process.env.NEXT_PUBLIC_API_URL ??
"http://localhost:8080"
);
}
return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
}
ブラウザ用の変数に NEXT_PUBLIC_ が付いているのは、Next.js の決まりです。
この接頭辞が付いた環境変数だけが、ビルド時に値ごと JavaScript に埋め込まれ、ブラウザでも読めます。
付いていない変数はサーバーでしか読めず、ブラウザ側では undefined になります。
今はサーバーからしか呼ばないので 1 つ
その後、カートや購入の処理を作る中で、Laravel を呼ぶのはすべてサーバー側(Server Components と Server Actions)に寄せました。
ブラウザが Laravel を直接呼ぶ箇所がなくなったので、ブラウザ用の URL は使われないまま残っていました。
この記事を書くために見直して気づいたので、消しました。
// サーバー専用: cookie を読んで Laravel を呼ぶ。Client Component から import しないこと。
// ...(import など)
function apiBase(): string {
return process.env.API_URL_INTERNAL ?? "http://localhost:8080";
}
URL を決める関数は、Client Component からも読まれる api.ts から、サーバー専用の server-api.ts に移しました。
フォールバックの http://localhost:8080 は、Next.js をコンテナではなくホストで動かすとき用です。
ブラウザからも API を呼ぶ構成なら、URL は 2 つ要ります。
サーバーからだけ呼ぶなら、コンテナ内の 1 つで済みます。
細部を任せた環境を書き出して理解しようとしたら、要らなくなっていたものが見つかりました。
この回を書いた目的が、さっそく効いた形です。
判断 4: 時刻は UTC で保存する
時刻は UTC で保存し、日本時間にするのは表示と日付の判定のときだけ、と決めています。
きっかけは、日本時間のつもりで入れた時刻が 9 時間ずれたことです。
何が起きるか
コンテナの中で再現してみます。
日本時間の 19:00 を Carbon で作り、そのまま PostgreSQL に渡します。
$jst = Carbon::parse('2026-03-01 19:00', 'Asia/Tokyo');
// 2026-03-01T19:00:00+09:00
DB::selectOne('select ?::timestamptz as t', [$jst]);
// t = 2026-03-01 19:00:00+00 (日本時間では 3/2 の 04:00)
19:00 JST のつもりが、19:00 UTC(日本時間の翌朝 4 時)として解釈されました。
モデルに代入して保存しても同じです($match->kickoff_at = $jst は 2026-03-01 19:00:00 として渡ります)。
原因は 2 段です。
- Laravel は、日時を DB に渡すときに
Y-m-d H:i:sの文字列に整形する。このときオフセット(+09:00)が落ちて、2026-03-01 19:00:00だけになる - PostgreSQL は、オフセットのない文字列を
timestamptzに入れるとき、接続のタイムゾーン(ここでは UTC)の時刻として解釈する
->utc() で UTC に直してから渡せば、10:00 UTC(日本時間で 19:00)として正しく入ります。
今の決まり
- アプリのタイムゾーンは UTC(
config/app.phpの既定のまま)。DB の接続も UTC に固定する(config/database.php) - 日本時間で組み立てた時刻(試合の日程など)は、保存の前に UTC に変換する
- 販売に関わる時刻(キックオフ、注文、仮押さえの期限など)のカラムは
timestamptz - 日本時間にするのは表示と日付の判定のときだけ。API のレスポンスを作るところ(
setTimezone('Asia/Tokyo'))、フロントの表示(toLocaleStringなどにtimeZone: "Asia/Tokyo")、会員資格の「今日」の判定
注意が 2 つあります。
- コンテナのタイムゾーンは、app も db も frontend もすべて UTC。フロントのサーバー側で
timeZoneを省くと、日本時間にならない -
created_at/updated_atは Laravel のtimestamps()のままなので、timestamptzではなくtimestamp(タイムゾーンなし)。中身は UTC だが、型は混ざっている
時刻を正しく持っておくことは、イベントログを日付ごとに分けて分析する第 2 部で効いてきます。UTC と日本時間で日付の区切りがずれる話は、そこで改めて書きます。
補足: ほかに踏んだこと
デモデータの生成で zend_mm_heap corrupted
合成のデモデータ(7 シーズン分の注文やチケット)を作るコマンドが、大量の insert の途中で zend_mm_heap corrupted で落ちることがありました。
docker compose exec -e USE_ZEND_ALLOC=0 app ... のように、PHP 独自のメモリ管理を止めて C ライブラリの malloc を使わせると安定します。
原因は特定できておらず(Alpine の musl との関係も裏付けは見つからなかった)、回避策として使っています。
dev のオーバーレイに negative time stamp
notFound() や redirect() を使うページで、開発中だけ Failed to execute 'measure' on 'Performance': ... cannot have a negative time stamp がオーバーレイに出ることがあります。
React の開発時だけの性能計測の既知のバグで(facebook/react#37561)、本番には影響しません。
この環境では、Docker の VM が止まっていた間に Next.js のプロセスの時計が進まなかった(起動から 25 時間 37 分で、プロセスの経過時間は 11 時間 20 分)のが引き金のようです。VM を止めたのは Mac のスリープだと推測しています。
docker compose restart frontend で消えます。
app コンテナが固まって止められなくなった(1 回だけ)
テストの実行中に、app コンテナの中の php artisan が返ってこなくなり、kill -9 でも消えなくなったことがあります。
その後は docker compose exec が error executing setns process、再起動が did not receive an exit event で失敗しました。
Docker Desktop を再起動して docker compose up -d で戻りました。app は状態を持たず、DB のデータはボリュームに残るので、作り直しても失うものはありません。
原因はわかっていません(バインドマウントのファイルを読む途中で止まったように見えたが、推測)。
まとめ
- ホストに PHP を入れず、PHP の版と拡張を Docker イメージで固定した。app と scheduler は同じイメージを使う
- node_modules はコンテナ専用のボリュームに置いた。ホストとコンテナでネイティブバイナリが違うため
- コンテナの中の localhost は自分自身なので、サーバー側からは compose のサービス名で API を呼ぶ。ブラウザから呼ばなくなったので、URL は 1 つにした
- 時刻は UTC で保存し、日本時間にするのは表示と日付の判定のときだけ。Laravel は日時を DB に渡すときにオフセットを落とす
手元で動かす手順は、リポジトリの README の「起動」にあります(clone した直後の状態から通ることを確かめています)。
次回は、座席図と購入フロー(仮押さえの期限、二重販売を防ぐユニーク制約、決済のモック)です。
シリーズの他の回:
この記事の骨格は Claude Code に作らせ、自分の言葉で書き直しています。記事中のコードと数値は、手元のリポジトリで確かめています。