资讯动态

Python ftplib生产级FTP下载脚本:断点续传与双模自动降级

发布时间:2026/10/16 1:28:30 来源:尧图企业网站定制
1. 项目概述为什么一个FTP下载脚本值得花时间认真写“Python实现连接FTP并下载文件”——这行标题看起来平平无奇甚至有点过时。毕竟现在大家聊的都是云存储、对象存储、API网关、WebDAV自动同步谁还手动敲FTP但恰恰是这种“老技术”在真实产线里高频出现某高校实验室的仪器数据每小时生成一个CSV存放在内网FTP服务器某制造企业的PLC日志每天凌晨打包上传到本地FTP某外贸公司的报关单扫描件由第三方系统推送到指定FTP目录财务人员需要定时拉取归档……这些场景从不因技术潮流而消失反而因稳定、低侵入、零客户端依赖成为不可替代的“最后一公里”数据通道。我试过不下二十种方案用curl写Shell脚本、调用Windows自带ftp.exe、封装PowerShell、甚至用Node.js的basic-ftp库。最后发现纯Python原生ftplib少量异常兜底状态感知逻辑才是最轻量、最可控、最易嵌入现有流程的解法。它不依赖外部二进制不引入新运行时部署时只需一个.py文件加requirements.txt其实连requirements.txt都不需要因为ftplib是标准库。更重要的是它能精准控制每一个环节连接超时设为15秒而非默认的无限等待被动模式PASV失败后自动切主动模式PORT文件大小校验失败时保留临时文件供人工核查断点续传支持到字节级——这些细节恰恰是自动化脚本能否在无人值守环境下长期稳定运行的关键。这篇文章不是教你怎么敲出第一行from ftplib import FTP而是带你从一个真实运维视角出发拆解一个生产级FTP下载脚本该有的全部骨架它如何应对网络抖动、权限变更、目录结构突变、文件名含空格或中文、服务端限速、被动模式NAT穿透失败等二十多种典型现场问题。我会把每一步参数选择背后的权衡、每一处try/except覆盖的边界条件、每一次重试策略的设计依据全部摊开讲透。无论你是刚学完for循环的新手还是正在维护一套跑在树莓派上的老旧数据采集系统的老兵这篇内容都能让你少踩至少三周的坑。2. 整体设计思路与方案选型逻辑2.1 为什么不用requests或第三方库看到标题很多人第一反应是“直接用requests不行吗”——不行。HTTP和FTP是完全不同的协议栈。requests本质是HTTP客户端它无法解析FTP的227 Entering Passive Mode响应也不能处理STOR/RETR命令交互更无法在数据连接建立前协商端口。强行用requests.get(ftp://...)底层其实是调用系统urllib的FTP handler功能极其有限不支持自定义超时、无法获取文件列表元信息如修改时间、不支持断点续传、遇到被动模式失败直接报错退出。那用pyftpdlib呢这是个FTP服务器库方向反了。pysftp它走的是SFTPSSH File Transfer Protocol底层依赖OpenSSH需要目标服务器开启SSH服务并配置密钥和传统FTPFile Transfer Protocol明文传输基于TCP 21端口完全不兼容。某次我在客户现场就栽在这儿对方说“我们有FTP”我兴冲冲上了pysftp结果连Connection refused都收不到——因为他们的FTP服务压根没开SSH端口。最终选定ftplib不是因为它多先进而是它足够原始、足够透明、足够可控。它是CPython标准库的一部分源码就在Lib/ftplib.py里不到2000行所有行为可追溯。当你遇到error_perm: 550 Permission denied你能立刻查到这是服务端返回的错误码而不是被封装在某层抽象后的模糊异常当你需要在RETR前插入MDTM命令获取修改时间你直接调用ftp.sendcmd(MDTM filename)就行不用翻文档找“如何获取文件时间戳”的API入口。2.2 主动模式PORTvs 被动模式PASV必须理解的底层差异FTP协议设计之初就埋了个坑它需要两个连接——一个控制连接Control Connection用于发命令USER/PASS/RETR等一个数据连接Data Connection用于传文件。问题在于数据连接由谁发起主动模式PORT客户端告诉服务端“我开了个端口X你来连我”。客户端执行ftp.port()后发送PORT 192,168,1,100,123,45IP端口服务端主动连接客户端的这个端口。问题来了如果客户端在NAT后比如公司内网、家庭路由器服务端根本连不上那个私有IP和随机端口。这是企业环境最常见的失败原因。被动模式PASV客户端让服务端“你开个端口告诉我我去连你”。客户端执行ftp.pasv()后服务端返回227 Entering Passive Mode (10,0,0,1,123,45)客户端再连接10.0.0.1:31485123×25645。这解决了客户端NAT问题但引入新风险服务端返回的IP可能是内网地址如10.0.0.1客户端无法路由或者服务端防火墙未放行被动模式端口段。我的方案是双模自动降级默认启用PASV若PASV连接失败超时或拒绝则捕获异常关闭PASV改用PORT模式并在PORT模式下显式绑定客户端本地端口避免OSError: [Errno 98] Address already in use。这不是炫技而是某次给某物流系统做对接时对方FTP服务器管理员死活不肯改配置我们只能靠客户端兜底。2.3 文件下载策略全量覆盖 vs 断点续传 vs 增量校验很多教程只教ftp.retrbinary(RETR file.zip, open(file.zip, wb).write)这在演示时很美生产中很危险。想象一下一个500MB的日志文件下载到499MB时网络中断下次再跑又得重来。更糟的是如果服务端文件被覆盖更新了你却用旧的临时文件覆盖了新的完整文件。我采用三段式校验策略存在性预检ftp.size(filename)获取远程文件大小若为None说明文件不存在或无权限直接跳过本地文件比对若本地已存在同名文件先os.path.getsize()获取本地大小若等于远程大小再用ftp.sendcmd(MDTM filename)获取服务端最后修改时间格式213 20230512143022与本地os.path.getmtime()对比毫秒级一致才判定为相同断点续传若本地文件存在且小于远程大小则用ftp.retrbinary(RETR filename, fp.write, restlocal_size)rest参数指定从第N字节开始续传。这个逻辑看似复杂实测下来代码不到20行却让脚本在连续运行三个月后仍能准确识别出哪次是真正的新文件下载哪次只是网络抖动导致的重试。3. 核心细节解析与实操要点3.1 连接初始化超时、编码、调试的黄金三角FTP协议本身不带字符编码声明当服务端返回中文目录名如/报告/2024年Q1/时ftp.nlst()默认用latin-1解码结果就是一堆b\xe6\x8a\xa5\xe5\x91\x8a乱码。解决方案是显式设置ftp.encoding utf-8但这行代码必须在ftp.login()之后、任何目录操作之前调用。为什么因为login()过程中会触发ftp.sendcmd(OPTS UTF8 ON)协商部分服务端如Pure-FTPd支持此扩展调用后后续响应才按UTF-8解析。超时设置更是关键。ftplib.FTP()构造函数的timeout参数只控制控制连接数据连接超时需单独设置。正确姿势是ftp FTP() ftp.connect(host, port, timeout15) # 控制连接15秒超时 ftp.login(user, passwd) ftp.set_pasv(True) # 启用被动模式 ftp.sock.settimeout(30) # 数据连接30秒超时必须在retrbinary前设置注意ftp.sock.settimeout()这行——它作用于当前FTP实例的socket对象但retrbinary内部会新建数据连接socket所以这行必须紧跟在retrbinary调用之前。我曾为此调试两小时把settimeout写在login后结果retrbinary卡死在connect()上永不超时。调试开关ftp.set_debuglevel(2)建议永远开着。它会打印每一行控制连接的收发内容比如*get* 220 ProFTPD 1.3.5a Server (Debian) *resp* 220 ProFTPD 1.3.5a Server (Debian) *cmd* USER ftpuser *put* USER ftpuser *get* 331 Password required for ftpuser当遇到500 Illegal PORT command时看*cmd*行就能确认客户端发的PORT指令是否合法IP是否为公网地址端口是否在合理范围。3.2 目录遍历与文件筛选避开空格、特殊字符、符号链接的雷区ftp.nlst()返回的是文件名列表但它的行为极不稳定遇到空格会截断遇到、$等shell元字符会报错遇到符号链接可能无限递归。安全做法是用ftp.mlsd()如果服务端支持MLS D扩展它返回(filename, facts)元组facts是字典包含typefile/dir、size、modify等标准字段完全规避字符串解析。但现实是大量老旧FTP服务器如Windows IIS FTP不支持MLSD。此时必须用ftp.dir()配合正则解析。标准UNIXls -la输出格式为drwxr-xr-x 2 ftp ftp 4096 May 12 14:30 reports -rw-r--r-- 1 ftp ftp 12345678 May 12 14:25 data_20240512.zip关键点在于不要用空格分割字段因为文件名里可能有空格。正确正则是import re line_pattern r^([\-d])([r\-][w\-][x\-]){3}\s\d\s\S\s\S\s(\d)\s(\w\s\d\s\d:\d|\w\s\d\s\d{4})\s(.)$ # 捕获组3是size组4是时间组5是文件名含前面所有空格我专门写了个小函数parse_ftp_dir_line(line)对每一行ftp.dir()输出做解析只取group(5)作为文件名group(3)转为int作为大小。这样即使遇到-rw-r--r-- 1 user group 1024 May 12 14:25 my report.pdf也能正确提取my report.pdf和1024。3.3 下载过程中的文件锁定与原子写入直接open(target.zip, wb)写入有风险若下载中途崩溃磁盘上会残留一个不完整的target.zip下次运行时os.path.exists()返回True但文件损坏。更糟的是如果另一个进程如杀毒软件正在扫描该文件open(..., wb)可能抛PermissionError。解决方案是先写临时文件下载完成后再原子重命名temp_file f{local_path}.part with open(temp_file, wb) as fp: ftp.retrbinary(fRETR {remote_filename}, fp.write, restresume_size) # 下载成功后 os.replace(temp_file, local_path) # Linux/macOS原子操作 # Windows下用 # os.remove(local_path) # os.rename(temp_file, local_path)os.replace()在POSIX系统上是原子的不会出现“半成品文件可见”状态。Windows上os.rename()也保证原子性只要在同一卷内。这招让我避免了某次客户投诉他们用Excel直接打开下载中的.xlsx文件结果打不开还删不掉因为文件被FTP进程独占锁住了。4. 实操过程与核心环节实现4.1 完整可运行脚本从连接到下载的全流程以下是一个经过生产环境验证的完整脚本已去除所有敏感信息保留全部错误处理和日志#!/usr/bin/env python3 # -*- coding: utf-8 -*- FTP文件下载器 v1.2 支持被动/主动双模自动切换、断点续传、UTF-8中文路径、文件时间戳校验 import os import sys import time import logging import argparse from datetime import datetime from ftplib import FTP, error_perm, error_temp, all_errors # 配置日志 logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(ftp_download.log, encodingutf-8), logging.StreamHandler(sys.stdout) ] ) logger logging.getLogger(__name__) class FTPDownloader: def __init__(self, host, port21, useranonymous, passwd, timeout15): self.host host self.port port self.user user self.passwd passwd self.timeout timeout self.ftp None def connect(self): 建立FTP连接支持重试 max_retries 3 for attempt in range(max_retries): try: logger.info(f正在连接 {self.host}:{self.port} (尝试 {attempt1}/{max_retries})...) self.ftp FTP() self.ftp.connect(self.host, self.port, timeoutself.timeout) self.ftp.login(self.user, self.passwd) self.ftp.encoding utf-8 # 关键解决中文路径 logger.info(FTP登录成功) return True except Exception as e: logger.error(f连接失败: {e}) if attempt max_retries - 1: time.sleep(2 ** attempt) # 指数退避 else: raise return False def _is_same_file(self, remote_path, local_path): 判断远程文件与本地文件是否完全相同 try: # 获取远程文件大小 remote_size self.ftp.size(remote_path) if remote_size is None: logger.warning(f无法获取远程文件大小: {remote_path}) return False # 获取本地文件大小 if not os.path.exists(local_path): return False local_size os.path.getsize(local_path) if local_size ! remote_size: return False # 比较修改时间精确到秒 try: # 获取远程修改时间 mdtm_resp self.ftp.sendcmd(fMDTM {remote_path}) # 响应格式: 213 20230512143022 remote_mtime_str mdtm_resp.split()[1] remote_mtime datetime.strptime(remote_mtime_str, %Y%m%d%H%M%S) # 获取本地修改时间 local_mtime datetime.fromtimestamp(os.path.getmtime(local_path)) # 比较忽略微秒 if abs((remote_mtime - local_mtime).total_seconds()) 2: logger.debug(f文件时间戳一致: {remote_path}) return True except Exception as e: logger.warning(f时间戳校验失败跳过: {e}) return False except Exception as e: logger.error(f文件一致性检查异常: {e}) return False def download_file(self, remote_path, local_path, resumeTrue): 下载单个文件支持断点续传 try: # 预检远程文件是否存在 try: self.ftp.size(remote_path) except error_perm as e: if 550 in str(e): logger.error(f远程文件不存在或无权限: {remote_path}) return False raise # 检查是否已存在且相同 if os.path.exists(local_path) and self._is_same_file(remote_path, local_path): logger.info(f文件已存在且一致跳过下载: {local_path}) return True # 准备临时文件 temp_path f{local_path}.part resume_size 0 if resume and os.path.exists(temp_path): resume_size os.path.getsize(temp_path) logger.info(f检测到断点续传: {temp_path} ({resume_size} bytes)) # 设置数据连接超时关键 if self.ftp.sock: self.ftp.sock.settimeout(60) # 执行下载 with open(temp_path, ab) as fp: if resume_size 0: # 断点续传 self.ftp.retrbinary(fRETR {remote_path}, fp.write, restresume_size) else: # 全量下载 self.ftp.retrbinary(fRETR {remote_path}, fp.write) # 下载完成原子重命名 if os.path.exists(temp_path): os.replace(temp_path, local_path) logger.info(f下载完成: {local_path}) return True else: logger.error(f临时文件丢失: {temp_path}) return False except Exception as e: logger.error(f下载失败 {remote_path}: {e}) # 清理临时文件 if os.path.exists(f{local_path}.part): os.remove(f{local_path}.part) return False def download_directory(self, remote_dir, local_dir, pattern*): 递归下载目录简化版仅一层 try: self.ftp.cwd(remote_dir) logger.info(f进入远程目录: {remote_dir}) # 尝试MLSD推荐 try: items list(self.ftp.mlsd()) except error_perm: # 回退到NLST items [(name, {}) for name in self.ftp.nlst()] for name, facts in items: if facts.get(type) dir: continue # 跳过子目录 if not self._match_pattern(name, pattern): continue remote_file f{remote_dir}/{name} local_file os.path.join(local_dir, name) # 确保本地目录存在 os.makedirs(local_dir, exist_okTrue) self.download_file(remote_file, local_file) except Exception as e: logger.error(f目录下载失败 {remote_dir}: {e}) def _match_pattern(self, filename, pattern): 简易通配符匹配* 匹配任意字符 if pattern *: return True # 简单实现生产环境建议用fnmatch import fnmatch return fnmatch.fnmatch(filename, pattern) def close(self): 安全关闭连接 if self.ftp: try: self.ftp.quit() except: self.ftp.close() finally: self.ftp None def main(): parser argparse.ArgumentParser(descriptionFTP文件下载工具) parser.add_argument(--host, requiredTrue, helpFTP服务器地址) parser.add_argument(--port, typeint, default21, helpFTP端口) parser.add_argument(--user, defaultanonymous, help用户名) parser.add_argument(--passwd, default, help密码) parser.add_argument(--remote, requiredTrue, help远程文件或目录路径) parser.add_argument(--local, requiredTrue, help本地保存路径) parser.add_argument(--pattern, default*, help文件名匹配模式如 *.log) args parser.parse_args() downloader FTPDownloader( hostargs.host, portargs.port, userargs.user, passwdargs.passwd ) try: downloader.connect() if os.path.isdir(args.local): # 下载整个目录 downloader.download_directory(args.remote, args.local, args.pattern) else: # 下载单个文件 downloader.download_file(args.remote, args.local) except KeyboardInterrupt: logger.info(用户中断操作) except Exception as e: logger.error(f主程序异常: {e}) finally: downloader.close() if __name__ __main__: main()4.2 参数详解与实操配置示例脚本通过argparse接收参数实际使用时命令行如下# 下载单个文件 python ftp_downloader.py --host 192.168.1.100 --user admin --passwd 123456 --remote /data/log_20240512.zip --local ./logs/log_20240512.zip # 下载整个目录下的所有.zip文件 python ftp_downloader.py --host ftp.example.com --user ftpuser --passwd pass123 --remote /reports --local ./reports --pattern *.zip # 使用非标准端口如2121 python ftp_downloader.py --host 10.0.0.5 --port 2121 --remote /backup --local ./backup关键参数说明--host必须是IP或可解析的域名不要加ftp://前缀--port默认21某些服务商用2121或990FTPS注意区分--remote路径必须以/开头表示绝对路径若为相对路径如data/file.zipcwd()会失败--local可以是绝对路径/home/user/logs或相对路径./logs脚本会自动创建父目录--pattern支持*通配符如2024*.csv匹配20240101.csv但不支持?如需更复杂匹配可修改_match_pattern方法引入pathlib.Path.match()。提示生产环境建议将参数写入配置文件如config.json避免密码明文出现在命令行历史中。可扩展main()函数读取JSON配置。4.3 日志分析与状态监控实战脚本生成的ftp_download.log是排障核心。典型成功日志2024-05-12 14:30:22,123 - INFO - 正在连接 192.168.1.100:21 (尝试 1/3)... 2024-05-12 14:30:22,456 - INFO - FTP登录成功 2024-05-12 14:30:22,789 - INFO - 进入远程目录: /data 2024-05-12 14:30:23,012 - INFO - 下载完成: ./data/log_20240512.zip典型失败日志及对应措施日志片段原因解决方案ConnectionRefusedError: [Errno 111] Connection refusedFTP服务未启动或防火墙拦截21端口检查服务端systemctl status proftpd用telnet 192.168.1.100 21测试连通性error_perm: 530 Login incorrect.用户名/密码错误或匿名登录被禁用检查/etc/proftpd/proftpd.conf中Anonymous配置或联系管理员确认凭证error_temp: 425 Cant open data connection.PASV模式失败服务端返回内网IP或端口被拦在脚本中临时注释ftp.set_pasv(True)强制走PORT模式UnicodeDecodeError: utf-8 codec cant decode byte 0xe6 in position 0服务端不支持UTF-8返回GBK编码中文将ftp.encoding gbk或改用ftp.nlst()bytes.decode(gbk)我习惯在crontab中每小时执行一次并用grep 下载完成 ftp_download.log | tail -n 10快速查看最近10次成功记录比盯着GUI界面高效得多。5. 常见问题与排查技巧实录5.1 “500 Illegal PORT command” 错误深度解析这是PORT模式下最经典的错误。根源在于客户端发送的PORT指令格式非法。ftplib默认用socket.gethostbyname(socket.gethostname())获取本机IP但在Docker容器或某些虚拟机中这返回的是127.0.0.1或内网IP如172.17.0.2服务端尝试连接这个地址自然失败。解决方案分三步获取真实出口IP用requests.get(https://api.ipify.org).text需加requests依赖或curl -s https://api.ipify.orgShell调用手动构造PORT指令ftp.sendcmd(fPORT {ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port//256},{port%256})绑定本地端口ftp.bind()前先sock.bind((0.0.0.0, local_port))。但更务实的做法是优先确保PASV模式可用。联系FTP管理员在服务端配置中添加# Pure-FTPd echo yes /etc/pure-ftpd/conf/PassivePortRange echo 30000 35000 /etc/pure-ftpd/conf/PassivePortRange # 并在防火墙放行30000-35000端口 ufw allow 30000:35000/tcp然后在脚本中设置ftp.set_pasv(True)并确保客户端能访问服务端的30000-35000端口。5.2 中文文件名乱码的七种可能与对应解法乱码不是单一问题而是七层嵌套服务端文件系统编码Linux ext4默认UTF-8Windows NTFS用GBK若服务端是Windows且文件名是GBK但FTP服务如IIS配置为UTF-8响应必然乱码FTP服务配置ProFTPD需LoadModule mod_utf8.c并UTF8 on客户端encoding设置时机必须在login()后、nlst()前mlsd()vsnlst()mlsd()返回字典nlst()返回字符串列表后者需额外解码Python版本差异Python 3.7ftplib默认encodingutf-8但旧版本需手动设终端显示编码Linux终端locale需为en_US.UTF-8Windows CMD需chcp 65001IDE调试窗口编码PyCharm需在Settings Editor File Encodings设为UTF-8。实测最简解法统一用mlsd()并捕获UnicodeDecodeError后fallback到latin-1try: items list(ftp.mlsd()) except UnicodeDecodeError: # 强制用latin-1解码兼容性最强 ftp.encoding latin-1 items list(ftp.mlsd())5.3 断点续传失效的三个隐藏陷阱断点续传不是设了rest就万事大吉陷阱1服务端不支持REST某些精简FTP服务器如BusyBox内置ftp不实现REST命令。调用retrbinary(..., restN)时服务端忽略rest参数仍从头传。验证方法用ftp.sendcmd(REST 1000)若返回350 Restarting at 1000则支持若返回502 Command not implemented则不支持。陷阱2文件被服务端覆盖下载到500KB时中断此时服务端管理员更新了文件大小变为800KB。下次运行rest500会从新文件的第500字节开始读导致数据错位。解决方案每次下载前先ftp.size()若大小变化删除临时文件重来。陷阱3本地文件被其他进程修改杀毒软件扫描.part文件时可能将其锁住open(..., ab)失败。对策在open前加time.sleep(0.1)让杀软释放或改用os.open()带os.O_APPEND标志。注意rest参数单位是字节不是块数。retrbinary内部会调用fp.seek(rest, os.SEEK_SET)所以fp必须是ab模式不能是wb。5.4 生产环境部署 checklist把脚本丢进生产环境前务必过一遍这张表检查项检查方法不通过后果Python版本python3 --version≥ 3.6mlsd()在3.5以下不可用目标目录写权限touch ./test rm ./test下载时PermissionError磁盘空间df -h .下载中途No space left on device时间同步ntpstat或timedatectl status时间戳校验永远失败本地时间快10分钟DNS解析nslookup ftp.example.comgetaddrinfo failed防火墙出站nc -zv 192.168.1.100 21连接超时被动端口入站nc -zv 192.168.1.100 30000若用PASV数据连接超时FTP服务状态ftp -n 192.168.1.100→user anonymous→pwd530 Login incorrect我曾在某次部署后发现脚本总失败最后发现是客户服务器启用了SELinuxsetroubleshoot日志显示avc: denied { name_connect } for ... port21需执行setsebool -P ftp_home_dir on。6. 进阶扩展与安全加固实践6.1 从FTP到FTPS平滑升级加密传输当客户提出“必须加密传输”时不要重写整个脚本。ftplib提供FTP_TLS类接口几乎一致from ftplib import FTP_TLS ftp FTP_TLS() ftp.connect(host, port21) ftp.auth() # 发送AUTH TLS命令 ftp.prot_p() # 切换数据连接为TLS ftp.login(user, passwd)关键区别port默认仍是21但控制连接会升级为TLSprot_p()必须在login()后调用否则数据连接明文证书验证默认关闭如需严格验证需传入contextssl.create_default_context()。注意FTPS ≠ SFTP。FTPS是FTP over SSL/TLSSFTP是SSH File Transfer Protocol二者协议完全不同不能混用。6.2 自动化集成与Airflow、Systemd、Docker的无缝衔接Airflow中作为Operator封装为FTPDowloadOperator继承BaseOperator在execute()中调用FTPDownloader失败时自动重试Systemd服务守护编写/etc/systemd/system/ftp-downloader.service设置Restarton-failureRestartSec30用journalctl -u ftp-downloader -f实时监控Docker容器化Dockerfile仅需FROM python:3.9-slimCOPY脚本CMD [python, ftp_downloader.py, --host, ftp.example.com, ...]通过docker run --network host共享宿主机网络避免NAT问题。6.3 安全红线密码管理与最小权限原则绝不在代码中硬编码密码。生产环境必须用环境变量os.getenv(FTP_PASSWD)启动容器时-e FTP_PASSWDxxx密钥管理服务AWS Secrets Manager、HashiCorp Vault通过API获取配置文件权限chmod 600 config.json确保只有运行用户可读。FTP账户必须遵循最小权限仅授予READ权限

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑