背景
生产环境有一个文件上传功能:用户在后台页面选择一个升级包,提交后服务端会把文件上传到 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 数据通道卡住。
排查经验
这类问题可以按下面顺序排查:
- 看后端日志,确认请求是否进入 Controller。
- 看是否有空指针、上传解析失败、文件为空等基础问题。
- 确认文件大小,排除
client_max_body_size、TomcatmaxPostSize等限制。 - 在应用服务器上用
curl测 FTP 登录、目录列表和上传。 - 注意
curl -v日志中的PASV/EPSV,这说明测试走的是被动模式。 - 检查 Java FTP 客户端是否也开启了被动模式。
- 给 FTP 连接、数据传输补超时,避免网关先超时。
- 在
connect、login、cwd、storeFile、数据库入库前后加阶段日志,快速定位卡点。
总结
这次问题的根因不是文件大、不是账号错、也不是 FTP 服务不可用,而是 Java FTP 客户端默认主动模式与生产网络环境不匹配。
最终修复点很小:
java
ftpClient.enterLocalPassiveMode();
但定位过程很有价值。看到 504 Gateway Timeout 时,不要只盯着网关配置;如果后端请求中包含外部系统调用,比如 FTP、HTTP、数据库、消息队列,也要重点检查后端线程是否阻塞在外部依赖上。