コンテナは「特別なプロセス」にすぎない

第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は、この仕組みを組み合わせてコンテナを作っています。

namespacecloneのフラグ隔離されるものコンテナでの見え方
pidCLONE_NEWPIDプロセスIDの番号空間コンテナ内で最初のプロセスがPID 1になる
netCLONE_NEWNETネットワークインターフェース、ルーティング、ポートコンテナごとに独自のeth0とlocalhostを持つ
mntCLONE_NEWNSマウントポイントイメージのファイルシステムがルート/に見える
utsCLONE_NEWUTSホスト名hostnameがコンテナIDなどになる
ipcCLONE_NEWIPC共有メモリ、セマフォほかのコンテナとSystem V IPCを共有しない
userCLONE_NEWUSERUID/GIDの対応コンテナ内のrootをホストの一般ユーザーに対応させられる
cgroupCLONE_NEWCGROUPcgroup階層の見え方自分のcgroupがルートに見える
timeCLONE_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.eventsoomや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 と表示し再起動
OOMKilledの流れ。殺すのはカーネルで、kubeletはその結果を表示しているだけ

同じ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"]
Pod終了の流れ。terminationGracePeriodSeconds(既定30秒)はpreStopの時間を含む

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: 500m

PID 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以降)アプリのログの最後のスタックトレース
134128 + 6(SIGABRT)V8のJavaScript heap out of memory、ネイティブコードのabortヒープ上限とheapUsedの推移
137128 + 9(SIGKILL)cgroupのOOM kill、猶予期間切れの強制終了PodのReasonがOOMKilledかErrorか、memory.events
143128 + 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年の動向と、この先の学習ロードマップを紹介します。

理解度チェック

問題 0 / 50%
Q1

KubernetesでPodがOOMKilled、終了コード137になった。直接このプロセスを終了させたのは誰ですか?

キーボード: 1〜4 で選択、Enter で回答