Technote

by sizuhiko

LocalStack Pro が Apple Silicon で起動しない(SIGILL / exit code 252)原因と解決方法

LocalStack の今後の方向性:AWS クラウド エミュレーターの提供方法の変更に関するお知らせ というニュースが出て、 今後は有償化になるよというので、他のエミュレーターに切り替えた人も多いでしょう。

一方で、v4 のコミュニティエディションを固定で使い続けて、どこかで(稟議通ったりしたら) LocalStack Pro に切り替えるという選択をした開発チームもあるでしょう。

僕らは後者を選択しました。そして遂にライセンスを手に入れたので、pro で起動してみたら以下のようなエラーになったので、それを解決したという記事です。

localstack-main   | 
localstack-main   | LocalStack version: 2026.8.0
localstack-main   | LocalStack build date: 2026-08-26
localstack-main   | LocalStack build git hash: ad728e1ae
localstack-main   | 
localstack-main   | 2026-08-27T02:47:53.265  INFO --- [  MainThread] l.licensing.licensingv2    : Successfully activated cached license XXXXXX :base from /var/lib/localstack/cache/license.json 🔑✅
localstack-main   | LocalStack supervisor: localstack process (PID 17) returned with exit code -4
localstack-main   | LocalStack supervisor: exiting
localstack-main exited with code 252

大事なことは最初に

この記事は長文になるので、読んでも無駄にならないように、対象者を限定しておきます。

  • まず docker で localstack/localstack-pro:stable を起動しようとしたら、ライセンスチェックは通過するけど、その後で終了してしまう人
  • Docker のゲストカーネルが古い人

後者で、僕は colima を使っているのですが確認方法は以下のとおりです

$ colima ssh -- uname -r
6.8.0-39-generic

ここでゲストカーネルが 7.x 系でない場合の人が主たる対象です。

もし Docker Desktop を使っている場合は、バージョンが 4.71.0 より前の人が対象です。

解決策

  • Docker Desktop では 4.38.0 で発生し、4.71.0 で解消したという報告があります(その間のどのバージョンで直ったかは不明)。ゲストカーネルのバージョンで判断するのが確実です。
  • Colima を使っている場合は、ゲストカーネルを更新してください(この記事の最後の方に手順があります)
  • それ以外の Docker 環境を使っている方: おそらくゲストカーネルの更新が必要です。やり方は生成AIに聞いてみてください

さて、このあとは、どのようにこの解決策に至ったかの物語であります。

LocalStack Pro に切り替えてみる

さてライセンスにワクワクしながら docker compose の設定を以下のように書き換えます

  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
    image: localstack/localstack-pro:stable # ここと
    ports:
      - "4566:4566"
      - "4510-4559:4510-4559"
    environment:
      - DEBUG=1
      - LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT=180
      - AWS_DEFAULT_REGION=ap-northeast-1
      - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:?} # ここと
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack" # ここ
      - "/var/run/docker.sock:/var/run/docker.sock"

3箇所を変更しました。

これで docker compose up して出たエラーは最初のとおりです。

Claude と原因調査してみる

まずググってみたのですが、あまりに該当する情報が見つからなかったので、生成AIと対話してみます。

アーキテクチャの不一致を疑われる

まず exit code -4 が肝です。これは終了コードではなく シグナル4 = SIGILL(不正命令)でプロセスが強制終了されたことを意味します(コンテナ全体の 252 = 256-4)。

ログを見ると Successfully activated cached license … 🔑✅ まで通っているので、AUTH_TOKEN やライセンス自体は正常です。その直後に Pro 版だけが持つネイティブ/コンパイル済みバイナリをロードした瞬間に落ちている、という形になります。

SIGILL の原因はほぼ CPU 命令セットの不一致で、実務上いちばん多いのは Apple Silicon 上で amd64 イメージを Rosetta / QEMU エミュレーションで動かしているケースです。Free(community)版は純 Python 中心なのでエミュレーションでも動きますが、Pro 版は落ちます。「Free では動いていたのに Pro にしたら落ちた」という状況と非常によく合致します。

Free(community)版は純 Python 中心 に関しては結果として根拠のない推測でした。後にこれは外れだと分かります

という初回の回答でしたので、確認用のコマンドを実行します。

$ uname -m
arm64
$ docker image inspect localstack/localstack-pro:stable --format '{{.Architecture}}'
arm64
$ echo $DOCKER_DEFAULT_PLATFORM

アーキテクチャは一致しているので アーキテクチャ説 は消えました。

Docker 自体が疑われる

その後、いろいろ細かい確認があったのですが、お前の Docker 環境教えろみたいな感じになってきました。 確認用のコマンドを実行します。

$ docker version | grep -i -A2 'server'
Server: Docker Engine - Community
 Engine:
  Version:          27.1.1
$ docker context ls
NAME       DESCRIPTION                               DOCKER ENDPOINT                                     ERROR
colima *   colima                                    unix:///Users/sizuhiko/.colima/default/docker.sock
default    Current DOCKER_HOST based configuration   unix:///var/run/docker.sock

僕は colima 使っているので、こんな感じの出力になります。

その VM 層こそが SIGILL の最有力候補だからです。

特に Colima のデフォルト(–vm-type=qemu)は要注意です。QEMU は Apple Silicon 上でも汎用的な CPU モデルをゲストに見せるため、ホストが持っている新しい ARM 命令が VM 内から見えなくなります。その命令を使ってビルドされたバイナリを実行すると、まさに SIGILL で落ちます。

Free 版(純 Python)→ 影響なし Pro 版(ネイティブ拡張)→ SIGILL

という今回の症状と完全に一致します。バージョンを跨いで再現したことも、ライセンス認証の直後に落ちることも、これで説明がつきます。

Free 版(純 Python) に関しては結果として根拠のない推測でした。後にこれは外れだと分かります

そして colima のステータスを確認するように聞かれたので実行します。qemu は使っておらず最初から vz でしたので、以下のようになります。

$ colima status
INFO[0000] colima is running using macOS Virtualization.Framework
INFO[0000] arch: aarch64
INFO[0000] runtime: docker
INFO[0000] mountType: virtiofs
INFO[0000] docker socket: unix:///Users/sizuhiko/.colima/default/docker.sock
INFO[0000] containerd socket: unix:///Users/sizuhiko/.colima/default/containerd.sock
$ grep -i vmType ~/.colima/default/colima.yaml
vmType: vz
# Utilise rosetta for amd64 emulation (requires m1 mac and vmType `vz`)
# Enable nested virtualization for the virtual machine (requires m3 mac and vmType `vz`)
# virtiofs is limited to macOS and vmType `vz`. It is the fastest of the options.
# 9p is the recommended and the most stable option for vmType `qemu`.
# The CPU type for the virtual machine (requires vmType `qemu`).

環境側の仮説は出尽くしました

既に vz、virtiofs、aarch64 ネイティブ。私が挙げてきた VM 層の候補は全部クリアされています。Rosetta も無関係(イメージが arm64 なので経路に入らない)。

ここまでで潰せたことを整理すると:

ライセンス・認証 → 正常(オンライン認証成功後に落ちている) アーキテクチャ不一致・エミュレーション → なし イメージのバージョン → 2 系列で再現 volume の残骸 → クリーンでも再現 VM の CPU エミュレーション → vz でホスト CPU がそのまま見えている

LocalStack Pro の arm64 ビルドが、この CPU で実行できない命令を含んでいるという線がほぼ残るだけです。ユーザー側の設定で直せる範囲は超えています。

なかなかにハードな状況のようです….

Claude は諦めました…

その後、少しやりとりがあって、以下のような状況に

見通し

正直に言えば、ここまでの切り分けでユーザー側で直せる可能性はかなり低いと思っています。私が挙げた環境側の仮説(アーキテクチャ、エミュレーション、VM の CPU、バージョン、キャッシュ)は全部潰れました。何度も外した推測にお付き合いいただいて申し訳ないです。

上記2つの出力が取れたら、それを添えて Pro サポートに上げるのが確実です。報告内容としては、

ライセンス認証成功の直後に SIGILL(supervisor が exit code -4 を報告) 2026.8.0 / 2026.7.6 の両方で再現、どちらも build date 2026-08-26 Apple M4 / arm64 ネイティブ / Rosetta 等のエミュレーションなし Colima + vz + virtiofs、Docker Engine 27.1.1、Docker Desktop 不使用 volume 削除済みのクリーン状態でも再現 LS_LOG=trace の末尾と、debug.exception-trace=1 有効化後の dmesg

で、サポートに上げるために、いろんなコマンドを実行していたところ、以下のような方向性に

結論が出ました

Illegal instruction (core dumped)、その前に Python の例外も panic メッセージも一切なし。

これで確定です。ライセンス認証の直後、Pro 専用のネイティブモジュールをロード/実行した瞬間に、CPU レベルの不正命令で死んでいます。 Rust panic 説は消えました(panic なら必ずメッセージが出ます)。LocalStack Pro は認証後に暗号化されたコンパイル済み拡張モジュールを復号してロードする作りなので、落ちる位置として完全に整合します。

Free 版で動いていたのは、そのモジュールが存在しなかったからです。

残っている唯一の実用的な回避策

まだ試していない QEMU での起動です。仮説はこうです。

M4 が持つ SVE2/SME 系の命令を、Pro のバイナリが「CPU が対応している」と判定して選択する。しかし vz + ゲストカーネルの組み合わせでは実際には実行できず SIGILL。QEMU なら汎用 CPU モデルなのでその機能ビットが見えず、安全なフォールバックパスが選ばれる可能性があります。

いやいや QEMU なんかにしたくないのよ…

Pro は認証後に暗号化されたコンパイル済み拡張モジュールを復号してロードする に関しては結果として根拠のない推測でした。後にこれは外れだと分かります

LocalStack のチャットで聞いてみる

また、いろんな確認を経て、Claude にサポートに投げる文面の素案も作ってもらって、送信しました。 エンジニアリングチームと繋いでもらって、週明けには回答するよ!みたいな返事ももらえました(金曜日だったからな…

僕は諦めが悪い

なんか v4(コミュニティ版が存在していたときのバージョン)では pro のイメージも動いて、それ以降で動かなくなるのはそれなりの理由があるんじゃないか?と思って、まずは LocalStack のリリースノートを古い順に読んでいきます。

そうすると、以下の記事で 2026-05 のバージョンで Debian のイメージが変更になっていると書いてありました。

https://blog.localstack.cloud/localstack-for-aws-release-2026-05-0/#debian-sid-base-image

何かヒントになるのでは?と思って、Claude に続けて聞いてみました。

いい発見です。ただ時系列が1つ合いません

Debian sid ベースへの移行は、有力な原因候補です。sid は glibc もツールチェーンも最先端なので、Pro のネイティブモジュールが新しいコンパイラでビルドされ、ARM のベースラインが上がったという筋は非常にありそうです。ライセンス認証の直後、つまり Pro のネイティブ拡張をロードした瞬間だけ落ちるという症状とも噛み合います(glibc 自体の問題ならもっと早い段階で死ぬはずなので、ビルド時のターゲット設定の方が説明として自然です)。

ただ、2026.04.0 は 2026.05.0 より前です。sid 移行が 2026.05.0 で入ったなら、4月ビルドの 2026.04.0 は影響を受けていないはずなのに落ちている。ここが噛み合いません。

考えられるのは2つです。

2026.04.0 の失敗は別の原因だった ベースイメージの変更が、アナウンスより前から段階的に入っていた

着眼点は褒めてもらえました。

2026.04.0 のエラーを再検証する

compose.yml の localstack イメージのタグを 2026.04.0 に変更して、コマンドを実行します。

$ docker compose logs localstack | tail -20
localstack-main  | LocalStack supervisor: starting
localstack-main  | LocalStack supervisor: localstack process (PID 17) starting
localstack-main  | LocalStack supervisor: localstack process (PID 17) returned with exit code -4
localstack-main  | LocalStack supervisor: exiting

エラーはそのままですが、落ち方が変わっていることに Claude が気づきました

落ちる位置が違います

これは前の2つとは別の挙動です。2026.8.0 / 2026.7.6 では

LocalStack version: … ← バナーが出る Successfully activated … ← ライセンス認証が通る exit code -4

だったのに、2026.04.0 はバナーも出ずに即死しています。ライセンス認証にすら到達していません。同じ SIGILL でも、より手前で死んでいます。

つまり「2026 系は一律ダメ」は結果として正しいのですが、中身は同一の不具合ではない可能性があります。 2026.04.0 の方は Python 起動直後のネイティブ読み込み段階で落ちている感じです。

Python 単体で落ちるか を疑う

2026.04.0 はライセンス認証より手前で死んでいるので、インタプリタや glibc の段階まで遡れる可能性があります。

ということで、確認のコマンドを実行します。

$ docker run --rm --entrypoint python3 localstack/localstack-pro:2026.04.0 -c "print('ok')"
ok

glibc とインタプリタは無罪

ok が返ったので、Python の起動と glibc の基本ルーチンは正常に動いています。となると SIGILL は、その後にロードされる特定のネイティブ拡張モジュールの中で起きています。sid の新しいツールチェーンでビルドされた .so が怪しい、という筋は残りますが、glibc 自体が原因ではありません。

Python の faulthandler で犯人を特定できます

これを先に提案すべきでした。Python の faulthandler は SIGILL を捕まえて、死んだ瞬間の Python スタックトレースを出してくれます。 どのモジュールのどの行で落ちたかが分かるので、ここまでで一番情報量のある手です。

faulthandler で調べる

faulthandler のトレースを見せてください。それで犯人が名前で分かります。

ということでコマンドを実行します。実行結果は長いので、ここでは省略します。

$ docker run --rm -it \
  -e LOCALSTACK_AUTH_TOKEN=$LOCALSTACK_AUTH_TOKEN \
  -e PYTHONFAULTHANDLER=1 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  --entrypoint bash \
  localstack/localstack-pro:2026.04.0

犯人が判明しました。LocalStack のコードではありません

なんとエラーの原因は LocalStack ではありませんでした(まぁ依存関係ではあるけど)。

トレースの最深部を見てください。

File “…/cryptography/x509/certificate_transparency.py”, line 8 in → create_module ← ここでネイティブモジュールをロード中に死亡 Extension modules: markupsafe._speedups, _awscrt (total: 2)

certificate_transparency.py の 8 行目は from cryptography.hazmat.bindings._rust import … です。つまり落ちているのは cryptography パッケージの Rust/OpenSSL ネイティブ拡張(_rust.abi3.so)のロード時。markupsafe._speedups と _awscrt は無事に読めているので、その 2 つは無罪です。

これは大きな前進です。

LocalStack 独自の Pro バイナリではなく、サードパーティ製の依存パッケージが原因 Pro 版だけ落ちるのは、認証経路で cryptography.x509 を通るから。Free 版はこの import 経路に入らないので生き延びていた 私が延々と追っていた「Pro のネイティブモジュール」説は、ほぼ外れです

そして cryptography の wheel は OpenSSL を同梱しています。sid ベースへの移行で cryptography のバージョンが上がり、同梱 OpenSSL の aarch64 アセンブリが実行できない命令を含んでいる、というのが最も自然な説明です。

ここで、v4のモジュールと、2026系のモジュールで cryptography のバージョンが違うのかを確認します。

$ docker run --rm --entrypoint /opt/code/localstack/.venv/bin/python3 \
>   localstack/localstack-pro:2026.04.0 \
>   -c "from cryptography.hazmat.bindings import _rust; print('ok')"
$ docker run --rm --entrypoint /opt/code/localstack/.venv/bin/python3 \
>   localstack/localstack-pro:2026.04.0 -c "import cryptography; print(cryptography.__version__)"
47.0.0

# 動く 4.x 側と比較
$ docker run --rm --entrypoint /opt/code/localstack/.venv/bin/python3 \
>   localstack/localstack-pro:4 -c "import cryptography; print(cryptography.__version__)"
46.0.5

cryptography バージョン47 が原因

決定的な差分が出ました

壊れている 47.0.0 / 動く 46.0.5。 これが原因です。

そして1つ目のコマンドの出力に注目してください。ok も出ず、エラーも出ず、何も表示されずにプロンプトが戻っています。docker run はコンテナ内プロセスの SIGILL をシェルのようには報告しないので、これがクラッシュした状態です。

つまり LocalStack をまったく起動せず、1行で再現できるところまで絞れました。原因は LocalStack のコードではなく、依存パッケージ cryptography 47.0.0 の aarch64 ビルド(同梱 OpenSSL を含む)です。

なお 47.0.0 は私の知識のカットオフより後のリリースなので、既知の不具合があるかは私からは判断できません。

イメージ変更のブログ記事から、一気に解決の糸口が見えてきました

回避策1: OPENSSL_armcap(まず試す)

OPENSSL_armcap 環境変数はOpenSSL が起動時に行う ARM 拡張命令の検出結果を上書きする環境変数。0 にすると全ての拡張命令を使わず汎用 C 実装にフォールバックします。

これを以前に失敗していたコマンドに入れて、動くかを確認します。

$ docker run --rm -e OPENSSL_armcap=0 \
>   --entrypoint /opt/code/localstack/.venv/bin/python3 \
>   localstack/localstack-pro:2026.04.0 \
>   -c "from cryptography.hazmat.bindings import _rust; print('ok')"; echo "exit=$?"
ok
exit=0

おおお、 ok が表示されるようになりました。

効きました。原因も回避策も確定です

OPENSSL_armcap=0 で ok / exit=0。同梱 OpenSSL の ARM 拡張命令の検出結果が原因だと裏付けられました。cryptography 47.0.0 の aarch64 ビルドが、CPU の機能ビットから「使える」と判断した命令を選び、それが Apple Virtualization.framework のゲストでは実行できずに SIGILL、という構図です。

OPENSSL_armcap=0 はその自動検出を丸ごと無効化して、汎用の C 実装にフォールバックさせます。

この状況で、 compose.yml の環境変数にも追加して起動してみます。

  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
    image: localstack/localstack-pro:stable
    ports:
      - "4566:4566"
      - "4510-4559:4510-4559"
    environment:
      - DEBUG=1
      - LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT=180
      - AWS_DEFAULT_REGION=ap-northeast-1
      - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:?}
      # cryptography 47.0.0 の同梱 OpenSSL が Apple Silicon の VM ゲストで
      # 実行できない ARM 拡張命令を選ぶため、機能検出を無効化して回避
      - OPENSSL_armcap=0
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

で、実行してみます。

localstack-main  | 2026-08-27T11:28:49.471 DEBUG --- [ady_monitor)] l.extensions.plugins       : calling extensions on_platform_ready
localstack-main  | Ready.

環境変数追加して、stable の最新版が起動しました。

issue 報告する

Claude からは

  • pyca/cryptography への報告
  • LocalStack への追加報告

を提案されたので、まずは *pyca/cryptography のリポジトリを見てみます。

もちろん issue 報告をする前には同じようなものがあるか確認した方が良いので Apple Silicon で検索してみました。

そしたら何てことでしょう。それっぽい issue が既にありました。

“Illegal instruction” (SIGILL) on version 47.0.0 - Apple Silicon (ARM) #14764

これをClaudeにも教えてあげます。

症状は完全に一致、そして一点だけ見立てが変わります

47.0.0、SIGILL、exit 132、Apple Silicon の M4 系、Linux ゲスト。あなたのケースと同じ不具合です。

注目すべきは報告者の解決方法です。

cryptography<47.0.0 に下げる(あなたが検証した通り) Docker Desktop を 4.38.0 → 4.71.0 に更新

後者が効いたということは、VM のカーネルや VMM 側の対応で直るということです。報告者のカーネルは 6.12.5-linuxkit、Docker Desktop 4.38.0 はかなり古いバージョンです。

私は「環境は無罪、原因は wheel だけ」と断定しましたが、正確には新しい cryptography が使う ARM 機能と、古いゲストカーネル/VMM の組み合わせが問題だったようです。ゲスト側が CPU の機能ビットを正しく扱えていない、という筋でしょう。断定は避けますが、環境側にも要因があったのは事実で、そこは訂正します。

根本的に直せる可能性がある

colima は brew でアップデートしてありました。今回の検証もアップデート後です。 しかしカーネルが古いようです。 brew でソフトウェアを更新していても、中身までは更新されませんよね…

$ colima ssh -- uname -r
6.8.0-39-generic
$ colima version
colima version 0.10.3
git commit: 00f6c297e92a82c04a4ab507db0a61435650d7e8

runtime: docker
arch: aarch64
client: v28.4.0
server: v27.1.1

Colima 本体は 0.10.3 で新しいのに、ゲストカーネルが 6.8.0-39-generic。Ubuntu 22.04/24.04 系の LTS カーネルです。issue の報告者が Docker Desktop 更新で直ったのは 4.71.0 に上げた後でそのカーネルバージョンは issue に書かれていませんが、この差が効いている可能性は十分あります。

ポイントは、Colima を更新しても、既存 VM のゲストカーネルは更新されないという点です。VM 内は Ubuntu イメージなので、カーネルは VM 側の apt 管理下にあります。colima delete して作り直しても、Lima が使う Ubuntu イメージのバージョン次第では同じカーネルになります。

作り直すより、VM 内で直接上げる方が確実です。

最終的な解決策: ゲストカーネルのバージョンを更新する

Colima でのゲストカーネル更新の方法は以下のとおりです。

$ colima ssh

sudo apt update
sudo apt install --install-recommends linux-generic-hwe-24.04
exit

$ colima restart
$ colima ssh -- uname -r
7.0.0-30-generic

※ linux-generic-hwe-24.04 で入るバージョンは実行時期で変わるので、記事の 7.0.0-30-generic と一致しない可能性があります

回避策なしで起動してみる

追加した OPENSSL_armcap を compose.yml から削除して、 compose up しなおします。

localstack-main  | 2026-08-27T11:40:42.527 DEBUG --- [ady_monitor)] l.extensions.plugins       : calling extensions on_platform_ready
localstack-main  | Ready.

すると、問題なく起動しました。

根本解決ですね。回避策なしで最新の stable が動くようになりました。

結論

原因は cryptography 47.0.0 の aarch64 ビルドと、古いゲストカーネル(6.8)の組み合わせでした。新しい cryptography が使う ARM 拡張命令を古いカーネルが正しく扱えず SIGILL。カーネルを 7.0 系に上げたら解消。issue の報告者が Docker Desktop 更新で直ったのと同じ筋です。

ということで、判明しているゲストカーネルと結果の対応は以下のとおりです。

ゲストカーネル 環境 結果
6.8.0-39-generic Colima (Ubuntu) SIGILL
6.12.5-linuxkit Docker Desktop 4.38.0 SIGILL
7.0.0-30-generic Colima (Ubuntu) 起動する
(4.71.0 同梱のもの) Docker Desktop 4.71.0 起動する

6.12 でも落ちているので、境界はそれより上にあります。 自分の環境がどちらかは、ゲストカーネルのバージョンで判断してください。

Claude との振り返りとまとめ

私は途中で何度も外しました。Rosetta、VM の CPU、M4 の SVE2、Rust panic、sid ベースイメージ。そして「環境は無罪、原因は wheel だけ」と断定したのも間違いで、実際にはカーネル側が本命でした。決め手になったのは PYTHONFAULTHANDLER=1 と、あなたが :4 まで大きく戻して切り分けた判断、そして上流 issue を見つけたことです。

生成AIでいろんなことが便利に解決していきますが、このような対応は泥臭く人間の察知力で突破口を見つけていくことは、まだまだ必要だと感じました。

最後に都度状況を追加報告していた LocalStack の方にも解決策などを Claude に要約してもらって報告を追加で行いました。

LocalStack チームからは詳細な報告と解決策について、感謝をもらっています。 また

I also sent a notification to the documentation team to add a warning in our docs until the Engineering team fixes the issue.

ということで、ドキュメントチームとも連携をとってくれるということで、何か LocalStack のドキュメントに今回のケースがよくある事象などで反映されるかもしれません。

少しは世界平和に貢献できたかな?と思います。

フロントエンド・PHPカンファレンス北海道2026に参加しました

久しぶりの札幌カンファレンス参加。2024は参加できなかったので、PHPカンファレンス北海道 2019で「PHPを学ぶということ」について発表してきました 以来となりました。

ちゅんとそのときの登壇者Tシャツ着ていったけど、気づいてくれた人はいただろうか?

フロントエンド・PHPカンファレンス北海道2026に参加しました。

前日入り

プロジェクトのスクラムイベントが金曜日になっているのと、新しい案件の見積り直前であんまり休んでる場合ではなかったので、エクストリーム出社することにしました。

ちょうど昨年のPHPカンファレンス福岡のときも忙しくて、前日入りして働いてました。

そんなときも永和システムマネジメントでは東京支社のオフィスが WeWork 京橋内にあって、それ以外の拠点の利用もできるので、ほんと遠征のときは楽で良いです。今回は 6:55 の Air DO で羽田から新千歳 -> エアポート快速 で無事に10:00には仕事を始められました。

ランチどうしようかな?と調べていたら隣にビルにスープカレーの GARAKU があったので、まずの北海道メシとしては最適でした。

定時になったので、さてビール、札幌だからクラシックでは?!と思ってビールサーバーを見たらガッツポーズ出たのですが、すぐに現実に戻されました。

6月になって関東ではびっくりするぐらい暑い日があったりしたのですが、今週末は涼しかったですね。 札幌は涼しいと言うより寒いぐらいでした。事前に天気予報みて最低気温11度予報だったので、ちゃんと上着も持って。

一度ホテルへチェックインして、夜どうしようかな?と思っていたら前日入りしていた人が、こちらに参加していて面白そうだったので合流。

なんかプチ前夜祭みたいな感じになってました。

#スナックとみお 楽しかったですね。

そのあとは、その場の流れでクラフトビールを飲みにいって(僕は右斜上です。thx: @takahashiyuya

ラーメン食べて(僕は右斜上です。thx: @WP_Daisuke

なぜかそのあと、すすきのの知り合いがバイトしてる店で2:30ぐらいまで飲んでました….

カンファレンス当日

ちゃんと起きて会場入り(ギリギリだったけど….

電車で @takahashiyuya に偶然会って昨晩のラーメン後の話でアイスブレークできた感じになりましたw

午前中

オープニングセッション以降で参加したセッションは以下のとおり

PR TIMESルーム

本当はSignals の話も聞きたかったけど、ちょうど 12:30 から懇親会チケット当日販売だったので、そちらを優先してしまいました。

ランチ

少しスポンサーブースでスタンプラリーしてからランチマッチングに参加しました。

社会人PHPer3人と学生5名がマッチングされて、 PHPer x 2 & 学生 x 3、 PHPer & 学生 x 2 で移動。 ちょうど雨が降り始めていたので、会場1Fのレストランに行きましたが、すでにそこにはたくさんの同胞がw

みんな考えることは一緒ですね。ただ会場ではイベント客が来ることが想定されていなかったのか、大変そうでした。ラーサラは美味しかったです。

午後

PR TIMESルーム

その後は LT 、クロージングでした。

各セッションの感想とかはツイートしてるので、気になる方は僕のタイムラインを見てください。

そのあとは懇親会、2次会(そーだいさん取りまとめありがとうございました)と流れて、日曜日もお昼前には飛行機に乗らないといけなかったので、3次会には行かずにホテルに戻りました(なぜ 6/7 日曜日に予定を入れてしまっていたのか、半年前の自分に問いたい…)。

後日

アフターハックにも観光にも行かず、空港へ。お土産を買って五十七番で寿司食べて帰ろうと思ったら、みたことない行列ができていたので、諦めて保安所内にある早い時間からやっているお寿司屋さんへ。

食べ終わってまもなく搭乗して無事帰宅。

最後に

そして、愛車のロド号の18ヶ月検診のため、マツダのディーラーに来て作業待ち時間を使ってブログを書いてます。

セッションの感想については X にポストしているので、全体を振り返って

  • 会場がとても素晴らしかった。昔手前の産業館でPHPカンファレンスが開催されていた記憶が(参加したと思う
  • フロントエンド x PHP という、自分は仕事上はフルスタックで全部やってるけど、こういうコラボカンファレンスとても良いと思った。自分の領域と違うところを知れる機会にもなるし
  • 1分間フィードバック(ぺちおだインスパイア)が採用されていて良かった
  • 会場 Wi-Fi があって良かった
  • 懇親会のジンギスカンが楽しかった
  • とにかく北海道が最高すぎた

ということで最後に今回の北海道メシをふりかえり

  • スープカレー
  • 味噌ラーメン
  • ラーメンサラダ
  • ジンギスカン
  • 寿司
  • たくさんのビール

ということで満遍なく堪能できました。

たくさんの刺激をもらったので、明日からも仕事頑張るぞー。

また北海道のカンファレンスに参加したいです。すべての現地にいた人に感謝です。

React 共通パッケージで SVG を賢く管理する —— SVGR を活用したコンポーネント化戦略

デザインシステムの共通化などで React コンポーネントをプロジェクト横断で使うことはよくあると思います。 もしくはモノレポ構成で複数のWebアプリがあったとき、その間で利用する共通コンポーネントだったりするかもしれません。

そんなとき SVG 画像をどうやって管理したら 気持ちいいか を考えてみたので、記事にしました。

SVG のままインポートすれば良いのでは?

いや、まぁそうなんです。

Vite React (TypeScript) vite-plugin-svgr で SVG をコンポーネントとして扱う のようにプラグインを使ったり、直接 .svg をインポートできるように tsconfig を設定したりすればできなくはないです。

アプリケーション側のアセット管理であれば、それで良いと思っています。

でも共通コンポーネントパッケージとしたとき、どうやって import するかという話になってきます。 もちろん package.json の import で拡張子が .svg だったとき public フォルダを参照させるとか方法はいくらでもあります。

今回の記事では、共通コンポーネント側で JavaScript ファイルにバンドルしたときに含まれてくれることをゴールにしたいと思います。 なので、アセット参照で良いや、という方はゴメンなさい。

SVG をどうやってコンポーネント化するか

SVGR というツールを使います。これがとても便利です。

モノレポだと以下のようなディレクトリ構成になっているとします。

└── packages/
    └── components/
        ├── icons/    <-- ここにFigmaなどから取得したsvgファイルを保存
        │     └── warning.svg
        └── src/
              ├── Button/    <-- ボタンコンポーネント
              └── Icons/    <-- ここに React コンポーネント化された svg が生成される

package.json の scriptsgen:icons を設定します。

  "scripts": {
    "gen:icons": "npx @svgr/cli -d src/Icons icons"
  },

icons ディレクトリにある svg 画像がすべて React コンポーネントになって src/Icons の下に変換されます。 warning.svgWarning.tsx に変換されます。

import * as React from 'react'
import type { SVGProps } from 'react'
const SvgWarning = (props: SVGProps<SVGSVGElement>) => (
    // 実際のSVGタグが入る
)
export default SvgWarning

あとは src/Icons/index.ts を作って export すれば良いだけです。簡単ですね

export { default as Warning } from './Warning'

アプリケーション側では、共通コンポーネントを npm i してから以下のように利用します。

// 使う側のイメージ
import { Warning } from '@common/components';

const App = () => <Warning color="red" />;

この方法の最大のメリットは、SVG がただの画像ではなく「コード」として扱えるようになることです。 これにより、Tree Shaking の恩恵を受けられたり、型定義によって props の補完が効くようになったりと、開発体験が劇的に向上します。

SVG コンポーネント化すると良いこと

Custom Templates が使える。これですね。CLIのパラメータでもある程度カスタマイズできますが、テンプレートを修正する方が見通しが良いです。

たとえばアプリケーションがテーマを変更できるようになっていたとき、SVGの色を変えたいことがあります。もちろん CSS で変更できる場合もありますが、SVG側でかなり考慮されていないと難しいです。 カスタムテンプレートを使えば fill の値を props にするよう定義することもできるので、そういった需要にも柔軟に対応できます。 これは 共通コンポーネント という共通化では重要になってくるのではないでしょうか?

さらに共通コンポーネント集を作る際、悩ましいのが「各プロジェクトで微妙に異なる命名規則や型定義」です。 Custom Template を使えば、例えば IconProps という独自の型を自動で付与したり、aria-label などのアクセシビリティ属性を必須にするような出力も自由自在です。

単なる変換ツールとしてではなく、「SVG の形をした React コンポーネントを量産する工場」を定義できるのが SVGR の真価だと言えます。

さいごに

今回はSVGR というツールの紹介でした。 実際使ってみてとても便利なので、みなさんも使ってみてくださいね。

モノレポ構成の Next.js を standalone ビルドして Docker で動かす(404エラー対策)

このブログは NPM workspaces を使ったモノレポ構成のリポジトリ内にある Next.js アプリを standalone するときに注意すべき点についてまとめた記事です。

NPM workspaces を使ったモノレポ構成

たとえば以下のようなディレクトリ階層を想定しています。

  • apps/webapp : Next.js の web アプリのワークスペース
  • infra : AWS CDK など IaC コードのワークスペース
  • packages/components : React コンポーネントのワークスペース
  • packages/functions/xxxxx : AWS Lambda 関数ごとのワークスペース

これ以外にも packages の下にいろいろなパッケージを入れるような構成はよくあると思います。

Next.js アプリを standalone ビルドする

next.config.ts が以下のような感じで standalone ビルドする想定です。

import type { NextConfig } from 'next'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const __dirname = dirname(fileURLToPath(import.meta.url))

const nextConfig: NextConfig = {
  reactStrictMode: true,
  transpilePackages: ['@packages/components'],
  images: { unoptimized: true },
  output: 'standalone',
  poweredByHeader: false,
  typescript: {
    ignoreBuildErrors: true,
  },
  experimental: {
    webpackMemoryOptimizations: true,
  },
}

一般的に Dockerfile は Next.js の公式サンプルからもってきます。

コンテナイメージをビルドしてみる

はい、いきなりビルドが通りません。 どうやら、モノレポの上位階層にある node_modules(依存パッケージ)を standalone ディレクトリにコピー対象として含めたいのですが、apps/webapp ディレクトリにないのが原因なようです。

これは next.config.ts が以下のような感じで outputFileTracingRoot を追加します。それによりルートディレクトリの node_modules を参照してくれます(これがないと、Next.js は自分のディレクトリ(apps/webapp)内しかトレースしてくれません)。

const nextConfig: NextConfig = {
  reactStrictMode: true,
  transpilePackages: ['@packages/components'],
  images: { unoptimized: true },
  outputFileTracingRoot: join(__dirname, '../../'),
  output: 'standalone',
  poweredByHeader: false,
  typescript: {
    ignoreBuildErrors: true,
  },
  experimental: {
    webpackMemoryOptimizations: true,
  },
}

これでビルドは成功するようになります。

動かしてみる

なんか全部のページが404エラーになります。

イメージビルド結果に public ディレクトリが入ってないことがまずわかりました。

ビルド方法を疑って調べてみてわかったこと

ここで Gemini とか生成AIを使って解決策を壁打ちしてたんですが、適切な回答はまったく得られませんでした。 で、それっぽいキーワードでネットを検索していると、良い記事を見つけました。モノレポ構成のNext.jsプロジェクトをDocker化する方法

大事なのは、記事の中でビルド結果のファイルをコピーしているとことです。

COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public

で、元の公式サンプルでは以下のようになっていました。

COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public

差分がわかりやすいですね。つまりモノレポの場合は、モノレポのパスを含めた状態で .next/staticpublic のフォルダをコピーする必要があるということです。

今回は .next/standalone の中身をこうすることになります:

└── apps/
    └── webapp/
        ├── server.js  <-- これを起動する
        ├── public/    <-- ここにコピーが必要
        └── .next/
            └── static/ <-- ここにコピーが必要

これで解決

結果としてできあがった Dockerfile は以下のような感じです

# syntax=docker.io/docker/dockerfile:1

ARG NODE_VERSION=24.13.0-alpine
FROM node:${NODE_VERSION} AS base

FROM base AS deps
RUN apk update && \
  apk upgrade && \
  apk add --no-cache libc6-compat make gcc g++ python3
WORKDIR /app

COPY . .
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM base AS builder
WORKDIR /app
# モノレポのため、全階層の node_modules が必要。webapp から参照するワークスペースについてはコピーする
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/webapp/node_modules* ./apps/webapp/node_modules/
COPY --from=deps /app/functions/command/node_modules* ./functions/command/node_modules/
COPY --from=deps /app/packages/components/node_modules* ./packages/components/node_modules/
COPY . .

RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run -w apps/webapp build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# モノレポのディレクトリパスを含める
COPY --from=builder --chown=nextjs:nodejs /app/apps/webapp/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/webapp/.next/static ./apps/webapp/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/webapp/public ./apps/webapp/public

USER nextjs

EXPOSE 3000

ENV PORT=3000

ENV HOSTNAME="0.0.0.0"
# モノレポのディレクトリパスを含める
CMD ["node",  "apps/webapp/server.js"]

COPY でモノレポの node_modules が必要になる理由

  • シンボリックリンクの解決: NPM workspaces は、packages/components などを node_modules 内にシンボリックリンクとして配置します。Docker の COPY プロセスでこのリンク関係が崩れたり、リンク先のパスが正しく参照できなかったりすることがあります。
  • 依存関係の分離: 一部のビルドツールや Next.js の outputFileTracing は、各ワークスペース直下の node_modules を探しに行く挙動をすることがあります。

server.js にモノレポのディレクトリパスを含める理由

Next.js の standalone モードは、デフォルトでは server.js をカレントディレクトリとして動作しようとしますが、モノレポだと outputFileTracingRoot の影響で apps/webapp/server.js のような深い階層に生成されるので、実行パスにも注意が必要です。

さいごに

意外とよくあるシーンだと思うのですが、意外と情報に辿り着くまでに時間がかかりました。 僕の記事も追加することで、同じ問題で困っている人の解決になればと思います。

AWS 環境の Next.js アプリから Lambda 呼び出しトレースを X-Rayで追跡できるようにする

Next.js アプリを AWS にデプロイしたとき、SSR などでサーバーサイドで実行した処理から Lambda など別の AWS サービスを呼び出すことがあると思います。 アプリケーションのオブザーバビリティを確保するため、AWS 上のアプリケーションでは X-RAY を使ったログ集積をやることが多いと思います。

ここでは Lambda など別のAWSサービスを呼び出したとき、トレースを追跡できるようにする方法について記録していきます。

また本記事での Next.js のバージョンは v15 を想定しています。それ以前のバージョンでは instrumentationexperimental になっていますのでご注意ください。

Next.js で X-RAY に出力できるように設定する

instrumentation.ts で以下のようにします。

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await require('pino')
    await require('next-logger')
    await import('./instrumentation.node')
  }
}

ログが JSON で保持されていると X-RAY と相性が良いので、ロガーを pino にしておくと吉でお勧めです。

重要なのは NEXT_RUNTIMEnodejs の場合に instrumentation.node というファイルをダイナミックインポートすることです。 つまりサーバーサイドの処理のときの計測コードを使うようにします。

instrumentation.node.ts の内容は以下のようになります。

import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'
import { AWSXRayIdGenerator } from '@opentelemetry/id-generator-aws-xray'
import { AwsInstrumentation } from '@opentelemetry/instrumentation-aws-sdk'
import { AWSXRayPropagator } from '@opentelemetry/propagator-aws-xray'
import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'

const sdk = new NodeSDK({
  idGenerator: new AWSXRayIdGenerator(),
  textMapPropagator: new AWSXRayPropagator(),
  resource: defaultResource().merge(resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'your-app-service' })),
  instrumentations: [
    new AwsInstrumentation({ suppressInternalInstrumentation: true }),
  ],
  spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
})

sdk.start()

OTEL 系のライブラリを使って計測の設定を行いますが、X-RAY を使うときは @opentelemetry/id-generator-aws-xray@opentelemetry/propagator-aws-xray が重要になります。 あと、aws-sdk を使った呼び出しを計測できるようにラッパーである @opentelemetry/instrumentation-aws-sdk を入れておくと楽になります。

この例では Next.js を App Runner(新規申込が終了してしまいました。残念ですね)にデプロイしているのでリソース定義も defaultResource().merge(resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'your-app-service' })) のようにしています。ここは Next.js をデプロイしたリソースによって書き方が変わります。

あとはいい感じに AWS のサービスを SDK で呼び出すと、Next.js(App Runner)から DynamoDB へのリクエストとか、S3 へのアクセスとか全部 X-RAY 上で線がつながって表示されるので、システムトレースの追跡がしやすくなります。

なぜか Lambda だけ線がつながらない?

さて、DynamoDBとかS3などのマネージドサービスは線が接続されていて問題がなさそうに思うのですが、Lambdaの呼び出し(InvokeFunction)だけは、うまく線が接続されません。Lambdaから外部のAPIサーバーを実行していたり、Aurora DBを実行するトレースなどの情報が AppRunner とは結合されておらず、App Runner から Lambda の呼び出し、Lambda から HTTP(API) 呼び出しみたいな別の線が出来上がっていて、システムトレースの追跡ができません。

調べてみた

そうすると X-Amzn-Trace-Id がセグメントドキュメントの http.request に記録されておらず、Lambdaへ正しく伝播されてないことがわかりました。 X-Amzn-Trace-Id が X-RAY で線を結びつけるための値です。

OpenTelemetry の仕様を調べてみる

公式ドキュメント 手動コンテキスト伝搬 などが参考になります。

propagation.inject(context.active(), output);

上記のようにアクティブコンテキストに追加すれば良さそうです。 さらに AWS-SDK の Lambda クライアントでコマンドが送信されたときにフックするにはミドルウェアを使います。

公式ドキュメント ミドルウェアを使用してリクエストをログに記録する

上記サイトの例は DynamoDB クライアントですが、他のクライアントでも同様に実装できます。

メモ

通常、AwsInstrumentation は多くの AWS サービスをカバーしていますが、Lambda の Invoke においては、ペイロードや呼び出し形式の都合上、自動でのコンテキスト注入が期待通りに動かないケースがあります。 これを解決するために、AWS SDK v3 の Middleware Stack を利用して、リクエストが送出される直前に OpenTelemetry のアクティブコンテキストからトレース情報を抽出し、HTTP ヘッダーに明示的にねじ込む処理を追加します。

やってみた

手動伝搬するコードの追加

まず x-ray.ts を作ります。

import { FinalizeHandlerArguments, HttpRequest, MiddlewareStack } from '@aws-sdk/types'
import { context, propagation } from '@opentelemetry/api'

interface HasMiddlewareStack {
  middlewareStack: MiddlewareStack<any, any>
}

const headerSetter = {
  set: (carrier: Record<string, unknown>, key: string, value: string) => {
    carrier[key] = value
  },
}

export const addXRayInjector = <T extends HasMiddlewareStack>(client: T): void => {
  client.middlewareStack.add(
    (next) => async (args) => {
      const finalizeArgs = args as FinalizeHandlerArguments<object>
      const request = finalizeArgs.request as HttpRequest

      if (request?.headers) {
        const activeContext = context.active()
        const carrier = request.headers as Record<string, string>
        propagation.inject(activeContext, carrier, headerSetter)
      }

      return next(args)
    },
    {
      step: 'serialize',
      name: 'xRayContextInjector',
      override: true,
    }
  )
}

このコードでは AWS-SDK のクライアントから X-RAY のコンテキストにリクエストヘッダーを手動伝搬しています。

トレースコンテキスト $C$ は、スパン ID $S$ とトレース ID $T$、およびフラグ $F$ の集合として定義されます。

$$ C = \{T, S, F\} $$

この $C$ を X-Amzn-Trace-Id ヘッダーとして $C_{AppRunner} \rightarrow C_{Lambda}$ へ手動で注入(Inject)することが、今回のコードのキモです。

Lambda クライアントのインスタンスに設定する

そして LambdaClient インスタンスを生成したら、 addXRayInjector で手動伝搬するためのミドルウェアを設定するようにします。

  const client = new LambdaClient()
  addXRayInjector(client)

最後に

今回は Lambda クライアントについて手動伝搬できるような記事となっていますが、S3やDynamoDBのようにさらにその先がない場合は伝搬を気にしなくて良いのですが、Lambdaと同様に以下のサービスについても手動伝搬は必要になります。

  • StepFunctions
  • SQS

つまりリクエストが伝搬して、さらに別のサービスまでトレースを伝搬するようなサービスを利用するときは、リクエストを受け取ったサービス(今回の例でいうと Next.js)から、さきほどの addXRayInjector を設定して X-Amzn-Trace-Id を伝搬できるようにしましょう。

これで良きオブザーバビリティが実現できますように!