深夜收到某同事的消息,说他负责的那批Linux服务器要更新一个公共配置文件,三十多台机器,他正开着图形工具一台一台拖。我回了一句:你手动拖半小时,不如写十行代码两分钟搞定。第二天他就开始在.NET Core里写命令行工具,走SSH通道把文件推上远程服务器。今天这篇就把这条链路完整拆开——用Renci.SshNet连接SSH,把本地文件传输到远程Linux服务器。这不是什么新东西,但真正上手之后你会发现,能跑通demo和能扛住生产完全是两码事。连接认证、路径约定、超时重试、并发复用,每一个细节都在等着坑你。这篇文章适合两类人:一类是想把文件上传做成自动化能力的.NET开发者,另一类是已经写过几版上传代码但总在奇怪地方翻车的朋友。我会先讲清楚底层协议选型,再贴完整可运行的代码,最后把我实际踩过的四个坑连排查过程一起给你,每一段都值得抄作业。1. 先搞清楚SCP与SFTP的区别,否则API选错白折腾1.1 加密通道下的两种“协议皮肤”很多初学者看到Renci.SshNet这个库,发现里面同时有SftpClient和ScpClient两个类,第一反应是随便挑一个用。这俩确实都走SSH加密通道,但工作方式有本质差异。SCP的全称是Secure Copy Protocol,它的思路特别朴素:基于SSH通道执行一个远程拷贝命令,把本地文件通过加密流推给远端,远端再由shell把它写到指定路径。整个过程就像你在Linux终端里敲scp命令一样,不需要额外的服务端进程,只要SSHD在跑就行。但它本质上是一次性动作,不支持列目录、删文件、改权限这些文件管理操作,也不支持断点续传。SFTP是SSH File Transfer Protocol,虽然名字里带FTP,但它和传统FTP完全不是一个东西。它走的是SSH内部独立的文件传输子系统,服务端专门处理文件操作请求,客户端可以像操作本地目录一样操作远端:列目录、建目录、改名、删除、改权限、断点续传,全都支持。1.2 一个简单的类比:快递车和网盘客户端我用一个生活化的类比帮你建立直观感受。SCP像一辆快递车,它把包裹从A点搬到B点,签收完就结束了。你问它仓库里还有哪些货、能不能把货架重新摆一下、某个包裹号是多少——它一概不知。SFTP像一个正经的网盘客户端,你打开它能看到远端目录树,能新建文件夹,能上传下载,能删旧文件,能看文件大小和修改时间,传大文件传一半断了还能续传。这个区别直接决定了你用哪个API。如果目标只是“把文件推上去,覆盖同名文件,没有花活”,SCP完全够用,代码还更简单。但凡是涉及到多目录上传、按目录增量同步、上传前检查磁盘空间、上传后校正权限这些场景,老老实实用SFTP。1.3 Renci.SshNet的能力清单与适用边界Renci.SshNet(NuGet包名是SSH.NET)是.NET生态里最常用的SSH协议库,不依赖原生系统库,纯托管代码实现。它覆盖的能力比你想的多:执行远程Shell命令,并读取标准输出和错误流SCP/SFTP文件上传下载远程端口转发和动态端口转发密码、私钥、键盘交互等多种认证方式支持.NET Standard 2.0,网上各种.NET Core版本基本都能用这些能力决定了它的典型应用边界:自动化部署工具推送发布包、定时任务备份数据到远程存储、配置分发系统下发配置文件、日志采集器上传日志文件。你如果看到有项目在Windows服务里跑,把生成的文件往Linux服务器上推,八成就是它。2. 连接Linux服务器:三种认证方式与最少可用代码2.1 NuGet包与.NET Core版本兼容先解决包的问题。在Visual Studio的NuGet管理器里搜SSH.NET,或者用命令行直接装:dotnet add package SSH.NET包名和命名空间名不一致很常见,命名空间是Renci.SshNet,包名却是SSH.NET,好多人找半天。这个库支持.NET Standard 2.0,所以从.NET Core 2.1到最新的.NET 8、.NET 9理论上都能跑,跨平台部署在Windows和Linux上也都问题不大。在选择版本时,我建议直接跟最新稳定版。这个库早年间出过几个安全相关的修复版本,你用老版本虽然功能一样,但在资产审计时会被标红。既然是内部工具,没必要给自己留这种运维隐患。2.2 密码认证、密钥认证与多认证组合建立连接的本质是完成一次SSH握手和认证。最直白的方式就是用户名加密码,SftpClient构造函数一把梭:using Renci.SshNet; // 最简单的密码认证模式 using var client = new SftpClient("192.168.1.100", 22, "deploy", "YourPassword123"); client.Connect();注意第22这个端口参数,好多人默认服务器就是22就漏掉不传,结果对方换过端口就连不上。生产服务器出于安全考虑改SSH端口很常见,端口一定要显式传。如果服务器只允许密钥登录,就改用PrivateKeyAuthenticationMethod。私钥文件可能是OpenSSH格式,也可能是PuTTY生成的ppk,后者需要先转换成OpenSSH格式才能用:using Renci.SshNet; using Renci.SshNet.Common; var keyFile = new PrivateKeyFile("C:\\keys\\id_rsa", "私钥密码如果有的话"); var authMethod = new PrivateKeyAuthenticationMethod("deploy", keyFile); var connectionInfo = new ConnectionInfo("192.168.1.100", 22, "deploy", authMethod); using var client = new SftpClient(connectionInfo); client.Connect();还有一种场景是服务器配了双因子认证或需要键盘交互响应,这时候可以同时传多个认证方法,SSH.NET会逐个尝试:var passwordAuth = new PasswordAuthenticationMethod("deploy", "YourPassword123"); var keyAuth = new PrivateKeyAuthenticationMethod("deploy", keyFile); var connectionInfo = new ConnectionInfo("192.168.1.100", 22, "deploy", passwordAuth, keyAuth); using var client = new SftpClient(connectionInfo); client.Connect();这段代码在生产环境特别实用。有些服务器要求密码加密钥双重验证,你只传一个认证方法上去怎么都连不通,加上两个后SSH协议栈会自动按序协商。2.3 验证连接成功的标志:IsConnected不够用连接完立刻打印client.IsConnected,返回true就以为万事大吉了。这里有个隐坑:IsConnected只代表TCP连接和SSH握手已完成,并不代表远程目录可写、磁盘空间充足、路径绝对有效。我建议连接成功之后顺手做一个探活操作,比如拿当前目录属性:client.Connect(); var currentDir = client.GetAttributes(".").Permissions; Console.WriteLine($"连接成功,当前目录可访问,权限标记:{currentDir}");探活的作用是把错误暴露在第一时间。很多自动化任务宁可启动时立刻失败,也不愿跑到一半才发现连不上,后者排查成本高得多。3. 文件上传主流程:从打开本地流到确认远端落盘3.1 SftpClient上传完整代码与参数逐行拆解核心上传代码其实短得让人意外:using Renci.SshNet; using Renci.SshNet.Sftp; public void UploadFile(string localPath, string remotePath) { using var client = new SftpClient("192.168.1.100", 22, "deploy", "YourPassword123"); client.ConnectionTimeout = TimeSpan.FromSeconds(60); client.OperationTimeout = TimeSpan.FromSeconds(60); client.Connect(); using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath); client.Disconnect(); }就这么几行,文件就上去了。但每一处都有讲究。ConnectionTimeout是建立TCP连接和SSH握手的超时时间,如果你的服务器在内网,默认值通常够用;走公网且链路差时,建议显式调到30秒以上,不然第一个握手包卡一下就抛超时。OperationTimeout是每次SFTP操作(打开会话、写入数据块、刷新属性)的超时时间,这个尤其影响大文件上传,后面我会单独讲。File.OpenRead是只读模式,比new FileStream更安全,误传读写碰撞的风险小。上传成功后,UploadFile方法正常返回,这时远端文件已经落盘,不需要额外调用保存一类的动作。3.2 目录不存在怎么办:递归创建远程目录新的坑马上出现:本地文件有目录结构,但远端这个目录根本不存在。比如你要把backup-20240715.zip上传到/opt/backups/daily/,服务器的/opt/backups/daily/还没建。直接UploadFile会抛SftpPathNotFoundException,文件根本写不进去。解决方式是先判断远端目录是否存在,不存在就一级一级创建。注意,SftpClient没有像File类那样的Directory.CreateDirectory自动建多级目录的方法,CreateDirectory一次只能建一级:private static void EnsureRemoteDirectory(SftpClient client, string remotePath) { if (string.IsNullOrEmpty(remotePath)) { return; } // 去掉开头的 /,再按 / 切分为多级 var pathParts = remotePath.TrimStart('/').Split('/'); var currentPath = string.Empty; foreach (var part in pathParts) { if (string.IsNullOrEmpty(part)) { continue; } currentPath += "/" + part; if (!client.Exists(currentPath)) { client.CreateDirectory(currentPath); } } }调用的时候先建目录再传文件:EnsureRemoteDirectory(client, "/opt/backups/daily/"); using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, "/opt/backups/daily/backup.zip");这里再补一句远程路径的约定:远端路径一律用正斜杠,不要用Windows风格的反斜杠。很多从Windows写代码的同事习惯直接拿Path.Combine生成的路径往远端传,传出来一个“\opt\backups\daily”的奇怪路径,服务器根本认不出来。我后面还会细讲这个坑。3.3 上传结果怎么确认:从返回值和重传机制说起UploadFile方法本身没有bool返回值。它正常返回,代表SFTP协议层已经确认写入了全部数据;它抛异常,代表中途失败了。所以判断成功的最简单方式就是:没抛异常就是成功。但生产环境不能这么乐观。网络传输中偶发的数据块损坏、远端磁盘满导致的隐式写入失败、服务端SFTP子系统异常退出,都可能让文件“传完了但不对”。最稳妥的校验方式是上传完成后对比文件大小:using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath); var remoteAttributes = client.GetAttributes(remotePath); var localSize = new FileInfo(localPath).Length; if (remoteAttributes.Size == localSize) { Console.WriteLine("文件大小校验通过"); } else { Console.WriteLine($"大小不一致,本地 {localSize},远端 {remoteAttributes.Size}"); }如果你的业务对数据完整性要求更高,可以在上传前计算本地文件MD5或SHA256,上传后在远端执行命令计算Hash对比。不过这个开销比较大,普通场景下做文件大小校验已经能拦截掉绝大多数异常。4. 生产环境中我踩过的四个坑:排查链路与最终解法4.1 坑一:上传大文件到一半连接被掐断,根因在超时窗口第一次踩这个坑是在某生产系统同步一个800多MB的日志包。小文件传得毫无压力,换成大文件后,每次都是传到百分之六七十就抛异常,日志记录的是“操作超时或网络连接已断开”。排查过程从三层展开。第一层看了眼服务器端SSHD日志,发现服务端侧连接被正常关闭,说明不是服务器主动拒绝。第二层用抓包工具观察了一段时间,发现TCP层偶尔有长时间静默期,某个数据包发出后要等十几秒才有ACK回来。第三层回来看代码,发现SftpClient默认的OperationTimeout并不适合大文件长时间写入。根因是OperationTimeout是单次底层操作的超时,不是整个上传过程的总超时。大文件传输过程中网络质量波动导致数据块写入等了一会儿,单次等待超过了超时阈值,库就判定本次操作失败并把连接断掉了。解法很简单,把超时调大,并加重试:client.ConnectionTimeout = TimeSpan.FromSeconds(60); client.OperationTimeout = TimeSpan.FromMinutes(10);然后把“上传”这个动作包到一个带重试的方法里,失败后等待几秒再重试:public static void UploadWithRetry(SftpClient client, string localPath, string remotePath, int maxRetry = 3) { for (var attempt = 1; attempt <= maxRetry; attempt++) { try { using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath); Console.WriteLine("上传成功"); return; } catch (Exception ex) when (attempt < maxRetry) { Console.WriteLine($"第 {attempt} 次上传失败:{ex.Message},等待重试..."); Thread.Sleep(TimeSpan.FromSeconds(attempt * 5)); } } }重试之间等待时间的递增是有讲究的,按1倍、2倍、3倍的节奏递增,给网络抖动留出恢复窗口,而不是疯狂重试把链路挤爆。这个坑最后总结的经验是:大文件传输的代码里,超时这个概念的设置顺序,永远要把OperationTimeout放在最优先考虑的位置。4.2 坑二:主机密钥校验失败,自动化任务直接挂掉第二个坑更隐蔽。某天凌晨,定时任务弹出一堆异常,全部指向主机密钥验证失败。我第一反应是服务器被换了,排查后发现根本不是——是那台服务器重装过系统,SSH主机密钥重新生成了,而客户端这边还留着第一次连接时记住的旧指纹。SSH协议里有个机制叫主机密钥校验,目的是防止中间人攻击。客户端连接时会把服务器的主机密钥指纹和本地信任列表比对。Renci.SshNet的默认行为比较固定,如果没显式设置HostKeyCallback,遇到未知主机密钥会抛异常。自动化任务和交互式命令行不同,没有弹窗让你确认“这个指纹可信吗”。所以我在这类程序里都会统一设定一个指纹白名单校验策略。第一次可以手工记录指纹,后续程序只信任这个指纹:client.HostKeyCallback = (sender, e) => { var expectedFingerprint = "SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; var actualFingerprint = Convert.ToHexString(e.FingerPrint); e.CanTrust = actualFingerprint.Equals(expectedFingerprint, StringComparison.OrdinalIgnoreCase); };这里多说一句,别一上来就写e.CanTrust = true。如果你在内网测试环境这么干没毛病,但如果是公网链路传输业务数据,直接把主机密钥校验关闭等于把中间人攻击的大门敞开。脚本小子拦在你和服务器之间改包,上传的文件内容被替换成可执行恶意文件,这个风险是要命的。正确做法是先把指纹抓出来,做成白名单,再让程序自动判断。4.3 坑三:Windows路径分隔符和中文文件名引发的“找不到文件”这个坑的典型场景是:程序在Windows上跑,远端路径拼接时顺手用了Path.Combine。Path.Combine在Windows上生成的路径是C:\Users\xxx\file.txt这种反斜杠风格。如果这个路径是用在本地File.OpenRead上,完全没问题。问题是很多人顺手也把这种路径格式塞给了remotePath参数。远端路径必须用正斜杠。你把“\opt\backups\file.zip”传给SftpClient,它会把整个字符串当作一个奇怪的相对路径去找,结果自然是找不到。这个问题的排查过程其实特别蠢:本地文件明明存在,远端目录也确认存在,但SftpPathNotFoundException就是不停冒出来。最后把remotePath打印出来才发现,里面全是反斜杠。解法:远端路径统一用字符串常量或正斜杠拼接。如果确实拿到了一个Windows风格路径,做一次替换:var safeRemotePath = remotePath.Replace('\\', '/');顺带聊一下中文文件名。SSH.NET的SFTP通道默认按UTF-8编码处理文件名,但远端的Linux服务器如果locale不是UTF-8(有些老系统是POSIX或GBK),中文文件名就会变成乱码,甚至操作失败。我在生产环境里的经验是:自动化传输链路上的文件名保持ASCII风格,用日期加业务标识命名,比如app_20250115_v3.zip。这既是传输层面的省心,也是运维审计层面的规范。如果你确实要传中文文件名,先把两端的编码一致性测透再上生产。4.4 坑四:多任务并发复用同一个连接,抛出一堆异常有一次项目里接了个需求,要同时把几十个文件分发给多台服务器。我图方便,初始化了一个SftpClient实例,灌进线程池里,让二十个任务同时调用UploadFile。结果一跑起来,异常一个接一个,全是连接已被占用、句柄无效之类的错误。排查思路一开始还以为是服务器连接数限制,后来看了错误堆栈才发现是SftpClient内部维护了会话状态,同一个连接同时处理多个请求时,底层的SFTP请求编号互相串了,直接导致协议错乱。简单来说,这个库的单实例不是线程安全的。解法是:每个任务使用独立的SftpClient实例。每个实例发起自己的TCP连接和SSH会话,各干各的,天然隔离:Parallel.ForEach(files, file => { using var client = new SftpClient(host, port, username, password); client.Connect(); using var stream = File.OpenRead(file.LocalPath); client.UploadFile(stream, file.RemotePath); client.Disconnect(); });每个任务都新建连接,代价是握手开销变高了,但换来了线程安全和隔离性。如果你对性能有更高要求,可以维护一个连接池,按需取空闲连接,用完归还,而不是每次都新建。连接池实现我放在下一章讲。5. 工程化封装:带进度、带重试、支持目录递归的上传服务5.1 进度回调用法:边传边刷新进度条SSH.NET的UploadFile方法有个重载,第三个参数接收一个Action<ulong>委托,ulong参数表示当前已经上传的字节数。这个设计用来做进度回显非常顺手。var totalBytes = new FileInfo(localPath).Length; using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath, uploadedBytes => { var percent = totalBytes == 0 ? 100 : (uploadedBytes * 100.0 / totalBytes); Console.Write($"\r上传进度:{percent:F2}%"); });注意回调不是每个字节都触发,而是按底层数据块的写入节奏回调,所以进度条变化看起来是一格一格跳的,这很正常。另一个细节是,回调里别写日志文件或用Console.WriteLine(带换行那种),否则大量回调会把控制台刷破,同时也影响传输性能。用\r回车符原地刷新就好。如果你在做图形界面程序,这个回调正好可以对接你的进度条控件,记得用控件自己的线程调度,别直接跨线程更新UI。5.2 递归上传目录:遍历本地目录树并映射到远端实际业务中,单文件上传跑起来之后,下一步一定是“一个目录递归传上去”。实现思路不复杂:本地用Directory.GetFiles和Directory.GetDirectories递归遍历,远端每进入一个子目录就调用EnsureRemoteDirectory建目录,然后逐个上传。核心是把本地路径映射成远端相对路径时把根目录前缀摘掉:public static void UploadDirectory(SftpClient client, string localRoot, string remoteRoot) { EnsureRemoteDirectory(client, remoteRoot); foreach (var file in Directory.GetFiles(localRoot)) { var fileName = Path.GetFileName(file); using var stream = File.OpenRead(file); client.UploadFile(stream, $"{remoteRoot}/{fileName}"); } foreach (var directory in Directory.GetDirectories(localRoot)) { var dirName = Path.GetFileName(directory); UploadDirectory(client, directory, $"{remoteRoot}/{dirName}"); } }递归函数里第一步永远是建目录,保证后续文件一定有所属位置。本地文件名用Path.GetFileName取出来的是纯文件名,不含目录结构,拼接远端路径时直接用正斜杠加文件名即可,不会带出本地分隔符。这个封装最大的价值在于一键同步整个发布目录。发布包、静态资源、配置文件树,本地是什么结构远端就是什么结构,省掉一层一层手建目录的重复劳动。5.3 重试策略与日志规范:让传输任务可以无人值守自动化任务最怕的不是失败,而是失败后没有任何反馈,第二天你才发现昨晚的任务全挂了。所以上传服务一定要有完整的日志记录。我习惯给每次上传任务打三个关键日志点位:任务开始、连接成功、文件上传完成。异常时输出异常类型、堆栈和当前重试次数。加上前面的重试机制,就组成了一个可以无人值守的传输模块。日志用结构化格式:Console.WriteLine("[{0}] 开始上传任务,目标:{1},本地:{2}", DateTime.Now, remotePath, localPath);如果有条件,写到ILogger或NLog里,配合文件大小校验结果一起记录,排查问题时会非常快。生产事故多数发生在凌晨,日志字段越规范,第二天定位根因越省事。5.4 连接复用与线程安全:连接池的简单实现思路前文提到,SftpClient实例不能多线程共用,但每次都新建连接又会有握手开销。折中方案是做一个轻量连接池。核心逻辑三层:取连接时从池里弹出一个空闲连接,检查IsConnected确保它没断;用完再还回去;如果池为空就新建。public class SftpConnectionPool { private readonly ConcurrentBag<SftpClient> _pool = new(); private string _host; private int _port; private string _username; private string _password; public SftpConnectionPool(string host, int port, string username, string password) { _host = host; _port = port; _username = username; _password = password; } public SftpClient Get() { if (_pool.TryTake(out var client) && client.IsConnected) { return client; } var newClient = new SftpClient(_host, _port, _username, _password); newClient.Connect(); return newClient; } public void Return(SftpClient client) { if (client.IsConnected) { _pool.Add(client); } else { client.Dispose(); } } }使用示例:var pool = new SftpConnectionPool(host, port, username, password); Parallel.ForEach(fileList, file => { var client = pool.Get(); try { using var stream = File.OpenRead(file.LocalPath); client.UploadFile(stream, file.RemotePath); } finally { pool.Return(client); } });这个池子的实现只适用于并发量不算极端的场景。连接数量本身受限于服务器端MaxSessions配置,所以别开上百个并发连接,池子大小控制在10左右就好。你当然可以扩展连接数上限和空闲清理机制,但做自动化文件分发,这个轻量版本已经足够。5.5 完整的上传服务模板把前面所有片段拼成一个可直接抄作业的上传服务类,整体结构大概长这样:public class SftpUploader { private readonly string _host; private readonly int _port; private readonly string _username; private readonly string _password; public SftpUploader(string host, int port, string username, string password) { _host = host; _port = port; _username = username; _password = password; } public void UploadFileWithCheck(string localPath, string remotePath) { using var client = CreateClient(); client.Connect(); EnsureRemoteDirectory(client, GetParentPath(remotePath)); using var stream = File.OpenRead(localPath); client.UploadFile(stream, remotePath); var remoteSize = client.GetAttributes(remotePath).Size; var localSize = new FileInfo(localPath).Length; if (remoteSize != localSize) { throw new InvalidOperationException($"文件大小校验失败,本地 {localSize},远端 {remoteSize}"); } client.Disconnect(); } public void UploadDirectory(string localRoot, string remoteRoot) { using var client = CreateClient(); client.Connect(); UploadDirectoryRecursive(client, localRoot, remoteRoot); client.Disconnect(); } private SftpClient CreateClient() { var client = new SftpClient(_host, _port, _username, _password) { ConnectionTimeout = TimeSpan.FromSeconds(60), OperationTimeout = TimeSpan.FromMinutes(10) }; // 生产环境务必替换为指纹白名单校验 client.HostKeyCallback = (sender, e) => e.CanTrust = true; return client; } }这个模板做对了三件事:目录自动创建、文件大小校验、超时参数显式化。拿它当起点,按自己的安全要求替换指纹校验,就可以直接接进你的部署工具或定时任务里。说回开头那个深夜电话。他后来把上传服务封装成独立模块,其他系统只要传本地路径和远端路径进来就完事,SSH细节完全不用关心。三十台服务器的配置分发,从手动半个小时缩到了自动三分钟,而且再没出现过漏发错发的情况。我的实际体会是:选对库只是一层皮,真正决定传输模块稳不稳定的,是对超时、重试、路径规范、并发模型这些边角料的处理。如果你接下来也要做类似的事,建议先把第三章到第五章里的坑埋进设计文档,能省下好几个通宵。
深夜收到某同事的消息,说他负责的那批Linux服务器要更新一个公共配置文件,三十多台机器,他正开着图形工具一台一台拖。我回了一句:你手动拖半小时,不如写十行代码两分钟搞定。第二天他就开始在.NET Core里写命令行工具,走SSH通道把文件推上远程服务器。今天这篇就把这条链路完整拆开——用Renci.SshNet连接SSH,把本地文件传输到远程Linux服务器。这不是什么新东西,但真正上手之后你会发现,能跑通demo和能扛住生产完全是两码事。连接认证、路径约定、超时重试、并发复用,每一个细节都在等着坑你。这篇文章适合两类人:一类是想把文件上传做成自动化能力的.NET开发者,另一类是已经写过几版上传代码但总在奇怪地方翻车的朋友。我会先讲清楚底层协议选型,再贴完整可运行的代码,最后把我实际踩过的四个坑连排查过程一起给你,每一段都值得抄作业。1. 先搞清楚SCP与SFTP的区别,否则API选错白折腾1.1 加密通道下的两种“协议皮肤”很多初学者看到Renci.SshNet这个库,发现里面同时有SftpClient和ScpClient两个类,第一反应是随便挑一个用。这俩确实都走SSH加密通道,但工作方式有本质差异。SCP的全称是Secure Copy Protocol,它的思路特别朴素:基于SSH通道执行一个远程拷贝命令,把本地文件通过加密流推给远端,远端再由shell把它写到指定路径。整个过程就像你在Linux终端里敲scp命令一样,不需要额外的服务端进程,只要SSHD在跑就行。但它本质上是一次性动作,不支持列目录、删文件、改权限这些文件管理操作,也不支持断点续传。SFTP是SSH File Transfer Protocol,虽然名字里带FTP,但它和传统FTP完全不是一个东西。它走的是SSH内部独立的文件传输子系统,服务端专门处理文件操作请求,客户端可以像操作本地目录一样操作远端:列目录、建目录、改名、删除、改权限、断点续传,全都支持。1.2 一个简单的类比:快递车和网盘客户端我用一个生活化的类比帮你建立直观感受。SCP像一辆快递车,它把包裹从A点搬到B点,签收完就结束了。你问它仓库里还有哪些货、能不能把货架重新摆一下、某个包裹号是多少——它一概不知。SFTP像一个正经的网盘客户端,你打开它能看到远端目录树,能新建文件夹,能上传下载,能删旧文件,能看文件大小和修改时间,传大文件传一半断了还能续传。这个区别直接决定了你用哪个API。如果目标只是“把文件推上去,覆盖同名文件,没有花活”,SCP完全够用,代码还更简单。但凡是涉及到多目录上传、按目录增量同步、上传前检查磁盘空间、上传后校正权限这些场景,老老实实用SFTP。1.3 Renci.SshNet的能力清单与适用边界Renci.SshNet(NuGet包名是SSH.NET)是.NET生态里最常用的SSH协议库,不依赖原生系统库,纯托管代码实现。它覆盖的能力比你想的多:执行远程Shell命令,并读取标准输出和错误流SCP/SFTP文件上传下载远程端口转发和动态端口转发密码、私钥、键盘交互等多种认证方式支持.NET Standard 2.0,网上各种.NET Core版本基本都能用这些能力决定了它的典型应用边界:自动化部署工具推送发布包、定时任务备份数据到远程存储、配置分发系统下发配置文件、日志采集器上传日志文件。你如果看到有项目在Windows服务里跑,把生成的文件往Linux服务器上推,八成就是它。2. 连接Linux服务器:三种认证方式与最少可用代码2.1 NuGet包与.NET Core版本兼容先解决包的问题。在Visual Studio的NuGet管理器里搜SSH.NET,或者用命令行直接装:dotnet add package SSH.NET包名和命名空间名不一致很常见,命名空间是Renci.SshNet,包名却是SSH.NET,好多人找半天。这个库支持.NET Standard 2.0,所以从.NET Core 2.1到最新的.NET 8、.NET 9理论上都能跑,跨平台部署在Windows和Linux上也都问题不大。在选择版本时,我建议直接跟最新稳定版。这个库早年间出过几个安全相关的修复版本,你用老版本虽然功能一样,但在资产审计时会被标红。既然是内部工具,没必要给自己留这种运维隐患。2.2 密码认证、密钥认证与多认证组合建立连接的本质是完成一次SSH握手和认证。最直白的方式就是用户名加密码,SftpClient构造函数一把梭:using Renci.SshNet; // 最简单的密码认证模式 using var client = new SftpClient("192.168.1.100", 22, "deploy", "YourPassword123"); client.Connect();注意第22这个端口参数,好多人默认服务器就是22就漏掉不传,结果对方换过端口就连不上。生产服务器出于安全考虑改SSH端口很常见,端口一定要显式传。如果服务器只允许密钥登录,就改用PrivateKeyAuthenticationMethod。私钥文件可能是OpenSSH格式,也可能是PuTTY生成的ppk,后者需要先转换成OpenSSH格式才能用:using Renci.SshNet; using Renci.SshNet.Common; var keyFile = new PrivateKeyFile("C:\\keys\\id_rsa", "私钥密码如果有的话"); var authMethod = new PrivateKeyAuthenticationMethod("deploy", keyFile); var connectionInfo = new ConnectionInfo("192.168.1.100", 22, "deploy", authMethod); using var client = new SftpClient(connectionInfo); client.Connect();还有一种场景是服务器配了双因子认证或需要键盘交互响应,这时候可以同时传多个认证方法,SSH.NET会逐个尝试:var passwordAuth = new PasswordAuthenticationMethod("deploy", "YourPassword123"); var keyAuth = new PrivateKeyAuthenticationMethod("deploy", keyFile); var connectionInfo = new ConnectionInfo("192.168.1.100", 22, "deploy", passwordAuth, keyAuth); using var client = new SftpClient(connectionInfo); client.Connect();这段代码在生产环境特别实用。有些服务器要求密码加密钥双重验证,你只传一个认证方法上去怎么都连不通,加上两个后SSH协议栈会自动按序协商。2.3 验证连接成功的标志:IsConnected不够用连接完立刻打印client.IsConnected,返回true就以为万事大吉了。这里有个隐坑:IsConnected只代表TCP连接和SSH握手已完成,并不代表远程目录可写、磁盘空间充足、路径绝对有效。我建议连接成功之后顺手做一个探活操作,比如拿当前目录属性:client.Connect(); var currentDir = client.GetAttributes(".").Permissions; Console.WriteLine($"连接成功,当前目录可访问,权限标记:{currentDir}");探活的作用是把错误暴露在第一时间。很多自动化任务宁可启动时立刻失败,也不愿跑到一半才发现连不上,后者排查成本高得多。3. 文件上传主流程:从打开本地流到确认远端落盘3.1 SftpClient上传完整代码与参数逐行拆解核心上传代码其实短得让人意外:using Renci.SshNet; using Renci.SshNet.Sftp; public void UploadFile(string localPath, string remotePath) { using var client = new SftpClient("192.168.1.100", 22, "deploy", "YourPassword123"); client.ConnectionTimeout = TimeSpan.FromSeconds(60); client.OperationTimeout = TimeSpan.FromSeconds(60); client.Connect(); using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath); client.Disconnect(); }就这么几行,文件就上去了。但每一处都有讲究。ConnectionTimeout是建立TCP连接和SSH握手的超时时间,如果你的服务器在内网,默认值通常够用;走公网且链路差时,建议显式调到30秒以上,不然第一个握手包卡一下就抛超时。OperationTimeout是每次SFTP操作(打开会话、写入数据块、刷新属性)的超时时间,这个尤其影响大文件上传,后面我会单独讲。File.OpenRead是只读模式,比new FileStream更安全,误传读写碰撞的风险小。上传成功后,UploadFile方法正常返回,这时远端文件已经落盘,不需要额外调用保存一类的动作。3.2 目录不存在怎么办:递归创建远程目录新的坑马上出现:本地文件有目录结构,但远端这个目录根本不存在。比如你要把backup-20240715.zip上传到/opt/backups/daily/,服务器的/opt/backups/daily/还没建。直接UploadFile会抛SftpPathNotFoundException,文件根本写不进去。解决方式是先判断远端目录是否存在,不存在就一级一级创建。注意,SftpClient没有像File类那样的Directory.CreateDirectory自动建多级目录的方法,CreateDirectory一次只能建一级:private static void EnsureRemoteDirectory(SftpClient client, string remotePath) { if (string.IsNullOrEmpty(remotePath)) { return; } // 去掉开头的 /,再按 / 切分为多级 var pathParts = remotePath.TrimStart('/').Split('/'); var currentPath = string.Empty; foreach (var part in pathParts) { if (string.IsNullOrEmpty(part)) { continue; } currentPath += "/" + part; if (!client.Exists(currentPath)) { client.CreateDirectory(currentPath); } } }调用的时候先建目录再传文件:EnsureRemoteDirectory(client, "/opt/backups/daily/"); using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, "/opt/backups/daily/backup.zip");这里再补一句远程路径的约定:远端路径一律用正斜杠,不要用Windows风格的反斜杠。很多从Windows写代码的同事习惯直接拿Path.Combine生成的路径往远端传,传出来一个“\opt\backups\daily”的奇怪路径,服务器根本认不出来。我后面还会细讲这个坑。3.3 上传结果怎么确认:从返回值和重传机制说起UploadFile方法本身没有bool返回值。它正常返回,代表SFTP协议层已经确认写入了全部数据;它抛异常,代表中途失败了。所以判断成功的最简单方式就是:没抛异常就是成功。但生产环境不能这么乐观。网络传输中偶发的数据块损坏、远端磁盘满导致的隐式写入失败、服务端SFTP子系统异常退出,都可能让文件“传完了但不对”。最稳妥的校验方式是上传完成后对比文件大小:using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath); var remoteAttributes = client.GetAttributes(remotePath); var localSize = new FileInfo(localPath).Length; if (remoteAttributes.Size == localSize) { Console.WriteLine("文件大小校验通过"); } else { Console.WriteLine($"大小不一致,本地 {localSize},远端 {remoteAttributes.Size}"); }如果你的业务对数据完整性要求更高,可以在上传前计算本地文件MD5或SHA256,上传后在远端执行命令计算Hash对比。不过这个开销比较大,普通场景下做文件大小校验已经能拦截掉绝大多数异常。4. 生产环境中我踩过的四个坑:排查链路与最终解法4.1 坑一:上传大文件到一半连接被掐断,根因在超时窗口第一次踩这个坑是在某生产系统同步一个800多MB的日志包。小文件传得毫无压力,换成大文件后,每次都是传到百分之六七十就抛异常,日志记录的是“操作超时或网络连接已断开”。排查过程从三层展开。第一层看了眼服务器端SSHD日志,发现服务端侧连接被正常关闭,说明不是服务器主动拒绝。第二层用抓包工具观察了一段时间,发现TCP层偶尔有长时间静默期,某个数据包发出后要等十几秒才有ACK回来。第三层回来看代码,发现SftpClient默认的OperationTimeout并不适合大文件长时间写入。根因是OperationTimeout是单次底层操作的超时,不是整个上传过程的总超时。大文件传输过程中网络质量波动导致数据块写入等了一会儿,单次等待超过了超时阈值,库就判定本次操作失败并把连接断掉了。解法很简单,把超时调大,并加重试:client.ConnectionTimeout = TimeSpan.FromSeconds(60); client.OperationTimeout = TimeSpan.FromMinutes(10);然后把“上传”这个动作包到一个带重试的方法里,失败后等待几秒再重试:public static void UploadWithRetry(SftpClient client, string localPath, string remotePath, int maxRetry = 3) { for (var attempt = 1; attempt <= maxRetry; attempt++) { try { using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath); Console.WriteLine("上传成功"); return; } catch (Exception ex) when (attempt < maxRetry) { Console.WriteLine($"第 {attempt} 次上传失败:{ex.Message},等待重试..."); Thread.Sleep(TimeSpan.FromSeconds(attempt * 5)); } } }重试之间等待时间的递增是有讲究的,按1倍、2倍、3倍的节奏递增,给网络抖动留出恢复窗口,而不是疯狂重试把链路挤爆。这个坑最后总结的经验是:大文件传输的代码里,超时这个概念的设置顺序,永远要把OperationTimeout放在最优先考虑的位置。4.2 坑二:主机密钥校验失败,自动化任务直接挂掉第二个坑更隐蔽。某天凌晨,定时任务弹出一堆异常,全部指向主机密钥验证失败。我第一反应是服务器被换了,排查后发现根本不是——是那台服务器重装过系统,SSH主机密钥重新生成了,而客户端这边还留着第一次连接时记住的旧指纹。SSH协议里有个机制叫主机密钥校验,目的是防止中间人攻击。客户端连接时会把服务器的主机密钥指纹和本地信任列表比对。Renci.SshNet的默认行为比较固定,如果没显式设置HostKeyCallback,遇到未知主机密钥会抛异常。自动化任务和交互式命令行不同,没有弹窗让你确认“这个指纹可信吗”。所以我在这类程序里都会统一设定一个指纹白名单校验策略。第一次可以手工记录指纹,后续程序只信任这个指纹:client.HostKeyCallback = (sender, e) => { var expectedFingerprint = "SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; var actualFingerprint = Convert.ToHexString(e.FingerPrint); e.CanTrust = actualFingerprint.Equals(expectedFingerprint, StringComparison.OrdinalIgnoreCase); };这里多说一句,别一上来就写e.CanTrust = true。如果你在内网测试环境这么干没毛病,但如果是公网链路传输业务数据,直接把主机密钥校验关闭等于把中间人攻击的大门敞开。脚本小子拦在你和服务器之间改包,上传的文件内容被替换成可执行恶意文件,这个风险是要命的。正确做法是先把指纹抓出来,做成白名单,再让程序自动判断。4.3 坑三:Windows路径分隔符和中文文件名引发的“找不到文件”这个坑的典型场景是:程序在Windows上跑,远端路径拼接时顺手用了Path.Combine。Path.Combine在Windows上生成的路径是C:\Users\xxx\file.txt这种反斜杠风格。如果这个路径是用在本地File.OpenRead上,完全没问题。问题是很多人顺手也把这种路径格式塞给了remotePath参数。远端路径必须用正斜杠。你把“\opt\backups\file.zip”传给SftpClient,它会把整个字符串当作一个奇怪的相对路径去找,结果自然是找不到。这个问题的排查过程其实特别蠢:本地文件明明存在,远端目录也确认存在,但SftpPathNotFoundException就是不停冒出来。最后把remotePath打印出来才发现,里面全是反斜杠。解法:远端路径统一用字符串常量或正斜杠拼接。如果确实拿到了一个Windows风格路径,做一次替换:var safeRemotePath = remotePath.Replace('\\', '/');顺带聊一下中文文件名。SSH.NET的SFTP通道默认按UTF-8编码处理文件名,但远端的Linux服务器如果locale不是UTF-8(有些老系统是POSIX或GBK),中文文件名就会变成乱码,甚至操作失败。我在生产环境里的经验是:自动化传输链路上的文件名保持ASCII风格,用日期加业务标识命名,比如app_20250115_v3.zip。这既是传输层面的省心,也是运维审计层面的规范。如果你确实要传中文文件名,先把两端的编码一致性测透再上生产。4.4 坑四:多任务并发复用同一个连接,抛出一堆异常有一次项目里接了个需求,要同时把几十个文件分发给多台服务器。我图方便,初始化了一个SftpClient实例,灌进线程池里,让二十个任务同时调用UploadFile。结果一跑起来,异常一个接一个,全是连接已被占用、句柄无效之类的错误。排查思路一开始还以为是服务器连接数限制,后来看了错误堆栈才发现是SftpClient内部维护了会话状态,同一个连接同时处理多个请求时,底层的SFTP请求编号互相串了,直接导致协议错乱。简单来说,这个库的单实例不是线程安全的。解法是:每个任务使用独立的SftpClient实例。每个实例发起自己的TCP连接和SSH会话,各干各的,天然隔离:Parallel.ForEach(files, file => { using var client = new SftpClient(host, port, username, password); client.Connect(); using var stream = File.OpenRead(file.LocalPath); client.UploadFile(stream, file.RemotePath); client.Disconnect(); });每个任务都新建连接,代价是握手开销变高了,但换来了线程安全和隔离性。如果你对性能有更高要求,可以维护一个连接池,按需取空闲连接,用完归还,而不是每次都新建。连接池实现我放在下一章讲。5. 工程化封装:带进度、带重试、支持目录递归的上传服务5.1 进度回调用法:边传边刷新进度条SSH.NET的UploadFile方法有个重载,第三个参数接收一个Action<ulong>委托,ulong参数表示当前已经上传的字节数。这个设计用来做进度回显非常顺手。var totalBytes = new FileInfo(localPath).Length; using var fileStream = File.OpenRead(localPath); client.UploadFile(fileStream, remotePath, uploadedBytes => { var percent = totalBytes == 0 ? 100 : (uploadedBytes * 100.0 / totalBytes); Console.Write($"\r上传进度:{percent:F2}%"); });注意回调不是每个字节都触发,而是按底层数据块的写入节奏回调,所以进度条变化看起来是一格一格跳的,这很正常。另一个细节是,回调里别写日志文件或用Console.WriteLine(带换行那种),否则大量回调会把控制台刷破,同时也影响传输性能。用\r回车符原地刷新就好。如果你在做图形界面程序,这个回调正好可以对接你的进度条控件,记得用控件自己的线程调度,别直接跨线程更新UI。5.2 递归上传目录:遍历本地目录树并映射到远端实际业务中,单文件上传跑起来之后,下一步一定是“一个目录递归传上去”。实现思路不复杂:本地用Directory.GetFiles和Directory.GetDirectories递归遍历,远端每进入一个子目录就调用EnsureRemoteDirectory建目录,然后逐个上传。核心是把本地路径映射成远端相对路径时把根目录前缀摘掉:public static void UploadDirectory(SftpClient client, string localRoot, string remoteRoot) { EnsureRemoteDirectory(client, remoteRoot); foreach (var file in Directory.GetFiles(localRoot)) { var fileName = Path.GetFileName(file); using var stream = File.OpenRead(file); client.UploadFile(stream, $"{remoteRoot}/{fileName}"); } foreach (var directory in Directory.GetDirectories(localRoot)) { var dirName = Path.GetFileName(directory); UploadDirectory(client, directory, $"{remoteRoot}/{dirName}"); } }递归函数里第一步永远是建目录,保证后续文件一定有所属位置。本地文件名用Path.GetFileName取出来的是纯文件名,不含目录结构,拼接远端路径时直接用正斜杠加文件名即可,不会带出本地分隔符。这个封装最大的价值在于一键同步整个发布目录。发布包、静态资源、配置文件树,本地是什么结构远端就是什么结构,省掉一层一层手建目录的重复劳动。5.3 重试策略与日志规范:让传输任务可以无人值守自动化任务最怕的不是失败,而是失败后没有任何反馈,第二天你才发现昨晚的任务全挂了。所以上传服务一定要有完整的日志记录。我习惯给每次上传任务打三个关键日志点位:任务开始、连接成功、文件上传完成。异常时输出异常类型、堆栈和当前重试次数。加上前面的重试机制,就组成了一个可以无人值守的传输模块。日志用结构化格式:Console.WriteLine("[{0}] 开始上传任务,目标:{1},本地:{2}", DateTime.Now, remotePath, localPath);如果有条件,写到ILogger或NLog里,配合文件大小校验结果一起记录,排查问题时会非常快。生产事故多数发生在凌晨,日志字段越规范,第二天定位根因越省事。5.4 连接复用与线程安全:连接池的简单实现思路前文提到,SftpClient实例不能多线程共用,但每次都新建连接又会有握手开销。折中方案是做一个轻量连接池。核心逻辑三层:取连接时从池里弹出一个空闲连接,检查IsConnected确保它没断;用完再还回去;如果池为空就新建。public class SftpConnectionPool { private readonly ConcurrentBag<SftpClient> _pool = new(); private string _host; private int _port; private string _username; private string _password; public SftpConnectionPool(string host, int port, string username, string password) { _host = host; _port = port; _username = username; _password = password; } public SftpClient Get() { if (_pool.TryTake(out var client) && client.IsConnected) { return client; } var newClient = new SftpClient(_host, _port, _username, _password); newClient.Connect(); return newClient; } public void Return(SftpClient client) { if (client.IsConnected) { _pool.Add(client); } else { client.Dispose(); } } }使用示例:var pool = new SftpConnectionPool(host, port, username, password); Parallel.ForEach(fileList, file => { var client = pool.Get(); try { using var stream = File.OpenRead(file.LocalPath); client.UploadFile(stream, file.RemotePath); } finally { pool.Return(client); } });这个池子的实现只适用于并发量不算极端的场景。连接数量本身受限于服务器端MaxSessions配置,所以别开上百个并发连接,池子大小控制在10左右就好。你当然可以扩展连接数上限和空闲清理机制,但做自动化文件分发,这个轻量版本已经足够。5.5 完整的上传服务模板把前面所有片段拼成一个可直接抄作业的上传服务类,整体结构大概长这样:public class SftpUploader { private readonly string _host; private readonly int _port; private readonly string _username; private readonly string _password; public SftpUploader(string host, int port, string username, string password) { _host = host; _port = port; _username = username; _password = password; } public void UploadFileWithCheck(string localPath, string remotePath) { using var client = CreateClient(); client.Connect(); EnsureRemoteDirectory(client, GetParentPath(remotePath)); using var stream = File.OpenRead(localPath); client.UploadFile(stream, remotePath); var remoteSize = client.GetAttributes(remotePath).Size; var localSize = new FileInfo(localPath).Length; if (remoteSize != localSize) { throw new InvalidOperationException($"文件大小校验失败,本地 {localSize},远端 {remoteSize}"); } client.Disconnect(); } public void UploadDirectory(string localRoot, string remoteRoot) { using var client = CreateClient(); client.Connect(); UploadDirectoryRecursive(client, localRoot, remoteRoot); client.Disconnect(); } private SftpClient CreateClient() { var client = new SftpClient(_host, _port, _username, _password) { ConnectionTimeout = TimeSpan.FromSeconds(60), OperationTimeout = TimeSpan.FromMinutes(10) }; // 生产环境务必替换为指纹白名单校验 client.HostKeyCallback = (sender, e) => e.CanTrust = true; return client; } }这个模板做对了三件事:目录自动创建、文件大小校验、超时参数显式化。拿它当起点,按自己的安全要求替换指纹校验,就可以直接接进你的部署工具或定时任务里。说回开头那个深夜电话。他后来把上传服务封装成独立模块,其他系统只要传本地路径和远端路径进来就完事,SSH细节完全不用关心。三十台服务器的配置分发,从手动半个小时缩到了自动三分钟,而且再没出现过漏发错发的情况。我的实际体会是:选对库只是一层皮,真正决定传输模块稳不稳定的,是对超时、重试、路径规范、并发模型这些边角料的处理。如果你接下来也要做类似的事,建议先把第三章到第五章里的坑埋进设计文档,能省下好几个通宵。