
Linux
イベント
マガジン
技術ブログ
想定読者:SOC アナリスト、脅威ハンター、検知エンジニア。SIEM の経験はあるが Elastic / EQL は初めて、あるいは復習したい方。 読了時間:約 15 分 セキュリティ運用の現場では、毎日とんでもない量のログを見ます。Elasticsearch は「ログを保存して検索する」のはとても得意です。でも、 脅威の検出 となると話が一段難しくなります。 なぜか。攻撃は単独のイベントではなく、 複数のステップが時間軸の上で連なる流れ だからです。 たとえば、次のような流れを「ひとかたまり」として検出したいとします。 ファイルが Temp フォルダに作成された その直後にそのファイルが実行された さらにそのプロセスが外部に通信した 普通のクエリでは、これを 3 回別々に検索して、ID やタイムスタンプで突き合わせる必要があります。これがしんどい。 EQL(Event Query Language)は、この「流れ全体」を一度のクエリで捕まえるために作られた言語 です。本記事では、セキュリティエンジニアが EQL を実務で使えるようになるための最短ルートを紹介します。 目次 EQL とは何か EQL を動かすための前提 通常の検索クエリ(KQL/Lucene)と何が違うか Splunk SPL を使っている人へ:EQL との対応表 同じ検出を SPL と EQL で書き比べる EQL の SPL に対する優位点 EQL の基本構文 最小単位の形 例 1:PowerShell 実行の検出 例 2:複数条件の組み合わせ EQL の演算子チートシート == と : の使い分け(ハマりやすい) ECS:Elastic Common Schema を理解する ECS とは何か セキュリティで頻出する ECS フィールド タスク文を EQL に変換するフレームワーク 5 ステップ翻訳法 実演:タスク文を 5 ステップで翻訳する キーワード → ECS フィールドの早見表 sequence と時間制約:流れの検出 構文 by の力:複数フィールドで相関 イベント間で値を引き継ぐ:per-event by maxspan の選び方 実例:セキュリティ検出パターン 5 選 パターン 1:暗号化されていない通信の検出(コンプライアンス) パターン 2:PowerShell ダウンロードからの C2 接続 パターン 3:Office アプリからのプロセス起動(Living off the Land) パターン 4:マルウェア配置 → 実行 → C2 通信 パターン 5:横展開の検出(ラテラルムーブメント) よくあるミス と正しい書き方 ミス 1:ECS でないフィールド名を使う ミス 2:大文字小文字の罠 ミス 3:event.action と event.type の混同 ミス 4:sequence の順序逆転 ミス 5:maxspan の単位忘れ ミス 6:EQL 関数の大文字小文字を間違える ミス 7:関数の引数の大文字小文字でヒットしない 実際に動かす方法 方法 1:Kibana の Dev Tools で試す 方法 2:Elastic Security の Timeline 方法 3:Detection Rule として登録 まとめ 参考資料 EQL とは何か EQL は Elastic が開発した、 イベントベースのクエリ言語 です。SQL や KQL に少し似ていますが、決定的に違うのは「 複数のイベントの順序と関係性を表現できる 」点です。 ひとことで言うと: EQL は「A が起きて、その後に B が起きて、さらに C が起きた」を 1 つのクエリで書ける言語。 これは脅威検出と相性が抜群です。なぜなら、攻撃チェーン(MITRE ATT&CK で言うところの Tactics の連鎖)は、まさにそういう構造をしているからです。 EQL を動かすための前提 ここで先に伝えておきたいことがあります。EQL は ECS(Elastic Common Schema)を前提に設計されているため、 検索対象のデータには @timestamp と event.category フィールドが必要 です。Elastic 公式ドキュメントにも、EQL はデフォルトでこの 2 つのフィールドを使うと明記されています。 つまり、 process where process.name == "powershell.exe" の process は、実際には「event.category が process のイベント」を意味します。 Elastic Agent や Elastic Defend、Beats から取り込んだデータは ECS に準拠しているのでそのまま EQL が動きます。独自データの場合は、最低限この 2 フィールドをマッピングしておく必要があります。 通常の検索クエリ(KQL/Lucene)と何が違うか 特性 通常のクエリ (KQL) EQL 1 つのイベント検索 ✅ 得意 ✅ 得意 集計・統計 ✅ 得意 ⚠️ 限定的 複数イベントの順序関係 ❌ 自前で組み立て ✅ ネイティブ対応 時間窓での相関 ❌ 困難 ✅ maxspan で簡単 ECS フィールドの活用 ✅ ✅ 「単一イベントを検索したい」なら KQL でも十分です。 「攻撃の流れを検出したい」なら EQL です。 Splunk SPL を使っている人へ:EQL との対応表 SOC 経験者の多くは Splunk SPL の知識があるはずです。両者の対応関係を理解しておくと、EQL の学習速度が一気に上がります。 やりたいこと Splunk SPL Elastic EQL 単一イベント検索 search index=… process=”…” process where process.name == “…” 複数イベントの相関 transaction host maxspan=30s sequence by host.name with maxspan=30s ストリーミング相関 streamstats sequence データモデル / 正規化 CIM (Common Information Model) ECS (Elastic Common Schema) フィールド指定 process_name(CIM) process.name(ECS) 否定 NOT not / != ワイルドカード * * (: または like 演算子) ⚠️ これは厳密な機能対応ではなく、考え方を理解するための対応表です。 Splunk の transaction はイベントをまとめて後処理するイメージが強く、EQL の sequence は「順序あるイベント列」をネイティブに表現する処理です。考え方として近い、というレベルで捉えてください。 同じ検出を SPL と EQL で書き比べる シナリオ: PowerShell が実行され、30 秒以内に外部 HTTPS 接続が発生 Splunk SPL: (index=endpoint sourcetype=process process_name="powershell.exe") OR (index=endpoint sourcetype=network dest_port=443) | transaction host maxspan=30s | where mvcount(sourcetype)>1 Elastic EQL: sequence by process.entity_id with maxspan=30s [process where process.name == "powershell.exe"] [network where destination.port == 443] EQL の方が「 意図がそのまま構文になっている 」のがわかります。「これが起きて、次にこれが起きる」と読める。(process.entityの代わりに広域な相関で host.name を使う事も可能です) EQL の SPL に対する優位点 実務でメリットになるのは、おおむね以下の点です。 構文が攻撃チェーンのメンタルモデルと一致 — sequence … [event A] [event B] という形が、そのままアナリストの思考順序になる 時間ウィンドウの指定が一行 — with maxspan=30s を足すだけ ECS により書いたクエリの再利用性が高い — process.name を含むデータであれば、Windows、Linux、各種 EDR を横断して同じクエリが使える 検知ルールにそのまま流用可能 — Elastic Security の Detection Engine が EQL をネイティブサポート オープンスタンダードに沿った設計 — ECS は OpenTelemetry の Semantic Conventions に統合される方向で進化している 逆に EQL が苦手なこと も正直に書いておきます。 大規模な統計集計(カウント、平均、グルーピング)→ ES|QL や Lens の方が向いている 複雑な条件分岐や後処理 → ES|QL を併用するのが現実的 フィールド同士の比較(例:「1 回目の host.name と 2 回目の host.name が違う」)→ EQL 単独では難しい。ES|QL での後続分析と組み合わせる EQL は「 順序ある脅威検出 」のためのツール。集計や複雑な比較が必要なら別のツールと組み合わせるのが Elastic 流です。 EQL の基本構文 ここから手を動かす段階に入ります。 最小単位の形 event_category where condition たったこれだけ。読むと: event_category:何のイベントを見るか(process、file、network、authentication、registry など。内部的には event.category の値) where:条件を続けますよ、という宣言 condition:実際の条件 例 1:PowerShell 実行の検出 process where process.name == "powershell.exe" 例 2:複数条件の組み合わせ process where process.name == "powershell.exe" and process.command_line : "*DownloadString*" ここで重要な演算子を整理しておきます。 EQL の演算子チートシート 演算子 意味 使いどころ == 厳密一致(大文字小文字を区別) プロセス名、ポート番号など正確に一致させたい時 != 一致しない 除外条件 : 大文字小文字を区別しない文字列一致 (ワイルドカード *、? 対応) パス、コマンドライン、拡張子(実体は like~ と等価) like ワイルドカード一致(大文字小文字を 区別する ) パターンマッチ like~ ワイルドカード一致(大文字小文字を 区別しない ) : と同じ in リストのいずれか(区別あり) process.name in (“cmd.exe”, “powershell.exe”) in~ リストのいずれか(区別なし) 大文字小文字が揺れるデータに not 否定 not process.name == “explorer.exe” and / or 論理演算子 条件の組み合わせ 💡 メモ: : は「ワイルドカードが使える ==」と覚えると間違いません。文字列の比較で、== より柔軟(大文字小文字も吸収、* ? も使える)。 == と : の使い分け(ハマりやすい) # ❌ ハマるパターン:ファイル拡張子に == を使うと小文字限定 file where file.extension == "exe" # .EXE は引っかからない # ✅ : を使えば大文字小文字の揺れを吸収 file where file.extension : "exe" # .exe も .EXE もマッチ Windows の拡張子は大文字小文字が混在することがよくあります。ファイルパスや拡張子は基本的に : を使う方が安全です。 ECS:Elastic Common Schema を理解する EQL を本気で使うには、 ECS の理解が欠かせません。これは Splunk の CIM に相当する概念です。 ECS とは何か ECS は「 Elasticsearch にイベントデータを保存するときの共通フィールド仕様 」です。 セキュリティログは多種多様なソースから来ます。 Windows Sysmon Linux Auditd ファイアウォール EDR クラウドサービスの監査ログ これらが全部バラバラのフィールド名(proc_name、process_name、pname、Image…)だと、検索のたびにスキーマを覚え直す羽目になります。 ECS はこれを process.name のような共通名に統一する仕様です。 ECS 対応のデータであれば、同じ意味の情報を同じフィールド名で扱える ようになります。 ⚠️ 注意: ECS は「共通化のルール」であって「すべてのデータソースが必ず全フィールドを持つ」保証ではありません。たとえばネットワーク機器のログには通常 process.name がありません。実際にどのフィールドが入っているかは、データソースやインテグレーションによって異なるため、Kibana の Discover や Data Views で確認してから書く習慣をつけましょう。 セキュリティで頻出する ECS フィールド 実務でほぼ毎日使うフィールドを覚えておきましょう。 プロセス フィールド 内容 process.name プロセス名(例:powershell.exe) process.executable フルパス(例:C:\Windows\System32\powershell.exe) process.command_line コマンドライン全文 process.pid プロセス ID(OS が割り当てる数値) process.entity_id プロセスの一意識別子(Elastic Defend などが付与。 推奨 ) process.parent.name 親プロセス名 process.parent.entity_id 親プロセスの entity_id 💡 process.pid と process.entity_id の違い: PID は OS のプロセス識別子ですが、プロセス終了後に 再利用される ため、長い時間ウィンドウのクエリでは別プロセスのイベントが混ざる可能性があります。Elastic Defend や Sysmon が付与する process.entity_id はプロセスごとに一意な値なので、sequence の by 句では process.entity_id を優先 します。 ファイル フィールド 内容 file.path フルパス file.name ファイル名 file.extension 拡張子(. なし) file.size バイト数 file.hash.sha256 SHA256 ハッシュ ネットワーク フィールド 内容 destination.ip 接続先 IP destination.port 接続先ポート source.ip 送信元 IP network.protocol プロトコル network.direction 通信の向き。 ホスト視点 なら ingress / egress 、 ネットワーク観測点視点 なら inbound / outbound / internal / external 💡 network.direction の値はデータソースによって違う: Elastic Defend や endpoint 系(Auditbeat、Sysmon 経由)は ingress / egress を使う傾向、Packetbeat や Zeek のような observer 系(ネットワーク監視目線)は inbound / outbound を使う傾向です。クエリを書く前に Discover で実際の値を確認しましょう。 イベント / 主体 フィールド 内容 event.category イベント大分類(process、file、network 等)。 EQL の必須フィールド event.type ECS 標準のサブタイプ(creation、start、end、connection 等) event.action データソース固有のアクション名(ソースによって値が異なる) event.outcome success / failure user.name ユーザー名 host.name ホスト名 @timestamp イベント時刻。 EQL の必須フィールド 💡 event.type と event.action の違い: event.type は ECS が定義する 標準化されたサブタイプ (creation、deletion、start、connection など決められた値)。event.action は データソース固有のアクション名 (Windows なら file-created、Sysmon なら FileCreated など値がバラバラ)。 ポータブルなクエリを書くなら event.type を使うのが基本 です。 タスク文を EQL に変換するフレームワーク 検知エンジニアリングで一番難しいのは、「日本語の脅威シナリオを、ECS フィールドと EQL に翻訳すること」です。これさえ身につければ、新しい脅威ハンティングのアイデアをすぐクエリにできるようになります。 5 ステップ翻訳法 どんな日本語のシナリオも、次の 5 つの問いに答えれば EQL になります。 Step 1. 何のイベント? → event.category (process / file / network …) Step 2. どんなアクション? → event.type (creation / start / connection …) Step 3. 何に対して? → file.* / process.* (オブジェクト) Step 4. 誰が引き起こした? → process.* / user.* (主体) Step 5. 順序や時間制約? → sequence / maxspan (流れ) 実演:タスク文を 5 ステップで翻訳する タスク: 「 実行ファイル が Word ドキュメント を 作成し 、30 秒以内に大量のデータ送信が起きたケースを検出したい」 誰が? → 実行ファイル 何を? → Word ドキュメント どうした? → 作成した EQL では、この「誰が・何を・どうした」を ECS フィールドに置き換えていきます。 誰が? → process.executable 何を? → file.extension どうした? → event.type つまり、今回の前半部分はこういう考え方になります。 .exe の実行ファイルが .doc / .docx の Word ドキュメントを 作成した file where event.type == "creation" and process.executable : "*.exe" and file.extension : ("doc", "docx") ここで大事なのは、 file.* と process.* の見方です。 file.* は、 作られたもの の情報です。 今回であれば、作られたものは Word ドキュメントなので、 file.extension : ("doc", "docx") になります。 一方で、 process.* は、 そのイベントを起こしたプログラム の情報です。 今回であれば、Word ドキュメントを作ったのは実行ファイルなので、 process.executable : "*.exe" になります。 つまり、ファイル作成イベントでは、次のように考えるとわかりやすいです。 process が file を event.type した 今回なら、こうです。 .exe が .doc/.docx を creation した 次に、後半の条件「30 秒以内に大量のデータ送信が起きた」を見ます。 これはネットワークイベントです。大量のデータ送信は、たとえば source.bytes で表現できます。 「作成」→ 「30 秒以内」→ 「大量送信」、流れの検出 → sequence + maxspan sequence with maxspan=30s [file where event.type == "creation" and process.executable : "*.exe" and file.extension : ("doc", "docx")] [network where source.bytes > 1000000] 完成です。日本語から EQL までの距離が、思ったより近いのが伝わったでしょうか。 キーワード → ECS フィールドの早見表 翻訳作業でつまずきやすい「日本語キーワード」と「対応する ECS フィールド」の対応表です。 日本語キーワード 条件フィルター よく組み合わせるフィールド 実行された / 起動された event.type == "start" process.name 、 process.executable 作成された / 保存された event.type == "creation" file.name 、 file.path 、 file.extension 削除された event.type == "deletion" file.* 変更された event.type == "change" file.* 、 registry.* 外部に通信 / 接続 event.type == "connection" destination.ip 、 destination.port ログインした event.category == "authentication" event.outcome 、 user.name 、 source.ip sequence と時間制約:流れの検出 EQL の真骨頂は sequence です。詳しく見ていきます。 構文 sequence [by FIELD] [with maxspan=TIME] [event_category where condition_1] [event_category where condition_2] [event_category where condition_3] by FIELD:イベント間を「何でつなぐか」(join key)。host.name、process.entity_id、user.name など with maxspan=TIME:「全体が何秒以内に起きたら検出するか」(s / m / h / d) […]:各ステップのイベント条件 by の力:複数フィールドで相関 sequence by host.name, user.name with maxspan=5m [authentication where event.outcome == "failure"] [authentication where event.outcome == "success"] 「同じホスト、同じユーザーで、ログイン失敗の後に成功」を 5 分以内で検出。 ブルートフォースの成功 を捕まえる典型パターンです。 イベント間で値を引き継ぐ:per-event by ここが少しトリッキーで、よく間違えるところです。 やりたいこと: ファイルが作られて、その 同じファイル名 のプロセスが起動した sequence by host.name with maxspan=1m [file where event.type == "creation" and file.extension : "exe"] by file.name [process where event.type == "start"] by process.name sequence by host.name で ホスト全体の相関 、各 […] の後の by file.name / by process.name で 値を引き継ぎ ます。これにより「file.name == process.name」という相関が成立。 ⚠️ 重要: by のキー数は 全イベントで揃える必要があります 。[eventA] には by を付けて [eventB] に付けない、という書き方はできません。全イベントに付けるか、付けないかのどちらかです。 maxspan の選び方 シナリオ 推奨 maxspan プロセス起動 → 即時通信 30s 〜 1m マルウェア配置 → 実行 1m 〜 5m ログイン → 横展開 5m 〜 30m 内部偵察 → 権限昇格 1h 〜 4h 持続化 → 後日の C2 1d 以上(要注意:誤検知も増える) 短すぎると正規の動作も誤検知、長すぎると無関係なイベントが混ざる。 ここは仮説に基づいてチューニングする 領域です。 実例:セキュリティ検出パターン 5 選 そのままルールとして登録できる形にしてあります。 パターン 1:暗号化されていない通信の検出(コンプライアンス) network where event.type == "connection" and ( (destination.port == 80 and network.protocol == "http") or (destination.port == 21 and network.protocol == "ftp") ) and network.direction in ("egress", "outbound") 社内から外向きの HTTP/FTP を可視化。PCI-DSS や社内ポリシー違反の検出に使えます。 パターン 2:PowerShell ダウンロードからの C2 接続 sequence by process.entity_id with maxspan=30s /* * Step 1: * PowerShell が起動されたイベントを探す。 * process.entity_id で後続の network イベントと同じプロセスに紐づける。 */ [process where event.type == "start" and process.name : "powershell.exe" /* * PowerShell のコマンドラインに、 * ダウンロードやコード実行でよく使われる文字列が含まれているかを見る。 * IEX / Invoke-Expression は、文字列として取得したコードを実行する時によく使われる。 * DownloadString / Net.WebClient は、外部からスクリプトやペイロードを取得する時によく使われる。 */ and ( process.command_line : "*IEX*" or process.command_line : "*DownloadString*" or process.command_line : "*Invoke-Expression*" or process.command_line : "*Net.WebClient*" ) ] /* * Step 2: * 同じプロセスが 30 秒以内に HTTPS 通信しているかを見る。 * destination.port == 443 は HTTPS 通信の代表的なポート。 */ [network where event.type == "connection" and destination.port == 443 /* * 接続先がプライベート IP ではないことを確認する。 * つまり、社内ネットワークではなく外部サーバーへ通信している可能性を見る。 */ and not cidrmatch( destination.ip, "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" ) ] PowerShell が「ダウンロード系」のコマンドを実行して、直後にプライベート IP 以外 へ HTTPS 接続したケース。C2 ビーコンの典型形です。 ✅ ポイント: by process.entity_id で 同一プロセス内の流れ に限定しています。process.pid は再利用されるため誤検知の原因になりますが、process.entity_id は一意なので安全。 パターン 3:Office アプリからのプロセス起動(Living off the Land) process where event.type == "start" and process.parent.name : ("winword.exe", "excel.exe", "powerpnt.exe", "outlook.exe") and process.name : ("cmd.exe", "powershell.exe", "wscript.exe", "cscript.exe", "mshta.exe", "rundll32.exe") Office アプリがシェル系プロセスを起動するのは、ほぼマクロ起点の攻撃。 現代の標的型攻撃で最頻出のパターン で、誤検知も少なめ。検知ルールとしてまず入れたい一本です。 パターン 4:マルウェア配置 → 実行 → C2 通信 sequence by host.name with maxspan=2m /* * Step 1: * 同じホスト上で、Temp フォルダに実行可能ファイルが作成されたイベントを探す。 * Temp フォルダは、マルウェアやドロッパーが一時的にファイルを置く場所としてよく使われる。 */ [file where event.type == "creation" and file.path : "*\\Temp\\*" /* * exe / dll / scr は、攻撃で使われやすい実行可能ファイルの拡張子。 * exe は通常の実行ファイル、dll はライブラリ、scr はスクリーンセーバー形式だが実行可能。 */ and file.extension : ("exe", "dll", "scr") ] by file.name /* * Step 2: * 直後に、作成されたファイルと同じ名前のプロセスが起動されたかを見る。 * ここでは by file.name と by process.name を使い、 * 「作られたファイル名」と「起動したプロセス名」を結びつけている。 */ [process where event.type == "start"] by process.name /* * Step 3: * さらに、そのプロセスが外部通信でよく使われるポートへ接続したかを見る。 * 80 / 443 は HTTP / HTTPS、 * 8080 は代替 HTTP、 * 4444 は攻撃ツールやリバースシェルで使われることがある代表的なポート。 */ [network where event.type == "connection" and destination.port in (80, 443, 8080, 4444) ] by process.name Temp に実行ファイルが落ちて → そのファイルが起動して → 外部通信、までを 2 分以内に検出。 ドロッパー型マルウェアの典型挙動 です。 パターン 5:横展開の検出(ラテラルムーブメント) sequence by user.name with maxspan=30m /* * Step 1: * あるユーザーでログイン成功イベントが発生したかを見る。 * 横展開では、攻撃者が盗んだ認証情報を使って、 * まずどこかの端末やサーバーへログインすることがある。 */ [authentication where event.outcome == "success"] /* * Step 2: * 同じユーザーの操作として、横展開で使われやすいツールが起動されたかを見る。 * * psexec.exe: * Windows 環境でリモート端末上にコマンドを実行するためによく使われる。 * * wmic.exe: * WMI を使って、リモート端末の情報取得やコマンド実行に使われることがある。 * * winrm.exe: * Windows Remote Management を使ったリモート操作に関係する。 */ [process where event.type == "start" and process.name : ("psexec.exe", "wmic.exe", "winrm.exe")] /* * Step 3: * その後、同じユーザーで再びログイン成功イベントが発生したかを見る。 * これは、別の端末やサーバーへ移動した可能性を確認するためのステップ。 */ [authentication where event.outcome == "success"] 「同じユーザーで、ログイン成功 → 横展開系ツール実行 → ログイン成功」という流れを 30 分以内で検出。 侵入後の活動段階 を捕まえます。 ⚠️ EQL の限界: 「 別ホスト へのログイン」まで厳密に判定したい(1 回目と 2 回目の host.name が違う、という条件)場合、EQL の by 句では「値が等しい」しか表現できないため、これだけでは難しいです。実務では次のどちらかで対応します。 EQL でこのクエリを実行して候補を絞り、Detection Rule の Investigation Guide で host.name の違いを確認する ES|QL で後続分析する(同じユーザーが複数ホストにログインしているかを集計) よくあるミス と正しい書き方 実務でハマる典型パターンです。 ミス 1:ECS でないフィールド名を使う # ❌ process where proc_name == "powershell.exe" # ✅ process where process.name == "powershell.exe" 対策: Kibana の Data Views でフィールド名を確認するクセをつける。SPL からの移行者がいちばんやりがちなミス。 ミス 2:大文字小文字の罠 # ❌ 大文字の .EXE は引っかからない file where file.extension == "exe" # ✅ file where file.extension : "exe" ミス 3:event.action と event.type の混同 # ⚠️ 動くかもしれないが、データソース依存(値が "file-created" や "FileCreated" など揺れる) file where event.action == "creation" # ✅ ECS 標準化された値を使う file where event.type == "creation" ミス 4:sequence の順序逆転 sequence は 書いた順 = 起きた順 です。 # ❌ 通信が先、プロセス起動が後…と書いている sequence by process.entity_id [network where destination.port == 443] [process where process.name : "powershell.exe"] # ✅ 「PowerShell 起動 → 通信」のシナリオなら sequence by process.entity_id [process where process.name : "powershell.exe"] [network where destination.port == 443] シナリオを先に 日本語で書き下す と、順序ミスが減ります。 ミス 5:maxspan の単位忘れ # ❌ 単位なしはエラー with maxspan=30 # ✅ with maxspan=30s s(秒)/ m(分)/ h(時間)/ d(日)を必ずつける。 ミス 6:EQL 関数の大文字小文字を間違える EQL の 関数名は大文字小文字を区別 します。これでハマる人は多いです。 # ❌ Unknown function エラーになる not cidrmatch(destination.ip, "10.0.0.0/8") # ✅ camelCase が正解 not cidrMatch(destination.ip, "10.0.0.0/8") よく使う関数の正しいスペル: cidrMatch 、 startsWith 、 endsWith 、 stringContains 、 indexOf 、 concat 。関数を大文字小文字を無視させたいときは ~ を付けます(例: endsWith~(file.path, ".exe") )。 ミス 7:関数の引数の大文字小文字でヒットしない 関数名そのものに加えて、 関数の引数(比較対象の文字列)もデフォルトで大文字小文字を区別 します。Windows のファイルパスのように大文字小文字が揺れるデータでは、これで取りこぼします。 # ❌ .EXE や .Exe は引っかからない(小文字 .exe のみマッチ) file where endsWith(file.path, ".exe") or endsWith(file.path, ".dll") # ✅ ~ を付けると大文字小文字を吸収 file where endsWith~(file.path, ".exe") or endsWith~(file.path, ".dll") ルール: 関数を大文字小文字を無視させたいときは、関数名の末尾に ~ を付けます。 endsWith~ 、 startsWith~ 、 stringContains~ 、 indexOf~ などすべての文字列関数で使えます。 💡 : と endsWith~ の使い分け: 単純な拡張子チェックなら file.extension : "exe" で十分。 endsWith~ はパス末尾や任意の文字列末尾を見たいとき(例: process.command_line の末尾)に便利です。 実際に動かす方法 方法 1:Kibana の Dev Tools で試す 最も手軽な方法。 POST logs-endpoint.*/_eql/search { "query": """ sequence by process.entity_id with maxspan=30s [process where process.name : "powershell.exe"] [network where destination.port == 443] """ } POST logs-endpoint.*/_eql/search 方法 2:Elastic Security の Timeline Elastic Security の「Timeline」では、UI から直接 EQL クエリを実行できます。脅威ハンティングのインタラクティブな探索に向いています。 方法 3:Detection Rule として登録 Elastic Security → Manage → Rules → Create new rule Rule type で「 Event Correlation 」を選択(これが EQL) クエリを貼り付け 重要度、MITRE ATT&CK タグ、Investigation Guide を設定 Save & enable 検証用のテストを必ず通してから本番有効化することをおすすめします。 まとめ EQL は「 順序ある脅威検出 」のために作られたクエリ言語 EQL を動かす前提は @timestamp と event.category の 2 フィールド Splunk SPL の transaction に近い考え方だが、構文が 攻撃チェーンのメンタルモデルに直結 している ECS は『同じ意味のデータには同じフィールド名を使う』というルール。だから 1 つのクエリで複数データソースに対応できる。ただし、すべてのデータソースが ECS の全フィールドを持っているわけではないので、Discover で実際の中身は必ず確認。 5 ステップ翻訳法(What → Action → Object → Actor → Sequence)で、日本語の脅威シナリオを EQL に変換できる sequence + maxspan + by が真骨頂。順序と時間ウィンドウを 1 つのクエリで表現 セキュリティ運用の効率は、「使えるクエリ言語」と「使える脅威モデル」の組み合わせで決まります。EQL は前者の強力な武器です。 Elastic 公式の Detection Rules リポジトリ をフォローして、新ルールをキャッチアップ 参考資料 EQL Syntax Reference (Elastic 公式) Event Query Language Overview Event correlation (EQL) r ules | Elastic Security Elastic Common Schema (ECS) Reference ECS Categorization Field: event.type ECS File Fields ECS Process Fields Elastic Security Detection Rules(GitHub) MITRE ATT&CK Framework ElasticsearchのためのLuceneクエリ入門ガイド The post セキュリティエンジニアのための EQL 入門:Elastic で脅威を見つけるクエリ言語 first appeared on Elastic Portal .
次世代基盤モデル「Gemini 3.5」とマルチモーダルモデル「Gemini Omni」 Googleの基盤モデルに、処理速度とマルチモーダル性能を強化した新しいラインナップが追加されました。
はじめに こんにちは、タイミーでエンジニアをしている徳富( @yannkazu1 )です。 クラウドネイティブ会議2026 で発表された「 ペアーズ本番環境でのcgroup-aware化との死闘録 」がめちゃくちゃ面白かったので、自分の手でも体感したくなりました。 GoのGOMAXPROCSがコンテナのCPU制限を無視するって、実際に見るとどうなるのか? 過剰並列のスループット低下って、数字で見るとどのくらいインパクトがあるのか? スロットリングとスレッド数の関係を自分の目でたしかめたい! 自分で動かして数字を見ないと腑に落ちないタイプなので、 ローカルのMac環境で全部再現してみました。 発表の要約 ペアーズのバックエンド pairs-main はGo製でAmazon EKS上で稼働。48コアのNodeで limits.cpu: 5000m (5コア)のPodが動いていたが、 GoのGOMAXPROCSがデフォルトで48 (=Node全体のコア数)になっていた。これにより以下の問題が発生: 過剰並列 : 5コアしか使えないのに48スレッドが走る → Goスケジューラのオーバーヘッド増大 CPUスロットリング : cgroupのクォータ(CPU時間の上限)をスレッドが共食い → 全スレッドが同時に停止 監視の死角 : CPU使用率は正常に見えるが、実際はスロットリングで断続的に停止 同じ問題がHAProxy( nbthread=48 、CPU制限1コア)でも発生していた。 これらをcgroup-awareな設定(GOMAXPROCS=5, nbthread=1)に修正したところ、大幅に改善した、という話でした。 用語の整理 ここから先で出てくる「コア」「GOMAXPROCS」「クォータ」「スロットリング」あたりがピンと来なくても大丈夫です。記事全体で繰り返し登場するので、最初にざっくり整理しておきます(すでに馴染みがある方はスキップでOK)。 CPUコア・プロセス・スレッド 用語 ざっくりした意味 CPUコア 計算を実行する物理的な実体。1コア = 同時に1つの処理を進められる プロセス 動いているプログラム1つ分の単位 スレッド プロセス内で実際にCPUに割り当てられる作業の単位。1プロセスは複数スレッドを持てる ざっくり言うと、 コアの数 = 同時に進められるスレッドの数の上限 です。8コアのCPUなら、ある一瞬に進行できるのは最大8スレッドまで。それ以上のスレッドを立ち上げた場合は、OSが順番にコアを割り当て直しながら回します(= コンテキストスイッチ)。 コンテナと cgroup 用語 ざっくりした意味 コンテナ 同じサーバー上で複数のアプリを互いに干渉しないように動かす仕組み(Docker や Kubernetes の中身)。実体はホストのカーネルをそのまま使う 「namespaces で見える範囲を、cgroup で使える量を制限したプロセス(群)」 にすぎず、VM のように専用カーネルを持つわけではない cgroup (Control Groups) Linuxカーネルの機能で「このプロセス群はCPUをここまで・メモリはここまで」と上限を設定する仕組み CPU制限 「このコンテナはCPU 1コア分まで」のような上限設定。実体は cgroup の cpu.max ファイル コンテナの「CPU 0.5コアまで」という設定は、Linuxカーネルが cgroup を通じて「100msのうち50msまでしかCPUを使わせない」という形で強制します。この 100msの枠を「ピリオド」、その中で使ってよい時間量を「クォータ」 と呼びます( cpu.max: 50000 100000 なら「100msのうち50ms使える = 0.5コア相当」)。 CFS スケジューラ Linux のデフォルトの CPU スケジューラを CFS(Completely Fair Scheduler) と呼びます。先ほどの「ピリオド」「クォータ」は、CFS が持つ 帯域制御(Bandwidth Controller) という機能の用語で、cgroup の cpu.max の値を実際にスレッドへ適用する(=クォータを使い切ったら停止させる)のはこの CFS の仕事です。 つまり「cgroup が制限値を持ち、CFS がそれを実施する」という分担関係。後の実験で出てくる nr_periods (CFS が時間を区切る単位の総数)や nr_throttled (CFS が停止させたピリオドの数)も、この CFS 帯域制御の統計を見ています。 Goroutine と GOMAXPROCS(Go特有の話) 用語 ざっくりした意味 goroutine Goの軽量スレッド。OSスレッドより遥かに軽く、1プロセスで数万〜数百万個立ち上げられる OSスレッド OSが実際にCPUにスケジュールするスレッド。コアを取り合うのはこちら GOMAXPROCS Goランタイムが同時に走らせるOSスレッドの数の上限。デフォルトはホストのCPUコア数 goroutine を何万個立ち上げても、Goランタイムは GOMAXPROCS 個の OSスレッドの上にそれらを多重化して実行します。つまり同時に CPU を握っているのは最大でも GOMAXPROCS 個。この割り当てを管理するのが Goスケジューラ です。 ポイントは、 コンテナのCPU制限が下がってもデフォルトの GOMAXPROCS はホストのCPU数のまま ということ。これがそもそも今回のテーマで、後の実験でその挙動を実際に確かめます。 過剰並列 CPU 制限よりも多くのスレッド(や goroutine、ワーカー)を同時に走らせている状態 を指します。たとえば 5 コア相当の CPU 制限に対して GOMAXPROCS=48 なら、約 9.6 倍の過剰並列。実際に走れるのは制限分のスレッドだけなので、残りはスケジューラの上で順番待ちをしつつ、共有クォータを早食いし合うことになります。 Go の GOMAXPROCS に限った話ではなく、HAProxy の nbthread 、Nginx の worker_processes 、Puma の workers など、 「並列数のデフォルトがホスト CPU 数に依存する」設定はすべて同じ構造で過剰並列を起こします 。 CPUスロットリング cgroupでCPU 0.5コア分に制限されたコンテナが、たくさんのスレッドでCPUを一気に使おうとすると、Linuxカーネルが 「クォータを使い切ったので、次のピリオドまで全スレッド一時停止」 と強制的にブロックします。これが CPUスロットリング です。 スロットリングが頻発すると、レスポンスが断続的に止まったり、スループットが落ちたりします。その結果、「なぜか遅延がスパイクする」原因になっているケースが多いです。発生状況は /sys/fs/cgroup/cpu.stat に出力されており、本記事では以下の3指標を追います: nr_periods : スケジューラの計測単位(ピリオド = 100ms)の総数 nr_throttled : そのうちスロットリングが起きたピリオドの数(回数) throttled_usec : スロットリングで実際にCPUが止められた累積時間(マイクロ秒) 「回数」だけでなく「 累積停止時間 」も見るのが重要だ、というのが発表の山場の一つで、後の実験3でその違いがハッキリ出ます。 Thundering Herd スロットリングで停止していた全スレッドが、 次のピリオドのリセットで一斉に走り出し、また一瞬でクォータを食い潰して同時に止まる 、というサイクルが繰り返される状態を 「Thundering Herd(雷鳴の群れ)」 と呼びます。元はソケット accept など I/O 文脈の用語ですが、cgroup の帯域制御下でも同じ構造の問題が起きます。スレッド数が多いほど被害が大きくなるのは、ここに端を発しています。実験4でその挙動を観察します。 cgroup-aware プログラムやライブラリが cgroup の制限( cpu.max など)を自分で読み取り、その値に合わせて並列度を調整する 設計のことを 「cgroup-aware」 と呼びます。Go 1.25 以降のランタイムや uber-go/automaxprocs は cgroup-aware に GOMAXPROCS を設定します。逆に Go 1.24 以前のように cgroup を見ずにホストの CPU 数だけ見る挙動は「cgroup-aware ではない」状態で、今回の過剰並列はそこから生まれています。 この記事で検証すること # 検証テーマ 発表でのポイント 1 GOMAXPROCSのデフォルト値 コンテナのCPU Limitを無視してホストのCPU数になる 2 過剰並列のパフォーマンス影響 GOMAXPROCSが大きすぎるとスループットが低下する 3 CPUスロットリングの発生 スレッド数が多いほどクォータを早く消費し、停止時間が増える 4 スレッド数とスロットリングの相関 スレッド数に比例して throttled_usec が増加する 1. ローカル環境構築(Mac) 前提条件 macOS (Apple Silicon / Intel 両対応) Docker Desktop がインストール済み なぜDockerで検証できるのか cgroup(Control Groups)は Linuxカーネルの機能 で、macOS 自体には存在しません。しかし Docker Desktop は内部で Linux VM を動かしており、コンテナはその Linux 上で動作します。 ┌─────────────────────────────────────────────┐ │ macOS │ │ ┌────────────────────────────────────────┐ │ │ │ Docker Desktop (Linux VM) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ コンテナ │ │ │ │ │ │ /sys/fs/cgroup/cpu.max ← ここ! │ │ │ │ │ │ /sys/fs/cgroup/cpu.stat │ │ │ │ │ └──────────────────────────────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ Docker の --cpus フラグは Kubernetes の limits.cpu と同じく cgroup の cpu.max に変換されます。つまり Kubernetes と同じ仕組みをローカルで再現 できます。 Docker Kubernetes cgroup v2 --cpus=0.5 limits.cpu: 500m cpu.max: 50000 100000 --cpus=1.0 limits.cpu: 1000m cpu.max: 100000 100000 --cpus=5.0 limits.cpu: 5000m cpu.max: 500000 100000 セットアップ手順 Step 1: Docker Desktop のインストール Docker Desktop for Mac からインストール。 docker --version # Docker version 27.x.x, build xxxxxxx Step 2: 検証用 Go アプリケーション 本記事の検証コードは以下のリポジトリにまとめています: hirosi1900day/cgroup-throttling-lab git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab 3つのモードを持つGoアプリケーションを書きました。 モード 用途 info GOMAXPROCSの値とcgroupの設定を表示 benchmark CPU負荷をかけてスループットを計測 throttle-demo CPU負荷をかけてスロットリングの Before/After を表示 コード解説 各パートを順に見ていきます。 1. CPU負荷を発生させる関数 // cpuIntensiveWork はCPU負荷をかける計算処理 // 平方根と三角関数を1万回ループし、意図的にCPUを使い切る func cpuIntensiveWork() float64 { result := 0.0 for i := 0 ; i < 10000 ; i++ { result += math.Sqrt( float64 (i)) * math.Sin( float64 (i)) } return result } この関数が実験の要です。 math.Sqrt と math.Sin の計算を1万回繰り返すことで、 純粋なCPU負荷 を発生させます。I/O待ちが一切ないので、GOMAXPROCS(=ワーカースレッド数)の影響がダイレクトに現れます。 2. infoモード — GoランタイムとcgroupのCPU設定を表示 func showRuntimeInfo() { // runtime.GOMAXPROCS(0) は「現在の値を返し、変更しない」 // ← これがコンテナのCPU制限と一致しているかがポイント fmt.Printf( "GOMAXPROCS: %d \n " , runtime.GOMAXPROCS( 0 )) fmt.Printf( "NumCPU: %d \n " , runtime.NumCPU()) // --- cgroup のCPU制限を直接読む --- // /sys/fs/cgroup/cpu.max は cgroup v2 のCPU制限ファイル // 中身は "クォータ ピリオド" の形式(例: "100000 100000") // Kubernetes の limits.cpu や Docker の --cpus がここに反映される if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.max" ); err == nil { fmt.Printf( "cpu.max: %s" , string (data)) } // /sys/fs/cgroup/cpu.stat はCPUスロットリングの統計情報 // nr_periods: CFSスケジューラのピリオド(100ms)の総数 // nr_throttled: スロットリングが発生したピリオドの数 // throttled_usec: スロットリングでCPUが停止した累積時間(μs) if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.stat" ); err == nil { fmt.Printf( "cpu.stat: \n %s" , string (data)) } } このモードでは、 GoランタイムがcgroupのCPU制限を認識しているか を見ます。Go 1.24以前では、 GOMAXPROCS がホストのCPU数のままなのが確認できるはずです。 3. benchmarkモード — スループットの計測 func runBenchmark() { // 環境変数でベンチマーク時間とgoroutine数を制御可能にしている duration := 10 * time.Second // BENCH_DURATION で変更可 goroutines := 100 // BENCH_GOROUTINES で変更可 // --- ここからが計測のコア --- var totalOps atomic.Int64 // goroutine間で安全にカウントを共有 var wg sync.WaitGroup // 全goroutineの完了を待つ // タイマーで終了を通知するチャネル done := make ( chan struct {}) go func () { <-time.After(duration) close (done) // ← 全goroutineに「終了」を伝える }() // goroutines個のgoroutineを起動し、それぞれが独立にCPU負荷をかける // これらのgoroutineは GOMAXPROCS 個のワーカースレッドに // Goスケジューラによって割り当てられる for i := 0 ; i < goroutines; i++ { wg.Add( 1 ) go func () { defer wg.Done() localOps := int64 ( 0 ) // goroutineローカルでカウント(競合を避ける) for { select { case <-done: totalOps.Add(localOps) // 最後にまとめて加算 return default : cpuIntensiveWork() // CPU負荷をかけ続ける localOps++ } } }() } wg.Wait() // Ops/sec = 単位時間あたりの処理回数 // この値が高いほどスループットが良い opsPerSec := float64 (totalOps.Load()) / elapsed.Seconds() } 100個のgoroutineが cpuIntensiveWork() を呼び続け、それらがGOMAXPROCS個のOSスレッド上でスケジュールされる構造。CPU制限がある環境では、スレッドが多いほどcgroupのクォータを早く使い切る、スロットリングでスループットが落ちるわけです。 (脱線)ベンチマークコードの工夫 — キャッシュコヒーレンシの話 cgroup の検証とは直接関係ないですが、このベンチマークコードには「計測自体が結果を歪めないための工夫」が入っています。せっかくなので解説します。 select + default でノンブロッキングに終了チェックしつつCPU処理を回し続ける、というのはGoの定番パターンなので軽く触れるだけにして、本題はカウンタの設計です。 localOps := int64(0) // goroutineローカル(普通のint) for { select { case <-done: totalOps.Add(localOps) // ← 終了時に1度だけatomic操作 return default: cpuIntensiveWork() localOps++ // ← 普通のインクリメント。超高速 } } ループ内では localOps++ (普通の int インクリメント)だけを使い、終了時に1度だけ totalOps.Add(localOps) ( atomic 操作)で合算しています。 「毎回 totalOps.Add(1) でいいのでは?」と思うかもしれませんが、それだと100個の goroutine が同じメモリアドレスに毎ループ書き込み合い、 キャッシュコヒーレンシ(Cache Coherency) のオーバーヘッドで性能が大きく落ちます。 キャッシュコヒーレンシとは まず前提として、CPUがデータにアクセスする仕組みを整理しておきます。 CPU のメモリ階層 CPUが変数やデータを読み書きするとき、毎回メインメモリ(DRAM)まで取りに行くのは遅すぎます。そこで CPUは メモリ階層(Memory Hierarchy) という多段のキャッシュ構造を持っています: ┌─────────────────────────────────────────────────┐ │ CPU コア │ │ ┌───────────┐ │ │ │ レジスタ │ ← 最速(~0.3ns)、数十〜数百個 │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L1 キャッシュ│ ← 32〜64KB / コア、~1ns │ │ │ (データ+命令)│ │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L2 キャッシュ│ ← 256KB〜1MB / コア、~3-10ns │ │ └─────┬─────┘ │ │ │ ┌──────────┐ │ │ │ │ TLB │ ← 仮想→物理アドレス │ │ │ │ │ 変換のキャッシュ │ │ │ └──────────┘ │ └────────┼────────────────────────────────────────┘ ┌─────┴─────┐ │ L3 キャッシュ│ ← 数MB〜数十MB、全コア共有、~10-30ns └─────┬─────┘ ┌─────┴──────────────────┐ │ メインメモリ(DRAM) │ ← 数GB〜数百GB、~50-100ns └─────┬──────────────────┘ ┌─────┴──────────────────┐ │ ストレージ(SSD/HDD) │ ← ~10,000ns (SSD) 〜 10,000,000ns (HDD) └───────────────────────┘ 階層 容量 レイテンシ 特徴 レジスタ 数百バイト ~0.3ns CPUが直接演算する場所 L1キャッシュ 32〜64KB/コア ~1ns データ用と命令用に分離。コアごとに専有 L2キャッシュ 256KB〜1MB/コア ~3-10ns コアごとに専有(アーキテクチャによる) L3キャッシュ 数MB〜数十MB ~10-30ns 全コア共有 。ここがコア間のデータの橋渡し TLB 数百〜数千エントリ ~1ns(ヒット時) 仮想アドレス→物理アドレスの変換キャッシュ メインメモリ 数GB〜 ~50-100ns L1の50〜100倍遅い CPUが localOps++ を実行するとき、その変数がレジスタや L1 にあれば 1ns 以下で完了します。しかし L1 にない(キャッシュミス)と L2 → L3 → メインメモリと順にたどる必要があり、最悪100nsかかる。 L1ヒットとメインメモリアクセスでは約100倍の速度差 があるわけです。 TLB(Translation Lookaside Buffer) は少し役割が違って、仮想メモリのアドレス変換を高速化するキャッシュです。プロセスが使うメモリアドレス(仮想アドレス)を実際の物理アドレスに変換するにはページテーブルを引く必要がありますが、毎回引くとメモリアクセスが2倍になるので、よく使う変換結果を TLB にキャッシュしています。TLB ミスが発生すると ページテーブルウォーク が走り、数十nsの追加コストがかかります。goroutine が大量のスタックやヒープを使うワークロードでは、TLB ミスもパフォーマンスに効いてきます。 この前提を踏まえると、マルチコアでのキャッシュ一貫性がなぜ重要かがわかります。 キャッシュコヒーレンシ問題 マルチコアCPUでは、各コアが独自の L1/L2キャッシュ を持っています。あるコアが変数を更新すると、他のコアが持つ同じ変数のキャッシュラインは 古い値(stale) になります。これを放置すると各コアが異なる値を見てしまうため、ハードウェアレベルで一貫性を保つ仕組みが必要です。これが キャッシュコヒーレンシプロトコル (代表的なものに MESI プロトコル )です。 MESI プロトコルでは、キャッシュラインは以下の4状態を遷移します: 状態 意味 M odified 自コアだけが変更済みの値を持つ E xclusive 自コアだけが持っているが、メモリと同じ値 S hared 複数コアが同じ値を持っている(読み取り専用) I nvalid 他コアが更新したので、このキャッシュラインは無効 atomic 変数への書き込みが発生すると: 書き込むコアがキャッシュラインの 排他的所有権 を要求 他の全コアの同じキャッシュラインが Invalid に変わる(無効化) 次にそのコアが同じ変数にアクセスすると、 キャッシュミス → メモリ(or 他コアのキャッシュ)から再取得 これが毎ループ・100 goroutine で発生すると: [NG] 毎回 atomic(キャッシュラインのピンポン) コア1: totalOps.Add(1) → キャッシュライン取得 (Exclusive) → 値を更新 (Modified) → 他の全コアのキャッシュラインが Invalid に コア2: totalOps.Add(1) → Invalid なので再取得が必要(キャッシュミス!) → コア1から転送 → Exclusive → Modified → 他の全コアのキャッシュラインが Invalid に コア3: totalOps.Add(1) → また Invalid → また再取得...(以下ピンポン状態) → 実際のCPU計算ではなく、キャッシュの同期にCPU時間が消える このキャッシュラインの奪い合いは 「キャッシュラインバウンシング」 や 「false sharing」 (同じキャッシュラインに別の変数が乗っている場合)とも呼ばれ、マルチスレッドプログラミングの有名なパフォーマンス落とし穴です。 一方、ローカルカウンタなら: [OK] ローカルカウンタ + 最後に1回だけ atomic コア1: localOps++ → 自コアのレジスタ or L1キャッシュだけ。他コアに影響なし コア2: localOps++ → 同上。各goroutineが独立したメモリを触る ... (終了時だけ totalOps.Add → atomic 操作は10秒間で合計たった100回) 「 ベンチマークそのもののコストでベンチマーク結果が歪む 」のを防ぐテクニックです。cgroup のスロットリングを正確に測るなら、計測のオーバーヘッドは極力削っておきたい。 4. throttle-demoモード — スロットリングの観測 func runThrottleDemo() { // GOMAXPROCS個のワーカーを起動(= OSスレッド数と一致させる) numWorkers := runtime.GOMAXPROCS( 0 ) // Before: スロットリング前の統計を記録 // cpu.stat の nr_throttled, throttled_usec を確認 fmt.Println( "--- Before ---" ) readCgroupStat() // numWorkers個のgoroutineでCPU負荷をかける // GOMAXPROCS=8 なら8本、GOMAXPROCS=1 なら1本 // → スレッド数の違いがスロットリングにどう影響するかを観測 for i := 0 ; i < numWorkers; i++ { go func () { for { cpuIntensiveWork() // 全スレッドでCPU全開 } }() } // 5秒間 CPU負荷をかけた後... // After: スロットリング後の統計を記録 // Before との差分が「この5秒間で発生したスロットリング」 fmt.Println( "--- After ---" ) readCgroupStat() } GOMAXPROCS の値がそのままワーカー数になります。GOMAXPROCS=8 なら8スレッドが同時にCPUを使おうとするので、共有クォータを一瞬で食い潰します。Before/After の throttled_usec の差分で、 実際にどれだけCPUが止められたか がわかります。 Dockerfile # ビルドステージ: Go 1.24 でコンパイル FROM golang:1.24-bookworm AS builder WORKDIR /app COPY go.mod ./ COPY main.go ./ RUN go build -o /app/cgroup-bench . # 実行ステージ: 軽量なイメージで実行 FROM debian:bookworm-slim COPY --from=builder /app/cgroup-bench /usr/local/bin/cgroup-bench ENTRYPOINT ["cgroup-bench"] CMD ["info"] Go バージョンについて Go の最新安定版 : 1.26.3(2026年5月時点) container-aware GOMAXPROCS が導入されたバージョン : Go 1.25 本記事で使うバージョン : Go 1.24(1.25直前の最終版) Go 1.25以降ではランタイムがcgroupの cpu.max を自動で読み取り、GOMAXPROCSをCPU制限に合わせて設定します。今回は 問題が発生していた当時の挙動を再現 するため、あえてGo 1.24を使用しています。 main.go 全文(クリックで展開) ```go package main import ( "encoding/json" "fmt" "math" "os" "runtime" "strconv" "sync" "sync/atomic" "time" ) type Result struct { GOMAXPROCS int `json:"gomaxprocs"` NumCPU int `json:"num_cpu"` CPULimit string `json:"cpu_limit"` Duration time.Duration `json:"duration_ns"` DurationStr string `json:"duration"` TotalOps int64 `json:"total_ops"` OpsPerSec float64 `json:"ops_per_sec"` GoroutineCount int `json:"goroutine_count"` } func cpuIntensiveWork() float64 { result := 0.0 for i := 0; i < 10000; i++ { result += math.Sqrt(float64(i)) * math.Sin(float64(i)) } return result } func main() { mode := "benchmark" if len(os.Args) > 1 { mode = os.Args[1] } switch mode { case "benchmark": runBenchmark() case "info": showRuntimeInfo() case "throttle-demo": runThrottleDemo() } } func showRuntimeInfo() { fmt.Println("=== Go Runtime Information ===") fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Printf("NumCPU: %d\n", runtime.NumCPU()) fmt.Printf("GOVERSION: %s\n", runtime.Version()) envGOMAXPROCS := os.Getenv("GOMAXPROCS") if envGOMAXPROCS == "" { fmt.Println("ENV GOMAXPROCS: (not set — using default)") } else { fmt.Printf("ENV GOMAXPROCS: %s\n", envGOMAXPROCS) } fmt.Println("\n=== cgroup CPU Information ===") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil { fmt.Printf("cpu.max: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.weight"); err == nil { fmt.Printf("cpu.weight: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("cpu.stat:\n%s", string(data)) } } func runBenchmark() { duration := 10 * time.Second if d := os.Getenv("BENCH_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } goroutines := 100 if g := os.Getenv("BENCH_GOROUTINES"); g != "" { if parsed, err := strconv.Atoi(g); err == nil { goroutines = parsed } } maxprocs := runtime.GOMAXPROCS(0) var totalOps atomic.Int64 var wg sync.WaitGroup done := make(chan struct{}) go func() { <-time.After(duration) close(done) }() start := time.Now() for i := 0; i < goroutines; i++ { wg.Add(1) go func() { defer wg.Done() localOps := int64(0) for { select { case <-done: totalOps.Add(localOps) return default: cpuIntensiveWork() localOps++ } } }() } wg.Wait() elapsed := time.Since(start) ops := totalOps.Load() opsPerSec := float64(ops) / elapsed.Seconds() fmt.Printf("GOMAXPROCS=%d Ops/sec=%.2f Total=%d\n", maxprocs, opsPerSec, ops) jsonData, _ := json.Marshal(Result{ GOMAXPROCS: maxprocs, OpsPerSec: opsPerSec, TotalOps: ops, }) fmt.Printf("JSON: %s\n", string(jsonData)) if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("\ncpu.stat:\n%s", string(data)) } } func runThrottleDemo() { fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Println("\n--- Before ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } numWorkers := runtime.GOMAXPROCS(0) duration := 5 * time.Second if d := os.Getenv("DEMO_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } var wg sync.WaitGroup stop := make(chan struct{}) go func() { <-time.After(duration) close(stop) }() for i := 0; i < numWorkers; i++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: cpuIntensiveWork() } } }() } wg.Wait() fmt.Println("\n--- After ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } } ``` Step 3: ビルド docker build -t cgroup-bench go-app/ これで準備完了です。 2. 実験と結果 検証環境: - macOS(Apple Silicon) - Docker Desktop - Docker VM: 11コア (ここがKubernetesの「48コアNode」に相当) 実験1: GOMAXPROCS はコンテナの CPU 制限を無視する 何を確認するか 発表では、コンテナの limits.cpu: 5000m に対して GOMAXPROCS が 48(ノードのコア数)になっていたことが、問題の発端でした。まずは、 GoランタイムがcgroupのCPU制限を見ていない という状態をローカルで確認します。 実行コマンド # A: CPU制限なし docker run --rm cgroup-bench info # B: CPU制限 1コア docker run --rm --cpus=1.0 cgroup-bench info # C: CPU制限 0.5コア docker run --rm --cpus=0.5 cgroup-bench info 実際の結果 A: CPU制限なし === Go Runtime Information === GOMAXPROCS: 11 ← Docker VMの全CPUコア数 NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: max 100000 ← "max" = 上限なし B: CPU制限 1コア( --cpus=1.0 ) === Go Runtime Information === GOMAXPROCS: 11 ← 制限をかけたのに11のまま! NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: 100000 100000 ← cgroupには1コア分の制限が正しく設定されている C: CPU制限 0.5コア( --cpus=0.5 ) === Go Runtime Information === GOMAXPROCS: 11 ← まだ11のまま! NumCPU: 11 === cgroup CPU Information === cpu.max: 50000 100000 ← cgroupには0.5コア分の制限が設定されている 結果を見てみる CPU制限 cpu.max(cgroup) GOMAXPROCS 過剰並列の倍率 なし max 100000 (無制限) 11 - 1コア 100000 100000 11 11倍 0.5コア 50000 100000 11 22倍 完全に無視してます。cgroupには cpu.max として正しくCPU制限が設定されているのに、 Go 1.24のランタイムは一切見ていない 。GOMAXPROCSは常にホスト(Docker VM)のCPU数=11がデフォルト。 発表の本番環境では48コアNodeで limits.cpu: 5000m だったので、 GOMAXPROCS=48(約10倍の過剰並列) が起きていた。ローカルでも同じ構造の問題を再現できました。 cpu.max の読み方 : クォータ ピリオド の形式。ピリオド(デフォルト100ms=100000μs)のうち、クォータ分だけCPUを使える。 50000 100000 なら「100msのうち50ms使用可能 = 0.5コア分」。 実験2: 過剰並列はスループットを低下させる 何を確認するか 発表では GOMAXPROCS を48→5に変えたらスループットが大幅改善、Goスケジューラの CPU使用率が50%以上減ったとのこと。同じ体験をローカルでも数字で見てみます。 実行コマンド CPU制限1コアの環境で、100個のgoroutineを10秒間走らせます。変えるのはGOMAXPROCSだけ。 # GOMAXPROCS=1(CPU制限に一致 = 適切) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=1 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark # GOMAXPROCS=8(CPU制限の8倍 = 過剰並列) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=8 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark 実際の結果 GOMAXPROCS=1(適切な並列数): GOMAXPROCS=1 Ops/sec=21503.63 Total=215525 cpu.stat: nr_periods 101 nr_throttled 56 throttled_usec 43791 GOMAXPROCS=8(過剰並列): GOMAXPROCS=8 Ops/sec=6832.54 Total=68646 cpu.stat: nr_periods 102 nr_throttled 101 throttled_usec 70703432 結果を見てみる 指標 GOMAXPROCS=1 GOMAXPROCS=8 差分 Ops/sec(スループット) 21,504 6,833 68.2% 低下 nr_throttled / nr_periods 56/101 (55%) 101/102 ( 99% ) ほぼ全ピリオドで停止 throttled_usec(累積停止時間) 43,791μs (0.04秒) 70,703,432μs (70.7秒) 1,614倍 正直、ここまで差が出るとは思っていませんでした。 GOMAXPROCS を1→8にするだけで、 スループットが約3分の1に落ちる 10秒間のテストで 累計70.7秒ものCPU停止 (8スレッドが各約8.8秒ずつ止まった計算) スロットリング率99% — ほぼ毎ピリオドで全スレッドが止められている 発表で説明されていた「 クォータをスレッドが共食いする 」現象そのものです。 ┌──────── 1ピリオド (100ms) ────────┐ GOMAXPROCS=1 の場合: [████████ 実行 ████████][░░ 停止 ░░] ← 1スレッドで穏やかに使う GOMAXPROCS=8 の場合: [█ 8スレッド一斉実行 █][░░░░░░░░░░░░░░░░░░░░░░ 長時間停止 ░░░░░░░░░░░░░░░░░░░░░░] ↑ クォータ枯渇 ↑ 全スレッドが同時にスロットリング 実験3: スロットリングの深刻度はスレッド数で変わる 何を確認するか 発表で「 時間も見れば、ピリオドの%が同じでも深刻度の違いが分かる 」と指摘されていました。これ、実際に nr_throttled (回数)は同じくらいなのに throttled_usec (停止時間)には大きな差が出るということなので、自分の目で見てみます。 実行コマンド CPU制限0.5コア(かなり厳しい制限)でGOMAXPROCS=8 vs 1 を比較。 # 過剰並列(GOMAXPROCS=8, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=8 -e DEMO_DURATION=5s cgroup-bench throttle-demo # 適切な並列(GOMAXPROCS=1, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=1 -e DEMO_DURATION=5s cgroup-bench throttle-demo 実際の結果 GOMAXPROCS=8(過剰並列): --- After --- nr_periods 52 nr_throttled 51 ← 98%のピリオドでスロットリング throttled_usec 39039180 ← 39秒のCPU停止 GOMAXPROCS=1(適切): --- After --- nr_periods 51 nr_throttled 50 ← 98%のピリオドでスロットリング(ほぼ同じ!) throttled_usec 2644221 ← 2.6秒のCPU停止 結果を見てみる 指標 GOMAXPROCS=8 GOMAXPROCS=1 差分 nr_throttled / nr_periods 51/52 (98%) 50/51 (98%) ほぼ同じ throttled_usec 39,039,180μs (39秒) 2,644,221μs (2.6秒) 14.8倍 数字を自分で並べてみて、初めて深刻さがわかりました。 nr_throttled の割合(スロットリング率)だけ見るとどっちも98%で全く同じに見えます。でも throttled_usec (実際の停止時間)には14.8倍もの差がある。 これが発表で言われていた「CPU使用率だけでは気づけない」「監視の死角」の正体です。 なぜCPU使用率では見えないのか ここをもう少し掘り下げます。実はこの実験、 どちらのケースもCPU使用率は約100% と表示されます。「え、GOMAXPROCS=8 のほうが遅いのにCPU使用率は同じ?」と思うかもしれませんが、これにはカラクリがあります。 CPU使用率の計算式は本質的にこうです: $$ \text{CPU使用率} = \frac{\text{消費したCPU時間}}{\text{割り当てクォータ}} $$ 今回の実験では --cpus=0.5 なので、1ピリオド(100ms)あたりのクォータは 50ms です。 GOMAXPROCS=1 GOMAXPROCS=8 クォータ 50ms / period 50ms / period 消費CPU時間 50ms(使い切る) 50ms(使い切る) CPU使用率 ≈100% ≈100% 消費ペース 1スレッド × 50ms = 50msかけて徐々に 8スレッド × 6.25ms = 約6msで一気に 残りの時間 50ms間は停止(穏やか) 94ms間 全スレッド凍結 どちらもクォータ50msを使い切るので、CPU使用率は同じ100%です。しかし 消費のペースがまるで違います 。 1ピリオド(100ms)の内訳: GOMAXPROCS=1: |███████████████████████████░░░░░░░░░░░░░░░░░░░| ← 1スレッドで50ms実行 →← 50ms 待機 → CPU使用率: 50/50 = 100% レイテンシ: 安定 GOMAXPROCS=8: |████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| ←6ms→←───────── 94ms 全スレッド凍結 ──────────→ CPU使用率: 50/50 = 100% レイテンシ: スパイク発生 GOMAXPROCS=1 は1スレッドで50msを穏やかに消費するので、処理は途切れつつも比較的均等に進みます。一方 GOMAXPROCS=8 は8スレッドが同時にCPUを要求するため、わずか約6msでクォータを食い尽くし、 残りの94msは全スレッドが完全に凍結 します。 つまりCPU使用率100%の裏で起きていることが全く異なるのに、 集約メトリクスではその違いが消えてしまう 。これが「監視の死角」の本質です。 まとめると: nr_throttled率が同じでも、 8スレッドが同時に止まる のと 1スレッドだけ止まる のでは深刻度がまるで違う CPU使用率は クォータを消費した量 しか示さず、 消費のペース(バースト性)を一切反映しない throttled_usec を合わせて監視しないと、スロットリングの実態はつかめない 実験4: スレッド数と停止時間の相関 何を確認するか スレッド数を段階的に増やしたとき、 throttled_usec が比例して増えるのか。発表スライドの「 クォータはスレッド間で共有 」「 スレッドが多いほど早く消費 」という説明を、グラフで体感してみます。 実行コマンド for MAXPROCS in 1 2 4 8 16; do echo "--- GOMAXPROCS=$MAXPROCS ---" docker run --rm --cpus=1.0 -e GOMAXPROCS=$MAXPROCS -e DEMO_DURATION=5s \ cgroup-bench throttle-demo 2>&1 \ | grep -E "nr_periods|nr_throttled|throttled_usec" | tail -3 echo "" done 実際の結果 --- GOMAXPROCS=1 --- nr_periods 51 nr_throttled 20 throttled_usec 21885 --- GOMAXPROCS=2 --- nr_periods 51 nr_throttled 50 throttled_usec 5075001 --- GOMAXPROCS=4 --- nr_periods 51 nr_throttled 50 throttled_usec 15053306 --- GOMAXPROCS=8 --- nr_periods 52 nr_throttled 51 throttled_usec 35847841 --- GOMAXPROCS=16 --- nr_periods 51 nr_throttled 51 throttled_usec 50338309 結果を見てみる GOMAXPROCS nr_throttled スロットリング率 throttled_usec 累積停止時間 1 20 / 51 39% 21,885 0.02秒 2 50 / 51 98% 5,075,001 5.1秒 4 50 / 51 98% 15,053,306 15.1秒 8 51 / 52 98% 35,847,841 35.8秒 16 51 / 51 100% 50,338,309 50.3秒 停止時間 (秒) 50 ┤ ● GOMAXPROCS=16 │ ╱ 40 ┤ ╱ │ ╱ 35 ┤ ● ╱ GOMAXPROCS=8 │ ╱ ╱ 20 ┤ ╱ │ ╱ 15 ┤ ● ╱ GOMAXPROCS=4 │ ╱ ╱ 10 ┤ ╱ │ ╱ 5 ┤ ● ╱ GOMAXPROCS=2 │ ╲╱ 0 ┤● GOMAXPROCS=1 └──┬──┬──┬──┬──┬──┬── 1 2 4 8 12 16 GOMAXPROCS GOMAXPROCS=1〜8の範囲ではほぼ線形に比例しています。 GOMAXPROCS=1 → 0.02秒(ほぼ停止なし) GOMAXPROCS=16 → 50.3秒(5秒のテストで累計50秒分の停止!) ただし GOMAXPROCS=16 では、同時にCPUを使えるスレッド数が Docker VM の物理CPU数(11コア)で頭打ちになるため、純粋な線形モデルの予測(75秒)より低い50.3秒に飽和しています。16スレッド中、同時に実行できるのは最大11スレッドなので、停止時間は $\min(n, 11) \times P - Q$ に近づきます。 GOMAXPROCS=8 以下では物理CPU数の制約を受けないため、きれいに $n \times P - Q$ の線形モデルと一致しています(8スレッド時の予測35秒 vs 実測35.8秒)。 発表でいう Thundering Herd 問題 そのもので、クォータリセットで全スレッドが一斉に再開 → 共有クォータを瞬殺 → 全スレッド同時停止、のサイクルが繰り返される。 3. 考察: なぜスレッドを増やすと「遅くなる」のか 実験4の結果を改めて見ると、 throttled_usec がスレッド数にほぼ比例して増えています。「スレッドを増やすほど損をする」って、直感に反しますが、CFS 帯域制御の仕組みから数式で説明できます。 CFS 帯域制御の数理 — クォータ消費のモデル cgroup の CPU 制限は「1ピリオド(100ms)あたり $Q$ だけ CPU を使える」というクォータ制です。 --cpus=1.0 なら $Q = 100\text{ms}$ です。 $n$ 本のスレッドが同時にフル稼働すると、CPU 時間は $n$ 倍の速度で消費されます。つまり: クォータ枯渇までの時間 : $\frac{Q}{n}$ 残りのピリオド : 全 $n$ スレッドが同時に停止 1スレッドあたりの停止時間 : $\text{ピリオド} - \frac{Q}{n}$ 累積停止時間 (= throttled_usec ): $n \times \left(\text{ピリオド} - \frac{Q}{n}\right)$ -cpus=1.0 ($Q = 100\text{ms}$、ピリオド $= 100\text{ms}$)で $n = 8$ の場合: クォータ枯渇: $\frac{100}{8} = 12.5\text{ms}$ で使い切る 各スレッドの停止: $100 - 12.5 = 87.5\text{ms}$ 1ピリオドあたりの累積停止: $8 \times 87.5 = 700\text{ms}$ 5秒間(50ピリオド)なら $50 \times 700\text{ms} = 35\text{秒}$。実験4の GOMAXPROCS=8 の結果(35.8秒)とほぼ一致します。 USL で見るとスループット悪化も説明がつく この現象をスケーリング法則の観点から見ると、Neil Gunther の USL(Universal Scalability Law) が当てはまります: $$ S(n) = \frac{n}{1 + \alpha(n-1) + \beta \cdot n(n-1)} $$ パラメータ 意味 cgroup 環境での具体例 $\alpha$ 競合 — 直列化ペナルティ Go スケジューラのロック競合、ランキュー管理 $\beta$ 一貫性 — スレッド間の協調コスト CFS クォータの共有消費 + 全スレッド一斉停止 USL で効いてくるのは $\beta$ の項です。$\beta \cdot n(n-1)$ は $n 2 $ オーダーで増大するため、ある閾値を超えるとスループットが ピークから減少に転じます 。実験2の「GOMAXPROCS=8 で 68% 低下」は、この retrograde(逆行)領域に入った結果です。 スループット ↑ ● ピーク(GOMAXPROCS=1) │ ╱╲ │ ╱ ╲ ← USL の retrograde 領域 │ ╱ ╲ │╱ ╲ ● GOMAXPROCS=8(68%低下) └──────────→ スレッド数 cgroup制限下では「スレッド1本」がピーク。 増やすほどクォータの奪い合いで損をする。 通常の並列プログラミングでは「コア数まではスケールする」のが常識ですが、cgroup でリソースが制限された環境では スレッド1本がすでに最適解 という直感に反する結果になる。CPU 時間の総量が固定されたゼロサム環境なので、スレッドを増やすほど「クォータの奪い合い → 一斉停止 → 再開 → また枯渇」のサイクルが重くなるだけ。 「I/O 待ちがある場合はどうなのか?」 ここまでの実験は cpuIntensiveWork() による 純粋な CPU バウンド処理 です。「I/O 待ちがあるならスレッドを増やしたほうがいいんじゃ?」と思いますよね。一般論としてはその通りで、スレッドが I/O で待っている間は CPU クォータを消費しないので、CPU 数より多いスレッドが有効な場面はあります。 ただし Go の場合は話が別 です。Goランタイムには以下の仕組みがあるので、GOMAXPROCS を I/O のために増やす必要は基本的にないです: I/O の種類 Goランタイムの挙動 GOMAXPROCS への影響 ネットワーク I/O netpoller が非同期処理。goroutine は待つが OS スレッドはブロックしない 影響なし ブロッキング syscall (ファイル I/O, CGO 等) ランタイムが GOMAXPROCS とは 別に追加の OS スレッドを自動生成 影響なし つまり Go では、ネットワーク I/O は goroutine レベルで多重化され、ブロッキング I/O は GOMAXPROCS の枠外で処理される。GOMAXPROCS が制御するのは CPU を実際に使うスレッドの数 だけなので、I/O の多寡に関わらず GOMAXPROCS = CPU 制限 が正解です。 DB クエリや API 呼び出しを大量に行う Web サービスでも、GOMAXPROCS=5(CPU 制限に一致)で大幅に改善した事例があるのは、この仕組みがあるからです。 一方、Go 以外のランタイムでは、それぞれ事情が違うので整理しておきます: ランタイム 並列の仕組み cgroup の影響 Java スレッドプール(ForkJoinPool 等)で並列化。 Runtime.availableProcessors() の値を基準にプールサイズが決まることが多い スレッドプールサイズを CPU limit より大きい値にするとスロットリング発生 Node.js メインスレッドはシングル。 UV_THREADPOOL_SIZE (デフォルト4)で fs/dns 等のブロッキング I/O を処理。CPU バウンド処理は worker_threads で並列化 worker_threads の数を CPU limit より大きい値にするとスロットリング発生 Ruby (CRuby) GVL(Global VM Lock)があるため、スレッドを増やしても CPU バウンド処理は並列実行されない 。Puma 等の Web サーバーは workers (fork によるマルチプロセス)で並列化 Puma の workers を CPU limit より大きい値にするとスロットリング発生 4. 発表の内容をローカルで再現できたか? 全検証結果まとめ # 発表のポイント ローカル検証の結果 再現 1 GOMAXPROCSはcgroupのCPU制限を考慮しない(Go 1.24以前) --cpus=0.5 でも GOMAXPROCS=11(ホストCPU数)のまま 再現 2 limits.cpu は cgroup の cpu.max (クォータ/ピリオド)に変換される --cpus=0.5 → cpu.max: 50000 100000 を確認 再現 3 過剰並列はスループットを低下させる GOMAXPROCS 1→8 で Ops/sec が 68.2% 低下 (21,504 → 6,833) 再現 4 クォータはスレッド間で共有され、多いほど早く消費される スレッド数と throttled_usec がほぼ線形に比例 再現 5 スロットリング時は全スレッドが同時に停止する GOMAXPROCS=16で5秒間に累計50.3秒分の停止を確認 再現 6 CPU使用率だけではスロットリングに気づけない nr_throttled 率は同じ98%でも throttled_usec に14.8倍の差 再現 7 GOMAXPROCSをCPU制限に合わせると改善する GOMAXPROCS=1 で停止時間が 1/1,614 に改善 再現 すべて手元で再現できました。 Docker と Go 1.24 だけでここまで体験できるのは、やってみてよかったと素直に思います。 個人的に印象に残った数字 比較 過剰並列の場合 適切な並列の場合 倍率 Ops/sec(スループット) 6,833 21,504 3.1倍の差 throttled_usec(停止時間) 70.7秒 0.04秒 1,614倍の差 GOMAXPROCS=16の停止時間 50.3秒 - 5秒のテストで50秒停止 本番環境との対応関係 発表 ローカル検証 48コアNode 11コア Docker VM limits.cpu: 5000m --cpus=0.5 GOMAXPROCS=48(デフォルト) GOMAXPROCS=11(デフォルト) 過剰並列倍率: 約10倍 過剰並列倍率: 最大22倍 /sys/fs/cgroup/cpu.max 同じパス(Docker内Linux) cpu.stat の nr_throttled 同じメトリクス 本番ではさらにHAProxy( nbthread=48 , CPU制限1コア = 48倍の過剰並列 )でも同じ問題が起きていたそうで、Goに限った話ではないということがわかります。 まとめ 1. 並列設定を cgroup-aware にする GOMAXPROCS に限らず、 コンテナ内で動くすべてのプロセスの並列設定 は確認したほうがいいです。 ソフトウェア 並列設定 対処 Go GOMAXPROCS Go 1.25+ で自動対応 / 1.24以前は uber-go/automaxprocs Ruby (Puma) WEB_CONCURRENCY / workers CPU制限に合わせて明示指定。cgroup非対応の auto 設定に注意 Java スレッドプールサイズ JDK 10+ は availableProcessors() が cgroup 認識。ライブラリ側も要確認 Node.js worker_threads 数 CPU バウンド処理の並列数を CPU 制限に合わせる HAProxy nbthread 手動でCPU制限に合わせて設定 Nginx worker_processes auto はcgroup非対応の場合あり、明示指定が安全 2. スロットリングを監視する CPU使用率だけじゃなくて、 スロットリングのメトリクスもセットで見る 。これを怠ると実験3で見たような死角にハマります。 メトリクス Linux Datadog 停止時間 throttled_usec kubernetes.cpu.cfs.throttled.seconds 停止ピリオド数 nr_throttled kubernetes.cpu.cfs.throttled.periods 3. throttled_usec まで見る 今回の実験を通して一番の収穫は、 nr_throttled の割合が同じ 98% でも、 throttled_usec に 14.8倍の差がある と自分の手で確認できたこと。スロットリング率だけ見ても、 実際にどれだけ止まっているかは見えない 。 CPUをもっと知りたくなった方へ — 個人的なおすすめ本 今回の検証を通して「もっとCPUの中身を理解したくなった」という方に、個人的に強くおすすめしたい一冊があります。 「プログラマーのためのCPU入門 — CPUは如何にしてソフトウェアを高速に実行するか」 パイプライン、スーパースカラ、分岐予測、キャッシュ、メモリオーダリングといった、 普段は意識しないけれど性能に直結するCPU内部のメカニズム が、プログラマーの目線で一通り整理されている本です。本記事の脱線で触れたキャッシュコヒーレンシまわりも、この本を読むとより腑に落ちると思います。 「なぜこのコードは速いのか/遅いのか」を、ハードウェア寄りの視点から考えられるようになる本なので、cgroup の挙動の先を覗いてみたい方にぴったりです。 参考 ペアーズ本番環境でのcgroup-aware化との死闘録(発表スライド) — 本記事のベースとなった発表 クラウドネイティブ会議2026 セッションページ Container-aware GOMAXPROCS | Go 1.25 Release Notes uber-go/automaxprocs — Go 1.24以前で使えるcgroup-aware GOMAXPROCS Kubernetes CPU limits and requests: A deep dive | Datadog 検証コード 本記事の検証に使ったコードは以下のリポジトリにあります: git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab docker build -t cgroup-bench go-app/ ./scripts/run_experiments.sh # 全実験を一括実行




















