コンテナは「特別なプロセス」にすぎない
第10章までは、1つのNode.jsプロセスの内側でイベントループを止めない方法を見てきました。 本番では、そのプロセスの多くがコンテナの中で動いています。Stack Overflow Developer Survey 2025ではDockerの利用率が71.1%、CNCFの2025年調査では回答者の82%が本番でKubernetesを使っています。
コンテナという言葉からは小さな仮想マシンを想像しがちですが、Linuxのコンテナに専用のカーネルはありません。 ホストのカーネルが動かしている普通のプロセスに、2種類の制限をかけたものです。 1つはnamespacesで、プロセスから見える範囲(PIDの番号、ネットワーク、ファイルシステムなど)を区切ります。 もう1つはcgroupsで、使える資源の量(メモリ、CPU時間など)を制限します。 ホストでpsを実行すると、コンテナの中のnodeもホストのPIDを持つプロセスとして見えます。
この見方ができると、本番でよく遭遇する3つの現象に説明がつきます。突然のOOMKilled、デプロイのたびに起きるリクエストの取りこぼし、そしてdocker stopに10秒かかる問題です。
namespaces — 見える範囲を区切る
第4章では、clone()に渡すフラグで「親と何を共有するか」が決まり、CLONE_VMやCLONE_FILESを立てればスレッドに近くなることを見ました。 同じclone()にCLONE_NEW*系のフラグを立てると、逆に「共有しない」指定になります。 子プロセスは新しいnamespaceの中で生まれ、親とは別のPID番号空間や別のネットワークスタックを持ちます。Dockerやcontainerdは、この仕組みを組み合わせてコンテナを作っています。
| namespace | cloneのフラグ | 隔離されるもの | コンテナでの見え方 |
|---|---|---|---|
| pid | CLONE_NEWPID | プロセスIDの番号空間 | コンテナ内で最初のプロセスがPID 1になる |
| net | CLONE_NEWNET | ネットワークインターフェース、ルーティング、ポート | コンテナごとに独自のeth0とlocalhostを持つ |
| mnt | CLONE_NEWNS | マウントポイント | イメージのファイルシステムがルート/に見える |
| uts | CLONE_NEWUTS | ホスト名 | hostnameがコンテナIDなどになる |
| ipc | CLONE_NEWIPC | 共有メモリ、セマフォ | ほかのコンテナとSystem V IPCを共有しない |
| user | CLONE_NEWUSER | UID/GIDの対応 | コンテナ内のrootをホストの一般ユーザーに対応させられる |
| cgroup | CLONE_NEWCGROUP | cgroup階層の見え方 | 自分のcgroupがルートに見える |
| time | CLONE_NEWTIME | 一部の時計(起動からの経過時間など) | チェックポイントからの復元などで使う |
namespaceはあくまで見える範囲を区切るだけで、資源の量は制限しません。 namespaceだけのコンテナでも、ホストのメモリを使い切ることはできてしまいます。量の制限は次のcgroupsの役割です。
cgroup v2 — 使える資源を制限する
cgroup(control group)は、プロセスをグループにまとめ、グループ単位で資源の上限を設定する仕組みです。 現在の主要ディストリビューションとKubernetesはcgroup v2を使っており、設定は/sys/fs/cgroup/以下のファイルとして見えます。 コンテナの中からcat /sys/fs/cgroup/memory.maxを実行すると、そのコンテナに許されたメモリの上限が読めます。
| ファイル | 意味 | 超えたときに起きること | Kubernetesとの対応 |
|---|---|---|---|
| memory.max | メモリ使用量の上限(ハードリミット) | まず回収を試み、それでも足りなければcgroup内のプロセスがOOM killされる | resources.limits.memory |
| memory.high | スロットリングを始める目安(ソフトリミット) | プロセスは殺されず、メモリ回収のために動作が遅くされる | 既定では使われない(MemoryQoS機能で利用) |
| memory.current | 現在の使用量 | (読み取り専用) | メトリクスの元データ |
| memory.events | oomやoom_killの発生回数 | (読み取り専用) | OOMの発生を後から確認できる |
| cpu.max | 期間あたりのCPU時間の上限(例: 50000 100000) | 上限に達するとその期間の残りは実行されない(スロットリング) | resources.limits.cpu(500mなら50000 100000) |
memory.maxとmemory.highの違いは、超えたときの扱いです。memory.maxを超えると、カーネルはまずページキャッシュなどの回収を試み、回収しきれなければcgroupの中のプロセスをOOM Killerで殺します。 第5章で見たOOM Killerと同じ仕組みが、ホスト全体ではなくcgroupの中だけで働くと考えると分かりやすいでしょう。 memory.highは、超えてもプロセスを殺さず、メモリを回収させながら動作を遅くします。
OOMKilledと終了コード137
OOMKilledが起きるまで
KubernetesでPodの状態にOOMKilled、終了コードに137と表示されたとき、裏では次の流れが起きています。 137は第3章で見た「シグナルで死んだら128+N」の規則で、128 + 9(SIGKILL)です。 SIGKILLはプロセス側で捕まえられないので、Node.jsのprocess.on('exit')もログ出力も実行されず、プロセスは何も言い残さずに消えます。
sequenceDiagram participant N as Nodeプロセス participant C as cgroup(memory.max) participant K as カーネル participant L as kubelet N->>C: ヒープやBufferが増え続ける C->>K: memory.max を超過 K->>K: ページキャッシュなどの回収を試みる K->>N: 回収しきれず OOM killer が SIGKILL Note over N: ハンドラは実行されず即終了 L->>L: 終了コード137と<br/>memory.events を確認 L-->>L: 状態を OOMKilled と表示し再起動
同じ137でも、OOM以外の原因があります。猶予期間内に終了しなかったプロセスにkubeletがSIGKILLを送った場合も137になり、このとき理由はOOMKilledではなくErrorと表示されます。 逆に、V8が自分のヒープ上限に達して「JavaScript heap out of memory」で落ちる場合は、V8がabortするので終了コードは134(128 + 6、SIGABRT)になり、ログにスタックが残ります。 終了コードを見るだけで、どこが上限にぶつかったのかを切り分けられます。
V8のヒープ上限とコンテナの上限
Node.jsはv12.7.0以降、起動時にcgroupのメモリ制限と物理メモリの小さい方をV8に伝え、V8はそれを基準にヒープ上限を決めます(cgroup v2への対応は20.3.0以降)。 64bit環境のV8は、おおむね利用可能メモリの半分を目安に、256MB〜4GBの範囲でヒープ上限を決めます。 たとえば上限512Miのコンテナなら、ヒープ上限はおよそ256MB前後になります。古いNode.jsやcgroup v1前提の環境では、ホストの物理メモリを基準にした大きなヒープ上限が設定され、ヒープが育ちきる前にcgroupの上限に当たって137で死ぬことがありました。
ヒープ上限を自分で決めるときは--max-old-space-size(MB単位)を使います。 このとき、コンテナのメモリ上限いっぱいの値にしてはいけません。 第5章で見たとおり、process.memoryUsage()のrssにはV8ヒープ以外に、BufferやArrayBuffer(external / arrayBuffers)、スレッドスタック、ネイティブアドオン、コード領域が含まれます。 cgroupが数えるのはヒープではなくこれら全部(とページキャッシュの一部)です。 ファイルやHTTPボディをBufferで大量に扱うサービスなら、ヒープ以外の分としてコンテナ上限の25%以上を空けておくのが安全な出発点です。 上限512Miなら--max-old-space-size=384程度から始め、本番のrssとexternalを観測して調整します。
CPU制限とスレッド数
CPUの制限はcpu.maxで、「100msのうち50msまで」のように時間で決まります。Kubernetesのlimits.cpu: 500mは50000 100000に変換されます。 上限に達するとその期間の残りは実行されないので、レイテンシが周期的に跳ねます。/sys/fs/cgroup/cpu.statのnr_throttledが増えていれば、このスロットリングが起きています。
注意したいのは、コンテナの中でもos.cpus().lengthがホストのコア数を返すことがある点です。 64コアのホストでlimits.cpu: 1のコンテナがワーカーを64本立てると、1コア分の時間を64本で取り合います。 ワーカー数の基準には、CPUアフィニティを反映するos.availableParallelism()を使い、cgroupのクォータまで反映されているかは起動時にログへ出してcpu.maxと照らし合わせます。 逆に、ファイルI/Oやdns.lookupが多いサービスでは、CPU制限が小さくてもUV_THREADPOOL_SIZEを既定の4より増やしたほうが待ちが減る場合があります。スレッドプールの多くはI/O待ちで、CPUを使っていないからです。
graceful shutdown — SIGTERMを受けたら
KubernetesがPodを止める手順
デプロイやスケールインでPodを止めるとき、KubernetesはいきなりSIGKILLを送るわけではありません。 まずpreStopフックがあれば実行し、次にコンテナのメインプロセスへSIGTERMを送り、terminationGracePeriodSeconds(既定30秒、preStopの時間を含む)が過ぎても終わらなければSIGKILLを送ります。 これと並行して、PodはServiceのエンドポイントから外されます。ただしロードバランサや各ノードへの反映には少し時間がかかるので、SIGTERMを受けた直後にもリクエストが届くことがあります。
graph TD
A["Pod の削除が決まる"] --> B["preStop フック(例: sleep 5)"]
A --> E["Service のエンドポイントから外す<br/>(反映に数秒かかる)"]
B --> C["メインプロセスに SIGTERM"]
C --> D{"猶予期間内に<br/>自分で終了したか"}
D -->|はい| F["終了コード 0 などで正常終了"]
D -->|いいえ| G["SIGKILL で強制終了<br/>終了コード 137"]Node.jsでの実装
第3章で見たとおり、Node.jsでprocess.on('SIGTERM', ...)を登録すると、既定の「シグナルで終了する」動作が無効になります。 ハンドラを登録したら、最後は自分でprocess.exit()するか、イベントループを空にして自然に終わらせる必要があります。 手順は、readinessを落として新しいリクエストを受けないようにし、server.close()で新規接続の受け付けを止め、処理中のリクエストが終わるのを待ち、DB接続などを閉じてから終了する、の順です。
import http from 'node:http';
let shuttingDown = false;
const server = http.createServer((req, res) => {
if (req.url === '/readyz') {
// 終了処理に入ったら readiness probe を失敗させる
res.statusCode = shuttingDown ? 503 : 200;
res.end();
return;
}
handle(req, res);
});
server.listen(3000);
process.on('SIGTERM', () => {
shuttingDown = true;
console.log('SIGTERM received, draining...');
// 新規接続の受け付けを止め、処理中のリクエストが終わったらコールバックが呼ばれる
server.close(async (err) => {
await closeDbPool(); // DB などの後片付け
process.exit(err ? 1 : 0);
});
// リクエスト処理中でない keep-alive 接続はすぐ閉じる
server.closeIdleConnections();
// 猶予期間(30秒)より前に自分で見切りをつける保険
setTimeout(() => {
console.error('drain timeout, forcing exit');
process.exit(1);
}, 25_000).unref();
});server.close()だけでは、keep-aliveでつながったままのアイドル接続が残り、コールバックがいつまでも呼ばれないことがあります。 そのためcloseIdleConnections()(v18.2.0以降)で処理中でない接続を閉じます。 タイムアウトの保険は、猶予期間より数秒短くしておきます。SIGKILLで殺されるとログも残らないからです。 Kubernetes側では、エンドポイントからの除外が行き渡るまでの時間をpreStopで稼ぎます。
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
image: example/api:1.0.0
lifecycle:
preStop:
exec:
command: ['sleep', '5'] # LB から外れるのを待ってから SIGTERM を受ける
readinessProbe:
httpGet:
path: /readyz
port: 3000
resources:
limits:
memory: 512Mi
cpu: 500mPID 1問題
pid namespaceの中で最初に起動したプロセスはPID 1になります。LinuxのPID 1には、普通のプロセスにはない2つの特別扱いがあります。
1つ目は、シグナルの既定動作が適用されないことです。普通のプロセスはSIGTERMのハンドラがなければ終了しますが、PID 1はハンドラを登録していないシグナルを無視します。 ハンドラを書いていないNode.jsアプリをPID 1で動かすと、docker stopのSIGTERMが効かず、既定の10秒待ったあとSIGKILLで殺されます。「docker stopに毎回10秒かかる」現象の多くはこれが原因です。
2つ目は、孤児プロセスを引き取ってゾンビを回収する責任です。第3章で見たとおり、親が先に終了した子プロセスはPID 1の子になり、PID 1がwait()しないと終了後もゾンビとして残ります。 Node.jsは自分で起動していない孫プロセスを回収しないので、child_processで起動したシェルがさらに子を作るようなアプリでは、ゾンビが溜まっていくことがあります。
tiniとexec形式のCMD
対策は、PID 1に小さなinitプロセスを置くことです。tiniはPID 1として起動し、受け取ったシグナルを子プロセス(node)へ転送し、ゾンビを回収します。 Dockerならdocker run --initでtini相当のinitが自動で入ります。Kubernetesにはこのオプションがないので、イメージにtiniを入れてENTRYPOINTにします。
FROM node:24-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
ENV NODE_OPTIONS=--max-old-space-size=384
# tini が PID 1 になり、シグナル転送とゾンビ回収を受け持つ
ENTRYPOINT ["/usr/bin/tini", "--"]
# exec 形式で node を直接起動する(npm start やシェル形式は使わない)
CMD ["node", "dist/index.js"]CMDの書き方にも落とし穴があります。CMD node dist/index.jsのようなシェル形式は/bin/sh -c経由で起動されるので、PID 1はシェルになり、SIGTERMがnodeまで届かないことがあります。CMD ["npm", "start"]も、npm(とその中で使われるシェル)がnodeとの間に挟まるので、シグナルが確実にnodeへ届く保証がなくなります。CMD ["node", "dist/index.js"]のexec形式でnodeを直接起動し、シグナルを受け取るのがnode自身になるようにします。
終了コード早見表
コンテナが落ちたときにまず見るのは終了コードです。128を超える値は「128 +シグナル番号」と読みます。
| 終了コード | 意味 | よくある原因 | 最初に確認すること |
|---|---|---|---|
| 0 | 正常終了 | graceful shutdownが完了した | (問題なし) |
| 1 | 一般的なエラー | キャッチされない例外、未処理のPromise rejection(Node 15以降) | アプリのログの最後のスタックトレース |
| 134 | 128 + 6(SIGABRT) | V8のJavaScript heap out of memory、ネイティブコードのabort | ヒープ上限とheapUsedの推移 |
| 137 | 128 + 9(SIGKILL) | cgroupのOOM kill、猶予期間切れの強制終了 | PodのReasonがOOMKilledかErrorか、memory.events |
| 143 | 128 + 15(SIGTERM) | SIGTERMのハンドラがなく既定動作で終了した | graceful shutdownを実装しているか |
まとめ
コンテナは、namespacesで見える範囲を、cgroup v2で使える資源を制限されたただのプロセスです。 memory.maxを超えるとカーネルのOOM KillerがSIGKILLを送り、kubeletはそれをOOMKilled(137)と表示します。 V8のヒープ上限はコンテナの上限より小さくし、Bufferやネイティブメモリの分を空けておきます。 終了時はSIGTERMを受けてserver.closeで処理中のリクエストを終え、猶予期間内に自分でexitします。PID 1の特別扱いにはtiniとexec形式のCMDで対処します。
ここまでで、プロセスの誕生から本番運用までをひととおり辿りました。最終章では、これまでの章で説明してきた挙動をstraceやベンチマークで実際に確かめます。 あわせて、sched_extやio_uring、Node.js 26 LTSなど2026年の動向と、この先の学習ロードマップを紹介します。
理解度チェック
KubernetesでPodがOOMKilled、終了コード137になった。直接このプロセスを終了させたのは誰ですか?
キーボード: 1〜4 で選択、Enter で回答