ASP.NET Core 10 + GreptimeDB:用 OTLP 写日志、用 SQL 读日志的可观测性管道实战

ASP.NET Core 10 Minimal API + Npgsql.EntityFrameworkCore.PostgreSQL + Serilog + Aspire + OTEL + GreptimeDB 搭一套"日志可观测性管道"

不写一行运维脚本,日志自己飞进时序库,查询还能用你最熟的 SQL

技术栈:

  • ASP.NET Core 10 (Minimal API)
  • Serilog.Sinks.OpenTelemetry 4.2.0
  • Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3
  • GreptimeDB Standalone v1.1.4
  • Aspire 13.5.3

  • 使用 Aspire.AppHost 托管服务,一键梭哈
  • GreptimeDB 容器服务启动后会显示
  • 预览 GreptimeDB Dashboard 看板启动效果

关于 greptimedb,请查看:


0. 引子:你的日志,到底去哪儿了?

每个程序员都经历过这样的深夜:生产环境炸了,你 SSH 上服务器,cat /var/log/xxx | grep ERROR,翻到第 800 行,发现日志被 logrotate 切走了。你心里一万头羊驼跑过。

于是你决定上"正经"方案。ELK?内存吃得比你的开发机还狠。Loki?还行,但 Grafana 查询语法又得学一遍。有没有一种可能:日志直接落进一个时序数据库,写入用 OTLP 标准协议,读取用你写了十年的 SQL

有。这就是本文要讲的组合拳。我们顺手把容器编排也交给 Aspire,做到 dotnet run 一把梭,连 docker compose 都懒得写。


1. 为什么是这套组合

角色 选型 它解决了什么"痛点"
Web 框架 ASP.NET Core 10 Minimal API 路由即代码,没有 Controller 样板,日志埋点离业务最近
可观测写入 Serilog.AspNetCore 10.0.0 + Serilog.Sinks.OpenTelemetry 4.2.0 Serilog.NET日志界的事实标准;Sink 直接吐 OTLP,未来换后端不掉头发
存储 GreptimeDB Standalone 时序库原生吃 OTLP,默认 append-only,还能用 PostgreSQL 协议 读------是的,你没看错,SQL 查日志
读取 Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 EF Core 10 + Npgsql 10,零改造复用 PostgreSQL 驱动直连 GreptimeDB4003 端口
编排 Aspire 13.5.3 AddContainerGreptimeDB本地local/K8s 一套代码,端口、卷、生命周期全托管

一句话总结:写用标准协议,读用标准 SQL,编排用标准 Aspire 。没有私有 Agent,没有私有查询语言,没有私有运维脚本。


2. 架构一览

#mermaid-svg-GxUlW1fYFKZEl0Bx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GxUlW1fYFKZEl0Bx .error-icon{fill:#552222;}#mermaid-svg-GxUlW1fYFKZEl0Bx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GxUlW1fYFKZEl0Bx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .marker.cross{stroke:#333333;}#mermaid-svg-GxUlW1fYFKZEl0Bx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GxUlW1fYFKZEl0Bx p{margin:0;}#mermaid-svg-GxUlW1fYFKZEl0Bx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .cluster-label text{fill:#333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .cluster-label span{color:#333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .cluster-label span p{background-color:transparent;}#mermaid-svg-GxUlW1fYFKZEl0Bx .label text,#mermaid-svg-GxUlW1fYFKZEl0Bx span{fill:#333;color:#333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .node rect,#mermaid-svg-GxUlW1fYFKZEl0Bx .node circle,#mermaid-svg-GxUlW1fYFKZEl0Bx .node ellipse,#mermaid-svg-GxUlW1fYFKZEl0Bx .node polygon,#mermaid-svg-GxUlW1fYFKZEl0Bx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GxUlW1fYFKZEl0Bx .rough-node .label text,#mermaid-svg-GxUlW1fYFKZEl0Bx .node .label text,#mermaid-svg-GxUlW1fYFKZEl0Bx .image-shape .label,#mermaid-svg-GxUlW1fYFKZEl0Bx .icon-shape .label{text-anchor:middle;}#mermaid-svg-GxUlW1fYFKZEl0Bx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GxUlW1fYFKZEl0Bx .rough-node .label,#mermaid-svg-GxUlW1fYFKZEl0Bx .node .label,#mermaid-svg-GxUlW1fYFKZEl0Bx .image-shape .label,#mermaid-svg-GxUlW1fYFKZEl0Bx .icon-shape .label{text-align:center;}#mermaid-svg-GxUlW1fYFKZEl0Bx .node.clickable{cursor:pointer;}#mermaid-svg-GxUlW1fYFKZEl0Bx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .arrowheadPath{fill:#333333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GxUlW1fYFKZEl0Bx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GxUlW1fYFKZEl0Bx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GxUlW1fYFKZEl0Bx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GxUlW1fYFKZEl0Bx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GxUlW1fYFKZEl0Bx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GxUlW1fYFKZEl0Bx .cluster text{fill:#333;}#mermaid-svg-GxUlW1fYFKZEl0Bx .cluster span{color:#333;}#mermaid-svg-GxUlW1fYFKZEl0Bx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GxUlW1fYFKZEl0Bx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GxUlW1fYFKZEl0Bx rect.text{fill:none;stroke-width:0;}#mermaid-svg-GxUlW1fYFKZEl0Bx .icon-shape,#mermaid-svg-GxUlW1fYFKZEl0Bx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GxUlW1fYFKZEl0Bx .icon-shape p,#mermaid-svg-GxUlW1fYFKZEl0Bx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GxUlW1fYFKZEl0Bx .icon-shape .label rect,#mermaid-svg-GxUlW1fYFKZEl0Bx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GxUlW1fYFKZEl0Bx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GxUlW1fYFKZEl0Bx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GxUlW1fYFKZEl0Bx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GreptimeDB Standalone v1.1.4
ApiService · .NET 10 Minimal API
OTLP/HTTP protobuf

X-Greptime-DB-Name 等
Npgsql SQL 只读

SELECT ... FROM omc_logs
托管
托管
Aspire AppHost (.NET Aspire 13.5.3)
编排 ApiService + GreptimeDB 容器

WithCertificateTrustScope(None) 修复 SSL_CERT_DIR
业务 Minimal API 端点
Serilog Logger
Serilog.Sinks.OpenTelemetry 4.2.0

Protocol=HttpProtobuf · Endpoint=/v1/otlp
LogQueryService / ObservabilityRepository
ObservabilityDbContext

Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3

Keyless · NoTracking · FromSqlRaw
HTTP 4000

/v1/otlp/v1/logs
PostgreSQL 4003
omc_logs

append-only 表

两条主链路,请刻进 DNA

  • 写入链路(绿色) :业务端点/EndpointsSerilogSerilog.Sinks.OpenTelemetryGreptimeDB 4000/v1/otlp/v1/logs)→ omc_logs
  • 读取链路(橙色)LogQueryServiceObservabilityRepositoryObservabilityDbContextNpgsql)→ GreptimeDB 4003PostgreSQL 协议)→ omc_logs

同一个表,两种协议,互不打架。写入走 OTLP 的"高速批量通道",读取走你最擅长的 SQL


3. 写入侧:Serilog.Sinks.OpenTelemetry 4.2.0 的"默认坑"

3.1 第一个坑:默认协议是 gRPC,不是 HTTP

Serilog.Sinks.OpenTelemetry 4.2.0OpenTelemetrySinkOptions 默认 Protocol = OtlpProtocol.Grpc ,默认端点 http://localhost:4317。而 GreptimeDBOTLP 接收端是 HTTP + 二进制 protobuf ,路径 /v1/otlp。所以你不改协议,日志会一头撞在 gRPC 端口上,GreptimeDB 表示"你谁啊"。

正确姿势:显式指定 HttpProtobuf 并给到 /v1/otlp 这个基址

csharp 复制代码
.WriteTo.OpenTelemetry(options =>
{
    // 必须显式覆盖默认 gRPC,否则 GreptimeDB 收不到
    options.Protocol = Serilog.Sinks.OpenTelemetry.OtlpProtocol.HttpProtobuf;
    // 只填基址 /v1/otlp,Sink 会自动追加 /v1/logs(logs 信号)
    options.Endpoint = "http://localhost:4000/v1/otlp";
    options.ResourceAttributes = new Dictionary<string, object>
    {
        ["service.name"] = "ai4c-omc-apiservice",
    };
});

⚠️ 路径拼接验证点(V1) :OTLP/HTTP exporter 会按信号类型自动在 Endpoint 后追加 /v1/logs。所以填 http://localhost:4000/v1/otlp 最终命中 http://localhost:4000/v1/otlp/v1/logs------恰好是 GreptimeDB 的 Logs 专用路径。别手贱填成 .../v1/otlp/v1/logs,否则会变成 .../v1/logs/v1/logs,GreptimeDB 一脸懵。

3.2 第二个坑:GreptimeDB 要靠 Header 认库认表

GreptimeDB 不像 OTel Collector 那样靠配置认库。它通过 HTTP 头 区分"写进哪个库、哪张表、抽哪些字段"。核心几个(以官方文档为准):

Header 必要性 作用
X-Greptime-DB-Name 推荐 目标数据库名
X-Greptime-Log-Table-Name 可选 日志表名,不填默认 opentelemetry_logs;我们起名 omc_logs
X-Greptime-Log-Extract-Keys 可选 把日志属性(如 trace_id,span_id,level)提升为表的顶级标签列,查询更快
Authorization 生产必选 Basic + Base64(user:pass);本地单机无鉴权可省

Serilog.Sinks.OpenTelemetry 4.2.0 的 HeadersIDictionary<string,string>,正好接上:

csharp 复制代码
options.Headers = new Dictionary<string, string>
{
    ["X-Greptime-DB-Name"]        = "greptime",
    ["X-Greptime-Log-Table-Name"] = "omc_logs",
    ["X-Greptime-Log-Extract-Keys"] = "trace_id,span_id,level",
};

🔎 Headers API 验证点(V2) :若编译期报 Headers 不存在,说明该版本把配置挪了位置,退路是前置一个 OTel Collector 或直接用 gRPC 网关。4.2.0 实测 Headers 存在,可放心用。

写入侧默认表结构含 timestamp(时间索引)、bodyseverity_textlog_attributesJSON)、resource_attributesJSON)等约 14 列,且默认 append-onlybody 还带全文索引。也就是说:你什么建表语句都不用写,日志自己长成一张表。


4. 读取侧:Npgsql EF Core 10.0.3 只读接入

4.1 为什么是 Keyless + NoTracking

GreptimeDB 的日志表是"只追加视图",没有主键、不该被 EF Core 跟踪变更。所以我们用:

  • Keyless 实体HasNoKey(),告诉 EF Core 这不是可被增删改的普通表。
  • NoTracking:只读查询,省去变更跟踪开销,更符合"日志是不可变事实"的语义。
  • FromSqlRaw 直查GreptimeDBSQL 方言和 PostgreSQL 高度兼容,但毕竟不是 100% PG,复杂 LINQ 可能被翻译成它不支持的算子;用 FromSqlRawSQL 控制权握在自己手里最稳。
csharp 复制代码
// 配置类:sealed class(Options 绑定需无参构造 + 可变属性,不用 record)
public sealed class ObservabilityOptions
{
    public string ConnectionString { get; set; } = string.Empty;
    public string TableName { get; set; } = "omc_logs";
}

// 实体:只读投影用 sealed record,值相等语义刚好适合 DTO
public sealed record OtelLogEntry(
    DateTimeOffset Timestamp,
    string SeverityText,
    string Body,
    string? ServiceName,
    string? LogAttributes);

public sealed class ObservabilityDbContext : DbContext
{
    private readonly string _connectionString;
    public ObservabilityDbContext(string connectionString) => _connectionString = connectionString;

    public DbSet<OtelLogEntry> Logs => Set<OtelLogEntry>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseNpgsql(_connectionString)
            .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

    protected override void OnModelCreating(ModelBuilder modelBuilder)
        => modelBuilder.Entity<OtelLogEntry>()
            .HasNoKey()
            .ToTable("omc_logs");
}

🧠 时间戳类型验证点(V5) :GreptimeDB 的 timestamp 列映射 DateTime 还是 DateTimeOffset,取决于它建的是 timestamp 还是 timestamptz。Npgsql 10 默认 timestampDateTimetimestamptzDateTimeOffset先按 DateTimeOffset 试,若抛"kind/range"异常就退回 DateTime。这是实战里最容易卡你的点,别硬刚。
📌 关于 record vs sealed class:DTO/查询出参用 public sealed record(项目里已 26 处实践);配置绑定类用 public sealed class{ get; set; },需要无参构造);EF 实体同理必须是 sealed class。record 的值相等语义会破坏 EF Core 的 identity map,所以配置项实体绝不用 record。

4.2 查询就是一行 SQL 的事

csharp 复制代码
public sealed class ObservabilityRepository(OtelLogEntry context)
{
    public async Task<IReadOnlyList<OtelLogEntry>> SearchAsync(
        string keyword, int pageSize, CancellationToken ct = default)
    {
        // FromSqlRaw:把 SQL 控制权握在手里,规避方言差异
        return await context.Logs
            .FromSqlRaw(
                "SELECT timestamp, severity_text, body, service_name, log_attributes " +
                "FROM omc_logs WHERE body ILIKE {0} ORDER BY timestamp DESC LIMIT {1}",
                $"%{keyword}%", pageSize)
            .ToListAsync(ct);
    }
}

读完你可能会愣一下:这就是查日志?对,这就是查日志。你甚至能在 Grafana 里直接 SELECT * FROM omc_logs WHERE severity_text = 'ERROR',把日志当业务表分析。


5. 编排:Aspire 托管 GreptimeDB(含世纪大坑)

5.1 AddContainer 起一个单机版

当前时段 Aspire 没有 GreptimeDB 官方集成包,但 AddContainer 足以应付。端口规划照搬官方单机模式:4000 / HTTP(OTLP)、4001 / gRPC、4002 / MySQL、4003 / PostgreSQL

csharp 复制代码
var greptimedb = builder.AddContainer("greptimedb", "greptime/greptimedb", "v1.1.4")
    .WithArgs("standalone", "start",
        "--http-addr", "0.0.0.0:4000",
        "--rpc-bind-addr", "0.0.0.0:4001",
        "--mysql-addr", "0.0.0.0:4002",
        "--postgres-addr", "0.0.0.0:4003")
    .WithHttpEndpoint(port: 4000, targetPort: 4000, name: "http")
    .WithEndpoint(port: 4003, targetPort: 4003, name: "pg")
    .WithVolume("omc_greptimedb_data", "/greptimedb_data")
    .WithLifetime(ContainerLifetime.Persistent)
    .WithCertificateTrustScope(CertificateTrustScope.None)   // ← 救命的一行,见下文
    .PublishAsKubernetesService(resource =>
    {
        if (resource.Workload is Deployment deployment)
            deployment.Spec.Replicas = 1;
    });

宿主端口刻意固定(4000/4003),与 Garnet 16379、PostgreSQL 5432 的既有约定一致------appsettings.json 里的连接串写死端口,交给 Aspire 随机分配本地就连不上了。--*-addr 必须绑 0.0.0.0,否则容器里只监听 127.0.0.1,宿主够不着。

5.2 世纪大坑:Aspire 注入的 SSL_CERT_DIR,把 GreptimeDB 整崩溃了

这是整篇文章最值钱的一节。

现象dotnet runGreptimeDB 容器启动即 panic,报错核心一句------

复制代码
No CA certificates were loaded from the system

根因Aspire 会"贴心"地给每个容器注入 SSL_CERT_DIR,值是 冒号分隔的多目录串

复制代码
/usr/lib/ssl/aspire/certs:/etc/ssl/certs:/usr/local/share/ca-certificates:/etc/pki/tls/certs

OpenSSL 笑纳冒号分隔的多目录。但 GreptimeDB 内部用的是 rustls (经由 reqwest/opendal),它在启动时 new reqwest::Client() 会无条件加载 CA,而 rustls 把这一整串当成单个目录路径open------目录当然不存在 → 一个 CA 都没加载 → panic,容器当场去世。

用大白话讲:Aspire 给 GreptimeDB 塞了一张"冒号分隔的通讯录",OpenSSL 能翻,rustls 直接把整本通讯录当成一个文件名去打开,打不开,气绝。

修复Aspire 官方其实留了后门------WithCertificateTrustScope(CertificateTrustScope.None)。它告诉 Aspire:"这个容器自己管证书,别往里塞你的 SSL_CERT_DIR"。GreptimeDB 于是回落到镜像自身(ubuntu 基镜像)的系统 CA 探测 /etc/ssl/certsrustls 正常加载,启动丝滑。

csharp 复制代码
.WithCertificateTrustScope(CertificateTrustScope.None)

⚠️ 顺带提醒:同族的 WithoutHttpsCertificate() 在部分 Aspire 版本是预览期 APIASPIRECERTIFICATES001 编译错误),非必需就别加,核心修复一行 WithCertificateTrustScope(None) 足矣。

💡 若极端情况下镜像精简到连系统 CA 都没有(仍 panic),退路是显式把单目录塞回去(因注入已关,单目录值会真正生效):

.WithEnvironment("SSL_CERT_DIR", "/etc/ssl/certs").WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt")


6. 端到端跑通 Checklist(验证项 V1--V5)

照着这张表逐项打勾,能省一下午调试:

# 验证项 判定方式 不通过的退路
V1 SinkHttpProtobuf 下是否自动追加 /v1/logs Endpointhttp://localhost:4000/v1/otlp,看 4000 是否 200 改填完整 /v1/otlp/v1/logs
V2 OpenTelemetrySinkOptions.Headers 是否存在 编译期即可判定 gRPC 或前置 OTel Collector
V3 Npgsql 10 能否连 GreptimeDB 4003(含 No Reset On Close=true 最小控制台 SELECT 1 改用 GreptimeDB HTTP API
V4 FromSqlRaw<OtelLogEntry> 能否绑定到 positional record 构造参数命中列名 退化为 sealed class + 无参 + { get; set; }
V5 timestamp 映射 DateTimeOffset 还是 DateTime Npgsql 读取是否抛异常 改用 DateTime 重试

7. 一句话总结

写日志用 OTLP 标准协议,存进 GreptimeDB 时序库,读日志用你写了十年的 SQL------编排交给 Aspire,连证书坑都帮你踩平了。

这套组合的甜点区很明确:

  • 中小团队想上"正经可观测"又不想养一整套 ELK/Loki 运维;
  • 已经吃 ASP.NET Core + EF Core 技术栈,希望查询日志和查询业务表用同一套肌肉记忆;
  • 本地 dotnet run 即拉起全栈,上 K8s 同一份 PublishAsKubernetesService 不变。

唯一要牢记的两句咒语:Sink 记得切 HttpProtobufGreptimeDB 容器记得加 WithCertificateTrustScope(None)。念错任何一句,日志都会用最优雅的方式教你做人。


本文代码示例为方案说明,涉及路径、头字段、实体映射等均已标注实战验证点(V1--V5),落地时以 GreptimeDB / Serilog / Npgsql 官方文档与本地实测为准。

相关推荐
changuncle3 个月前
OpenTelemetry搭配aspire-dashboard来监测系统性能
性能监测·opentelemetry·aspire
雪雁8 个月前
CodeSpirit・码灵:以 AI 赋能,重构业务智能边界
aspire·.net 10·codespirit
xiangji10 个月前
Aspire+.NET10+手搓线程池打造抓不死的云应用
高并发·aspire·.net10·手搓线程池
码观天工1 年前
.NET AI Preview 2 发布:从 .NET Aspire 支持到 Qdrant 向量数据库集成,助你快速构建云原生 AI 应用
ai·c#·.net·向量数据库·aspire·qdrant
Bruce_Cheung2 年前
Aspire8一文通
c#·azure·aspire
万雅虎2 年前
.NET服务发现(Microsoft.Extensions.ServiceDiscovery)集成Consul
服务发现·aspire