NYC's Blog - 云原生 http://niyanchun.com/category/cloud-native/ 云原生技术。 k8s网络学习(4)——Service的艺术 http://niyanchun.com/k8s-network-4-service.html 2021-02-16T21:59:00+08:00 K8s的网络主要需要解决4个问题:高度耦合的容器间的通信问题Pod与Pod之间的通信Pod和Service之间的通信(本文)外部系统和Service之间的通信(本文涉及一部分)上篇文章讨论了第2个问题,本文继续讲述第3个问题和第4个问题:Pod和Service之间的通信、外部系统和Service之间的通信。其核心就在Service,官方文档(英文,中文)已经介绍的比较详细了,本文主要是学习笔记和总结。我们在k8s中部署应用时典型的操作是下面这样的:图中通过Deployment部署了两个应用:app1:有3个副本:pod r1、pod r2、pod r3,且暴露了一个service:app1-svc;app2:有2个副本:pod r1'、pod r2',且暴露了一个service:app2-svc。其中app2需要访问app1,所以app2的Pod通过app1暴露的app1-svc访问app1。这个流程是生产中常用的一种部署方式,可以看到Service是访问服务的核心所在。为什么需要Service答案大家都知道,因为在k8s设计中,Pod是非永久性资源(nonpermanent resources),也就是它可能会随时挂掉。所以还设计了Deployment/RC来保证挂掉之后能重新创建新的Pod(当然实际的工作是由kube-controller-manager做的,再具体点就是Replication Controller),然而新创建的Pod IP地址一般都会发生改变。所以尽管按照k8s的网络模型,pod和pod之间的网络是通畅的,我们也不会直接使用Pod的IP去访问Pod,而是通过一个更加“稳定”的抽象资源Service。当创建一个Service的时候,集群就会给他分配一个集群内唯一的IP,这个IP称之为“ClusterIP”,该IP只能在集群内访问。只要不主动删除这个Service,它就会一直存在,IP也不会改变。需要注意的是,这个ClusterIP是一个虚拟IP(Virtual IP),只有和Service中定义的端口(port)配合使用时才有意义,其它端口都是没用的(所以不要尝试ping它,不会有响应的)。当然光稳定还不够,Service是一个虚拟的资源,自身不能提供应用功能,要对外提供服务,必须要能够找到后端真正提供服务的应用Pod。在查找方式上面,又分为两类Service:1,带selector的Service。这是最常用的,比如上面图中app1-svc的定义可能就类似下面这样:apiVersion: v1 kind: Service metadata: name: app1-svc spec: selector: app: app1 ports: - protocol: TCP port: 80 targetPort: 9376带selector的Service匹配后端Pod的方式就是看哪些Pod具有和自己selector中一样的label,匹配到的Pod会被加到这个Service的Endpoint(端点,也是k8s的一种资源)中去,这个匹配的工作是由Endpoints Controller去做的。比如我的集群里面有一个kube-dns Service,我们可以通过下面的命令查询这个service的endpoints:➜ ~ kubectl -n kube-system get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kube-dns ClusterIP 10.96.0.10 <none> 53/UDP,53/TCP,9153/TCP 49d ➜ ~ kubectl -n kube-system get endpoints kube-dns NAME ENDPOINTS AGE kube-dns 10.244.0.140:53,10.244.1.32:53,10.244.0.140:9153 + 3 more... 49d2, 不带selector的Service。把上面service里面的selector去掉,就是不带selector的service了:apiVersion: v1 kind: Service metadata: name: app1-svc spec: ports: - protocol: TCP port: 80 targetPort: 9376因为没有了selector,所以k8s无法自己去匹配端点,这个时候需要用户自己定义端点,比如像下面这样:apiVersion: v1 kind: Endpoints metadata: name: app1-svc subsets: - addresses: - ip: 192.0.2.42 ports: - port: 9376从这个定义可以看出来,其实Service不仅仅能匹配Pod,还可以匹配其它服务(比如非容器服务),因为端点的定义里面填的是一个连接信息。这也是不带selector的Service的用途:在当前k8s集群使用集群之外的服务/应用。比如另外一个k8s集群的服务,抑或是非容器化的服务,典型的比如部署在物理机上面的DB。为什么用Service而不是DNS从上面的分析可以看到,Service的核心作用就是在不稳定的Pod之上做了一层封装,将Pod所代表的服务稳定的暴露出来,同时提供一些负载均衡或其它方式的路由功能。而这种需求场景在没有容器产生之前就有了,而且有很多种策略方案,最常用的一个就是DNS。比如Google的搜索服务,后台会成千上万台机器支撑,但对外就暴露了一个www.google.com域名,不同地区的用户访问这个域名,会被DNS解析到不同的机器上面去,基本实现的功能和上面说的Service是一样的,那k8s为什么没有使用DNS技术,而是设计了一个基于虚拟IP(ClusterIP)的Service呢?官方是这样解释的:There is a long history of DNS implementations not respecting record TTLs, and caching the results of name lookups after they should have expired.Some apps do DNS lookups only once and cache the results indefinitely.Even if apps and libraries did proper re-resolution, the low or zero TTLs on the DNS records could impose a high load on DNS that then becomes difficult to manage.大致翻译一下:长久以来,DNS的实现都没有遵守TTL记录,而且在名称查找结果到期后仍然进行缓存。有些应用程序仅执行一次DNS查找,然后无限期地缓存结果。即使应用和库进行了适当的重新解析(即没有上面2条问题),比较低甚至为0的DNS TTL会给DNS服务带来很高的负载,从而使管理变得困难。可以看到,核心问题就在解析,所以k8s直接使用虚拟IP,而不是DNS。Service到Pod的流量转发——kube-proxy通过selector或者手动创建Endpoints(没有selector的service)的方式找到后端只是实现了第一步,当客户端访问Service的时候,还需要把流量也转发过去,这样才算完成功能的闭环。这个流量转发的工作就是通过运行在每个节点上面的网络代理kube-proxy实现的。目前支持代理TCP、UDP和SCTP协议的流量,默认是TCP,具体见Supported protocols。kube-proxy支持3种代理模式:user space proxy modeiptables proxy modeIPVS proxy mode下面分别介绍。user space proxy mode在user space proxy模式下,kube-proxy会watch k8s控制平面Service和Endpoints对象的增加和删除。每当有Service创建的时候,就会在本地随机挑选一个端口进行监听,当有请求发到这个代理端口的时候,就会被转发到后端的Endpoint,也即转发到实际的Pod。同时,kube-proxy也会创建iptable规则,将发送到ClusterIP:port上面的流量转发到上面的随机端口上。整个流程图如下(图片来自官网):在user space proxy模式下,kube-proxy默认使用round-robin算法向后端转发流量。iptables proxy modeuser space proxy如其名字所示,是在用户空间的,工作的时候需要从用户态切换到内核;而iptables是工作在内核空间的(准确的说iptables只是个配置规则的工具,实质解析规则、发挥作用的是内核中的netfilter模块),所以基于iptables的iptables proxy也是直接工作在内核空间的。在该模式下,kube-proxy依旧会watch k8s控制平面Service和Endpoints对象的增加和删除。当监测到增加Service的时,就会创建iptable规则,将发送到ClusterIP:port上面的流量转发到后端Endpoints上面(最终会转发到Pod上面)。当监测到Endpoints增加时,就会创建iptables,选择一个后端的Pod。默认情况下,工作在该模式的kube-proxy是随机选择后端的。因为工作在内核空间,iptables proxy会比user space proxy更加可靠一些。但有一个弊端就是在iptables proxy模式下,如果最终转发的某个Pod没有响应,那请求就会失败。而user space proxy模式下,则会自动重试其它后端。如果要避免这种问题,可以在部署Pod时增加readiness probes检测,这样就可以提前剔除不正常的Pod了。iptables proxy模式的流程如下(图片来自官网):IPVS proxy mode讲解IPVS proxy mode之前先了解一些背景知识。维基百科的定义是这样的:IPVS (IP Virtual Server) implements transport-layer load balancing, usually called Layer 4 LAN switching, as part of the Linux kernel. It's configured via the user-space utility ipvsadm(8) tool.IPVS is incorporated into the Linux Virtual Server (LVS), where it runs on a host and acts as a load balancer in front of a cluster of real servers. IPVS can direct requests for TCP- and UDP-based services to the real servers, and make services of the real servers appear as virtual services on a single IP address. IPVS is built on top of the Netfilter.Linux Virtual Server (LVS) is load balancing software for Linux kernel–based operating systems.以下摘自IPVS-Based In-Cluster Load Balancing Deep Dive:什么是 IPVS ?IPVS (IP Virtual Server)是在 Netfilter 上层构建的,并作为 Linux 内核的一部分,实现传输层负载均衡。IPVS 集成在 LVS(Linux Virtual Server,Linux 虚拟服务器)中,它在主机上运行,并在物理服务器集群前作为负载均衡器。IPVS 可以将基于 TCP 和 UDP 服务的请求定向到真实服务器,并使真实服务器的服务在单个IP地址上显示为虚拟服务。 因此,IPVS 自然支持 Kubernetes 服务。为什么为 Kubernetes 选择 IPVS ?随着 Kubernetes 的使用增长,其资源的可扩展性变得越来越重要。特别是,服务的可扩展性对于运行大型工作负载的开发人员/公司采用 Kubernetes 至关重要。Kube-proxy 是服务路由的构建块,它依赖于经过强化攻击的 iptables 来实现支持核心的服务类型,如 ClusterIP 和 NodePort。 但是,iptables 难以扩展到成千上万的服务,因为它纯粹是为防火墙而设计的,并且基于内核规则列表。尽管 Kubernetes 在版本v1.6中已经支持5000个节点,但使用 iptables 的 kube-proxy 实际上是将集群扩展到5000个节点的瓶颈。 一个例子是,在5000节点集群中使用 NodePort 服务,如果我们有2000个服务并且每个服务有10个 pod,这将在每个工作节点上至少产生20000个 iptable 记录,这可能使内核非常繁忙。另一方面,使用基于 IPVS 的集群内服务负载均衡可以为这种情况提供很多帮助。 IPVS 专门用于负载均衡,并使用更高效的数据结构(哈希表),允许几乎无限的规模扩张。可以看到,IPVS底层技术和iptables一样,都是基于内核的Netfilter,不过使用哈希表作为底层数据结构,所以更加高效。在IPVS proxy模式下,kube-proxy同样监控Service和Endpoints,根据两者的变动调用netlink接口创建对应的IPVS规则,并定期将规则与Service和Endpoints进行同步。当客户端访问Service的时候,IPVS根据规则将请求转发到后端Pod。流程图如下:相比于前面的两种proxy,IPVS proxy支持更多的转发规则:rr: round-robinlc: least connection (smallest number of open connections)dh: destination hashingsh: source hashingsed: shortest expected delaynq: never queue如果使用IPVS proxy模式,必须确保在kube-proxy启动前就安装好IPVS(IPVS依赖6个内核模块:ip_vs、ip_vs_rr、ip_vs_wrr、ip_vs_sh、nf_conntrack_ipv4)。如果kube-proxy没有检测到必须的内核模块,就会回退使用iptables proxy。几种代理模式注意点1, 如何设置基于Session Affinity进行转发?从上面的介绍可以看到,不同的proxy模式,转发的默认规则也不一样。有默认基于round-robin的,也有随机的。如果想使用Session Affinity的话,在Service定义时将service.spec.sessionAffinity定义为ClientIP即可(默认值为None),比如下面的示例:apiVersion: v1 kind: Service metadata: name: app1-svc spec: sessionAffinity: ClientIP # 默认值为 None ports: - protocol: TCP port: 80 targetPort: 93762, 如何指定使用哪种proxy?k8s 1.2版本之前默认使用user space proxy。1.1版本中支持了iptables proxy,并且从1.2版本开始将iptables proxy设置为默认proxy。1.8版本中引入了IPVS模块,但默认没有开启。用户可以通过kube-proxy的命令行参数--proxy-mode选择要使用的proxy模式,可选值为:userspace、iptables、ipvs。如果为空(默认),则使用对应版本的默认值。需要注意的是,iptables proxy和ipvs proxy都对内核版本有要求,因为他们依赖的内核功能是在后来的内核中加入的。所以即使用户设置了ipvs,如果节点内核不满足需求,就会自动降级使用iptables;如果内核版本或者iptables版本仍然太低,就会降级为userspace。当然,不论使用哪种proxy,对于最终使用service的人都是无感知的。多端口Service多端口服务就是一个服务暴露多个端口,比如像下面这样同时暴露80和443端口:apiVersion: v1 kind: Service metadata: name: my-service spec: selector: app: MyApp ports: - name: http protocol: TCP port: 80 targetPort: 9376 - name: https protocol: TCP port: 443 targetPort: 9377当暴露多个端口的时候,name字段必须填写。服务发现有了Service以后,其它Pod还需要能够发现这个Service才可以,k8s支持两种方式:环境变量和DNS。环境变量这种方式很简单,当我们创建一个Service的时候,kubelet会生成两个环境变量注入到之后启动的所有Pod里面。这两个环境变量是:{SVCNAME}_SERVICE_HOST和{SVCNAME}_SERVICE_PORT,服务名称会全部转为大写,如果包含中划线还会转为下划线。有了服务名和端口,就可以访问服务了。以系统自带的kubernetes Service为例:➜ ~ kubectl get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 32d # 部署一个busybox pod,然后查看其环境变量: ➜ ~ kubectl exec -it busybox -- env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOSTNAME=busybox TERM=xterm KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1 KUBERNETES_SERVICE_HOST=10.96.0.1 # {SVCNAME}_SERVICE_HOST KUBERNETES_SERVICE_PORT=443 # {SVCNAME}_SERVICE_PORT KUBERNETES_SERVICE_PORT_HTTPS=443 KUBERNETES_PORT=tcp://10.96.0.1:443 KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443 KUBERNETES_PORT_443_TCP_PROTO=tcp KUBERNETES_PORT_443_TCP_PORT=443 HOME=/root这种方式很简单,但弊端似乎也很明显:当服务多了以后,环境变量会非常非常多,不利于维护。另外一个非常大的弊端是这种方式对于顺序有要求:如果要一个Pod要能够被注入某个Service相关的环境变量信息,那这个Pod就必须在Service之后才创建,这种顺序依赖对于大型系统几乎是不可接受的,所以环境变量这种方式在大型系统中使用的较少。更多的都是使用DNS。DNS手动部署过k8s集群的人都知道,DNS组件在k8s中是一个可选的组件,是以扩展(addons)的形式提供的。但因为环境变量有上述所说的问题,所以一般都会部署一个DNS集群,常用的有kube-dns和core-dns(推荐)。DNS组件会监控新服务的产生,当监控到一个服务被创建后,就会给该服务创建对应的DNS记录。如果DNS功能是整个集群可用的话,所有的Pod就能根据这个DNS记录解析到对应的服务。每个服务的DNS名创建的规则是:{service-name}.{namespace}。处于同一个Namespace的Pod访问服务时,可以直接使用{service-name};处于不同Namespace事,需要带上Namespace。DNS会将该DNS名字解析为服务的ClusterIP。无头服务(Headless Service)当我们不需要Service提供的负载均衡或虚拟IP时,就可以使用无头服务。定义无头服务的方式就是将service.spec.clusterIP字段设置为None。此时,如何找到后端需要根据是否定义了service.spec:selector进行区分:定义了selector:根据selector匹配后端即可;没有定义selector时,分两种情况:如果是ExternalName类型的Service(后面有介绍),DNS则直接返回定义的CNAME记录;如果是其它类型的Service,DNS则查找与Service名称相同的任何Endpoints的记录。比如ElasticSearch的9300端口是ES集群内部通信使用的,不需要暴露给用户(即不需要虚拟IP),所以该端口上面的服务可以定义成无头服务:apiVersion: v1 kind: Service metadata: name: elasticsearch-cluster namespace: elasticsearch spec: clusterIP: None selector: app: es-cluster ports: - name: transport port: 9300集群外访问Service有的时候我们需要从集群外部访问Service,目前有两大类方式:通过k8s提供的Service Type(service.spec.type)里面的一些类型:NodePort、LoadBalancer、ExternalName。这些方式都是工作在L4的,只能提供基础的负载均衡功能。使用Ingress。Ingress不是Service type,它也是一种独立的k8s API资源。Ingress是专门用于解决集群外访问Service问题的,它工作在L7,所以拥有更加强大的功能,比如高级的流量转发、SSL等,后面单独文章专门介绍。下面介绍几种Service Type。ClusterIPClusterIP是service.spec.type的默认值,即将服务暴露到集群内部的一个虚拟IP(ClusterIP)上面,此时是无法从外部访问集群的。所以该方式不算是从集群外部访问Service的方式,这里放在这里,以方面是为了完整介绍service.spec.type的可选值,另一方面,下面介绍的其它几种Service Type一般也会自动创建ClusterIP,而且外部的流量进来一般也是转发到这个内部的ClusterIP上面。NodePortNodePort的原理是这样的:当我们定义一个使用NodePort的Service时,k8s控制平面就会从kube-apiserver的--service-node-port-range参数定义的端口范围内选择一个未使用的端口,然后每个节点上面都打开这个端口。如果用户定义了service.spec.ports[*].nodePort,则使用用户定义的端口;若该端口已经被占用,则Service创建失败。NodePort几乎是相对较小的k8s集群对外暴露服务最常用的方式了,因为它无依赖,且使用比较简单。一个示例资源文件如下:apiVersion: v1 kind: Service metadata: name: elasticsearch-nodeport namespace: elasticsearch spec: selector: app: es-cluster type: NodePort ports: - name: http port: 80 # 如果不设置`targetPort`,则默认与`port`一样. targetPort: 9200 # 如果不设置,则会在`--service-node-port-range` (default: 30000-32767)设置的范围内选一个可用的端口 nodePort: 30091 protocol: TCP这里涉及了3个端口:port: 即ClusterIP暴露的端口,用户通过ClusterIP:port来访问后端的应用。需要注意的是这个端口并不监听在节点上面,所以在节点上面是看不到的,所以也无法通过节点IP:port来访问服务。targetPort: 即后端Pod里面的容器服务监听的端口,前面介绍的kube-proxy就是要将port的流量转发到targetPort去。nodePort: 即NodePort这种方式对外暴露的端口。这个端口是暴露在各个节点上的,所以我们可以直接通过任意一个节点IP:nodePort的方式访问服务。上面的elasticsearch-nodeport Service创建后如下:➜ ~ kubectl get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE elasticsearch-nodeport NodePort 10.98.96.32 <none> 80:30091/TCP 21d ➜ ~ kubectl describe svc elasticsearch-nodeport Name: elasticsearch-nodeport Namespace: default Labels: <none> Annotations: Selector: app=es-cluster Type: NodePort IP: 10.98.96.32 # 即使使用NodePort,也会分配一个ClusterIP Port: http 80/TCP TargetPort: 9200/TCP NodePort: http 30091/TCP Endpoints: <none> Session Affinity: None External Traffic Policy: Cluster Events: <none>也就是通过上面定义的Service,我们有3种方式可以访问后端Pod里面9200端口上面的ElasticSearch服务:集群内:直接访问Pod:PodIP:targetPort,即PodIP:9200通过ClusterIP访问:ClusterIP:port,即10.98.96.32:80;集群外:通过NodePort暴露的端口访问:任意一个节点IP:30091。默认情况下,通过NodePort这种方式暴露出来的服务,可以使用任意节点的任意IP访问,但如果一个节点有多个网卡,我们只想让某个网卡可以访问的话(生产环境经常有多个网卡用于不同用途,且相互隔离),可以给kube-proxy增加--nodeport-addresses=10.0.0.0/8参数,这样kube-proxy就只会在处于当前IP范围的IP上暴露服务了。LoadBalancer这种方式一般需要云厂商支持,定义方式类似下面这样:apiVersion: v1 kind: Service metadata: name: elasticsearch-loadbalancer namespace: elasticsearch spec: type: LoadBalancer selector: app: es-cluster ports: - name: http port: 9200 targetPort: 9200LoadBalancer方式的Service创建是异步的,所以即使你的k8s集群没有LoadBalancer功能,你也可以创建该类型的Service,只不过会一直分配不到外部IP(EXTERNAL-IP),比如我的k8s集群:➜ ~ kubectl -n logkeeper-adv get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE elasticsearch-loadbalancer LoadBalancer 10.101.94.173 <pending> 9200:31763/TCP 21dLoadBalancer方式的很多功能都是由云厂商自己实现的,所以不同厂商的产品可能不太一样,需要结合自己使用的产品查看。ExternalName这种类型主要用于以Service的方式集成外部服务,比如外部数据库。定义方式如下:apiVersion: v1 kind: Service metadata: name: my-service namespace: prod spec: type: ExternalName externalName: my.database.example.com定义该服务后,当访问my-service.prod.svc.cluster.local时,集群的DNS就会返回my.database.example.com这样一个CNAME记录。该类型需要注意的是:externalName字段需要填写DNS名称,如果直接填写IP,也会被当成是DNS名称去解析,而不是直接当成IP使用;该服务类型的工作方式与前面介绍的几种大致相同,唯一区别在于重定向发生在DNS解析时,而之前介绍的服务类型重定向或者转发是由kube-proxy做的。External IPs当我们有一些可用的外部IP可以路由到集群的话,也可以通过External IPs这种方式将服务暴露出去。看个例子吧:apiVersion: v1 kind: Service metadata: name: my-service spec: selector: app: MyApp ports: - name: http protocol: TCP port: 80 targetPort: 9376 externalIPs: - 80.11.12.10当我们创建一个这样的Service之后,就可以通过externalIP:port(即80.11.12.10:80)访问后端的9376端口上面的服务。此时Service Type设置为上面介绍的任意一种都行。这里省略了,默认就使用ClusterIP。总结本文内容篇幅较长,但核心内容就3个:为什么需要Service?因为Service能够提供一个稳定的虚拟IP,并且将流量负载均衡到后端Pod上面。如何将Service的流量转发到后端Pod上面?这个是通过运行在各个节点上面的kube-proxy实现的,而kube-proxy则支持3种运行模式:userspace proxy、iptables proxy,IPVS proxy。如何从集群外部访问Service?可以通过NodePort、LoadBalancer、ExternalName这几种Service Type或者ExternalIPs将Service暴露到集群外面。除了这些,还有更高级的工作在L7的Ingress,下篇文章介绍。 k8s网络学习(3)——网络模型&amp;&amp;CNI&amp;&amp;CNM http://niyanchun.com/k8s-network-3.html 2021-02-08T20:25:00+08:00 K8s的网络主要需要解决4个问题:高度耦合的容器间的通信问题Pod与Pod之间的通信(本文)Pod和Service之间的通信外部系统和Service之间的通信上篇文章讨论了第1个问题,本文继续讲述第2个问题:Pod与Pod之间的通信。k8s网络模型pod是k8s中最小的部署单元,不同pod可能会分布在不同机器上面,解决了Pod之间的通信问题,其实也就解决了跨主机容器通信的问题。而k8s在这方面的设计上考虑的不仅要解决通信问题,还要以一种对应用层使用更友好的方式。所以便提出了k8s网络模型:集群中的每个Pod都要有自己的IP,这个模型也称之为“IP-per-pod”。同时,k8s还要求所有实现该模型的网络实现都满足以下要求:一个节点上的pod可以和所有节点上面的Pod以非NAT的方式进行通信;一个节点上的代理(比如系统守护、kubelet)可以和该节点上面的所有Pod通信;运行在主机网络中的pod也可以和所有节点上面的Pod以非NAT的方式进行通信。这3个要求基本将集群内所有Pod/容器的网络直接打通了,整个集群的容器网络将完全扁平化,而且还没有使用NAT这种方式(NAT会带来性能损耗,限制集群规模;而且因为替换了源地址,很难追溯,不利于排错)。可以看到这个IP-per-pod(其实也是IP-per-container,只不过同一个Pod内的容器IP相同)的模型以及3个要求都非常简单清楚,而且从网络通信的角度看,当实现了这个模型以后,其实每个Pod相当于传统意义上的一个虚拟机或者物理机了,原来在虚拟机或者物理机部署的应用几乎可以不用做任何网络层面的适配就可以迁移到Pod里面。而这个模型规定的是一个结果,但实现这个结果有很多种方式,如果没有一个统一的标准(特别是如何使用),大家都各自按自己想法去实现,对于最终的使用者来说将是一场噩梦。所以容器网络的标准便产生了,只是中间发生了一些有趣的故事。CNM、CNI和k8s的恩怨情仇如该系列第一篇文章所述,早期的docker提供的网络非常简单:只有基于“veth-pair+linux bridge”单机容器通信。后来随着容器的快速发展,容器跨主机通信的需求越来越迫切,于是Docker公司收购了一家做SDN的初创公司SocketPlane专门研究跨主机容器通信方案,并最终提出了一套容器网络标准CNM(Container Network Model),并且将网络部分的代码从原来的docker核心代码中剥离出来,形成了一个单独的项目libnetwork,该项目便是第一个实现CNM的项目,并且作为docker容器内置的网络插件。没过多久,另外一个做容器的公司CoreOS也提出了另外一套容器网络标准CNI(Container Network Interface),相比于CNM,CNI更加通用、简单。而就在Docker CNM发布的时候,Kubernetes内部已经实现了一个很初级的网络插件Kubenet,这个插件非常简单,也只有单机容器网络的能力,要实现容器跨机通信,需要依赖一些云厂商提供的cloud provider,或者使用一些其它网络插件(比如flannel)。也就是当时容器网络有3个势力:k8s的kubenet、Docker的CNM、CoreOS的CNI。一方面k8s的kubenet相比于CNM和CNI还处于非常早期(alpha版本),而CNM和CNI都已经相对成熟,尤其是CNM,已经发布正式版了;另一方面,k8s社区也想更多的借助开源的力量,所以便决定从CNM或者CNI中选择一个作为k8s的容器网络标准。起初k8s社区是比较倾向于使用更加成熟一些的CNM,但由于一些技术和非技术的原因,最终却选择了CoreOS的CNI作为k8s的容器网络标准。至于其中的缘由,k8s创始人之一 Tim Hockin还专门发布了一篇文章:Why Kubernetes doesn’t use libnetwork。简单概括一下:技术方面:最主要的技术原因是CNM在设计之初就是结合docker去设计的,所以做了一些可能只有docker才有的假设,这使得要想将CNM作为一个通用容器的网络标准比较困难。而CNI却没有这些问题,虽然也是基于CoreOS自己的容器运行时rkt进行设计,但却更加开放、灵活,可以和其它容器运行时使用,是一个通用的容器网络标准,而且也的确比CNM更加简单,这也正符合Kubernetes的设计理念:简单。也比较符合UNIX的设计理念:doing one thing well。非技术方面:k8s开发者觉得Docker公司并没有打算让CNM变得开放、独立。他们给libnetwork提了很多让CNM更加独立的PR和优化建议,都没有采纳。更多细节可以看原文。在k8s没有站CNI的队之前,很多公司都还在开发符合CNM的插件,但在k8s选择CNI之后,一些大公司马上也选择了CNI,比如像Cloud Foundry、Mesos这些比较大的容器平台。如今CNI也已经是CNCF的一员,并且CNCF也致力于将CNI变为工业标准,CNI可以说是前途一片光明。最后说些题外话:站在现在来看,个人觉得Docker公司其实挺“悲壮”的。作为一个创业公司,在短短几年间让容器在IT界“家喻户晓”,成为业界的明星、宠儿。同时,设计的容器镜像分层技术更是一笔宝贵的财富,包括Docker Hub。然而由于是基于开源的运作方式,一时很难找到商业变现的途径。而后来发现可以在容器编排方面实现商业变现,却无奈碰到了k8s这种神一样的对手。现在k8s已经成为容器编排领域的事实标准(docker swarm以其简单性也还有少量的市场),甚至是云时代的操作系统。随着CRI、CNI等厂商独立的标准成熟,Docker的统治地位越来越被削弱。现在看来,docker最有价值的部分似乎就剩下Docker Hub和镜像了。对于容器行业来说,也许Google这样的巨头推出的k8s更加符合需求,更能促进行业的开放、发展。但从创业的角度来说,在巨头面前,docker这样的小创业公司的命运又不得让人唏嘘。不过不管是CNI还是CNM,都没有涉及网络策略(Network Policy),但这对于平台的网络是非常重要的,特别是公有云和混合云。所以k8s提出了Network Policies,一些比较新的符合CNI的插件,一般也会实现网络策略的功能。本系列是介绍k8s网络的,所以本文就不再介绍CNM模型了,以后单独介绍吧。有兴趣的看这里CNM design。CNI介绍CNI的完整介绍可参见官方文档:CNI SPEC v0.4.0,要更好的理解CNI,最好是结合CNI plugin介绍,不过本文就先不深入介绍了,这里先挑一些比较通用的知识简单介绍。架构从概念上说,CNI标准和CNM类似,都是在容器运行时和具体的网络之间设计了一层抽象,对上层提供统一接口,屏蔽下层的网络差异和细节,如下图(图片来自网络)所示:具体的网络细节都由下层的插件实现。从功能上说,CNI就是定义了创建网络(ADD)和删除网络(DEL)两个接口,各个插件来实现这两个接口。然后CRI在创建容器时创建网络,并调用ADD接口将容器加入该网络;在容器销毁时调用DEL将容器从网络中删除,必要时删除这个网络。如下图(图片来自网络):相比于CNM,CNI首先在接口设计上就很简化,只有上述2个核心接口(随着发展,又增加了一些接口,但核心的还是这两个)。另外,在配置文件方面CNI也很简单:CNM需要一个分布式key-value来存储其网络配置,而CNI的配置就是一个JSON文件,存储在本地。更多的细节就不介绍了,英文好建议直接看我前面附的官方SPEC,喜欢看中文的可以看这篇翻译文章:CNI - Container Network Interface(容器网络接口)。如何在k8s中使用CNICNI标准规定实现CNI的插件必须是一个可以被容器管理系统(比如k8s、rkt)调用的可执行文件。所以从最终使用者的角度而言,在k8s中使用CNI就是准好好这个可执行文件,外加一个JSON格式的配置。Kubelet和CNI规定了2个默认的路径:/etc/cni/net.d:默认的CNI配置文件存储目录。如果要修改默认目录,在kubelet启动参数中通过--cni-conf-dir指定。如果指定目录下有多个配置文件,则使用文件名字典序的第一个。/opt/cni/bin:默认的CNI插件存储目录。如果要修改默认目录,在kubelet启动参数中通过--cni-bin-dir指定。k8s要求CNI插件的最低版本为0.2.0。随便查看一个使用了CNI的k8s集群节点(kubelet的启动参数中有--network-plugin=cni),都可以看到这两个目录。比如我的两个k8s集群:# 集群1,使用的是Flannel插件 ➜ ~ ls /opt/cni/bin/ bandwidth bridge dhcp firewall flannel host-device host-local ipvlan loopback macvlan portmap ptp sbr static tuning vlan ➜ ~ ls /etc/cni/net.d/ 10-flannel.conflist ➜ ~ cat /etc/cni/net.d/10-flannel.conflist { "name": "cbr0", "cniVersion": "0.3.1", "plugins": [ { "type": "flannel", "delegate": { "hairpinMode": true, "isDefaultGateway": true } }, { "type": "portmap", "capabilities": { "portMappings": true } } ] } # 集群2,使用的是Calico插件 ➜ ~ ls /opt/cni/bin bandwidth calico dhcp flannel host-local loopback portmap sbr tuning bridge calico-ipam firewall host-device ipvlan macvlan ptp static vlan ➜ ~ ls /etc/cni/net.d 10-calico.conflist calico-kubeconfig ➜ ~ cat /etc/cni/net.d/10-calico.conflist { "name": "k8s-pod-network", "cniVersion": "0.3.1", "plugins": [ { "type": "calico", "log_level": "info", "datastore_type": "kubernetes", "nodename": "epimoni", "mtu": 1440, "ipam": { "type": "calico-ipam" }, "policy": { "type": "k8s" }, "kubernetes": { "kubeconfig": "/etc/cni/net.d/calico-kubeconfig" } }, { "type": "portmap", "snat": true, "capabilities": {"portMappings": true} }, { "type": "bandwidth", "capabilities": {"bandwidth": true} } ] }其中的name字段就是网络名称,type字段就是实现对应功能的二进制文件名,可以看到一个完整的网络功能往往需要调用多个不同的插件,特别是对于那些功能不完整的插件,比如上面的10-calico.conflist中不仅使用了calico实现基本的k8s网络模型,还使用了portmap提供端口映射服务,使用bandwidth提供带宽控制功能。可见这种机制是非常简单灵活的,可以根据需求进行组合配置。总结本篇主要介绍了k8s采用的网络标准CNI,但也主要是介绍了比较通用的部分,并没有详细介绍每个CNI Plugin在k8s里面的具体使用,以及各个插件是如何实现k8s的网络模型的。这部分每个不同的插件是不一样的,而且比较细节化,后面单独分几篇文章来介绍常用的CNI插件,比如flannel、calico。 k8s网络学习(2)——pause容器的秘密 http://niyanchun.com/k8s-network-2.html 2021-02-06T22:51:00+08:00 上一篇文章介绍了单机容器网络的基础知识,本篇开始介绍K8s的网络。本文假定你已经对K8s的各种基本概念(比如Node、Pod、Service等)非常了解,如果还不了解,可以参考我之前的博客:Kubernetes架构及资源关系简单总结。K8s的网络主要需要解决4个问题:高度耦合的容器间的通信问题(本文)Pod与Pod之间的通信Pod和Service之间的通信外部系统和Service之间的通信本文介绍第一个问题:高度耦合的容器间的通信问题。什么是高度耦合?举个例子,比如我使用的博客系统typecho就是由nginx+php+mysql组成。如果要容器化部署的话,那就需要3个容器:nginx、php、mysql。这3个容器单独都无法提供博客功能,只有组合到一起时才可以成为一个完整可用的对外系统,这种就是高度耦合的容器。如果你是使用docker去部署这么一个博客系统,那就需要自己去配置这3个容器之间的网络通信。而K8s在设计之初就考虑到了这个问题,提出了Pod的概念。简单理解,一个Pod有点类似于传统的虚机、物理机,不过非常轻量化,Pod也是K8s里面最小的部署单元。对Pod还不熟悉的朋友可以参考官方文档:Pods,本文就不介绍Pod的细节了,只介绍Pod的网络部分。从最佳实践的角度讲,高度耦合的容器一般部署在一个Pod里面,这样他们就类似于部署在同一个虚机或者物理机上面,可以直接通过本地回环地址(127.0.0.1)进行通信。那这个是如何实现的呢?秘密就在Pause容器。K8s的pause容器有两大作用:它作为Pod内的“系统容器”,提供一个基础的Linux Namespace,以供其它用户容器加入。如果开启了PID Namespace共享的话,它还作为Pod的“init进程”(pid=1,也即所有其它容器的父容器,所有其它进程的父进程),负责僵尸进程的回收。下面分别介绍。Pause容器的代码这里先贴一下pause容器的代码,方便后面分析。Github: pause.c:#include <signal.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #define STRINGIFY(x) #x #define VERSION_STRING(x) STRINGIFY(x) #ifndef VERSION #define VERSION HEAD #endif static void sigdown(int signo) { psignal(signo, "Shutting down, got signal"); exit(0); } static void sigreap(int signo) { while (waitpid(-1, NULL, WNOHANG) > 0) ; } int main(int argc, char **argv) { int i; for (i = 1; i < argc; ++i) { if (!strcasecmp(argv[i], "-v")) { printf("pause.c %s\n", VERSION_STRING(VERSION)); return 0; } } if (getpid() != 1) /* Not an error because pause sees use outside of infra containers. */ fprintf(stderr, "Warning: pause should be the first process\n"); if (sigaction(SIGINT, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0) return 1; if (sigaction(SIGTERM, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0) return 2; if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap, .sa_flags = SA_NOCLDSTOP}, NULL) < 0) return 3; for (;;) pause(); fprintf(stderr, "Error: infinite loop terminated\n"); return 42; }这段C代码去除注释和空行,还不到50行,功能也非常简单,但其作用却非常大,真的很神奇。功能1:共享Namespace在Linux系统中,当我们创建一个新的子进程的时候,该进程就会继承父进程的Namespace,K8s的Pod就是模拟这种方式。创建Pod时,里面第一个被创建的容器永远都是pause容器(只不过是系统自动创建的),pause容器创建好之后,才会创建用户容器。系统(CRI和CNI)会为pause容器创建好Namespace,后面的用户容器都加入pause的Namespace,这样大家就处于同一个Namespace了,类似于大家都在一个“虚拟机”里面部署。对于网络而言,就是共享Network Namespace了。下面用docker模拟一下k8s的Pod过程。第一步:模拟创建Pod的“系统容器”pause容器。➜ ~ docker run -d --name pause -p 8080:80 --ipc=shareable registry.aliyuncs.com/k8sxio/pause:3.2 184cfffc577e032902a293bf1848f6353bd8a335f57d6b20625b96ebca8dfaa0 ➜ nginx docker ps -l CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 184cfffc577e registry.aliyuncs.com/k8sxio/pause:3.2 "/pause" 13 seconds ago Up 11 seconds 0.0.0.0:8080->80/tcp pause这里的操作就是创建一个docker容器,并将容器内的80端口映射到外部的8080端口。需要注意的是为了让其他容器可以共享该容器的IPC Namespace,需要增加--ipc=shareable。第二步:创建“用户容器” nginx。nginx默认是监听80端口的,先创建一个配置nginx.conf:error_log stderr; events { worker_connections 1024; } http { access_log /dev/stdout combined; server { listen 80 default_server; server_name example.com www.example.com; location / { proxy_pass http://127.0.0.1:2368; } } }配置非常简单,将所有请求转发到127.0.0.1:2368。然后启动nginx容器,不过该容器共享pause容器的Namespace,而不是像docker默认行为那样创建自己的Namespace:➜ ~ docker run -d --name nginx -v `pwd`/nginx.conf:/etc/nginx/nginx.conf --net=container:pause --ipc=container:pause --pid=container:pause nginx 660af561bc2d5936b3da727067ac30663780c5f7f73efe288b5838a5044c1331 ➜ nginx docker ps -l CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 660af561bc2d nginx "/docker-entrypoint.…" 4 seconds ago Up 3 seconds nginx此时,curl一下本机的8080(也就是pause容器的80)端口,就可以看到以下信息:➜ ~ curl 127.0.0.1:8080 <html> <head><title>502 Bad Gateway</title></head> <body> <center><h1>502 Bad Gateway</h1></center> <hr><center>nginx/1.19.6</center> </body> </html>也就是说pause容器的80端口上面是nginx服务,这是因为它们都处于一个Network Namespace。这里报502是因为我们再nginx配置里面配了所有请求都转发到2368端口,但这个端口没有服务监听。第三步:再创建一个“用户容器” ghost(一个博客系统,默认监听2368端口):➜ ~ docker run -d --name ghost --net=container:nginx --ipc=container:nginx --pid=container:nginx ghost b455cb94dcec8a1896e55fc3ee9ac1133ba9f44440311a986a036efe09eb9227 ➜ ~ docker ps -l CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES b455cb94dcec ghost "docker-entrypoint.s…" 4 seconds ago Up 3 seconds ghost容器启动后,我们直接在浏览器访问本机的8080端口,就可以看到ghost的web页面了:此时,高度耦合的两个容器nginx+ghost就在一个Namespace里面了,特别是处于同一个Network Namespace(可以通过127.0.0.1相互访问),让他们的通信变得极其方便。如果再加上pause容器,那他们3个就是k8s里面的一个Pod了。其关系如下图(图片来自第一篇引用的文章):这整个过程便是我们在k8s中部署容器时Pod里面发生的过程。最后再回头看一下pause.c,我们发现该代码最后是一个死循环+pause调用,pause函数的作用是让当前进程暂停,进入睡眠状态,直到被信号中断。先不考虑信号中断的话(下面介绍),这个程序几乎就是无限睡眠。这样设计的原因是在Linux里面,要维持Namespace的存在,就必须有一个属于该Namespace的进程或者文件等对象存在(虽然有手动删除Namespace的命令,但如果有对象存在,删除其实是个假删除,真正删除发生在最后一个对象消亡时)。所以为了维护Pod里面pause创建的Namespace,pause就必须一直存在。好,最后总结一下,当我们在k8s里面创建容器时,CRI(容器运行时,比如docker)和CNI(容器网络接口,负责容器网络)首先创建好pause容器这个系统容器,这就包含创建并初始化各种Namespace(Network Namespace部分由CNI负责)。之后再创建用户容器,用户容器加入pause的Namespace。其中Network Namespace是默认就共享的,PID Namespace则需要看配置。功能2:充当系统init进程在Linux系统中,pid=1的进程我们称之为“init”进程,是内核启动的第一个用户级进程,现在比较新的Linux发行版的init进程就是systemd进程。这个进程有许多工作,其中一个重要工作就是负责“收养孤儿进程”,防止产生太多僵尸进程。简单介绍一下相关的基本概念:Linux系统维护了一个进程表,记录每个进程的状态信息和退出码(exit code),当一个进程退出时,它在表中的信息会一直保留,直到其父进程调用wait(包括waitpid)获取其退出码。所谓僵尸进程就是进程已经退出了,但它的信息还在进程表里面。正常情况下,进程退出时父进程会马上查询该表,并回收子进程的相关资源,所以僵尸进程的持续状态一般都很短。但如果(1)父进程启动子进程之后没有调用wait或者(2)父进程先于子进程挂掉了,那子进程就会变成僵尸进程。如果是第2种情况,即父进程先于子进程死掉了,那操作系统就会把init进程设置为该父进程所有子进程的父进程,即init进程收养了该父进程的所有子进程。当这些子进程退出时,init进程就会充当父进程的角色,从而避免长时间的僵尸进程。但对于第1种情况,一般认为是代码有缺陷,这种情况因为子进程的父进程存在(只是没有调用wait而已),init进程是不会做处理的。此时子进程会成为僵尸进程长期存在,如果要消除这种僵尸进程,只能kill掉父进程。而pause容器的第二个功能就是充当这个init进程,负责回收僵尸进程。从上面的代码可以看到,pause启动的时候会判断自己的pid是否为1。不过如果要实现该功能,则Pod内的所有容器必须和pause共享PID Namespace。在K8s 1.8版本之前默认是开启PID Namespace共享的,之后版本默认关闭了,用户可以通过--docker-disable-shared-pid=true/false自行设置。开启PID Namespace的好处就是可以享受pause回收僵尸进程的功能,并且因为容器同处于一个PID Namespace,进程间通信也会变得非常方便。但也有一些弊端,比如有些容器进程的PID也必须为1(比如systemd进程),这就会和pause容器产生冲突,另外也涉及一些安全问题。这里只讨论回收僵尸进程的这个功能。为了更确切的观察,我们再启动一个busybox容器,并加入到之前pause容器的Namespace里面:➜ ~ docker run -idt --name busybox --net=container:pause --ipc=container:pause --pid=container:pause busybox:1.28 5b6a92bab7c0861b4bebc96115c43ae502b38f788a9b40a9d6c5f8bc77f8fc2d ➜ ~ docker exec -it busybox sh / # ps -ef PID USER TIME COMMAND 1 root 0:00 /pause 7 root 0:00 nginx: master process nginx -g daemon off; 35 101 0:00 nginx: worker process 46 root 0:00 sh 59 1000 0:23 node current/index.js 127 root 0:00 sh 133 root 0:00 ps -ef可以看到,pause容器是系统内的“init进程”(pid=1)。然后看一下上面pause.c,里面有对SIGCHLD信号的处理,该信号是子进程退出时发给父进程的。注册的信号处理函数sigreap里面调用了waitpid来等待所有的子进程(第一个参数为-1),这样就可以实现僵尸进程的回收了。那能否用其它进程作为init进程呢?比如不使用特殊的pause容器,而是直接创建一个nginx容器,然后ghost加入nginx进程?一般是不行的,因为普通的进程里面不一定有wait所有子进程的操作。不过在容器化里面,僵尸进程的问题已经不是很严重了,因为最佳实践都是一个容器里面一个进程,当进程挂掉的时候,容器也就销毁了。可能在传统的docker里面,有时为了方便高耦合的几个应用通信,会在一个容器里面启动多个进程,但在k8s里面基本不会有这种场景,因为有Pod这种设计的存在(这真是一个过分优秀的设计)。总结K8s通过Pod这个优秀的设计解决了高度耦合容器之间的通信,其机制也非常简单:就是增加了一个非常简单但却很精妙的pause容器,该容器由系统创建维护,对用户透明,它负责创建好Linux Namespace,用户容器直接加入即可。同时,该容器还有回收僵尸进程的作用。不得不说,Kubernetes的很多设计真的非常符合Unix/Linux设计哲学:小而简单(small, simple)!Reference:The Almighty Pause ContainerKubernetes Documentation: Cluster NetworkingKubernetes网络权威指南 k8s网络学习(1)——单机容器网络 http://niyanchun.com/k8s-network-1.html 2021-02-06T12:47:00+08:00 最近阅读了一下《Kubernetes网络权威指南:基础、原理与实践》,觉得是非常不错的一本书,本系列Kubernetes网络的文章也是阅读此书后的一些实践、扩展阅读,算是学习笔记和总结吧。我觉得容器(Linux容器)的发展大致可以分这么三个阶段:第一阶段:Linux内核时代,内核提出并实现了Namespace技术,为容器技术奠定了基石,但此时容器还只是一颗深埋的种子,大多数人还不知道。第二阶段:Docker时代,此时深埋的种子破土而出,出现在众人的视野中。Docker公司基于Linux Namespace技术推出的Docker容器,一下子引爆了容器领域,让大多数人知道了容器技术。但此时容器主要用于平时的开发,生产中还无法大规模使用,因为容器还有很多问题没有解决,比如跨主机的网络通信、容器的管理等。第三阶段:Kubernetes时代,Kubernetes的出现,补齐了容器在生产方面应用的短板,解决了跨主机网络通信、容器管理等问题,因为是站在Google Borg这个巨人的肩膀上,所以一出现,就成为大杀器。目前Kubernetes基本已经成为容器化领域的事实标准,CNCF基金会的绝大多数技术也都是基于Kubernetes。现在容器技术正处于如火如荼的发展中,一方面是很多公司开始将一些传统IT架构迁移到容器上面;另一方面,基于容器的一些新技术也层出不穷。从目前看,服务网格(Service Mesh)有可能会成为下一代的火热技术。回到网络,容器化的网络方案主要要解决3个问题:单主机的容器通信(主机与容器、容器与容器的通信)跨主机间的容器通信容器与主机间的通信(更多指跨主机)其中1最好解决,本文的内容也是描述docker是如何解决问题1的。容器的基石:Linux Kernel NamespaceLinux系统里面有各种资源:用户、用户ID、进程、进程ID、套接字、主机名等等,Linux内核提供了Namespace这种机制用来隔离这些资源,从Linux kernel 3.8开始共有6个:Mount Namespace: 隔离文件系统挂载点UTS Namespace: 隔离主机名和域名信息IPC Namespace: 隔离进程间通信PID Namespace: 隔离进程PIDNetwork Namespace: 隔离网络资源User Namespace: 隔离用户和用户组ID此外,Linux kernel 4.6 (2016.3) 中又加入了Control group (cgroup) Namespace;Linux kernel 5.6 (2018提出,2020.3发布) 中增加了Time Namespace。Namespace非常好理解,就是隔离资源,让其内的进程感觉自己看到的是系统所有的资源。默认情况下,Linux有一个默认的根Namespace(Root Namespace)。每个进程都有一个/proc/[pid]/ns/目录,下面是该进程所属的Namespace(以符号链接的形式存在):➜ ~ ls -l /proc/$$/ns | awk '{print $1, $9, $10, $11}' # $$代表当前shell进程的pid,也可以换成任意进程的pid,比如init进程的pid 1 total lrwxrwxrwx cgroup -> cgroup:[4026531835] lrwxrwxrwx ipc -> ipc:[4026531839] lrwxrwxrwx mnt -> mnt:[4026531840] lrwxrwxrwx net -> net:[4026531992] lrwxrwxrwx pid -> pid:[4026531836] lrwxrwxrwx pid_for_children -> pid:[4026531836] lrwxrwxrwx user -> user:[4026531837] lrwxrwxrwx uts -> uts:[4026531838]后面的数字是对应文件的inode,处于同一个Namespace的进程这个符号链接的inode值也是相等的。关于Namespace的更多信息可参考:Linux namespaces Wikipedianamespaces(7) — Linux manual pageNamespace技术是容器的基石,接下来要讨论的网络就是基于Network Namespace的,所以通过一些例子感受一下Network Namespace。Network NamespaceNetwork Namespace用于隔离网络资源,比如网卡、IP地址、端口、路由表、防火墙等。下面通过一些实验感受一下Network Namespace(使用iproute2包里面的ip命令,具体的命令这里就不解释了,可通过ip help和ip <sub-cmd> help自行查看):# 查看Network Namespace列表(不包含默认的根Namespace) ➜ ~ ip netns list # 创建一个名为demo_netns的Network Namespace ➜ ~ ip netns add demo_netns # 再次查看,已经创建好了 ➜ ~ ip netns list demo_netns对比一下宿主机的Network Namespace(后面也简称NN):# 宿主机的NN中的网络资源 ➜ ~ ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3 valid_lft 85496sec preferred_lft 85496sec inet6 fe80::ec5a:5f1d:d894:fd26/64 scope link noprefixroute valid_lft forever preferred_lft forever 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff inet 192.168.56.5/24 brd 192.168.56.255 scope global noprefixroute enp0s8 valid_lft forever preferred_lft forever inet6 fe80::202c:ab9:1e26:f0a4/64 scope link noprefixroute valid_lft forever preferred_lft forever # 新创建的NN里面的网络资源 ➜ ~ ip netns exec demo_netns ip addr 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00可以看到宿主机的根NN里面有3个网卡(lo、enp0s3、enp0s8),都是UP状态,且都分配了IP;而新创建的NN中只有一个lo网卡,且是DOWN的状态。这样其实已经看出NN的隔离效果了,两个NN都有各自的网络。现在我们尝试在新NN里面进行一些操作:# 网卡是DOWN的,所以ping不通 ➜ ~ ip netns exec demo_netns ping 127.0.0.1 connect: Network is unreachable # 把网卡激活 ➜ ~ ip netns exec demo_netns ip link set dev lo up # 因为是lo网卡,所以激活后也自动分配了IP 127.0.0.1 ➜ ~ ip netns exec demo_netns ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever # 此时就可以ping通了 ➜ ~ ip netns exec demo_netns ping 127.0.0.1 PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data. 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.027 ms ^C --- 127.0.0.1 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.027/0.027/0.027/0.000 ms # 尝试ping一下跟NN的网卡,因为NN之间隔离,所以ping不同 ➜ ~ ip netns exec demo_netns ping 10.0.2.15 connect: Network is unreachable从前面看到,不同Network Namespace的网络是完全隔离的,而docker容器的网络就是基于上述Network Namespace的原理,每个容器都会创建自己的Network Namespace,这样每个容器都有自己的网络空间,不同的容器之间相互隔离、不会产生冲突。但我们实际使用docker容器的时候会发现默认情况下,宿主机是可以和上面的容器通信的,不同的容器之间也是可以通信的,如何实现的呢?答案是:veth pair + Linux bridge。veth pairveth是虚拟以太网卡(Virtual Ethernet)的简写。正如名字所示,veth pair总是成对出现的,一端发送数据会在另一端接收。像极了Linux IPC中的pipe(管道),不过veth pair是“全双工”的,也就是数据可以双向流动。Linux IPC pipe用于进程间通信,而veth pair则可用于不同Network Namespace之间的网络通信。使用方法非常简单,创建一个veth pair,分别放到两个不同的Network Namespace,这样这两个NN就可以通信了,如下图:注:假设根Network Namespace为#1,新创建的demo_netns为#2.下面看如何实现上述通信:# 在 NN #1中创建一个veth pair,一端叫veth0,另一端叫veth1(名字可以随便起) ➜ ~ ip link add veth0 type veth peer name veth1 # 确认一下,veth1和veth2两个虚拟网卡已经创建出来了 ➜ ~ ip link list 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff 4: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/ether 5a:92:ec:3f:d4:7d brd ff:ff:ff:ff:ff:ff 5: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/ether 1a:1e:88:60:19:0d brd ff:ff:ff:ff:ff:ff # 然后将veth pair的一端veth1移到NN #2里面去 ➜ ~ ip link set veth1 netns demo_netns # 可以看到NN #1里面已经没有veth1了 ➜ ~ ip link list 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff 5: veth0@if4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/ether 1a:1e:88:60:19:0d brd ff:ff:ff:ff:ff:ff link-netnsid 0 # veth1已经到了NN #2里面 ➜ ~ ip netns exec demo_netns ip link list 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 4: veth1@if5: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/ether 5a:92:ec:3f:d4:7d brd ff:ff:ff:ff:ff:ff link-netnsid 0至此,已经将veth pair的两端放到了两个不同的Network Namespace中。需要注意的是:可以将虚拟网络设备移入任何一个NN中,但真实的硬件网络设备只能在根NN中;一个网络设备只能存在于一个NN中。现在要通信,还需要激活并且配置IP:# 分别激活veth0和veth1,并配置IP ➜ ~ ip netns exec demo_netns ifconfig veth1 10.1.1.1/24 up ➜ ~ ifconfig veth0 10.1.1.2/24 up # 检查 ➜ ~ ip addr show veth0 5: veth0@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link/ether 1a:1e:88:60:19:0d brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 10.1.1.2/24 brd 10.1.1.255 scope global veth0 valid_lft forever preferred_lft forever inet6 fe80::181e:88ff:fe60:190d/64 scope link valid_lft forever preferred_lft forever ➜ ~ ip netns exec demo_netns ip addr show veth1 4: veth1@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link/ether 5a:92:ec:3f:d4:7d brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 10.1.1.1/24 brd 10.1.1.255 scope global veth1 valid_lft forever preferred_lft forever inet6 fe80::5892:ecff:fe3f:d47d/64 scope link valid_lft forever preferred_lft forever配置成功以后,再尝试ping一下:# 从NN #1 ping NN#2 ➜ ~ ping -c 1 10.1.1.1 PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data. 64 bytes from 10.1.1.1: icmp_seq=1 ttl=64 time=0.022 ms --- 10.1.1.1 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.022/0.022/0.022/0.000 ms # 从NN #2 ping NN#1 ➜ ~ ip netns exec demo_netns ping -c 1 10.1.1.2 PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data. 64 bytes from 10.1.1.2: icmp_seq=1 ttl=64 time=0.029 ms --- 10.1.1.2 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.029/0.029/0.029/0.000 ms如果将上面的NN #1、NN #2看做是宿主机和一个容器,或者看做两个容器,那我们已经通过veth pair实现了宿主机与容器或者容器与容器之前的通信了。不过很快又发现一个问题,如果有 n 个容器之前需要通信,按照现在的这种方式,每个容器需要创建 n-1 个veth,这很容易就爆炸了,解决这个问题的方式就是网桥。Linux bridge网桥和veth类似,也是一种虚拟网络设备,可以配置IP、MAC等,但普通的网络设备只有两端,从一端进来的数据从另一端出去。比如上面的veth pair;又比如物理网卡收到外部数据会转发给内核协议栈,内核协议栈的数据会发给物理网卡发送到外部网络。而网桥则不同,它有多个端口,数据可以从任意端口进来,然后根据MAC地址决定从哪个口出去,类似于交换机。真实的网络设备和虚拟的网络设备都可以连接到网桥上面,不过仅限于本机的网络设备,无法跨机器将网络设备连接到网桥上面。如何通过网桥将本机上面的宿主机、各个容器之间的网络打通呢?看了下我之前介绍Kubernetes Flannel网络时的一篇文章中使用的图(图片来自Google搜素),挺合适的,就不画了:简单说明一下:Container里面的eth0和外面的vethxxxx就是上例中我们创建的veth pair。docker0就是一个网桥(这个是docker daemon启动时创建的)外面的eth0是宿主机上面的一个物理网卡可以看到,原理非常简单:在之前veth pair的基础上,创建了一个网桥docker0,然后将宿主机上的所有veth pair那端都连到了这个网桥上面,同时也将宿主机的物理网卡也连接到了网桥上。这样宿主机与各个容器之间、容器与容器之间就都通过这个网桥连接在一起了。如果你对网桥或者上述过程不是特别理解,强烈建议一步步动手实操一下“《Kubernetes网络权威指南》1.3 连接你我他:Linux bridge”作者构造的例子,非常用心,非常详细。docker的单机网络docker提供了4种网络:bridge模式,通过--network=bridge指定(其中=也可以换为空白符);host模式,通过--network=host指定;container模式,通过--network=container:NAME_or_ID指定,即joiner容器;none模式,通过--network=none指定。其中bridge模式是默认模式。bridge模式Docker daemon启动的时候会创建一个名为docker0的网桥,在bridge网络模式下,每启动一个docker容器,docker daemon就会给该容器创建一个Network Namespace,同时创建一个veth pair,容器里面的一端命名为容器的eth0;宿主机上的一端命名为vethxxx,同时连接到docker0这个网桥上面。这样,就通过上面我们介绍的veth pair+Linux bridge的方式,实现了宿主机与本机各容器、容器之间的通信。测试一下:# 启动docker前 ➜ ~ ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3 valid_lft 86383sec preferred_lft 86383sec inet6 fe80::ec5a:5f1d:d894:fd26/64 scope link noprefixroute valid_lft forever preferred_lft forever 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff inet 192.168.56.5/24 brd 192.168.56.255 scope global noprefixroute enp0s8 valid_lft forever preferred_lft forever inet6 fe80::202c:ab9:1e26:f0a4/64 scope link noprefixroute valid_lft forever preferred_lft forever # 启动docker ➜ ~ systemctl start docker ➜ ~ ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3 valid_lft 86331sec preferred_lft 86331sec inet6 fe80::ec5a:5f1d:d894:fd26/64 scope link noprefixroute valid_lft forever preferred_lft forever 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff inet 192.168.56.5/24 brd 192.168.56.255 scope global noprefixroute enp0s8 valid_lft forever preferred_lft forever inet6 fe80::202c:ab9:1e26:f0a4/64 scope link noprefixroute valid_lft forever preferred_lft forever 4: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default link/ether 02:42:bb:ea:39:52 brd ff:ff:ff:ff:ff:ff inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 valid_lft forever preferred_lft forever可以看到,启动docker之后多出了一个docker0网桥。然后我们启动一个容器:# 启动一个busybox ➜ ~ docker run -it busybox / # ifconfig eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:02 inet addr:172.17.0.2 Bcast:172.17.255.255 Mask:255.255.0.0 UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:21 errors:0 dropped:0 overruns:0 frame:0 TX packets:0 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:0 RX bytes:2692 (2.6 KiB) TX bytes:0 (0.0 B) lo Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0 UP LOOPBACK RUNNING MTU:65536 Metric:1 RX packets:0 errors:0 dropped:0 overruns:0 frame:0 TX packets:0 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:0 (0.0 B) TX bytes:0 (0.0 B) # 在另外一个终端查看网卡信息 ➜ ~ ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3 valid_lft 86171sec preferred_lft 86171sec inet6 fe80::ec5a:5f1d:d894:fd26/64 scope link noprefixroute valid_lft forever preferred_lft forever 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff inet 192.168.56.5/24 brd 192.168.56.255 scope global noprefixroute enp0s8 valid_lft forever preferred_lft forever inet6 fe80::202c:ab9:1e26:f0a4/64 scope link noprefixroute valid_lft forever preferred_lft forever 4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:bb:ea:39:52 brd ff:ff:ff:ff:ff:ff inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 valid_lft forever preferred_lft forever inet6 fe80::42:bbff:feea:3952/64 scope link valid_lft forever preferred_lft forever 8: veth86b1a59@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default link/ether 7a:d3:79:e0:2b:ff brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet6 fe80::78d3:79ff:fee0:2bff/64 scope link valid_lft forever preferred_lft forever可以看到多出了一个veth86b1a59,它是一个veth设备,另一端就是busybox容器里面的eth0。通过bridge link命令可以查看网桥上面挂了哪些设备,检查一下,这个宿主机上的veth86b1a59是不是挂到了docker0网桥上面:➜ ~ bridge link 8: veth86b1a59 state UP @if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master docker0 state forwarding priority 32 cost 2 已经自动连到了网桥上面,这些操作都是docker daemon帮我们做了。可以再同时启动几个容器观察观察。host模式如其名称所代表的那样,该模式下容器并不会创建自己的Network Namespace,而是直接共享宿主机的网络。只从网络的角度而言,该模式下容器化启动一个应用和直接在宿主机上面非容器化启动一个应用是一样的,所以网络性能也是最好的。测试一下:# 启动一个nginx容器 ➜ ~ docker run --rm -d --network host --name my_nginx nginx dfdaad58044dc28ad10eb0441f273b1154c7666856536cd08fe6ab00e7095494 # 登录到nginx容器中,为了查看网络资源,我们在容器里面安装iproute2软件包 ➜ ~ docker exec -it my_nginx bash root@NYC:/# apt update # 输出省略 root@NYC:/# apt install iproute2 # 输出省略 # 查看容器中的网络信息 root@NYC:/# ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3 valid_lft 82806sec preferred_lft 82806sec inet6 fe80::ec5a:5f1d:d894:fd26/64 scope link noprefixroute valid_lft forever preferred_lft forever 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff inet 192.168.56.5/24 brd 192.168.56.255 scope global noprefixroute enp0s8 valid_lft forever preferred_lft forever inet6 fe80::202c:ab9:1e26:f0a4/64 scope link noprefixroute valid_lft forever preferred_lft forever 4: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default link/ether 02:42:b5:a0:9e:9a brd ff:ff:ff:ff:ff:ff inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 valid_lft forever preferred_lft forever inet6 fe80::42:b5ff:fea0:9e9a/64 scope link valid_lft forever preferred_lft forever # 退出容器,查看主机的网络信息 ➜ ~ ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:1a:62:ef brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3 valid_lft 82773sec preferred_lft 82773sec inet6 fe80::ec5a:5f1d:d894:fd26/64 scope link noprefixroute valid_lft forever preferred_lft forever 3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:37:83:c5 brd ff:ff:ff:ff:ff:ff inet 192.168.56.5/24 brd 192.168.56.255 scope global noprefixroute enp0s8 valid_lft forever preferred_lft forever inet6 fe80::202c:ab9:1e26:f0a4/64 scope link noprefixroute valid_lft forever preferred_lft forever 4: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default link/ether 02:42:b5:a0:9e:9a brd ff:ff:ff:ff:ff:ff inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 valid_lft forever preferred_lft forever inet6 fe80::42:b5ff:fea0:9e9a/64 scope link valid_lft forever preferred_lft forever # 在宿主机上curl一下80端口 ➜ ~ curl 127.0.0.1 <!DOCTYPE html> <html> <head> <title>Welcome to nginx!</title> <style> body { width: 35em; margin: 0 auto; font-family: Tahoma, Verdana, Arial, sans-serif; } </style> </head> <body> <h1>Welcome to nginx!</h1> <p>If you see this page, the nginx web server is successfully installed and working. Further configuration is required.</p> <p>For online documentation and support please refer to <a href="http://nginx.org/">nginx.org</a>.<br/> Commercial support is available at <a href="http://nginx.com/">nginx.com</a>.</p> <p><em>Thank you for using nginx.</em></p> </body> </html>可以看到,以host模式启动的时候,容器的确直接使用了宿主机的Network Namespace。container模式这种模式就是创建一个容器时指定该容器和一个已经存在的容器共享一个Network Namespace(不能和宿主机共享),新创建的这个容器不会创建自己的网卡、配置自己的IP,而是和指定的这个容器共享IP、端口范围等,他们可以通过lo网卡通信。当然这个共享仅限于网络方面的资源,文件系统、进程ID等其他资源都还是隔离的。Kubernetes的Pod网络就是采用这种模式。测试一下,我们先以默认的bridge模式启动一个busybox容器,这样改容器会有个配置好的自己的Network Namespace,然后再以container模式启动一个nginx容器,并且复用busybox的网络:# 启动一个busybox,该容器 ➜ ~ docker run -it --name my_busybox busybox / # # 再启动一个nginx容器,复用busybox的网络 ➜ ~ docker run --rm -d --network container:my_busybox --name my_nginx nginx b99b4fb9e1b479b45a34a29032d5f74a0fd70ca37088eff97d9f90c14368c098 # 登录到nginx容器中,为了查看网络资源,我们在容器里面安装net-tools软件包 ➜ ~ docker exec -it my_nginx bash root@NYC:/# apt update # 输出省略 root@NYC:/# apt install net-tools # 输出省略 # 查看网络信息 root@40c5e251fc63:/# ifconfig eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 inet 172.17.0.2 netmask 255.255.0.0 broadcast 172.17.255.255 ether 02:42:ac:11:00:02 txqueuelen 0 (Ethernet) RX packets 4242 bytes 8905163 (8.4 MiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 4209 bytes 228359 (223.0 KiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536 inet 127.0.0.1 netmask 255.0.0.0 loop txqueuelen 1000 (Local Loopback) RX packets 2 bytes 168 (168.0 B) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 2 bytes 168 (168.0 B) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 # 在busybox容器中探测一下 / # ifconfig eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:02 inet addr:172.17.0.2 Bcast:172.17.255.255 Mask:255.255.0.0 UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:4242 errors:0 dropped:0 overruns:0 frame:0 TX packets:4209 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:0 RX bytes:8905163 (8.4 MiB) TX bytes:228359 (223.0 KiB) lo Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0 UP LOOPBACK RUNNING MTU:65536 Metric:1 RX packets:2 errors:0 dropped:0 overruns:0 frame:0 TX packets:2 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:168 (168.0 B) TX bytes:168 (168.0 B) # 看下进程 / # ps -ef PID USER TIME COMMAND 1 root 0:00 sh 10 root 0:00 ps -ef # 看下监听 / # netstat -alnp | grep 80 tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN - tcp 0 0 :::80 :::* LISTEN -从上面的例子可以看出,两个容器共享了同一个网络,但其它资源还是隔离的,比如进程信息,在busybox容器里面ps是看不到nginx容器的进程信息的。none模式该模式下,docker daemon会为容器创建一个Network Namespace,但不会做任何配置,就跟我们之前创建的demo_netns一样,里面只要一个lo网卡,所有配置都需要用户自己手动操作。这个就不实验了,有兴趣的参照上面的流程自行测试。总结单机容器的通信是最基础最容易解决的,所以Docker在出现的时候就解决了,但却在很长时间内都没有解决好文章刚开始提到的2和3,这也限制了docker容器的大规模使用,特别是生产中的使用,从而丢失了很机会。而后面出来的Kubernetes一出现就解决了2和3,后面的文章讲述Kubernetes是如何解决的。 Skywalking流程简析及源码调试 http://niyanchun.com/skywalking-code-debug.html 2020-07-19T22:01:00+08:00 如之前的文章所介绍,Skywalking主要由Agent、OAP、Storage、UI四大模块组成(如下图):Agent和业务程序运行在一起,采集链路及其它数据,通过gRPC发送给OAP(部分Agent采用http+json的方式);OAP还原链路(图中的Tracing),并分析产生一些指标(图中的Metric),最终存储到Storage中。本文从源码角度来串联一下这整个流程(基于目前最新的Skywalking 8.0.1)。源码编译Skywalking本地调试必须先从源码编译Skywalking,有两种方式,一种是从GitHub拉取代码,一种是从Apache Skywalking的release页面下载代码。区别在于GitHub上面的代码是使用git module管理的,拉取下来需要执行一系列操作,最主要的是没有科学上网的话,速度比较慢。Release页面下载的是已经把依赖关系全部整理好的代码,整个源码包不到3MB,还有很多国内镜像地址,所以下载非常快。两种我都使用过,我的建议是:如果你想看历史提交记录或者想持续跟上游版本的话,就选用从GitHub拉取代码的方式;如果你想方便或者从GitHub clone超级慢的话,建议直接从Release处下载。不管哪种,编译以及导入IDEA或Eclipse官方文档写的都比较详细,我就不做翻译了,基本都是命令操作,英文不好也看得懂(just copy-and-paste~~):How to build.源码编译成功以后(务必保证编译成功),就可以准备进行调试了。源码流程简析及调试这里通过一个简单的Spring MVC程序来演示如何调试Agent和OAP。创建一个Spring MVC程序在Skywalking项目下增加一个简单的Spring MVC模块(注意这里一定要以Skywalking项目module的方式添加),这里我创建了一个名叫simple-springmvc的module,增加了一个简单的Controller:/hello/{name}。如下图:然后在这个这个MVC程序的VM option中增加如下配置:-javaagent:{源码根目录}/skywalking-agent/skywalking-agent.jar -Dskywalking.agent.service_name=simple-springmvc注意,-javaagent后面那个skywalking-agent.jar路径换成你自己的路径。源码如果编译成功的话,源码根目录下面会出现这个skywalking-agent目录,并且里面会有这个skywalking-agent.jar。如下图:这样调试用的示例程序以及Skywalking的agent注入也配置好了。先别启动,接下来还需要启动OAP。启动OAP如果想先只调试agent的话,可以单独下载一个Skywalking的二进制(编译完以后,根目录下的dist目录也有二进制安装包),本地启动(参考我之前的文章)即可。第一次调试的话,我建议agent和OAP单独调试,因为两者有一些公用代码,在一个工程里面启动的话,容易造成混淆。分开调试的话就本地单独起一个Skywalking就行,这里讲直接在项目里面启动一个OAP的方式。启动OAP非常简单,OAP的代码是源码根目录下的oap-server,入口函数是org.apache.skywalking.oap.server.starter包下面的OAPServerStartUp类。直接启动即可。需要注意的是这样只启动了OAP,为了方便查看还原的链路(不启动也不影响调试,不看Web的直接跳过),我们再手动启动一个Web UI。直接在Skywalking安装目录下面(注意是二进制安装目录,不是源码目录)的webapp目录下执行:java -jar skywalking-webapp.jar即可。默认访问地址为http://127.0.0.1:8080/。OAP和UI(optional)启动好以后,就可以开始调试了。流程简析启动调试之前,我先简单介绍一下数据流向以及一些关键的函数,方便提前打断点。整个数据流如下图:这里我们先创建了一个Spring MVC程序simple-springmvc,并且配置了javaagent,这样Skywalking agent就会以字节码注入的方式运行在simple-springmvc里面。当我们使用curl命令发送请求时,就会产生链路数据。需要注意的是,Skywalking默认已经实现了Spring MVC的插件{源码根目录}/skywalking-agent/plugins/apm-springmvc-annotation-commons-8.0.1.jar,对应的源码是{源码根目录}/apm-sniffer/apm-sdk-plugin/spring-plugins/mvc-annotation-commons。它的增强函数就是在这个模块下的AbstractMethodInterceptor类中实现的,给这个类的beforeMethod方法打个断点(为了节省篇幅,省略了一些不重要的代码),就可以观察数据agent增强流程:package org.apache.skywalking.apm.plugin.spring.mvc.commons.interceptor; public abstract class AbstractMethodInterceptor implements InstanceMethodsAroundInterceptor { @Override public void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes, MethodInterceptResult result) throws Throwable { // 给下面这行打个断点 Boolean forwardRequestFlag = (Boolean) ContextManager.getRuntimeContext().get(FORWARD_REQUEST_FLAG); /** * Spring MVC plugin do nothing if current request is forward request. * Ref: https://github.com/apache/skywalking/pull/1325 */ if (forwardRequestFlag != null && forwardRequestFlag) { return; } // 以下省略 } // 以下省略 }然后启动了OAP,后端存储使用了默认内建的内存数据库H2。为了方便查看链路,可以选择性启动一个UI。Agent和OAP之间是通过gRPC来发送链路信息的。Agent端维护了一个队列(默认5个channel,每个channel大小为300)和一个线程池(默认1个线程,后面称为发送线程),链路数据采集后主线程(即业务线程)会写入这个队列,如果队列满了,主线程会直接把把数据丢掉(丢的时候会以debug级别打印日志)。发送线程会从队列取数据通过gRPC发送给后端OAP,OAP经过处理后写入存储。为了看得清楚,我把涉及的框架类画到了下面的图里面(格式是:{类名}#{方法名}({方法中调用的重要函数}):这里只列举了核心函数,每个函数内部的方法就不赘述了。需要说明的就是Skywalking代码的模块化还是做得很不错,大家跟踪代码的时候可以关注一下功能所属的模块,更有利于学习整个项目或者进行二次开发。调试给这些核心方法,打上断点,以Debug模式启动oap-server和simple-springmvc,然后用curl发一个请求,就可以愉快的调试了。总结Just read the source code, good luck ! OpenTracing概念术语介绍 http://niyanchun.com/opentracing-introduction.html 2020-07-12T22:04:00+08:00 本来计划接着上文介绍Skywalking的架构,但是我想了一下,觉得还是有必要先把链路跟踪里面涉及的一些基础概念术语介绍一下,介绍这些Skywalking并不是一个比较好的选择。原因一方面是Skywalking只是众多APM实现之一,里面有些设计并不适合其它APM,另一方面Skywalking提供的比较好的探针多时Java的,而且是字节码注入的,不利于观察学习。当然最重要的是有一个更合适的选择:OpenTracing。OpenTracing介绍分布式请求/链路跟踪(Distributed Request Tracing)最早是Google内部在用,后来相对成熟以后,2010年对外发布了一篇论文:Dapper, a Large-Scale Distributed Systems Tracing Infrastructure,但没有将系统开源。接着就有一些公司和社区开始基于Dapper实现自己的链路跟踪系统,比较有名的有(这里只列举开源的):Twitter的Zipkin、韩国的PinPoint、大众点评的CAT,以及一些后起之秀:Uber的Jaeger,Apache Skywalking等。在百花齐放的时候,出现了OpenTracing,关于它的介绍官方是这样说的:What is OpenTracing?It is probably easier to start with what OpenTracing is NOT.OpenTracing is not a download or a program. Distributed tracing requires that software developers add instrumentation to the code of an application, or to the frameworks used in the application.OpenTracing is not a standard. The Cloud Native Computing Foundation (CNCF) is not an official standards body. The OpenTracing API project is working towards creating more standardized APIs and instrumentation for distributed tracing.OpenTracing is comprised of an API specification, frameworks and libraries that have implemented the specification, and documentation for the project. OpenTracing allows developers to add instrumentation to their application code using APIs that do not lock them into any one particular product or vendor.我简单概括一下,OpenTracing制定了一些链路跟踪的API规范,并且提供了一些框架和库,这些框架和库实现了它制定的那些API规范。而且它是一个独立开放的项目,现在已经是云原生基金会(Cloud Native Computing Foundation, CNCF)的项目了。任何组织和个人都可以贡献符合API规范的库/框架。虽然OpenTracing不是一个标准规范,但现在大多数链路跟踪系统都在尽量兼容OpenTracing。需要重点说明的是OpenTracing提供的框架和库只是采集最原始的链路数据,并不做分析。如果放到Skywalking的架构中,它只实现了探针部分。也就是OpenTracing并不是一个完备的链路系统,所以我们无法单独使用,必须配合兼容OpenTracing规范的系统使用,比如Jaeger、LightStep、Apache Skywalking、Elastic APM等。最新的进展是OpenTracing已经和CNCF的另外一个项目OpenTelemetry合并了。题外话:虽然APM各个系统号称兼容OpenTracing,但兼容性到底如何,其实还是参差不齐的,比如Jaeger就比Skywalking兼容性好。OpenTracing 术语介绍说明:基本上所有的链路跟踪系统以及OpenTracing里面术语大多都是来自于Dapper论文里面的。下面介绍的规范都是OpenTracing定义的一些规范为了方便理解和交流,概念术语等使用英文,不做翻译。分布式链路跟踪系统的数据模型:Traces(一般翻译为链路):一起请求从发出,然后经过多个模块(这个模块可能是函数或者系统,或者都有),最终得到请求回复,整个请求按照调用时间和关系串起来就是一个trace。Span则是组成trace的最基本单元,它一般代表分布式系统中一个独立的工作单元。有点抽象,没关系,后面看一些例子就懂了。一个Span包含如下几部分:操作名称:一般用于展示、过滤、聚合开始和结束时间戳:用于计算耗时由key-value组成的Tags:用于添加一些时间无关的信息(可选)由key-value组成并包含时间戳的Logs:用于添加一些时间相关的信息(可选)span上下文,即SpanContext。一般包含两部分数据:(1)span的状态数据,比如traceID和spanID(2)Baggage Items。Baggage是链路跟踪提供的一个通用的跨进程/服务传递数据的方式,格式也是key-value形式的。Trace就是由若干个span组成的有向无环图,图中的每个节点就是Span,连接节点的边称之为References。每个trace有一个唯一标识符traceID,每个span也有一个唯一标识符spanID。一个链路中的所有span的traceID是相同的,但spanID各不相同。一个链路中span典型的调用关系图如下:Causal relationships between Spans in a single Trace [Span A] ←←←(the root span) | +------+------+ | | [Span B] [Span C] ←←←(Span C is a `ChildOf` Span A) | | [Span D] +---+-------+ | | [Span E] [Span F] >>> [Span G] >>> [Span H] ↑ ↑ ↑ (Span G `FollowsFrom` Span F)对应的时间维度为:Temporal relationships between Spans in a single Trace ––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time [Span A···················································] [Span B··············································] [Span D··········································] [Span C········································] [Span E·······] [Span F··] [Span G··] [Span H··]其实很简单,就是个调用关系。需要说明的是一个trace的span间有两种可能的关系:ChildOf:即父子关系,也是最常见的关系。比如上图中的Span A和Span B、Span C就是父子关系,表示只有Span B和Span C(包括Span A和Span B的所有子Span)都完成了,Span A才能完成,类似于同步调用(仅仅是类似于,并不完全一样)。FollowsFrom:其实也是父子关系,子Span是由父Span调用产生的,但父Span是否完成不依赖于子Span。比如图中的Span F和Span G就属于FollowsFrom关系。Span G由Span F调用创建,但Span F是否完成不依赖于Span G,有点类似于异步调用。最后需要介绍的一个概念就是“active span”。一个线程里面可以包含多个span,但同一时刻只能有一个span处于工作状态,这个span称之为ActiveSpan。Span可以有这么几个状态:StartedNot FinishedNot "active"ActiveSpan的状态由ScopeManager管理,但是否实现由开发者决定。另外OpenTracing定义了Inject和Extract接口来简化SpanContext跨进程传递。如果你是第一次了解分布式链路跟踪,看了上面这些,我相信你还是一头雾水,心里有很多疑问。没事,理论结合实践是掌握知识最佳的手段,先对这些概念有个大概理解和印象,然后看下面的几篇实战文章:OpenTracing Java Library教程(1)——trace和span入门OpenTracing Java Library教程(2)——进程间传递SpanContextOpenTracing Java Library教程(3)——跨服务传递SpanContextOpenTracing Java Library教程(4)——Baggage介绍说明:这4篇文章内容主要翻译(意译)自Yurishkuro大神的opentracing-tutorial java,加了一些补充说明以及在Jaeger UI上面的展示,方便理解,习惯看英文的也可以看原文,代码自行从GitHub拉取。通过这几篇文章,对于分布式链路跟踪基本概念和原理应该可以理解的比较好了。后面会介绍一些SDK如何写,以及一些具体的APM。 OpenTracing Java Library教程(4)——Baggage介绍 http://niyanchun.com/opentracing-java-library-tutorial-4.html 2020-07-12T21:59:00+08:00 本文内容主要翻译(意译)自Yurishkuro大神的opentracing-tutorial java,加了一些补充说明,方便理解,习惯看英文的也可以看原文。总共4篇,本文是第4篇。如果你还没接触过OpenTracing,建议先读这篇文章《OpenTracing概念术语介绍》和官方文档。第1篇:单span的trace创建。第2篇:多span的trace创建(进程内SpanContext传递)。第3篇:跨服务(进程)传递SpanContext。第4篇:Baggage介绍(本文)。目标学习:理解分布式上下文的传递使用baggage来传递数据开发流程在上一节中看到,SpanContext是如何在不同的服务之间传递的。我们可以将这种机制通用化,相当于实现了一个在分布式环境中进行信息传递的通道,这样就可以在服务间传递一些自定义数据了。这就是OpenTracing定义的Baggage:baggage是SpanContext的一部分,也是以key-value形式存在的。和tags、logs的差别在于,baggage是全局传递的。父Span里面的baggage信息会自动被所有子span继承,这就是所谓的全局性。也就是你能在调用链上任意地方读取到该信息。下面我们看个具体的例子。在Client中增加Baggage代码还是在上一篇文章的代码之上修改。我们再增加一个命令行参数,然后将这个参数放到baggage里面。修改Hello的main方法:public static void main(String[] args) { if (args.length != 2) { throw new IllegalArgumentException("Expecting two arguments, helloTo and greeting"); } String helloTo = args[0]; String greeting = args[1]; Tracer tracer = Tracing.init("hello-world"); new Hello(tracer).sayHello(helloTo, greeting); }然后将第二个参数放到baggage里面:private void sayHello(String helloTo, String greeting) { Span span = tracer.buildSpan("say-hello").start(); try (Scope scope = tracer.scopeManager().activate(span)) { span.setTag("hello-to", helloTo); // 把第二个参数信息放到baggage里面 span.setBaggageItem("greeting", greeting); String helloStr = formatString(helloTo); printHello(helloStr); } finally { span.finish(); } }其它地方都不用修改。在Formatter服务中读取Baggage@GET public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) { Span span = Tracing.startServerSpan(tracer, httpHeaders, "format"); try (Scope scope = tracer.scopeManager().activate(span)) { // 读取Baggage String greeting = span.getBaggageItem("greeting"); if (greeting == null) { greeting = "Hello"; } String helloStr = String.format("%s, %s!", greeting, helloTo); span.log(ImmutableMap.of("event", "string-format", "value", helloStr)); return helloStr; } finally { span.finish(); } }Baggage注意点首先,上面实现的功能也可以通过在接口中增加参数来实现。但服务比较多的时候比较麻烦,而且接口改动是一个比较大的改动,一般定了以后不能随意更改。而Baggage是不需要改动接口的,基本对于服务自身是透明的。这也是Baggage的意义所在。这里列举一些Baggage的使用场景:多租户系统的租户信息底层调用者的标识信息混沌工程中传递一些错误注入指令passing request-scoped dimensions for other monitoring data, like separating metrics for prod vs. test traffic但需要主要的是Baggage是全局传递的,所以数据量不能太大,否则可能会产生性能问题。一些库/框架会在实现层限制这个大小。总结Baggage是链路跟踪给出的一个通用的分布式数据传输机制,可以根据场景合理利用。 OpenTracing Java Library教程(3)——跨服务传递SpanContext http://niyanchun.com/opentracing-java-library-tutorial-3.html 2020-07-12T21:58:00+08:00 本文内容主要翻译(意译)自Yurishkuro大神的opentracing-tutorial java,加了一些补充说明,方便理解,习惯看英文的也可以看原文。总共4篇,本文是第3篇。如果你还没接触过OpenTracing,建议先读这篇文章《OpenTracing概念术语介绍》和官方文档。第1篇:单span的trace创建。第2篇:多span的trace创建(进程内SpanContext传递)。第3篇:跨服务(进程)传递SpanContext(本文)。第4篇:Baggage介绍。目标学习如何:跨服务做链路跟踪使用Inject和Extract方法在服务间传递span上下文(SpanContext)信息使用一些OpenTracing推荐的tags开发流程构建Hello-World微服务为了演示跨服务做链路跟踪,我们先来构建几个服务:Hello.java :基于上一节的代码,修改了部分代码,增加了HTTP请求代码。Formatter.java:基于Dropwizard-based的HTTP服务器,提供这样的一个接口:发送GET 'http://localhost:8081/format?helloTo=Bryan',返回"Hello, Bryan!" 字符串。Publisher.java:类似Formatter.java,提供这样一个接口:发送GET 'http://localhost:8082/publish?helloStr=hi%20there'请求,就往标准输出打印一个"hi there" 字符串。先把后面两个HTTP Server运行起来:// terminal tab 1 $ ./run.sh lesson03.exercise.Formatter server // terminal tab 2 $ ./run.sh lesson03.exercise.Publisher server然后发送一个HTTP请求:$ curl 'http://localhost:8081/format?helloTo=Bryan' Hello, Bryan!如果出现上面打印,说明我们的服务已经OK了。最后我们像前一篇文章一样,继续运行Hello服务:./run.sh lesson03.solution.Hello Bryan 进程/服务间链路信息传递虽然我们的Hello中做了两个RPC请求(HTTP也是RPC的一种),但运行之后会发现链路图和之前的一样:产生了一个包含三个span的链路,都是hello-world这个服务产生的。我们当然希望链路可以展示出这个调用中的涉及的所有服务,这个时候就需要实现在服务间(即跨进程)传递链路信息。链路信息一般包装在上下文中,这个上下文称之为SpanContext:一般至少包含链路的状态信息(比如traceID、spanID等)和Baggage信息。Baggage信息下篇文章介绍。所以链路信息的传递就是传递这个SpanContext。OpenTracing提供了一个抽象,在Tracer接口中定义了两个接口:inject(spanContext, format, carrier) :用于将SpanContext注入到RPC请求中;extract(format, carrier):用于从RPC请求中获取SpanContext。按照OpenTracing API定义,format参数表示SpanContext的编码格式(或者说传递方式吧),需要为以下三个编码之一:TEXT_MAP:将SpanContext编码为key-value形式BINARY:编码为字节流;HTTP_HEADERS:和TEXT_MAP一样,区别在于key的信息必须能够安全的放在HTTP headers里面(即不能包含一些http头不支持的特殊字符)。经常把HTTP_HEADERS归到TEXT_MAP类。carrier是基于底层RPC框架做的一层抽象,用于传递SpanContext。比如TEXT_MAP格式对应的carrier接口允许tracer实例通过put(key, value)方法将key-value格式的数据写入到请求中。同理,BINARY格式的就是ByteBuffer。下面我们看如何通过inject和extract来实现进程间的链路上下文信息传递。客户端增强首先需要在客户端发送HTTP请求前将SpanContext注入进去,发送给服务端。现在的HTTP请求是封装在 Hello#getHttp()中的,所以在这里加:import io.opentracing.propagation.Format; import io.opentracing.tag.Tags; Tags.SPAN_KIND.set(tracer.activeSpan(), Tags.SPAN_KIND_CLIENT); Tags.HTTP_METHOD.set(tracer.activeSpan(), "GET"); Tags.HTTP_URL.set(tracer.activeSpan(), url.toString()); tracer.inject(tracer.activeSpan().context(), Format.Builtin.HTTP_HEADERS, new RequestBuilderCarrier(requestBuilder));这里是以TEXT_MAP(HTTP_HEADERS)编码SpanContext的,所以需要实现TextMap类:import java.util.Iterator; import java.util.Map; import okhttp3.Request; public class RequestBuilderCarrier implements io.opentracing.propagation.TextMap { private final Request.Builder builder; RequestBuilderCarrier(Request.Builder builder) { this.builder = builder; } @Override public Iterator<Map.Entry<String, String>> iterator() { throw new UnsupportedOperationException("carrier is write-only"); } @Override public void put(String key, String value) { builder.addHeader(key, value); } }tracer会调用put方法将SpanContext中的信息以key-value的形式加到HTTP头中。这里的信息主要是我们写的一些跟请求想关的Tags信息。这样,客户端已经通过inject将SpanContext加入到请求中了。接下来看服务端收到请求后,如何使用extract取出这些信息。服务端增强服务端增强和客户端类似,先参照客户端创建一个Tracer实例。这部分一样,就略过了,重点看如何取出SpanContext信息。这里封装一个startServerSpan函数,这个函数实现的功能如下:从收到的请求中读取头信息(SpanContext信息包含在里面)使用extract方法解析出SpanContext,并基于此创建了一个新的Span,同时增加了一些服务端的信息public static Span startServerSpan(Tracer tracer, javax.ws.rs.core.HttpHeaders httpHeaders, String operationName) { // format the headers for extraction MultivaluedMap<String, String> rawHeaders = httpHeaders.getRequestHeaders(); final HashMap<String, String> headers = new HashMap<String, String>(); for (String key : rawHeaders.keySet()) { headers.put(key, rawHeaders.get(key).get(0)); } Tracer.SpanBuilder spanBuilder; try { SpanContext parentSpanCtx = tracer.extract(Format.Builtin.HTTP_HEADERS, new TextMapAdapter(headers)); if (parentSpanCtx == null) { spanBuilder = tracer.buildSpan(operationName); } else { spanBuilder = tracer.buildSpan(operationName).asChildOf(parentSpanCtx); } } catch (IllegalArgumentException e) { spanBuilder = tracer.buildSpan(operationName); } return spanBuilder.withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_SERVER).start(); }Formatter和Publisher两个服务都需要做这个事情。有了这个span,就可以使用了,这里展示一下Formatter代码的代码:@GET public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) { // 调用封装的startServerSpan函数,基于客户端传递过来SpanContext的创建一个新的span,并在tags中加入服务端的一些信息 Span span = startServerSpan(tracer, httpHeaders, "format"); try (Scope scope = tracer.scopeManager.activate(span)) { String helloStr = String.format("Hello, %s!", helloTo); span.log(ImmutableMap.of("event", "string-format", "value", helloStr)); return helloStr; } finally { span.finish(); } }至此,服务端的增强也实现好了,是时候见证奇迹了。见证奇迹重新运行Formatter、 Publisher和Hello服务(我没有改时区,所以日志中的时间差了8小时,实际现在是周日早晨8点):// terminal tab 1:启动Formatter服务 $ ./run.sh lesson03.exercise.Formatter server // 省略了部分日志 INFO [2020-07-12 00:57:48,181] io.jaegertracing.internal.reporters.LoggingReporter: Span reported: 2b20ca6e8ddc6547:2eb6a1fbef6e9789:8a92a88a65fb4776:1 - format 127.0.0.1 - - [12/Jul/2020:00:57:48 +0000] "GET /format?helloTo=Bryan HTTP/1.1" 200 13 "-" "okhttp/3.9.0" 4 // terminal tab 2:启动Publisher服务 $ ./run.sh lesson03.exercise.Publisher server // 省略了部分日志 Hello, Bryan! INFO [2020-07-12 00:57:48,440] io.jaegertracing.internal.reporters.LoggingReporter: Span reported: 2b20ca6e8ddc6547:93916ee579078535:75065f170bf15bff:1 - publish 127.0.0.1 - - [12/Jul/2020:00:57:48 +0000] "GET /publish?helloStr=Hello,%20Bryan! HTTP/1.1" 200 9 "-" "okhttp/3.9.0" 137 // terminal tab 3:启动Hello,启动后会分别调用Formatter服务和Publisher服务 -> % ./run.sh lesson03.solution.Hello Bryan // 省略了部分日志 08:57:48.206 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 2b20ca6e8ddc6547:8a92a88a65fb4776:2b20ca6e8ddc6547:1 - formatString 08:57:48.468 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 2b20ca6e8ddc6547:75065f170bf15bff:2b20ca6e8ddc6547:1 - printHello 08:57:48.468 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 2b20ca6e8ddc6547:2b20ca6e8ddc6547:0:1 - say-hello然后看下生成的链路图:可以看到新生成的链路包含了3个服务,共5个span。点击查看链路详情:从右侧可以清楚的看出调用关系,左侧可以看出耗时。然后再看下每个服务的一些详细信息:Tags包含了各个span的一些关键信息。总结本文主要展示了如何跨进程/服务传递SpanContext,下一篇介绍另外一种传递信息的方式,也是SpanContext中非常重要的一部分:Baggage。 OpenTracing Java Library教程(2)——进程间传递SpanContext http://niyanchun.com/opentracing-java-library-tutorial-2.html 2020-07-12T21:57:00+08:00 本文内容主要翻译(意译)自Yurishkuro大神的opentracing-tutorial java,加了一些补充说明,方便理解,习惯看英文的也可以看原文。总共4篇,本文是第2篇。如果你还没接触过OpenTracing,建议先读这篇文章《OpenTracing概念术语介绍》和官方文档。第1篇:单span的trace创建。第2篇:多span的trace创建(进程内SpanContext传递)(本文)。第3篇:跨服务(进程)传递SpanContext。第4篇:Baggage介绍。目标学习如何:跟踪不同的函数创建一个包含多个span的链路在进程(其实是线程)内部上下文之间传递链路信息开发步骤说明:源代码的exercise包下面的类是空的,是留给我们按教程一步步补充完善的;solution包是已经编写好的代码。本文基于教程1,所以先把把教程1里面solution下面的Hello.java拷贝到lesson02/exercise/Hello.java下面。跟踪不同的函数上篇文章的sayHello(String helloTo)方法做了两件事情:格式化输出字符串和打印输出。因为在一个方法里面,所以只产生了一个span,为了演示如何产生多个san,这里我们将这个方法拆分成两个单独的函数:String helloStr = formatString(span, helloTo); printHello(span, helloStr);函数体如下:// 格式化字符串 private String formatString(Span span, String helloTo) { String helloStr = String.format("Hello, %s!", helloTo); span.log(ImmutableMap.of("event", "string-format", "value", helloStr)); return helloStr; } // 打印输出 private void printHello(Span span, String helloStr) { System.out.println(helloStr); span.log(ImmutableMap.of("event", "println")); }然后我们再给每个函数里面加上span信息:private String formatString(Span rootSpan, String helloTo) { Span span = tracer.buildSpan("formatString").start(); try { String helloStr = String.format("Hello, %s!", helloTo); span.log(ImmutableMap.of("event", "string-format", "value", helloStr)); return helloStr; } finally { span.finish(); } } private void printHello(Span rootSpan, String helloStr) { Span span = tracer.buildSpan("printHello").start(); try { System.out.println(helloStr); span.log(ImmutableMap.of("event", "println")); } finally { span.finish(); } }运行(这截取了span相关的日志信息):-> % ./run.sh lesson02.solution.HelloManual Bryan 08:07:28.078 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 7e2c775eb9a9ac55:7e2c775eb9a9ac55:0:1 - formatString Hello, Bryan! 08:07:28.087 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: ab294a35b8deb4c3:ab294a35b8deb4c3:0:1 - printHello 08:07:28.087 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 45dcde709e09015d:45dcde709e09015d:0:1 - say-hello 这里简单介绍一下Span reported:后面的输出,这里有几个使用冒号分隔的ID:第一个ID代表traceID,唯一标识一个链路;第二个是spanID,唯一标识一个span;第三个是当前span的父spanID,如果没有(即自己就是根节点),则为0。这里的确产生了三个span,但这三个span的TraceID各不一样,也就是说这三个span分别代表一个链路,即我们创建了三个trace,每个trace包含1个span,这显然不是我们想要的,因为这里三个span是有调用关系的,它们最终应该形成一个trace,这个trace包含3个span。产生这个结果的原因是tracer.buildSpan("操作名").start();会创建一个新的span,而且默认这个span就是根节点,如果需要将其作为子节点的话,需要使用ChildOf明确指出其父节点:// formatString Span span = tracer.buildSpan("formatString").asChildOf(rootSpan).start(); // printHello Span span = tracer.buildSpan("printHello").asChildOf(rootSpan).start();修改代码后,重新运行函数:./run.sh lesson02.solution.HelloManual Bryan 08:20:56.204 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 836db6b9e1cb6e87:159e8c7d440d171a:836db6b9e1cb6e87:1 - formatString Hello, Bryan! 08:20:56.209 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 836db6b9e1cb6e87:149c8f7aeadb5319:836db6b9e1cb6e87:1 - printHello 08:20:56.209 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: 836db6b9e1cb6e87:836db6b9e1cb6e87:0:1 - say-hello 这次可以看到,三个span的traceID是一样的,说明它们三个都属于一个trace,且前两个spanID的父spanID都是最后一个,这个结果是比较符合预期的。我们再看下链路图:可以看到只新产生了一个trace,且里面包含了3个span。点开这个trace:从这个链路图就可以看出来函数间的调用关系,以及各个调用的耗时。如果点击每个span,还可以查看附加的一些基本信息。 至此,功能基本就完成了。美中不足的是每个函数调用都要传递父span,非常不方便,幸运的是java有thread local变量,可以使用这个机制省略掉同一个线程里面函数间的这个参数传递。代码如下:import io.opentracing.Scope; private void sayHello(String helloTo) { Span span = tracer.buildSpan("say-hello").start(); try (Scope scope = tracer.scopeManager().activate(span)) { span.setTag("hello-to", helloTo); String helloStr = formatString(helloTo); printHello(helloStr); } finally { span.finish(); } } private String formatString(String helloTo) { Span span = tracer.buildSpan("formatString").start(); try (Scope scope = tracer.scopeManager().activate(span)) { String helloStr = String.format("Hello, %s!", helloTo); span.log(ImmutableMap.of("event", "string-format", "value", helloStr)); return helloStr; } finally { span.finish(); } } private void printHello(String helloStr) { Span span = tracer.buildSpan("printHello").start(); try (Scope scope = tracer.scopeManager().activate(span)) { System.out.println(helloStr); span.log(ImmutableMap.of("event", "println")); } finally { span.finish(); } }代码说明:trace.scopeManager().active(span) 使当前span变为active span(每个线程内只能有一个active span)。当这个span关闭后,上一个span会自动又变为active span。Scope 实现了AutoCloseable接口, 所以我们可以使用try-with-resource语法.如果当前(当前线程内)已经有一个active span的话,使用buildSpan()创建新span的时候,会自动将当前active span设置为新span的父span。修改完后运行程序,效果和前面我们自己在函数间传递span信息是一样的。总结本文展示了如何在线程内部传递链路信息,下篇文章介绍如何跟踪RPC请求,也就是如何在网络间(跨进程/线程)传递链路信息。 OpenTracing Java Library教程(1)——trace和span入门 http://niyanchun.com/opentracing-java-library-tutorial-1.html 2020-07-12T21:51:00+08:00 本文内容主要翻译(意译)自Yurishkuro大神的opentracing-tutorial java,加了一些补充说明,方便理解,习惯看英文的也可以看原文。总共4篇,本文是第1篇。如果你还没接触过OpenTracing,建议先读这篇文章《OpenTracing概念术语介绍》和官方文档。第1篇:单span的trace创建(本文)。第2篇:多span的trace创建(进程内SpanContext传递)。第3篇:跨服务(进程)传递SpanContext。第4篇:Baggage介绍。目标学习如何:实例化一个Tracer创建一个简单的链路给链路增加一些注解(annotation):即增加Tag和Log。开发步骤说明:源代码的exercise包下面的类是空的,是留给我们按教程一步步补充完善的;solution包是已经编写好的代码。我翻译的时候,都直接运行的是solution里面的代码,但教程里面是逐步完善代码的,也就是会有一个中间状态。所以我会根据内容作了一些必要的注释和修改。但如果你是第一次看的话,建议按照教程自己手动在exercise里面完善。跟着教程一步步学习。创建一个简单的Hello-World程序创建一个简单的打印程序:接受一个参数,输出"Hello, {arg}!"。代码如下:package lesson01.exercise; public class Hello { private void sayHello(String helloTo) { String helloStr = String.format("Hello, %s!", helloTo); System.out.println(helloStr); } public static void main(String[] args) { if (args.length != 1) { throw new IllegalArgumentException("Expecting one argument"); } String helloTo = args[0]; new Hello().sayHello(helloTo); } }运行:$ ./run.sh lesson01.exercise.Hello Bryan Hello, Bryan!创建一个trace一个trace是由若干span组成的有向无环图。一个span代表应用中的一个逻辑操作,每个span至少包含三个属性:操作名(an operation time)、开始时间(start time)、结束时间(finish time)。下面我们使用一个io.opentracing.Tracer实例创建由一个span组成的trace,可以使用io.opentracing.util.GlobalTracer.get()创建一个全局的Tracer实例。代码如下:import io.opentracing.Span; import io.opentracing.Tracer; import io.opentracing.util.GlobalTracer; public class Hello { private final Tracer tracer; private Hello(Tracer tracer) { this.tracer = tracer; } private void sayHello(String helloTo) { Span span = tracer.buildSpan("say-hello").start(); String helloStr = String.format("Hello, %s!", helloTo); System.out.println(helloStr); span.finish(); } public static void main(String[] args) { if (args.length != 1) { throw new IllegalArgumentException("Expecting one argument"); } String helloTo = args[0]; new Hello(GlobalTracer.get()).sayHello(helloTo); } }这里我们使用了OpenTracing API的一些基本特性:调用tracer实例的buildSpan()方法创建一个spanbuildSpan()方法的参数就是span的操作名调用start()方法真正创建出一个span通过调用finish()方法结束一个spanspan的开始时间和结束时间由具体的tracer实现自动生成(获取创建和结束span时的系统时间戳)此时,我们运行程序并不会和原来的程序有什么区别,也不会产生链路数据。因为OpenTracing只提供了SDK,并没有提供具体的链路实现,所以要产生真正的链路数据,需要借助具体的链路实现。部署Jaeger(补充段落,原文没有)这里我们选择Uber开源的Jaeger(发音为\ˈyā-gər\ ),因为它对OpenTracing支持的比较好,而且部署使用也非常简单。另外Jaeger的作者就是Yurishkuro。这里就不介绍Jaeger的细节了,有兴趣的可以去官网了解:Jaeger官网。Jaeger部署非常简单,从这里下载安装包或者下载docker镜像。这里我下载的macOS的安装包,解压后可以看到如下文件:example-hotrod jaeger-agent jaeger-all-in-one jaeger-collector jaeger-ingester jaeger-query直接运行./jaeger-all-in-one便可以启动一个完整的Jaeger。此时访问http://localhost:16686/即可查看Jaeger的UI:这样,一个OpenTracing的实现(Jaeger)就有了。接下来我们看如何在代码中集成。集成Jaeger在pom.xml中引入Jaeger的依赖:<dependency> <groupId>io.jaegertracing</groupId> <artifactId>jaeger-client</artifactId> <version>0.32.0</version> </dependency>然后写一个创建tracer的函数:import io.jaegertracing.Configuration; import io.jaegertracing.Configuration.ReporterConfiguration; import io.jaegertracing.Configuration.SamplerConfiguration; import io.jaegertracing.internal.JaegerTracer; public static JaegerTracer initTracer(String service) { SamplerConfiguration samplerConfig = SamplerConfiguration.fromEnv().withType("const").withParam(1); ReporterConfiguration reporterConfig = ReporterConfiguration.fromEnv().withLogSpans(true); Configuration config = new Configuration(service).withSampler(samplerConfig).withReporter(reporterConfig); return config.getTracer(); }最后,修改原来代码中的main函数:Tracer tracer = initTracer("hello-world"); new Hello(tracer).sayHello(helloTo);注意我们给initTracer()方法传入了一个参数hello-world,这个是服务名。该服务里面产生的所有span公用这个服务名,一般服务名会用来做过滤和聚合。现在运行代码,可以看到日志中有输出产生的span信息,而且也能看到Tracer实例的一些信息:19:07:10.645 [main] DEBUG io.jaegertracing.thrift.internal.senders.ThriftSenderFactory - Using the UDP Sender to send spans to the agent. 19:07:10.729 [main] DEBUG io.jaegertracing.internal.senders.SenderResolver - Using sender UdpSender() # tracer实例信息 19:07:10.776 [main] INFO io.jaegertracing.Configuration - Initialized tracer=JaegerTracer(version=Java-1.1.0, serviceName=hello-world, reporter=CompositeReporter(reporters=[RemoteReporter(sender=UdpSender(), closeEnqueueTimeout=1000), LoggingReporter(logger=Logger[io.jaegertracing.internal.reporters.LoggingReporter])]), sampler=ConstSampler(decision=true, tags={sampler.type=const, sampler.param=true}), tags={hostname=NYC-MacBook, jaeger.version=Java-1.1.0, ip=192.168.0.109}, zipkinSharedRpcSpan=false, expandExceptionLogs=false, useTraceId128Bit=false) Hello, Bryan! # span信息 19:07:10.805 [main] INFO io.jaegertracing.internal.reporters.LoggingReporter - Span reported: a86d76defe28d413:a86d76defe28d413:0:1 - say-hello 当然也可以以调试模式启动,观察更多细节。这个时候,我们打开Jaeger的UI,左侧的Service选择“hello-world”,然后点击最下面的“Find Traces”,就可以查到刚才这次程序运行产生的Trace信息了:点击链路详情进去后,再次点击操作名,可以查看一些基本信息,Jaeger默认已经加了一些基本的信息。下面我们来看如何加一些自定义的信息。增加Tags和LogsOpenTracing规定了可以给Span增加三种类型的注解信息:Tags:key-value格式的数据,key和value完全由用户自定义。需要注意的是Tags增加的信息应该是属于描述整个span的,也就是它是span的一个静态属性,记录的信息适用于span从创建到完成的任何时刻。再说直白点就是记录和时间点无关的信息,这个主要是和下面的Logs作区分。Logs:和Tags类似,也是key-value格式的数据,区别在于Logs的信息都会带一个时间戳属性,记录这条属性产生的时间戳,所以比较适合记录日志、异常栈等一些和时间相关的信息。Baggage Items:这个主要是用于跨进程全局传输数据,后面的lesson04专门演示这个特性,这里先不展开介绍了。Tags和Logs的记录非常的简单和方便:private void sayHello(String helloTo) { Span span = tracer.buildSpan("say-hello").start(); // 增加Tags信息 span.setTag("hello-to", helloTo); String helloStr = String.format("Hello, %s!", helloTo); // 增加Logs信息 span.log(ImmutableMap.of("event", "string-format", "value", helloStr)); System.out.println(helloStr); // 增加Logs信息 span.log(ImmutableMap.of("event", "println")); span.finish(); }注意这里使用了Guava's ImmutableMap.of()来构造一个Map。再次运行程序,同样会产生一个span,但这次span会多了一个Tag和Log信息(Jaeger默认已经加了一些内部的tag数据):从图中可以看到代码中加的Tags信息和Logs信息,而且Logs信息是带了时间了(这里展示的是从span开始时间经过的毫秒数)。关于Tags和Logs的规范,OpenTracing做了一些引导规范,可以参考:semantic_conventions.总结本文主要展示了如何创建一个span,下篇文章演示如何如果创建一个包含多个span的trace,以及如何在进程内部(不同方法间)传递span信息。