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

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

このコミットは、Go言語の標準ライブラリであるos/execパッケージのテストスイートに、netパッケージが使用するepoll(またはそれに相当するI/O多重化メカニズム)のファイルディスクリプタ(FD)が、子プロセスに意図せず継承されないことを検証する新しいテストを追加するものです。これにより、子プロセスが不要なFDを保持することによるリソースリークやセキュリティリスクを防ぎます。

コミット

commit 178be83e0eb465156be32c69e59aba0f815fb746
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Mon Dec 19 09:23:07 2011 -0800

    exec: add test to verify net package's epoll fd doesn't go to child
    
    R=rsc
    CC=golang-dev
    https://golang.org/cl/5490075

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

https://github.com/golang/go/commit/178be83e0eb465156be32c69e59aba0f815fb746

元コミット内容

exec: add test to verify net package's epoll fd doesn't go to child

このコミットは、netパッケージが内部的に使用するepoll(または他のOSにおける同等のI/O多重化メカニズム)に関連するファイルディスクリプタが、os/execパッケージによって起動される子プロセスに誤って継承されないことを確認するためのテストを追加します。

変更の背景

Unix系OSでは、fork()システムコールによって子プロセスが生成されると、親プロセスの開いているファイルディスクリプタ(FD)がデフォルトで子プロセスに継承されます。これは多くの場合望ましい動作ですが、ネットワークソケットやepollインスタンスのような特定のFDは、子プロセスに継承されるべきではありません。

特に、netパッケージが内部的に使用するepoll FDが子プロセスに継承されると、以下のような問題が発生する可能性があります。

  1. リソースリーク: 子プロセスが終了しても、親プロセスが管理するepollインスタンスへの参照が残る可能性があり、リソースが適切に解放されない。
  2. セキュリティリスク: 子プロセスが親プロセスのネットワーク活動を監視したり、意図しない操作を行ったりする可能性がある。
  3. 予期せぬ動作: 子プロセスが親プロセスのepoll FDを操作しようとすると、競合状態やデッドロックを引き起こす可能性がある。

Goのランタイムは、子プロセスを起動する際に、不要なFDが継承されないようにCLOEXEC (Close-on-exec) フラグを設定する責任があります。このコミットは、netパッケージが生成するFDが正しくCLOEXECフラグでマークされていることを検証するためのテストを追加することで、この重要な動作が保証されるようにします。

前提知識の解説

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

Unix系OSにおいて、ファイルディスクリプタは、プロセスが開いているファイルやI/Oリソース(ソケット、パイプ、デバイスなど)を参照するための抽象的なハンドルです。各プロセスは、0から始まる整数値のFDを持ちます。

  • 0: 標準入力 (stdin)
  • 1: 標準出力 (stdout)
  • 2: 標準エラー出力 (stderr) これら以外のFDは、プログラムがファイルを開いたり、ソケットを作成したりする際にOSによって割り当てられます。

fork()とFDの継承

fork()システムコールは、現在のプロセス(親プロセス)のほぼ完全なコピーである新しいプロセス(子プロセス)を作成します。この際、親プロセスが開いているすべてのファイルディスクリプタは、デフォルトで子プロセスに継承されます。つまり、親プロセスがFD 3でファイルを開いていた場合、子プロセスもFD 3で同じファイルを開いている状態になります。

exec()CLOEXECフラグ

exec()システムコール群(例: execve(), execlp()など)は、現在のプロセスイメージを新しいプログラムで置き換えます。fork()exec()は通常組み合わせて使用され、新しいプロセスを起動する一般的な方法です(fork()で子プロセスを作成し、その子プロセスでexec()を呼び出して新しいプログラムを実行する)。

exec()が呼び出される際、デフォルトでは開いているFDは新しいプログラムに引き継がれます。しかし、特定のFDを新しいプログラムに引き継ぎたくない場合があります。このために、CLOEXEC (Close-on-exec) フラグが存在します。

CLOEXECフラグは、FDがexec()システムコールによって新しいプログラムが実行される際に自動的に閉じられるように設定するものです。これにより、子プロセスが不要なFDを継承するのを防ぎ、リソースリークやセキュリティ上の問題を回避できます。Goのos/execパッケージは、子プロセスを起動する際に、デフォルトでCLOEXECフラグを適切に設定するようになっています。

epoll (Linux) / kqueue (BSD/macOS) / I/O Completion Ports (Windows)

これらは、多数のI/O操作を効率的に多重化するためのOSレベルのメカニズムです。

  • epoll: Linuxカーネルが提供する高性能なI/Oイベント通知メカニズム。多数のソケットやファイルディスクリプタからのイベント(読み取り可能、書き込み可能など)を効率的に監視できます。ノンブロッキングI/Oと組み合わせて、高並行なネットワークサーバーなどで広く利用されます。
  • kqueue: FreeBSD、macOS、NetBSD、OpenBSDなどのBSD系OSで利用される同様のメカニズム。
  • I/O Completion Ports (IOCP): Windowsで利用される高性能な非同期I/Oメカニズム。

Goのnetパッケージは、これらのOS固有のI/O多重化メカニズムを内部的に利用して、ネットワーク接続の効率的な処理を実現しています。これらのメカニズム自体もファイルディスクリプタ(epoll_createはFDを返す)を使用するため、それらのFDも子プロセスに継承されないようにCLOEXECフラグが設定されている必要があります。

技術的詳細

このコミットは、os/exec/exec_test.goファイルにTestExtraFilesという既存のテスト関数を修正し、ネットワーク操作を強制的に実行することで、netパッケージが内部的に使用するFDが子プロセスに漏洩しないことを検証します。

テストの基本的な流れは以下の通りです。

  1. ネットワークリスナーの作成: net.Listen("tcp", "127.0.0.1:0")を呼び出して、一時的なTCPリスナーを作成します。これにより、netパッケージが内部的にソケットFDと、それを監視するためのepoll(またはOS固有のI/O多重化)FDを生成します。
  2. 子プロセスの起動: os/execパッケージを使用して子プロセスを起動します。この子プロセスは、親プロセスから特定のFD(このテストではFD 3)を継承し、その内容を読み取るように設定されています。
  3. FDの漏洩チェック: 子プロセス側で、継承されたFDのリストを走査し、予期しないFD(特にnetパッケージが生成したFD)が存在しないことを確認します。

重要なのは、net.Listenが呼び出されたときに作成されるFDが、子プロセスに継承されないようにCLOEXECフラグが設定されていることです。もし設定されていなければ、子プロセスは親プロセスのネットワークリスナーに関連するFDを継承してしまい、テストは失敗します。

コミットのコメントにあるTODO(bradfitz,iant): the rest of this test is disabled for now. remove this block once 5494061 is in.という部分は、当初はより厳密なFD漏洩チェック(FD 3以外のすべてのFDが閉じられていることを確認する)を意図していたものの、当時のGoのランタイムの制約により、一時的にその部分を無効化していたことを示唆しています。しかし、このコミットの主要な目的である「netパッケージのFDが子プロセスに漏洩しないこと」の検証は、ネットワークリスナーの作成と子プロセスの起動によって達成されています。

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

src/pkg/os/exec/exec_test.go

--- a/src/pkg/os/exec/exec_test.go
+++ b/src/pkg/os/exec/exec_test.go
@@ -10,6 +10,7 @@ import (
  	"fmt"
  	"io"
  	"io/ioutil"
+	"net"
  	"os"
  	"runtime"
  	"strconv"
@@ -146,6 +147,15 @@ func TestExtraFiles(t *testing.T) {
  		t.Logf("no operating system support; skipping")
  		return
  	}
+
+	// Force network usage, to verify the epoll (or whatever) fd
+	// doesn't leak to the child,
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ln.Close()
+
  	tf, err := ioutil.TempFile("", "")
  	if err != nil {
  		t.Fatalf("TempFile: %v", err)
@@ -167,7 +177,7 @@ func TestExtraFiles(t *testing.T) {
  	c.ExtraFiles = []*os.File{tf}
  	bs, err := c.CombinedOutput()
  	if err != nil {
-		t.Fatalf("CombinedOutput: %v", err)
+		t.Fatalf("CombinedOutput: %v; output %q", err, bs)
  	}
  	if string(bs) != text {
  		t.Errorf("got %q; want %q", string(bs), text)
@@ -246,6 +256,29 @@ func TestHelperProcess(*testing.T) {
  			fmt.Printf("ReadAll from fd 3: %v", err)
  			os.Exit(1)
  		}
+		// TODO(bradfitz,iant): the rest of this test is disabled
+		// for now. remove this block once 5494061 is in.
+		{
+			os.Stderr.Write(bs)
+			os.Exit(0)
+		}
+		// Now verify that there are no other open fds.
+		var files []*os.File
+		for wantfd := os.Stderr.Fd() + 2; wantfd <= 100; wantfd++ {
+			f, err := os.Open(os.Args[0])
+			if err != nil {
+				fmt.Printf("error opening file with expected fd %d: %v", wantfd, err)
+				os.Exit(1)
+			}
+			if got := f.Fd(); got != wantfd {
+				fmt.Printf("leaked parent file. fd = %d; want %d", got, wantfd)
+				os.Exit(1)
+			}
+			files = append(files, f)
+		}
+		for _, f := range files {
+			f.Close()
+		}
  		os.Stderr.Write(bs)
  	case "exit":
  		n, _ := strconv.Atoi(args[0])

コアとなるコードの解説

TestExtraFiles関数の変更

  1. "net"パッケージのインポート追加:

    +	"net"
    

    ネットワーク操作を行うためにnetパッケージをインポートします。

  2. ネットワークリスナーの作成:

    +	// Force network usage, to verify the epoll (or whatever) fd
    +	// doesn't leak to the child,
    +	ln, err := net.Listen("tcp", "127.0.0.1:0")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	defer ln.Close()
    

    net.Listenを呼び出すことで、TCPリスナーが作成されます。この操作により、OSレベルでソケットFDと、それを監視するためのepoll(Linuxの場合)または同等のI/O多重化メカニズムのFDが生成されます。127.0.0.1:0は、ループバックアドレス上の利用可能な任意のポートでリッスンすることを意味します。defer ln.Close()は、テスト関数が終了する際にリスナーを確実にクローズし、関連するFDを解放します。このステップが、netパッケージがFDを生成するトリガーとなります。

  3. エラーメッセージの改善:

    -		t.Fatalf("CombinedOutput: %v", err)
    +		t.Fatalf("CombinedOutput: %v; output %q", err, bs)
    

    子プロセスの実行結果(bs)もエラーメッセージに含めることで、デバッグ時の情報量を増やしています。

TestHelperProcess関数の変更(コメントアウトされた部分)

+		// TODO(bradfitz,iant): the rest of this test is disabled
+		// for now. remove this block once 5494061 is in.
+		{
+			os.Stderr.Write(bs)
+			os.Exit(0)
+		}
+		// Now verify that there are no other open fds.
+		var files []*os.File
+		for wantfd := os.Stderr.Fd() + 2; wantfd <= 100; wantfd++ {
+			f, err := os.Open(os.Args[0])
+			if err != nil {
+				fmt.Printf("error opening file with expected fd %d: %v", wantfd, err)
+				os.Exit(1)
+			}
+			if got := f.Fd(); got != wantfd {
+				fmt.Printf("leaked parent file. fd = %d; want %d", got, wantfd)
+				os.Exit(1)
+			}
+			files = append(files, f)
+		}
+		for _, f := range files {
+			f.Close()
+		}

この部分は、子プロセス側でFDの漏洩をより厳密にチェックしようとしたコードですが、コミット時点ではTODOコメントと共にコメントアウトされています。これは、os.Stderr.Fd() + 2からFD 100までを走査し、それぞれos.Open(os.Args[0])を試みることで、予期しないFDがオープンされていないかを確認するものです。もし予期しないFDがオープンされていれば、os.Openは新しいFDを割り当てられず、エラーになるか、あるいは既存のFDを再利用してしまい、got != wantfdのチェックで漏洩が検出されるはずです。

しかし、このコードブロックはos.Exit(0)によってすぐに終了するため、実質的には実行されません。これは、当時のGoランタイムのFD管理の複雑さや、特定のプラットフォームでの挙動の違いにより、この汎用的なチェックが困難であったためと考えられます。コミットメッセージにある5494061は、関連する別の変更リスト(CL)を参照しており、そのCLがマージされればこの部分が有効になる予定だったことを示唆しています。

このコミットの主な価値は、net.Listenを呼び出すことでnetパッケージがFDを生成する状況を作り出し、そのFDが子プロセスに継承されないことを、TestExtraFilesの既存のフレームワーク(子プロセスが特定のFDのみを継承することを期待する)を通じて間接的に検証している点にあります。もしnetパッケージが生成するFDにCLOEXECフラグが適切に設定されていなければ、子プロセスは予期しないFDを継承し、テストは失敗するでしょう。

関連リンク

参考にした情報源リンク