水文视频代理统一改造实战:nginx代理踩坑记

在水文监测系统中,视频监控是重要的数据来源之一。最近在项目中遇到了一个视频代理的需求:将原本直接访问视频服务器的架构改为通过 nginx 统一代理。看似简单的改造,却在 URL 编码和 nginx 配置上踩了不少坑。本文详细记录了这次改造的全过程,希望能为遇到类似问题的开发者提供参考。

前言

水文监测系统需要实时查看各监测站点的视频画面,这些视频流来自不同的视频服务器。原有的实现方式是直接在前端拼接视频服务器地址,但这种方式存在安全隐患和维护困难的问题。为了统一管理和安全控制,我们需要将所有视频请求通过 nginx 代理转发。

这个需求看起来很简单:把视频地址从 http://19.15.67.125/video/stream 改成 http://10.144.32.219:18001/video-proxy?url=http://19.15.67.125/video/stream。但实际操作中,URL 编码、nginx 变量解码、DNS 解析等问题接踵而至,让我深刻体会到了”魔鬼在细节中”这句话的含义。

背景:为什么要统一代理

nginx代理架构图
图1:nginx代理架构示意

原有架构的问题

在改造之前,系统采用的是简单的字符串替换方案:

1
2
3
4
String proxyUrl = videoUrl.replace(
"http://19.15.67.125",
"http://10.144.32.219:18001/video-proxy"
);

这种方式有明显的局限性:

  1. 只能代理固定域名:如果视频源地址变化,必须修改代码
  2. 无法支持多视频源:不同站点可能使用不同的视频服务器
  3. 安全隐患:前端直接暴露视频服务器地址
  4. 维护困难:每次视频源变更都需要重新部署

统一代理方案设计

新的设计方案是通过 ?url= 参数传递原始视频地址,nginx 根据这个参数动态转发:

1
2
前端请求:http://10.144.32.219:18001/video-proxy?url=http://19.15.67.125/video/stream
nginx 转发:http://19.15.67.125/video/stream

这样的好处是:

  • 支持代理任意视频源
  • 前端无需知道真实视频地址
  • nginx 统一处理认证、限流、日志

问题一:nginx 返回 500 错误

现象描述

配置好 nginx 后,访问代理地址直接返回 500 错误,查看 nginx 错误日志:

1
[error] no resolver defined to resolve "19.15.67.125"

根因分析

proxy_pass 使用变量时,nginx 不会使用系统的 DNS 解析器,必须显式配置 resolver 指令。

在我们的配置中,proxy_pass $target 使用了变量 $target,nginx 需要知道如何解析这个变量中包含的域名,但配置中缺少 resolver 指令。

解决方案

在 nginx 配置中添加 resolver 指令:

1
2
3
4
5
6
7
8
9
10
11
location /video-proxy {
# 关键:使用变量的proxy_pass必须配置resolver
resolver 211.136.192.6 valid=30s;

set $target $arg_url;
proxy_pass $target;

proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

注意resolver 地址需要根据实际网络环境配置。我们使用的是内网 DNS 211.136.192.6,如果你的环境不同,请替换为对应的 DNS 服务器地址。

问题二:$arg_url 不自动解码

nginx变量解码行为对比图
图2:nginx变量URL解码行为对比

现象描述

解决了 resolver 问题后,nginx 不再返回 500,但代理请求仍然失败。通过调试发现,$arg_url 变量的值是 URL 编码后的字符串,nginx 没有自动解码。

nginx 变量的 URL 解码行为

这是一个容易被忽视的细节。nginx 的不同变量在 URL 解码方面行为不一致:

nginx 变量 是否自动 URL 解码
$args / $query_string
$arg_NAME
regex 捕获组 $1
$uri
$request_uri

这意味着 $arg_url 中的 http%3a%2f%2f19.15.67.125%2f... 会被原样传递给 proxy_pass,nginx 会尝试连接 http%3a%2f%2f19.15.67.125 这个”域名”,显然会失败。

尝试的解决方案

方案一:使用 map + regex

1
2
3
map $arg_url $decoded_url {
~^(.+)$ $1;
}

测试发现 regex 捕获组 $1 同样不会自动解码,此方案不可行。

方案二:使用 rewrite

1
rewrite ^/video-proxy/(.*)$ /video-proxy?url=$1 last;

rewrite 的正则匹配的是 URI 部分,不包含 query string,无法处理 ?url= 参数,此方案也不可行。

问题三:URLEncoder.encode 编码过度

URL编码流程图
图3:URL编码流程示意

现象描述

在 Java 端使用 URLEncoder.encode(videoUrl, "UTF-8") 对视频地址进行编码后,nginx 收到的 URL 变成了:

1
http%3a%2f%2f19.15.67.125%2fvideo%2fstream

nginx 无法识别这是一个 URL,代理失败。

根因分析

Java 的 URLEncoder.encode 会将所有非字母数字字符编码,包括 URL 的结构字符:

字符 编码后 说明
: %3A URL 协议和端口分隔符
/ %2F URL 路径分隔符
? %3F query string 分隔符
& %26 多参数分隔符

这些字符是 URL 的结构组成部分,不应该被编码。

关键发现:哪些字符需要编码

经过测试,我发现了 URL 作为 query 参数传递时的编码规则:

字符 是否需要编码 原因
? 不需要 nginx 只认第一个 ? 作为 query string 分隔符
: 不需要 不会被 nginx 特殊处理
/ 不需要 不会被 nginx 特殊处理
& 必须编码 否则 nginx 会当作多个参数的分隔符

最终方案

1
2
3
4
5
6
7
8
9
// 1. 先用URLEncoder.encode编码(会过度编码)
String encodedUrl = URLEncoder.encode(videoUrl, "UTF-8");

// 2. 还原不需要编码的结构字符
encodedUrl = encodedUrl.replace("%3A", ":") // 还原 :
.replace("%2F", "/"); // 还原 /

// 3. 拼接代理地址
String proxyUrl = "http://10.144.32.219:18001/video-proxy?url=" + encodedUrl;

这样处理后,传递给 nginx 的 URL 是:

1
http://10.144.32.219:18001/video-proxy?url=http://19.15.67.125/video/stream&other=param

其中原始 URL 中的 & 已经被编码为 %26,不会被 nginx 误解为多个参数。

完整配置方案

Java 端代码

1
2
3
4
5
6
7
8
9
10
11
public String buildProxyUrl(String videoUrl) {
try {
// URL编码,只保留&被编码
String encodedUrl = URLEncoder.encode(videoUrl, "UTF-8");
// 还原 : 和 /
encodedUrl = encodedUrl.replace("%3A", ":").replace("%2F", "/");
return "http://10.144.32.219:18001/video-proxy?url=" + encodedUrl;
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("URL编码失败", e);
}
}

nginx 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server {
listen 18001;
server_name _;

location /video-proxy {
# 使用变量的proxy_pass必须配置resolver
resolver 211.136.192.6 valid=30s;

# $arg_url 不会自动URL解码
set $target $arg_url;

# 代理转发
proxy_pass $target;

# 传递原始请求头
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# 超时设置
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 30s;
}
}

测试验证

视频代理测试结果图
图4:视频代理测试结果对比

改造完成后,我们进行了详细的测试:

测试项 预期结果 实际结果
直接访问视频服务器 HTTP 200 HTTP 200,10秒下载1.3MB
nginx 代理(修复后) HTTP 200 HTTP 200,15秒下载1.7MB
代理正常工作 无 500 错误 通过
多视频源代理 支持任意域名 通过

测试结果表明,代理功能正常工作,虽然由于多了一层代理,下载速度略有下降(从 10 秒增加到 15 秒),但这是可以接受的性能损失。

经验总结

1. nginx 使用变量的 proxy_pass 必须配置 resolver

这是一个常见的坑。当 proxy_pass 使用变量时,nginx 不会使用系统的 DNS 解析器,必须显式配置 resolver 指令。否则会返回 500 错误。

2. nginx 变量的 URL 解码行为不一致

不同 nginx 变量的 URL 解码行为不同,需要特别注意:

  • $uri 会自动解码
  • $arg_*$args、regex 捕获组都不会自动解码

3. URL 作为 query 参数的编码规则

URL 作为 query 参数传递时,编码规则与普通 URL 编码不同:

  • ? 不需要编码:nginx 只认第一个 ?
  • :/ 不需要编码:它们是 URL 结构字符
  • & 必须编码为 %26:否则会被当作参数分隔符

4. URLEncoder.encode 会过度编码

Java 的 URLEncoder.encode 会将所有非字母数字字符编码,包括 URL 结构字符。在使用时需要反向还原这些字符。

5. 调试技巧

遇到 nginx 代理问题时,可以使用以下调试方法:

  • 查看 nginx 错误日志:/var/log/nginx/error.log
  • 使用 curl -v 查看详细的请求和响应
  • 在 nginx 配置中添加 add_header X-Debug-Target $target; 查看变量值

扩展思考

安全性考虑

统一代理方案虽然解决了功能问题,但也引入了新的安全风险:

  • 任意 URL 代理可能被滥用(SSRF 攻击)
  • 需要添加白名单或验证机制

建议在生产环境中:

  1. 添加视频源域名白名单
  2. 验证 URL 格式和协议
  3. 限制代理的响应大小
  4. 添加访问日志和监控

性能优化

对于视频流代理,可以考虑:

  1. 使用 nginx 的 proxy_buffering off 减少延迟
  2. 配置 proxy_cache 缓存热点视频
  3. 使用 X-Accel-Redirect 实现更灵活的转发

结语

这次视频代理改造虽然功能简单,但在 URL 编码和 nginx 配置上踩了不少坑。通过这次经历,我深刻理解了 nginx 变量的解码机制和 URL 编码的细节。希望这篇文章能为遇到类似问题的开发者提供参考,少走一些弯路。

技术细节往往决定了一个功能的成败,作为开发者,我们需要对这些”小事”保持敬畏之心。正如这次改造所展示的,一个简单的 URL 编码问题,如果不深入了解其机制,可能会花费大量时间去排查。


相关资源


背景音乐