プロセスとメモリ
この部の 3 / 12 章 ・ 全体で 24 / 76 章 ・ 読了目安 45 分
- OOMKilled と too many open files の原因を切り分けられる
- SIGTERM を受けて安全に終了する必要性を説明できる
- goroutine が軽い理由を説明できる
ここまでの章で、説明せずに使ってきた言葉があります。
- Pod が OOMKilled で落ちた(コンテナと Kubernetes)
- goroutine は軽いので何万個でも作れる(Go)
- SIGTERM を受けたらグレースフルに終了する(ハンズオン: サービスを1本作って動かす)
too many open filesというエラー
これらは全部、OS がプログラムをどう動かしているかの話です。 知らなくてもコードは書けますが、障害の原因が分からなくなります。
この章は、そこだけを埋めます。OS の教科書ではないので、 実務で必要になるところだけを扱います。
プログラムとプロセス
ディスクにある実行ファイルは、ただのバイト列です。 それを OS が読み込んで動いている状態にしたものがプロセスです。
./server ディスク上のファイル(静止している)
↓ 実行
プロセス(PID 4821) メモリを持ち、CPU を割り当てられ、動いている
同じ実行ファイルから、プロセスは何個でも作れます。 それぞれが独立したメモリを持ちます。
仮想メモリ — 各プロセスは世界を独り占めしていると思っている
プロセスは「アドレス 0x00400000 に自分のコードがある」と認識していますが、
それは本物のメモリの番地ではありません。
プロセスA が見るアドレス → [OS + CPU の変換] → 実際の物理メモリ
プロセスB が見るアドレス → [OS + CPU の変換] → 別の物理メモリ
この仕組みのおかげで、
- プロセス同士が互いのメモリを壊せない(隔離)
- 物理メモリより大きなアドレス空間を扱える
- 足りなければディスクに退避できる(スワップ)
「別のプロセスの変数を直接書き換える」ができないのは、これが理由です。 だからプロセス間で情報をやり取りするには、 ファイル・ソケット・パイプといった OS の仕組みを通す必要があります。
ps aux | grep node の | は、
左のプロセスの標準出力を、右のプロセスの標準入力につなぐよう OS に頼んでいます。
プロセスが隔離されているからこそ、こういう明示的な接続が必要になります。
プロセスのメモリは4つに分かれている
高位アドレス
┌─────────────────┐
│ スタック │ ← 関数呼び出しごとに積む。下に伸びる
│ ↓ │
│ │
│ ↑ │
│ ヒープ │ ← 実行中に確保する。上に伸びる
├─────────────────┤
│ データ領域 │ ← グローバル変数・定数
├─────────────────┤
│ コード領域 │ ← 機械語(読み取り専用)
└─────────────────┘
低位アドレス
実務で効いてくるのは、スタックとヒープの違いです。
スタックとヒープ
| スタック | ヒープ | |
|---|---|---|
| 何を置くか | 関数のローカル変数・引数・戻り先 | 実行時にサイズが決まるデータ |
| 確保の速さ | 非常に速い(ポインタを動かすだけ) | 遅い(空き領域を探す) |
| 解放 | 関数を抜けたら自動 | GC か手動 |
| サイズ | 小さい(数MB) | 大きい(メモリの許す限り) |
func f() {
x := 42 // スタック(関数を抜けたら消える)
s := make([]int, 1e6) // 中身はヒープ(大きい・寿命が読めない)
_ = s
}スタックは小さくて固定的なので、深すぎる再帰で溢れます。
function f(n) { return f(n + 1); }
f(0); // RangeError: Maximum call stack size exceeded「無限再帰でクラッシュ」の正体はこれです。 再帰の深さがデータ量に比例する処理(木の探索など)を書く時は、 入力の深さに上限があるかを確認してください。
GC(ガベージコレクション)
ヒープに置いたデータを、いつ解放するか。 Go・JavaScript・Java は GC が自動で回収します。
GC は「どこからも参照されていないもの」を探して解放します。 そのため、次のことが起きます。
- メモリリークは「解放し忘れ」ではなく「参照し続けている」で起きる
- GC が動く瞬間、わずかに処理が止まる(stop-the-world)
// これはリークする。listeners が増え続け、GC が回収できない
const listeners = [];
function onRequest(handler) { listeners.push(handler); }Go は、変数をスタックとヒープのどちらに置くかをコンパイラが判断します。 関数の外に参照が漏れるものだけがヒープに行きます(エスケープする、と言います)。
go build -gcflags='-m' ./... # どれがヒープに逃げたかを表示する性能を詰める段階で使う道具ですが、 「なぜこの変数がヒープに行くのか」を追えると、GC の負荷を減らせます。
スレッド — メモリを共有する実行の流れ
1つのプロセスの中に、複数の実行の流れを持てます。これがスレッドです。
プロセス(メモリを1つ持つ)
├── スレッド1 ← スタックは別々
├── スレッド2 ← しかしヒープとグローバルは共有
└── スレッド3
プロセスは隔離されるが、スレッドは共有する。 ここが決定的な違いです。
だから、Goのデータ競合が起きます。
counter := 0
// 2つの goroutine が同時に counter++ を実行すると、
// 読み → 足す → 書く の途中で割り込まれ、片方の結果が消える共有しているものを同時に触るから壊れる。共有していなければ壊れません。 これが「メモリを共有して通信するのではなく、通信してメモリを共有せよ」という Go の標語の意味です。
コンテキストスイッチ
CPU のコア数より多くのスレッドは、切り替えながら動きます。 切り替えには、レジスタの保存・復元と、OS のスケジューラの処理が必要です。
これが goroutine が軽い理由につながります。
| OS スレッド | goroutine | |
|---|---|---|
| スタック | 最初から数MB確保 | 2KB から始まり、必要に応じて伸びる |
| 切り替え | OS のスケジューラ(カーネルに入る) | Go ランタイムが行う(ユーザー空間で完結) |
| 現実的な数 | 数千 | 数十万 |
goroutine は OS スレッドの上に Go ランタイムが実装した軽量な実行単位です。 「goroutine を10万個作ってよい」のは、OS を経由しないからです。
システムコール — カーネルに頼む
プログラムは、自分ではファイルを読めませんし、ネットワークにも触れません。 ハードウェアを直接触れるのは OS(カーネル)だけです。
ユーザー空間 あなたのプログラム
│ read(fd, buf, n) ← システムコール
─────────────────────┼──────────────────
カーネル空間 OS がディスクから読む
この境界をまたぐのはコストがかかります。だから、
- 小さい read/write を大量に呼ぶと遅い(バッファリングする理由)
- ログを1行ごとに
flushすると重い - I/O が遅いのは、ディスクやネットワークが遅いだけでなく、この往復もある
# プロセスがどんなシステムコールを呼んでいるか見る
strace -p <PID> # Linux
sudo dtruss -p <PID> # macOS(SIP の解除が必要な場合がある)「なぜか遅い」時に、実際に何を呼んでいるかを見られるのは強力です。
ファイルディスクリプタ — 番号で開いているものを指す
プロセスが開いているファイル・ソケットは、整数の番号で管理されます。
0 標準入力 (stdin)
1 標準出力 (stdout)
2 標準エラー (stderr)
3〜 開いたファイル・ソケット
コマンドラインで生きるの 2>&1(標準エラーを標準出力に流す)は、この番号のことでした。
ファイルや HTTP レスポンスを閉じ忘れると、番号が増え続けて上限に達します。
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close() // これを忘れるとリークする上限は ulimit -n で確認できます(開発機だと 256 や 1024 のことがある)。
症状が出るのが数時間後なので、原因にたどり着きにくいのが厄介です。 「しばらく動かすと繋がらなくなる」なら、まずこれを疑ってください。
lsof -p <PID> | wc -l # そのプロセスが今いくつ開いているかシグナル — プロセスへの通知
OS から、あるいは他のプロセスから、プロセスに送る短い通知です。
| シグナル | 意味 | 止められるか |
|---|---|---|
SIGINT (2) | Ctrl+C | できる |
SIGTERM (15) | 「終了してください」 | できる |
SIGKILL (9) | 「今すぐ死ね」 | できない(カーネルが強制終了) |
SIGHUP (1) | 端末が切れた / 設定再読み込みの慣習 | できる |
Kubernetes の終了フローはこうなっている
1. Pod の削除が決まる
2. Service のエンドポイントから外される(新しいリクエストが来なくなる)
3. コンテナに SIGTERM が送られる ← ここで後始末をする
4. terminationGracePeriodSeconds(既定30秒)待つ
5. まだ生きていたら SIGKILL ← 問答無用で殺される
3 で何もしないと、処理中のリクエストが途中で切られます。 だからグレースフルシャットダウンを書きます。
// SIGTERM を受けたら、新規受付を止め、処理中が終わるのを待ってから落ちる
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx) // 処理中のリクエストの完了を待つ上の例で 20*time.Second にしているのは、
Kubernetes の猶予(既定30秒)より短くするためです。
猶予より長く待つと、待っている最中に SIGKILL され、 グレースフルシャットダウンを書いた意味がなくなります。
コンテナの中では、あなたのプロセスが PID 1 になることがあります。 PID 1 には特別扱いがあり、明示的にハンドラを登録していないシグナルが無視されます。
さらに、シェル経由で起動すると(CMD sh -c "./server")、
PID 1 はシェルになり、シェルは SIGTERM を子に転送しません。
CMD ["./server"] # 良い(exec 形式。server が PID 1 になる)
CMD ./server # 危険(shell 形式。sh が PID 1 になる)「Pod の削除にいつも30秒かかる」なら、SIGTERM が届いておらず SIGKILL を待っている可能性が高いです。
メモリ制限と OOMKilled
RSS と仮想メモリ
top や ps に出るメモリには2種類あります。
| 表記 | 意味 |
|---|---|
| VSZ / VIRT | プロセスが確保した仮想アドレス空間。実際には使っていない分も含む |
| RSS / RES | 実際に物理メモリを使っている量 |
見るべきは RSS です。 VSZ は Go や JVM だと巨大な数字が出ますが、 それ自体は問題ではありません。
OOMKilled
コンテナには limits.memory でメモリ上限を設定します(コンテナと Kubernetes)。
上限を超えると、カーネルがそのプロセスを SIGKILL で殺します。
State: Terminated
Reason: OOMKilled
Exit Code: 137 ← 128 + 9(SIGKILL)
Exit Code 137 を見たら OOM と覚えてください。
原因は主に3つです。
- 本当に足りない — limit を上げる
- リークしている — 参照を持ち続けている箇所を探す
- 一度に全部読んでいる — 100万行のファイルを丸ごとメモリに載せている
3 が新人に最も多いパターンです。
// 危険: ファイル全体をメモリに載せる
data, _ := os.ReadFile(path)
// 安全: 1行ずつ処理する(メモリ使用量が入力サイズに依存しない)
sc := bufio.NewScanner(f)
for sc.Scan() { process(sc.Text()) }DB のクエリも同じです。LIMIT の無い SELECT は、
本番のデータ量でプロセスを殺します。
Pod が Exit Code 137 で再起動を繰り返しています。まず何を確認しますか。
見るための道具
ps aux | head # プロセス一覧(RSS・CPU・起動コマンド)
top / htop # リアルタイムの負荷
lsof -p <PID> # そのプロセスが開いているファイル・ソケット
lsof -i :8080 # そのポートを掴んでいるプロセス
kill -TERM <PID> # 終了を依頼する(まずこれ)
kill -KILL <PID> # 強制終了(最後の手段)
pgrep -fl server # 名前でプロセスを探すkill -9(SIGKILL)は、プロセスに後始末をさせません。
- 書きかけのファイルが壊れる
- ロックが残る
- 処理中のリクエストが失われる
まず kill(SIGTERM)を送り、それでも終わらない時だけ -9 を使います。
「-9 でないと止まらない」こと自体がバグの兆候です。
実務の落とし穴まとめ
- Exit Code 137 — OOMKilled。ログは残らない
too many open files— Close 漏れ。数時間後に発症するCMD ./server(shell 形式) — SIGTERM が届かず、常に SIGKILL される- grace period より長く待つ — 待っている途中で殺される
- ファイル・クエリ結果を全部メモリに載せる — 本番のデータ量で落ちる
- 深い再帰 — スタックオーバーフロー
kill -9を最初に打つ — 後始末が行われない- VSZ を見て「メモリを食っている」と判断 — 見るべきは RSS
まとめ
- プロセスはメモリを隔離され、スレッドはメモリを共有する。 だからデータ競合はスレッド(goroutine)でだけ起きる
- スタックは速くて小さい・自動で消える、ヒープは大きくて GC が要る
- goroutine が軽いのは、スタックが 2KB から可変で、 切り替えがユーザー空間で完結するから
- I/O はシステムコールでカーネルに頼む。往復コストがあるからバッファリングする
- 開いたものはファイルディスクリプタ。閉じ忘れると
too many open files - SIGTERM は止められる、SIGKILL は止められない。 Kubernetes は SIGTERM → 猶予 → SIGKILL の順で来る
- Exit Code 137 = OOMKilled。見るのは RSS
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| Linux man pages(signal(7) など) | https://man7.org/linux/man-pages/ |
| Go runtime パッケージ | https://pkg.go.dev/runtime |
| Kubernetes: Pod のライフサイクル | https://kubernetes.io/ja/docs/concepts/workloads/pods/pod-lifecycle/ |
章末問題
デプロイのたびに、数リクエストが 502 エラーになります。最も可能性が高い原因は?
goroutine を10万個起動しても動くのに、OS スレッドを10万個作るとマシンが止まるのはなぜですか。
次の章では、これらのプロセスが実際に動いている場所—— Linux サーバーの中を歩きます。ログはどこにあり、サービスはどう管理されているのか。