Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 15814] ファイルの概要

このコミットは、Go言語の os/exec パッケージにおけるファイルディスクリプタ (FD) リークのバグを修正するものです。具体的には、Command オブジェクトの LookPath メソッドが失敗し、その後に StdinPipeStdoutPipe、または StderrPipe が呼び出された場合に、これらのパイプに関連するFDが適切にクリーンアップされずにリークする問題に対処しています。

コミット

commit 9db0583007e1f644b16d957c2e567ad5e5922338
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Mon Mar 18 09:52:39 2013 -0700

    os/exec: fix fd leak with Std*Pipe + LookPath
    
    If LookPath in Command fails, sets a sticky error, and then
    StdinPipe, StdoutPipe, or StderrPipe were called, those pipe
    fds were never cleaned up.
    
    Fixes #5071
    
    R=golang-dev, rogpeppe
    CC=golang-dev
    https://golang.org/cl/7799046
---
 src/pkg/os/exec/exec.go      |  2 ++
 src/pkg/os/exec/exec_test.go | 27 +++++++++++++++++++++++++++
 2 files changed, 29 insertions(+)

diff --git a/src/pkg/os/exec/exec.go b/src/pkg/os/exec/exec.go
index 8368491b0f..a3bbcf3005 100644
--- a/src/pkg/os/exec/exec.go
+++ b/src/pkg/os/exec/exec.go
@@ -235,6 +235,8 @@ func (c *Cmd) Run() error {
 // Start starts the specified command but does not wait for it to complete.
 func (c *Cmd) Start() error {
 	if c.err != nil {
+		c.closeDescriptors(c.closeAfterStart)
+		c.closeDescriptors(c.closeAfterWait)
 		return c.err
 	}
 	if c.Process != nil {
diff --git a/src/pkg/os/exec/exec_test.go b/src/pkg/os/exec/exec_test.go
index 611ac02676..dfcf4be231 100644
--- a/src/pkg/os/exec/exec_test.go
+++ b/src/pkg/os/exec/exec_test.go
@@ -151,6 +151,33 @@ func TestPipes(t *testing.T) {
 	check("Wait", err)
 }
 
+// Issue 5071
+func TestPipeLookPathLeak(t *testing.T) {
+	fd0 := numOpenFDS(t)
+	for i := 0; i < 4; i++ {
+		cmd := Command("something-that-does-not-exist-binary")
+		cmd.StdoutPipe()
+		cmd.StderrPipe()
+		cmd.StdinPipe()
+		if err := cmd.Run(); err == nil {
+			t.Fatal("unexpected success")
+		}
+	}
+	fdGrowth := numOpenFDS(t) - fd0
+	if fdGrowth > 2 {\n\t\tt.Errorf("leaked %d fds; want ~0", fdGrowth)
+	}
+}
+
+func numOpenFDS(t *testing.T) int {
+	lsof, err := Command("lsof", "-n", "-p", strconv.Itoa(os.Getpid())).Output()
+	if err != nil {
+		t.Skip("skipping test; error finding or running lsof")
+		return 0
+	}
+	return bytes.Count(lsof, []byte("\n"))
+}
+
 var testedAlreadyLeaked = false
 
 // basefds returns the number of expected file descriptors

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/9db0583007e1f644b16d957c2e567ad5e5922338

元コミット内容

os/exec: Std*PipeLookPath によるFDリークを修正。 Command 内の LookPath が失敗し、その後に StdinPipeStdoutPipe、または StderrPipe が呼び出された場合、これらのパイプのファイルディスクリプタはクリーンアップされなかった。 Issue #5071 を修正。

変更の背景

Go言語の os/exec パッケージは、外部コマンドを実行するための機能を提供します。このパッケージを使用する際、プロセス間通信のためにパイプ(StdinPipe, StdoutPipe, StderrPipe)を確立することがよくあります。これらのパイプは、内部的にファイルディスクリプタ(FD)を使用します。

このコミットが行われる前の os/exec パッケージには、特定の条件下でファイルディスクリプタがリークするという問題がありました。具体的には、exec.Command で指定された実行可能ファイルのパスを解決する LookPath メソッドが失敗した場合(例えば、指定されたコマンドが見つからない場合など)、Command オブジェクトは内部的にエラー状態(c.err)を設定します。

しかし、このエラー状態が設定された後でも、ユーザーが StdinPipe()StdoutPipe()、または StderrPipe() を呼び出すことが可能でした。これらのメソッドは、たとえコマンドの実行が不可能であることが事前に分かっていても、パイプを作成し、それに対応するファイルディスクリプタを割り当てていました。問題は、Command.Start() メソッドが c.err が設定されていることを検出してすぐにエラーを返した場合、これらの新しく作成されたパイプのファイルディスクリプタが閉じられずに残ってしまう点にありました。これにより、アプリケーションが多数のコマンド実行を試み、その多くが LookPath の失敗によって中断されるようなシナリオでは、システムのリソースであるファイルディスクリプタが徐々に枯渇し、最終的には「Too many open files」のようなエラーが発生する可能性がありました。

このリークは、特にサーバーアプリケーションや、頻繁に外部コマンドを呼び出すツールにおいて深刻な問題となり得ました。そのため、このファイルディスクリプタのリークを修正し、リソースの適切な解放を保証することが必要とされました。

前提知識の解説

1. ファイルディスクリプタ (File Descriptor, FD)

ファイルディスクリプタは、Unix系オペレーティングシステムにおいて、プロセスが開いているファイルやその他のI/Oリソース(パイプ、ソケットなど)を識別するために使用される抽象的なハンドルです。各プロセスは、開いているリソースごとに一意の非負の整数値のFDを受け取ります。プログラムがファイルやパイプを操作する際には、このFDを通じてOSにアクセスを要求します。FDは有限のリソースであり、リークが発生するとシステム全体の安定性に影響を与える可能性があります。

2. os/exec パッケージ

Go言語の標準ライブラリである os/exec パッケージは、外部コマンドを実行するための機能を提供します。主なコンポーネントは以下の通りです。

  • exec.Command(name string, arg ...string) *Cmd: 指定されたコマンドと引数を持つ Cmd オブジェクトを作成します。この関数は、コマンドの実行パスを解決するために内部的に LookPath を呼び出すことがあります。
  • Cmd 構造体: 実行するコマンドに関する情報(パス、引数、環境変数、標準入出力など)を保持します。
  • Cmd.Start() error: コマンドを新しいプロセスとして開始しますが、完了を待ちません。
  • Cmd.Run() error: コマンドを開始し、完了するまで待ちます。これは Start()Wait() の組み合わせに相当します。
  • Cmd.StdinPipe() (io.WriteCloser, error): コマンドの標準入力に接続するためのパイプを返します。
  • Cmd.StdoutPipe() (io.ReadCloser, error): コマンドの標準出力に接続するためのパイプを返します。
  • Cmd.StderrPipe() (io.ReadCloser, error): コマンドの標準エラー出力に接続するためのパイプを返します。
  • exec.LookPath(file string) (string, error): 指定されたファイル名(コマンド名)を、システムのPATH環境変数に基づいて実行可能なファイルの絶対パスに解決します。コマンドが見つからない場合はエラーを返します。

3. パイプ (Pipe)

パイプは、プロセス間でデータをやり取りするための単方向の通信チャネルです。os/exec パッケージでは、子プロセスの標準入出力と親プロセスの間でデータを送受信するためにパイプが使用されます。パイプは、読み取り側と書き込み側の2つのファイルディスクリプタによって表現されます。

4. リソースリーク (Resource Leak)

リソースリークとは、プログラムが使用したシステムリソース(メモリ、ファイルディスクリプタ、ネットワークソケットなど)を、使用後に適切に解放しないために、それらのリソースがシステム内に残り続ける現象を指します。リソースリークが続くと、利用可能なリソースが枯渇し、システムのパフォーマンス低下やクラッシュにつながる可能性があります。ファイルディスクリプタのリークは、特に長期間稼働するサーバーアプリケーションで問題となります。

技術的詳細

このFDリークは、os/exec パッケージの Cmd オブジェクトのライフサイクル管理における特定のシーケンスで発生していました。

  1. exec.Command の呼び出し: cmd := exec.Command("something-that-does-not-exist-binary") のように、存在しないコマンドを指定して Command を呼び出すと、内部的に exec.LookPath が実行されます。
  2. LookPath の失敗: LookPath は指定されたコマンドを見つけられないため、エラーを返します。このエラーは Cmd オブジェクトの内部フィールド c.err に設定されます。この時点で、コマンドは実行不可能であることが確定しています。
  3. Std*Pipe() の呼び出し: cmd.StdoutPipe()cmd.StderrPipe()cmd.StdinPipe() のいずれか、または複数が呼び出されます。これらのメソッドは、c.err が設定されているかどうかに関わらず、パイプを作成し、対応するファイルディスクリプタを割り当てます。
  4. Cmd.Run() または Cmd.Start() の呼び出し: cmd.Run() または cmd.Start() が呼び出されます。これらのメソッドは、実行を開始する前に c.err をチェックします。
  5. 早期リターンとリーク: c.err が設定されているため、Start() メソッドはすぐにエラーを返して終了します。このとき、ステップ3で作成されたパイプのファイルディスクリプタは閉じられることなく、開いたままになってしまいます。通常、これらのFDは子プロセスが開始され、親プロセスが Wait() を呼び出す際にクリーンアップされることを期待しますが、子プロセスが開始されないため、このクリーンアップパスが実行されません。

このコミットの修正は、Cmd.Start() メソッドの冒頭で、c.err が設定されている場合に、コマンドに関連付けられたファイルディスクリプタを明示的に閉じる処理を追加することで、このリークを防ぎます。c.closeDescriptors(c.closeAfterStart)c.closeDescriptors(c.closeAfterWait) が追加され、Start() が早期にエラーを返す前に、すでに割り当てられている可能性のあるパイプのFDを確実に閉じます。これにより、コマンドが実際に実行されなかった場合でも、リソースが適切に解放されるようになります。

テストケース TestPipeLookPathLeak は、この問題を再現し、修正が機能することを確認するために追加されました。このテストは、存在しないコマンドに対して複数回 CommandStd*Pipe を呼び出し、Run() を実行した後、開いているファイルディスクリプタの数を lsof コマンドを使用して計測します。FDの増加が期待値(ほぼ0)を超えないことを確認することで、リークが修正されたことを検証します。

コアとなるコードの変更箇所

--- a/src/pkg/os/exec/exec.go
+++ b/src/pkg/os/exec/exec.go
@@ -235,6 +235,8 @@ func (c *Cmd) Run() error {
 // Start starts the specified command but does not wait for it to complete.
 func (c *Cmd) Start() error {
 	if c.err != nil {
+		c.closeDescriptors(c.closeAfterStart)
+		c.closeDescriptors(c.closeAfterWait)
 		return c.err
 	}
 	if c.Process != nil {
--- a/src/pkg/os/exec/exec_test.go
+++ b/src/pkg/os/exec/exec_test.go
@@ -151,6 +151,33 @@ func TestPipes(t *testing.T) {
 	check("Wait", err)
 }
 
+// Issue 5071
+func TestPipeLookPathLeak(t *testing.T) {
+	fd0 := numOpenFDS(t)
+	for i := 0; i < 4; i++ {
+		cmd := Command("something-that-does-not-exist-binary")
+		cmd.StdoutPipe()
+		cmd.StderrPipe()
+		cmd.StdinPipe()
+		if err := cmd.Run(); err == nil {
+			t.Fatal("unexpected success")
+		}
+	}
+	fdGrowth := numOpenFDS(t) - fd0
+	if fdGrowth > 2 {
+		t.Errorf("leaked %d fds; want ~0", fdGrowth)
+	}
+}
+
+func numOpenFDS(t *testing.T) int {
+	lsof, err := Command("lsof", "-n", "-p", strconv.Itoa(os.Getpid())).Output()
+	if err != nil {
+		t.Skip("skipping test; error finding or running lsof")
+		return 0
+	}
+	return bytes.Count(lsof, []byte("\n"))
+}
+
 var testedAlreadyLeaked = false
 
 // basefds returns the number of expected file descriptors

コアとなるコードの解説

src/pkg/os/exec/exec.go の変更

Cmd.Start() メソッドの冒頭に以下の2行が追加されました。

	if c.err != nil {
		c.closeDescriptors(c.closeAfterStart)
		c.closeDescriptors(c.closeAfterWait)
		return c.err
	}
  • if c.err != nil: これは、Command オブジェクトが既にエラー状態にあるかどうかをチェックします。このエラーは、通常、exec.LookPath がコマンドを見つけられなかった場合などに設定されます。
  • c.closeDescriptors(c.closeAfterStart): closeAfterStart は、コマンドが開始された後に閉じるべきファイルディスクリプタのリストを保持しています。LookPath の失敗後でも Std*Pipe が呼び出された場合、これらのパイプのFDがこのリストに含まれる可能性があります。この呼び出しにより、それらのFDが明示的に閉じられます。
  • c.closeDescriptors(c.closeAfterWait): 同様に、closeAfterWait はコマンドの完了を待った後に閉じるべきFDのリストを保持しています。ここにもリークの原因となるFDが含まれる可能性があるため、同様に閉じられます。

これらの変更により、Start() メソッドが c.err のために早期にリターンする場合でも、それまでに作成されたパイプのファイルディスクリプタが確実に閉じられ、FDリークが防止されます。

src/pkg/os/exec/exec_test.go の変更

TestPipeLookPathLeak という新しいテスト関数が追加されました。

func TestPipeLookPathLeak(t *testing.T) {
	fd0 := numOpenFDS(t) // テスト開始時のオープンFD数を記録
	for i := 0; i < 4; i++ {
		cmd := Command("something-that-does-not-exist-binary") // 存在しないコマンドを作成
		cmd.StdoutPipe() // パイプを作成
		cmd.StderrPipe() // パイプを作成
		cmd.StdinPipe()  // パイプを作成
		if err := cmd.Run(); err == nil { // コマンドを実行 (LookPathが失敗するためエラーになるはず)
			t.Fatal("unexpected success") // 成功したらエラー
		}
	}
	fdGrowth := numOpenFDS(t) - fd0 // テスト後のオープンFD数と初期値の差を計算
	if fdGrowth > 2 { // FDの増加が2を超えたらリークと判断
		t.Errorf("leaked %d fds; want ~0", fdGrowth)
	}
}

func numOpenFDS(t *testing.T) int {
	lsof, err := Command("lsof", "-n", "-p", strconv.Itoa(os.Getpid())).Output()
	if err != nil {
		t.Skip("skipping test; error finding or running lsof")
		return 0
	}
	return bytes.Count(lsof, []byte("\n")) // lsofの出力行数からFD数をカウント
}
  • TestPipeLookPathLeak: このテストは、存在しないコマンドに対して Command を作成し、Std*Pipe を呼び出し、Run() を実行するという一連の操作を複数回繰り返します。これにより、FDリークが発生するシナリオをシミュレートします。
  • numOpenFDS(t *testing.T) int: このヘルパー関数は、現在のプロセスのオープンされているファイルディスクリプタの数を lsof コマンドを実行して取得します。lsof -n -p <pid> は、指定されたPIDのプロセスが開いているすべてのファイルに関する情報を表示し、その行数を数えることでFDの数を概算します。
  • テストの最後に、fdGrowth が2を超える場合にエラーを報告します。これは、ごく少数のFD(例えば、lsof 自体が使用するFDなど)の増加は許容しつつ、それ以上の増加をリークと見なすための閾値です。このテストの追加により、この特定のFDリーク問題が将来的に再発しないことを保証するための自動化された検証メカニズムが提供されました。

関連リンク

参考にした情報源リンク