feat: 完善网关容器化与限流监控
This commit is contained in:
@@ -1009,3 +1009,50 @@ docker-compose up -d --force-recreate --no-deps api
|
||||
docker pull takeout-saas-api:previous-version
|
||||
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 /`:返回服务元信息,可作为简易诊断接口。
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
25
src/Gateway/TakeoutSaaS.ApiGateway/Dockerfile
Normal file
25
src/Gateway/TakeoutSaaS.ApiGateway/Dockerfile
Normal 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"]
|
||||
@@ -1,38 +1,129 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using OpenTelemetry.Logs;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.ApiGateway.Configuration;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
const string CorsPolicyName = "GatewayCors";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext();
|
||||
});
|
||||
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy(CorsPolicyName, policy =>
|
||||
{
|
||||
const string partitionKey = "proxy-default";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 100,
|
||||
Window = TimeSpan.FromSeconds(1),
|
||||
QueueLimit = 50,
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
|
||||
});
|
||||
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 =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
{
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(remoteIp, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = Math.Max(1, rateLimitOptions.PermitLimit),
|
||||
Window = TimeSpan.FromSeconds(Math.Max(1, rateLimitOptions.WindowSeconds)),
|
||||
QueueLimit = Math.Max(0, rateLimitOptions.QueueLimit),
|
||||
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();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
app.UseExceptionHandler(errorApp =>
|
||||
{
|
||||
// 1. 捕获所有未处理异常并返回统一结构。
|
||||
errorApp.Run(async context =>
|
||||
{
|
||||
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");
|
||||
var start = DateTime.UtcNow;
|
||||
await next(context);
|
||||
var elapsed = DateTime.UtcNow - start;
|
||||
logger.LogInformation("Gateway {Method} {Path} => {Status} ({Elapsed} ms)",
|
||||
context.Request.Method,
|
||||
context.Request.Path,
|
||||
context.Response.StatusCode,
|
||||
(int)elapsed.TotalMilliseconds);
|
||||
options.MessageTemplate = "网关请求 {RequestMethod} {RequestPath} => {StatusCode} 用时 {Elapsed:0.000} 秒";
|
||||
options.GetLevel = (httpContext, elapsed, ex) => ex is not null ? Serilog.Events.LogEventLevel.Error : Serilog.Events.LogEventLevel.Information;
|
||||
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||
{
|
||||
diagnosticContext.Set("TraceId", Activity.Current?.Id ?? httpContext.TraceIdentifier);
|
||||
diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress?.ToString());
|
||||
};
|
||||
});
|
||||
|
||||
app.UseRateLimiter();
|
||||
app.UseCors(CorsPolicyName);
|
||||
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
app.UseRateLimiter();
|
||||
}
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
// 确保存在请求 ID,便于上下游链路追踪。
|
||||
// 1. 确保请求拥有可追踪的 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 tenantCode = context.Request.Headers["X-Tenant-Code"];
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
context.Request.Headers["X-Tenant-Id"] = tenantId;
|
||||
}
|
||||
|
||||
var tenantCode = context.Request.Headers["X-Tenant-Code"];
|
||||
if (!string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
context.Request.Headers["X-Tenant-Code"] = tenantCode;
|
||||
@@ -92,13 +187,7 @@ app.Use(async (context, next) =>
|
||||
await next(context);
|
||||
});
|
||||
|
||||
app.MapReverseProxy(proxyPipeline =>
|
||||
{
|
||||
proxyPipeline.Use(async (context, next) =>
|
||||
{
|
||||
await next().ConfigureAwait(false);
|
||||
});
|
||||
});
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.MapGet("/", () => Results.Json(new
|
||||
{
|
||||
@@ -107,4 +196,11 @@ app.MapGet("/", () => Results.Json(new
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
app.MapGet("/healthz", () => Results.Json(new
|
||||
{
|
||||
Service = "TakeoutSaaS.ApiGateway",
|
||||
Status = "Healthy",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
app.Run();
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"OpenTelemetry": {
|
||||
"Enabled": false
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"Routes": [
|
||||
{
|
||||
|
||||
75
src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json
Normal file
75
src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json
Normal 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": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user