feat: 完善网关容器化与限流监控

This commit is contained in:
2025-12-03 14:49:33 +08:00
parent 5361451140
commit 6b5da04751
8 changed files with 332 additions and 37 deletions

View File

@@ -1009,3 +1009,50 @@ docker-compose up -d --force-recreate --no-deps api
docker pull takeout-saas-api:previous-version docker pull takeout-saas-api:previous-version
docker-compose up -d docker-compose up -d
``` ```
## 13. 网关 TakeoutSaaS.ApiGateway 部署
1. **部署拓扑**
- Nginx 负责域名 `kjkj.qiyuesns.cn`(含后续 HTTPS 证书),并将所有流量反代到本机 `http://127.0.0.1:5000`
- .NET 网关容器TakeoutSaaS.ApiGateway负责 YARP 路由、限流、日志与 OpenTelemetry 埋点,向下游 49.7.179.246 的 Admin/User/Mini API 转发。
2. **构建与运行**
```bash
# 构建镜像
docker build -f src/Gateway/TakeoutSaaS.ApiGateway/Dockerfile -t takeoutsaas/apigateway:latest .
# 启动容器(生产环境建议挂载独立配置)
docker run -d --name takeout-gateway -p 5000:5000 ^
-e ASPNETCORE_ENVIRONMENT=Production ^
-v /opt/takeoutsaas/gateway/appsettings.Production.json:/app/appsettings.Production.json ^
takeoutsaas/apigateway:latest
```
- `appsettings.json` 默认将 `/api/admin|mini|user/**` 指向主应用服务器7801/7701/7901 端口)。
- `appsettings.Development.json` 可在本地覆盖为 `localhost:5001/5002/5003`,无需改代码。
3. **Nginx 参考配置**
```nginx
server {
listen 80;
server_name kjkj.qiyuesns.cn;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
- 后续启用 HTTPS 时,将 `listen 443 ssl` 与证书配置加入同一 `server`,其余保持不变。
4. **关键配置项说明**
- `Gateway:RateLimiting`:按客户端 IP 固定窗口限流(默认 300 次/60 秒),可通过配置文件调整或关闭。
- `ReverseProxy`:集中声明路由规则(`/api/{service}/**`),后端地址变更时只需改配置即可。
- `OpenTelemetry`:默认开启 OTLP 导出Collector 地址通过 `OpenTelemetry:OtlpEndpoint` 指定。
- `Serilog`:统一输出到控制台,日志采集器可以直接收集 Docker stdout。
5. **健康检查**
- `GET /healthz`:基础健康,用于探活或监控告警。
- `GET /`:返回服务元信息,可作为简易诊断接口。

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.ApiGateway.Configuration;
/// <summary>
/// 网关 OpenTelemetry 导出配置。
/// </summary>
public class GatewayOpenTelemetryOptions
{
/// <summary>
/// 是否启用 OpenTelemetry。
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 服务名称,用于 Resource 标识。
/// </summary>
public string ServiceName { get; set; } = "TakeoutSaaS.ApiGateway";
/// <summary>
/// OTLP 导出端点http/https
/// </summary>
public string? OtlpEndpoint { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.ApiGateway.Configuration;
/// <summary>
/// 网关限流参数配置。
/// </summary>
public class GatewayRateLimitOptions
{
/// <summary>
/// 是否开启固定窗口限流。
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 固定窗口内允许的最大请求数。
/// </summary>
public int PermitLimit { get; set; } = 300;
/// <summary>
/// 固定窗口长度(秒)。
/// </summary>
public int WindowSeconds { get; set; } = 60;
/// <summary>
/// 排队等待的最大请求数。
/// </summary>
public int QueueLimit { get; set; } = 100;
}

View File

@@ -0,0 +1,25 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# 1. 先只复制 csproj 文件 (利用 Docker 缓存)
COPY ["src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj", "src/Gateway/TakeoutSaaS.ApiGateway/"]
# 2. 还原依赖 (如果 csproj 没变,这一步会直接使用缓存,瞬间完成)
RUN dotnet restore "src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj"
# 3. 再复制剩余的所有源代码
COPY . .
# 4. 发布
RUN dotnet publish "src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj" -c Release -o /app/publish
# --- 运行时环境 ---
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .
# 显式声明端口
EXPOSE 5000
ENV ASPNETCORE_URLS=http://+:5000
ENTRYPOINT ["dotnet", "TakeoutSaaS.ApiGateway.dll"]

View File

@@ -1,38 +1,129 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Logs;
using Microsoft.Extensions.Hosting; using OpenTelemetry.Metrics;
using Microsoft.Extensions.Logging; using OpenTelemetry.Resources;
using System.Diagnostics; using OpenTelemetry.Trace;
using Serilog;
using TakeoutSaaS.ApiGateway.Configuration;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
const string CorsPolicyName = "GatewayCors";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
loggerConfiguration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
});
builder.Services.AddReverseProxy() builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddCors(options =>
{
options.AddPolicy(CorsPolicyName, policy =>
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.Configure<GatewayRateLimitOptions>(builder.Configuration.GetSection("Gateway:RateLimiting"));
var rateLimitOptions = builder.Configuration.GetSection("Gateway:RateLimiting").Get<GatewayRateLimitOptions>() ?? new();
if (rateLimitOptions.Enabled)
{
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext => options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{ {
const string partitionKey = "proxy-default"; var remoteIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions return RateLimitPartition.GetFixedWindowLimiter(remoteIp, _ => new FixedWindowRateLimiterOptions
{ {
PermitLimit = 100, PermitLimit = Math.Max(1, rateLimitOptions.PermitLimit),
Window = TimeSpan.FromSeconds(1), Window = TimeSpan.FromSeconds(Math.Max(1, rateLimitOptions.WindowSeconds)),
QueueLimit = 50, QueueLimit = Math.Max(0, rateLimitOptions.QueueLimit),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}); });
}); });
}); });
}
var otelOptions = builder.Configuration.GetSection("OpenTelemetry").Get<GatewayOpenTelemetryOptions>() ?? new();
if (otelOptions.Enabled)
{
builder.Services.AddOpenTelemetry()
// 1. 配置统一的 Resource便于追踪定位。
.ConfigureResource(resource => resource.AddService(otelOptions.ServiceName ?? "TakeoutSaaS.ApiGateway"))
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint))
{
metrics.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(otelOptions.OtlpEndpoint);
});
}
})
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation();
if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint))
{
tracing.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(otelOptions.OtlpEndpoint);
});
}
});
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeScopes = true;
logging.ParseStateValues = true;
if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint))
{
logging.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(otelOptions.OtlpEndpoint);
});
}
});
}
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders();
app.UseExceptionHandler(errorApp => app.UseExceptionHandler(errorApp =>
{ {
// 1. 捕获所有未处理异常并返回统一结构。
errorApp.Run(async context => errorApp.Run(async context =>
{ {
var feature = context.Features.Get<IExceptionHandlerFeature>(); var feature = context.Features.Get<IExceptionHandlerFeature>();
@@ -54,36 +145,40 @@ app.UseExceptionHandler(errorApp =>
}); });
}); });
app.Use(async (context, next) => app.UseSerilogRequestLogging(options =>
{ {
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Gateway"); options.MessageTemplate = "网关请求 {RequestMethod} {RequestPath} => {StatusCode} 用时 {Elapsed:0.000} 秒";
var start = DateTime.UtcNow; options.GetLevel = (httpContext, elapsed, ex) => ex is not null ? Serilog.Events.LogEventLevel.Error : Serilog.Events.LogEventLevel.Information;
await next(context); options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
var elapsed = DateTime.UtcNow - start; {
logger.LogInformation("Gateway {Method} {Path} => {Status} ({Elapsed} ms)", diagnosticContext.Set("TraceId", Activity.Current?.Id ?? httpContext.TraceIdentifier);
context.Request.Method, diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress?.ToString());
context.Request.Path, };
context.Response.StatusCode,
(int)elapsed.TotalMilliseconds);
}); });
app.UseCors(CorsPolicyName);
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter(); app.UseRateLimiter();
}
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
// 确保存在请求 ID便于上下游链路追踪 // 1. 确保请求拥有可追踪的 ID
if (!context.Request.Headers.ContainsKey("X-Request-Id")) if (!context.Request.Headers.ContainsKey("X-Request-Id"))
{ {
context.Request.Headers["X-Request-Id"] = Guid.NewGuid().ToString("N"); context.Request.Headers["X-Request-Id"] = Activity.Current?.Id ?? Guid.NewGuid().ToString("N");
} }
// 透传租户与认证头 // 2. 透传租户等标识头,方便下游继续使用
var tenantId = context.Request.Headers["X-Tenant-Id"]; var tenantId = context.Request.Headers["X-Tenant-Id"];
var tenantCode = context.Request.Headers["X-Tenant-Code"];
if (!string.IsNullOrWhiteSpace(tenantId)) if (!string.IsNullOrWhiteSpace(tenantId))
{ {
context.Request.Headers["X-Tenant-Id"] = tenantId; context.Request.Headers["X-Tenant-Id"] = tenantId;
} }
var tenantCode = context.Request.Headers["X-Tenant-Code"];
if (!string.IsNullOrWhiteSpace(tenantCode)) if (!string.IsNullOrWhiteSpace(tenantCode))
{ {
context.Request.Headers["X-Tenant-Code"] = tenantCode; context.Request.Headers["X-Tenant-Code"] = tenantCode;
@@ -92,13 +187,7 @@ app.Use(async (context, next) =>
await next(context); await next(context);
}); });
app.MapReverseProxy(proxyPipeline => app.MapReverseProxy();
{
proxyPipeline.Use(async (context, next) =>
{
await next().ConfigureAwait(false);
});
});
app.MapGet("/", () => Results.Json(new app.MapGet("/", () => Results.Json(new
{ {
@@ -107,4 +196,11 @@ app.MapGet("/", () => Results.Json(new
Timestamp = DateTimeOffset.UtcNow Timestamp = DateTimeOffset.UtcNow
})); }));
app.MapGet("/healthz", () => Results.Json(new
{
Service = "TakeoutSaaS.ApiGateway",
Status = "Healthy",
Timestamp = DateTimeOffset.UtcNow
}));
app.Run(); app.Run();

View File

@@ -1,4 +1,7 @@
{ {
"OpenTelemetry": {
"Enabled": false
},
"ReverseProxy": { "ReverseProxy": {
"Routes": [ "Routes": [
{ {

View File

@@ -0,0 +1,75 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning",
"Yarp": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
],
"Enrich": [ "FromLogContext" ],
"Properties": {
"Application": "TakeoutSaaS.ApiGateway"
}
},
"Gateway": {
"RateLimiting": {
"Enabled": true,
"PermitLimit": 300,
"WindowSeconds": 60,
"QueueLimit": 100
}
},
"OpenTelemetry": {
"Enabled": true,
"ServiceName": "TakeoutSaaS.ApiGateway",
"OtlpEndpoint": "http://localhost:4317"
},
"ReverseProxy": {
"Routes": [
{
"RouteId": "admin-route",
"ClusterId": "admin",
"Match": { "Path": "/api/admin/{**catch-all}" }
},
{
"RouteId": "mini-route",
"ClusterId": "mini",
"Match": { "Path": "/api/mini/{**catch-all}" }
},
{
"RouteId": "user-route",
"ClusterId": "user",
"Match": { "Path": "/api/user/{**catch-all}" }
}
],
"Clusters": {
"admin": {
"Destinations": {
"primary": { "Address": "http://49.7.179.246:7801/" }
}
},
"mini": {
"Destinations": {
"primary": { "Address": "http://49.7.179.246:7701/" }
}
},
"user": {
"Destinations": {
"primary": { "Address": "http://49.7.179.246:7901/" }
}
}
}
},
"AllowedHosts": "*"
}