先纠正题目里的一个说法“Core”这个词在 .NET 5 之后就正式退出命名了.NET 7.0 的全名就是“.NET”不是什么“.NET Core 7”。但大家叫习惯了一说“Core项目”基本默认是 ASP.NET Core Web API、Worker Service 这类跑在 .NET 7.0 上的现代应用。这篇文章聊的事情很具体把项目里原本躺在控制台输出窗口和 .log 文件里的日志升级成一套能搜、能筛、能按请求链路回放的可视化日志平台。适合谁看正在用 C# 写 .NET 7.0 Web API 或微服务、排障还靠 ssh 上去 grep 日志的朋友也适合团队规模不大、不想一上来就上全家桶日志平台的中小型项目。先说结论这个需求最省事的落地组合就是 Serilog Seq再不行就往 Elasticsearch 或 Loki 上接。Serilog 负责把散落的日志整理成结构化数据Seq 负责把数据变成好用的可视化面板。整个流程从零到跑通半小时够了而且这套方案的生产级扩展路径也清晰。1. 可视化日志解决的核心痛点和技术选型1.1 传统日志方式的三个老大难只要在 .NET 里做过线上排障应该都经历过这三种原始状态。第一种是控制台日志。开发时盯着窗口看觉得挺直观可一旦把服务部署成多个实例每个容器都往自己的标准输出里打日志查一次问题要在各个节点之间来回切时间线还是错位的。第二种是文件日志。写进 log.txt 或 log-YYYYMMDD.txt人手一多文件越来越大检索基本靠 less grep能撑到几十上百 MB 已经是极限再往上连编辑器打开都要卡半天。第三种最要命日志根本没有统一格式不同开发写的风格完全不一样有的人打中文有的人打英文有的人带时间戳有的人不带落盘之后想用脚本分析发现连正则都没法写。这些痛点的本质是日志没有被当成数据来管理只是被当成字符串输出。字符串只能看不能查、不能聚合、不能关联上下文一旦出问题排查效率全部取决于个人经验和运气。1.2 为什么要做结构化日志结构化日志的核心理念是把一次日志记录当成一个“事件”来看待。事件包含时间、日志级别、消息模板、参数化属性例如订单号、用户ID、请求耗时、机器名、环境名。这样一条消息在面板上就是一行可展开的卡片字段都能单独过滤而不是一串不可分割的文字。举个例子。你用_logger.LogInformation(用户下单成功金额 amount)打出来的日志在传统文件里是一条“用户下单成功金额199.00”的纯文本但如果你用_logger.LogInformation(用户 {UserId} 下单成功金额 {Amount}, userId, amount)可视化面板就能把 UserId 和 Amount 识别成独立字段。之后哪怕日志有上万条也可以直接筛选UserId 10086瞬间定位某个人所有操作记录。可视化平台之所以能“可视化”前提就是日志里这些字段被结构化了。1.3 方案选型Seq、ELK、Loki 怎么选这个我帮不少团队做过选型总结下来就三句话中小团队先用 Seq已有监控家族的直接挂 Loki日志量和查询复杂度都很高再考虑 ELK。方案部署成本适合规模缺点Seq一个 Docker 容器几分钟启动中小项目、内部服务免费版有限制超大规模要付费或换方案ELKElasticsearch Kibana三个组件起先得养 ES 集群日志量大、已有运维体系重运维成本高Loki一个容器但要配 Grafana 展示已有 Grafana 监控体系社区 sink 包质量参差不齐要试我的建议是如果你们团队从零开始别一上来就搭 ELK大概率搭完没人维护。先用 Seq 把整个链路跑顺让开发人员实实在在感受到“日志可以搜”后面哪天日志量真的大到 Seq 顶不住了再平滑切到 ElasticsearchSerilog 换个 Sink 就行代码改动很小。2. 在 .NET 7 项目里接入 Serilog 并输出结构化日志2.1 安装 NuGet 包假设你手上已经有一个 .NET 7.0 的 ASP.NET Core Web API 项目在项目根目录执行下面几条命令dotnet add package Serilog.AspNetCore dotnet add package Serilog.Sinks.Seq dotnet add package Serilog.Enrichers.Environment dotnet add package Serilog.Enrichers.ThreadSerilog.AspNetCore这个包很关键它把 Serilog 和 ASP.NET Core 的日志抽象接在了一起并且自带了 Console 输出。只要装上它就能用builder.Host.UseSerilog()接管默认日志管道。其他几个包分别负责把日志写到 Seq、补充机器名/环境名/线程号等上下文信息。这里要注意不要只装Serilog裸包否则你还要手动接一堆扩展Serilog.AspNetCore才是整合好的入口。2.2 用 UseSerilog 替换默认日志管道打开 Program.cs在builder.Build()之前加入 Serilog 配置using Serilog; using Serilog.Core; using Serilog.Events; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddHttpContextAccessor(); builder.Host.UseSerilog((context, services, config) { var httpContextAccessor services.GetRequiredServiceIHttpContextAccessor(); config .MinimumLevel.Information() .MinimumLevel.Override(Microsoft, LogEventLevel.Warning) .MinimumLevel.Override(Microsoft.AspNetCore, LogEventLevel.Warning) .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithEnvironmentName() .Enrich.WithThreadId() .Enrich.With(new RequestLogEnricher(httpContextAccessor)) .WriteTo.Console( outputTemplate: [{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}) .WriteTo.Seq(http://localhost:5341); }); var app builder.Build(); app.UseSerilogRequestLogging(); app.MapControllers(); app.Run();这段代码里我故意把配置写在代码里而不是塞进 appsettings.json原因很简单新手阶段看代码比看配置文件直观字段对应关系一目了然。等你跑通了再考虑把WriteTo、Enrich挪到配置文件里用ReadFrom.Configuration读取。要注意Enrich.With(new RequestLogEnricher(...))这一行我没有用WithRequestLogEnricher()的写法。原因是无参的WithT()走的是 Activator 直接创建实例如果你的 Enricher 构造函数里依赖了IHttpContextAccessor直接注册会报错。最稳的方式就是从 DI 容器里取出实例再传进去这个细节能帮你省掉一个运行时异常。2.3 模板消息的正确写法别再字符串拼接这块值得单独拉出来说因为很多老项目本质上是“写着写着就变成了什么都拼”。看下面的对比// 不推荐字符串插值 _logger.LogInformation($用户{userId}下单成功金额{amount}); // 推荐参数化模板 _logger.LogInformation(用户 {UserId} 下单成功金额 {Amount}, userId, amount);两者表面上看只是写法差异但本质完全不同。字符串插值在日志级别被过滤掉的时候插值表达式照样会执行ToString()白白消耗性能更重要的是拼接出来的消息到了 Seq 里就是一团纯文本你只能全文检索“下单成功”没法按UserId过滤。参数化模板则会把UserId和Amount作为独立字段传给日志管道Seq 自动把它们识别为可筛选属性。我自己的习惯是新项目编码规范里直接加一条禁止在LogInformation、LogWarning、LogError这类方法里用字符串插值或string.Format所有日志必须走模板 参数。老代码的存量日志不强制改但新代码必须守规矩。2.4 每一条 HTTP 请求都自动记录app.UseSerilogRequestLogging()是 Serilog.AspNetCore 里最值得开局就开的功能。它会在中间件层面给每个 HTTP 请求额外记录一条结构化日志包含请求方法、请求路径、状态码、耗时等字段。这意味着你不需要在每个 Controller 里手动写日志就已经拥有了基础的可观测能力。之前排查线上慢请求只能靠用户反馈“刚才那一下卡了很久”然后去翻各种日志拼上下文。接了这行中间件之后在 Seq 里直接筛StatusCode 500所有失败请求和耗时全列出来一眼就知道问题出在哪个接口上。注意这个中间件要放在MapControllers()之前的适当位置一般放在UseAuthorization()周边即可不用纠结只要确保它在终结点中间件之前执行就行。3. 启动可视化面板Seq 接入实战3.1 用 Docker Compose 一分钟拉起 Seq可视化面板我用 Seq 作为主角。它是个单容器日志平台内部自带查询界面、API 和存储非常适合中小团队的内部日志需求。在你的服务器或开发机上创建一个docker-compose.ymlservices: seq: image: datalust/seq:latest container_name: seq-log ports: - 5341:80 environment: - ACCEPT_EULAY volumes: - seq_data:/data restart: unless-stopped volumes: seq_data:seq_data卷用来持久化日志数据容器删了日志还在。端口映射我把容器内的 80 映射到宿主机 5341浏览器访问http://localhost:5341即可打开管理界面。首次打开会引导设置管理员密码按提示走就行。ACCEPT_EULAY是必须的不接受许可协议容器会一直重启。免费版对每日日志入库量和保留天数有限制具体数字以官方许可证说明为准但中小团队内部日志量一般够用。3.2 配置应用写入 Seq 并验证回到刚才 Program.cs 里那行WriteTo.Seq(http://localhost:5341)。如果你的应用跑在宿主机上这个地址就是对的如果应用也跑在 Docker 里那localhost指向的是容器自身要改成宿主机 IP或者干脆把应用和 Seq 放在同一个 compose 网络里用服务名http://seq:80访问。启动应用后切到 Seq 首页的 Events 页面你会看到日志像流水一样冒出来。每条日志展开能看到时间、级别、消息、机器名、环境名、线程号。还没看到多半是日志级别过滤掉了把应用的MinimumLevel临时改成Verbose再试确认能写通后再改回来。为了区分不同服务建议在 Seq 设置里创建 API Key每个应用一个 Key。这样 Seq 页面上能按应用筛选日志后续做告警也能按应用区分。API Key 在 Seq 的 Settings API Keys 里生成生成后把字符串填在WriteTo.Seq(http://seq:80, apiKey: xxx)里。3.3 Seq 查询语法速查Seq 的查询自己有一套表达式语法不是标准 SQL但上手特别快。以下几种最常用查询需求Seq 表达式只看错误日志Level Error搜索消息关键字Message like %订单超时%按业务订单号过滤OrderId 1024按机器名过滤MachineName prod-01组合条件Level Error and StatusCode 500排除某个路径not (Message like %healthz%)实际排障时最顺手的组合是先Level Error拉出所有错误再根据某个请求里的 TraceIdentifier 或 OrderId 字段横向展开把这次请求前后关联的所有日志都用同一条链路串起来。这个能力是文件日志完全不具备的。3.4 团队已有 ELK 或 Loki 时怎么接如果团队已经有 Elasticsearch 集群Serilog 切过去也很简单.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri(http://elasticsearch:9200)) { IndexFormat order-api-{0:yyyy.MM.dd}, AutoRegisterTemplate true, NumberOfShards 1, NumberOfReplicas 0 });需要装Serilog.Sinks.ElasticSearch包。要注意AutoRegisterTemplate要开它会让 Serilog 自动帮 ES 生成索引模板否则 Kibana 里很多字段会识别成字符串类型过滤器没法用。如果已经用了 Grafana 和 Loki可以找社区维护的Serilog.Sinks.Grafana.Loki但那个包活跃度不稳接入前先在测试环境压一下日志量别直接往生产上放。4. 日志上下文增强与敏感信息保护4.1 用 Enricher 补齐机器、环境、线程信息日志光有消息是不够的得让每条日志自带“环境属性”。我在前面的代码里已经加了WithMachineName()、WithEnvironmentName()、WithThreadId()分别对应机器名、环境名、线程号。为什么要加这些假设你有三台机器跑同一个服务用户在线上出了一个诡异的问题没有机器名你根本不知道是哪个节点有问题没有环境名你会分不清这条日志是来自 Development 还是 Production更糟的是两个环境都接同一个 Seq日志混在一起直接看懵线程号则是在分析并发问题时特别有用能帮你判断一个请求内部不同线程的执行顺序。生产环境里我还习惯再加一个WithProcessId()配合Serilog.Enrichers.Process包使用。容器环境下进程号能帮你区分是否为同一个容器的多个实例排查资源问题时用处不小。4.2 为每个请求和业务链路打上标识光有机器信息还不够还得让日志能串联起一次完整的请求链路。前面代码里的RequestLogEnricher是自定义类它可以把 HttpContext 里的 TraceIdentifier 和客户端 IP 塞进每条日志属性using Serilog.Core; using Serilog.Events; public class RequestLogEnricher : ILogEventEnricher { private readonly IHttpContextAccessor _httpContextAccessor; public RequestLogEnricher(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor httpContextAccessor; } public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { var httpContext _httpContextAccessor.HttpContext; if (httpContext null) return; logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty( TraceId, httpContext.TraceIdentifier)); logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty( ClientIp, httpContext.Connection.RemoteIpAddress?.ToString())); } }这套设计的好处在微服务场景下尤其明显。假设用户下单失败了你先在 Seq 里用TraceId abc123筛选就能把一次请求从网关到业务服务的所有日志拽到一起不用再逐个服务翻日志拼时间线。除了全局的 TraceId业务字段的上下文也值得记。推荐在业务边界使用BeginScopeusing (_logger.BeginScope(OrderScope {OrderId}, orderId)) { _logger.LogInformation(开始处理订单); // 其他业务逻辑 _logger.LogInformation(订单处理完成); }这样这一段代码产生的所有日志都会自动带上OrderId属性。之后排查某个具体订单的问题直接在 Seq 里搜OrderId 10086所有相关日志一次拉齐比关键词搜消息可靠得多。4.3 日志脱敏别把密钥和隐私写进日志接入了 Seq 之后日志越来越完整风险也随之而来接入面板的日志如果包含密码、Token、连接字符串相当于把这些敏感信息集中放在一个系统里任何人都可以搜到。哪怕内部网络也必须有最基本的红线观念。一个简单的做法是写一个脱敏 Enricher专门处理常见的敏感字段名public class SensitiveDataEnricher : ILogEventEnricher { private static readonly HashSetstring SensitiveKeys new(StringComparer.OrdinalIgnoreCase) { password, pwd, secret, token, api_key, apikey, connectionstring, authorization }; public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { foreach (var propertyName in logEvent.Properties.Keys.ToList()) { if (SensitiveKeys.Contains(propertyName)) { logEvent.AddOrUpdateProperty( propertyFactory.CreateProperty(propertyName, ***REDACTED***)); } } } }注册方式config.Enrich.WithSensitiveDataEnricher();注意这个 Enricher 只能处理已经作为日志属性传入的敏感字段对那些已经用字符串插值拼进消息正文的明文内容是无能为力的。所以最根本的做法是编码规范层面约定连接字符串、密钥、Token 一律不准出现在日志模板里。临时调试必须打印时也要记得在合并代码前删掉。4.4 控制日志级别避免可视化面板被噪音淹没所有日志都接进 Seq 之后最烦的一件事情就是框架的日志噪音。ASP.NET Core 自带一大堆Microsoft.AspNetCore.*的信息级日志如果不去压Seq 首页会被这些无关日志刷屏真正有用的业务日志反而沉到底下。推荐把日志级别写进 appsettings.json 里统一管理用ReadFrom.Configuration读取{ Serilog: { Using: [ Serilog.Sinks.Console, Serilog.Sinks.Seq ], MinimumLevel: { Default: Information, Override: { Microsoft: Warning, Microsoft.AspNetCore: Warning, System: Warning } }, Enrich: [ FromLogContext, WithMachineName, WithEnvironmentName ], WriteTo: [ { Name: Console }, { Name: Seq, Args: { serverUrl: http://localhost:5341 } } ] } }如果你已经在代码里把 Enricher 和 WriteTo 都写好了appsettings.json 里的Enrich和WriteTo就别再重复写否则日志会重复输出。配置文件方式的优势是部署环境不用重新编译改一下 JSON 就能调整日志级别运维同学也能自己操作。5. 常见问题排查与调优经验5.1 日志一直不出现或突然消失日志完全没进 Seq优先检查三个地方。第一WriteTo.Seq的服务地址能不能通容器里用 localhost 是最常见的坑。第二日志级别是不是被压太高了MinimumLevel.Default是 Warning 时Information 日志不显示很正常。第三检查 Seq 管理界面里的 License 状态免费版如果当日入库量或保留天数到了上限新日志虽然能写但你查询时可能看不到显得像“日志消失了”。如果只是本地想快速验证连通性我个人偏好临时加一行.WriteTo.Debug()这样只要应用控制台能输出日志就不会怀疑代码配错了先保证日志生成了再谈可视化。5.2 时间显示错位问题Seq 存储的日志时间默认是 UTC界面显示时会把 UTC 转成本地时区。如果你发现服务器日志时间和 Seq 面板时间差了 8 个小时第一反应不要改应用的日志时间先看 Seq 界面右上角的时间显示设置。很多人想“把日志时间改成北京时间”直接在代码里DateTime.Now拼进消息这反而破坏了结构化日志的时区一致性跨时区排查更乱。正确做法是让日志始终记录 UTC 时间显示层负责时区转换。Seq 和 Kibana 都支持显示时区设定把显示时区切到Asia/Shanghai就行。5.3 高并发下日志丢失Seq 是网络 SinkSerilog 默认会做批量异步写入避免网络等待拖慢业务线程。但批量写入意味着日志不是即时落盘如果进程突然被强杀或容器崩溃还留在队列里的日志就会丢。这个深度越深越影响排查。我的建议是分层处理核心关键日志订单状态变更、支付回调、异常堆栈除了写 Seq再单独加一个本地文件 Sink并使用buffered: false强制同步写入。虽然同步写文件的性能会差一些但换来的是日志可靠性对于写操作密集但是量不大的场景完全值得。日志量大的场景就反过来优先保证业务不阻塞接受一定比例的日志丢失。5.4 出现重复日志重复日志通常来自同一个 AppSettings 里Logging节点和 Serilog 配置同时生效或者代码里写了 Console配置文件里也写了 Console。我的习惯是明确一个原则代码负责 Enricher 和 Sink 的组装配置只负责 MinimumLevel 和 Override。在 Program.cs 里接管 Serilog 之后执行一下builder.Logging.ClearProviders()从源头清掉默认的 Logging Provider这样能最大限度避免重复。5.5 Docker 环境连不上 Seq应用容器和 Seq 容器不在同一个网络时最典型的错误是Connection refused。解决方式有两种一种是把 Seq 的端口映射到宿主机应用容器里填http://宿主机IP:5341另一种是写进同一个 docker-compose然后用服务名http://seq:80。我偏好后者因为服务名在 compose 网络里是稳定的不像 IP 会变。另外别忘了Seq 容器启动时ACCEPT_EULAY这个环境变量必须给否则容器反复重启日志里会出现 EULA 相关提示。很多人在这一步卡了半天其实是容器压根没起来。5.6 查询变慢或日志无限膨胀Seq 默认会把所有日志一直保存时间一长磁盘占用和查询速度都会恶化。建议在 Seq 设置里配置保留策略比如开发环境保留 7 天、生产环境保留 30 天超期自动清理。如果业务上确实需要长期审计日志那可以把审计日志单独走一个文件 Sink需要的时候归档到对象存储而不是一直堆在 Seq 里。查询慢还有一个容易被忽略的原因Seq 界面没有给高频字段做索引。如果你经常用OrderId、UserId过滤去 Seq Settings 的 Indexes 里把这两个字段加进索引查询响应会明显变快。最后说点实际的这套 Serilog Seq 的方案我在一个订单查询的模拟项目里完整跑过一轮最大的感受就是“日志终于可以像数据库一样被查询了”。以前排查一个接口偶发超时要在十几份日志文件里来回翻运气好撞上运气不好一上午就耗进去。现在通过UseSerilogRequestLogging拿到的状态码和耗时字段再加上TraceId关联基本几十秒就能圈定问题范围。个人经验是只要是新开的 .NET 7.0 项目先把 Serilog 和 Seq 的通道接好比什么架构设计都来得实在。如果你也正在做类似改造建议在项目编码规范里就锁定三个原则日志模板必须参数化、敏感字段不进日志、重要业务节点用BeginScope带上业务标识。把这几点落实到位可视化日志平台才真正有用。等哪天日志量真的涨到免费版扛不住再考虑迁移到 ElasticsearchSerilog 换 Sink 的代价非常小完全不用推倒重来。