ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C#跨平台客户端fog-client:系统采集、任务执行与上报实战解析

C#跨平台客户端fog-client:系统采集、任务执行与上报实战解析

简介:fog-client是一款基于C#开发的跨平台计算机管理客户端,面向系统管理员与IT运维人员,解决Windows、Linux、macOS环境下的远程集中管理问题。资源包为zip格式,共304个文件,压缩包整体仅7.7MB,文件类型以cs源码、resx界面资源、config配置文件为主,同时包含plist、sh等跨平台配置与脚本,便于二次开发与自定义部署。目前已有169人学习下载。包内提供完整客户端源码及构建相关配置,覆盖自动登出、自动更新、能源管理、设备改名、Active Directory/Samba/Open Directory目录加入,以及TCP/IP、CUPS等多样打印机管理模块。无论是希望研究跨平台客户端架构的开发者,还是需要对接FOG服务器的运维人员,都能从中获得可直接参考的实现思路与可编译的基础工程。

1. 快速判断:fog-client 凭什么值得你下

内网里几十台 Windows 和 Linux 机器需要统一纳管的时候,大家的第一反应通常是 Ansible 或者 SaltStack。但如果你要的是一套能嵌进自己运维平台、采集项和上报协议都能定制、跑起来就一个进程的轻量客户端,fog-client 这种 C# 实现的方案反而更顺手。它本质是一个跨平台计算机管理客户端:定时采集 CPU、内存、磁盘和系统标识,把数据以 JSON 上报到服务端;同时轮询远程任务,执行完回传结果。适合在做资产盘点、运维自动化或边缘设备管理、且团队有 C# 技术栈的开发者。最直接的价值是部署成本可控,不依赖目标机预装运行时,一条发布命令就能压出 Windows 和 Linux 两个平台的单文件程序。下面把它的模块划分、核心代码、部署方式和坑一个个拆开。

2. 客户端底座:C# 跨平台的模块划分与选型取舍

2.1 为什么选 C#:管理客户端技术路线的取舍

管理客户端这个场景,候选语言通常就三门:Python、Go、C#。我以前用 Python 写过一版采集代理,上线第一个月就遇到两个问题:一是打包出来的目录结构在无图形环境里看着像黑匣子,二是每次升级都要处理系统和 Python 版本错位。后来切到 Go 试过,编译产物确实干净,但团队里没人愿意维护复杂的状态机,而且遇到 Windows 域环境、WMI 这类系统 API,总觉得隔了一层。

C# 的路数不一样:它在 Windows 上原生能碰 WMI、注册表和性能计数器,在 Linux 上又能通过 /proc 和 /sys 拿系统信息,两边都能用同一套 async/await 去组织采集流程。加上 .NET 5 以后的自包含发布,目标机不需要预装运行时,单个可执行文件带着 runtime 跑,跟 Go 的部署形态差不多。

维度C#(.NET 5+)PythonGo
部署产物单文件 + 自带运行时目录、虚拟环境单文件
Windows 系统 API原生(WMI/注册表)需第三方库封装需 syscall 层封装
Linux 系统信息/proc、/sysfs 直接读方便方便
异步并发模型async/await 成熟相对弱强
团队维护成本有 C# 底子的团队容易接脚本编辑自由需要 Go 经验

这个对比不是说 C# 一定比另外两门强,而是在管理客户端这个场景里它两边都能占到便宜:Windows 侧不需要绕一层 agent,Linux 侧不需要自找麻烦。后面要拆的 fog-client,大体上就按采集器、任务执行器、上报通道、本地调度四个模块组织,职责边界非常清楚。

2.2 从采集到上报:四个模块的职责边界

管理客户端最忌把所有逻辑混在 Main 方法里,fog-client 的拆分方式值得参考。采集器只负责从系统层拿数据,输出统一的 DeviceInfo 对象,不关心数据怎么发出去;任务执行器从队列里取服务端下发的任务,区分 shell 命令、文件同步、定时快照等类型,执行完写 TaskResult;上报通道把序列化后的 JSON POST 到服务端,管超时、重试和失败缓存;本地调度用多个周期定时器驱动上面的三块,心跳、全量上报、任务轮询各自用独立间隔。

这个拆分带来的直接好处是:你改采集逻辑时不用动上报代码,换服务端协议时也不用碰采集器。比如服务端从 HTTP 改成 HTTPS,只需要动 Reporter 和配置文件,采集器一行不用改。任务执行器的本地队列还有个隐藏作用:哪怕服务端异常下发了一百个任务,客户端也会一条条消化,而不是瞬时创建一百个进程把 CPU 打满,相当于一个天然的背压阀。

2.3 同一套源码跑两个平台:条件编译与运行时判断

跨平台在 C# 里靠两件事落地:条件编译符号和运行时平台判断。条件编译解决“某些包只有特定平台有”的问题,运行时判断解决“同一个代码入口按平台走不同实现”的问题。下面的代码是 fog-client 里很典型的平台分流写法,解决“读 CPU 型号”这一个功能在 Windows 和 Linux 上各走一套实现:

// HardwareInfo.cs // 场景:采集 CPU 型号,Windows 走 WMI,Linux 走 /proc/cpuinfo using System.Runtime.InteropServices; public static class HardwareInfo { public static string GetCpuModel() { if (OperatingSystem.IsWindows()) { // Windows 平台:用 ManagementObjectSearcher 查 Win32_Processor // 注意 System.Management 是 Windows-only 的 API,需要 NuGet 包 using var searcher = new System.Management.ManagementObjectSearcher( "SELECT Name FROM Win32_Processor"); foreach (var obj in searcher.Get()) { return obj["Name"]?.ToString() ?? "Unknown CPU"; } } else if (OperatingSystem.IsLinux()) { // Linux 平台:解析 /proc/cpuinfo,取第一行 model name foreach (var line in File.ReadAllLines("/proc/cpuinfo")) { if (line.StartsWith("model name")) { return line.Split(':', 2)[1].Trim(); } } } return "Unknown CPU"; } }

逻辑说明:Windows 分支用 ManagementObjectSearcher 执行 WQL 查询,返回第一个 CPU 的 Name;Linux 分支逐行读 /proc/cpuinfo,命中 model name 后按冒号切两段再 Trim,两边结果语义一致。冒号拆分用 Split(':', 2) 只拆第一个冒号,防止 CPU 型号文本里再出现冒号时被截断。

参数说明:OperatingSystem.IsWindows() 是 .NET 5+ 提供的方法,旧项目可以用 RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 等价替代。System.Management 本身是 Windows-only 的包,在 Linux 上编译能通过,但一调就翻车,所以平台判断必须先于 WMI 调用。条件编译的典型用法则是隔离引用差异:

#if NETFRAMEWORK // .NET Framework 下 System.Management 直接可用 #else // .NET 5+ 需要先引入 System.Management NuGet 包 #endif

实际工程里,我一般建议条件编译只包住引用层面的差异,业务逻辑尽量走运行时判断。这样 Linux 包在发布前就能完整编译出来,不会等到运行时才发现平台专属调用。

3. 核心模块实战:采集器、任务执行器与重试上报

3.1 采集器:机器标识与内存信息的两套来源

采集器是整个客户端的入口,第一优先要采集的是 MachineId。Windows 上常见做法是读注册表 Cryptography 项下的 MachineGuid,Linux 上则是 systemd 维护的 /etc/machine-id。两者都是安装系统时生成的持久化 ID,适合做设备主键。关键是要做好回退链,因为精简系统和容器里文件不一定存在。

// SysInfoCollector.cs —— 机器标识采集,含回退链 public static string GetMachineId(string configuredSource = "auto") { if (configuredSource == "guid") return Guid.NewGuid().ToString("N"); if (OperatingSystem.IsWindows()) { // Windows: 注册表 MachineGuid,一般不会变化 using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( @"SOFTWARE\Microsoft\Cryptography"); var value = key?.GetValue("MachineGuid")?.ToString(); if (!string.IsNullOrEmpty(value)) return value; } else if (OperatingSystem.IsLinux()) { // Linux: 优先 /etc/machine-id,容器里往往不存在 var candidates = new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" }; foreach (var path in candidates) { if (File.Exists(path)) return File.ReadAllText(path).Trim(); } } // 兜底:用随机 GUID,至少客户端不会因为取 ID 失败而启动崩溃 return Guid.NewGuid().ToString("N"); }

逻辑说明:configuredSource 参数允许配置中心强制指定 ID 来源;auto 模式按平台走默认链路。Linux 分支给了两个候选路径,因为容器镜像或精简系统里 /etc/machine-id 可能不存在,但 /var/lib/dbus/machine-id 还在。最后兜底用随机 GUID,保住采集流程不中断。

参数说明:MachineIdSource 配置项推荐值有 auto、registry、file、guid。auto 最省事;guid 模式适合不想暴露机器真实标识的离线场景。注意回退到随机 GUID 后,重启会生成新 ID,资产系统里这会造成设备重复。正确做法是把回退原因和最终 ID 一起上报,由服务端判断是否合并。

内存采集同样分两套来源,注意 /proc/meminfo 单位是 kB,需要转成 MB:

// 内存总量,Windows 走 WMI,Linux 走 /proc/meminfo public static long GetTotalMemoryMb() { if (OperatingSystem.IsWindows()) { using var searcher = new System.Management.ManagementObjectSearcher( "SELECT TotalPhysicalMemory FROM Win32_ComputerSystem"); foreach (var obj in searcher.Get()) { var value = obj["TotalPhysicalMemory"]?.ToString(); if (long.TryParse(value, out var bytes)) return bytes / 1024 / 1024; } } else if (OperatingSystem.IsLinux()) { foreach (var line in File.ReadAllLines("/proc/meminfo")) { if (line.StartsWith("MemTotal:")) { var parts = line.Split(':', 2)[1].Trim() .Split(' ', StringSplitOptions.RemoveEmptyEntries); if (long.TryParse(parts[0], out var kb)) return kb / 1024; // meminfo 单位是 kB,转成 MB } } } return 0; }

为什么 Windows 总内存走 WMI 而不是 PerformanceCounter:PerformanceCounter 按分类计数据,部分精简服务器没有对应计数器类别;WMI 的 Win32_ComputerSystem.TotalPhysicalMemory 对 Windows Server Core 同样可用,兼容面更广。Split 用 RemoveEmptyEntries 是为了处理多空格间隔,避免解析出空字符串。

3.2 任务执行器:队列、分发与超时控制

服务端下发的任务到达客户端后,先进入本地 ConcurrentQueue,再由 Dispatcher 一条条消费。这样即使服务端短暂不可用,已拉到的任务也不会丢,同时限制并发度,避免任务风暴打爆客户端。

// TaskDispatcher.cs —— 任务队列与并发安全的消费 public class TaskDispatcher { private readonly ConcurrentQueue<RemoteTask> _queue = new(); public void Enqueue(RemoteTask task) => _queue.Enqueue(task); public async Task RunLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { if (_queue.TryDequeue(out var task)) { var result = await ExecuteAsync(task, ct); // 结果回传由 Reporter 完成,Dispatcher 不关心 HTTP 细节 await OnTaskCompleted(result, ct); } else { await Task.Delay(50, ct); } } } private async Task<TaskResult> ExecuteAsync(RemoteTask task, CancellationToken ct) { var timeout = TimeSpan.FromSeconds( task.TimeoutSeconds > 0 ? task.TimeoutSeconds : 60); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout); try { return task.Type switch { "shell" => await RunShellAsync(task.Payload, cts.Token), "file_sync" => await FileSyncAsync(task.Payload, cts.Token), _ => new TaskResult { Success = false, Message = $"未知任务类型: {task.Type}" } }; } catch (OperationCanceledException) { return new TaskResult { Success = false, Message = $"任务执行超时({timeout.TotalSeconds}s)" }; } catch (Exception ex) { // 任何异常都要落回 TaskResult,不能让 Dispatcher 崩溃 return new TaskResult { Success = false, Message = ex.Message }; } } }

逻辑说明:Enqueue 负责收任务,RunLoopAsync 在没有取消时不断出队,空队列时等 50ms 再轮询。每个任务用独立的 CancellationTokenSource 控制超时,switch 表达式按 type 分发,未知类型返回失败结果而不是抛异常,保证执行器稳定。

参数说明:TimeoutSeconds 是任务 payload 里的字段,默认 60 秒。CreateLinkedTokenSource 同时关联客户端停机信号和任务超时,避免停机时任务还在后台跑。如果任务本身需要长时间执行,超时值要由服务端配置,而不是写死。

跑 shell 命令时,注意用 WaitForExitAsync 而不是阻塞式 WaitForExit,否则 CancellationToken 没法真正中断命令:

// RunShellAsync 简化实现 private static async Task<TaskResult> RunShellAsync(RemoteTaskPayload payload, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/bash", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (OperatingSystem.IsWindows()) psi.Arguments = $"/c \"{payload.Command}\""; else { psi.ArgumentList.Add("-c"); psi.ArgumentList.Add(payload.Command); } using var process = Process.Start(psi); var outputTask = process.StandardOutput.ReadToEndAsync(); var errorTask = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(ct); await Task.WhenAll(outputTask, errorTask); return new TaskResult { Success = process.ExitCode == 0, Message = outputTask.Result }; }

关键点:UseShellExecute=false 是必须的,它让 stdout 和 stderr 可以被重定向;CreateNoWindow=true 保证客户端在后台运行时不弹黑框。Linux 分支用 ArgumentList 而不是拼字符串,减少命令注入和转义问题。

3.3 上报通道:JSON over HTTP、指数退避与失败落盘

上报通道是客户端和服务端之间的唯一数据通路,最怕网络抖动导致丢数据。fog-client 的上报用 POST JSON 加指数退避重试:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多三次就进入失败落盘,而不是无限重试。这样既不过度消耗服务端资源,也能在瞬断场景恢复。

// Reporter.cs —— 带指数退避的设备上报 public class Reporter { private readonly HttpClient _httpClient; private readonly string _baseUrl; private readonly string _cacheDirectory; public async Task<bool> ReportAsync(DeviceInfo payload, CancellationToken ct) { var maxAttempt = 3; for (var attempt = 1; attempt <= maxAttempt; attempt++) { try { using var resp = await _httpClient.PostAsJsonAsync( $"{_baseUrl}/api/devices/report", payload, ct); if (resp.IsSuccessStatusCode) { return true; } // 非 2xx 也退避,防止服务端被瞬时高负载压垮 await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct); } catch (HttpRequestException) { if (attempt == maxAttempt) break; // 网络瞬断:按 1、2、4 秒退避 await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct); } } // 三次都失败就落盘,下个周期再补报 var cacheFile = Path.Combine(_cacheDirectory, $"{payload.MachineId}.json"); await File.WriteAllTextAsync(cacheFile, JsonSerializer.Serialize(payload), ct); return false; } }

逻辑说明:PostAsJsonAsync 会把 payload 按 System.Text.Json 配置序列化后 POST,返回 2xx 直接结束;网络异常类型(DNS 解析失败、连接超时、连接被重置)走指数退避。最后失败会把 JSON 写进本地磁盘,丢数据窗口从几秒降到几乎为零。

参数说明:两组退避时间分别对应服务端“能响应但不成功”和“根本连不上”两种场景。重试次数和间隔建议放配置中心,比如 ReportRetryCount 和 ReportRetryBaseSeconds。如果内网断连超过 10 分钟,只重试三次不够,必须靠失败落盘兜底。

3.4 本地调度:心跳、全量上报与任务轮询的三个节奏

fog-client 有三条独立的节拍:心跳每 5 分钟一次,数据量极小,只上报机器在线的状态和客户端版本;全量上报每 30 分钟一次,把采集到的完整信息发出去;任务轮询每 15 秒一次,因为任务执行对实时性有要求,这个间隔要短。三个周期用同一个 CancellationToken,停机时全部循环一起退出。

用 PeriodicTimer 实现比 Task.Delay 好,因为 PeriodicTimer 会在下一个 tick 重新计时,不会累计漂移:

// Scheduler.cs —— 三个独立周期 public class Scheduler : IAsyncDisposable { private readonly PeriodicTimer _heartbeatTimer = new(TimeSpan.FromMinutes(5)); private readonly PeriodicTimer _reportTimer = new(TimeSpan.FromMinutes(30)); private readonly PeriodicTimer _taskTimer = new(TimeSpan.FromSeconds(15)); public async Task RunAsync(CancellationToken ct) { var tasks = new[] { RunHeartbeatLoopAsync(ct), RunReportLoopAsync(ct), RunTaskPollLoopAsync(ct) }; await Task.WhenAll(tasks); } private async Task RunHeartbeatLoopAsync(CancellationToken ct) { while (await _heartbeatTimer.WaitForNextTickAsync(ct)) { await _reporter.ReportHeartbeatAsync(ct); } } }

要点:PeriodicTimer 能感知执行时长。某一次上报卡了 40 秒,下一个 tick 不会立刻触发,而是等满 5 分钟再说,天然错峰。任务轮询这 15 秒间隔要结合服务端并发量调,轮询百台机器可以,轮询上万台就要考虑长连接或消息推送,不然服务端容易被每秒几百个空请求打满。

4. 部署与配置:从发布命令到服务托管

4.1 发布命令:RID、自包含与单文件

把 fog-client 推到目标机,最常见方式是 dotnet publish。两个典型命令一组,分别产出 Windows 和 Linux 的部署包:

# Windows 10/11、Windows Server 2019+ 的 x64 机器 dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o ./dist/win-x64 # 常见 Linux 发行版(CentOS 7+、Ubuntu 18.04+)的 x64 机器 dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o ./dist/linux-x64

参数说明:-r 指定 RuntimeIdentifier,管理客户端建议直接用 win-x64 和 linux-x64,不用 x86 兼容畸形架构。如果目标机是 ARM 盒子,把 -r 换成 linux-arm64。--self-contained true 把 .NET 运行时打进发布目录,目标机零依赖,代价是体积多 30-50 MB。PublishSingleFile=true 把所有托管 DLL 并进单个 exe,方便脚本分发。

我一般还会显式关掉裁剪,避免 WMI 和反射类操作被剪出问题:

dotnet publish -c Release -r linux-x64 --self-contained true \ -p:PublishSingleFile=true -p:PublishTrimmed=false

PublishTrimmed=false 是给使用 WMI 或反射代码的客户端留的一条后路。裁剪模式可能剪掉 Win32_Processor 查询所需的内部类型,发布包多几 MB,换稳定性,值得。如果确实要裁剪,先跑一遍第 6 章的 mock 三连测再发。

4.2 配置文件:服务端地址与采集参数

fog-client 的配置集中在单个 appsettings.json,结构设计得很直接:

{ "Server": { "BaseUrl": "http://ops-cfg-server:8080", "ReportIntervalMinutes": 30, "HeartbeatIntervalMinutes": 5, "TaskPollingIntervalSeconds": 15, "ReportRetryCount": 3, "UseSystemProxy": false }, "Client": { "MachineIdSource": "auto", "CacheDirectory": "cache", "LogLevel": "Information", "TaskTimeoutSeconds": 60 } }
配置项默认/推荐说明
Server.BaseUrl必填服务端根地址,不带尾部斜杠
ReportIntervalMinutes30全量上报周期
HeartbeatIntervalMinutes5心跳周期
TaskPollingIntervalSeconds15任务轮询周期,值越小任务越及时,服务端压力越大
ReportRetryCount3上报失败重试上限
UseSystemProxyfalse内网环境默认关闭系统代理,避免请求绕一圈

注意 BaseUrl 末尾不要加斜杠,否则拼接 /api/devices/report 时会出现 //api,不少网关会直接 404。

代码里用 ConfigurationBuilder 加载并绑定强类型:

// AppConfig.cs —— 配置加载与强类型绑定 var configuration = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); var serverConfig = configuration.GetSection("Server").Get<ServerOptions>();

reloadOnChange 设为 true 后,服务端参数调整客户端不需要重启进程,对资产管理场景很关键,毕竟几十台客户端不可能一台台手动重启。改完配置源,新值会在下个轮询周期生效。

4.3 服务托管:Windows 用 sc,Linux 用 systemd

客户端必须开机自启,不能靠用户手动双击。Windows 上最直接的服务注册命令:

sc create fog-client binPath= "C:\Program Files\FogClient\fog-client.exe" start= auto

这条命令有个经典坑:binPath= 后面的空格是必须的,sc 的语法要求在等号后有一个空格,否则报语法错误。binPath 必须指向完整可执行文件路径,不能写相对路径,路径里有空格的整段用双引号包住。启动和查询:

sc start fog-client sc query fog-client

Linux 侧就是 systemd service 文件:

# /etc/systemd/system/fog-client.service [Unit] Description=Fog Client After=network-online.target Wants=network-online.target [Service] WorkingDirectory=/opt/fog-client ExecStart=/opt/fog-client/fog-client Restart=always RestartSec=10 Environment=DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 [Install] WantedBy=multi-user.target

DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 用于精简系统没有 ICU 库的场景,否则客户端启动时会因为找不到文化信息抛异常。如果客户端日志需要完整本地化输出,这个变量不要加,让 .NET 走默认的全球化模式。随后执行:

sudo systemctl daemon-reload sudo systemctl enable --now fog-client systemctl status fog-client

After=network-online.target 和 Wants=network-online.target 的组合要保留,防止客户端在网卡还没就绪时启动,把 BaseUrl 的 DNS 解析失败当成不可恢复错误。

4.4 网络前置条件:先确认四项再分发

服务端地址配置完成后,在目标机上先做一次连通性验证,不要部署几十台再回头排查。

curl -v http://ops-cfg-server:8080/api/devices/ping

四项检查按顺序做:一是目标机能不能解析服务端域名,用 getent hosts 或 nslookup;二是防火墙是否放行出站,ping 通不代表 TCP 层通,用 telnet IP 8080 验证;三是是否需要系统代理,如果需要就打开 UseSystemProxy;四是服务端是否允许该网段来源,收到 403 或 401 就让服务端查白名单。这四项过了再批量推包,比事后救火省心得多。

5. 避坑指南:五个让客户端半夜挂掉的真实场景

5.1 WMI 首次查询慢,采集器被误判为卡死

现象:客户端刚部署完,日志显示第一次采集 CPU 信息耗时 8-12 秒,业务侧误判为客户端卡死,超时后重试导致采集队列堆叠。

原因:WMI 服务(Winmgmt)是懒启动的,首次 WQL 查询会触发 WMI 仓库初始化,在 Windows Server Core 上更明显,耗时能到十几秒。

解决:采集逻辑里给 ManagementObjectSearcher 设置检索超时,超过 15 秒就放弃这次采集并返回空值。更重要的是超时后不要立即重试,让下个周期自然补采。fog-client 的采集器按模块各自独立 try-catch,单个采集点失败不影响其他项。

5.2 /etc/machine-id 不存在,客户端开机即崩

现象:容器化部署或精简 Linux 系统上,客户端启动第一条日志就是 FileNotFoundException,指向 /etc/machine-id。

原因:容器镜像默认不带 machine-id,部分精简系统把 dbus 相关文件也裁掉了,而代码里直接 File.ReadAllText 读固定路径。

解决:采集 MachineId 时按候选路径探测,/etc/machine-id、/var/lib/dbus/machine-id 依次尝试,都没有就回退到 hostname 加启动时间生成的稳定键,同时打 warning 日志。回退时不要直接用随机 GUID,否则每次重启设备在服务端都会生成一个新资产。

5.3 sc create 等号后没有空格,服务建不出来

现象:sc create 命令在 Windows 上报无效参数,或者服务创建成功但 start= auto 没生效,服务一启动就退出。

原因:sc 命令的语法是 name= value,等号后必须有一个空格。脚本里写成 binPath= 不带空格时,系统解析会把路径参数吞掉,服务永远起不来。

解决:脚本里固定写成 binPath= "C:..." start= auto,等号后保留空格。这是 Windows 服务命令里最容易踩的坑,没有之一。建议写成一个 .bat 模板让所有部署人员直接复制,不要靠手敲。

5.4 System.Management 在 Linux 上能编译,运行时才翻车

现象:把同一份源码在 Linux 上 publish 成功,但跑到采集步骤抛 System.PlatformNotSupportedException,调用栈指向 ManagementObjectSearcher。

原因:System.Management 是 Windows-only API,NuGet 包在非 Windows 平台没有实际实现。编译期不报错是因为 TFM 声明支持,运行时才暴露真实平台限制。

解决:所有 WMI 调用必须用 OperatingSystem.IsWindows() 包起来,Linux 分支永远走 /proc 和 /sys。发布流程里加一道双平台 smoke test,先 Linux 编译跑一次采集,再 Windows 编译跑一次,任何一边崩都直接拦截。

5.5 HttpClient 按请求创建,内网 socket 被拖垮

现象:客户端跑了一周,任务执行越来越慢,netstat 看到大量 TIME_WAIT 连接,日志出现 SocketException: Too many open files。

原因:Reporter 里每次上报都 new HttpClient(),HttpClient 底层的 socket 不会随请求结束立刻释放,加上内网代理,连接积压把端口耗尽。

解决:HttpClient 做成单例,或者用 IHttpClientFactory 管理生命周期。注意单例后 DNS 变更不会自动生效,配合 PooledConnectionLifetime 设置一个连接复用时长,比如 5 分钟,既能复用端口又能看到 DNS 更新。这属于上线前就该定下来的底层设计,真等到 socket 耗尽再改,客户端得全部重发一遍。

6. 进阶验证:用本地 Mock 服务端做三连测

6.1 Mock 服务端起在本地,把三测跑通

部署任何管理客户端,我最怕的是代码逻辑正确但服务端对不上。所以 fog-client 工程下来后,先起一个本地 Mock 服务端,把上报、心跳、任务回执三件事验完,再考虑真实环境。用 ASP.NET Core Minimal API 五十行内就能完成:

// MockServer/Program.cs —— 临时验证用,不用于生产 using System.Collections.Concurrent; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); var received = new ConcurrentBag<DeviceInfo>(); app.MapPost("/api/devices/report", (DeviceInfo device) => { received.Add(device); return Results.Ok(new { code = 0 }); }); app.MapPost("/api/devices/heartbeat", (HeartbeatInfo hb) => { return Results.Ok(new { code = 0, serverTime = DateTime.UtcNow }); }); app.MapPost("/api/tasks/poll", () => { // 固定下发一条 shell 任务,用于验证任务执行链路 return Results.Ok(new { task = new { id = Guid.NewGuid().ToString(), type = "shell", payload = new { command = "echo ok" } } }); }); app.MapGet("/api/devices/received", () => received.ToArray()); app.Run(); public record DeviceInfo(string MachineId, string CpuModel, long MemoryMb); public record HeartbeatInfo(string MachineId, string Version);

运行起来后,把目标机的 BaseUrl 指向本地地址:

dotnet run --project MockServer curl http://localhost:5225/api/devices/heartbeat

然后启动 fog-client。第一测是上报,等服务端日志打到 report 请求,验证 DeviceInfo 的 JSON 字段名大小写和序列化配置一致;第二测是心跳,测试时把 HeartbeatIntervalMinutes 临时改成 1 分钟,不用真等五分钟;第三测是任务回执,Mock 的 poll 接口返回一条 echo 命令,客户端执行后应回传 TaskResult,再补一个 MapPost /api/tasks/result 就能验证结果。

6.2 两个高频返工点:字段命名与自签证书校验

三连测最容易暴露的是字段命名问题。客户端序列化配置用了 camelCase 策略时,上报字段是 machineId、cpuModel,而服务端如果按 PascalCase 绑字典,就匹配不上。Mock 服务端用 record 类接收时 ASP.NET Core 默认大小写不敏感,很容易掩盖这个坑。正确的验证方式是把 Mock 收到的原始 JSON 原样打印出来看一次,而不是依赖模型绑定后的值。

另一个高频返工点是自签证书。内网服务端一般用 HTTPS 加自签证书,客户端 HttpClient 默认校验严格,直接连会抛证书错误。稳妥做法是在 HttpClientHandler 的 ServerCertificateCustomValidationCallback 里只放行配置过的证书指纹,不要全局忽略校验。测试阶段可以临时放开,生产环境必须回到白名单校验,否则等于把半条命交给中间人。

从那以后,我每次改采集逻辑或序列化配置,都强制走一遍这套流程:本地 Mock 起服务、改 BaseUrl、跑三连测、确认原始 JSON,全绿才执行发布命令。这个习惯帮我挡住了至少三次字段对不上和 WMI 超时引发的线上问题。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表