BGP多线主机的 PHP 高并发,出口源 IP 才是那个沉默的凶手:CURLOPT_INTERFACE 静默失效、owner 打 fwmark 被 conntrack 漏掉、curl_multi 把单线 28k 端口烧穿

现场:白天一切正常,晚八点开始 5% 的超时,日志里干干净净

一台 BGP 多线主机,电信、联通、移动各绑一个地址,对外广播一个 VIP。上面跑 PHP-FPM + Nginx,业务是订单聚合:每个用户请求进来,PHP 要串行调三个外部接口——风控、支付下单、短信。单机 QPS 峰值 800,PHP-FPM 池 pm.max_children = 120。

现象:白天平稳,晚八点后上游调用超时率爬到 5%,php-fpm.log 里只有 WARNING: [pool www] server reached max_children,Nginx 的 upstream_response_time 慢请求集中在调外部 API 的那段。对方(支付方)查了说没收到请求,或者收到了但源 IP 不在白名单。

三条独立线索,最后串成一根绳:出站流量的源 IP 不受你控制,而你以为它受控制。

坑一:CURLOPT_INTERFACE 传网卡名,在 PHP-FPM 里静默返回 45

先确认现状。在 PHP-FPM 的运行用户下(不是 root)看内核怎么选出站源 IP:

sudo -u www-data ip route get 203.0.113.50
# 203.0.113.50 via 10.0.0.1 dev eth0 src 198.51.100.7 uid 33

src 就是真实源 IP。BGP 多线主机上默认路由只有一条,所有出站流量都从这个 src 走——不管目标用户在哪条线。你想"调电信的 API 就走电信线",代码里写了:

<?php
$ch = curl_init('https://api.example.com/order');
curl_setopt($ch, CURLOPT_INTERFACE, 'eth1');   // 想绑电信网卡
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$r = curl_exec($ch);
if ($r === false) {
    error_log('curl err: ' . curl_error($ch) . ' code=' . curl_errno($ch));
}

日志里会出现 code=45,也就是 CURLE_INTERFACE_FAILED。原因:libcurl 拿到字符串后先判断它是不是 IP、是不是主机名,都不是就当网卡名处理,走 setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, ...)。这个选项需要 CAP_NET_RAW,www-data 没有,于是 epoll 里那个 connect 直接失败。而很多人把 curl_exec 的返回值当成"网络抖动"重试三次,重试三次都 45,最终对外就是一个 502。

解法:传 IP,不传网卡名。传 IP 时 libcurl 走 bind(),任何用户都合法:

<?php
curl_setopt($ch, CURLOPT_INTERFACE, '198.51.100.7');  // 直接绑地址

验证一句话搞定,别在 PHP 里猜:

sudo -u www-data curl -s --interface 198.51.100.7 https://ifconfig.me
# 返回 198.51.100.7 才算生效

如果你确实要按网卡绑(比如希望走二层,避免同网段路由波动),那就给 php-fpm 补能力,别用 root 跑:

# systemd drop-in
sudo systemctl edit php-fpm8.2
# [Service]
# AmbientCapabilities=CAP_NET_RAW
# CapabilityBoundingSet=CAP_NET_RAW
sudo systemctl daemon-reload && sudo systemctl restart php-fpm8.2
# 校验
grep Cap /proc/$(pgrep -f 'php-fpm: master' | head -1)/status

顺带一个隐藏依赖:绑定了源 IP 之后,DNS 解析仍然走默认路由。CURLOPT_INTERFACE 只管 TCP,不管解析。如果对方域名有 AAAA 记录而你的机器上 IPv6 出口是断的,libcurl 的 happy eyeballs 会先试 v6,等 200ms 才回落 v4——高并发下这 200ms 会直接把连接池占满。加上:

<?php
curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
curl_setopt($ch, CURLOPT_DNS_LOCAL_IP4, '198.51.100.7');  // 解析也走这条线

坑二:给 PHP 按 uid 打 fwmark,但 conntrack 表满了,标记随机丢失

不是所有出站都能改代码。PDO/mysqlnd 连数据库、phpredis 连 Redis,都没有源地址绑定参数。这里的正解是 iptables -m owner 按 uid 打 mark,配策略路由:

# 策略路由表:电信走 table 100
ip route add default via 198.51.100.1 dev eth1 table 100
ip rule add fwmark 0x100 table 100 pref 100

# 只对 php-fpm 用户(uid 33)去往支付网关 443 的流量打标
iptables -t mangle -A OUTPUT -m owner --uid-owner 33 \
  -p tcp -d 203.0.113.0/24 --dport 443 -j MARK --set-xmark 0x100/0x100
iptables -t mangle -A OUTPUT -m owner --uid-owner 33 \
  -p tcp -d 203.0.113.0/24 --dport 443 -j CONNMARK --save-mark

# 回包按标记还原(多线机器必须)
iptables -t mangle -A PREROUTING -j CONNMARK --restore-mark

看起来完美。但高并发下会漏,漏的地方在 /proc/net/nf_conntrack:

cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max
dmesg -T | grep -i conntrack | tail
# nf_conntrack: nf_conntrack: table full, dropping packet

默认 nf_conntrack_max = 65536。你的 PHP-FPM 120 个 worker,每个请求打 3 个外部调用,如果不复用连接,峰值连接速率轻松过 2000/s,TIME_WAIT 撑 60 秒,conntrack 表 12 万条起步——直接溢出。溢出后内核随机丢 SYN,表现就是"curl 偶发超时、对方说没收到",而 ss 上看到的都是正常的 SYN-SENT。

同时注意 rp_filter。三条线三个地址,回包从不是出站那块网卡进来是常态,严格模式会静默丢回包:

sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w net.ipv4.conf.eth1.rp_filter=2
sysctl -w net.ipv4.conf.eth2.rp_filter=2
# 2 = loose,只校验源地址可达,不校验出接口

配套把 conntrack 上限按 worker 数 × 并发出站数 × 3 抬上去,并开超时回收:

sysctl -w net.netfilter.nf_conntrack_max=1048576
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=600
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
# 哈希桶按 max/4 给,别用默认值
echo 262144 > /sys/module/nf_conntrack/parameters/hashsize

排查手法:不要看 ss,看 conntrack 里你那条策略的命中数:

conntrack -L -d 203.0.113.50 --dport 443 2>/dev/null | wc -l
conntrack -L -d 203.0.113.50 --dport 443 2>/dev/null | grep -v 'mark=256' | head

最后这条命令是关键——本该打标却没打标的连接会出现在这里。能直接看到 conntrack 表满的时候,有多少连接回落到了默认路由,跑了错线。

坑三:curl_multi 到单个目标,本地端口空间被一条线吃完

前两个坑修完,超时率降到 1%,但还在。这时候看端口账:

cat /proc/sys/net/ipv4/ip_local_port_range
# 32768    60999        → 28232 个端口

ss -tan state time-wait '( dport = :443 )' | wc -l
# 26410

一条线一个源 IP,到同一个目标 IP:port,本地端口空间只有 28232。TIME_WAIT 撑 60 秒(tcp_fin_timeout 不影响 TIME_WAIT,那是 tcp_tw_reuse 的事),单目标的连接建立上限约 28232 / 60 ≈ 470 次/秒。你的 curl_multi 拉 1000 并发上去,超出部分报 Cannot assign requested address,PHP 里看到的仍然只是 CURLE_COULDNT_CONNECT。

常规三板斧先上:

sysctl -w net.ipv4.ip_local_port_range="10240 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.ipv4.tcp_fin_timeout=15
sysctl -w net.ipv4.tcp_max_tw_buckets=262144

但 tcp_tw_reuse 只对出站 SYN 生效,且要求对端开时间戳;端口空间仍然只有 64K,一个源 IP 的天花板摆在那里。

BGP 多线主机在这里的优势才真正体现出来:你有三个源 IP,端口空间 ×3。把出口源 IP 做成池,按目标 + 序号轮换:

<?php
final class EgressPool
{
    private static array $ips = ['198.51.100.7', '203.0.113.9', '192.0.2.11'];
    private static int $idx = 0;

    public static function pick(string $target): string
    {
        // 同一目标固定用同一个源 IP,避免对端风控认为来源漂移
        $h = crc32($target);
        return self::$ips[$h % count(self::$ips)];
    }

    public static function next(): string
    {
        return self::$ips[self::$idx++ % count(self::$ips)];
    }
}

$mh = curl_multi_init();
curl_multi_setopt($mh, CURLMOPT_MAXCONNECTS, 200);
curl_multi_setopt($mh, CURLMOPT_MAX_HOST_CONNECTIONS, 60);

$handles = [];
foreach ($tasks as $i => $task) {
    $ch = curl_init($task['url']);
    curl_setopt_array($ch, [
        CURLOPT_INTERFACE   => EgressPool::next(),   // 三条线轮流分摊端口
        CURLOPT_IPRESOLVE   => CURL_IPRESOLVE_V4,
        CURLOPT_TCP_KEEPALIVE => 1,
        CURLOPT_FORBID_REUSE  => 0,                  // 必须允许复用
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS     => 1200,
        CURLOPT_CONNECTTIMEOUT_MS => 300,
    ]);
    curl_multi_add_handle($mh, $ch);
    $handles[(int)$ch] = $ch;
}
do {
    curl_multi_exec($mh, $running);
    curl_multi_select($mh, 0.1);
} while ($running > 0);

注意 CURLOPT_FORBID_REUSE = 0 和 CURLMOPT_MAXCONNECTS —— 连接复用一旦开启,libcurl 是按 (源 IP, 目标 IP, 端口) 分桶的。绑定不同源 IP 的 handle 不会共享连接池,所以你既分摊了端口,也把池的开销摊开了。

还有一层:CURLOPT_INTERFACE 绑的 IP 如果从网卡上消失了(BGP 故障切换、运营商侧回收地址、或者你手滑 ip addr del),libcurl 直接返回 45,不是超时,所以你的重试逻辑(只捕获 timeout)根本兜不住,请求当场 502。上线前必须做地址可用性探测,失败时平滑回落到 0.0.0.0:

ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | sort -u

把这个输出缓存进 Redis(TTL 60 秒),PHP 端只从"当前真实存在的 IP"里挑。

收尾

三个坑的共性:全都是"你以为配了,其实没生效"。网卡名绑不上是因为权限、打标被 conntrack 吃掉是因为表满了、端口耗尽是因为单源 IP 天花板。它们不会在压测里出现——压测通常 10 分钟,conntrack 和 TIME_WAIT 还没堆起来;也不会在低峰出现——800 QPS 才能把 28k 端口烧穿。

后来我们把机器换成轻云互联的 BGP 多线,控制台上能直接看到每条线绑了哪几个 IP,源 IP 池的维护成本一下降下来了,上面那段 EgressPool::pick() 才敢真正上线按目标做固定分配。回头看,问题从来不在 BGP 路由本身,而在于多线主机给了你三套出口,PHP 却默认只用了一套——还得你自己在 bind() 那一层把它叫醒。