2-1. net/http - 標準ライブラリで本番運用できるHTTPサーバー
所要時間: 40-60分(がっつりなら2セッション分) コミット内容:
~/learn/go/level2/day01/に最小HTTPサーバーと本番用http.Server設定例をコミット
このレッスンのゴール
-
http.Handlerインターフェース(ServeHTTP(w, r))を自分で実装できる - 関数を
http.HandlerFunc経由で Handler に変換できる -
ResponseWriterの Header/Write/WriteHeader の順序を守れる - 本番用
http.Serverのタイムアウト4種類を設定できる - Slowloris 対策と
MaxBytesReaderが打てる
なぜ学ぶか - Level 2 の出発点として
「Express の代わりに gin? echo? それとも標準?」 - Go 1.22 以降は 標準 net/http だけで本番運用が現実的になった。Docker、Kubernetes、Prometheus も net/http ベース。ここを飛ばしてフレームワークに行くと、トラブルシューティングで内部が辿れなくなる。Level 2 全体(ルーティング、JSON、ミドルウェア、エラー処理)が net/http のインターフェース設計を理解している前提で進む。
前章とのつながり
1-6_構造体とメソッド で扱った 「インターフェースは1メソッドの契約」 の感覚が、ここで http.Handler という形で具現化する。さらに 1-7_CLIアプリ で書いた「サブコマンド分岐」の設計が、本章では「メソッド + パスごとのハンドラ分岐」に発展する。
これができると何が嬉しいか
go run main.goで本番に近いサーバー が立つ - フレームワーク不要- OSS の Go サーバー実装が読める - Kubernetes / Docker のソースに突撃できる
- Slowloris で落ちないサーバー が書ける - セキュリティの基本線
ストーリー導入: net/http は「インターフェース1枚で組み立てるレゴ」
http.Handler は ServeHTTP(w, r) という1メソッドだけの契約。これを実装すれば、関数でも構造体でも何でもハンドラになれる。ミドルウェアは「ハンドラを受け取って、別のハンドラを返す関数」。レゴブロックのように 小さな部品を組み合わせるだけで、ログ・認証・タイムアウトを後付け できる。これが Go の「シンプルな抽象が大きな表現力を生む」哲学の代表例。
大前提: なぜ Go の net/http を学ぶか
Node.js + Express、Python + Flask、Ruby + Rails のように、ほとんどの言語は「Webフレームワーク」を入れて初めて本番サーバーが立つ。Go は違う。標準ライブラリ net/http だけで、Kubernetes・Docker・Terraform・Prometheus といった世界的に動いているOSSの大半がHTTPを喋っている。
Kubernetes の API サーバーも、Docker デーモンも、Prometheus も
net/httpベース。Web フレームワークは使っていない。
つまり、net/http を理解する = 本番OSSの「読み解ける目」を手に入れるということ。これを飛ばしてフレームワーク(gin / echo)に行くと、トラブった時に内部を辿れなくなる。
FE出身(TypeScript)の人へ: Express の app.get('/', (req, res) => {}) に相当する処理を、Go は「言語に内蔵された関数」として持っている。require('express') が要らない世界。
セッション①: net/http の設計思想と基本(30分)
0. 録画スタート&作業ディレクトリ
mkdir -p ~/log ~/learn/go/level2/day01
cd ~/learn/go/level2/day01
script ~/log/go_level2_day01.log
# Go モジュール初期化
go mod init example.com/level2/day011. まずは動かす - 最小HTTPサーバー
// main.go
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World")
})
http.ListenAndServe(":8080", nil)
}go run main.go
# 別ターミナルで
curl -i http://localhost:8080/このコードで何が起きているか
たった8行で、本番でも(少し手を加えれば)使えるHTTPサーバーが立つ。Node.js なら
expressをnpm installする所だが、Go は標準ライブラリだけ。内部の流れ:
http.HandleFunc("/", fn)で「/というパスに来た時の処理」をデフォルトの ServeMux に登録http.ListenAndServe(":8080", nil)で 8080 ポートで listen を開始。第2引数nilは「デフォルトの ServeMux を使う」の意味- リクエストが来るたび、Go ランタイムは goroutine を1本立てて ハンドラを実行する
Node + Express との対比
Express の同等コード:
import express from 'express'; const app = express(); app.get('/', (req, res) => res.send('Hello')); app.listen(8080);表面的には似ているが、内部モデルが根本的に違う:
観点 Node.js / Express Go / net/http 並行モデル シングルスレッド + イベントループ リクエストごとに goroutine(数千同時OK) ブロッキング処理 NG(イベントループ詰まる) OK(goroutine 単位で詰まるだけ) CPU バウンド処理 worker_threads が必要 普通に書ける フレームワーク必須度 ほぼ必須 標準で本番運用可能 FE出身者が最初にハマるのは「
time.Sleep(5*time.Second)を書いても他のリクエストを止めない」という点。Node の感覚だと「サーバー全体が止まる」と思いがちだが、Go は止まらない。
2. http.Handler インターフェースが全ての中心
// http.Handler は標準ライブラリ内で以下のように定義されている
type Handler interface {
ServeHTTP(w ResponseWriter, r *Request)
}http.Handler とは「HTTPリクエストに応答できる何か」の最小契約
このインターフェースを満たすあらゆる型がHTTPハンドラになる。これが Go HTTP 設計の核心。
たった1メソッド
ServeHTTP(w, r)を実装すれば、その型はサーバーに登録できる。クラス継承やextends Controllerのような重い構造はない。TypeScript で言うと:
interface Handler { serveHTTP(w: ResponseWriter, r: Request): void; }Go では
implementsキーワードがなく、メソッドを実装したら自動でインターフェースを満たす(structural typing)。
// 自前で Handler インターフェースを満たす型を作る
package main
import (
"fmt"
"net/http"
)
type GreetingHandler struct {
Greeting string
}
// このメソッドを持っているだけで、GreetingHandler は http.Handler になる
func (h *GreetingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s, %s\n", h.Greeting, r.URL.Path[1:])
}
func main() {
mux := http.NewServeMux()
mux.Handle("/", &GreetingHandler{Greeting: "Hello"})
mux.Handle("/bonjour/", &GreetingHandler{Greeting: "Bonjour"})
http.ListenAndServe(":8080", mux)
}設計思想: なぜ「インターフェース1つ」で済むのか
Go の格言「Accept interfaces, return structs」。標準ライブラリは小さなインターフェースを受け取って、巨大な機能を組み立てる。
http.Handlerは1メソッド。io.Readerも1メソッド。io.Writerも1メソッド。1メソッドの小さなインターフェースを組み合わせるのが Go 流。これにより、ハンドラの差し替え・テスト・ミドルウェアでのラップが極めて簡単になる。後の 2-4 ミドルウェア でこの恩恵を体感する。
3. http.HandlerFunc アダプタ - 関数を Handler に変換
// 標準ライブラリ内の定義
type HandlerFunc func(ResponseWriter, *Request)
// HandlerFunc 自身に ServeHTTP メソッドが生えていて、それは「自分自身を呼ぶ」だけ
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}HandlerFunc は「関数を Handler 扱いするための糊」
普通の関数
func(w, r)をhttp.Handlerインターフェースに 変換 するアダプタ。関数に
ServeHTTPメソッドを生やすという「型の上にメソッドを定義」できる Go ならではの仕掛け。これにより、関数型もインターフェースを満たせる。handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) mux.Handle("/health", handler)よく使う
http.HandleFunc(path, fn)は、内部でmux.Handle(path, http.HandlerFunc(fn))を呼んでいるだけ。便利関数。
設計思想: なぜクラス継承が要らないか
Express の
app.get,app.useは内部で配列にコールバックを積んでいる。Rails のApplicationControllerは継承を強制する。Go はそのどちらでもなく、「
ServeHTTPという1メソッドを持つ何か」を受け入れる。これにより:
- 関数だけでも書ける(軽量)
- 構造体に状態を持たせたハンドラも書ける(DB コネクション・ロガーを持たせる時に有用)
- 既存型を後付けで Handler 化できる
- テストではモック構造体を渡すだけ
シンプルな抽象が広い表現力を生む典型例。
io.Readerio.Writererrorも同じ思想で作られている。
4. ResponseWriter と *Request の本質
func handler(w http.ResponseWriter, r *http.Request) {
// リクエスト情報
method := r.Method // "GET", "POST", ...
path := r.URL.Path // "/users/42"
query := r.URL.Query() // url.Values 型のマップ
host := r.Host // "api.example.com"
ua := r.UserAgent() // User-Agent ヘッダ
body := r.Body // io.ReadCloser(後で必ず Close)
_ = method; _ = path; _ = query; _ = host; _ = ua; _ = body
// レスポンス書き込み
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}ResponseWriter は「インターフェース」、Request は「構造体ポインタ」
なぜ片方はインターフェース、片方はポインタなのか。これは設計上の理由がある。
- ResponseWriter (interface): レスポンス生成は 戦略を差し替えたい ことが多い(圧縮、バッファリング、ロギング、HTTP/2 対応)。インターフェースなら自由にラップできる
- Request (struct pointer): リクエストは「来たデータ」であり、ハンドラ間で 同一のものを参照 したい。コピーされたら困る(Body のストリームが分裂する)。ポインタが自然
ResponseWriter の3メソッド(だけ)
type ResponseWriter interface { Header() Header // レスポンスヘッダのマップを返す Write([]byte) (int, error) // ボディに書く WriteHeader(statusCode int) // ステータスコードを送る }たった3つ。これだけで全 HTTP レスポンスが組める。
落とし穴: WriteHeader の順序
w.WriteHeader()を呼んだ後にヘッダを変更してもクライアントには届かない。// NG: ヘッダ送信後に Content-Type を設定しても遅い w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // 無視される w.Write([]byte(`{"ok":true}`))正しい順序: ヘッダ設定 → WriteHeader → Write
// OK w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`))
アンチパターン: WriteHeader を2回呼ぶ
w.WriteHeader(http.StatusOK) // ... 何か処理 ... if err != nil { w.WriteHeader(http.StatusInternalServerError) // 警告: superfluous WriteHeader call }なぜNG: HTTPプロトコル上、ステータスコードは1リクエストにつき1回しか送れない。2回目は無視され、
net/httpがログに警告を吐く。早期 return か、フラグで状態管理する。
r.Body は必ず Close する(POST/PUT 時)
defer r.Body.Close() body, err := io.ReadAll(r.Body)Body は
io.ReadCloser。閉じないと コネクションが Keep-Alive 再利用されない → ファイルディスクリプタとメモリリーク。HTTP サーバーが defer してくれる場合もあるが、自分でも defer する癖を付ける。
セッション②: 本番運用 - タイムアウトとセキュリティ(30分)
5. デフォルト http.ListenAndServe は「本番では使うな」
// よく見るコード
http.ListenAndServe(":8080", nil)http.ListenAndServe(":8080", nil) は本番禁止
このコードには タイムアウトがゼロ秒も設定されていない。つまり:
- 悪意あるクライアントが1バイトずつ送り続けるとコネクションが永遠に開いたまま
- 体感では「サーバーが応答しない」状態に陥る
- ファイルディスクリプタを食い尽くす → サーバー全体が新規接続を受け付けなくなる
これが Slowloris 攻撃 の入口。2009年に有名になり、今でも素のHTTPサーバーへの基本的な攻撃手法として現役。
6. 本番用 http.Server の設定
package main
import (
"context"
"log/slog"
"net"
"net/http"
"os"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
// 接続してから「リクエストヘッダ全て」を送り終わるまでの上限
ReadHeaderTimeout: 5 * time.Second,
// リクエスト本体(ボディ含む)全体を読み終わるまでの上限
ReadTimeout: 30 * time.Second,
// レスポンス書き込みの上限(ハンドラの処理時間込み)
WriteTimeout: 30 * time.Second,
// Keep-Alive で接続を維持するアイドル時間の上限
IdleTimeout: 120 * time.Second,
// リクエストヘッダの最大サイズ(デフォルト 1MB)
MaxHeaderBytes: 1 << 20,
// ベースとなる context
BaseContext: func(_ net.Listener) context.Context {
return context.Background()
},
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("starting server", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "err", err)
os.Exit(1)
}
}各タイムアウトの役割
タイムアウト 何の上限か 推奨値の感覚 ReadHeaderTimeoutヘッダ送信完了まで 5-10秒(Slowloris 対策の主役) ReadTimeoutボディ送信完了まで 30秒-1分(大ファイルアップロードなら長め) WriteTimeoutレスポンス送信完了まで 30秒-1分(ハンドラ実行時間 + 送信) IdleTimeoutKeep-Alive で次のリクエスト待ち 2分前後(LB の Idle と合わせる) 本番では全部設定する。デフォルトはゼロ = 無制限 = 危険。
ReadHeaderTimeout が最も重要な理由
Slowloris 攻撃は「ヘッダを1バイトずつ送って接続を保持」する。
ReadHeaderTimeoutを5秒に設定すれば、ヘッダ全体を5秒以内に送れない接続は 即切断。AWS ALB / GCP LB の前段でカバーされることも多いが、自分のサーバーでも防御線を張るのが正解(Defense in Depth)。
7. 内部の本質: goroutine per request
クライアントA ──┐
クライアントB ──┼─→ Listener ─→ Accept() ループ
クライアントC ──┘ │
├─ go func() { ハンドラA } ← goroutine 1
├─ go func() { ハンドラB } ← goroutine 2
└─ go func() { ハンドラC } ← goroutine 3
1リクエスト = 1 goroutine
Go サーバーは すべてのリクエストを独立した goroutine で処理する。これが Node.js との根本的な違い。
結果:
- 同期コードがそのまま書ける(async/await 不要)
- 一つのリクエストでブロッキングしても他に影響なし
- 数千〜数万の同時接続を1プロセスで捌ける
goroutine は OS スレッドより遥かに軽い(初期スタック 2KB)。数万本立てても OS スレッドは数十本で済む(M:N スケジューラ)。
アンチパターン: ハンドラ内でグローバル変数を直接書き換える
var counter int // グローバル func handler(w http.ResponseWriter, r *http.Request) { counter++ // 競合状態! fmt.Fprintln(w, counter) }なぜNG: 同時に1000リクエストが来たら、
counter++は 複数 goroutine から同時実行される。これは race condition(競合状態)で、go run -raceで実行するとすぐ検出される。修正:
sync/atomicのatomic.AddInt64か、sync.Mutexで保護。これは Level 3 並行処理で深掘る。
8. 実装: タイムアウト付きエコーサーバー
// echo_server.go
package main
import (
"context"
"io"
"log/slog"
"net"
"net/http"
"os"
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
mux := http.NewServeMux()
mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
// ボディサイズ制限(DoS 対策)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body too large or read error", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write(body)
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
BaseContext: func(_ net.Listener) context.Context {
return context.Background()
},
}
logger.Info("starting server", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed", "err", err)
os.Exit(1)
}
}# 動作確認
go run echo_server.go &
curl -X POST -d "hello" http://localhost:8080/echo
# → hello
# 1MB 超のリクエストはエラー
dd if=/dev/zero bs=1M count=2 2>/dev/null | curl -X POST --data-binary @- http://localhost:8080/echo
# → "body too large or read error" (400)
curl http://localhost:8080/health
# → okhttp.MaxBytesReader が必須な理由
Body 読み込み中に メモリを食い尽くす DoS 攻撃 がある。攻撃者が Content-Length を偽って巨大データを送ると、
io.ReadAll(r.Body)が OOM する。
http.MaxBytesReader(w, body, 1<<20)で 読める上限を1MBに制限。超えると自動的にエラーを返し、コネクションも閉じる。鉄則: 全てのPOST/PUTエンドポイントに MaxBytesReader。
落とし穴: タイムアウトと SSE / ロングポーリング
WriteTimeoutを30秒に設定すると、Server-Sent Events (SSE) や WebSocket、ロングポーリングが30秒で切断される。対策:
- SSE 専用のエンドポイントは別
http.Serverで運用する- もしくは
WriteTimeoutを0(無制限)にして、ハンドラ内でcontext.WithTimeoutで個別管理- WebSocket は
http.Hijackerで TCP 接続を奪うので、HTTP タイムアウトの管轄外
9. セキュリティチェックリスト(本番投入前)
本番投入前の最低ライン
ReadHeaderTimeout設定済み(Slowloris 対策)ReadTimeout/WriteTimeout設定済みMaxBytesReaderでボディ上限あり- エラーメッセージにスタックトレース・SQL 文・ファイルパスを含めない
http.Errorでクライアントに返す文字列を吟味(内部情報を漏らさない)- TLS 必須: 平文 HTTP は本番禁止(
http.Server.ListenAndServeTLS)- レート制限(ミドルウェアで)
- Graceful shutdown(Level 3 で扱う)
これを守らないだけで、CTF 初心者でも落とせるサーバーになる。
練習課題
/timeエンドポイントを作り、JSON で現在時刻を返す(time.Now().Format(time.RFC3339))/slowエンドポイントでtime.Sleep(10 * time.Second)し、別ターミナルからcurlを同時に投げて「他のリクエストが詰まらない」ことを確認WriteTimeoutを3秒に設定して/slowを呼ぶ → タイムアウトでクライアント側がエラーを受け取ることを確認- 自前 struct で
http.Handlerを実装し、コンストラクタで*slog.Loggerを受け取ってServeHTTPでロギング http.MaxBytesReaderを外して/echoを実装し、dd if=/dev/zero bs=1M count=100 | curl ...で挙動を観察(メモリ消費に注意、すぐ止める)
締め: git で証跡を残す
cd ~/learn/go/level2/day01
git init
git add .
git commit -m "feat(go-http): 標準ライブラリで本番タイムアウト付きHTTPサーバー実装"
exit # script から抜けるアンチパターン集 - やらかし事例
net/http の定番事故
1.
http.ListenAndServe(":8080", nil)を本番で使う タイムアウトゼロ秒 = Slowloris 直撃ライン。CTF 初心者でも落とせる。2.
WriteHeaderを複数回呼ぶw.WriteHeader(200) if err != nil { w.WriteHeader(500) } // superfluous warningHTTP 上はステータスコード1回限定。早期 return か フラグ管理。
3.
r.Body.Close()忘れ Keep-Alive で接続再利用されず、FD 漏れ。defer r.Body.Close()を癖に。4. ハンドラ内グローバル変数の競合書き込み
var counter int func h(w, r) { counter++ } // race condition
go run -raceで必ず検出。sync/atomicかsync.Mutex。5. エラーレスポンスに内部情報を含める
http.Error(w, err.Error(), 500) // SQL 文・パスがクライアントへ漏洩ユーザー向けは「サーバーエラー」、内部はログにだけ。
対比表で違いを明確化
http.Handlervshttp.HandlerFunc
観点 http.Handlerhttp.HandlerFunc型 インターフェース 関数型 実装 構造体に ServeHTTPメソッド関数そのもの 状態 フィールドで持てる(DB/ロガー) クロージャでキャプチャ 用途 状態付きハンドラ 軽量ハンドラ
Node.js (Express) vs Go (net/http)
観点 Express net/http 並行モデル イベントループ 1リクエスト1 goroutine ブロッキング NG(全体停止) OK(goroutine単位) フレームワーク 必須 不要 タイムアウト デフォルト無し 明示設定(4種)
自己評価チェックリスト
手を動かせた
- 最小 HTTP サーバーを
go runで起動 - 構造体ハンドラで
ServeHTTPを実装した -
http.HandlerFuncで関数をハンドラ化した -
http.Serverで4タイムアウトを設定 -
MaxBytesReaderで巨大ボディを弾いた
説明できる
-
http.Handlerインターフェースの定義を空で書ける -
WriteHeader後にヘッダ変更が無効になる理由を1分で -
ReadHeaderTimeoutと Slowloris の関係 - 1リクエスト1 goroutine モデルが Node と違う理由
やらかし回避
-
nilハンドラ + デフォルト ListenAndServe を本番禁止と言える -
r.Body.Close()を defer で書く習慣 - エラーメッセージで内部情報を漏らさない
詰まった時のチートシート
| やりたいこと | コード |
|---|---|
| 最小HTTPサーバー | http.ListenAndServe(":8080", nil)(本番は別物) |
| ハンドラ登録 | mux.HandleFunc("/path", fn) |
| ステータス送る | w.WriteHeader(http.StatusXxx) |
| ヘッダ設定 | w.Header().Set("X-Foo", "bar")(WriteHeader 前に) |
| JSON 返す | w.Header().Set("Content-Type", "application/json") → json.NewEncoder(w).Encode(obj) |
| Body 読む | defer r.Body.Close() → io.ReadAll(r.Body) |
| Body サイズ制限 | r.Body = http.MaxBytesReader(w, r.Body, 1<<20) |
| メソッド分岐 | if r.Method != http.MethodPost { ... }(Go 1.22+ は ServeMux で書ける) |
「実務OK」基準
http.Handlerで構造体ハンドラが書ける: DB / Logger を持たせた状態付きハンドラ- タイムアウトを4種類すべて設定した
http.Serverを即書ける - Slowloris と
ReadHeaderTimeoutの関係を後輩に説明できる r.Body.Close()とhttp.MaxBytesReaderを反射的に書く- 「素の
net/httpで十分」と言えるだけの自信を持つ
さらに深掘りするなら
- 標準ライブラリのソース:
src/net/http/server.go-ServeHTTPの実装を読むと開眼する - 公式 blog: The Go Blog - net/http 関連記事
- 書籍: 『Go 言語による並行処理』(Katherine Cox-Buday) - HTTP サーバーが goroutine をどう使うかの背景
- OSS 実例: Kubernetes の
staging/src/k8s.io/apiserver/pkg/server/-net/http上に API Server を構築している好例 - Cloudflare ブログ: “The complete guide to Go net/http timeouts” - タイムアウト解説の決定版
次のレッスン
2-2 ルーティング で ServeMux の使い倒し方と、chi / gin / echo の比較に進む。Go 1.22 で大幅強化されたルーティング機能(メソッド指定、パスパラメータ)を本気で学ぶ。
つながりの予告
- 本章の
mux.HandleFunc("/", h)は次章でmux.HandleFunc("GET /todos/{id}", h)に発展 http.Handlerインターフェースは 2-4_ミドルウェア で「ハンドラを返す関数」として核心になる- タイムアウト・MaxBytesReader は 3-7_本番準備 のチェックリストに再登場