はじめに
私が 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 detector、go 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 を書いて validate も plan も通りました。それでも 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.x は ALB のパブリック 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 でした