一次 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、数据库、消息队列,也要重点检查后端线程是否阻塞在外部依赖上。

相关推荐
码农进化录1 小时前
Java 程序员的 AI 进化论 | AI 辅助 CI/CD,让 AI 帮你做代码审查和部署决策
java·spring boot·openai
zyplayer-doc1 小时前
企业文档的进化:网盘和在线文档的下一个升级方向—知识库系统
java·大数据·数据库·人工智能·ocr
吃饱了得干活1 小时前
从零设计一个高并发点赞系统:缓存+MQ异步持久化实战
java·架构
奇牙coding1 小时前
gpt-5.6-luna 调用报 400 bad_request 怎么办?同样的 messages 在 gpt-5.5 没问题——两处校验差异和修复写法
java·windows·gpt·ai
凤山老林1 小时前
从“无法创建本地线程“异常说起:深度解析线程栈与JVM线程优化指南
java·开发语言·jvm
聚美智数2 小时前
常见疾病查询-疾病症状—疾病介绍-疾病大全-疾病治疗查询API接口介绍
android·java·数据库
慧都小妮子2 小时前
Aspose.Words for JasperReports v26.6 发布,底层Java大升级
java·pdf·报表工具·aspose.words·文档转换·报表导出·jasperreports
白宇横流学长2 小时前
基于SSM医院电子病历管理系统的设计与实现
java
观远数据2 小时前
经营闭环的最后一公里:从看见问题到解决问题之间还差什么
java·开发语言·数据库