不少人自己啃 Python 官方文档的时候,都会在某个不起眼的章节里碰到5.10.9 ftplib。看名字就是“FTP 协议客户端”,但很多人第一反应是:这年头还有人用 FTP 吗?我的答案是:有,而且比你想象的多得多。企业内部文件分发、设备日志抓取、机房数据备份、老系统间做数据交换,这些场景里 FTP 仍然是主力。更关键的是,ftplib是 Python 标准库自带的模块,不装任何第三方依赖就能连上 FTP 服务器,批量上传下载、目录浏览、断点续传全都能做。这篇文章不打算给你念文档,而是按我实际使用的顺序,带你把ftplib的常用姿势一个一个过一遍,顺便把我踩过的坑都摆出来。
1. 先把概念理清楚:FTP 客户端到底在做什么
1.1 标准库里的“老古董”,为什么到现在还值得学
很多人会拿 FTP 和 HTTP、SFTP 作比较,然后得出“FTP 过时了”的结论。但实际接需求的时候会发现,很多硬件设备、嵌入式系统、老业务平台只提供 FTP 接口,你没法要求对方改协议。还有一类场景是内网离线环境,机器上可能连 pip 都用不了,这时候标准库能做什么就变得非常重要。ftplib刚好就是这样的存在:不需要额外安装,不会因为依赖版本冲突而报警,直接import ftplib就能开始干活。
从协议本身来说,FTP 的历史包袱不轻,控制连接与数据连接分离、主动被动模式切换、各种服务器实现差异,都会让新手困惑。但如果你能抱着“它就是一个文件操作工具”的心态去理解,ftplib的 API 其实非常直观:cwd是切换目录,mkdir是建目录,delete是删文件,retrbinary是下载,storbinary是上传。只要对文件操作熟悉,基本上很快就能上手。
还有一点容易被忽略:ftplib自带了一个比较完整的错误体系,ftplib.error_perm、error_temp、error_proto分别对应权限问题、临时错误、协议解析问题。写批量脚本的时候,捕获这些异常再决定是重试还是跳过,比直接让程序崩溃要舒服得多。
1.2 主动模式和被动模式:决定你能不能连上的核心
刚开始用ftplib的人经常会遇到一种诡异现象:能登录,但一执行nlst()或下载文件就卡死,最后超时。这个问题十有八九出在主动模式和被动模式上。
FTP 有一个其他协议很少有的特点:一条连接用于发送命令,另一条连接用于传输数据。在主动模式(PORT 模式)下,客户端先告诉服务器“我的某个端口开着,你主动连过来”,服务器发起数据连接;在被动模式(PASV/EPSV 模式)下,服务器把数据端口开放给客户端,客户端去主动连接。ftplib默认使用被动模式,这也是目前绝大多数场景下最稳的做法。
为什么默认是被动?因为主动模式有天然劣势:服务器要主动连接客户端,而客户端电脑或内网网关的防火墙通常不允许外界主动建连。被动模式把连接方向换成客户端发起,和常见的 HTTP 请求方向一致,更容易穿过防火墙。所以日常使用中我基本不会去改set_pasv(False),除非遇到极其老旧的 FTP 服务端不支持 PASV,才会临时切回主动模式试一把。
1.3 核心类与异常结构:先记住这几个名字
ftplib主要提供了两个类:FTP和FTP_TLS。普通的 FTP 连接用FTP,需要加密传输的上层用FTP_TLS。大多数方法都在FTP类里定义,FTP_TLS只是在连接握手和通道保护方面做了扩展,文件操作的方法名基本一致。
异常方面也值得记一下。ftplib.all_errors是一个合并了所有相关异常的元组,包含error_reply、error_temp、error_perm、error_proto以及底层 socket 异常。写健壮脚本的时候,我习惯直接except ftplib.all_errors as exc,这样不管是服务器拒绝、网络断开还是命令不支持,都能被统一接住并记录日志。
对了,FTP类还支持上下文管理器协议,也就是说可以这样写:
from ftplib import FTP with FTP("ftp.example.com") as ftp: ftp.login("username", "password") print(ftp.nlst())退出with代码块时,连接会被自动关闭,不用惦记着手动quit(),对异常处理来说特别省心。
2. 从第一行代码开始:连接、登录、浏览目录
2.1 建立连接和登录
先来一个最简单、但能跑通的示例。FTP构造函数里可以直接传主机地址,它会立刻发起连接;如果没传,就需要先实例化,再调用connect()方法。我通常在构造函数里就传地址,顺手把超时时间也定好:
from ftplib import FTP ftp = FTP(host="ftp.example.com", timeout=10) print(ftp.getwelcome()) ftp.login(user="username", passwd="password") print(ftp.pwd()) ftp.quit()getwelcome()会拿到服务器发送的欢迎信息,这个信息经常携带服务器类型或版权提示,排障时能提供一点背景。login(user, passwd)默认不带参数时走匿名登录,不过实际使用中我还是会明确写user="anonymous"来表示意图。如果登录失败,会抛出ftplib.error_perm,错误信息里一般含有 530 等状态码。
连接结束后要注意quit()和close()的区别。quit()会向服务器发送QUIT命令,然后等待响应,是规范的退出流程;close()则直接断开底层 socket,不会服务器打招呼。如果服务器已经不响应了,quit()可能会卡一会儿,这时候用close()更干脆。为了避免连接关闭互相等待,我一般会在finally块里调用close(),正常路径里则用quit()。
2.2 目录列表的两种拿法
登录成功之后,最常见的操作就是看你到底能访问哪些文件。ftplib提供两个方法:nlst()和dir()。
nlst()返回的是文件名列表,比如["index.html", "images", "logs"],纯粹、干净,适合程序直接使用。dir()返回的是类似ls -l的长列表信息,里面包含权限、属主、大小、日期,磁盘监控脚本或者想解析文件时间时很有用。不过dir()的输出格式在不同服务器上差异很大,解析时不能指望所有系统都长得一样。
具体使用方式是这样的:
remote_files = ftp.nlst("/data/2025") for name in remote_files: print(name) lines = [] ftp.dir("/data/2025", lines.append) for line in lines: print(line)注意,nlst()在某些服务器上遇到空目录或没有列表权限时,可能返回空列表,也可能直接抛异常。如果你的脚本对空结果特别敏感,先cwd到目标目录再nlst(),能少一些歧义。另外别把dir()回传的文件大小想当然当成整数,有些服务器输出第一列还是权限字符串,大小列在中间,解析前最好先打印出来看一眼。
2.3 创建目录、删除目录、移动文件和删除文件
目录和文件操作基本是这几招:mkd()创建目录,rmd()删除目录,delete()删除文件,rename()改名或移动。对应到ftplib里就是下面的样子:
ftp.mkd("/upload/today") ftp.cwd("/upload/today") ftp.rename("./daily_report.txt", "./daily_report_20250101.txt") ftp.delete("./old_file.txt") ftp.rmd("/upload/old_dir")rename在 FTP 协议里对应的是RNFR和RNTO两条命令,ftplib帮你封装好了,调用起来很顺手。想实现“移动文件到另一个目录”,也可以直接rename("/dir/a.txt", "/newdir/a.txt"),前提是目标目录存在且当前用户有足够权限。这些操作如果失败,通常会抛error_perm,异常消息里的 550 或 553 码就指明具体原因了。
有一点要注意:mkd()在目标目录已经存在的时候是会报错的。很多新手写脚本的时候忘了先判断,导致重跑任务失败。我习惯用nlst()先判断目录是否存在,再决定要不要mkd(),或者在异常里做兼容处理。
3. 核心场景:文件的下载与上传
3.1 用 retrbinary 做下载
文件传输是 FTP 最核心的功能。在ftplib里,下载文件首选retrbinary()。它的第一个参数是 FTP 命令字符串,通常是"RETR 文件名";第二个参数是回调函数,每收到一块数据就会调用一次,并把数据块的 bytes 传进去。
最简单的用法就是把文件句柄的write方法当成回调传过去:
from ftplib import FTP ftp = FTP("ftp.example.com", timeout=10) ftp.login("username", "password") with open("./local_backup.zip", "wb") as fp: ftp.retrbinary("RETR /backup/2025.zip", fp.write) ftp.quit()这里最容易踩的坑是本地文件的打开模式。如果你用open("local_backup.zip", "w"),回调收到的 bytes 就有可能与文本模式编码冲突,导致TypeError或写入乱码。所以下载二进制文件时,本地文件必须以"wb"方式打开。
retrbinary还支持第三个参数blocksize,默认 8192 字节。对于大文件,适当调大块大小可以减少回调次数,但收益不一定明显。真正需要关注的是第 4 个关键字参数rest,这个参数用于断点续传,下面会单独说。
3.2 用 storbinary 做上传
上传文件对应的方法是storbinary()。同样,第一个参数是"STOR 文件名",第二个参数是已打开的文件对象。storbinary会逐块调用文件对象的read()方法,把数据推给服务器:
from ftplib import FTP ftp = FTP("ftp.example.com") ftp.login("username", "password") with open("./daily_report_big.xlsx", "rb") as fp: ftp.storbinary("STOR /upload/daily_report_big.xlsx", fp) ftp.quit()上传时本地文件同样要用二进制模式打开。如果你文件很大,不想一次读完,storbinary的blocksize参数就可以发挥作用,比如设置 65536,内存占用会稳定许多。
有个小细节:storbinary默认会先发送TYPE I命令,把传输模式切成二进制。所以即使之前用了retrlines切换了文本模式,storbinary也能把状态纠正回来。这也是为什么我推荐上传下载尽量走 binary 两个方法,不容易被模式问题坑到。
3.3 为什么二进制模式这么重要
ftplib里还有retrlines()和storlines()这两个方法,字面意思是“按行传输”。它们对应 FTP 的文本模式,一般用于传输纯文本文件。问题在于,不同操作系统的换行符号不一样,服务器和本地机器的换行处理可能不一致,直接把文本文件按行读入再拼接,很容易把原来的\r\n或\n破坏掉。
哪怕你确定文件内容是纯文本,我也建议用二进制方法传。因为你不知道后续这个文件会不会被其他程序以二进制方式检查完整性。只要用文本模式传一次,文件中间多出或缺少一个\r,MD5 就对不上了。我在实际项目里的规矩是:除了一两个特别老的纯脚本接口会迁就retrlines,其余全部用retrbinary和storbinary。
4. 工程化进阶:加密连接、断点续传、超时和编码
4.1 需要加密时改用 FTP_TLS
FTP 本身是明文协议,用户名、密码、文件内容都会以明文在网络里跑。现在很多单位的安全策略已经不允许明文 FTP,所以如果你的服务器支持 FTPS,那就应该用FTP_TLS而不是FTP。FTP_TLS支持 TLS 加密,但有一点很多人没意识到:它默认只对控制连接加密,数据连接要主动开启保护。
用起来很简单:
from ftplib import FTP_TLS import ssl ctx = ssl.create_default_context() ftp = FTP_TLS("ftps.example.com", timeout=10, context=ctx) ftp.login("username", "password") ftp.prot_p() # 保护数据连接 ftp.retrbinary("RETR secret_data.zip", open("secret_data.zip", "wb").write) ftp.quit()prot_p()的意思是给数据连接也加上 TLS 保护,这样文件和目录列表内容就不再是明文了。如果不调用prot_p(),只靠FTP_TLS自带的控制连接加密,文件内容仍然可能被监听。反过来,如果服务器不支持加密数据连接,prot_p()可能报错,这时可以退而求其次只加密控制连接,或者干脆换 SFTP 方案。生产环境能用 SFTP 还是尽量 SFTP,但很多老设备只开放 FTPS,那FTP_TLS就是你唯一顺手的选择。
4.2 断点续传:别急着传完,先问一下服务器
下载大文件的时候,网络偶发断开太正常了。用retrbinary的rest参数,可以从服务器上的指定偏移量开始继续传输:
local_size = 0 try: local_size = os.path.getsize("./big_data.iso") except FileNotFoundError: pass if local_size > 0: with open("./big_data.iso", "ab") as fp: ftp.retrbinary("RETR /data/big_data.iso", fp.write, rest=local_size) else: with open("./big_data.iso", "wb") as fp: ftp.retrbinary("RETR /data/big_data.iso", fp.write)这里隐含的前提是服务器支持REST命令。大多数主流 FTP 服务端都支持,但也有精简版固件会拒绝。如果retrbinary抛error_perm,说明服务器不支持断点,那就完整重下。
上传断点续传相对复杂,因为storbinary的rest参数只能配合STOR命令使用,而且不少服务器支持得不好。我的经验是先查远端文件大小,再用本地偏移量发起断点上传:
remote_size = ftp.size("/upload/target.iso") if remote_size is None: remote_size = 0 with open("./target.iso", "rb") as fp: fp.seek(remote_size) ftp.storbinary("STOR /upload/target.iso", fp, rest=remote_size)ftp.size()返回的是服务器端文件大小,如果不支持SIZE命令,会返回None,这时只能从头传。断点续传的可靠性,最终取决于服务器实现,必要的时候先打印ftp.sendcmd("FEAT")看看服务端支持哪些扩展命令,不要盲目依赖。
4.3 超时、心跳和连接释放
批量任务里最怕的不是单台服务器慢,而是脚本因为某个连接迟迟不响应而卡住。ftplib的构造函数和connect()都支持timeout参数,直接传秒数就行。设置了timeout之后,阻塞式 socket 在指定时间内没收到数据就会抛 socket 超时异常,脚本可以接住异常继续跑下一台服务器。
对于长时间运行的脚本,定期发送NOOP命令可以保持连接不被服务端回收。ftplib里可以直接调用:
ftp.voidcmd("NOOP")voidcmd用于发送不期待返回大量数据的命令,比如NOOP。服务端正常响应的话,这个调用会静默通过;如果连接已经断了,这里就会抛异常。把它放在定时任务里,就能把失效的连接早点暴露出来,而不是等到真正传文件时才报错。
还有一点,每处理完一个服务器,尽量调用quit(),如果异常则close()。连接不释放会耗尽服务器端的进程资源,尤其当你用脚本巡检几十台设备时,这个问题会被无限放大。
4.4 文件名编码:中文乱码的根源
FTP 协议里没有统一规定文件名的字符编码,不同服务器用自己的系统编码。碰到中文文件名,ftplib返回的字符串可能是一堆乱码。FTP类有一个encoding属性,默认是utf-8。如果服务器用的是 GBK 或者 GB18030,连接后手动改一下:
ftp.encoding = "gbk"Python 3.9 之后的FTP构造函数也直接提供了encoding参数。老版本可以直接给属性赋值,效果一样。目录列表乱码时,我一般会在utf-8、gbk、gb18030这几个编码之间来回试,很快就能定位。
需要注意的是,ftp.encoding只影响ftplib解析控制连接的回复,数据通道传输的字节内容不受影响。所以文件内容本身不是乱码,乱码只会出现在nlst()返回的文件名、retrlines()的文本行这类控制信息里。
5. 实战中的常见问题和排查技巧实录
5.1 错误码速查:530、550、501、425 到底什么意思
FTP 服务器的错误信息五花八门,但核心状态码就那些。我把最常遇到的几类整理成一个速查表,遇到异常先别慌,看状态码再决定怎么办。
| 状态码 | 常见含义 | 对应的处理方法 |
|---|---|---|
| 230 | 登录成功 | 继续操作 |
| 331 | 用户名正确,等待密码 | 检查密码是否正确 |
| 421 | 服务过载或限流 | 等待一段时间后重试 |
| 425 | 无法打开数据连接 | 排查被动模式端口和防火墙 |
| 450 | 文件不可用 | 检查服务器磁盘权限 |
| 500 | 命令无法识别 | 可能服务器不支持该扩展命令 |
| 502 | 命令未实现 | 和 500 类似,跳过该功能 |
| 530 | 未登录或登录失败 | 检查账号、密码、IP 白名单 |
| 550 | 权限不足或文件不存在 | 检查目录路径和账号权限 |
| 553 | 文件名不允许 | 检查文件名是否包含非法字符 |
ftplib会把大部分错误包装成error_perm或error_temp,异常消息里含有这些状态码。在日志里只记录str(exc)是不够的,最好把exc.args一起打出来,里面有完整的服务端响应文本。
5.2 能登录但无法列出目录:多半是数据连接的问题
新手最常见的排障场景是:登录正常,pwd()正常,但一执行nlst()就卡住,最后超时。这不是代码有 bug,而是数据连接没有建立。前面讲过,被动模式下客户端要主动连服务器的数据端口,如果服务器只开放了固定的被动端口范围,或者防火墙没放行,握手就会失败。
遇到这种情况,先确认服务器有没有配置 PASSIVE 端口池。很多服务端默认动态分配 1024 以上的随机端口,客户端无法预料,只能靠控制连接里返回的 IP 和端口去连接。如果服务器处在一个 NAT 后面,返回的内网 IP 可能客户端根本访问不到,这就是跨网络 FTP 最头疼的问题之一。实在没辙就试主动模式:
ftp.set_pasv(False)主动模式下,客户端自己监听端口,服务器来连。但前提是客户端不能有防火墙拦截入站连接。所以在内网环境里,先测试被动模式,不行再切主动,往往能定位出是哪一侧的防火墙在捣乱。
5.3 下载下来的压缩包打不开:先检查传输模式
用ftplib下载.zip或.exe,打开时提示文件损坏,第一反应不应该是文件本来就是坏的。先回看代码,是不是用了retrlines()或storlines()。文本模式传输时,FTP 服务端和客户端都会做换行转换,二进制文件里的\r\n序列会被改写,文件就废了。
正确的做法是所有二进制用retrbinary()+"wb"打开本地文件。还有一个隐蔽的坑:回调函数除了fp.write,别再追加任何其他逻辑,比如print或者字符编码处理。 有些人图省事,在回调里对数据做 decode,一旦遇到非 UTF-8 字节就抛异常,导致下载中断。保存原始字节,后面再统一处理。
5.4 中文文件名乱码的处理顺序
如果nlst()返回的中文名是乱码,按下面这个顺序处理:
- 确认服务器操作系统字符集,常见的有 UTF-8、GBK、GB18030、拉丁语系编码。
- 重新设置
ftp.encoding,马上再执行一次nlst()。 - 如果还是乱码,可以尝试
ftp.sendcmd("OPTS UTF8 ON"),让服务器把响应切到 UTF-8。 - 如果以上都不行,就用
dir()拿原始字节,自定义解析函数。
这里要强调一点:不要在你的代码里“修”乱码字符串。不要一个个 replace 乱码字符,因为编码错乱后信息已经损失了。正确做法是在源头把encoding设对,让ftplib在解码阶段就拿到正确文本。
5.5 多线程批量任务:每个线程各用各的连接
写批量检查或批量下载脚本时,很多人会想着用一个FTP实例,开多线程去并发干活。这是大忌。FTP 控制连接是有状态的,你在这个线程里发出了CWD,另一个线程可能正在发RETR,两个命令交错,服务器应答就全乱了。
正确的姿势是每个线程创建自己的连接,用完立刻关闭。比如用ThreadPoolExecutor处理多台服务器:
from concurrent.futures import ThreadPoolExecutor from ftplib import FTP def handle_one(server_info): with FTP(server_info["host"], timeout=10) as ftp: ftp.login(server_info["user"], server_info["passwd"]) ftp.cwd("/data") files = ftp.nlst() # 处理逻辑 return len(files) with ThreadPoolExecutor(max_workers=10) as pool: results = list(pool.map(handle_one, server_list))并发数也不要开太野。FTP 服务器对并发连接数有硬限制,连接建得太快会被直接拒绝,或者触发服务端封 IP。我的经验是批量任务并发数控制在 5 到 10 之间,单台服务器内部多文件操作则尽量串行,稳定优先。
xxftp 这个模块用下来,最大的感受就是“稳”。它不花哨,很多功能要自己封一层,但正因为标准库没有给你包一层厚厚的糖,你会被迫理解 FTP 协议的底层握手、状态码、模式和编码,这反而让脚本更容易定位问题。如果你刚好在写一个对接 FTP 的自动化任务,别急着上第三方库,先给ftplib十分钟试用一下,大概率就能解决你的需求。多试试retrbinary和storbinary,剩下的问题基本都能在这个模块的源码和异常消息里找到答案。