Blog

間違いだらけのプロンプトでもOK! 数十案件の同時並行開発を始めよう シリーズ「しかばねエンジニアが逝く」その2 〜 普段の開発をしながら複数案件の脆弱性チェックを行うには

blog_skelton_20261031_02.png

オシャレなしゃれこうべと申します。

わたくしは、世の定説どおり35歳で寿命を迎えたエンジニアでございます。
死ぬ間際の最期の記憶は 「エンジニア35歳定年説って本当だったんだ」 というものでした。
しかし、死してなお会社に骨を拾われ、死霊術なぞのぎしきにより復活を遂げて以来、今日も今日とて、こうして労働に使役されております。

◇ ◇ ◇

「大変です! インターネット、終わったらしいです!」

10月も終わりに近いある日、弊社のディレクターが、スマートフォンを握りしめて駆け込んできました。

「PHP がシェルで、HTTP も 2 でシェルなんです! ブロッコリーみたいなやつがメモリを解放しちゃって、もう 1 億 6,500 万人が流出してて、AWS もやられてるって! X でも『全サイト今すぐ止めろ』ってみんないっています! CVSS 9 ですよ、9!」
それにですよ、と彼女は語気も荒く続けました。

「インターネットが終わったら、人類は終わります!!!」

「そんな大袈裟な」

「大袈裟じゃないです! インターネットがないなんて、無理です。地図も、乗り換えも、ランチのお店も調べられないんですよ!」

「昭和には、インターネットはなかったけどなあ。それに電話はちゃんとあったよ。廊下にだけど」

「廊下に!?」

令和な彼女にとって、インターネットは電気や水道と同じく、あって当たり前のものなのでしょう。
それこそ、ないと人類が終わるぐらいに。
昭和のしかばねとは、どうやら終末の基準が違うようです。

明日までです

とはいえ、わたしは落ち着いておりました。
世界は1999年に一度滅んでいるし、わたしも一度は死んで しゃれこうべ となった身の上です。
大抵のことには動じないのです。

「まあ、うちは MovableType で静的サイトを作って、S3 と CloudFront から配信する流れだから。大丈夫なんじゃない?」

公開しているのは、書き出した HTML ファイルなどの静的ファイルだけ。配信する側では、PHP も Node.js も動いておりません。

「そうじゃないのもあるじゃないですか!」

弊社は AEM、Drupal、WordPress など、さまざまな CMS でのサイト構築を生業としております。
MovableType の静的サイトばかりではありません。
PHP が動いている案件も、Node.js が動いている案件も、しかばねの担当表に並んでいたような気がします。……朧げな記憶ですが。

「そんなのあったかなあ」

「今回の脆弱性だけじゃないです。担当の案件、あらゆる脆弱性です。全部の脆弱性、明日の朝までに確認してください!」

全部。
明日の朝までに。

忘れたふりは通じませんでした。

ミッション開始!

──全ての案件の脆弱性を朝までにチェックする。
どうやったら、この無茶振りミッションをクリアできるでしょうか?

骨には勝算がありました。

確かめるには、まず各プロジェクトを立ち上げて、情報漏洩の危険がない 安全な隔離環境 で AI に確かめさせる必要があるでしょう。
幸い、どの案件にも docker compose で組んだ開発環境がありますので、それを転用してサンドボックス環境を作るのはむずかしくありません。

簡単そうだね?

しかし、脆弱性を調べる以前に問題がありました。
1 プロジェクトずつ順番に調査していたら、朝までに終わりません。
全案件を同時に起動して、AI に一斉に確認作業を進めさせる必要があります。

さっそくやってみたら……。

Error response from daemon: ... bind: address already in use

2 件目で止まりました。

3つの厄介ごと

調べるまでもなく、原因はわかっておりました。
どの案件の compose.yml にも、こんな風に書いてあるのです。

ports:
  - "443:443"
  - "3306:3306"

Web サーバーは 443、DB は 3306。
どの案件も、手元の Mac の同じポート番号を「自分のものだ」と言い張っている。
早い者勝ちで、2 件目以降は門前払いです。

厄介ごとは三つありました。

  • ポートが被る: 443 や 3306 や 8080 の取り合いになる。番号をずらせば起動はするが、どの番号がどの案件か、覚えていられない
  • 名前が被る: container_name を固定している案件がある。同じ雛形から作った案件どうしは、コンテナの名前も同じになっている
  • 証明書が面倒: 案件ごとに自己署名の証明書を作り、キーチェーンに登録し、/etc/hosts を書き換えている

古典的な解決策

ただ、しゃれこうべにもひとつだけ案がありました。

  • 案件の前に nginx を 1 台置いて、80 と 443 はそこだけが受ける。server_name ごとに、その案件のコンテナへ proxy_pass する
  • 証明書は mkcert で作る。ブラウザに信頼される証明書が、手元で発行できる
  • 名前は /etc/hosts に書いて、127.0.0.1 に向ける

80 と 443 は nginx だけが受けるので、案件どうしで取り合わなくていい。
https://案件名.test のように、名前で開ける。

mkcert と /etc/hosts と nginx。昔からの安定のある組み合わせです。

完璧だね!

間違いだらけのプロンプト

そこで、Claude Code に相談しました。
記憶をたぐりながら、勢いで打ち込んだのがこちらです。

docker conpose のプロジェクトを大量に起動したい。ポートが被って起動しないから。
ポート番号ではなく https://案件名.test のような名前で開けるようにしたい。
mkcart で証明書を作り、/etc/host に名前を書き、前に ngnix を置いて振り分ければいい気がしている。
この案をたたき台に、いい感じにしてよね!

以上です。

送ってから読み返して、眼を疑いました。
conpose、mkcart、/etc/host、ngnix。
四行のうちに、綴りの間違いが四つ。
しかも、あとでわかるとおり、案そのものも間違っておりました。

しかばねの記憶は、綴りから先に風化するようです。

今回は前回と違い、丸投げではありません。
わたしのぼんやりした案を、Claude と壁打ちしながら、詰めていきました。

返ってきた最初の答えは、こうです。

この案のままでは、うまくいきません。
nginx から proxy_pass するには、案件ごとに Mac 側のポートを 8081、8082……と別々に割り振る必要があります。80 と 443 の取り合いはなくなりますが、ポートの割り振りは残ります。
さらに、案件を 1 件足すたびに、/etc/hosts に 1 行、mkcert で証明書を 1 枚、nginx に server ブロックを 1 つ書くことになります。数十件なら、その 3 倍の手作業です。
名前解決、振り分け、証明書の発行を、どれも「書かなくてよい」形にしましょう。

diagram_01_mkcert-hosts-nginx.png

ダメでした。

言われてみれば、そのとおりです。
ポート番号を覚えなくて済むはずの案で、ポート番号の割り振り表を作るところでした。
どの案件にどれを書いたか、しかばねとなった朧な頭で覚えていられるはずもありません。

一方で、綴りの間違いには一言も触れていません。
返事の中では、何事もなかったように mkcert と /etc/hosts と nginx に直っている。
綴りは黙って読み替え、直すべき案の中身だけを指摘してくる。

間違いだらけのプロンプトでも、話は正しい方向へ進み始めました。
清書してから頼む必要は、なかったのです。

その一: /etc/hosts を書かない

名前解決の方は、こうです。

  • .test で終わる名前は、すべて自分の Mac(127.0.0.1)に向ける。dnsmasq を常駐させ、*.test にはすべて 127.0.0.1 と答えさせる
  • macOS には、ドメインごとに問い合わせ先を変える仕組み(/etc/resolver/)がある。/etc/resolver/test を置けば、.test の問い合わせだけが dnsmasq へ行く。ほかの名前解決には一切触らない

/etc/hosts は、もう書かなくていい。
新しい名前を思いついたら、その場で使える。

その二: プロキシの設定を書かない

nginx のほうは、こうです。

リバースプロキシは、nginx の代わりに Traefik を提案します。
Docker のコンテナに付けたラベルを読んで、振り分け先を自動で決めます。
案件の側にラベルを付ければ、案件を足すたびに server ブロックを書かなくて済みます。

案件の compose.yml は、こう書き換えます。

services:
  web:
    ports:
      - "127.0.0.1::80"     # Mac 側のポートはランダムに任せる
    labels:
      - traefik.enable=true

"127.0.0.1::80" のように Mac 側のポート番号を空けておくと、Docker が空いている番号を勝手に選びます。
大量に起動しても、取り合いになりません。

そしてラベルに traefik.enable=true と書いておけば、Traefik がそのコンテナを見つけて、web.<プロジェクト名>.test という名前で振り分けてくれる。
ホスト名を書かなければ、<サービス名>.<docker compose のプロジェクト名>.test になる決まりです。

番号は、Docker が決める。
名前は、ラベルから決まる。
骨が覚えることは、何もありません。

脳のない者にも優しい設計です。

その三: 証明書は、名前ごとに

残るは証明書です。

わたしの案は、名前を足すたびに mkcert で証明書を作り、nginx に持たせる、というものでした。

Claude は、mkcert を捨てませんでした。

mkcert のルート CA は、そのまま使いましょう。ブラウザはすでに信頼しています。
そのうえで手元で ACME の認証局(step-ca)を動かし、mkcert のルート CA で署名させます。
Traefik が名前ごとの証明書を step-ca から自動で取るので、証明書を手で作らなくて済みます。

本番のサイトが Let's Encrypt から証明書を自動で取得するのと同じ流れです。
コンテナを起動すると、数秒でそのホスト名の証明書が発行される。

案件ごとの自己署名 CA も、キーチェーンへの登録も、もう要りません。

まとめると、構成はこうなりました。

diagram_02_dnsmasq-traefik-step-ca_02.png

常駐するのは、dnsmasq、Traefik、step-ca の三つ。
macOS の launchd に登録して、Mac の起動やログインに合わせて勝手に立ち上がるようにしました。Docker Desktop が後から起動しても、Traefik が数秒おきにつなぎ直してくれます。

わたしの案と並べると、こうなります。

役割 わたしの案 できあがった構成
ポート 8081、8082……と手で割り振る Docker が空いている番号を選ぶ(127.0.0.1::80)
名前解決 /etc/hosts に 1 行ずつ書く dnsmasq と /etc/resolver/test で *.test をまとめて 127.0.0.1 へ
振り分け nginx に server ブロックを 1 つずつ書く Traefik がコンテナのラベルを読む
証明書 mkcert で 1 枚ずつ作る step-ca が名前ごとに自動で発行(mkcert のルート CA で署名)

ダメ出しはされましたが、しゃれこうべの案も、まるきり無駄ではなかった。役割の分け方は、そのまま残っています。
Claude がしたのは、それぞれの役割から「人が手で書くこと」を抜くことでした。

三つの罠

すんなり動いたわけではありません。
つまずきは三回ありました。

罠その一 — 502 Bad Gateway

最初の案件を移したところ、ブラウザには 502 Bad Gateway。
Traefik は受けたものの、転送先につながらない状態です。

原因は、わたしがその案件を手で書き換えたとき、ポート番号を消すことだけに気を取られて、ports に "80" とだけ書いていたことでした。
この書き方だと、コンテナは Mac の 0.0.0.0 で公開されます。
すると Traefik は、ホスト側のポートではなく、コンテナ内部の IP アドレスへ直接つなごうとする。
ところが macOS の Docker では、コンテナは仮想マシンの中にいるので、その IP には Mac から届きません。

Claude に 502 を伝えると、すぐにこの差を指摘してきました。"127.0.0.1::80" と、127.0.0.1 を明示して解決です。
Traefik が「公開先の IP とポート」を使う設定(useBindPortIP)にしてあるための決まりです。

罠その二 — 中間 CA を置けない

step-ca を動かすとき、ふつうは「ルート CA の下に中間 CA を置き、中間 CA で署名する」形にします。
ところが mkcert のルート CA は、pathlen:0 という制約つきで作られていて、下に中間 CA を置けません。

そこで step-ca には、mkcert のルート CA の鍵で直接署名させる構成にしました。
ブラウザはすでに mkcert のルート CA を信頼しているので、これで発行された証明書もそのまま信頼されます。

罠その三 — 検査を始めたら、開発が止まった

検査のために案件を起動したとたん、わたしが手元で開発していた同じ案件の環境が巻き込まれました。
開いていた画面は、さっきまで作業していたブランチのものではなくなっている。

脆弱性の検査は朝まで。
そのあいだも、ふだんの開発の仕事は待ってくれません。

原因は、検査と開発が同じ場所を使っていたことでした。
同じ作業ディレクトリで起動すれば、docker compose のプロジェクト名も同じになります。
プロジェクト名が同じなら、コンテナも、ボリュームも、ホスト名も同じ。
Claude が検査のためにブランチを切り替えて docker compose up すると、わたしの作業中のファイルも、動いているコンテナも、そのまま巻き込まれるのです。

そこで、Claude には案件ごとに git worktree を切って検査させることにしました。
worktree は、同じリポジトリを別のディレクトリに取り出す仕組みです。わたしの作業ディレクトリには触りません。

そのうえで、worktree ごとの .env にプロジェクト名を書いておきます。

COMPOSE_PROJECT_NAME=<リポジトリ名>-<worktree 名>

これでコンテナもボリュームも、ホスト名も worktree ごとに分かれます。
開発は https://www.project-a-main.test、検査は https://www.project-a-audit.test。
ポートはもともと Docker が空いている番号を選ぶので、同じ案件を二つ並べても被りません。

そうして無事に大量のウィンドウで、大量の AI が脆弱性検査を始めたのでした。

◇ ◇ ◇

席に戻ってきたディレクターが、画面をのぞき込んで言いました。

「なにこれ? 気持ちわるい。小さいウィンドウが大量に並んでる……。もしかして全部 AI にやらせてるんですか!? それってずるくないですか」

え?、ず、ずるくないよ?
環境つくるの大変だったんだよ?

まとめ

ポートが被って同時に起動できなかった docker compose の数十件の案件を、https://<名前>.test で開けるようにしました。
dnsmasq で *.test を手元に向け、Traefik がコンテナのラベルで振り分け、step-ca が名前ごとに証明書を発行します。
構成は Claude との壁打ち(全部おまかせ)で決め、検査は worktree ごとに起動して、開発の手を止めずに進められるようにしました。

わたしの仕事は、方針を決めて動作確認するだけでした。
docker compose config で展開後の設定を、https://traefik.test/api/http/routers でルーターの登録を、curl でステータス(404 はルーターがない、502 は転送先につながらない)を確かめるだけです。

人類がほろびなくてよかったです。

 
↑