☰
Python操作FTP全指南:从ftplib客户端到pyftpdlib服务端
2026/10/26 16:37:02 网站建设 项目流程

简介:这是一个基于Python实现的FTP协议完整示例,面向正在学习网络编程、Socket通信和多线程开发的Python爱好者。压缩包内包含FtpServer.py与FtpClient.py两个源文件,共7KB,代码量精简,便于逐行阅读和二次修改。示例在Windows环境下运行,实现了文件和文件夹的上传与下载,支持多用户、多线程并发连接,并通过图形界面实时显示当前程序的运行状态。读者既可以把它作为课程设计或毕业设计的参考代码,也可以借它理解FTP协议中控制连接与数据连接的协作方式、客户端与服务器端的分工、文件路径的处理方法,以及如何用Python的socket模块搭建自定义网络服务。目前已有552人学习下载,适合用于快速入门网络编程,也可作为后续扩展断点续传、用户权限管理等功能的起点。

1. Python 写 FTP 协议交互:为什么这个老协议还值得自己动手

接到一个数据迁移需求,旧机器上只有 FTP 服务和一批历史文件,没有 SSH 权限,也没有专门的同步工具。我第一反应是用 FileZilla 手动拖,但文件有几千个,目录还套了多层,手动拖到第二天也拖不完。真正解决问题的是一段几十行的 Python 脚本,基于标准库ftplib写了一个批量拉取程序,把整个目录树完好地搬到了新服务器。这就是本文想讲透的事:用 Python 操作 FTP 协议,既能做客户端从远端下载、上传、遍历目录,也能用pyftpdlib在你本地起一个可控的 FTP 服务端。适合谁——需要定时同步文件、做数据备份、给测试环境搭一个临时文件服务,或者只是想把日常文件传输自动化的人。这个方向投入成本极低,回报却非常直接。

2. ftplib 客户端最小实现:从连接到上传下载的代码拆解

ftplib是 Python 自带的标准库,不需要pip install任何东西。它实现了 RFC 959 定义的 FTP 客户端协议,支持主动模式(Active Mode)和被动模式(Passive Mode),支持上传、下载、目录跳转、删除、重命名等几乎全部常用命令。这也是它至今没有被requests这类库替代的原因——FTP 和 HTTP 语义完全不同,标准库里这个模块足够稳定。

2.1 先分清主动模式和被动模式,这是后面所有坑的根源

FTP 比 HTTP 复杂的地方在于它使用双通道:一条控制连接(默认端口 21)和一条数据连接。数据连接的建立方式分成两种模式。

主动模式下,客户端向服务器发送PORT命令,告诉服务器一个客户端监听的端口,服务器主动从这个端口去连接客户端。问题很明显:客户端如果在内网或启用了防火墙,服务器根本连不进来。

被动模式下,客户端发送PASV命令,服务器返回一个 IP 和端口,客户端再主动去连接这个端口。这个模式对客户端防火墙友好,但要求服务器端开放一段端口范围。现在绝大多数 FTP 服务器默认支持被动模式,我们写 Python 客户端时也优先用被动模式。

ftplib中控制这个行为的入口是set_pasv()方法。默认set_pasv(True)使用被动模式,不需要额外设置。只有当服务器在 NAT 后面且被动模式配置不对时,才需要切回主动模式——后文避坑章节会展开讲。

2.2 最简连接与上传下载:一个脚本看清完整流程

先写一个最小可用的客户端脚本。下面的代码实现了连接、登录、切换目录、上传、下载并最终退出会话。

from ftplib import FTP import os # 连接并登录 ftp = FTP() ftp.connect(host="192.168.1.100", port=21, timeout=10) ftp.login(user="ftpuser", passwd="ftppass") # 显式开启被动模式(默认就是True,这里写出来方便新手知道有这个开关) ftp.set_pasv(True) # 切换远端目录 ftp.cwd("/data/upload") # 上传:以二进制方式读本地文件,传输到远端 local_file = "local_data.bin" remote_file = "remote_data.bin" with open(local_file, "rb") as f: ftp.storbinary(f"STOR {remote_file}", f, blocksize=8192) # 下载:把远端文件写回本地 download_path = "downloaded.bin" with open(download_path, "wb") as f: ftp.retrbinary(f"RETR {remote_file}", f.write, blocksize=8192) # 结束会话 ftp.quit()

这段代码的核心逻辑是:connect负责建立 TCP 连接,login发送USER和PASS命令完成认证,cwd切换远端目录,storbinary发送STOR命令做上传,retrbinary发送RETR命令做下载,quit发送QUIT并断开连接。

标重点的参数有两个。timeout控制的是控制连接的空闲超时,单位是秒,建议设置成 10 到 30 之间——设太短会导致长时间没有数据交互的任务被踢掉,设太长又会让异常情况下程序挂很久。blocksize是每次从文件读多少字节写入数据连接,默认是 8192,对于大文件适当提高到 32768 能减少系统调用次数,传输速度会明显提升。

2.3 为什么用二进制模式而不是文本模式

很多新手会问:storbinary和retrbinary已经写了 binary,那 FTP 有没有文本模式?有。ftplib提供的storlines和retrlines就是文本模式,它们会做换行符转换。

问题就出在这个转换上。Windows 的换行符是\r\n,Linux 是\n。FTP 协议规定文本模式下,服务器和客户端之间传输要用\r\n做统一行分隔符。也就是说,你在 Linux 上调retrlines拿一个文本文件,拿到手的换行符可能已经被转换过了。这个逻辑在旧系统上经常和实际文件编码不一致,导致文本文件被读出来多一个\r。

我自己的习惯是:所有文件一律用二进制模式传输,不管它是不是文本文件。文本文件的换行符差异,用 Python 读取后再用replace("\r\n", "\n")或者replace("\n", "\r\n")显式处理,比依赖 FTP 协议层面做转换可控得多。FTP 的文本模式本质上是历史遗留设计,今天的自动化脚本没必要再用它。

2.4 FTP_TLS:遇到需要加密传输的服务器就要换这个类

现在公网上的 FTP 服务大多要求 TLS 加密,也就是 FTPS(FTP over SSL/TLS)。ftplib里封装了对应的类:FTP_TLS。

from ftplib import FTP_TLS ftps = FTP_TLS() ftps.connect(host="ftps.example.com", port=990, timeout=10) ftps.login(user="user", passwd="pass") # 数据连接也启用TLS保护 ftps.prot_p()

用FTP_TLS时有一个容易漏掉的操作:login完成之后,一定要调用prot_p()命令,它对应 FTP 协议的PROT P指令,要求所有后续的数据连接也必须走 TLS。只调用login不调用prot_p(),会出现控制连接是加密的、数据连接却是明文的情况——认证信息保住了,文件内容裸奔在网络上,这是安全事件,不是小问题。

另一个需要注意的点是证书校验。FTP_TLS默认不校验证书,如果你要连的是重要的生产服务器,建议把服务器证书下载到本地并显式传给FTP_TLS。这个操作不复杂:先拿到服务器的crt文件,然后在代码里构造一个SSLContext并加载它。

3. 遍历目录与批量传输:把 FTP 当文件系统用的 Python 写法

连接和单文件传下载只是热身。实际工作中更常见的需求是:把服务器上某个目录及其所有子目录下的文件全部下载到本地,或者把本地一个项目目录整体推送到远端。这类需求的核心在于目录遍历。这个环节我做了一版又一版脚本,从最原始的LIST解析到后来改用MLSD,踩了不少坑,下面把可行的做法完整写出来。

3.1 用 MLSD 拿结构化目录信息,别再用字符串解析了

早期写 FTP 目录遍历,标准做法是用retrlines("LIST")拿一串文本列表,然后靠字符串截断和正则表达式解析文件名和权限。这个做法的痛点是,不同 FTP 服务器实现返回的LIST格式差异极大——Windows IIS 的格式和 Linux vsftpd 的格式长得完全不同,解析规则无法通用。

FTP 协议在 RFC 3659 里定义了MLSD命令,可以返回机器可读的结构化目录列表,每行都是type=file;modify=20240101120000; /data/a.txt这样的格式。ftplib提供了对应的方法mlsd()。

from ftplib import FTP ftp = FTP() ftp.connect(host="192.168.1.100", port=21, timeout=10) ftp.login(user="user", passwd="pass") ftp.set_pasv(True) # 获取当前目录结构化列表 for name, facts in ftp.mlsd("/data"): file_type = facts.get("type") # file / dir / cdrom 等 modify_time = facts.get("modify") # 形如 20240101120000 size = facts.get("size") # 字节数,目录通常没有 if file_type == "dir": print(f"[目录] {name}") elif file_type == "file": print(f"[文件] {name} 大小={size} 修改时间={modify_time}") ftp.quit()

mlsd()返回的是一个可迭代的(name, facts)元组,facts是字典,可以直接用type区分文件还是文件夹,用size拿大小,用modify拿修改时间。整个解析过程干净利落,不需要任何正则。

但这里必须提一个边界问题:MLSD是 2007 年发布的 RFC 3659 标准,仍有一些老旧的嵌入式系统或精简版 FTP 服务器不实现它。调用mlsd()可能会报error_perm或者返回空结果。代码里要做兼容——先尝试mlsd(),捕获异常后回退到nlst()拿纯文件名列表。

try: items = list(ftp.mlsd("/data")) except ftplib.error_perm: # 老服务器不支持MLSD,退回到只拿文件名 items = [(name, {"type": "unknown"}) for name in ftp.nlst("/data")]

3.2 递归遍历整个目录树:一个带深度控制的函数

拿到单层目录结构还不够,真实场景几乎都是多层嵌套目录。这时候需要写一个递归函数,遍历远端目录树。

def walk_ftp(ftp, remote_path, depth=0, max_depth=10): """递归遍历FTP目录,返回所有文件的完整路径列表。""" if depth > max_depth: return [] results = [] try: entries = list(ftp.mlsd(remote_path)) except ftplib.error_perm: # 没有权限读该目录,跳过 return results for name, facts in entries: # 跳过 . 和 .. 这类特殊目录 if name in (".", ".."): continue full_path = remote_path.rstrip("/") + "/" + name if facts.get("type") == "dir": results.extend(walk_ftp(ftp, full_path, depth + 1, max_depth)) elif facts.get("type") == "file": results.append(full_path) return results

这个函数的几个设计点值得说明。max_depth参数是为了防止某些目录结构存在符号链接或循环引用时递归无限深入。except ftplib.error_perm捕获的是权限拒绝或目录不可读的异常——在真实服务器上,某些目录对当前用户是不可见的,遇到这种情况应该跳过而不是让整个脚本崩溃。

需要注意,mlsd()在某些旧服务器上对带空格的文件名会解析异常,name可能被截断。遇到这种情况,除了换用nlst()没有特别优雅的办法。所以我倾向于在国内化工具脚本里直接用nlst()递归——它不返回文件类型,但配合try判断是否能cwd进某个路径,也能区分文件和目录。

def walk_ftp_nlst(ftp, remote_path, max_depth=10): """兼容老服务器的递归遍历实现。""" if max_depth <= 0: return [] results = [] try: names = ftp.nlst(remote_path) except ftplib.error_perm: return results for name in names: full_path = remote_path.rstrip("/") + "/" + name try: # 尝试进入该目录,成功说明是目录 ftp.cwd(full_path) ftp.cwd("..") results.extend(walk_ftp_nlst(ftp, full_path, max_depth - 1)) except ftplib.error_perm: # 不能进入,视为文件 results.append(full_path) return results

cwd进去再cwd回来这个操作会产生额外的控制命令交互,比较慢,但兼容性最好。如果确定服务器支持MLSD,优先用 3.1 的第一个版本。

3.3 批量上传下载脚本:进度反馈和失败清单是必须项

遍历目录树的最终目的是批量传输。一个值得投入的思路是:写一个可复用的同步脚本,支持下载整个远端目录到本地,也支持把本地目录推送到远端。脚本的核心结构是——遍历产生文件清单,然后循环处理每个文件,记录失败项。

import os from ftplib import FTP import socket def download_all(ftp, remote_root, local_root): """把远端目录完整下载到本地。""" all_files = walk_ftp(ftp, remote_root) failed = [] for remote_path in all_files: # 把远端路径映射成本地路径 rel_path = os.path.relpath(remote_path, remote_root) local_path = os.path.join(local_root, rel_path) os.makedirs(os.path.dirname(local_path), exist_ok=True) try: with open(local_path, "wb") as f: ftp.retrbinary(f"RETR {remote_path}", f.write, blocksize=32768) print(f"[成功] {remote_path}") except (socket.timeout, ftplib.error_perm, OSError) as e: failed.append((remote_path, str(e))) print(f"[失败] {remote_path}: {e}") if failed: print(f"共失败 {len(failed)} 个文件:") for path, reason in failed: print(f" {path} -> {reason}") else: print("全部文件传输完成") ftp = FTP() ftp.connect(host="192.168.1.100", timeout=20) ftp.login(user="user", passwd="pass") ftp.set_pasv(True) download_all(ftp, "/data/backup", "./local_backup") ftp.quit()

这个脚本里有三个细节值得复制到你的生产代码里。

第一,os.makedirs(..., exist_ok=True)必须先于文件写入执行,远端目录结构在本地要原样重建。第二,失败清单不是直接抛异常退出,而是收集到failed列表里最后统一打印——这样大批量传输时,一个文件坏了不会中断整批任务。第三,block size提到 32768,实测大文件传输比默认的 8192 快不少,尤其是跨局域网时。

上传方向的逻辑完全对称,只是把retrbinary换成storbinary,把读本地写远端换成读远端写本地。唯一额外的处理是:上传前要确保远端的父目录存在,否则STOR会报 550 错误。常见做法是遍历本地目录时,对每个子目录先执行ftp.mkd(dirname),捕获error_perm表示目录已存在,不处理。

4. 本地搭一个 FTP 服务端:pyftpdlib 的配置、权限与被动端口

很多场景下,我们不只需要连别人的 FTP,还需要在自己机器上起一个 FTP 服务。最常见的使用场景是:给同事临时传文件、给自动化测试环境提供一个文件交换入口、或者模拟一个远端 FTP 做客户端脚本的开发调试。pyftpdlib就是 Python 生态里做这件事最成熟的选择,纯 Python 实现,跨平台,pip 安装即用。

4.1 最小可用服务端:5 行代码跑起来

安装很简单:

pip install pyftpdlib

然后写一个最简服务端:

from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer # 用户授权管理 authorizer = DummyAuthorizer() authorizer.add_user("ftpuser", "ftppass", homedir="/srv/ftp", perm="elradfmwMT") # 处理器:把授权器挂到FTP处理器上 handler = FTPHandler handler.authorizer = authorizer # 服务器:绑定全部网卡的21端口 server = FTPServer(("0.0.0.0", 21), handler) server.serve_forever()

这段代码的职责划分是:DummyAuthorizer管理虚拟用户和权限,FTPHandler处理 FPT 协议层的连接事件,FTPServer负责 TCP 监听和事件循环。把三者拆开是pyftpdlib的设计理念,在不改其他部分的前提下,可以单独替换授权器或处理器。

这里要重点解释perm参数。这个字符串是权限集合,每个字母对应一种操作:e是切换目录(CWD),l是列出文件(LIST/NLST),r是从服务器下载文件(RETR),a是上传文件并断点续传(APPE),d是删除文件(DELE),f是重命名(RNFR/RNTO),m是创建目录(MKD),w是设置文件权限(SITE CHMOD),M是修改文件时间戳,T是修改文件内容。你给用户什么权限,就拼上对应字母。

4.2 给匿名用户开一个只读目录

调试客户端脚本时,不搞用户体系是常有的事。pyftpdlib也支持匿名用户。

authorizer = DummyAuthorizer() # 第一个参数留空表示匿名用户 authorizer.add_anonymous("/srv/ftp/anonymous", perm="elr")

注意这里权限只给了elr——能进入、能看列表、能下载,但是不能上传不能删。如果你要把这个匿名目录当作文件收集入口,可以加a和d,但我不建议这样做。匿名用户本身没有审计追踪能力,一旦有破坏性操作没法定位到人。用于交换文件时,更稳妥的做法是开两个账号:一个只读账号用于分发,一个带写权限的账号用于收集。

4.3 配置被动端口范围和带宽限制,让服务端能进生产

开发和调试用一个裸serve_forever()没问题,但一旦要让别的机器连过来,必须处理两件事:被动端口范围和传输带宽限制。

from pyftpdlib.handlers import FTPHandler, ThrottledDTPHandler handler = FTPHandler handler.authorizer = authorizer # 限制被动模式数据端口范围 handler.passive_ports = range(60000, 60100) # 限制带宽: 读最大 1MB/s,写最大 500KB/s dtp_handler = ThrottledDTPHandler dtp_handler.read_limit = 1024 * 1024 dtp_handler.write_limit = 512 * 1024 handler.dtp_handler = dtp_handler server = FTPServer(("0.0.0.0", 21), handler) server.serve_forever()

passive_ports这个参数特别容易被忽视。如果服务器在防火墙后面,而防火墙只放行了 21 端口,被动模式的数据连接会全部失败。你要做的就是把被动端口固定在一个省段(比如 60000-60100),然后在防火墙里一并放行。否则用pyftpdlib提供的FTPHandler默认行为,是随机使用临时端口,防火墙根本没法配策略。

带宽限制属于生产环境的必备项。不限带宽的后果是:一个大文件传进来,CPU 和网络被打满,其他业务接口彻底卡死。ThrottledDTPHandler的限制单位是字节每秒,上面代码是读限 1MB/s、写限 512KB/s。

4.4 对接系统真实用户的选项:UnixAuthorizer

DummyAuthorizer的用户信息完全在代码里维护,适合测试和轻量场景。如果你的程序要跑在 Linux 服务器上,并且希望直接用操作系统的用户名和密码认证,pyftpdlib提供了UnixAuthorizer。

from pyftpdlib.authorizers import UnixAuthorizer authorizer = UnixAuthorizer() authorizer.override_user("ftp_user", password="custom_pass", homedir="/home/ftp_user")

这个类会去读/etc/passwd和/etc/shadow,认证逻辑和系统登录一致。它有个额外的方便之处:可以用override_user覆盖特定用户的主目录和密码,不用动系统用户配置就能给 FTP 用户重新指定根目录。

但这个方案要求 Python 进程有读取/etc/shadow的权限,通常是 root 或者 shadow 用户组。出于安全考虑我一般不推荐直接把服务端跑在 root 下。如果确实要用系统用户认证,建议单独创建一个低权限的系统用户,并把pyftpdlib服务配置成只绑定内网 IP。

5. 避坑:FTP + Python 开发里最常见的 5 个翻车现场

从写第一个 FTP 脚本到现在,我在这个领域遇到的坑大多不是 Python 语法问题,而是 FTP 协议本身的特性、服务器端的配置、编码和防火墙交互导致的。下面 5 条是出现频率最高、排查成本最大的坑,每一条都是实际经历过的翻车现场。

5.1 上传总卡住不动,日志停在“PORT”命令

现象:脚本运行到storbinary时长时间无响应,最终报TimeoutError或者ConnectionRefusedError。如果用 debug 模式,能看到最后一条命令是PORT。

原因:客户端默认发了PORT命令,说明当前是主动模式。主动模式下服务器会反向连接客户端的 IP 和端口,客户端所在机器有防火墙或不在公网上,服务器根本连不进来。

解决:在login后显式调用ftp.set_pasv(True)切换到被动模式。被动模式是客户端主动连接服务器返回的端口,不要求客户端监听任何入站端口。

ftp.set_pasv(True)

这是一行代码解决最重大坑的典型场景。不过要注意,某些内网部署的 FTP 服务器本身不支持被动模式,或者被动端口被网关封了。此时反而要切回主动模式,同时需要把客户端机器的防火墙放行一段数据端口。判定用哪种模式的原则是:看客户端能不能向外发起新连接——能就选被动模式,不能就选主动模式。

5.2 中文文件名乱码,下载后文件名变成“绗埖浣???”

现象:fetch回来的文件名用print显示正常,但写入本地文件系统后,Windows 上资源管理器里显示乱码,Linux 上显示一串奇怪的字符。

原因:FTP 协议本身不声明文件名编码。绝大多数中文 Windows 的 FTP 服务器(比如 IIS FTP)用的是 GBK/GB2312 编码发送文件名,而ftplib默认会用 UTF-8 解码。

解决:如果你的Python版本在 3.9 及以上,直接在创建FTP对象时指定编码:

ftp = FTP() ftp.connect(host="192.168.1.100", port=21, timeout=10) ftp.login(user="user", passwd="pass") ftp.encoding = "gbk"

或者更直接一点:

from ftplib import FTP ftp = FTP(host="192.168.1.100") ftp.encoding = "gbk" # Linux上的vsftpd可能是utf-8,Windows IIS多用gbk

判断服务器是什么编码的小技巧:先print一个中文目录名,如果显示乱码,就切换ftp.encoding试试另一种编码。不知道服务器具体是什么编码,就用gbk和utf-8两个值轮换,总有一个是正确的。

5.3 下载的文件大小和服务器上不一致,但没报错

现象:retrbinary正常执行完毕,没有抛任何异常,但本地文件大小比服务器上小了几百字节。

原因:我用文本模式retrlines下载时会把\r\n转成\n,字节数少是对应换行符的数量。另一个原因是用写普通文件的方式(文本模式open)保存下载数据,Windows 下还会做一次换行符转换。

解决:下载时坚持二进制模式。写入本地文件时用"wb"打开文件,上传时用"rb"打开文件。永远不要让 Python 的文件文本模式参与 FTP 传输链路。

with open(local_path, "wb") as f: # 二进制写,不做任何换行处理 ftp.retrbinary(f"RETR {remote_path}", f.write, blocksize=32768)

这个坑隐蔽在“没报错”三个字上。程序没报错、文件也能打开,但字节数不对,如果下游是解压或者校验和逻辑,就可能在半个小时后才暴露问题。

5.4 mlsd() 在旧服务器上报错,或者返回空列表

现象:同一套代码,在一台服务器上能正常列出目录,在另一台服务器上list(ftp.mlsd("/"))要么抛error_perm,要么返回完全为空。

原因:MLSD命令依赖服务器端实现。很多嵌入式 Linux 里的精简 FTP 服务器(比如 BusyBox ftpd)根本不认识MLSD,会返回 500 或者 502 错误码,mlsd()就会抛异常。还有一些低质量的服现实现,返回了空列表也不报错。

解决:写一个兼容层函数,优先尝试mlsd(),任何异常或空结果都自动回退到nlst(),然后通过cwd探测区分目录和文件。这在前文 3.2 节给了完整代码,核心就是捕获ftplib.error_perm和IndexError,然后改走nlst()逻辑。

def safe_listdir(ftp, path): try: result = list(ftp.mlsd(path)) if not result: raise RuntimeError("mlsd返回空") return result except Exception: return [(name, {"type": "unknown"}) for name in ftp.nlst(path)]

5.5 服务端搭好了,其他机器却连不上,卡在数据连接

现象:在本地用pyftpdlib起了服务端,本机脚本能上传下载,但局域网另一台机器连上来后卡在数据传输,控制连接一切正常。

原因:两件事最可疑。第一,passive_ports没有配置,数据连接随机使用端口,而防火墙只放行了 21 端口。第二,绑定的地址写成了"127.0.0.1",其他机器自然连不进来。

解决:绑定"0.0.0.0"监听所有网卡,并配置固定被动端口范围,在防火墙里放行这些端口。同时还要检查:如果服务器有多个网卡或部署在 NAT 后面,pyftpdlib返回给客户端的被动模式 IP 可能不对。这种情况需要在启动服务时指定对外 IP:

from pyftpdlib.servers import FTPServer server = FTPServer(("0.0.0.0", 21), handler) server.masquerade_address = "203.0.113.5" # 替代为服务器对外的公网IP,内网场景可不设置

masquerade_address的含义是告诉客户端去连接哪个 IP 的被动端口。没有这一行,服务器如果检测不到正确的对外 IP,会返回一个内网地址给客户端,客户端连不上,卡在数据连接阶段。

6. 进阶技巧:超时重试、断点续传与传输校验的落地写法

最后一章我希望大家带走几个能直接放进生产脚本里的进阶设计:超时重试、断点续传、传输后的完整性校验。这三件事能极大提升 FTP 自动化脚本的可靠性。

6.1 给传输加超时重试:网络抖动不该废掉整批任务

FTP 底层是 TCP,偶尔抖动一次导致连接断开是常态。重试逻辑的写法不复杂,关键是只在可重试的异常上重试,别把权限错误和文件不存在也无限重试。

import socket from ftplib import FTP, error_perm, error_temp def retry_upload(ftp, local_path, remote_path, attempts=3): for i in range(attempts): try: with open(local_path, "rb") as f: ftp.storbinary(f"STOR {remote_path}", f, blocksize=32768) return True except (socket.timeout, error_temp, ConnectionResetError) as e: print(f"第{i+1}次尝试失败: {e}") # 重连一次,保证下一次循环用的是新连接 ftp.connect(host=ftp.host, port=ftp.port, timeout=20) ftp.login(user=ftp.user, passwd=ftp.passwd) except error_perm as e: # 550权限错误,重试也白搭 print(f"权限错误,放弃重试: {e}") return False return False

6.2 断点续传:大文件中途断了不用从头再传

ftplib的transfercmd支持从指定偏移量恢复传输。原理是 FTP 协议里有REST命令,告诉服务器从文件的第 N 字节开始处理后续命令。

from ftplib import FTP import os local_path = "big_file.iso" remote_path = "/data/big_file.iso" local_size = os.path.getsize(local_path) ftp = FTP() ftp.connect(host="192.168.1.100", port=21, timeout=30) ftp.login(user="user", passwd="pass") ftp.set_pasv(True) with open(local_path, "ab") as f: # rest=local_size:告诉服务器跳过前local_size字节 ftp.storbinary(f"STOR {remote_path}", f, rest=local_size)

注意两点。第一,open要用扩展读追加"ab",不能是"wb",否则会清空本地已有的部分再写。第二,是否支持断点续传取决于服务器是否实现了REST STREAM命令,绝大多数主流 FTP 服务器都支持,少数精简实现不支持,遇到error_perm就只能全量重传。

6.3 用 MD5 校验传输完整性:大小一致不等于内容一致

文件大小一致只说明传输没有丢字节,无法发现内容被篡改或磁盘写坏。只要性能允许,我建议对关键文件做一次 MD5 校验。不能直接读远端文件的哈希(FTP 协议没有标准校验命令),但可以下载后本地计算比对。

import hashlib def calc_md5(path): md5 = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): md5.update(chunk) return md5.hexdigest() src_md5 = calc_md5("local_big.bin") # 假设已下载到remote_big.bin dst_md5 = calc_md5("remote_big.bin") if src_md5 == dst_md5: print("校验一致,文件完整") else: print("校验失败,需要重新传输")

这招最适合备份场景。我现在的备份脚本里,每个文件传完后会写一条日志到本地,记录文件路径、大小、MD5 和传输时间。第二天检查日志,发现有校验不一致的条目就直接重新推送那个文件,不用整批重传。

以上这些技巧,都是我在线上环境一步步踩出来的。最开始写 FTP 工具,我用的是裸ftplib,跑一次挂一次,后来慢慢把重试、校验、断点续传都加了进去,脚本才能稳定放在 cron 里每天跑。最重要的一条经验是:脚本能用了不算结束,确认它能把失败情况也处理好,才算真的完成。希望帮到你。

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

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

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

立即咨询