☰
FTP上传下载实战:ftplib、被动模式与断点续传排错
2026/10/29 2:28:28 网站建设 项目流程

简介:面向Java开发者的FTP文件传输学习包,围绕从服务器上传与下载文件的常见需求,梳理连接建立、登录验证、主动/被动模式切换、目录操作、二进制传输、文件收发及断开清理等核心环节。压缩包共2个文件,以1个Java源码和1个jar依赖库为主,整体约106KB,源码可用于对照理解FTPClient调用流程,jar依赖便于直接补齐运行环境,适合在本地搭建小型FTP客户端示例并调试。已有251人学习下载,说明该主题在Java网络编程入门与日常运维脚本场景中具有一定参考需求。读者可从中获得一套可运行的FTP操作代码骨架,结合异常捕获、传输模式设置与资源释放等细节,快速定位上传下载失败、目录切换异常或连接未关闭等问题,并在此基础上扩展超时控制、重试机制与安全传输配置,形成适合自身业务的文件传输工具。

1. FTP 上传下载文件这件事:老协议为什么还压在生产线上

「FTP 上传下载文件」听着像是上个时代的活,但真正在机房待过的人都知道,它离消失还早得很。我最近一次和它打交道,是个凌晨跑完不对账的定时任务:控制连接一切正常,登录成功、命令一条条返回 226,可目标目录里就是少了一个包。后来定位到是数据连接被中间的防火墙掐了,日志干净得像没事发生——这种「看起来很成功」的失败,是 FTP 最典型的一面。

这份资源要解决的,就是绕不开 FTP 的那几类活:老设备只肯吐 FTP、外部对接方只给一个账号密码、内网机器之间搬运日志和备份包、需要按天做增量同步。新手可以照着脚本跑通第一次上传和下载,熟手更该盯住它的边界——两条独立连接、被动模式的端口区间、文件名编码、断点续传的偏移量,每一项都能让你在半夜被叫起来。下面按「先看懂模型、再动手写脚本、最后把坑挖出来」的顺序往下走。

2. 两条连接与两种模式:通信模型里最容易配错的三处

FTP 和 HTTP 最大的差别在于,它一次会话要开两条 TCP 连接,而且这两条连接的建立方向可以反过来。很多人脚本写得没问题、账号密码也没错,最后卡在 425 或者「列表能拿到但文件传不动」,根子都在这一层。这一章把连接结构、主动被动模式、传输类型与编码三件事拆开,配一段可以直接跑的探测代码,先把链路摸清楚再谈上传下载。

2.1 控制连接与数据连接是两套东西

控制连接固定连服务器的 21 端口(明文 FTP 的默认端口,实际部署也可能改成别的),整个会话期间一直保持,所有命令和响应都以文本行的形式在这条通道上走:USER、PASS 完成认证,CWD 切目录,PWD 看当前位置,TYPE 设传输类型,PASV 协商数据通道。你看到的220、230、250、226这些三位数字码,全是控制连接在说话。

数据连接是临时的,只在实际传文件或者列目录的那一小段时间里存在。LIST、NLST、MLSD、RETR、STOR这些命令才会触发数据连接,传完立刻关闭。问题在于,这条数据连接由谁来发起、连到哪个端口,是可以变的——这就是主动模式和被动模式的差别。

理解这一点的实际价值在于:控制连接通、数据连接不通,是完全可能的。防火墙、NAT 网关、容器网络策略,任何一个环节都可能只管住数据连接那一侧。我一般会先用一段最小的探测代码,把欢迎语、当前目录、文件列表和传输类型都打出来,判断到底是哪条腿瘸了。

from ftplib import FTP, error_perm ftp = FTP() # 编码必须先设,很多老服务器回包是 GBK,不设会直接抛 UnicodeDecodeError ftp.encoding = 'utf-8' ftp.set_debuglevel(2) # 2 会把收发的每条命令打印出来,排错时非常有用 ftp.connect('ftp.example.com', 21, timeout=15) ftp.login('demo_user', 'demo_pass') print('欢迎语:', ftp.getwelcome()) print('当前目录:', ftp.pwd()) # TYPE I 表示二进制传输,避免服务器做换行符转换 print('TYPE 响应:', ftp.sendcmd('TYPE I')) print('目录列表:', ftp.nlst('.')[:10]) # 只取前 10 条,防止目录太大刷屏 ftp.quit()

这段代码的价值不在功能,而在诊断。set_debuglevel(2)会把--> PASV和<-- 227 Entering Passive Mode (...)这类交互原样打出来,你能直接看到服务器返回的被动端口是不是落在了防火墙放行的区间里。nlst是一次典型的数据连接操作,如果它抛TimeoutError或者ConnectionRefusedError,而前面pwd明明返回正常,那基本可以确诊为数据通道被拦。

参数上要留意三处:encoding建议先按服务器实际情况选utf-8或gbk,拿不准就两种都试;timeout不要设成默认的None,卡死的连接会让整个脚本挂在那里不动,15 到 30 秒是个合理区间;set_debuglevel只在排错时开,生产脚本里关掉,否则日志会被撑爆。

2.2 主动模式与被动模式:425 多半出在这里

主动模式(PORT)的流程是反的:客户端用 PORT 命令告诉服务器「你来连我的这个 IP 和端口」,然后服务器从自己的 20 端口主动向客户端发起数据连接。这在没有 NAT 的年代很高效,但在今天几乎必然出事——客户端躲在 NAT 后面时,报给服务器的私网 IP 服务器根本连不上,或者客户端本机的防火墙根本不接受外部主动连入。

被动模式(PASV)把方向掰了回来:客户端发 PASV,服务器回一个227 Entering Passive Mode (h1,h2,h3,h4,p1,p2),里面是服务器开放的临时端口,客户端主动去连。Python 的 ftplib 默认就是被动模式(set_pasv(True)),这也是绝大多数场景该用的模式。但被动模式有自己的坑:服务器开放的是一段随机端口区间,常见做法是在服务端配置里把区间固定下来,比如 50000 到 50100,然后在防火墙和云安全组里把这段放行。区间不固定的话,你永远不知道下一次连接会撞到哪个被拦的端口。

判断方法很直接:如果控制连接正常、PWD正常,但一执行LIST或RETR就报 425「Can't open data connection」,先怀疑被动端口区间没放行;如果脚本在本地开发机跑得好好的,一上生产服务器就挂,八成是生产机的出网策略只放了 21 端口。

from ftplib import FTP ftp = FTP() ftp.encoding = 'utf-8' ftp.connect('ftp.example.com', 21, timeout=20) ftp.login('demo_user', 'demo_pass') # 显式声明使用被动模式,等价于发送 PASV 命令 ftp.set_pasv(True) # 手动发一次 PASV,看看服务器到底回了哪个 IP 和端口 resp = ftp.sendcmd('PASV') print('PASV 响应:', resp) # 227 Entering Passive Mode (10,0,0,5,195,80) ftp.quit()

把195,80这两个数换算一下:195 * 256 + 80 = 50000,这就是服务器让你去连的端口。这段换算值得记住,因为排障时运维只问你「卡在哪个端口」,你答得出来就能省半小时。如果这个端口落在安全组放行范围之外,解决办法有两个:让服务端把pasv_min_port/pasv_max_port固定成一段连续区间并放行,或者临时切到主动模式验证——但主动模式只能作为定位手段,不能作为长期方案,尤其是在容器和云主机上。

2.3 传输类型与文件名编码:看不见的破坏力

TYPE命令决定数据连接上怎么解释字节。TYPE A(ASCII)会让服务器在传输过程中做换行符转换,TYPE I(IMAGE/Binary)则原样搬运。这两个选错了,后果是静默的:传一个压缩包用 ASCII 模式,大小可能差几十个字节,解压直接报损坏;传一个 shell 脚本用二进制模式,到了对端可能因为换行符不对而执行失败。

我的习惯是无脑TYPE I,然后在需要的地方自己处理换行。ftplib 的storbinary和retrbinary默认就走二进制,这一点比裸写 socket 省心,但如果你的脚本混用了storlines,那就要清楚它是按行读写的,适合纯文本且你确认对端接受当前换行风格。

编码是第二个隐形杀手。FTP 协议早期是纯 ASCII 的,中文文件名怎么表示完全看服务器实现。常见的两种做法是在登录后发OPTS UTF8 ON声明使用 UTF-8,或者干脆用服务器本地的 GBK。ftplib 的encoding属性必须和服务器一致,否则你上传时文件名是对的,nlst列出来却是一串乱码,后面按名字下载全部失败。

from ftplib import FTP, error_perm ftp = FTP() ftp.encoding = 'utf-8' ftp.connect('ftp.example.com', 21, timeout=20) ftp.login('demo_user', 'demo_pass') # 尝试开启 UTF-8 模式,服务器不支持会抛 500/502,忽略即可 try: print(ftp.sendcmd('OPTS UTF8 ON')) except error_perm as e: print('服务器不支持 UTF8 选项:', e) ftp.set_pasv(True) ftp.sendcmd('TYPE I') # 二进制传输,压缩包、图片、数据库备份都必须用这个 # 用 MLSD 拿结构化列表,能同时得到文件名、大小和修改时间 for name, facts in ftp.mlsd('/', facts=['type', 'size', 'modify']): print(name, facts) ftp.quit()

MLSD比LIST和NLST都好用,因为它返回的是机器可解析的键值对,size和modify直接能拿来做增量判断,不用再去正则匹配ls -l格式的字符串。可惜不是所有老服务器都支持,遇到500 Unknown command就退回NLST加SIZE命令组合。如果mlsd抛出的文件名是乱码,把encoding换成gbk再试一遍,这是我遇到中文名问题时固定的第一步。

3. ftplib 落地:上传、下载、递归拉目录的四段可抄代码

模型清楚之后,剩下的就是把动作固化成函数。这一章的四段代码对应四种最常见的活:稳定连接、上传带进度、下载并保留目录结构、传完做校验。每一段都可以单独抠出来用,也可以拼成一个脚本。我一般会把连接参数抽到配置里,认证信息走环境变量,避免密码硬编码在仓库里——这条不是洁癖,是审计要求。

3.1 连接骨架:超时、重连与环境变量

生产脚本和演示脚本最大的差别是容错。FTP 服务器的空闲超时往往设在 300 秒左右,一个跑批脚本如果中间有长时间的本地处理,再回来发命令就可能收到421 Timeout,然后控制连接被服务端关掉。这时候继续用同一个连接对象发命令,必然报error_temp或者直接EOFError。

解决办法是把「拿连接」封装成函数,每次用之前判断连接是否还活着,不活就重连。ftp.voidcmd('NOOP')是最轻量的探活方式,服务器回200就说明通道还在。

import os from ftplib import FTP, all_errors def get_ftp(host, timeout=20): ftp = FTP() ftp.encoding = 'utf-8' ftp.connect(host, 21, timeout=timeout) # 认证信息从环境变量读,不要写死在代码里 ftp.login(os.environ['FTP_USER'], os.environ['FTP_PASS']) ftp.set_pasv(True) ftp.sendcmd('TYPE I') return ftp def ensure_alive(ftp, host): try: ftp.voidcmd('NOOP') # 探活,成功返回 200 return ftp except all_errors: try: ftp.close() except Exception: pass return get_ftp(host)

all_errors是 ftplib 提供的异常元组,把error_reply、error_temp、error_perm、error_proto和OSError都包了进去,捕它比逐个列异常省事。ensure_alive的写法看起来有点粗暴——连接断了就整个重建——但在批量任务里这是最省心的策略,因为 FTP 的连接状态很难恢复到「干净」的状态,重建比修复便宜。

要提醒一点:connect的timeout和login之后的数据传输超时是两个概念。socket层面的超时由connect传入的timeout控制,会作用到之后所有的 socket 操作上。如果你的网络抖动厉害,把它设到 20 到 30 秒;如果链路很稳但要传大文件,设太小会在传输中途触发超时,那时候就只能靠断点续传来救,代价更大。

3.2 上传:storbinary 的回调与进度统计

上传的核心是storbinary(cmd, fp, blocksize=8192, callback=None, rest=None)。第一个参数是命令字符串,通常是STOR 远程文件名;第二个是打开的文件对象,必须是二进制模式;blocksize决定每次从本地读多少字节往 socket 里写,默认 8192,内网可以调到 65536 提速,跨公网保持在 8192 到 32768 更稳。

callback每次发送一个块就被调用一次,入参是这一块的字节数。用它做进度条或者日志最好用,但注意不要在回调里做耗时操作,否则会拖慢整体传输。

import os from ftplib import FTP def upload_file(ftp, local_path, remote_dir, remote_name=None): remote_name = remote_name or os.path.basename(local_path) total = os.path.getsize(local_path) sent = 0 def _on_block(block): nonlocal sent sent += len(block) percent = sent * 100 // total if total else 100 print(f'\r上传 {remote_name}: {sent}/{total} 字节 ({percent}%)', end='') # 确保远程目录存在,不存在则逐级创建 try: ftp.cwd(remote_dir) except error_perm: for part in remote_dir.strip('/').split('/'): try: ftp.mkd(part) except error_perm: pass ftp.cwd(part) with open(local_path, 'rb') as fp: ftp.storbinary(f'STOR {remote_name}', fp, blocksize=32768, callback=_on_block) print()

这里有个细节值得说:mkd在目录已存在时会抛error_perm(通常是 550),直接吞掉继续cwd就行,这是 FTP 建目录的常规写法,没有幂等的mkdir -p。另外注意cwd的语义——FTP 的当前目录是服务端会话状态,你的函数改完之后要记得它对后续操作有影响,最稳妥的做法是每次操作前显式cwd到目标目录,不要依赖上一次残留的位置。

进度统计用nonlocal累加会出现一个偏差:callback收到的块大小是写入 socket 的量,正常情况下和文件大小一致,但如果中途失败,sent会停在半路。所以这个进度只能用于人看,不能当成成功的判据——真正的成功判据是storbinary正常返回且远程SIZE对得上。

3.3 下载:retrbinary 与递归拉取整个目录

下载用retrbinary('RETR 文件名', callback, blocksize, rest=None),回调收到的是数据块,需要自己往本地文件里写。这一点和上传不同,上传是 ftplib 帮你读本地文件,下载是 ftplib 把数据交给你,怎么存由你决定。

递归拉目录的难点在于 FTP 没有现成的递归命令,只能自己维护一个栈。服务端的目录遍历有两条路:nlst只能列出名字,MLSD能带出type字段区分目录和文件。优先用MLSD,不支持的话就用cwd试着切进去,切成功就是目录——这个技巧虽然土,但兼容性最好。

import os from ftplib import FTP, error_perm def download_tree(ftp, remote_dir, local_dir): os.makedirs(local_dir, exist_ok=True) try: entries = list(ftp.mlsd(remote_dir, facts=['type', 'size'])) structured = True except error_perm: entries = [(n, {}) for n in ftp.nlst(remote_dir)] structured = False for name, facts in entries: if name in ('.', '..'): continue remote_path = f'{remote_dir.rstrip("/")}/{name}' local_path = os.path.join(local_dir, name) is_dir = facts.get('type') == 'dir' if structured else False if not structured: # 没有 MLSD 时,用 cwd 试探是否为目录 try: ftp.cwd(remote_path) ftp.cwd(remote_dir) is_dir = True except error_perm: is_dir = False if is_dir: download_tree(ftp, remote_path, local_path) else: with open(local_path, 'wb') as fp: ftp.retrbinary(f'RETR {remote_path}', fp.write, blocksize=32768) print('已下载', remote_path)

递归函数里对目录的判断逻辑有个隐患:cwd试探会改变当前目录,所以切进去之后必须立刻切回来,代码里ftp.cwd(remote_dir)那一行就是干这个的。如果服务端对CWD的响应慢,这个方案会明显拖慢遍历速度,那时候就只能退而求其次,约定好目录结构,只同步固定层级。

另外注意local_path里的文件名直接来自服务器,中文名场景下如果encoding设错,这里会在本地创建出一堆乱码文件名。跑之前先用一个小目录试一遍,确认文件名字节正确,再放开全量。这个验证动作我每次都做,因为它属于「出错之后清理成本极高」的那类问题。

3.4 校验:用 SIZE 和 MD5 把传输闭环

传输成功不等于文件正确。FTP 在二进制模式下不做完整性校验,网络中断、磁盘写满、服务端中途返回 426,都可能留下一个大小不对的文件。storbinary和retrbinary在不抛异常的情况下返回226 Transfer complete,这只说明服务端认为传完了,不代表字节一定对齐。

第一层校验是SIZE命令,单位是字节,直接和本地os.path.getsize对比。这一层能挡住绝大多数截断问题,成本几乎为零。

import hashlib from ftplib import FTP, error_perm def remote_size(ftp, remote_path): try: return ftp.size(remote_path) # 返回 int,单位字节 except error_perm: return None # 服务器不支持 SIZE 命令时返回 None def remote_md5(ftp, remote_path): h = hashlib.md5() ftp.retrbinary(f'RETR {remote_path}', h.update, blocksize=65536) return h.hexdigest() def local_md5(path): h = hashlib.md5() with open(path, 'rb') as fp: for chunk in iter(lambda: fp.read(65536), b''): h.update(chunk) return h.hexdigest()

remote_md5的实现方式是把文件整个再拉一遍喂给哈希对象,对于几 GB 的备份包来说代价不小,所以我的实际策略是分级的:日常小文件用SIZE对比就够了;重要数据在传完之后再跑一次MD5比对;超大文件改成传完立刻对端算哈希、通过别的通道回传,不在 FTP 里绕这一圈。

还要注意ftp.size()在某些服务器上对 ASCII 模式的文件返回的是转换后的大小,所以前面强调TYPE I不是可选项。如果你拿到的大小和本地差几字节且文件是文本类,第一反应应该是传输模式被改过,而不是文件坏了。

4. 命令行侧的打法:curl 与 lftp 在批量同步脚本里的分工

不是所有活都值得写 Python。临时拉一个文件、CI 流水线里传个制品、crontab 里跑个每日同步,命令行工具更直接,出问题也更容易在日志里看到原始错误码。这一章说清楚 curl 和 lftp 各自适合什么,以及怎么把它们塞进 shell 脚本而不留隐患。

4.1 curl 的四种姿势:单文件上传下载与建目录

curl 支持 FTP 协议,语法统一,返回值规范,是脚本里的首选。四种最常见的用法:下载单文件、上传单文件、上传时自动建目录、断点续传下载。

# 1. 下载单个文件到当前目录,保留远程文件名 curl -u "$FTP_USER:$FTP_PASS" \ --ftp-pasv \ --connect-timeout 15 \ -O "ftp://ftp.example.com/data/report.csv" # 2. 上传单文件,-T 指定本地文件 curl -u "$FTP_USER:$FTP_PASS" \ -T "./report.csv" \ "ftp://ftp.example.com/upload/report.csv" # 3. 上传时自动创建缺失的远程目录 curl -u "$FTP_USER:$FTP_PASS" \ --ftp-create-dirs \ -T "./report.csv" \ "ftp://ftp.example.com/upload/2024/06/report.csv" # 4. 断点续传下载,-C - 表示让 curl 自动计算偏移 curl -u "$FTP_USER:$FTP_PASS" -C - -O "ftp://ftp.example.com/data/big.tar.gz"

--ftp-pasv在新版 curl 里已经是默认行为,显式写上是为了让脚本意图清楚,也防止某些环境变量把它改掉。--connect-timeout只控制建连阶段,传输阶段的卡顿要靠--speed-time和--speed-limit来控制,比如「连续 60 秒速度低于 1KB/s 就断开」,这在链路不稳的场景下很实用,能让脚本快速失败而不是挂一晚上。

--ftp-create-dirs是最容易被忽略但最省事的一个参数。没有它,你把文件传到不存在的目录会直接得到550 No such directory,而很多人会误以为是权限问题,去翻服务端配置翻半天。

退出码要检查。curl 返回 0 才是成功,$?非 0 时要按码定位:6 是域名解析失败,7 是连不上,9 是 FTP 访问被拒,67 是登录失败,78 是远程文件不存在。把这几个码写进脚本的错误分支,比事后翻日志快得多。

if ! curl -sS -u "$FTP_USER:$FTP_PASS" --ftp-create-dirs \ -T "./report.csv" "ftp://ftp.example.com/upload/report.csv"; then code=$? case $code in 67) echo "认证失败,检查账号密码" >&2 ;; 78) echo "远程路径不存在" >&2 ;; *) echo "上传失败,curl 退出码 $code" >&2 ;; esac exit 1 fi

-sS的组合值得解释:-s静默掉进度条,-S在出错时仍然把错误信息打出来。只写-s会让脚本失败得悄无声息,这是我在流水线里踩过的坑。

4.2 lftp 的 mirror:目录级同步的正解

单文件用 curl,整目录同步就该换 lftp。mirror子命令是 lftp 的核心竞争力:它支持递归、支持断点续传、支持并行、支持排除规则,写起来还短。

# 把远程 /data/2024 同步到本地 ./backup,-c 表示续传,-n 只拉新文件 lftp -u "$FTP_USER,$FTP_PASS" ftp.example.com -e " set ftp:passive-mode true; set net:timeout 20; set net:max-retries 3; mirror -c -n --parallel=4 /data/2024 ./backup; bye " # 反向:把本地 ./dist 推送到远程 /public/release lftp -u "$FTP_USER,$FTP_PASS" ftp.example.com -e " set ftp:passive-mode true; mirror -R -c --parallel=4 --exclude-glob '*.tmp' ./dist /public/release; bye "

-c是续传,-n是只传新文件(基于大小和时间做判断),-R是反向镜像(本地到远程),--parallel=4开 4 个并发连接。并发数不是越大越好,很多 FTP 服务器对单 IP 的连接数有限制,超过会直接被拒或者限速,4 到 8 是比较安全的区间。

set net:max-retries 3配合net:timeout是稳定性关键。FTP 在大批量小文件场景下很容易出现个别文件超时,没有重试的话整个 mirror 会中断。但重试也别设太大,否则一个坏文件能把你卡到天亮。

--exclude-glob用来排除临时文件,这个在镜像构建目录时特别必要,否则.tmp、.swp这些垃圾会反复被推送,还会因为时间戳变化导致每次都被判定为「新文件」。

4.3 定时同步脚本:锁、日志与失败告警

把上面的命令塞进 crontab 之前,有三件事必须补上:防并发、留日志、失败可见。

防并发用flock,防止上一轮没跑完下一轮又起来了,两个进程同时往一个目录写,结果一定是灾难。日志按天切分,别让它无限增长。失败告警最简单的做法是靠退出码触发,lftp在-e里的命令失败时退出码非 0,可以在外层判断。

#!/usr/bin/env bash set -euo pipefail LOG_DIR="/var/log/ftp-sync" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/sync-$(date +%Y%m%d).log" { echo "=== 开始 $(date '+%F %T') ===" if ! lftp -u "${FTP_USER},${FTP_PASS}" ftp.example.com -e " set ftp:passive-mode true; set net:timeout 20; set net:max-retries 3; mirror -c -n --parallel=4 /data/2024 /data/backup/2024; bye "; then echo "同步失败 $(date '+%F %T')" >&2 exit 1 fi echo "=== 结束 $(date '+%F %T') ===" } >> "$LOG_FILE" 2>&1

crontab 里的写法是*/30 * * * * flock -n /tmp/ftp-sync.lock /opt/scripts/ftp-sync.sh。flock -n表示拿不到锁就立刻退出,不会堆积。这套结构看着朴素,但比很多花哨的同步框架可靠,因为它每一环的失败都能在日志里找到对应的一行。

5. 避坑与排查:乱码、425、超时、550 的五条血泪记录

前面几章讲的是「怎么让它跑起来」,这一章讲的是「它跑起来之后为什么还会出事」。下面五条都是我在实际环境里遇到过的,按「现象 → 原因 → 解决」记,能对上一个就省一晚上的时间。

5.1 中文文件名变成乱码,上传后下载不回来

现象:本地文件名是日报_20240601.csv,上传到服务器后用客户端看是一串问号或者方块,用脚本nlst列出来是??_20240601.csv,再按原名RETR直接报 550。

原因:控制连接上的文件名字节编码和服务器解释的编码不一致。ftplib 的encoding默认是 UTF-8,很多国内老服务器默认 GBK,字节流过去之后被按另一套码表解码,就成了乱码。另外有些服务器即使支持 UTF-8,也需要显式发OPTS UTF8 ON才切换。

解决:先试ftp.sendcmd('OPTS UTF8 ON'),成功说明服务器支持 UTF-8,保持encoding='utf-8';如果返回500或502,把encoding改成'gbk'再跑一遍。命令行侧 curl 没有直接的编码开关,我一般改用 lftp 并设置set ftp:charset gbk或set ftp:charset utf-8。验证方法很简单:上传一个中文名的小文件,立刻nlst回来比对字节,别等全量传完再发现。

5.2 425 打不开数据连接,控制连接却完全正常

现象:登录成功,PWD正常返回,一执行LIST或RETR就报425 Can't open data connection,重试几次偶尔能成功。

原因:被动模式下服务器返回的临时端口没有在防火墙或安全组放行。偶尔成功是因为区间里个别端口恰好被放通了,或者会话复用了上一次的连接。另一种可能是服务器返回的 PASV IP 是内网地址,客户端在公网侧根本连不上。

解决:先开set_debuglevel(2)或者手动发PASV,把服务器返回的 IP 和端口算出来(前面讲过p1*256+p2的换算)。然后核对防火墙放行区间,让服务端把pasv_min_port和pasv_max_port固定成一段连续端口并整体放行。如果服务器返回的是内网 IP,需要在服务端配置里指定对外通告的地址,这一项各服务端软件叫法不同,得看具体实现。

5.3 大文件传到 90% 断了,重跑又从零开始

现象:几 GB 的备份包传了半小时,在快结束时抛TimeoutError或ConnectionResetError,脚本重跑一遍,进度从 0% 重新开始,反复几次永远传不完。

原因:FTP 的空闲超时和数据传输超时都会在中途触发,尤其是链路质量差的时候。更关键的是,默认的STOR命令会截断同名文件重新写,不具备续传语义,所以重跑必然是覆盖。

解决:上传用APPE命令追加,或者用STOR配合rest参数指定偏移量;下载用RETR配合rest。ftplib 的storbinary和retrbinary都有rest参数,传字节偏移即可。偏移量从远程SIZE拿,本地文件也按同样大小截断对齐。更省事的做法是直接用 lftp 的mirror -c或者 curl 的-C -,它们内部处理了这套逻辑。

5.4 550 权限错误,但账号明明有写权限

现象:上传时报550 Permission denied或者550 No such file or directory,运维确认账号对目标目录有写权限。

原因:三种可能。一是目标目录根本不存在,FTP 不会自动建目录,550 No such file or directory很多时候就是这个意思;二是当前工作目录不在预期位置,脚本上一步cwd到了别处;三是服务端的chroot或家目录限制让绝对路径不可达,你用的/upload/xxx在服务端被解释成了别的路径。

解决:先用ftp.pwd()确认当前位置,再用ftp.cwd('/')回到根看目录结构,最后用相对路径操作。目录不存在就按前面mkd逐级创建的方式补上,或者命令行加--ftp-create-dirs。如果cwd('/')之后看到的结构和预期完全不同,那就是被 chroot 了,所有路径都得按账号家目录的相对路径来写。

5.5 脚本跑着跑着不报错也不动了

现象:定时任务卡住,没有输出,进程一直挂着,ps能看到但 CPU 和网络都是 0。

原因:connect时用了默认的timeout=None,socket 进入阻塞状态永不超时。服务端已经悄悄断了控制连接,客户端还在傻等一个永远不会来的响应。

解决:所有connect调用强制传timeout,20 到 30 秒是常用值;在长任务中间定期发NOOP探活,失败就重建连接。crontab 外面再套一层timeout 3600兜底,让卡死的进程自己被杀掉,比事后手工清理干净得多。这套「超时 + 探活 + 外层兜底」三重保险,是我在吃过一次卡死一整晚的亏之后固定下来的写法。

6. 进阶收口:断点续传、显式 FTPS 与传输后校验

把基础的传通之后,剩下的价值都在「出事时能恢复」和「传完之后能证明它是对的」这两件事上。这一章说三个具体做法,都是可以直接塞进现有脚本的。

6.1 用 REST 和 SIZE 做真正的断点续传

断点续传的核心是一个字节偏移量。下载时先用SIZE拿到远程文件总大小,再看本地已下载多少,用rest参数告诉服务器从哪个字节开始发。上传同理,但方向反过来:远程用SIZE拿已上传的字节数,本地文件用seek跳到对应位置。

import os from ftplib import FTP def resume_download(ftp, remote_path, local_path): remote_total = ftp.size(remote_path) local_size = os.path.getsize(local_path) if os.path.exists(local_path) else 0 if local_size >= remote_total: print('本地文件已完整,跳过') return mode = 'ab' if local_size else 'wb' with open(local_path, mode) as fp: # rest 参数即偏移量,服务器从该字节位置开始发送 ftp.retrbinary(f'RETR {remote_path}', fp.write, blocksize=65536, rest=local_size) # 传完必须校验,不一致说明中途又断了 if os.path.getsize(local_path) != remote_total: raise RuntimeError('续传后大小不一致,需要整体重传')

这段逻辑里最容易错的是最后那个校验。rest参数在部分服务器上行为不规范,可能忽略偏移量从头开始发,结果就是本地文件前面一段被重复追加,大小直接超过远程。加一道大小比对,能第一时间发现这种「假续传」,代价只是一次getsize。

上传侧的写法和这个镜像对称,用storbinary的rest参数,配合本地fp.seek(local_size)。要注意storbinary从文件对象的当前位置开始读,所以seek必须在调用前完成。

6.2 显式 FTPS:把明文通道关掉

如果传输的是账号、日志、业务数据,明文 FTP 意味着密码和内容都在网络上裸奔。显式 FTPS(FTP over TLS)在 21 端口上先明文建连,再用AUTH TLS升级加密,兼容性比隐式 FTPS 好,大多数服务端都支持。

import ssl from ftplib import FTP_TLS ftps = FTP_TLS(context=ssl.create_default_context()) ftps.encoding = 'utf-8' ftps.connect('ftp.example.com', 21, timeout=20) ftps.login('demo_user', 'demo_pass') ftps.prot_p() # 关键:把数据连接也加密,不加这行数据仍是明文 ftps.set_pasv(True) print(ftps.nlst('.')) ftps.quit()

prot_p()这一行是分水岭。很多脚本登录之后忘了调用它,控制连接加密了、数据连接还是明文,等于白配。另外ssl.create_default_context()会启用证书校验,如果服务端用的是自签证书,会直接抛SSLCertVerificationError;生产环境应该把服务端证书的 CA 加进信任链,而不是图省事用_create_unverified_context()关掉校验——关掉之后,加密就只能防旁观,防不了中间人。

6.3 传输后的校验清单

最后说一个我固定执行的动作:每次批量传输结束后,跑一遍校验清单,而不是相信「脚本没报错就是成功」。

检查项手段判定标准
文件数量本地ls计数 vs 远程NLST计数数量一致,缺一个都要查
单文件大小os.path.getsizevsftp.size()逐字节相等
关键文件哈希本地 MD5 vs 远程RETR计算 MD5抽样比对重要文件
修改时间本地 mtime vsMDTM返回值增量同步时用于判断新旧
传输模式TYPE I是否生效文本文件大小不应出现偏差

这张表我一般做成一个独立的小脚本,传完之后单独跑,输出一份对账结果。它的价值不在于发现大问题——大问题脚本早就抛异常了——而在于发现那些「差几十字节」「少一个文件」的静默失败,这类问题在第二天业务方找上门时才发现,排查成本是当场发现的十倍。

还有一个习惯:任何一次改动过连接参数、编码、路径的脚本,上线前先用一个只含两三个小文件的测试目录完整跑一遍,包括上传、下载、校验和故意中断后的续传。FTP 的问题大多不是逻辑错,是环境差异,本地跑通不代表生产跑通,但本地跑不通一定别往生产推。

那次凌晨少了一个包的教训之后,我每次把同步脚本交给别人之前,都会强制走一遍「小目录全流程 + 断网续传 + 大小校验」这三步,宁可多花二十分钟,也不想再对着干净的日志找一晚上原因。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询