0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

未経験だった頃に PHP で作った掲示板を、AI と組んで作り直した

0
Posted at

はじめに

私が Qiita に最後に記事を書いたのは、PHP と Laravel 5.5 の話でした。タグには「未経験」と付けていました。MySQL を使い、Apache に置いていました。

あれから同じ題材 —— 掲示板 —— をもう一度作りました。

今度は Go と PostgreSQL と Next.js で、AWS に Terraform で出して、動作を確認してから消しました。

この記事は「新しい技術を使いました」という話ではありません。 同じものを作り直したときに、何を決める必要があると気づいたかの記録です。当時の私は、技術を選ぶことと、選んだ理由を残すことが別だと知りませんでした。

作ったものはこれです。

Go 1.25 (Gin) / PostgreSQL 17 / Next.js 16 (App Router, RSC)
sqlc / oapi-codegen / Terraform / ECS Fargate / CloudFront

リポジトリはこちらです。この記事に出てくる ADR も、実測用のコマンドも、Terraform も全部入っています。

https://github.com/sonsyu0103/develop-experiments


1. 何が一番変わったか

先に結論を書きます。

「たぶんこっちが速い」で決めなくなったことです。

当時の私は、技術を選ぶとき「よく使われているから」「速いらしいから」で決めていました。それが間違いだとは思いません。情報が無い段階では、それしか判断材料がないからです。

ただ今回は、選択肢を両方実装して、測ってから選ぶようにしました。リポジトリにはその比較を実行するコマンドが入っています。

make concurrency-probe   # コメント投稿の並行制御 4 方式を実 DB で比較
make viewcount-probe     # 閲覧数の反映方式を比較 (バッファ / 同期 UPDATE)
make partition-probe     # テーブル分割がどの規模から効き始めるか
make scale-probe         # 読み取りのスループットが接続数のどこで折り返すか

これらは Makefile にあります。make help で全部出ます。

たとえば「コメント投稿の排他制御」は、SSI・悲観ロック・一意制約・素朴な実装の 4 つを全部実装して、実際の PostgreSQL に対して同時に叩いて比較しました。どれが速いかではなく、どれがどう壊れるかを見るためです。

この習慣がついたのが、6 年で一番変わったところだと思います。


2. 言語をどう選んだか

候補は Go / Rust / TypeScript

まず「なぜ PHP をやめたか」ではなく、「今なら何を選ぶか」を考えました。候補は 3 つでした。

Rust は魅力的でした。所有権とライフタイムによる保証は本物で、今回の主題である並行処理とも噛み合っています。

落としたのは学習コストと開発速度です。言語の習得に時間を割くと、本来やりたかった並行制御の実測に届かないと判断しました。

ここは正直に書いておきたいのですが、これは技術的な優劣の判断ではありません。「Rust は不要だった」ではなく「今回は選べなかった」 が正確です。期間の制約が外れれば結論は変わります。

TypeScript も候補でした。フロントと言語が揃うメリットは本物で、型定義を共有できます。

ただバックエンドの機能としては弱いと判断しました。goroutine、race detectorgo test -bench —— 今回の主題を測る道具が、Node では同じ密度になりません。言語を揃えるために、測る道具を手放すことになると考えました。

決め手は「AI が書いたものを、安く検証できるか」

最終的に Go を選んだ理由の中で、一番重いのはこれでした

今回は最初から AI と組んで書く前提でした。その前提では、言語の選択基準が変わります。

重要なのは「AI がその言語を書けるか」ではありません。ほとんどの主要言語は書けます。「AI が書いたものを、こちらが安く検証できるか」 です。

静的型なら、

  • 型の不一致はコンパイルで落ちる
  • go vet / golangci-lint実行前に検出する
  • race detector が並行処理の誤りを実行時に捕まえる

動的型だと、同じ誤りがテストを書くまで表に出ません。生成量が増えるほど、この差は開きます。

人間が 1 日に書ける量には限りがあるので、目視レビューでも追いつきます。AI と組むと生成量が跳ね上がるので、機械が先に弾ける割合がそのまま開発速度になります。

「Go は速いから」を採用理由にしなかった理由

事実ではあります。でもこの規模では言語の速度が律速になりません

見積もったピークは API 550〜4,200 RPS、書き込みは毎秒 10〜56 件でした。ボトルネックを順に並べるとこうなります。

# ボトルネック
1 DB 接続数 (タスク数に比例)
2 閲覧数の書き込み集中
3 セッション検索がプライマリに集中
4 読み取り QPS
5 画像の帯域
6 閲覧数フラッシュがタスク数に比例

6 つとも DB か帯域で、アプリケーションの実行速度は 1 つも出てきません。

ここで「Go は速いから」を採用理由に据えると、測る前に答えを決めたことになります。当時の私が「Laravel は書きやすいから」で選んでいたのと、構造としては同じです。

フロントは Next.js —— ただし Vue との差は小さいと思っている

React を選んだのも同じ軸です。最も使われている = 学習データが厚い = AI が正確に書ける

正直に書きますが、フレームワークの表現力で選んでいません。Vue でも同じものが作れるし、設計判断もほとんど変わらないと思っています。差が出るのは「AI が書いたコードの精度」と「読み手の多さ」であって、機能の優劣ではありません。

ここを「React のほうが優れているから」と書いてしまうと、また「測る前に答えを決める」ことになります。


3. フレームワークを薄く使う

Gin を使っていますが、意図的に薄く使っています

使う 使わない
ルーティング、ミドルウェアの連結 ORM 的なデータアクセス
リクエストの受け口と応答 バリデーションの DSL
ClientIP() 独自のコンテキスト伝播

依存を数えるとこうなります。

gin を import しているパッケージ   internal/httpapi/ と cmd/api/ のみ
domain・usecase 層での import      0 件

層ごとの規模で見ると、HTTP 層は全体の 14% にとどまります

行数
domain 2,705
usecase 3,866
infrastructure 8,930
httpapi (Gin を含む) 2,544

「差し替えられるように」は理由にしていない

よくある説明ですが、採りませんでした。Gin から echo へ移す予定はないし、移す理由も思いつきません。

薄いのは移植性のためではなく、HTTP は入出力の変換であって、業務の判断をそこに置くべきではないという理由です。

そして重要なのは、薄さが「心がけ」ではないことです。go-arch-lint で依存方向を検査しているので、Gin の機能を usecase や domain で使うとその時点で CI が落ちます

ハンドラの形も同様で、OpenAPI の仕様書から ServerInterface を生成しています。引数と戻り値を決めているのは仕様書であって、フレームワークではありません。


4. 当時やらなかったこと

検査そのものを検査する

テストが通ることと、テストが機能していることは違います。

そこでわざと壊して、検査が落ちることを確かめる仕組みを入れました。

make mutation-probe MUTATIONS=<変異リスト>   # 実装を壊し、テストが落ちるか測る
make arch-probe        # 依存方向の検査が、違反を本当に検出するか
make checker-probe     # 静的検査が「落とすべきものを落とす」か
make https-verify      # プロキシ配下の分岐を実測。壊して効きも見る

これで実際に穴が見つかりました。

手元に本番相当の構成 (TLS 終端のリバースプロキシ) を立てて、X-Forwarded-Proto ヘッダをわざと落としてみたときの出力です。

=== 5. X-Forwarded-Proto と selfOrigin の因果 (検査が効いているかの確認) ===
  OK    XFP があれば selfOrigin が https を組み立て、書き込みが通る
  OK    XFP を落とすと 403 になる (検査が効いている)
  OK    その状態でも GET は 200 (監視では気づけない)

書き込みだけが 403 になり、GET と画面は 200 のままです。ヘルスチェックが GET なら、監視は緑のまま全部の書き込みが止まります。

この壊れ方を本番に出す前に手元で踏めたことが、後で効いてきます (次章)。

設計判断を書き残す

ADR (Architecture Decision Record) を 25 本書きました。

特に効いたのは、決定だけでなく「実装して分かったこと」を追記する運用にしたことです。設計時の想定が外れた点を、同じ文書の末尾に足していきます。

例として、私は当初こう書いていました。

同一オリジン構成では CORS_ALLOWED_ORIGINS を設定する理由がない

これは誤りでした。実測すると、許可リストに自分のオリジンを明示しておくことが、X-Forwarded-Proto が落ちたときの保険になっていました。ADR にはこの訂正も残してあります。


5. AWS に出して、消す

1 日 $1.3 の構成

「ポートフォリオを AWS に出したいが課金が怖い」という人は多いと思います。私もそうでした。

そこで 「常時公開しない」を最初から前提にしました。見せたい日に立てて、終わったら消します。

リソース 月額
ALB $18
RDS (db.t4g.micro) $15
Fargate Spot × 2 $5
CloudFront / S3 / ECR ~$1
NAT Gateway $0 (置かない)
ドメイン + Route 53 $0 (取らない)
合計 約 $39/月 = 1 日 $1.3

NAT Gateway を捨てたのが一番効きました。 教科書的にはコンテナをプライベートサブネットに置いて NAT 経由で外に出しますが、NAT は動いているだけで月 $33 かかり、この構成で一番高い部品になります。

代わりにコンテナをパブリックサブネットに置き、受信をファイアウォールで全部塞ぎました。「住所が公開されている」ことと「玄関が開いている」ことは別、という考え方です。

ドメインも取っていません。CloudFront の既定ドメイン (xxxxx.cloudfront.net) には証明書が付いてくるので、それで HTTPS が成立します。

「消せること」を構成の要件にする

この運用にすると、「消せること」が要件になります。AWS の既定は逆 (消えにくいほう) に倒れているので、明示的に外す必要があります。

リソース 既定 この構成
RDS 最終スナップショットを取る 取らない
RDS 削除保護あり 無効
S3 中身があると消せない force_destroy
ECR イメージがあると消せない force_delete
Secrets Manager 30 日の復旧猶予 0 日

最後が特に効きます。既定のままだと消しても論理削除で残り、次に同じ名前で作れません。「消したのに作れない」という、一番分かりにくい失敗になります。


6. apply して初めて分かったこと

Terraform を書いて validateplan も通りました。それでも apply で 15 回つまずきました。そのうちいくつかは、ドキュメントを読んでも出てこない類のものでした。

ファイアウォールの規則が 4 つで上限 60 に当たる

RulesPerSecurityGroupLimitExceeded

書いた規則は 4 つ、上限は 60。一見あり得ません。

原因は「CloudFront からの通信だけ許可する」という1 行でした。AWS が管理するプレフィックスリストを使ったのですが、その中身が 46 個の IP レンジだったのです。1 行に見えて 46 行分を消費していました。

これを 2 か所で使っていたので 46 × 2 + 2 = 94 になり、上限を超えました。

VPC の中から ALB を引くと、パブリック IP が返る

画面だけが真っ白になりました。API は正常、DB まで到達、書き込みも通るのに、/ だけが届きません。

ログを見るとこうでした。

Connect Timeout Error (attempted addresses: 54.238.198.29:8080, ...)

54.238.x.xALB のパブリック IP です。

  • インターネット向けの ALB は、VPC の中から DNS を引いてもパブリック IP を返す
  • コンテナにはパブリック IP が付いている (NAT を置かない判断の帰結)
  • つまり通信はいったんインターネットに出て戻る
  • ALB から見た送信元は「コンテナのパブリック IP」であって、ファイアウォールのグループではない

設定は正しかったのに、経路がそもそも VPC の外を通っていました。 NAT を置かない判断の副作用が、ここで初めて表に出ました。

destroy が対話を求める —— 「消したつもり」が一番怖い

自動化された環境から terraform destroy を実行したところ、確認プロンプトで止まり、1 つも消えませんでした

出力には消える予定のリソースがずらりと並ぶので、一見すると消えたように見えます

これは「使う日だけ立てる」運用と正面から衝突します。消し忘れが一番怖い運用なのに、「消したつもりで課金が続く」経路が既定で存在していたわけです。

plan -destroy でファイルに固めてから適用する形に変えました。

手元で先に踏んでいたおかげで助かったこと

4 章で書いた X-Forwarded-Proto の話が、そのまま効きました。

CloudFront で HTTPS を終端して ALB へ HTTP で流す構成では、ALB が X-Forwarded-Proto を自分の受信スキーム (http) で上書きします。アプリ側は「http でアクセスされた」と判断し、すべての書き込みが 403 になります

これは手元で再現していた壊れ方そのものでした。そして解も同じで、許可リストに公開ドメインを明示することで通りました。

手元にエッジを立てていなければ、「デプロイできたのに書き込みだけ全部 403」で詰まっていたと思います。


7. AI と組んで書いて、記録が消えた話

最後に、この記事で一番書きたかったことを書きます。

言語選択の記録が、リポジトリに無かった

Phase を全部通し終えて振り返ったとき、ADR に言語とフレームワークの選択理由が無いことに気づきました。

PostgreSQL を選んだ理由 (ADR 0001) はありました。並行制御の方式も、閲覧数の反映方式も、認証の設計も、全部書いてありました。器の選択だけが抜けていました。

理由ははっきりしています。設計を詰める段階では、別の AI を壁打ち相手に使っていたからです。

そこで言語とフレームワークは決まりました。議論もしました。でもその対話は仕様書に落ちませんでした

この漏れ方は、決定そのものの裏返しだった

2 章で書いたとおり、Go を選んだ一番の理由は「AI と組んで書く前提だから、静的型が検証の足場になる」でした。

その決定自体が、AI との対話の中で消えました。

対話は決定を早く進めます。でも決定が進むことと、決定が残ることは別です。

コードは残ります。AI が生成したものは全部リポジトリに入ります。でも**「なぜそれを選んだか」は、意識して書き出さないかぎり残りません**。

ここは道具では埋まらないと思っています。何を記録すべきかを判断するのは人間側の仕事で、その判断そのものは AI に委ねられません。

何が問題だったか

「言語の選択は主題ではない」と考えていたわけではありません。単に、記録する対象として認識していませんでした。

でも読み手が最初に問うのは器の選択です。そこに理由が無いと、その先の判断もすべて「なんとなく」に見えます

「なぜリードレプリカを置かないか」を数字で説明したのと同じ密度で、選ばなかったものにも理由が要ります

再発を防ぐために

対話で決めたことは、その場で ADR の見出しだけでも起こすようにしました。中身は後から埋められますが、見出しが無いものは存在しなかったことになります


おわりに

6 年前の記事を読み返すと、「動いた」ことしか書いていませんでした。何を選んだかは書いてあっても、なぜ選ばなかったかが書いてありません

今回作り直して一番変わったのは、そこだと思います。

  • 選ぶ前に測る
  • 検査が機能しているかを検査する
  • 選ばなかった理由も残す
  • AI と組んでも、記録は自分で残す

最後のひとつは、6 年前には存在しなかった課題でした。AI は決定を早くしてくれますが、決定を残してはくれません。そこだけは、まだ人間側の仕事だと思っています。


リポジトリ

https://github.com/sonsyu0103/develop-experiments

記事で触れたものの置き場所です。

設計判断の記録 (25 本) docs/adr/
言語とフレームワークの選択 docs/adr/0025-language-and-framework.md
ローカルのエッジと実測 docs/adr/0023-local-edge-and-https.md
AWS へのデプロイと 15 件のつまずき docs/adr/0024-aws-deployment.md
Terraform infra/terraform/
ローカルの TLS 終端 infra/caddy/

補足: 記事中の数字について

  • ADR 25 本、apply でのつまずき 15 件、手元での実測 8 件は、リポジトリの記録そのままです
  • 層ごとの行数、gin の import 数は、記事を書いた時点で実際に計測しました
  • コスト $1.3/日 は東京リージョンでの概算で、実際に 1 時間稼働させた実績は $0.07 でした
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?