一次 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. 在 connect、login、cwd、storeFile、数据库入库前后加阶段日志,快速定位卡点。

总结

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

最终修复点很小:

java 复制代码
ftpClient.enterLocalPassiveMode();

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

相关推荐
MandalaO_O10 小时前
IDEA 开发(快捷键 + 调试 + 序列化)
java·ide·intellij-idea
xiaoqiMikko11 小时前
JVM 线上排查实战(七):jps 看不见它、jstack 连不上它,可它明明活得好好的
java·jvm
狼爷11 小时前
Rust/Go/Java/Python/PHP 大比拼:负载下后端框架到底差多少?
java·后端·编程语言
念何架构之路11 小时前
zap扩展生态与总结
java·前端·数据库
第七页独白12 小时前
汽车零件厂如何通过 QMS 真正落地 IATF 16949——QMS软件系统:品质检验-内审稽核-8d客诉管理:全星质量管理软件系统
java·前端·数据库
QCoding12 小时前
Spring AI Alibaba Graph实战:从ReAct Agent到Workflow,企业AI复杂流程该如何编排?
java·人工智能
用户0942485680312 小时前
第25章:Java虚拟线程(Project Loom)实战与调度协作
java·jvm
砚底藏山河13 小时前
量化实战:截面因子有效性检验(IC 分析与分层回测)
java·python·金融·maven
wuminyu13 小时前
ForkJoinPool内部WorkQueue的Lock-Free数组操作以及并发任务窃取原理剖析
java·linux·c语言·jvm·c++
Elastic 中国社区官方博客13 小时前
使用 Lucene 搜索你的 Bean — 索引
java·大数据·数据库·elasticsearch·搜索引擎·全文检索·lucene