Python+WinRM从Windows服务器传文件速度慢的原因及解决办法
Python + WinRM 拉取Windows服务器文件速度过慢的原因与解决办法
我使用python + winrm从Windows服务器拉取文件,代码运行正常但速度极慢,日均仅能传输约10GB文件。而通过远程桌面(mstsc.exe或其他远程客户端)手动复制文件时,一晚上即可传输140GB文件。想了解代码传输速度慢的原因,以及对应的解决办法。
环境信息
python 3.7.9 pywinrm 0.4.3
WinRM配置
Config MaxEnvelopeSizekb = 500 MaxTimeoutms = 60000 MaxBatchItems = 32000 MaxProviderRequests = 4294967295 Client NetworkDelayms = 5000 URLPrefix = wsman AllowUnencrypted = false Auth Basic = true Digest = true Kerberos = true Negotiate = true Certificate = true CredSSP = false DefaultPorts HTTP = 5985 HTTPS = 5986 TrustedHosts Service MaxConcurrentOperations = 4294967295 MaxConcurrentOperationsPerUser = 1500 EnumerationTimeoutms = 240000 MaxConnections = 300 MaxPacketRetrievalTimeSeconds = 120 AllowUnencrypted = true Auth Basic = true Kerberos = true Negotiate = true Certificate = false CredSSP = false CbtHardeningLevel = Relaxed DefaultPorts HTTP = 5985 HTTPS = 5986 IPv4Filter = * IPv6Filter = * EnableCompatibilityHttpListener = false EnableCompatibilityHttpsListener = false CertificateThumbprint AllowRemoteAccess = true Winrs AllowRemoteShellAccess = true IdleTimeout = 7200000 MaxConcurrentUsers = 10 MaxShellRunTime = 2147483647 MaxProcessesPerShell = 25 MaxMemoryPerShellMB = 1024 MaxShellsPerUser = 30
实现代码
import winrm import re import base64 import os import logging from winrm import Response logging.getLogger('urllib3').setLevel(logging.CRITICAL) logging.getLogger('spnego').setLevel(logging.CRITICAL) log = logging.getLogger('') class WinRmConnection: def __init__(self, hostname='', username='', password='', connect_now=True, **kwargs): self.__rdc = None self.hostname = hostname self.username = username self.password = password self.transport = kwargs.get('transport', 'ntlm') self.port = kwargs.get('port', 5985) if connect_now: self.connect(hostname=f'{hostname}:{self.port}', username=username, password=password, transport=self.transport) def connect(self, **kwargs): hostname = kwargs.get('hostname', self.hostname) username = kwargs.get('username', self.username) password = kwargs.get('password', self.password) transport = kwargs.get('transport', self.transport) try: assert hostname password = password except: raise Exception('Connection params error.') self.__rdc = winrm.Session( hostname, auth=(username, password), transport=transport ) def close(self): try: self.__rdc.close() except: pass def cmd(self, cmd, ps=False, raise_err=False, retry=3): if not cmd: return '' log.debug('Execute %scommand: %s' % ('' if not ps else 'PowerShell ', cmd)) try: result = self.__rdc.run_ps(cmd) if ps else self.__rdc.run_cmd(cmd) stdout = result.std_out stderr = result.std_err try: out = stdout.decode('utf-8') err = stderr.decode('utf-8') except UnicodeDecodeError: out = stdout.decode('gbk') err = stderr.decode('gbk') if raise_err and (stderr and err): raise Exception(err) if result.status_code: out = err except Exception as e: log.exception('Error occurred when executing [%s]: %s' % (cmd, repr(e))) if raise_err: raise e return '' else: log.debug('Command output: %s' % out) reg = re.compile(r'^\*') return reg.sub('', out.strip()) def download(self, remote_path, local_path, overwrite=True): is_dir_src = 0 remote_path = remote_path.rstrip(' \t\\/') if self.cmd(f'Test-Path {remote_path} -PathType Container', ps=True) == 'True': is_dir_src = 1 log.debug(f'Prepare to download ' + ('directory ' if is_dir_src else '') + f'{remote_path} from server.') target = '' append_name = remote_path.split('\\')[-1] if is_dir_src: # downloading folder local_path = os.path.join(local_path, append_name) if not os.path.exists(local_path): os.makedirs(local_path) elif not overwrite: raise Exception('Destination path has been exists already.') target += local_path else: # downloading file if not os.path.exists(local_path): # treat `local_path` as a folder if it does not exists os.makedirs(local_path) target += os.path.join(local_path, append_name) elif not overwrite: raise Exception('Destination path has been exists already.') elif os.path.isdir(local_path): target += os.path.join(local_path, append_name) else: target += local_path result = 1 if not is_dir_src: result = int(self._do_get_file(remote_path, target)) if not result: log.debug(f'Failed while downloading {remote_path}.') else: obj = self.cmd(f'Get-ChildItem -LiteralPath {remote_path} -Name', ps=True) if obj: for o in obj.splitlines(): result &= bool(self.download(f'{remote_path}\\{o}', target)) log.debug(f'Finish downloading {remote_path} to {target}.') return target if result else None def _do_get_file(self, remote_path, local_path, buffer_size=2 ** 19): # Refer to: https://github.com/ansible/ansible/blob/devel/lib/ansible/plugins/connection/winrm.py # 0.5MB chunks by default out_file = None err = 0 try: offset = 0 while True: command_id = None try: script = ''' $path = '%(path)s' $buffer_size = %(buffer_size)d $offset = %(offset)d $stream = New-Object -TypeName IO.FileStream($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) $stream.Seek($offset, [System.IO.SeekOrigin]::Begin) > $null $buffer = New-Object -TypeName byte[] $buffer_size $bytes_read = $stream.Read($buffer, 0, $buffer_size) if ($bytes_read -gt 0) { $bytes = $buffer[0..($bytes_read - 1)] [System.Convert]::ToBase64String($bytes) } $stream.Close() > $null ''' % dict(buffer_size=buffer_size, path=remote_path, offset=offset) script = '\n'.join([x.strip() for x in script.splitlines() if x.strip()]) encoded_ps = base64.b64encode(script.encode('utf_16_le')).decode('utf-8') shell_id = self.__rdc.protocol.open_shell(codepage=65001) data = None try: command_id = self.__rdc.protocol.run_command(shell_id, 'PowerShell', ('-EncodedCommand', encoded_ps)) resptuple = self.__rdc.protocol.get_command_output(shell_id, command_id) result = Response(tuple(v.decode('utf-8', 'replace') if isinstance(v, bytes) else v for v in resptuple)) if result.status_code != 0: raise Exception(result.std_err) data = base64.b64decode(result.std_out.strip()) self.__rdc.protocol.cleanup_command(shell_id, command_id) self.__rdc.protocol.close_shell(shell_id) except Exception as e: log.exception(e) log.error(f'Error occurred when downloading to `{local_path}`.') err = 1 if command_id: self.__rdc.protocol.cleanup_command(shell_id, command_id) self.__rdc.protocol.close_shell(shell_id) else: if data is None: break else: if not out_file: out_file = open(local_path, 'wb') out_file.write(data) if len(data) < buffer_size: break offset += len(data) log.debug('%d bytes done.' % offset) except Exception as e: log.exception(e) log.error(f'Error occurred when downloading to `{local_path}`.') err = 1 if command_id: try: self.__rdc.protocol.cleanup_command(shell_id, command_id) except: pass self.__rdc.protocol.close_shell(shell_id) break finally: if out_file: out_file.close() return not err
调用示例
params = { 'hostname': '', # 远程服务器IP 'username': '', # 远程登录用户名 'password': '' # 远程登录密码 } remote = WinRmConnection(**params) remote.download(r'e:\temp\1.txt', r'd:\work') remote.close()
速度慢的核心原因
- 低效的请求模式:当前代码每次读取0.5MB数据都要新建并销毁WinRM Shell,Shell的创建、销毁会带来巨大的网络开销和服务端资源消耗,这是速度瓶颈的主要来源。
- 数据编码损耗:文件字节流需在服务端Base64编码后传输,本地再解码,Base64会让数据体积膨胀33%,增加传输量和处理时间。
- WinRM协议局限性:WinRM是基于HTTP的管理协议,设计初衷是执行命令而非高效传输大文件,天生比RDP的文件传输协议效率低。
- 缓冲区过小:默认0.5MB的缓冲区导致请求次数过多,放大了协议本身的开销。
对应的解决办法
1. 复用WinRM Shell,减少交互开销
不要每次读取都创建新Shell,而是在下载前打开一个Shell,所有分块读取复用该Shell,完成后再关闭。修改_do_get_file方法:
def _do_get_file(self, remote_path, local_path, buffer_size=2**22): # 调整为4MB缓冲区 out_file = None err = 0 shell_id = None try: # 提前打开Shell,复用整个下载过程 shell_id = self.__rdc.protocol.open_shell(codepage=65001) offset = 0 while True: command_id = None try: script = ''' $path = '%(path)s' $buffer_size = %(buffer_size)d $offset = %(offset)d $stream = New-Object -TypeName IO.FileStream($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) $stream.Seek($offset, [System.IO.SeekOrigin]::Begin) > $null $buffer = New-Object -TypeName byte[] $buffer_size $bytes_read = $stream.Read($buffer, 0, $buffer_size) if ($bytes_read -gt 0) { $bytes = $buffer[0..($bytes_read - 1)] [System.Convert]::ToBase64String($bytes) } $stream.Close() > $null ''' % dict(buffer_size=buffer_size, path=remote_path, offset=offset) script = '\n'.join([x.strip() for x in script.splitlines() if x.strip()]) encoded_ps = base64.b64encode(script.encode('utf_16_le')).decode('utf-8') command_id = self.__rdc.protocol.run_command(shell_id, 'PowerShell', ('-EncodedCommand', encoded_ps)) resptuple = self.__rdc.protocol.get_command_output(shell_id, command_id) result = Response(tuple(v.decode('utf-8', 'replace') if isinstance(v, bytes) else v for v in resptuple)) if result.status_code != 0: raise Exception(result.std_err) data = base64.b64decode(result.std_out.strip()) self.__rdc.protocol.cleanup_command(shell_id, command_id) if not data: break if not out_file: out_file = open(local_path, 'wb') out_file.write(data) if len(data) < buffer_size: break offset += len(data) log.debug('%d bytes done.' % offset) except Exception as e: log.exception(e) log.error(f'Error occurred when downloading to `{local_path}`.') err = 1 if command_id: try: self.__rdc.protocol.cleanup_command(shell_id, command_id) except: pass break finally: if shell_id: try: self.__rdc.protocol.close_shell(shell_id) except: pass if out_file: out_file.close() return not err
2. 增大缓冲区并调整WinRM配置
将默认缓冲区从0.5MB调整为4MB或更大(如8MB),同时修改WinRM的MaxEnvelopeSizekb配置,确保其大于缓冲区大小:
# 服务端执行,提升最大 envelope 大小至4096KB(4MB) Set-Item WSMan:\localhost\MaxEnvelopeSizekb 4096 Restart-Service WinRM
3. 切换更高效的传输方式
- SMB协议:使用
smbclient或pysmb库直接访问Windows共享文件夹,SMB是专门为文件传输设计的协议,效率远高于WinRM。 - 启用CredSSP认证:CredSSP比NTLM认证更高效,支持更大数据传输。服务端和客户端都需启用:
代码中修改# 服务端启用CredSSP Enable-WSManCredSSP -Role Server -Force # 客户端(运行Python的机器)启用CredSSP Enable-WSManCredSSP -Role Client -DelegateComputer "远程服务器IP或主机名" -Forcetransport参数为credssp:self.transport = kwargs.get('transport', 'credssp')
4. 批量处理多文件下载
对于多文件场景,一次性获取所有文件列表后批量处理,避免递归调用时频繁执行WinRM命令,减少交互次数。
内容的提问来源于stack exchange,提问作者vassiliev
相关产品推荐
相关产品推荐

