mybook

プロローグ — 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_restart

Capistranoデプロイ設定

# 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が今どんな構成で動いているか、図で見てみよう。

Loading diagram...

シンプルで美しい構成だ。しかし、単一障害点(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倍に移行しやすい」設計を選ぶ