☰
.NET 7.0集成Serilog+Seq:打造可视化结构化日志平台实战
2026/10/16 18:00:28 网站建设 项目流程

先纠正题目里的一个说法:“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 容器,几分钟启动中小项目、内部服务免费版有限制,超大规模要付费或换方案
ELK(Elasticsearch + Kibana)三个组件起,先得养 ES 集群日志量大、已有运维体系重,运维成本高
Loki一个容器,但要配 Grafana 展示已有 Grafana 监控体系社区 sink 包质量参差不齐,要试

我的建议是:如果你们团队从零开始,别一上来就搭 ELK,大概率搭完没人维护。先用 Seq 把整个链路跑顺,让开发人员实实在在感受到“日志可以搜”,后面哪天日志量真的大到 Seq 顶不住了,再平滑切到 Elasticsearch,Serilog 换个 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.Thread

Serilog.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.GetRequiredService<IHttpContextAccessor>(); 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(...))这一行,我没有用With<RequestLogEnricher>()的写法。原因是无参的With<T>()走的是 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.yml:

services: seq: image: datalust/seq:latest container_name: seq-log ports: - "5341:80" environment: - ACCEPT_EULA=Y volumes: - seq_data:/data restart: unless-stopped volumes: seq_data:

seq_data卷用来持久化日志数据,容器删了日志还在。端口映射我把容器内的 80 映射到宿主机 5341,浏览器访问http://localhost:5341即可打开管理界面。首次打开会引导设置管理员密码,按提示走就行。

ACCEPT_EULA=Y是必须的,不接受许可协议容器会一直重启。免费版对每日日志入库量和保留天数有限制,具体数字以官方许可证说明为准,但中小团队内部日志量一般够用。

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,业务字段的上下文也值得记。推荐在业务边界使用BeginScope:

using (_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 HashSet<string> 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.With<SensitiveDataEnricher>();

注意这个 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 是网络 Sink,Serilog 默认会做批量异步写入,避免网络等待拖慢业务线程。但批量写入意味着日志不是即时落盘,如果进程突然被强杀或容器崩溃,还留在队列里的日志就会丢。这个深度越深越影响排查。

我的建议是分层处理:核心关键日志(订单状态变更、支付回调、异常堆栈)除了写 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_EULA=Y这个环境变量必须给,否则容器反复重启,日志里会出现 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带上业务标识。把这几点落实到位,可视化日志平台才真正有用。等哪天日志量真的涨到免费版扛不住,再考虑迁移到 Elasticsearch,Serilog 换 Sink 的代价非常小,完全不用推倒重来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询