新卒1年目、バックエンドを担当している永井です。最近色々学ぶことができたのでここに共有したいと思います。また、このブログで少しでも誰かの役に立てたら嬉しいです。
(本ブログに出てくるコードは正しく動かないです。説明用に伝えたいことしか書いていないのでご了承ください。)
背景
SSRF対策の一環でコンテナ上でコマンドを実行するようにしていました。そこで、コンテナ経由で実行したコマンドの標準入出力を扱ったので、その手法を紹介します。

実際にどう書くのか
コードを見るのが一番早いと思うのでまずは簡単な例を示します。
$descriptorspec = [
0 => ['pipe', 'r'], //標準入力
1 => ['pipe', 'w'], //標準出力
2 => ['pipe', 'w'], //標準エラー
];
//標準入力を受け取って出力するだけのコマンド
$cmd = 'docker run --rm -i alpine cat -';
$proc = proc_open($cmd, $descriptorspec, $pipes);
//標準入力に変換するものを入力
fwrite($pipes[0], 'hello world');
//標準入力のストリームは閉じなければ処理が先に進まない
fclose($pipes[0]);
$result = stream_get_contents($pipes[1]);//→hellow world
//エラーは直接変数に格納する
$stderr = stream_get_contents($pipes[2]);
//proc_open()で開かれたプロセスは全て閉じられる
proc_close($proc);
ハイフンでコマンドの引数を標準入力にする
catコマンドは通常、ファイルパスを指定して使用します。しかし、catコマンドの引数にハイフン(-)を使うことで、代わりに標準入力からデータを受け取ることができます。この場合、ファイルパスを指定する代わりに、catコマンドに直接値を渡すことができます。これにより、便利な操作が可能になります。
参考
- ハイフンで標準入出力

proc_open()のパイプラインについて
標準入力、標準出力・エラーは特にパイプラインを閉じなくてもproc_close()で閉じられるのですが、標準入力はfclose()を使ってすぐ閉じないと読み込み待ちになり処理が先に進まなくなります。よって、$pipes[0]についてはproc_close()の前にfclose()を使って閉じています。
(これに気付けずに、謎にアプリケーションがタイムアウトしていて沼にハマることもありました。。)
参考
- proc_open()で標準入力を渡す時について

- proc_close()について
標準出力が画像やPDF、CSVのような出力データサイズが大きい時
今回の例なら何も気にすることはないのですが、もし仮に実行するコマンドが何かデータを受け取って画像やPDF、CSVなどの大きいデータサイズを出力するものだったら意識しないといけない部分があります。それは入力されるデータサイズに幅があり、メモリ消費をおさえたいということです。 この場合、一時的にメモリに乗せずに保存できる場所が必要となります。ではどうすれば良いでしょうか。
標準出力を一時ファイルに保存する方法
この手法だと一時ファイルの作成と削除をするコードを書く必要があります。削除を忘れるとファイルがいつまでも残ってしまうので、他の手法を考えたいところです。
function execute_proc_open(){
//中略
$proc = proc_open($cmd, $descriptorspec, $pipes);
//中略
//一時ファイルを作成する手間がある
$fp = fopen({一時ファイルのパス}, w);
fwrite($pipes[1], $fp);
fclose($fp);
//一時ファイルを削除する手間がある
unlink({一時ファイルのパス});
proc_close($proc);
}
標準出力を直接ファイルに書き込める場合
そもそも一時ファイルを作らず、proc_open()の標準出力(上記で言うと$pipes[1])を直接ファイルに書き込むことも考えられます。
function execute_proc_open(){
//中略
$proc = proc_open($cmd, $descriptorspec, $pipes);
//中略
//以下の処理で出力結果である$pipes[1]を直接ファイルに保存できる
stream_copy_to_stream($pipes[1], {出力結果を保存したいファイルのストリーム});
//proc_open()で開かれたプロセスは全て閉じられる
proc_close($proc);
}
標準出力を直接ファイルに書き込めない場合
しかし、テストがしやすいようにproc_open()が使われる関数とは別の関数で「出力結果をファイルに保存する処理」を書きたい場合、前述した通りproc_close()で標準出力へのストリームが閉じられるので標準出力を直接ファイルには書き込めないです。
function execute_proc_open(){
//中略
$proc = proc_open($cmd, $descriptorspec, $pipes);
//中略
//以下で標準出力である$pipes[1]のストリームも閉じられてしまう
proc_close($proc);
//よって、これは使えない
return $pipes[1];
}
//本当はこの関数にexecute_proc_open()関数の戻り値として「コマンドの標準出力結果のストリーム」を渡したい
function save_to_file_from_stream($stream){
//中略
stream_copy_to_stream($stream, {出力結果を保存したいファイルのストリーム});
//中略
}
php://tempを使う
そこで、特殊な一時ストリームとしてphp://tempを使って、データサイズが一定以上になったらメモリに乗せずディスクに書き出すようにして、一時ストリームに標準出力からコピーするようにしました。さらに、php://tempは自分で一時ファイルを作成や削除をする必要はないです。そして、php://tempは任意の時にfclose()してストリームを閉じることができます。 ここで、簡単な例を示します。
//例としてあるデータを画像に変換するコマンドの出力結果を一時ストリームに入れて返す関数を定義します。
function convert_data_to_image($too_large_data){
//proc_open()で作られる子プロセスのストリームの設定をします
$descriptorspec = [
0 => ['pipe', 'r'], //標準入力
1 => ['pipe', 'w'], //標準出力
2 => ['pipe', 'w'], //標準エラー
];
//標準入力で受け取ったデータを画像やPDF、CSVなどに変換するコマンド
$cmd = 'docker run --rm -i alpine {コマンド} -';
$proc = proc_open($cmd, $descriptorspec, $pipes);
//標準入力に「画像に変換するデータ」を入力
fwrite($pipes[0], $too_large_data);
//標準入力のストリームは直後に閉じなければ処理が先に進まない
fclose($pipes[0]);
//一時ストリームの作成
$output_stream = fopen('php://temp', 'r+b');
//標準出力=変換した結果を一時ストリームにコピーする
stream_copy_to_stream($pipes[1], $output_stream);
//エラーは変数に格納する→負荷にならないデータサイズ
$stderr = stream_get_contents($pipes[2]);
//proc_close()でproc_open()で開かれたプロセスは全て閉じられる
proc_close($proc)
//ファイルポインタを先頭に戻す
rewind($output_stream);
return $output_stream;
}
$too_large_data = {画像に変換するサイズが大きいデータ};
$image_stream = convert_data_to_image($too_large_data);
$file_path_stream = fopen({保存するファイルパス}, 'w+b');
//ストリームからストリームへ中身をメモリに乗せることなくコピーしている
stream_copy_to_stream($image_stream, $file_path_stream);//→ファイルに「画像データ」が格納される
//ここでphp://tempのストリームを閉じている
fclose($image_stream);
fclose($file_path_stream);
rewind()という関数が出てきていますが詳しくは以下にリンクも貼っています、php.netを参照してください。
参考
- php://tempについて
- rewind()について
get_stream_contents()について
エラー($pipes[2])を取得するのに使っているget_stream_contents()を使っています。しかし、上記のように画像に変換するデータstream_copy_to_stream()を使っている理由としては、get_stream_contents()を使ってストリームからファイルへ書き込むとget_stream_contents()を実行した時に一回データがメモリに乗ってしまうからです。
参考
- stream_copy_to_stream()について
- stream_get_contents()について

最後に
最近、私は業務で実際にproc_open()の標準入出力を使用してDockerコンテナ内でコマンドを実行する必要がありました。今回のような技術を扱うのは初めてで、私にとってかなり難しいタスクでしたが、たくさんの学びがありました。その結果、このように開発者ブログの執筆に役立てることができたので良かったなと思っています! これからも一歩踏み込んだところまで学んでいき、パフォーマンス面やセキュリティ面など視野を広く持ち開発して行きます。
ここまで読んで頂きありがとうございました。少しでも役立つ情報がありましたら幸いです。