Apache环境下PHP反向代理WebSocket请求实现故障排查
PHP反向代理WebSocket长连接阻塞问题
问题背景
现有noVNC客户端通过WebSocket协议接入服务,链路设计如下:
- 公网HTTPS请求先到达NGINX反向代理,转发至内网Apache服务器
- Apache上运行
index.php校验请求会话合法性,校验通过后将请求转发至后端支持WebSocket的VNC服务器(地址192.168.100.13:5900) - 跳过Apache/PHP层让NGINX直连VNC服务时所有功能正常,加入PHP中间层后连接始终失败
当前NGINX配置如下:
server { listen 443 ssl; server_name vnc.domain.com; gzip off; server_tokens off; location / { proxy_set_header X-Forwarded-Host $host:$server_port; proxy_set_header X-Forwarded-Server $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_pass https://webapi.lan/; } ssl_certificate /etc/letsencrypt/live/vnc.domain.com/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/vnc.domain.com/privkey.pem; # managed by Certbot } server { listen 80; server_name vnc.domain.com; return 301 https://$host$request_uri; }
排查过程
- 最初使用
echo file_get_contents('proxypage');实现转发,所有请求头、响应头全部丢失,连接失败。 - 改用cURL实现转发,逐步手动补全协议切换相关头:手动返回101切换响应码、添加
Upgrade: websocket和Connection: Upgrade头、补传Sec-WebSocket-Accept头,但始终提示该头值错误,原因是请求头未完整透传给后端VNC服务,初始cURL代码如下:function proxy_pass_vnc( ) { $options = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_ENCODING => "", CURLOPT_AUTOREFERER => true, CURLOPT_CONNECTTIMEOUT => 120, CURLOPT_TIMEOUT => 120, CURLOPT_MAXREDIRS => 10, ]; $ch = curl_init('http://192.168.100.13:5900'); curl_setopt_array($ch, $options); $remoteSite = curl_exec($ch); $header = curl_getinfo($ch); curl_close($ch); header('Upgrade: websocket'); header('Connection: Upgrade'); header('Sec-WebSocket-Accept: ' . $header['Sec-WebSocket-Accept']); http_response_code(101); echo $remoteSite; } - 优化cURL逻辑,自动透传所有入站请求头、自动返回所有后端响应头后,代理普通HTTP服务正常,但代理WebSocket服务时先出现504超时,调高NGINX超时后发现代码阻塞在
curl_exec($ch)处,直到cURL 2分钟超时才返回。经调试确认头信息透传完全正确,阻塞原因是cURL会等待后端连接关闭才返回结果,而WebSocket是长连接,握手后不会主动断开,导致无法完成后续双向实时通信。优化后的cURL代码如下:$vnc_request_headers = apache_request_headers(); $vnc_request_headers_simple_array = []; foreach ($vnc_request_headers as $vnc_request_header => $value) { array_push($vnc_request_headers_simple_array, $vnc_request_header . ': ' . $value); } $options = [ CURLOPT_HTTPHEADER => $vnc_request_headers_simple_array, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_ENCODING => "", CURLOPT_AUTOREFERER => true, CURLOPT_CONNECTTIMEOUT => 120, CURLOPT_TIMEOUT => 120, CURLOPT_MAXREDIRS => 10, ]; $ch = curl_init('http://192.168.100.13:5900'); curl_setopt_array($ch, $options); $vnc_response_headers = []; curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use (&$vnc_response_headers) { $len = strlen($header); $header = explode(':', $header, 2); if (count($header) < 2) return $len; $vnc_response_headers[strtolower(trim($header[0]))][] = trim($header[1]); return $len; } ); $vnc_response = curl_exec($ch); $vnc_header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $vnc_httpCode = curl_getinfo($ch , CURLINFO_HTTP_CODE); curl_close($ch); $vnc_body = substr($vnc_response, $vnc_header_size); http_response_code($vnc_httpCode); foreach ($vnc_response_headers as $vnc_response_header => $array) { foreach($array as $vnc_response_header_value) { header($vnc_response_header . ": " . $vnc_response_header_value); } } echo $vnc_body;
根本原因
cURL从设计层面就是为一次性短连接HTTP请求服务的,核心逻辑是等待服务端返回完整响应、连接关闭后才会把数据交回上层代码,完全不支持WebSocket握手后的全双工实时通信场景,无论怎么调整cURL参数都解决不了阻塞问题,属于选型错误。
解决方案
方案1:鉴权逻辑前移,用Apache原生模块代理WebSocket(优先推荐)
长连接代理本身就是Web服务器的核心能力,用Apache原生模块处理比PHP实现性能高一个数量级,稳定性也更好,不需要处理复杂的非阻塞IO逻辑:
- 开启Apache所需模块:
a2enmod proxy proxy_wstunnel proxy_http rewrite systemctl restart apache2 - 把PHP中的会话校验逻辑前移到Apache层:如果是PHP默认文件会话,可直接通过Apache读取会话校验;如果是自定义Token/Cookie鉴权,可直接通过Rewrite规则判断合法性,校验不通过直接返回403。校验通过后通过Apache原生代理规则转发WebSocket请求,不需要经过PHP处理,示例配置片段:
<VirtualHost *:443> ServerName webapi.lan # 此处配置SSL证书、其他站点规则 RewriteEngine On # 替换为实际的会话校验规则,校验不通过直接拦截 RewriteCond %{HTTP_COOKIE} !PHPSESSID=[有效会话匹配规则] [NC] RewriteRule .* - [F,L] # WebSocket代理配置 ProxyPreserveHost On ProxyRequests Off # 自动识别WebSocket升级请求走ws协议转发 RewriteCond %{HTTP:Upgrade} websocket [NC] RewriteCond %{HTTP:Connection} upgrade [NC] RewriteRule /(.*) "ws://192.168.100.13:5900/$1" [P,L] # 普通HTTP请求转发规则根据实际业务配置 </VirtualHost> - 该方案下Apache会自动处理WebSocket握手、长连接保活、双向数据实时转发,和NGINX直连VNC的效果完全一致,没有额外性能损耗。
方案2:必须用PHP处理时,放弃cURL,用非阻塞Socket实现双向转发
如果鉴权逻辑极其复杂无法前移到Web服务器层,必须用PHP实现,需要直接用PHP Socket扩展手写非阻塞双向转发逻辑,不能使用cURL:
- 核心实现逻辑:
- 会话校验通过后,用
socket_create建立到后端VNC服务的TCP连接 - 读取客户端发来的WebSocket握手请求,原样转发给后端VNC服务
- 读取后端返回的101握手响应,关闭输出缓冲立刻flush返回给客户端
- 设置PHP脚本超时时间为0(永不超时),以非阻塞模式同时监听客户端、后端VNC两个连接的可读事件:
- 客户端有数据到达就立刻转发给后端
- 后端有数据返回就立刻转发给客户端
- 任意一端主动断开连接,就关闭两端套接字,结束脚本
- 会话校验通过后,用
- 注意事项:
- 该方案下一个WebSocket长连接会长期占用一个PHP进程(FPM/Apache工作进程),并发稍高就会占满PHP进程池,导致其他PHP请求无法响应,仅适合低并发内部场景使用
- 必须关闭PHP、Web服务器的所有输出缓冲,否则会出现数据延迟
- 需要调整PHP、Web服务器的长连接超时配置,避免连接被中途强制断开
内容的提问来源于stack exchange,提问作者Héctor
相关产品推荐
相关产品推荐

