一次 FTP 上传引发 504 Gateway Timeout 的排查复盘

背景

生产环境有一个文件上传功能:用户在后台页面选择一个升级包,提交后服务端会把文件上传到 FTP 服务器,并在数据库中保存文件记录。

某天上传一个很小的文件时,页面返回了:

html 复制代码
504 Gateway Time-out
openresty

文件只有几百字节到 1KB 左右,所以一开始很容易误判为前端上传、网关超时配置或者文件解析问题。

现象

页面请求链路大致如下:

text 复制代码
浏览器
  -> openresty/nginx
  -> Tomcat/Spring MVC
  -> FTP 服务器

后端上传接口逻辑大致是:

java 复制代码
public String upload(MultipartFile file, FileInfo fileInfo, HttpServletRequest request) {
    fileInfo.setFileName(file.getOriginalFilename());
    boolean success = ftpFileService.insert(fileInfo, file.getInputStream());
    return success ? "ok" : "error";
}

服务层中会直接连接 FTP 并上传:

java 复制代码
FTPClient ftpClient = new FTPClient();
ftpClient.connect(host, port);
ftpClient.login(username, password);
ftpClient.changeWorkingDirectory(path);
ftpClient.setFileType(FTPClient.BINARY_FILE_TYPE);
ftpClient.storeFile(fileName, inputStream);

问题是,请求一直不返回,最终由 openresty 返回 504。

初步排查

先看后端日志,曾经出现过一次空指针:

text 复制代码
java.lang.NullPointerException
at XxxController.upload(...)

定位到是 MultipartFile file 为空时直接调用了:

java 复制代码
file.getOriginalFilename()

这确实是一个代码健壮性问题,所以先补了防御:

java 复制代码
if (file == null || file.isEmpty()) {
    log.error("文件上传失败:未收到上传文件,contentType={}", request.getContentType());
    return "error";
}

但这不是 504 的根因。因为实际页面正常提交了文件,文件也很小,真正的 504 发生在后端继续处理 FTP 上传时。

验证 FTP 状态

为了确认 FTP 服务本身是否正常,在应用服务器上执行了几类命令。

测试登录和目录列表:

bash 复制代码
curl -v --connect-timeout 10 --max-time 30 --list-only \
  --user '***:***' \
  'ftp://ftp.example.com/meter/'

测试上传:

bash 复制代码
echo 'ftp upload test' > /tmp/ftp_test.bin

curl -v --connect-timeout 10 --max-time 30 \
  --user '***:***' \
  --upload-file /tmp/ftp_test.bin \
  'ftp://ftp.example.com/meter/ftp_test.bin'

结果显示:

text 复制代码
230 Logged on
250 CWD successful
EPSV
229 Entering Extended Passive Mode
150 Opening data channel
226 Successfully transferred

这说明:

  • FTP 服务器可达
  • 账号密码正确
  • 目标目录存在
  • 目录有上传权限
  • 应用服务器到 FTP 的网络链路可用

但注意日志中的关键点:EPSV

curl 成功上传时使用的是 FTP 被动模式。

根因

问题出在 Java 代码使用 Apache Commons Net FTPClient 上传时,没有开启被动模式。

原代码类似:

java 复制代码
public boolean storeFile(InputStream inputStream, String fileName) throws IOException {
    // ftpClient.enterLocalPassiveMode();
    return ftpClient.storeFile(fileName, inputStream);
}

FTPClient 默认使用主动模式。

FTP 有两个连接:

  • 控制连接:登录、切目录、发送命令
  • 数据连接:传文件、列目录

主动模式下,FTP 服务器会反向连接应用服务器的数据端口。生产环境通常存在防火墙、云安全组、NAT、容器网络或代理层,这种反向连接很容易失败或卡住。

所以当时的真实情况是:

text 复制代码
控制连接正常:connect/login/changeWorkingDirectory 成功
数据连接异常:storeFile 建立数据通道失败或长时间等待
请求线程阻塞:Tomcat 一直没有响应
网关超时:openresty 返回 504

这也解释了为什么文件只有 1KB 仍然超时。问题不是传输慢,而是 FTP 数据通道建立方式不适合当前生产网络环境。

修复

核心修复是在上传前启用 FTP 被动模式:

java 复制代码
ftpClient.enterLocalPassiveMode();
boolean result = ftpClient.storeFile(fileName, inputStream);

同时建议补上超时配置,避免 FTP 异常时拖到网关超时:

java 复制代码
private static final int FTP_CONNECT_TIMEOUT = 10_000;
private static final int FTP_DEFAULT_TIMEOUT = 10_000;
private static final int FTP_DATA_TIMEOUT = 30_000;
private static final int FTP_SOCKET_TIMEOUT = 30_000;

private void setFtpTimeouts(FTPClient client) {
    client.setConnectTimeout(FTP_CONNECT_TIMEOUT);
    client.setDefaultTimeout(FTP_DEFAULT_TIMEOUT);
    client.setDataTimeout(FTP_DATA_TIMEOUT);
}

连接时设置:

java 复制代码
FTPClient ftpClient = new FTPClient();
setFtpTimeouts(ftpClient);
ftpClient.connect(host, port);
ftpClient.setSoTimeout(FTP_SOCKET_TIMEOUT);

上传时设置:

java 复制代码
ftpClient.enterLocalPassiveMode();
ftpClient.setFileType(FTPClient.BINARY_FILE_TYPE);

boolean result = ftpClient.storeFile(remoteFileName, inputStream);
if (!result) {
    log.error("FTP 上传失败,replyCode={}, replyString={}",
            ftpClient.getReplyCode(), ftpClient.getReplyString());
}

服务层也应该判断 FTP 是否连接成功:

java 复制代码
if (!ftpUtil.connectFtp(ftpConfig)) {
    log.error("FTP 连接失败,host={}, port={}, path={}",
            ftpConfig.getHost(), ftpConfig.getPort(), ftpConfig.getPath());
    return false;
}

为什么 openresty 报 504

504 并不一定代表 openresty 本身有问题。

这里的含义是:

text 复制代码
openresty 已经把请求转发给后端
但后端在网关允许的时间内没有返回响应
所以 openresty 返回 504

后端没有返回响应的原因,是请求线程同步等待 FTP 上传,而 FTP 数据通道卡住。

排查经验

这类问题可以按下面顺序排查:

  1. 看后端日志,确认请求是否进入 Controller。
  2. 看是否有空指针、上传解析失败、文件为空等基础问题。
  3. 确认文件大小,排除 client_max_body_size、Tomcat maxPostSize 等限制。
  4. 在应用服务器上用 curl 测 FTP 登录、目录列表和上传。
  5. 注意 curl -v 日志中的 PASV/EPSV,这说明测试走的是被动模式。
  6. 检查 Java FTP 客户端是否也开启了被动模式。
  7. 给 FTP 连接、数据传输补超时,避免网关先超时。
  8. connectlogincwdstoreFile、数据库入库前后加阶段日志,快速定位卡点。

总结

这次问题的根因不是文件大、不是账号错、也不是 FTP 服务不可用,而是 Java FTP 客户端默认主动模式与生产网络环境不匹配。

最终修复点很小:

java 复制代码
ftpClient.enterLocalPassiveMode();

但定位过程很有价值。看到 504 Gateway Timeout 时,不要只盯着网关配置;如果后端请求中包含外部系统调用,比如 FTP、HTTP、数据库、消息队列,也要重点检查后端线程是否阻塞在外部依赖上。

相关推荐
szephyr4 小时前
Nginx 反向代理实战:从 502、跨域到 HTTPS,一次打通
运维·nginx·https·部署·反向代理
devpotato4 小时前
RPO与RTO:容灾的两个关键指标
java·后端
迷茫的大专生4 小时前
服务器部署与 MySQL 运维学习笔记:从 Ansible 到高可用与性能优化
mysql·nginx·ansible·mysql优化·keepalive
宁渡AI大模型5 小时前
AI 全栈面试新趋势:Vibe Coding、前端、Java 后端高频面试题深度解析|河南宁渡科技有限公司编程教程
java·javascript·人工智能·python·ai大模型
CallFay云起未来5 小时前
AI客服如何与人工客服协同?从任务路由到上下文交接的Agent架构实践
java·人工智能·文心一言
cfm_29145 小时前
观察者模式
java
野生技术架构师5 小时前
1000+ Java面试题知识图谱:从基础语法到分布式架构的完整拓扑
java·面试
步行cgn5 小时前
Spring 底层如何创建对象:反射机制与实例化策略
java·后端·spring
何以解忧,唯有..6 小时前
Redis 过期事件监听:原理与实战
java
风中的小熊生气6 小时前
Spring Boot 面试知识(二):分层、IoC、异常、配置与 Redis
java·springboot