プロローグ — 1台のサーバーから始まる
ハルトとEchoTaskの誕生
2024年の春、田中ハルトは東京・渋谷の古いマンションの一室で、ひとりコードを書いていた。
ハルトは26歳。大手SIerを2年で辞め、その後スタートアップでRailsエンジニアとして3年間働いた。月80時間以上の残業、毎朝届く上司からの「どうなった?」Slack、他社のプロダクトを羨ましそうに眺める日々。チームはいつも人手不足で、技術的負債が山積みだった。チケット管理ツールはJIRAだったが、使い方がチームによってバラバラで、誰も全体像を把握できていなかった。
「タスク管理ツール、もっとシンプルにできるはずだ」
そのつぶやきが、EchoTaskの出発点だった。
前職で覚えた教訓がひとつある。ユーザーに届けるまでの速度がすべてだ。完璧な設計を追い求めて3ヶ月かけるより、動くものを3週間で出して検証する方がずっといい——少なくとも、最初の段階は。
ハルトはRails一択だと決めていた。個人開発者がひとりで速く動くなら、Railsの「設定より規約」思想は最大の武器になる。データベースはPostgreSQL、フロントエンドはHotwire(Turbo + Stimulus)でSPA的な体験を実現する。インフラは月5ドルのVPS一台で十分だ。
3週間で動かした最初のプロダクト
Railsプロジェクトのスキャフォールド
# プロジェクト作成
rails new echo_task --database=postgresql --css=tailwind
cd echo_task
# コアモデルの生成
rails generate model User email:string:uniq name:string password_digest:string
rails generate model Task title:string description:text status:integer user:references due_date:date
rails generate model Comment body:text task:references user:references
# マイグレーション実行
rails db:create db:migrateデータベーススキーマ
# db/schema.rb (抜粋)
ActiveRecord::Schema[7.1].define(version: 2024_03_15_000001) do
enable_extension "plpgsql"
create_table "users", force: :cascade do |t|
t.string "email", null: false
t.string "name", null: false
t.string "password_digest", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
end
create_table "tasks", force: :cascade do |t|
t.string "title", null: false
t.text "description"
t.integer "status", default: 0, null: false
t.bigint "user_id", null: false
t.date "due_date"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_tasks_on_user_id"
t.index ["status"], name: "index_tasks_on_status"
end
add_foreign_key "tasks", "users"
endモデル定義
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :tasks, dependent: :destroy
has_many :comments, dependent: :destroy
validates :email, presence: true, uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true, length: { maximum: 50 }
before_save { self.email = email.downcase }
end# app/models/task.rb
class Task < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
validates :title, presence: true, length: { maximum: 100 }
enum :status, { pending: 0, in_progress: 1, completed: 2, archived: 3 }
scope :active, -> { where(status: [:pending, :in_progress]) }
scope :overdue, -> { active.where("due_date < ?", Date.current) }
scope :due_today, -> { active.where(due_date: Date.current) }
def overdue?
due_date.present? && due_date < Date.current && !completed?
end
endコントローラーとルーティング
# config/routes.rb
Rails.application.routes.draw do
root "dashboard#index"
resource :session, only: [:new, :create, :destroy]
resources :users, only: [:new, :create]
resources :tasks do
resources :comments, only: [:create, :destroy]
member do
patch :complete
patch :archive
end
end
end# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
before_action :authenticate_user!
before_action :set_task, only: [:show, :edit, :update, :destroy, :complete, :archive]
def index
@tasks = Current.user.tasks.active.order(due_date: :asc, created_at: :desc)
end
def create
@task = Current.user.tasks.new(task_params)
if @task.save
redirect_to @task, notice: "タスクを作成しました"
else
render :new, status: :unprocessable_entity
end
end
def complete
@task.completed!
redirect_to tasks_path, notice: "タスクを完了しました"
end
private
def set_task
@task = Current.user.tasks.find(params[:id])
end
def task_params
params.require(:task).permit(:title, :description, :due_date)
end
endデプロイ — 月5ドルのVPS 1台に全部乗せる
コードが動いた。次はデプロイだ。ハルトはDigitalOceanの一番安いDroplet(1 vCPU、1GB RAM、25GB SSD)を借りた。
構成はシンプルだ。Nginxがリバースプロキシとして外部リクエストを受け、Pumaに転送する。PostgreSQLは同じサーバー上で動く。すべてが1台に同居する。
インターネット
│
▼
[Nginx :80/:443] ← リバースプロキシ、静的ファイル配信
│
▼
[Puma :3000] ← Railsアプリサーバー(4ワーカー)
│
▼
[PostgreSQL :5432] ← データベース(同一サーバー内)
│
[/var/log/] ← ログファイル(同じディスク)
Nginx設定ファイル
# /etc/nginx/sites-available/echo_task
upstream echo_task {
server unix:///var/www/echo_task/shared/tmp/sockets/puma.sock fail_timeout=0;
}
server {
listen 80;
listen [::]:80;
server_name echotask.app www.echotask.app;
# HTTPSへリダイレクト
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name echotask.app www.echotask.app;
ssl_certificate /etc/letsencrypt/live/echotask.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/echotask.app/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# ワーカープロセス数(CPUコア数に合わせる)
worker_processes auto;
# リクエストタイムアウト
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
root /var/www/echo_task/current/public;
client_max_body_size 10m;
# 静的ファイルはNginxが直接返す(Pumaを通さない)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
gzip_static on;
}
# Railsへのプロキシ
location / {
proxy_pass http://echo_task;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
}Pumaの設定
# config/puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count
# ワーカーは0(シングルプロセス)で始める
# 1 vCPU のVPSでは複数ワーカーはメモリ不足になる
workers ENV.fetch("WEB_CONCURRENCY") { 0 }
port ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "development" }
# ソケット経由でNginxと接続
bind "unix:///var/www/echo_task/shared/tmp/sockets/puma.sock"
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
plugin :tmp_restartCapistranoデプロイ設定
# config/deploy.rb
lock "~> 3.17"
set :application, "echo_task"
set :repo_url, "git@github.com:haruto/echo_task.git"
set :deploy_to, "/var/www/echo_task"
set :branch, :main
set :linked_files, %w[.env]
set :linked_dirs, %w[log tmp/pids tmp/cache tmp/sockets storage]
set :keep_releases, 5
namespace :deploy do
after :finishing, :restart do
invoke "puma:restart"
end
endリリース初日、ハルトはTwitter(X)に一言投稿した。「タスク管理ツールを作りました。フィードバックください」。その日の夜、登録者は100人を超えた。ハルトは喜んだ。
最初の危機 — Tech Crunchの朝
リリースから3ヶ月が経ったある月曜日の午前7時。ハルトのiPhoneが連続して震えた。
最初はUptimeRobotからのメールだった。
Subject: [ALERT] echotask.app is DOWN
Time: 2024-06-17 06:52 JST
Status: HTTP 503 Service Unavailable
Response time: timeout (>30s)
Slackを開くと、ベータユーザーグループが荒れていた。
@haruto サービス落ちてる?
@haruto ページが開かない
@haruto タスク保存できない!大事なデータ消えてない?
状況を把握するためにサーバーへSSHした。
$ ssh deploy@203.0.113.10
Last login: Sun Jun 16 23:41:02 2024
$ uptime
06:58:01 up 92 days, 7:22, 1 user,
load average: 28.4, 26.1, 22.7ロードアベレージが28。1 vCPUのサーバーで通常は1以下であるべき数値だ。
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 25G 24G 580M 98% /
tmpfs 489M 4.0K 489M 1% /dev/shmディスクが98%。Railsのログファイルが膨らんでいた。
$ ls -lh /var/www/echo_task/shared/log/
-rw-r--r-- 1 deploy deploy 8.7G Jun 17 06:55 production.logログが8.7GB。ローテーション設定を入れていなかった。
$ psql echo_task_production -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active';"
count
-------
497
(1 row)PostgreSQLのアクティブ接続数が497。デフォルトの最大接続数(max_connections)は500。Pumaのスレッドがコネクションを使い切っていた。
$ tail -n 50 /var/www/echo_task/shared/log/production.log
...
PG::ConnectionBad: FATAL: sorry, too many clients already
PG::ConnectionBad: FATAL: sorry, too many clients already
ActiveRecord::StatementInvalid: PG::ConnectionBad: FATAL: sorry, too many clients already
app/controllers/tasks_controller.rb:8:in `index'
...ここでハルトはスマホで技術系ニュースを確認した。Tech Crunchに記事が出ていた。
「スタートアップEchoTaskがJIRAキラーを目指す——シンプルさで勝負するタスク管理の新星」
昨夜11時の掲載だった。一夜にして1万2,000人が登録し、同時アクセスが数百人規模に達していた。
WARNING
Tech Crunchスラッシュドット効果
メディアに取り上げられた直後の急激なトラフィック増加は「スラッシュドット効果」とも呼ばれる。準備なしに受けると、ほぼ確実にサービスが落ちる。しかし、この「落ちる経験」こそが、システム設計を本気で学ぶ最大の動機になる。
ハルトは応急処置を始めた。まずログをローテーションしてディスクを確保し、PostgreSQLのコネクション数を調整した。しかし根本的な解決には至らなかった。サーバーの限界は、スペックの限界だからだ。
なぜコードの品質だけでは足りないのか
ハルトのコードは、品質が低かったわけではない。Rubyのベストプラクティスに従い、N+1クエリを避け、バリデーションも適切に書かれていた。問題はコードではなく、アーキテクチャにあった。
コードの品質は「1人のユーザーがどれだけ快適に使えるか」を決める。システム設計は「100万人が同時に使っても耐えられるか」を決める。この2つはまったく別の問いだ。
どれだけ美しいRailsコードを書いても、それを動かすサーバーが1台しかなければ、そのサーバーのCPUとメモリとディスクが上限になる。コードをリファクタリングして処理が10%速くなっても、同時アクセスが10倍になればアウトだ。
システム設計が解くのは、以下のような問いだ。
- ユーザーが増えたとき、どうやってサーバーを増やすか?
- 1台のデータベースに書き込みが集中したとき、どう分散させるか?
- サーバーが突然落ちたとき、サービスをどう継続させるか?
- 世界中のユーザーに低レイテンシで画像を届けるにはどうするか?
- ログやメトリクスをどう収集し、異常を即座に検知するか?
これらはすべて、コードを書く技術とは独立した「システムを設計する技術」だ。
現在の構成と単一障害点(SPOF)
ハルトのEchoTaskが今どんな構成で動いているか、図で見てみよう。
シンプルで美しい構成だ。しかし、単一障害点(SPOF: Single Point of Failure) だらけでもある。
INFO
単一障害点(SPOF)とは
システム内の1つのコンポーネントが故障しただけで、システム全体が機能停止してしまう箇所のこと。信頼性の高いシステム設計では、SPOFをなくすことが基本方針になる。「どこが1点だけ落ちたらサービス全体が死ぬか?」という目で設計を見直すのが第一歩だ。
この1台構成では、以下のどれが起きてもサービス全体が止まる。
Nginxが落ちたら? リバースプロキシが停止するため、すべてのHTTPリクエストがタイムアウトする。ユーザーは503エラーや接続拒否を受ける。Pumaとデータベースが正常でも、入り口が閉まっているのでまったく意味がない。
PostgreSQLが落ちたら? アプリ自体は起動しているが、データベースへのクエリがすべて失敗する。ユーザーはログインもできず、タスクの一覧も取得できない。最悪の場合、クラッシュしたDBのデータが破損するリスクもある。
ディスクが溢れたら? 今回のハルトのケースがまさにこれだ。ディスクがフルになると、Railsはログを書けなくなり、PostgreSQLも新しいデータを書けなくなる。アプリがエラーで落ち始める。セッションファイルも書けないため、新しいログインもできなくなる。
サーバー自体がハードウェア故障したら? VPS提供者のハイパーバイザー障害やネットワーク障害でサーバーが消えれば、データも含めてすべてが失われる可能性がある。バックアップがなければ復旧不可能だ。
CPUが100%に貼り付いたら? 重いクエリや無限ループが走ると、CPUがすべて消費される。他のリクエストがレスポンスできなくなり、タイムアウトが連鎖する。SSH接続すら重くなる。
設計における永遠のトレードオフ
システム設計には「完璧な正解」は存在しない。何かを改善すれば、何かを犠牲にする。この構造的なジレンマを「トレードオフ」と呼ぶ。
経験の浅いエンジニアは、トレードオフを「どちらかが正しい」という問いだと誤解しがちだ。しかし熟練したアーキテクトは、「いまこの状況では、何を優先するか」という問いとして捉える。
トレードオフ1: シンプルさ vs 堅牢性
1台のサーバーにすべてを乗せる構成は、極めてシンプルだ。設定ファイルが少なく、デバッグしやすく、インフラコストが最小で、一人の開発者が全体を把握できる。
しかし堅牢ではない。SPOFが至るところにあり、スケールの余地がない。
複数台構成、ロードバランサー、レプリカDB——これらを追加すれば堅牢になる。しかし、設定ファイルが増え、デプロイが複雑になり、「どのサーバーにSSHすればいいか」という問いが生まれ、コストも上がる。
判断軸: ユーザー数が100人のときと100万人のときでは、正解が変わる。ハルトが最初に1台構成を選んだのは正しい判断だった。問題は、それを変えるタイミングを見誤ったことだ。
トレードオフ2: 一貫性 vs 可用性
ECサイトの在庫表示を例に考えよう。
「一貫性を優先」する設計では、常に最新の在庫数をデータベースから読む。在庫が5個なら、世界中のどのユーザーが見ても「残り5個」と表示される。しかし、DBが重くなったときにレスポンスが遅くなり、最悪DBが落ちるとページ自体が表示できなくなる。
「可用性を優先」する設計では、Redisにキャッシュした在庫数を返す。DBが落ちてもキャッシュから返せるので、ページは表示され続ける。しかし、実際の在庫が0になっていても、キャッシュには「5個」と残っている可能性がある。10秒〜数分のズレが許容できるかどうかは、ビジネス要件による。
判断軸: 「数分古いデータを見せることで生じる損害」と「サービスが完全停止することで生じる損害」を比較する。タスク管理アプリなら多少古くてもいい。しかし金融系なら1秒でもズレてはいけない。
トレードオフ3: コスト vs パフォーマンス
EchoTaskを高速化したいなら、CDNを使って静的ファイルを世界中のエッジに配置し、Redisでデータベースクエリをキャッシュし、ElastiCacheのクラスターを複数台構成にし、RDSをMulti-AZで冗長化すればいい。レスポンスタイムは劇的に改善される。
しかし、月5ドルのVPSは月数百〜数千ドルのAWSインフラになる。100人のユーザーしかいないサービスで、この投資は回収できない。
判断軸: パフォーマンス要件とビジネス規模のバランス。「どこまで遅くてもユーザーが離れないか」を計測し、そのラインを下回る最小コストの構成を選ぶ。
トレードオフ4: 開発速度 vs 運用品質
マイクロサービスアーキテクチャは、各サービスを独立してデプロイでき、障害が局所化し、技術スタックを自由に選べる。大規模チームには向いている。
しかし、サービス間通信のデバッグは難しく、分散トレーシングの仕組みが必要になり、Dockerオーケストレーション(ECS/Kubernetes)の知識が求められる。3人チームでマイクロサービスを運用すると、コア機能の開発よりインフラ管理に時間を取られる。
判断軸: チームの規模と、ドメインの複雑さ。ハルトのような個人開発では、モノリシックRailsが正解だ。
INFO
「今の規模」に合った設計を選ぶ
Google・Amazonのようなシステム設計パターンを最初から採用する必要はない。彼らがそのアーキテクチャを選んだのは、その規模が必要だったからだ。重要なのは「今の規模に合っていて、次の10倍の規模に移行しやすい設計」を選ぶことだ。
この本で学ぶこと
この本は、ハルトのEchoTaskが1台のVPSから大規模分散システムへと進化する物語を通じて、実践的なシステム設計を学ぶ。各章で、ハルトが直面する具体的な問題と、その解決策としてのシステム設計パターンを学んでいく。
| 章 | テーマ | ハルトが直面する問題 | 学ぶ設計パターン |
|---|---|---|---|
| 2章 | スケーリング | VPS 1台の限界 | 垂直・水平スケーリング |
| 3章 | ロードバランシング | 複数サーバーへの振り分け | ALB / Nginx upstream |
| 4章 | データベース基礎 | 読み込みが遅い | インデックス・クエリ最適化 |
| 5章 | DBレプリケーション | 書き込みがボトルネック | Primary / Replica 構成 |
| 6章 | キャッシュ | 同じクエリが繰り返される | Redis / Memcached |
| 7章 | CDN | 静的ファイルが遅い | CloudFront / S3 |
| 8章 | 非同期処理 | メール送信でタイムアウト | Sidekiq / SQS |
| 9章 | マイクロサービス入門 | モノリスの限界 | サービス分割の判断軸 |
| 10章 | API設計 | サービス間通信 | REST / gRPC |
| 11章 | データ一貫性 | 分散トランザクション | Saga パターン |
| 12章 | 高可用性 | AZ障害に耐える | Multi-AZ / フェイルオーバー |
| 13章 | モニタリング | 障害を検知できない | CloudWatch / Datadog |
| 14章 | セキュリティ | SQLインジェクション・認証 | VPC / IAM / WAF |
| 15章 | 設計の思考法 | どの設計を選ぶか | トレードオフ分析 |
INFO
前提知識について
この本はRailsの基本的な読み書きができることを前提とする。ActiveRecord、ルーティング、コントローラーの概念を知っていれば十分だ。AWSの知識は不要——必要な概念はその都度説明する。
まとめ — 痛みから始まる学び
ハルトの物語は、多くのエンジニアが経験する「成長の痛み」から始まった。
コードは動いている。テストも通っている。しかし、ユーザーが増えると壊れる。
この痛みは、実は最高の学習機会だ。本番環境で起きた障害ほど、記憶に深く刻まれる教訓はない。ハルトが今夜経験したこと——ディスクフル、コネクション枯渇、ロードアベレージの爆発——は、どんな技術書よりも生々しく「システム設計が必要な理由」を教えてくれる。
この本を読み終えるころ、あなたはハルトとともに以下のことができるようになる。
- システムのボトルネックを特定し、適切なスケーリング戦略を選べる
- 単一障害点をなくし、障害に強い構成を設計できる
- トレードオフを理解し、状況に応じた判断ができる
- AWSのサービスを組み合わせて本番品質のインフラを構築できる
- モニタリングとアラートで障害を早期発見できる
次の章では、スケーリングの2つの基本戦略——垂直スケーリングと水平スケーリング——を学ぶ。ハルトがサーバーのスペックをアップグレードして得た「一時的な安堵」と、それでも解決できなかった「本質的な問題」から始めよう。
WARNING
この章のキーポイント
- 1台構成はシンプルで始めやすいが、必ずスケーリングの壁にぶつかる
- 単一障害点(SPOF)はシステムのどこにでも潜んでいる——Nginx、DB、ディスク、サーバー本体
- コードの品質と、システムの設計品質は別物だ。両方が必要
- トレードオフは「どちらが正しいか」ではなく「今の状況でどちらを優先するか」という問い
- 最初から完璧な設計を目指す必要はない。「今の規模に合っていて、次の10倍に移行しやすい」設計を選ぶ