macOSのネットワーク階層:開発者向けガイド
macOSネットワークスタックを開発者目線で案内するガイド。socket(2)からNetwork.frameworkまで、各層の可観測性ポイントも踏まえて解説します。
- Developer tools
- macOS
- Networking
- Deep dive
遅いAPI呼び出しをデバッグしています。サーバログはレスポンスが80msで出たと言います。ユーザはページが4秒かかったと言います。回線とUIの間のスタックのどこかで時間が消えました——そして「ネットワーク」は有用なほど狭くない答えです。どこを見るか知るには、最下層のBSDソケット呼び出しから最上層のアプリが実際に使うフレームワークまで、macOSネットワーク層のメンタルマップが必要です。
これはmacOS上でパケットがどうURLResponseになるか、各層がどこでログを取り計装するか、各レベルでどのツールに手を伸ばすかの開発者向けツアーです。長いのは、スタックが正当な理由で層化されていて、すべてを理解する価値があるためです。
macOSネットワーク層、上から下へ
下から上へ、macOSネットワークスタックはおよそ次のように見えます:
- ハードウェアとカーネルドライバ — Wi-Fi用の
IO80211Family、イーサネット用のIOEthernetFamilyなどのIOKitファミリー。 - BSDネットワークスタック — IP、TCP、UDP、ソケットバッファ、パケットフィルタリング用の
pf。 - ネットワーク機能拡張 — コンテンツフィルタ、パケットトンネルプロバイダ、DNSプロキシ、新しい
nw_path機構。 socket(2)システムコール — 最も低いユーザ空間API。今では直接呼ばれることはまれ。- CFNetwork / Network.framework — 接続、パス、リスナー用のモダンなC/Swift API。
- NSURLSession(
URLSession) — FoundationのHTTP/HTTPSクライアント。ほぼすべてのアプリの既定。 - アプリレベルのライブラリ — Alamofire、AFNetworking、gRPC-Swift、WebSocketライブラリ、Apollo、
URLSessionをラップするもの何でも。 - ビュー層 — 最終的にレスポンスを描画するSwiftUI、UIKit、AppKitビュー。
ほとんどのアプリ開発者は層6〜8で時間を過ごし、それ以下すべてを「ネットワーク」として扱います。何かが壊れるまでは問題ありません。本物のバグ——タイムアウトしているTLSハンドシェイク、プロキシ認証で行き詰まったリクエスト、依存からの疑わしいトラフィック——を狩るときには、各macOSネットワーク層が何を提供しどこでログを取るか知る必要があります。
下層スタック: BSD、ネットワーク機能拡張、ソケット
下の3つのプログラム可能な層は密に組み合わさるので、1つの板として見るのが報われます。
層2: BSDスタック
en0にパケットが届くと、カーネルはそれをBSDネットワークスタックに通します。パケットがあなた宛か(宛先IPとポートをソケットに一致させる)を判断し、pf(BSDパケットフィルタ、macOSファイアウォール)を通し、受信ソケットのバッファにキューイングします。
この層はtcpdumpとWiresharkが住む場所。BPF(Berkeley Packet Filter)デバイスにタップし、カーネルがあまり処理していない生のパケットを見ます。
sudo tcpdump -i en0 -n 'tcp port 443'便利なBSDスタックレベルツール:
netstat -an— リッスン中のソケット、確立された接続、リッスンポートnetstat -rn— ルーティングテーブルpfctl -s rules— 現在のpfルールセット(ファイアウォールを有効にしない限りしばしば空)ifconfig/networksetup -listallnetworkservices— インターフェース状態
IP/TCP層に問題があると疑うときここで計装します: TCP再送ストーム、フラッピングしているルート、トラフィックを遮断するpfルール。
層3: ネットワーク機能拡張
ネットワーク機能拡張は、サードパーティアプリとApple自身がkext(廃止予定)を書かずにデータパスにフックする方法です。大きなカテゴリ:
- コンテンツフィルタプロバイダ — フローのメタデータを見て許可/拒否を決める。Little SnitchとLuLuがこれを使う。
- パケットトンネルプロバイダ — フルVPNスタイルのトンネリング。Tailscale、WireGuardアプリ、NordVPNなど。
- アプリプロキシプロバイダ — リモートエンドポイント経由のアプリ別ルーティング。
- DNSプロキシプロバイダ — 解決前にDNSクエリを傍受。
トラフィックが予期しない場所に行っているなら、ネットワーク機能拡張は最も可能性のある犯人の1つです。アクティブな拡張はsystemextensionsctl listで確認。
層4: socket(2)とその仲間
古典的なPOSIX API: socket()、bind()、connect()、send()、recv()、close()。今ではmacOSで直接このコードを書くことはほぼなく——Appleは新しいアプリには非推奨です。接続変更のためのnw_pathと統合せず、Happy Eyeballs(IPv4/IPv6デュアルスタック)を扱わず、TLSが無料で得られないからです。
しかし、依然として基盤です。すべての高レベルAPIは最終的にsocket()を呼びます。カーネルからプロセス帰属を読むとき——PROC_PIDFDSOCKETINFO付きのproc_pidinfo経由、または/dev/bpf経由——ソケット呼び出しでセットアップされた状態を読んでいます。
ここで計装するもの:
lsof -i -P -n— システム上のすべての開いたソケット、PIDとプロセス名付きproc_pidinfoAPI — Appleが祝福した「このPIDはどのソケットを開いているか」を尋ねる方法dtraceとdtruss— システムコールレベルのトレース(未署名スクリプトにはSIPオフが必要)InstrumentsNetworkテンプレート — ソケット呼び出し用にdtraceプローブをラップ
ovaのようなアプリ別通信量モニターはこの層を読みます。約1Hzでカーネルのプロセス別ソケットとインターフェースのバイトカウンタをサンプリングし、各PIDのバイトを親アプリバンドルに帰属させ、結果の時系列をローカルに保存します。パケット検査はありません——バイトはカーネルがnettopに公開するのと同じカウンタから来ます。
中層スタック: Network.frameworkとURLSession
ほとんどのアプリレベルネットワーキングはこの2つのAPIにあります。共通の系譜を持ちますが異なる問題を解きます。
層5: CFNetworkとNetwork.framework
CFNetworkは古いC API。Network.framework(2018年導入、Swift対応)はそのモダンな代替で、Appleが新しい低レベルネットワーキングコードに推奨するものです。
Network.frameworkが提供するもの:
- 外向き接続用の
NWConnection(TCP、UDP、QUIC、TLS、カスタムプロトコル) - 受信接続を受け付ける
NWListener - パスの可用性を観察する
NWPathMonitor(Wi-Fi vs セルラー vs イーサネット) - Bonjour発見用の
NWBrowser NWProtocolFramerとして組み立て可能なTLS、プロキシ、プロトコルスタック
ピアツーピアプロトコル、カスタムバイナリプロトコル、マルチパスTCPを書いたり、macOS用のサーバソフトウェアを実装したりするときに直接使います。HTTP形式のすべては1層上に行きます。
この層での計装: NWConnection.stateUpdateHandler、パス変更を監視するNWPathMonitor、統合ログサブシステムcom.apple.network(統合ログについては下を参照)。
層6: URLSession
アプリがHTTPを話すなら、ほぼ確実にURLSessionを使います。HTTP/1.1、HTTP/2、HTTP/3(QUIC)対応箇所、TLS、リダイレクト、キャッシング、クッキー、認証チャレンジを扱います。テスト用にトラフィックを傍受できるようNSURLProtocolとも統合されます。
ここで最も有用な計装フックは:
URLSessionDelegateとURLSessionTaskDelegate— すべてのリダイレクト、チャレンジ、完了を見るURLSessionTaskMetrics— タスク別のタイミング内訳: DNS、接続、セキュア接続、リクエスト、レスポンスURLSessionConfiguration.protocolClasses— テストですべてのリクエストをログするためにNSURLProtocolサブクラスを登録- サブシステム
com.apple.CFNetworkでのos_log— CFNetworkが発行する統合ログチャンネル
URLSessionTaskMetricsは過小評価されています。タスクごとに、DNS解決 vs TCP接続 vs TLSハンドシェイク vs リクエスト送信 vs サーバ処理 vs レスポンス送信に費やされた時間を正確に教えてくれます。遅いAPI呼び出し問題が実は遅いハンドシェイクなら、これがそれを証明するデータです。
func urlSession(_ session: URLSession,
task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics) {
for tx in metrics.transactionMetrics {
print("DNS:", tx.domainLookupEndDate?.timeIntervalSince(tx.domainLookupStartDate ?? .distantPast) ?? 0)
print("Connect:", tx.connectEndDate?.timeIntervalSince(tx.connectStartDate ?? .distantPast) ?? 0)
print("TLS:", tx.secureConnectionEndDate?.timeIntervalSince(tx.secureConnectionStartDate ?? .distantPast) ?? 0)
}
}ovaを動かしてみる
一目で分かるメニューバー通信量モニター——ローカル、署名済み、約3MB。
スタックの上層: ライブラリとビュー層
最上の2層は、ほとんどのアプリコードが時間を過ごす場所です。ここのバグはネットワーク問題と勘違いされやすい。
層7: アプリレベルのライブラリ
ほとんどのアプリはURLSessionをAlamofireやカスタムネットワーキングモジュールのようなものでラップします。これらのライブラリは通常ログ用にフックできるリクエスト完了シグナルを公開します。下の層の問題を覆い隠しうる独自のリトライ、スロットリング、キューイングロジックも持ちます。
デバッグ時:
- ライブラリが自動リトライをしているか確認——3リトライは1つの失敗リクエストをログ4行と回線上のバイト数4倍に変えうる。
- タイムアウト構成を確認。既定はしばしば意外(
URLSessionのリソースタイムアウトは60秒)。 - 隠れた並列性を探す。
maxConcurrentOperationCount = 1のOperationQueueは予期しない方法でリクエストを直列化します。
層8: ビュー層
時にバグはネットワークにまったくない。レスポンスは80msで届いたが、誰かが同期JSONデコードをメインスレッドに置いたか、レイアウトパスが800セルにわたって連鎖したため、ビューの描画に3.9秒かかった。InstrumentsのTime Profilerがこれの正しいツール——メインスレッド時間がJSONDecoder.decodeに費やされたのかUIView.layoutSubviewsに費やされたのか教えてくれます。
教訓: 測定するまでネットワークを責めないこと。URLSessionTaskMetrics+Time Profilerトレースで、ほとんどの「遅いページ」議論は10分で決着します。
統合ログ
すべての層を横断するのが統合ログ(os_log / Logger)です。ネットワーク関連のサブシステムには:
com.apple.CFNetwork— URLSessionレベルのイベントcom.apple.network— Network.frameworkイベントcom.apple.networkd— ネットワークデーモンイベントcom.apple.cfnetwork.NSURLConnection— レガシーNSURLConnectionログ
ネットワークログをライブで読む:
log stream --predicate 'subsystem == "com.apple.network"' --level debug直近1時間のCFNetworkイベントを読む:
log show --last 1h --predicate 'subsystem == "com.apple.CFNetwork"'問題が「リクエストがそもそもデバイスを離れなかった」のとき非常に役立ちます。ログはパスが満たされなかった、プロキシ自動構成スクリプトがDIRECTを返した、TLSサーバ証明書が特定の理由コードで拒否されたと教えてくれます。
正しいツールを選ぶ、いくつかの習慣
「この層に問題がある、どのツール?」のチートシート:
| 症状 | 可能性の高い層 | 最初のツール |
|---|---|---|
| アプリがどのホストにも到達できない | 2-3(BSD / NE) | ping、traceroute、systemextensionsctl list |
| 1つのホストが到達不能 | 2(BSD / DNS) | dig、nslookup、dscacheutil -q host |
| 遅いハンドシェイク | 5-6(TLS / URLSession) | URLSessionTaskMetrics、統合ログ |
| リクエストがデバイスを離れない | 5-6(Network.framework) | 統合ログcom.apple.network |
| アプリ別通信量予算が不明 | 4(カーネルカウンタ) | nettop、ova |
| 疑わしい宛先 | 2-3(BSD / NEフィルタ) | lsof -i、コンテンツフィルタ |
| ランダムな本文破損 | 6+(HTTP) | URLProtocolインターセプタ、パケットキャプチャ |
| 速いレスポンスの後の遅い描画 | 8(ビュー) | Instruments Time Profiler |
これらすべてに渡って報われるいくつかの習慣:
- 常に
taskMetricsをログに記録する。 リリース時でも。安価で後で何時間も節約します。 - すべての外向きリクエストにタグを付ける。
X-Request-Idのようなカスタムヘッダを設定し、クライアントログ行とサーバログ行に入れる。これで結合できます。 - アクティビティインジケータを信用しない。 何らかのリクエストが進行中であることだけを示し、どれかどれだけ保留しているかは示しません。
- コールドとウォームを別々にプロファイル。 起動後の最初のリクエストはDNS、TCP、TLS、可能性としてHTTP/3ネゴシエーションに当たる。10番目のリクエストは接続を再利用し10〜50倍速い。両方の数字が重要。
- 接続合体に注意。 HTTP/2とHTTP/3は証明書を共有するホスト間で単一の接続を再利用します。これによりホスト別の推論が難しくなります。
まとめ
macOSネットワーク層スタックは密ですが知ることはできます。最下層のsocket(2)から最上層のURLSessionとビュー層まで、各層は特定のレンズを提供します——パケット、フロー、リクエスト、タスク、フレーム。正しいレンズを選べば、バグは「ネットワークが遅い」から「証明書チェーンが毎回再取得されているのでapi.example.comへのTLSハンドシェイクに1.2秒かかる」に折りたたまれます。
ターミナルでnettopを動かさずに今どのアプリが通信量を使っているかのカジュアルで常時オンのビューが欲しいなら、ovaをインストールしましょう。メニューバーアプリ、約3MB、macOS 14以降で動作、約1Hzでサンプリング、すべてをローカル保存。テレメトリなし、リモートダッシュボードなし、買い切り。