MinIO 上传大文件失败(413 / 网络错误)
01 前置理论与核心机制¶
1. 先分清是哪种上传通道:UI 与 CLI 的行为完全不同¶
MinIO 有两条上传通道,遇到"上传限制"时第一步就是判断走的是哪条:
| 通道 | 传输方式 | 是否受「单请求体大小」限制 |
|---|---|---|
| Web UI(Console) | 把整个文件作为一个 multipart/form-data 请求 POST 给 console 自身,再由 console 代理转给 MinIO |
受限。文件多大,请求体就多大 |
CLI / SDK(mc、boto3、aws-cli) |
S3 multipart 分片上传,每片通常几 MB | 不受限。每片都远小于任何网关的默认上限 |
最快的判据:如果 CLI 能传大文件、只有 UI 传不了,那几乎可以断定是「某个七层网关的单请求体上限」在拦,而不是 MinIO 本身或文件系统的问题。
2. UI 上传其实是两段链路,只通一段没用¶
- ①段:如果浏览器访问 console 用的是对外域名(走七层负载均衡),那么整个文件都会经过那台网关,直接撞请求体上限;
- ②段:console 转发的目标由环境变量
MINIO_SERVER_URL决定。如果它被设成对外域名,那么集群内部的这次转发会再绕一圈网关,同样会被拦。
只看一段是经典误判:把 console 的访问端口换成四层(NodePort)绕开了①②中的①,但 ② 仍走域名,结果照样失败。
3. 413 有两种签名,直接告诉你该改哪一层¶
| 返回者 | 响应特征 | 含义 |
|---|---|---|
| 云负载均衡七层引擎 | 响应头带网关签名(如 Server: stgw),body 极短 |
请求还没进集群就被拒 |
| 后端 Web Server(nginx 等) | body 形如 <center>nginx</center> |
请求已穿过 LB,被后端拒 |
排查第一步永远是看 413 的签名。
4. 这个限制是云 LB 的「实例级」配置,不在 Ingress 注解里¶
最常走的两条弯路:
- 看到 K8s 里有 Ingress,就去加
nginx.ingress.kubernetes.io/proxy-body-size注解 —— 无效。请求在到达 ingress-nginx 之前就被 LB 拦掉了,一个字节都没进后端; - 翻云厂商的 Ingress 注解文档、Terraform/API 的监听器参数表,找不到对应字段,于是断定"改不了" —— 同样是误判。它是云负载均衡的实例级「个性化配置」,与 Ingress 声明、监听器参数是两套东西。
5. 前端为什么只报一句「网络错误」¶
浏览器上传大文件时,前端常常只显示 Error: A network error occurred,看不到 413。
原因:413 响应通常不带 CORS 头,浏览器(axios)会把它归类成"网络错误"。
所以排查这类问题不要依赖前端提示,必须用命令行拿到真实状态码。
6. 一个很实用的现象换算¶
UI 进度条停在某个百分点就断,可以用它反推限制值:
例如文件 1.2GB、进度停在 5% → 限制大约就是 60MB。这个换算能帮你快速判断"是不是撞了某个整齐的默认值"。
02 故障排查与恢复闭环 SOP¶
现象:MinIO Console 页面上传大文件失败,小文件正常;前端只报 A network error occurred。
步骤一:用 CLI 做对照,确认是"UI 独有问题"¶
# 用 boto3(或 mc)传一个几百 MB 的文件,看是否能成功
python3 -c "
import boto3
from boto3.s3.transfer import TransferConfig
s3 = boto3.client('s3', endpoint_url='https://<minio-对外地址>',
aws_access_key_id='<AK>', aws_secret_access_key='<SK>', region_name='us-east-1')
cfg = TransferConfig(multipart_threshold=8*1024*1024, multipart_chunksize=8*1024*1024)
s3.upload_file('/tmp/big.bin', '<bucket>', 'probe/big.bin', Config=cfg)
print('OK')
"
- CLI 成功、UI 失败 → 确认是「单请求体上限」问题,继续往下;
- 两者都失败 → 方向不对,先查 MinIO 服务端自身(磁盘、权限、bucket 策略)。
步骤二:命令行复现 UI 的那次请求,拿到真实状态码¶
# 造固定大小的测试 body
head -c $((61*1024*1024)) /dev/zero > /tmp/probe.bin
curl -sk -D - -o /dev/null -X POST \
-H 'Content-Type: application/octet-stream' \
--data-binary @/tmp/probe.bin \
https://<console-对外域名>/ | head -8
- 观测点:状态码是否
413;响应头里有没有网关签名(Server: stgw之类)。 - 小技巧:curl 对大 body 默认发
Expect: 100-continue,超限时网关会立刻回 413,不用真传完。
步骤三:二分定位限值边界¶
for MB in 32 48 56 60 62; do
head -c $((MB*1024*1024)) /dev/zero > /tmp/probe.bin
printf '%-6s -> ' "${MB}MiB"
curl -sk -o /dev/null -w '%{http_code}\n' --max-time 300 \
-X POST --data-binary @/tmp/probe.bin https://<console-对外域名>/
done
- 观测点:边界是不是一个整齐的整数(60MiB / 500MiB / 1MiB 等)。是的话就是某台网关的固定配置。
- 记下这个数字,它是后面找配置的依据,也是客户沟通时的量化依据。
步骤四:绕开 LB 做对照,区分是「LB 拦的」还是「后端 nginx 拦的」¶
同一份数据,分别走"经 LB"和"绕过 LB":
# A) 经域名(走 LB)
curl -sk -o /dev/null -w 'via LB -> %{http_code}\n' \
-X POST --data-binary @/tmp/probe.bin https://<console-对外域名>/
# B) 绕开 LB:在集群内部直连后端入口(带 Host 头保留路由)
# 例如从任意 Pod 内执行,直连 Ingress Controller 的 Service ClusterIP
curl -s -o /dev/null -w 'direct -> %{http_code}\n' \
-H "Host: <console-对外域名>" -X POST --data-binary @/tmp/probe.bin \
http://<ingress-controller-clusterip>:80/
- A=413 且 B=通过 → 拦截在 LB,后端完全正常,只需改 LB;
- 两者都 413 → LB 放行了,是后端 nginx 的
client_max_body_size(集群全局值或 Ingress 注解)在拦。
补充判据:把同一份 body 裸 POST 到一个后端接口(不带
Content-Type)—— 返回415说明数据已完整抵达后端(只是内容类型不对),返回413说明根本没进集群。 用 415/413 的差别就能判断"到没到后端"。
步骤五:检查 MinIO 侧的转发目标¶
这是 UI 上传特有的一段,最容易被忽略:
# console 转发给 MinIO 用哪个地址
kubectl -n <ns> get deploy <minio-release> \
-o jsonpath='{range .spec.template.spec.containers[0].env[*]}{.name}={.value}{"\n"}{end}' | grep -E 'MINIO_SERVER_URL|MINIO_BROWSER_REDIRECT_URL'
- 观测点:如果
MINIO_SERVER_URL是一个对外域名,而集群内访问该域名会绕回 LB,那 console 的转发(②段)也会撞上限。 - 正确做法:让它指向集群内部可达的地址(Service 名,如
http://<release>-minio:9000)。 ⚠️ 注意:指成「节点 IP + NodePort」不一定通 —— Pod 回访自己所在节点的 NodePort 存在 hairpin 问题。 - 另注:如果 console 是内嵌在 MinIO Server 里的(
console.embedded: true),浏览器和 console 转发都落在同一个 Pod 上,排查时别去找独立的 console Pod。
步骤六:确认云 LB 实例是否已绑定个性化配置¶
控制台:负载均衡 → 选中实例 → 看是否已关联「个性化配置」。
API(只读):
# 查实例详情,关注 ConfigId:为空 = 没绑,走引擎默认值(常见默认 60M)
tccli clb DescribeLoadBalancers --region <region> --LoadBalancerIds '["lb-xxxxxxxx"]'
# 列出账号下已有的个性化配置及其内容
tccli clb DescribeCustomizedConfigList --region <region> --ConfigType CLB --Limit 20
- 观测点:
ConfigId为空说明用的是引擎默认值,应与步骤三测出的边界值吻合。
03 解决方案¶
A. 治本:给 LB 绑定实例级个性化配置¶
配置内容(每一行以 ; 结尾):
client_max_body_size:客户端请求体上限。默认常见60M,可配范围一般[1-10240]M;proxy_request_buffering off:不缓存客户端请求体、收到即转发。当上限大于 256M 时,官方要求必须设为 off(其默认值本就是 off,显式写上更保险,也便于后人 review)。
控制台路径:负载均衡控制台 → 左侧「个性化配置」→ 新建 → 保存后在操作栏「绑定」目标实例。
# API 方式
tccli clb SetCustomizedConfigForLoadBalancer --region <region> \
--LoadBalancerId lb-xxxxxxxx \
--ConfigContent "client_max_body_size 10240M;proxy_request_buffering off;"
前置条件:不少实例默认开启操作保护(OperateProtect),会拒绝这类修改。在 K8s 场景下它常由 Ingress 注解体现,需要先关掉:
B. 绕行:给 MinIO Console 开一条四层通道¶
四层(TCP)转发不解析 HTTP,没有请求体上限这个概念。给 console 的 Service 加 NodePort 后,用 http://<节点IP>:<nodePort> 访问 UI 即可绕开网关。
⚠️ 这条通道只解决了①②中的①。如果 MINIO_SERVER_URL 仍指向对外域名,console 转发(②段)依旧会撞上限——必须同时按步骤五把它改成集群内部地址。
C. 绕行:改用 CLI / SDK 分片上传¶
分片上传每片几 MB,天然不受限。适合"文件必须马上传上去"的应急场景,但不是 UI 体验。
收尾提醒:后端 nginx 的上限会变成下一道瓶颈¶
LB 放宽到 10G 之后,如果后端 nginx 的 client_max_body_size / proxy-body-size 还停在 500m(K8s 上常见于 ingress-nginx 的 ConfigMap 全局配置),那么超过 500M 的请求会改由 nginx 拒绝。
- nginx 的 413 签名是
<center>nginx</center>,看到它就说明需要动后端了; - 放宽方式:改 ConfigMap 的
proxy-body-size(全局生效)或给对应 Ingress 加nginx.ingress.kubernetes.io/proxy-body-size注解(只影响该服务)。
结论:两层要一起放开,只改一层瓶颈只会平移。
04 高频坑位清单¶
- 在后端加
proxy-body-size注解无效 —— 请求在 LB 就被拒了,到不了后端; - 只看 Ingress 注解 / Terraform 监听器参数表就断言"改不了" —— 实例级个性化配置是另一套东西,这两处查不到是正常的;
- 把链路方向搞反 —— 误以为"后端转发给 LB 时被 LB 拒绝",实际是"客户端到 LB 时就被拒",LB 在最前面;
- 只绕一段就以为解决了 —— 把 console 换成 NodePort 但没改
MINIO_SERVER_URL,②段仍会撞限; MINIO_SERVER_URL指向节点 IP + NodePort —— Pod 回访自己所在节点的 NodePort 可能不通(hairpin),优先用 Service 名;- MinIO 不打请求日志 ——
logs里往往只有启动横幅,别指望从日志里看到上传失败的记录,要靠复现; - 想直接调 console 的上传接口做复现时字段名踩坑 —— 接口是
POST /api/v1/buckets/<bucket>/objects/upload?prefix=<p>,multipart 的字段名必须是数字(例如-F "0=@file");字段名用字符串会返回500 strconv.ParseInt: parsing "file": invalid syntax; - 被前端文案带偏 —— 413 无 CORS 头时浏览器只显示
A network error occurred,务必用 curl 拿真实状态码; - 放宽实例级配置的影响面 —— 一台 LB 后面常挂多个域名/服务,改实例级配置会同时影响其上所有服务,动之前评估并通知相关方;
- Share 链接的地址跟着
MINIO_SERVER_URL走 —— 把它改成集群内部地址后,UI 里 Share 生成的临时链接在集群外会打不开;改成对外域名则相反(但要注意 ②段会绕网关)。
05 一页速查¶
# 1) 复现并看签名(网关签名 = 改 LB;<center>nginx</center> = 改后端 nginx)
head -c $((61*1024*1024)) /dev/zero > /tmp/probe.bin
curl -sk -D - -o /dev/null -X POST --data-binary @/tmp/probe.bin https://<console-对外域名>/ | head -5
# 2) 二分找边界:32 / 48 / 56 / 60 / 62 MiB ...
# 3) 绕开 LB 对照:集群内直连 Ingress Controller 的 ClusterIP,带 Host 头
# 4) 查 MinIO 的转发目标(这一段最容易被忽略)
kubectl -n <ns> get deploy <minio-release> \
-o jsonpath='{range .spec.template.spec.containers[0].env[*]}{.name}={.value}{"\n"}{end}' \
| grep -E 'MINIO_SERVER_URL|MINIO_BROWSER_REDIRECT_URL'
# 5) 查 LB 实例是否已绑配置(ConfigId 为空 = 走默认值)
tccli clb DescribeLoadBalancers --region <region> --LoadBalancerIds '["lb-xxxxxxxx"]'
tccli clb DescribeCustomizedConfigList --region <region> --ConfigType CLB --Limit 20
# 6) 绑定实例级个性化配置
tccli clb SetCustomizedConfigForLoadBalancer --region <region> \
--LoadBalancerId lb-xxxxxxxx \
--ConfigContent "client_max_body_size 10240M;proxy_request_buffering off;"
核心记忆点
- CLI 能传、UI 传不了 → 单请求体上限问题,去查网关;
- 413 + 网关签名 → 改 LB 的实例级个性化配置(
client_max_body_size); - 413 + nginx 签名 → 改 后端 nginx;
- 两层要一起放开,否则瓶颈只是平移;
- UI 的链路有两段(浏览器→console、console→MinIO),改
MINIO_SERVER_URL才能把第二段也绕开。