ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

FTP上传下载实战:ftplib、被动模式与断点续传排错

FTP上传下载实战:ftplib、被动模式与断点续传排错 简介面向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, timeout15) 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「Cant open data connection」先怀疑被动端口区间没放行如果脚本在本地开发机跑得好好的一上生产服务器就挂八成是生产机的出网策略只放了 21 端口。from ftplib import FTP ftp FTP() ftp.encoding utf-8 ftp.connect(ftp.example.com, 21, timeout20) 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 AASCII会让服务器在传输过程中做换行符转换TYPE IIMAGE/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, timeout20) 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, timeout20): ftp FTP() ftp.encoding utf-8 ftp.connect(host, 21, timeouttimeout) # 认证信息从环境变量读不要写死在代码里 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, blocksize8192, callbackNone, restNone)。第一个参数是命令字符串通常是STOR 远程文件名第二个是打开的文件对象必须是二进制模式blocksize决定每次从本地读多少字节往 socket 里写默认 8192内网可以调到 65536 提速跨公网保持在 8192 到 32768 更稳。callback每次发送一个块就被调用一次入参是这一块的字节数。用它做进度条或者日志最好用但注意不要在回调里做耗时操作否则会拖慢整体传输。import os from ftplib import FTP def upload_file(ftp, local_path, remote_dir, remote_nameNone): 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(fSTOR {remote_name}, fp, blocksize32768, 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, restNone)回调收到的是数据块需要自己往本地文件里写。这一点和上传不同上传是 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_okTrue) 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(fRETR {remote_path}, fp.write, blocksize32768) 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(fRETR {remote_path}, h.update, blocksize65536) 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 --parallel4 /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 --parallel4 --exclude-glob *.tmp ./dist /public/release; bye -c是续传-n是只传新文件基于大小和时间做判断-R是反向镜像本地到远程--parallel4开 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 --parallel4 /data/2024 /data/backup/2024; bye ; then echo 同步失败 $(date %F %T) 2 exit 1 fi echo 结束 $(date %F %T) } $LOG_FILE 21crontab 里的写法是*/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保持encodingutf-8如果返回500或502把encoding改成gbk再跑一遍。命令行侧 curl 没有直接的编码开关我一般改用 lftp 并设置set ftp:charset gbk或set ftp:charset utf-8。验证方法很简单上传一个中文名的小文件立刻nlst回来比对字节别等全量传完再发现。5.2 425 打不开数据连接控制连接却完全正常现象登录成功PWD正常返回一执行LIST或RETR就报425 Cant open data connection重试几次偶尔能成功。原因被动模式下服务器返回的临时端口没有在防火墙或安全组放行。偶尔成功是因为区间里个别端口恰好被放通了或者会话复用了上一次的连接。另一种可能是服务器返回的 PASV IP 是内网地址客户端在公网侧根本连不上。解决先开set_debuglevel(2)或者手动发PASV把服务器返回的 IP 和端口算出来前面讲过p1*256p2的换算。然后核对防火墙放行区间让服务端把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时用了默认的timeoutNonesocket 进入阻塞状态永不超时。服务端已经悄悄断了控制连接客户端还在傻等一个永远不会来的响应。解决所有connect调用强制传timeout20 到 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(fRETR {remote_path}, fp.write, blocksize65536, restlocal_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 意味着密码和内容都在网络上裸奔。显式 FTPSFTP over TLS在 21 端口上先明文建连再用AUTH TLS升级加密兼容性比隐式 FTPS 好大多数服务端都支持。import ssl from ftplib import FTP_TLS ftps FTP_TLS(contextssl.create_default_context()) ftps.encoding utf-8 ftps.connect(ftp.example.com, 21, timeout20) 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 的问题大多不是逻辑错是环境差异本地跑通不代表生产跑通但本地跑不通一定别往生产推。那次凌晨少了一个包的教训之后我每次把同步脚本交给别人之前都会强制走一遍「小目录全流程 断网续传 大小校验」这三步宁可多花二十分钟也不想再对着干净的日志找一晚上原因。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表