この記事で学ぶこと
#25 から #31 まで、認証認可を土台から作り込みました。Part 5 はこれで完結です。今日から Part 6、Webアプリ全体を設計する編に入ります。
ここまでの回は「アプリの中身」を見てきました。SQL の書き方、層の分け方、トークンの検証。今回は視点をぐっと引いて、Webアプリというシステム全体が、どんな部品の組み合わせでできているのかを見ます。テーマは3層アーキテクチャです。Webサーバー、アプリケーションサーバー、データベースという分け方は、世の中のWebサービスのほとんどが採用している定番中の定番で、この後の AWS 編(#34〜#38)はすべてこの構図の上に乗ります。ここを押さえると、インフラ構成図が「箱の羅列」ではなく「意味のある配置」として読めるようになります。
この記事を終えると以下ができるようになります。
- 3層アーキテクチャの各層の役割と、なぜその分け方が定番になったのかを人に説明できる
- 「アプリケーション層はステートレスに保つ」という原則を、スケールアウトの仕組みから説明できる
- データベースをインターネットに直接公開しない理由を、実際の構成で確かめて説明できる
この回は言語に依存しません。Go編・Python編どちらの読者もそのままお読みください。
見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。
🔴 銀行の支店で考える
銀行の支店を思い浮かべてください。建物の中は大きく3つに分かれています。
まず窓口です。客が直接やりとりする場所で、用件を受け付け、結果を伝えます。次に事務室です。窓口から回ってきた依頼を処理する場所で、残高を確認し、規定に照らして判断します。最後に金庫です。現金や重要書類が実際に保管されている場所です。
ここで大事なのは、客が触れられるのは窓口だけという点です。客が事務室に上がり込むことはできませんし、金庫に直接手を伸ばすことは論外です。金庫を開けられるのは、決められた手続きを踏んだ職員だけです。
Webアプリの3層アーキテクチャは、この構図そのものです。
| 層 | 銀行でいうと | Webアプリでは | 代表的な担当 |
|---|---|---|---|
| プレゼンテーション層 | 窓口 | リクエストの受付、静的ファイル配信、振り分け | Webサーバー(nginx など) |
| アプリケーション層 | 事務室 | ビジネスロジックの実行、判断 | APサーバー(自分で書くアプリ) |
| データ層 | 金庫 | データの保管と取り出し | DBサーバー(PostgreSQL など) |
外(インターネット)に顔を出すのはプレゼンテーション層だけです。アプリケーション層はその後ろに隠れ、データ層はさらにその奥にいます。リクエストは 窓口 → 事務室 → 金庫 の順に流れ、結果が逆順で返ります。層を飛ばして客が金庫に触ることはできません。
このシリーズで作ってきたものを当てはめると、#3 で書いた HTTPサーバーと #14〜#16 で構造化したコードがアプリケーション層、#4 からずっと使っている PostgreSQL がデータ層です。#25 で Basic認証の実験に使った nginx が、プレゼンテーション層の代表です。実はすでに全部の層に触っています。今回はそれを「配置」の視点で組み直します。
🟡 #14のレイヤードアーキテクチャとは何が違うのか
「層に分ける話は #14 でやったのでは」と思った方、いい着眼です。あちらはコードの中の論理的な分割、こちらはサーバーという物理的な分割で、別の話です。英語では区別があって、コードの中の層を layer、物理的に分かれた層を tier と呼び分けます。3層アーキテクチャは three-tier architecture です。
| レイヤードアーキテクチャ(#14〜#16) | 3層アーキテクチャ(今回) | |
|---|---|---|
| 分けるもの | 1つのプログラムの中のコード | システムを構成するサーバー |
| 層の例 | handler / usecase / repository | Webサーバー / APサーバー / DBサーバー |
| 分ける単位 | パッケージ・モジュール | プロセス・マシン・コンテナ |
| 目的 | 変更しやすさ、テストしやすさ | スケール、可用性、セキュリティ |
紛らわしいのは、どちらも「3層」で図に描くとそっくりなことです。区別のコツは分割の単位を見ることです。import 文で分かれているなら layer、ネットワーク越しの通信で分かれているなら tier です。3層アーキテクチャのアプリケーション層の中に、レイヤードアーキテクチャの3つの layer が入っている、という入れ子の関係になります。
🟡 なぜこの分け方が定番になったのか
「役割が違うから分ける」だけなら #14 と同じ理屈です。tier を分ける本当の理由は、層ごとに増やし方と守り方がまるで違うからです。
増やし方が違う
アクセスが増えてサーバーの処理能力が足りなくなったとき、打ち手は2つあります。サーバー1台の性能を上げる(スケールアップ)か、サーバーの台数を増やす(スケールアウト)かです。
アプリケーション層はスケールアウトに向いています。同じアプリを2台、3台と並べて、リクエストを振り分ければ、単純計算で処理能力が2倍、3倍になります。ただしこれには条件があって、後で詳しく見ます。
データ層はそう簡単にいきません。DBを2台に増やすと「どちらのDBが正しいデータを持っているのか」という同期の問題が発生します。#9 でトランザクションの一貫性を学びましたが、あれは1台のDBの中でさえ苦労して守っていたものです。2台にまたがると難易度が跳ね上がります。だからデータ層はまずスケールアップで粘り、それでも足りないときに読み取り専用のレプリカを足す、という順で戦います(#36 の Aurora / RDS の回で扱います)。
増やしやすい層と増やしにくい層が混ざっていると、増やしやすい層まで増やせなくなります。分けてあれば、アプリケーション層だけを気軽に増減できます。
守り方が違う
もう1つの理由がセキュリティです。データ層には全ユーザーの情報が入っています。ここがインターネットから直接触れる位置にあると、認証の突破もアプリの脆弱性も関係なく、DBへの攻撃が総当たりで試せてしまいます。
3層に分けると、外に公開するのはプレゼンテーション層の1点だけに絞れます。DBには「アプリケーション層からしか届かない」というネットワークの壁を作れます。#31 で「CSRFトークンと SameSite を層で守る」と言いましたが、あれと同じ発想です。アプリのコードで守り、さらにネットワークの構造でも守る。どちらかが破れても、もう一方が残ります。
🔴 アプリケーション層はステートレスに保つ
スケールアウトの「条件」の話をします。ここが今回いちばん大事な原則です。
アプリを2台に増やして振り分ける構成では、同じユーザーのリクエストが1回目は1台目、2回目は2台目に届くことが普通に起きます。このときアプリが「前回のリクエストの続き」をサーバーのメモリに覚えていたら、どうなるでしょうか。ログイン状態を1台目のメモリに置いていたら、2台目に届いたリクエストは「この人は誰?」となり、ユーザーはログインしたはずなのに弾かれます。
だから原則はこうです。アプリケーション層は状態(state)を持たない。リクエストを処理するのに必要な情報は、毎回リクエスト自身が運んでくるか、データ層から取り出す。処理が終わったら何も覚えない。この性質をステートレスと呼びます。どのサーバーに当たっても同じ結果になるので、台数を自由に増減でき、1台が壊れても他の台がそのまま引き継げます。
実はこの原則、すでに実践しています。#26 で作った JWT認証は、ログイン状態をサーバーに保存せず、トークンという形でリクエスト自身に運ばせる方式です。あの設計がスケールアウトと相性がいい理由が、ここでつながります。状態を持つ必要があるもの(セッション、キャッシュ)は、アプリのメモリではなくデータ層側(DBや Redis のような共有ストア)に置きます。
【手を動かす①】3層をdocker composeで組む
理屈を確かめます。#23 で使い込んだ docker compose で、3層をミニチュアで組みます。プレゼンテーション層は nginx、データ層は PostgreSQL。アプリケーション層には traefik/whoami という、アクセスすると自分のホスト名(=どのコンテナが応答したか)を返すだけの小さなWebアプリを使います。言語を問わず「アプリが2台いる」状況を作れるので、振り分けの観察にちょうどいい教材です。
mkdir -p ~/tier3-demo && cd ~/tier3-demo
compose.yaml を作ります。ポイントは2つ。ports で外に公開しているのは web だけであること、replicas: 2 でアプリケーション層を最初から2台にしていることです。
services:
web:
image: nginx:1.27
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
app:
image: traefik/whoami:v1.10
deploy:
replicas: 2
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
nginx の設定 nginx.conf を作ります。届いたリクエストを app サービスに横流しする(リバースプロキシ)だけの設定です。
# 学習用にワーカーを1つに絞る(振り分けを観察しやすくするため)
worker_processes 1;
events {}
http {
server {
listen 80;
location / {
proxy_pass http://app:80;
# 落ちたアプリを2秒で見切って、残りのアプリに回す
proxy_connect_timeout 2s;
}
}
}
worker_processes 1; について一言だけ。nginx は普通、CPUコア数ぶんのワーカープロセスを立てて並列にさばきます。ただしリクエストをどのアプリに回したかの記憶はワーカーごとに別々なので、ワーカーが多いと少ないリクエスト数では振り分けが偏って見えます。私も最初この設定なしで試して「全部同じアプリに行くぞ?」と首をひねりました。今回は振り分けを目で確かめたいので、あえて1つに絞ります。
起動して、4つのコンテナが上がったことを確かめます。
docker compose up -d
docker compose ps --format '{{.Name}}\t{{.Status}}'
tier3-demo-app-1 Up 3 seconds
tier3-demo-app-2 Up 3 seconds
tier3-demo-db-1 Up 3 seconds
tier3-demo-web-1 Up 3 seconds
app が2台います。窓口(web)に何度かリクエストを送って、どのアプリが応答したかを見ます。
for i in $(seq 1 6); do curl -s localhost:8080 | grep Hostname; done
Hostname: 9026ec2b9bdc
Hostname: f43bd946bcf1
Hostname: 9026ec2b9bdc
Hostname: f43bd946bcf1
Hostname: 9026ec2b9bdc
Hostname: f43bd946bcf1
2つのホスト名が交互に現れました。私たちは localhost:8080 という1つの窓口しか叩いていないのに、裏では2台のアプリが交代でさばいています。これが負荷分散(ロードバランシング)です。nginx は app という名前を引くと2台ぶんの宛先を受け取り、順繰りに振り分けます。#23 でやった「サービス名で名前解決できる」仕組みが、そのまま振り分けの土台になっています。
利用者からは1つのサービスに見えて、中の台数は自由に変えられる。プレゼンテーション層が「窓口」として前に立つ価値がこれです。
【手を動かす②】外から触れるのは玄関だけ
次に「守り方」を確かめます。ホスト(コンテナの外の世界。インターネット側の視点の代役です)から、各層のポートに TCP 接続を試みます。
timeout 3 bash -c 'echo > /dev/tcp/localhost/8080' 2>/dev/null && echo "8080: 開いている" || echo "8080: 閉じている"
timeout 3 bash -c 'echo > /dev/tcp/localhost/5432' 2>/dev/null && echo "5432: 開いている" || echo "5432: 閉じている"
/dev/tcp/ホスト/ポート は bash の機能で、そのポートに TCP 接続できるかを試せます。
8080: 開いている
5432: 閉じている
web の 8080 には届きますが、PostgreSQL の 5432 には届きません。compose.yaml で ports を書いたのが web だけだからです。DBが動いていないわけではありません。内側のネットワークからなら届くことを確かめます。
docker run --rm --network tier3-demo_default postgres:16 pg_isready -h db -U postgres
アプリケーション層と同じネットワークに一時的なコンテナを入れて、そこから DB の生存確認(pg_isready)をしています。
db:5432 - accepting connections
内側からは元気に応答しています。つまりこの DB は「アプリと同じネットワークにいる者だけが触れる金庫」になっています。攻撃者がインターネットから DB のパスワードを総当たりしようにも、そもそも接続が届きません。本番の AWS でも、セキュリティグループとサブネットで同じ構造を作ります(#34 で扱います)。
最後に、スケールアウトのもう1つの効能である可用性を見ます。アプリを1台わざと落とします。
docker stop tier3-demo-app-1
for i in 1 2 3; do curl -s -w 'time_total: %{time_total}s\n' localhost:8080 | grep -E 'Hostname|time_total'; done
Hostname: f43bd946bcf1
time_total: 1.856866s
Hostname: f43bd946bcf1
time_total: 0.000969s
Hostname: f43bd946bcf1
time_total: 0.001035s
1台落ちてもエラーにはならず、残った1台がすべて応答しています。応答時間に注目してください。最初の1回だけ2秒弱かかっています。nginx が落ちた側への接続を試み、失敗してから残りに回したためです。2回目以降は1ミリ秒未満に戻っています。nginx は一度失敗した宛先をしばらく振り分け候補から外すので、次からは生きている側に直行するのです。壊れた台を勝手に外し、直れば勝手に戻す。台数を並べる構成は、性能だけでなくこの「1台壊れてもサービスが続く」性質(可用性)を買っています。
確認が終わったら片付けます。
docker compose down
実務での判断基準
🔴 状態をアプリのメモリに置きそうになったら手を止める
ステートレス原則は、日々のコードレビューで一番よく効きます。「ログインユーザーの情報をグローバル変数にキャッシュする」「アップロード途中のファイルをローカルディスクに置く」「処理の進捗をメモリ上の map で管理する」。どれも1台なら動きますが、2台に増やした瞬間に壊れます。しかも壊れ方が「たまにログインが切れる」のような再現困難な形で出るので、原因究明に苦しみます。状態を持ちたくなったら、DB か共有ストアに置けないかをまず考えてください。
🟡 最初から3台に分けるかは規模次第
では個人開発でも nginx + アプリ + DB を別マシンに分けるべきかというと、そうではありません。小さなサービスなら全部1台のサーバーに同居させるのは合理的な選択です。大事なのは、同居していても論理的な境界を保つことです。アプリはステートレスに書く、DBへの接続情報は設定で外から渡す(#21 の環境変数の話です)。これができていれば、負荷が増えた日に層ごとに切り離すのは難しくありません。逆に境界が溶けたコードは、分けようとした日に書き直しになります。物理的に分けるのは後でいい。論理的な分離は最初から。これが定石です。
🟡 ボトルネックはたいていデータ層に落ちる
アプリケーション層は増やせばさばけるので、性能問題は最終的に「増やしにくい層」であるデータ層に集まります。#8 のインデックス、#12 の N+1 とコネクションプールで学んだことは、つまり3層アーキテクチャの一番弱い場所の守り方だったわけです。「遅い」と言われたらまず DB 周りを疑う、という現場の勘所はこの構造から来ています。
⚪ 3層の先にあるもの
現実の大規模サービスでは、この3層にさらに部品が足されます。プレゼンテーション層の前に CDN(静的ファイルを世界中の拠点から配る仕組み)、アプリとDBの間にキャッシュ層(Redis など)、そしてアプリケーション層自体を機能ごとに分割するマイクロサービス。いずれも3層を置き換えるものではなく、3層の変形です。土台のこの構図を押さえていれば、初見の構成図も読み解けます。今は名前だけ知っておけば十分です。
確認テスト
知識問題
Q1. 3層アーキテクチャの3つの層の名前と役割を、外部に公開されるのはどの層かに触れながら説明してください。
Q2. #14 のレイヤードアーキテクチャと今回の3層アーキテクチャは、どちらも「3つの層」ですが別物です。何を分けているのかの違いと、両者の関係を説明してください。
Q3. 「アプリケーション層はステートレスに保つ」という原則について、①ステートレスとはどういう状態か、②守らないとスケールアウト時に何が起きるか、の2点を説明してください。
Q4. データベースをインターネットに直接公開しない構成にするのはなぜですか。アプリ側の認証やバリデーションがしっかりしていれば公開してもよい、という意見への反論として説明してください。
実技問題
Q5. 手を動かす①の構成で、アプリケーション層を3台に増やしてください(ヒント: compose.yaml の1行を変えて docker compose up -d し直す)。増えたことを、リクエストの応答でどう確認するかまで含めて答えてください。
確認テスト 解答・解説
Q1
プレゼンテーション層はリクエストの受付と振り分け、静的ファイルの配信を担う層で、nginx のようなWebサーバーが代表です。アプリケーション層はビジネスロジックの実行を担う層で、自分たちが書くアプリ本体です。データ層はデータの保管と取り出しを担う層で、PostgreSQL のようなDBサーバーが代表です。
外部(インターネット)に公開するのはプレゼンテーション層だけです。リクエストは窓口であるプレゼンテーション層から入り、アプリケーション層、データ層へと順に流れます。銀行の支店で客が触れられるのが窓口だけで、金庫には決められた手続きを踏んだ職員しか触れないのと同じ構図です。
Q2
レイヤードアーキテクチャは1つのプログラムの中のコードを論理的に分けるもので、handler / usecase / repository のようにパッケージ単位で分かれます(英語では layer)。3層アーキテクチャはシステムを構成するサーバーを物理的に分けるもので、Webサーバー / APサーバー / DBサーバーのようにプロセスやマシンの単位で分かれます(英語では tier)。
目的も違います。layer を分けるのは変更しやすさとテストしやすさのため、tier を分けるのはスケール・可用性・セキュリティのためです。関係としては入れ子です。3層アーキテクチャのアプリケーション層という1つの tier の中に、レイヤードアーキテクチャの3つの layer が収まっています。
Q3
① ステートレスとは、アプリがリクエストをまたいで状態を覚えていないことです。処理に必要な情報は毎回リクエスト自身が運んでくるか、データ層から取り出します。だからどのサーバーに当たっても同じ結果になります。
② 守らないと、複数台に振り分けたときに「その状態を持っている1台」でしか正しく動かなくなります。典型例がログイン状態をサーバーのメモリに置くケースで、1回目のリクエストが1台目に、2回目が2台目に届くと、2台目は状態を知らないためログインが切れたように見えます。台数を増やすほど発生確率が変わる再現困難な不具合になるのが厄介な点です。状態はDBや共有ストアに置き、アプリ自身は何も覚えないのが原則です。
Q4
守りを1枚に頼らないためです。アプリ側の認証やバリデーションは、アプリを経由してくる攻撃には効きますが、DBが直接公開されていると攻撃者はアプリを素通りしてDBそのものを攻められます。DBのパスワード総当たり、DBソフトウェア自体の脆弱性への攻撃は、アプリのコードがどれだけ堅くても防げません。
ネットワークの構造で「アプリケーション層からしか届かない」壁を作っておけば、そうした攻撃はそもそも接続の時点で届きません。アプリの守りとネットワークの守りは防げるものが違うので、どちらかで代用はできず、重ねて使います。#31 の「層で守る」と同じ考え方です。
Q5
compose.yaml の replicas: 2 を replicas: 3 に変えて、もう一度起動します。
app:
image: traefik/whoami:v1.10
deploy:
replicas: 3
docker compose up -d
docker compose restart web
確認は、①と同じようにリクエストを何度か送り、応答に含まれる Hostname の種類を数えます。
for i in $(seq 1 9); do curl -s localhost:8080 | grep Hostname; done
3種類のホスト名が現れれば、3台に振り分けられています。
ポイント
- 設定ファイルの数字を1つ変えるだけで処理能力を増やせるのは、アプリがステートレスだから。どの台に当たっても同じ応答なので、ただ並べるだけでよい
-
webを再起動しているのは、nginx が起動時にappの宛先一覧を名前解決して覚えるため。増えた3台目を認識させるには引き直しが必要になる。実務のロードバランサーはここを動的にやってくれる - クライアント側には一切変更がない。窓口を1つに絞ってあるおかげで、裏の増減が利用者に見えない
まとめ
- 3層アーキテクチャは、システムをプレゼンテーション層(窓口)・アプリケーション層(事務室)・データ層(金庫)に分ける定番構成。外に公開するのは窓口だけで、リクエストは層を順に流れる
- #14 の layer はコードの論理的な分割、今回の tier はサーバーの物理的な分割。3層の「アプリケーション層」の中に layer の3層が入る入れ子の関係
- tier を分けるのは、層ごとに増やし方と守り方が違うから。アプリケーション層はスケールアウトしやすく、データ層はしにくい。だからボトルネックはデータ層に集まりやすい
- スケールアウトの条件はアプリケーション層のステートレス化。状態はアプリのメモリではなくデータ層に置く。JWT がスケールと相性がいいのもこの理屈
- DBを外に公開しないのは、アプリの守りとネットワークの守りで防げるものが違うから。物理的な分離は後からでもいいが、論理的な分離は最初から保つ
次回は #33 REST API設計の原則 です。3層の窓口から受け付ける「リクエストの形」をどう設計するか。URL とHTTPメソッドの使い分けから、実務で迷いがちなポイントまで整理します。