.NET 微服务实践(5)-搭建指令服务
文章声明:本文系油管上的一个系列(.NET Microservices – Full Course)教程的学习记录的第四章。不涉及营销,有兴趣看可以原视频。
本文分成七部分:脚手架(项目创建和包安装)、控制器(服务外部调用)、同步以及异步消息传递、添加HTTP客户端、部署服务到K8S、内部网络配置、配置API Gateway。
创建项目
ASP.NET Core Web API + .NET Core 5.0
添加依赖的Nuget包
- AutoMapper.Extensions.Microsoft.DependencyInject
- Microsoft.EntityFrameworkCore.5.0.8
- Microsoft.EntityFrameworkCore.Design.5.0.8
- Microsoft.EntityFrameworkCore.InMemory.5.0.8
创建Controller
建立一个Controller用于模拟两个服务之间基于HTTP协议的通信。
using Microsoft.AspNetCore.Mvc; using System; namespace CommandService.Controllers { [Route("api/c/[controller]s")] [ApiController] public class PlatformController : ControllerBase { [HttpPost] public ActionResult TestInboudConnection() { string feedback = ">>>Inbound Post Command Service"; Console.WriteLine($"{feedback}"); return Ok(feedback); } } }
同步和异步消息
在建立平台服务和指令服务通信之前,介绍一下通信的两种方式:同步以及异步消息。
同步消息/Synchronous Message
它的特征包括:
- 整个通信是一个Request/Response闭环
- 请求者不得不等待响应
- 对外暴露接口的服务通常要以以同步消息的模式才可以访问(例如以HTTP请求的方式)
- 服务之间通常需要直到对方是谁
- 一般有两种方式:HTTP请求与GRPC
这里要区分由异步关键词修饰的接口方法。例如
[HttpGet] [Route("{studentName}")] public async Task<ActionResult<Student>> GetStudentByName([FromRoute] string studentName) { }
- 该方法从消息通信的角度来看,还是一个同步消息
- 客户端(或者请求方)仍然需要等待服务端的消息处理和响应
- 它的“异步”体现在它处理请求时的线程管理上:譬如它整个CLR同时处理多个请求时,若该方法被调用且需要等待时,CLR会暂时挂起当前工作者线程去处理其它的请求、等到它完成请求处理后再继续该线程(可能线程Id已经更换),最后返回响应值。
在微服务中,同步消息通信不可避免,同步消息可以将服务建立通信耦合(虽然解耦可能才是微服务体系实施的初衷)、继而建立起服务间的依赖关系。而这种依赖极有可能演化成长依赖关系。
异步消息/Asynchronous Message
与同步消息相反:
- 不存在Request/Response闭环
- 请求者无须等待响应
- 通信的模式是事件驱动的发布订阅模式
- 通常需要用到消息总线(Service Bus)
- 服务之间只需要直到消息总线、而不需要直到对方是谁
- 在服务之间更常用
消息总线对于微服务架构而言是非常重要的,有时作为唯一的消息通信介质、会不可避免得庞大
- 内部通信会因为消息总线的宕机而停滞
- 服务本身由于相互解耦,所以消息总线出现故障并不会影响服务本身的运行
- 应当考虑对消息总线的网络设计、物理层持久化以及集群管理等
- 服务和消息总线之间的通信应该设置Retry策略
- 由于消息总线还是一个队列模式,所以服务的消息发布订阅模式仍然需要围绕小范围服务通信设计
以同步方式调用指令服务
构造调用指令服务的接口并实现
在平台服务中,创建如下接口并实现
using PlatformService.PlatformDomain; using System.Threading.Tasks; namespace PlatformService.Utils.CommandService { public interface ICommandClient { Task SendMessageToCommand(PlatformReadDto platformReadDto); } }
接口的实现
using Microsoft.Extensions.Configuration; using PlatformService.PlatformDomain; using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; namespace PlatformService.Utils.CommandService { public class CommandClient : ICommandClient { private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; public CommandClient(HttpClient httpClient, IConfiguration configuration) { _httpClient = httpClient; _configuration = configuration; } public async Task SendMessageToCommand(PlatformReadDto platformReadDto) { var httpContext = new StringContent( JsonSerializer.Serialize(platformReadDto), Encoding.UTF8, "application/json"); var apiPath = _configuration["CommandService"]; var response = await _httpClient.PostAsync($"{apiPath}/api/v1/platforms/", httpContext); if (response.IsSuccessStatusCode) { Console.WriteLine(">>>Sync Post to CommandService was Ok"); } else { Console.WriteLine(">>>Sync Post to CommandService was NOT Ok"); } } } }
要注意的是,调用CommandService需要知道其地址,这里采用配置(该配置项目前是开发环境:appsettings.Development.json
)的方式来获取地址、注入到HTTP 请求中。
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "CommandService": "https://localhost:44345" }
此外,通信的实现是构造了一个HttpClient,需要在项目启动时,注册CommandService接口以及实现——它会同时注册HttpClient。
//Register HttpClient Factor services.AddHttpClient<ICommandClient, CommandClient>();
与指令服务通信
在原先PlatformController中的创建Platform方法里,添加通知CommandService创建对象这一行为。
private readonly ICommandClient _commandClient; private readonly IMapper _mapper; private readonly IPlatformRepository _platformRepository; public PlatformController( IPlatformRepository platformRepository, IMapper mapper, ICommandClient commandClient) { _commandClient = commandClient; _mapper = mapper; _platformRepository = platformRepository; } [HttpPost] public async Task<ActionResult<PlatformReadDto>> CreatePlatformAsync( [FromBody] PlatformWriteDto platformWriteDto) { if (platformWriteDto is null) { return this.BadRequest(new { Message = "Platform data should not be bull." }); } Console.WriteLine(">>>Creating target Platform..."); var platform = _mapper.Map<Platform>(platformWriteDto); _ = await _platformRepository.CreatePlatformAsync(platform); var platformReadDto = _mapper.Map<PlatformReadDto>(platform); try { await _commandClient.SendMessageToCommand(platformReadDto); } catch (Exception ex) { Console.WriteLine($">>>Could not send synchronously to Command Service: {ex.Message}"); } return CreatedAtRoute( nameof(GetPlatformByIdAsync), new { PlatformId = platform.PlatformId }, platformReadDto); }
和之前API的区别,就是调用了注册的ICommandClient接口。向CommandService发送了创建的通知。
接下来同时启动PlatformService与CommandService,进行通信测试:通过调用PlatformService接口创建Platform,然后查看CommandService中是否有收到Platform的创建消息、以及PlatformService中是否收到CommandService的回传响应。
通过Postman调用本地PlatformService创建Platform:
本地CommandService接收到Platform被创建的消息:
本地PlatformService接收到CommandService回传的响应
部署两个服务到K8S中,在集群内实现两个服务的通信
上面的部署已经实现了两个服务在本机上的通信。接下来将实现在K8S集群内部,实现两个服务的通信。步骤分成
- 容器化CommandService
- 配置两个服务的ClusterIP(作为集群内通信的地址)
- 通信测试
容器化CommandService
类似于PlatformService的容器镜像制作过程,为CommandService配置容器化脚本
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build-env WORKDIR /app COPY *.csproj ./ RUN dotnet restore COPY . ./ RUN dotnet publish -c Release -o out FROM mcr.microsoft.com/dotnet/aspnet:5.0 WORKDIR /app COPY --from=build-env /app/out . ENTRYPOINT ["dotnet", "CommandService.dll"]
构建镜像并运行
docker build -t <docker hub id>/commandservice . docker run -p 8080:80 -d <docker hub id>/commandservice
部署在Docker中的CommandService容器:
将构建的镜像推送到Docker Hub上,用于之后在K8S中部署。
docker push <docker hub id>/commandservice
Kubernetes IP 类型
K8S中有以下几种IP:
Node IP:Kubernetes集群中每个节点(服务器)物理网卡的IP地址,这是一个真实存在的物理网络(也可能是虚拟机网络)。它的作用是集群中每个节点之间通过该网络地址实现相互之间的通信,或者集群节点与集群外服务可以通过该网络地址加端口(NodeIP: NodePort)进行相互通信。
kubectl get nodes kubectl describe node <Node Name>
获取当前集群的Node
获取集群节点的详细信息
可以看到当前集群只有一个Node、Docker Desktop,详细信息中Address的Internal IP就是Node IP。
- Port IP:Pod IP是每个Pod的IP地址,它是Docker Engine根据docker网桥的IP地址段进行分配的,通常是一个虚拟的二层网络。基于Port IP,同Service下的pod可以直接根据Pod IP相互通信;不同Service下的pod在集群间Pod通信要借助于Cluster IP;Pod和集群外通信,要借助于Node IP。
kubectl get podes kubectl describe pod <Node Name>
获取集群节点的Pod(PlatformService)详细信息
可以看到PlatformService的Port IP地址为10.1.0.50,如果有一个该Port同时与另一个Port同在一个服务下(比如说Node Port Service),另一个Port则可以通过该IP地址访问PlatformService。
目前Node Port Service下只有一个Port、对应的APP指向PlatformService。
- Cluster IP:Service的IP地址,此为虚拟IP地址。外部网络无法ping通,只有kubernetes集群内部访问使用——不同Service下的Pod节点在集群间可以通过Cluster IP实现相互访问。
用于PlatformService的Cluster IP Service
Cluster IP的Endpoint指向PlatformService的PortIP。目前给我的感觉是,不同Port的Cluster IP是互连的,Port IP通过Cluster IP这一层虚拟IP达成不同Service下Port之间的相互通信。
配置两个服务的ClusterIP
回到上一章提到的架构设计
对于PlatformService以及CommandService,两个服务由于都在集群中,相互之间的通信将通过集群内的Service(访问ClusterIP地址)实现。
在原来的platforms-depl.yaml文件中新增配置如下:
apiVersion: apps/v1 kind: Deployment metadata: name: platforms-depl spec: replicas: 1 selector: matchLabels: app: platformservice template: metadata: labels: app: platformservice spec: containers: - name: platformservice image: <Docker Hub Id>/platformservice:latest --- apiVersion: v1 kind: Service metadata: name: platforms-clusterip-svc spec: selector: app: platformservice ports: - name: platformservice protocol: TCP port: 80 targetPort: 80
其中selector
中的app name
以及ports
中的name
共同指向之前部署的容器名。
注意这里其实也可以将配置ClusterIP的服务独立出来作为service配置。
也为CommandService配置相同的文件
apiVersion: apps/v1 kind: Deployment metadata: name: commands-depl spec: replicas: 1 selector: matchLabels: app: commandservice template: metadata: labels: app: commandservice spec: containers: - name: commandservice image: <Docker Hub Id>/commandservice:latest --- apiVersion: v1 kind: Service metadata: name: commands-clusterip-svc spec: selector: app: commandservice ports: - name: commandservice protocol: TCP port: 80 targetPort: 80
由于之后两个服务部署之后(生产环境)都是在集群内部实现通信,因此在PlatformService中不再通过localhost去访问CommandService,而是应该改成ClusterIP的地址。
在PlatformService中新增appsettings.Production.json中,添加CommandService的地址:
{ "CommandService": "http://commands-clusterip-svc:80" }
在上一篇中提到过,service作为一种通信的抽象,集群内部的调用其它容器服务将通过调用Service来实现,因此此时PlatformService容器会调用CommandService容器的ClusterIP服务。这里的80端口是Service暴露给集群内其它Pods组的端口,PlatformService的容器调用时,顺序是Port(80)→ Target Port(80)→ Container Port(80),最终访问到CommandService容器。
部署两个服务
对于PlatformService,由于更新了代码、需要重新构建镜像并推送到Docker Hub。然后重新运行PlatformService的部署脚本
kubectl apply -f platforms-depl.yaml
发现部署的脚本(platforms-depl)没有改动,新增的ClusterIP (platforms-clusterip-svc)服务提示已经创建。
因此,需要强制让K8S从Docker Hub上拉取最新的PlatformService镜像(里面配置了生产环境调用CommandService的地址,以及更新了接口、用于发送消息给CommandService)。
kubectl rollout restart deployment platforms-depl
可以看到执行命令后,旧的Pod还在运行的同时、新的Pod正在创建,等新的Pod创建好之后、旧的Pod就被删除了。在Docker Desktop中也可以看到一致的表现:编号1标记的旧Pod已经被删除,编号2标记的新Pod开始运行。
可以看到重新运行的容器中已经打印出当前它可以访问的CommandService地址。
接着再部署CommandService到K8S中
kubectl apply -f commands-depl.yaml
部署CommandService到K8S中
测试部署的服务
为PlatformService配置的NodePort是30001,IP地址是localhost。进行部署的服务测试
现在整体的调用顺序是Node Port(localhost,3001)→ Port(NP Service,80)→Target Port(PlatformService,80)→ Container Port(PlatformService,80)→ Port(ClusterIP Service,80)→Target Port(CommandService,80)→ Container Port(CommandService,80)。
配置API Gateway
再来回顾一下整个微服务体系的架构。虽然有NodePort作为访问平台服务的入口,但是对于访问指令服务、以及在大量流量汇聚访问两个服务的情况,当前的架构并不能很好地处理。解决方案之一,是添加一个反向代理的入口,通过负载平衡,允许访问两个服务。
Ingress Nginx 部署
通过以下命令可以直接部署Ingress-nginx,具体信息可以查看官方说明
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.2.0/deploy/static/provider/cloud/deploy.yaml
可以看到Docker-Desktop中已经部署了Ingress-Nginx
也可以通过命令行具体查看,不过需要指定命名空间
配置Ingress Nginx访问平台与指令服务
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: ingress-svc annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/user-regex: 'true' spec: rules: - host: microservicetrial.com http: paths: - pathType: Prefix path: "/api/v1/platforms" backend: service: name: platforms-clusterip-svc port: number: 80 - pathType: Prefix path: "/api/c/platforms" backend: service: name: commands-clusterip-svc port: number: 80
注意事项:
host
:充当主机的URL,ingress会监听发送到该URL的请求。该URL的应当与集群的IP地址保持一致path
:指的是访问的API地址backend
中,service name需要和配置的cluster service name保持一致port
应当与cluster service的targer port保持一致
配置Hostfile
在C:\Windows\System32\drivers\etc
地址中,打开hosts文件
在其中为之前设置的host url地址配置对应的IP
这里实际过程是,当我们访问microservicetrial.com时,浏览器会先在hosts中找到对应的URL解析的IP地址,如果DNS能够解析得到一个地址,那么浏览器的访问会被劫持、直接转向K8S集群;此时,Ingress就会监听到请求,并把请求转发给对应的服务。
部署Ingress Nginx服务
在包含ingress配置文件的目录下,运行
kubectl apply -f ingress-svc.yaml
此时可能会提示报错
Error from server (InternalError): error when creating "ingress-svc.yaml": Internal error occurred: failed calling webhook "validate.nginx.ingress.kubernetes.io": Post "https://ingress-nginx-controller-admission.ingress-nginx.svc:443/networking/v1/ingresses?timeout=10s": dial tcp 10.102.215.51:443: connect: connection refused
此提示说明了Ingress-nginx控制器没有响应:这是由于在配置ingress时,一些内容没有被完全配置好(需要手动配置),然而依赖这些内容的全局对象、如ValidatingWebhookConfiguration仍然存在。因此快速的解决方案是,删掉ValidatingWebhookConfiguration ;另一种方案(未尝试),是打开443端口、同时在配置ingress-nginx时配置好证书认证 。
下面是针对第一种解决方案的演示
kubectl delete -A ValidatingWebhookConfiguration ingress-nginx-admission
之后再重新部署服务就会顺利完成
测试Ingress Nginx
Postman中,分别测试通过Ingress访问集群的平台服务,创建和获取对象
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下