CentOS7.9 で io.Copy がハングする:splice システムコールと古い Linux カーネルの相性問題

事象の概要

Go 1.21.0〜1.21.4 でコンパイルした TCP プロキシが CentOS 7.9(カーネル 3.10.0)上で動作中、io.Copy が永遠に戻らなくなるケースが発生しました。
原因は net.TCPConn.ReadFrom が内部的に使う spliceSPLICE_F_NONBLOCK フラグを失ったことで、ノンブロッキング動作が無効になり、古いカーネルでブロックしていたためです。
Go 1.21.5 で該当フラグが復活し事象は解消しています。

環境

  • ホスト: Debian 12(6.1.0-10-amd64)
  • ゲスト VM: CentOS 7.9(3.10.0-1160.62.1.el7.x86_64)
  • 言語ランタイム: Go 1.21.1(問題が出たバージョン)
  • ハイパーバイザ: QEMU 7.2.4 + virtio-net

プロキシ実装例

package main

import (
	"flag"
	"io"
	"log"
	"net"
	"sync"
)

func main() {
	from := flag.String("l", "", "listen addr")
	to   := flag.String("t", "", "target addr")
	flag.Parse()

	ln, err := net.Listen("tcp", *from)
	if err != nil { log.Fatal(err) }

	for {
		c1, _ := ln.Accept()
		go func() {
			c2, err := net.Dial("tcp", *to)
			if err != nil { log.Print(err); c1.Close(); return }

			var wg sync.WaitGroup
			wg.Add(2)
			go func() { io.Copy(c2, c1); c2.Close(); wg.Done() }()
			go func() { io.Copy(c1, c2); c1.Close(); wg.Done() }()
			wg.Wait()
		}()
	}
}

問題の切り分け

  1. strace -p <pid> で goroutine が splice(…, 0) で止まっていることを確認。
  2. netstat -ntp で受信バッファに 1.6 MB 以上のデータが滞留。
  3. kill -3 でスタックトレースを取得すると net.splicepoll.Splicesyscall.Splice のコールパスが残る。
  4. Go 1.18 で同じコードをビルドすると正常に動作。
  5. Go 1.21.0〜1.21.4 の poll/splice_linux.go を確認すると SPLICE_F_NONBLOCK が削除されていた。

最小再現 C コード

#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <errno.h>

static ssize_t relay(int out, int in, size_t max) {
    int p[2];
    pipe2(p, 0);                        /* ノンブロッキングではないパイプ */
    ssize_t n = splice(in, 0, p[1], 0, max, 0);
    if (n > 0) splice(p[0], 0, out, 0, n, 0);
    close(p[0]); close(p[1]);
    return n;
}

このコードはカーネル 3.10でパイプバッファが一杯になると splice が返らなくなります。

カーネル実装の違い

カーネルpipe 残容量チェック一度に転送可能サイズ結果
3.10.0転送ループ内で判定無制限(要求サイズ丸ごと)ブロック
6.1.0事前に wait_for_spacemin(要求, 残容量)非ブロック

回避策・対処法

  • Go 1.21.5 以降を利用する(推奨)
  • 環境変数 GODEBUG=netpoll=0 を設定して splice を無効化
  • プロキシ内部で自前の read/write ループを実装し io.CopyBuffer を使用
  • カーネルアップグレード(5.x 以降)

まとめ

Go 1.21.0〜1.21.4 のゼロコピー実装が Linux 3.10 の pipe 実装と相性を起こし、splice がブロックするケースがありました。
Go 1.21.5 で SPLICE_F_NONBLOCK が復活したため、CentOS 7 など古いカーネルを使う場合は必ず 1.21.5 以降を利用してください。

タグ: Go io.Copy splice Linux Kernel centos7

7月23日 22:50 投稿