You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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()

速度慢的核心原因

  1. 低效的请求模式:当前代码每次读取0.5MB数据都要新建并销毁WinRM Shell,Shell的创建、销毁会带来巨大的网络开销和服务端资源消耗,这是速度瓶颈的主要来源。
  2. 数据编码损耗:文件字节流需在服务端Base64编码后传输,本地再解码,Base64会让数据体积膨胀33%,增加传输量和处理时间。
  3. WinRM协议局限性:WinRM是基于HTTP的管理协议,设计初衷是执行命令而非高效传输大文件,天生比RDP的文件传输协议效率低。
  4. 缓冲区过小:默认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或主机名" -Force
    
    代码中修改transport参数为credssp:
    self.transport = kwargs.get('transport', 'credssp')
    

4. 批量处理多文件下载

对于多文件场景,一次性获取所有文件列表后批量处理,避免递归调用时频繁执行WinRM命令,减少交互次数。


内容的提问来源于stack exchange,提问作者vassiliev

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 07:08:09