别让容器端口“裸奔”:docker compose 端口收敛与 Nginx 单入口实践
一个很常见的开头
上周帮朋友整理一台内网服务器的部署脚本,docker compose ps 一看,端口列表是这样:
0.0.0.0:5173->5173/tcp
0.0.0.0:19000->19000/tcp
0.0.0.0:19200->19200/tcp
脚本能跑,功能也正常,但它意味着:这三个端口对所有能访问到这台机器的人都敞开着。为了让它能用,安全组被迫开了三个洞,而洞的源地址是 0.0.0.0/0。
三个具体的风险
第一,反代层的所有能力被跳过。 常见架构是 Nginx → 应用,限流、访问日志、HTTPS、统一的鉴权头都在 Nginx 上。一旦应用端口本身可直连,客户端直接打 19200,等于绕过了整套外围防护。
第二,内网横向移动变得容易。 同网段任何一台机器(包括被入侵的办公电脑、闲置测试机)都能直接连上 REST 网关和 WebSocket,不需要经过任何入口。
第三,安全组规则会失效。 很多内网环境(学校、家庭宽带)出口 IP 是动态的,按源 IP 收紧的规则过几天就不匹配。维护成本一高,最后往往又被改回 0.0.0.0/0。
做法一:把端口收到 loopback
Docker 的 ports 有两种写法,差别很大:
services:
web:
ports:
- "5173:5173" # 等价于 0.0.0.0:5173 -> 容器 5173
web-local:
ports:
- "127.0.0.1:5173:5173" # 只监听本机 loopback
后者用 ss -tlnp 看是 127.0.0.1:5173,前者是 *:5173。改完之后,外部(包括同网段)无法直连,只有本机的 Nginx 能访问。
这里有个细节:Docker 通过 iptables 的 DNAT 实现端口映射,绑定 0.0.0.0 时规则对所有网卡生效;指定 127.0.0.1 时只对本机回环生效,不会为外部流量建转发规则。所以从宿主机外部 telnet 内网IP 5173 会直接 connection refused,而不是被防火墙 DROP——排查时这一点很有用。
做法二:Nginx 单入口
server {
listen 443 ssl http2;
server_name example.internal;
location / {
proxy_pass http://127.0.0.1:5173;
}
location /api/ {
proxy_pass http://127.0.0.1:19200/;
}
location /ws {
proxy_pass http://127.0.0.1:19000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
WebSocket 反代有三项必配,缺一项就出问题:
proxy_http_version 1.1——HTTP/1.0 不支持 Upgrade,漏了会得到 400 或握手失败;Upgrade与Connection两个头——不带就退化成普通 HTTP 请求;proxy_read_timeout——默认 60 秒,长连接会被 Nginx 主动断开,表现为“页面放着不动就掉线,刷新又好了”。
代价:前端要改成相对路径
收敛之后最容易踩的坑是前端写死了绝对地址:
const ws = new WebSocket('ws://10.0.0.5:19000/ws');
这样的代码反代之后就废了。正确做法是用相对路径,由浏览器按当前 origin(协议 + 主机 + 端口)拼接:
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws`);
本地开发时用 Vite 的 server.proxy 把 /ws、/api 转发到后端,线上自然走 Nginx,一份代码两边都能用。
最后的安全组
收敛完成后,安全组只留两条:
22/tcp,源地址填自己的出口 IP,优先密钥登录;443/tcp,按实际使用者收敛。
规则从此固定,换网络、换教室、换办公室都不用改。把“能用”变成“可维护”,往往就是这一两步的差别。
- 点赞
- 收藏
- 关注作者
评论(0)