NYC's Blog - Kubernetes 2021-02-16T21:59:00+08:00 Typecho http://niyanchun.com/feed/atom/tag/kubernetes/ <![CDATA[k8s网络学习(4)——Service的艺术]]> http://niyanchun.com/k8s-network-4-service.html 2021-02-16T21:59:00+08:00 2021-02-16T21:59:00+08:00 NYC https://niyanchun.com K8s的网络主要需要解决4个问题:

  1. 高度耦合的容器间的通信问题
  2. Pod与Pod之间的通信
  3. Pod和Service之间的通信(本文)
  4. 外部系统和Service之间的通信(本文涉及一部分)

上篇文章讨论了第2个问题,本文继续讲述第3个问题和第4个问题:Pod和Service之间的通信、外部系统和Service之间的通信。其核心就在Service,官方文档(英文中文)已经介绍的比较详细了,本文主要是学习笔记和总结。

我们在k8s中部署应用时典型的操作是下面这样的:

service

图中通过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...   49d

2, 不带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种代理模式:

  1. user space proxy mode
  2. iptables proxy mode
  3. IPVS proxy mode

下面分别介绍。

user space proxy mode

在user space proxy模式下,kube-proxy会watch k8s控制平面Service和Endpoints对象的增加和删除。每当有Service创建的时候,就会在本地随机挑选一个端口进行监听,当有请求发到这个代理端口的时候,就会被转发到后端的Endpoint,也即转发到实际的Pod。同时,kube-proxy也会创建iptable规则,将发送到ClusterIP:port上面的流量转发到上面的随机端口上。整个流程图如下(图片来自官网):

services-userspace-overview

在user space proxy模式下,kube-proxy默认使用round-robin算法向后端转发流量。

iptables proxy mode

user 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模式的流程如下(图片来自官网):

services-iptables-overview

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。流程图如下:

services-ipvs-overview

相比于前面的两种proxy,IPVS proxy支持更多的转发规则:

  • rr: round-robin
  • lc: least connection (smallest number of open connections)
  • dh: destination hashing
  • sh: source hashing
  • sed: shortest expected delay
  • nq: 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: 9376

2, 如何指定使用哪种proxy?k8s 1.2版本之前默认使用user space proxy。1.1版本中支持了iptables proxy,并且从1.2版本开始将iptables proxy设置为默认proxy。1.8版本中引入了IPVS模块,但默认没有开启。用户可以通过kube-proxy的命令行参数--proxy-mode选择要使用的proxy模式,可选值为:userspaceiptablesipvs。如果为空(默认),则使用对应版本的默认值。需要注意的是,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进行区分:

  1. 定义了selector:根据selector匹配后端即可;
  2. 没有定义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,目前有两大类方式:

  1. 通过k8s提供的Service Type(service.spec.type)里面的一些类型:NodePortLoadBalancerExternalName。这些方式都是工作在L4的,只能提供基础的负载均衡功能。
  2. 使用Ingress。Ingress不是Service type,它也是一种独立的k8s API资源。Ingress是专门用于解决集群外访问Service问题的,它工作在L7,所以拥有更加强大的功能,比如高级的流量转发、SSL等,后面单独文章专门介绍。

下面介绍几种Service Type。

ClusterIP

ClusterIPservice.spec.type的默认值,即将服务暴露到集群内部的一个虚拟IP(ClusterIP)上面,此时是无法从外部访问集群的。所以该方式不算是从集群外部访问Service的方式,这里放在这里,以方面是为了完整介绍service.spec.type的可选值,另一方面,下面介绍的其它几种Service Type一般也会自动创建ClusterIP,而且外部的流量进来一般也是转发到这个内部的ClusterIP上面。

NodePort

NodePort的原理是这样的:当我们定义一个使用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服务:

  1. 集群内:

    • 直接访问Pod:PodIP:targetPort,即PodIP:9200
    • 通过ClusterIP访问:ClusterIP:port,即10.98.96.32:80
  2. 集群外:通过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: 9200

LoadBalancer方式的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                                   21d

LoadBalancer方式的很多功能都是由云厂商自己实现的,所以不同厂商的产品可能不太一样,需要结合自己使用的产品查看。

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个:

  1. 为什么需要Service?因为Service能够提供一个稳定的虚拟IP,并且将流量负载均衡到后端Pod上面。
  2. 如何将Service的流量转发到后端Pod上面?这个是通过运行在各个节点上面的kube-proxy实现的,而kube-proxy则支持3种运行模式:userspace proxy、iptables proxy,IPVS proxy。
  3. 如何从集群外部访问Service?可以通过NodePort、LoadBalancer、ExternalName这几种Service Type或者ExternalIPs将Service暴露到集群外面。除了这些,还有更高级的工作在L7的Ingress,下篇文章介绍。
]]>
<![CDATA[k8s网络学习(3)——网络模型&&CNI&&CNM]]> http://niyanchun.com/k8s-network-3.html 2021-02-08T20:25:00+08:00 2021-02-08T20:25:00+08:00 NYC https://niyanchun.com K8s的网络主要需要解决4个问题:

  1. 高度耦合的容器间的通信问题
  2. Pod与Pod之间的通信(本文)
  3. Pod和Service之间的通信
  4. 外部系统和Service之间的通信

上篇文章讨论了第1个问题,本文继续讲述第2个问题:Pod与Pod之间的通信。

k8s网络模型

pod是k8s中最小的部署单元,不同pod可能会分布在不同机器上面,解决了Pod之间的通信问题,其实也就解决了跨主机容器通信的问题。而k8s在这方面的设计上考虑的不仅要解决通信问题,还要以一种对应用层使用更友好的方式。所以便提出了k8s网络模型:集群中的每个Pod都要有自己的IP,这个模型也称之为“IP-per-pod”。同时,k8s还要求所有实现该模型的网络实现都满足以下要求:

  1. 一个节点上的pod可以和所有节点上面的Pod以非NAT的方式进行通信;
  2. 一个节点上的代理(比如系统守护、kubelet)可以和该节点上面的所有Pod通信;
  3. 运行在主机网络中的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-layer

具体的网络细节都由下层的插件实现。

从功能上说,CNI就是定义了创建网络(ADD)和删除网络(DEL)两个接口,各个插件来实现这两个接口。然后CRI在创建容器时创建网络,并调用ADD接口将容器加入该网络;在容器销毁时调用DEL将容器从网络中删除,必要时删除这个网络。如下图(图片来自网络):

cni-architecture

相比于CNM,CNI首先在接口设计上就很简化,只有上述2个核心接口(随着发展,又增加了一些接口,但核心的还是这两个)。另外,在配置文件方面CNI也很简单:CNM需要一个分布式key-value来存储其网络配置,而CNI的配置就是一个JSON文件,存储在本地。更多的细节就不介绍了,英文好建议直接看我前面附的官方SPEC,喜欢看中文的可以看这篇翻译文章:CNI - Container Network Interface(容器网络接口)

如何在k8s中使用CNI

CNI标准规定实现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。

]]>
<![CDATA[k8s网络学习(2)——pause容器的秘密]]> http://niyanchun.com/k8s-network-2.html 2021-02-06T22:51:00+08:00 2021-02-06T22:51:00+08:00 NYC https://niyanchun.com 上一篇文章介绍了单机容器网络的基础知识,本篇开始介绍K8s的网络。本文假定你已经对K8s的各种基本概念(比如Node、Pod、Service等)非常了解,如果还不了解,可以参考我之前的博客:Kubernetes架构及资源关系简单总结

K8s的网络主要需要解决4个问题:

  1. 高度耦合的容器间的通信问题(本文)
  2. Pod与Pod之间的通信
  3. Pod和Service之间的通信
  4. 外部系统和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容器有两大作用:

  1. 它作为Pod内的“系统容器”,提供一个基础的Linux Namespace,以供其它用户容器加入。
  2. 如果开启了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页面了:

ghost

此时,高度耦合的两个容器nginx+ghost就在一个Namespace里面了,特别是处于同一个Network Namespace(可以通过127.0.0.1相互访问),让他们的通信变得极其方便。如果再加上pause容器,那他们3个就是k8s里面的一个Pod了。其关系如下图(图片来自第一篇引用的文章):

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:

]]>
<![CDATA[k8s网络学习(1)——单机容器网络]]> http://niyanchun.com/k8s-network-1.html 2021-02-06T12:47:00+08:00 2021-02-06T12:47:00+08:00 NYC https://niyanchun.com 最近阅读了一下《Kubernetes网络权威指南:基础、原理与实践》,觉得是非常不错的一本书,本系列Kubernetes网络的文章也是阅读此书后的一些实践、扩展阅读,算是学习笔记和总结吧。我觉得容器(Linux容器)的发展大致可以分这么三个阶段:

  • 第一阶段:Linux内核时代,内核提出并实现了Namespace技术,为容器技术奠定了基石,但此时容器还只是一颗深埋的种子,大多数人还不知道。
  • 第二阶段:Docker时代,此时深埋的种子破土而出,出现在众人的视野中。Docker公司基于Linux Namespace技术推出的Docker容器,一下子引爆了容器领域,让大多数人知道了容器技术。但此时容器主要用于平时的开发,生产中还无法大规模使用,因为容器还有很多问题没有解决,比如跨主机的网络通信、容器的管理等。
  • 第三阶段:Kubernetes时代,Kubernetes的出现,补齐了容器在生产方面应用的短板,解决了跨主机网络通信、容器管理等问题,因为是站在Google Borg这个巨人的肩膀上,所以一出现,就成为大杀器。目前Kubernetes基本已经成为容器化领域的事实标准,CNCF基金会的绝大多数技术也都是基于Kubernetes。现在容器技术正处于如火如荼的发展中,一方面是很多公司开始将一些传统IT架构迁移到容器上面;另一方面,基于容器的一些新技术也层出不穷。从目前看,服务网格(Service Mesh)有可能会成为下一代的火热技术。

回到网络,容器化的网络方案主要要解决3个问题:

  1. 单主机的容器通信(主机与容器、容器与容器的通信)
  2. 跨主机间的容器通信
  3. 容器与主机间的通信(更多指跨主机)

其中1最好解决,本文的内容也是描述docker是如何解决问题1的。

容器的基石:Linux Kernel Namespace

Linux系统里面有各种资源:用户、用户ID、进程、进程ID、套接字、主机名等等,Linux内核提供了Namespace这种机制用来隔离这些资源,从Linux kernel 3.8开始共有6个:

  • Mount Namespace: 隔离文件系统挂载点
  • UTS Namespace: 隔离主机名和域名信息
  • IPC Namespace: 隔离进程间通信
  • PID Namespace: 隔离进程PID
  • Network 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的更多信息可参考:

Namespace技术是容器的基石,接下来要讨论的网络就是基于Network Namespace的,所以通过一些例子感受一下Network Namespace。

Network Namespace

Network Namespace用于隔离网络资源,比如网卡、IP地址、端口、路由表、防火墙等。下面通过一些实验感受一下Network Namespace(使用iproute2包里面的ip命令,具体的命令这里就不解释了,可通过ip helpip <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 pair

veth是虚拟以太网卡(Virtual Ethernet)的简写。正如名字所示,veth pair总是成对出现的,一端发送数据会在另一端接收。像极了Linux IPC中的pipe(管道),不过veth pair是“全双工”的,也就是数据可以双向流动。Linux IPC pipe用于进程间通信,而veth pair则可用于不同Network Namespace之间的网络通信。使用方法非常简单,创建一个veth pair,分别放到两个不同的Network Namespace,这样这两个NN就可以通信了,如下图:

veth-pair

注:假设根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中。需要注意的是:

  1. 可以将虚拟网络设备移入任何一个NN中,但真实的硬件网络设备只能在根NN中;
  2. 一个网络设备只能存在于一个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搜素),挺合适的,就不画了:

linux-bridge

简单说明一下:

  • 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是如何解决的。

]]>
<![CDATA[Flink Native Kubernetes支持Volume Mount]]> http://niyanchun.com/flink-native-k8s-volume-mount.html 2020-12-23T22:02:00+08:00 2020-12-23T22:02:00+08:00 NYC https://niyanchun.com 在之前的文章 Flink快速了解(4)——NativeKubernetes&HA 中讲到 Native Kubernetes在Flink 1.12版本中已经成为一个正式特性,使用起来也的确非常的简单、方便,但文末提到我碰到的一个问题:无法挂载volume。其实目前Flink Native Kubernetes这种方式提供的容器自定义能力还非常有限。从代码看,是通过一个个配置去支持的(见KubernetesConfigOptions.java),但k8s的Pod定义选项太多了,通过这种方式去支持,会一直疲于奔命,而且还要不断的和k8s版本关联。所以,目前社区有一个JIRA FLINK-15656: Support user-specified pod templates,计划直接支持用户自定义pod template,但目前好像还没有明确的版本计划。另外,考虑到pod挂载volume是一个更加普遍化的高需求,所以还有一个单独的JIRA FLINK-15649: Support mounting volumes,不过目前也没有明确的版本计划。

我看了一下这个JIRA,其实已经有人提了PR(#14283)了,不过还没有被合进去。这个PR的代码非常简单,有兴趣的可以看下,我把这个代码合到我本地的1.12分支,然后把新增的3个class和修改的3个class文件加到官方1.12发布的包中测试了一下,是可以实现volume mount的。下面记录一下过程,有兴趣的可以自行编译,或者直接下载我编译好的(点此下载,密码: hi52。怎么现在分享还必须设置密码了...)。

使用说明

这个PR增加的功能是给Flink Native Kubernetes部署模式下的JobManager和TaskManager增加volume mount的功能,支持 emptydir(默认)、hostpath、pvc三种。使用方式代码里面也写清楚了:

// KubernetesConfigOptions
public static final ConfigOption<String> JOBMANAGER_VOLUME_MOUNT =
    key("kubernetes.jobmanager.volumemount")
        .stringType()
        .noDefaultValue()
        .withDescription("Volume (pvc, emptydir, hostpath) mount information for the Job manager. " +
            "Value can contain several commas-separated volume mounts. Each mount is defined by several : separated " +
            "parameters - name used for mount, mounting path and volume specific parameters");

public static final ConfigOption<String> TASKMANAGER_VOLUME_MOUNT =
    key("kubernetes.taskmanager.vlumemount")
        .stringType()
        .noDefaultValue()
        .withDescription("Volume (pvc, emptydir, hostpath) mount information for the Task manager. " +
            "Value can contain several commas-separated volume mounts. Each mount is defined by several : separated " +
            "parameters - name used for mount, mounting path and volume specific parameters");

也可以从单元测试文件看使用方法:

// VolumeMountDecoratorTest
@Override
protected void setupFlinkConfig() {
    super.setupFlinkConfig();

    this.flinkConfig.setString(KubernetesConfigOptions.JOBMANAGER_VOLUME_MOUNT.key(),
        VolumeMountDecorator.KUBERNETES_VOLUMES_PVC + ":pvc-mount1:/opt/pvcclaim/tes1/:testclaim1:false,"
            + VolumeMountDecorator.KUBERNETES_VOLUMES_PVC + ":pvc-mount2::testclaim:false:path1->/opt/pvcclaim/test/path1;path2->/opt/pvcclaim/test/path2,"
            + VolumeMountDecorator.KUBERNETES_VOLUMES_EMPTYDIR + ":edir-mount:/emptydirclaim:" + VolumeMountDecorator.EMPTYDIRDEFAULT + ","
            + VolumeMountDecorator.KUBERNETES_VOLUMES_HOSTPATH + ":hp-mount:/var/local/hp:/var/local/hp:DirectoryOrCreate");
}

emptydir和hostpath的使用非常简单就不说了。pvc的使用有两种方式:

  • 方式1:不使用subpath,共5个参数。示例:-Dkubernetes.jobmanager.volumemount=pvc:<volume名称,自己起个名字>:<挂载路径>:<pvc名称>:<false|true>。最后一个false或者true表示是否以只读方式挂载。
  • 方式2:使用subpath,共6个参数。示例:-Dkubernetes.jobmanager.volumemount=pvc:<volume名称,自己起个名字>::<pvc名称>:<false|true>:<subPath>-><mountPath>

下面利用这个PR实现基于NFS的Flink Kubernetes HA。

  1. 先用修改过的flink-dist_2.11-1.12.0.jar替换官方包里面lib目录下的flink-dist_2.11-1.12.0.jar(懒得自己编译的,可以直接下载上面我编译好的,我是在官方包的基础上增加和替换了PR涉及的几个class文件,所以改动量非常小),注意是替换你提交任务的flink包的对应jar,不是替换容器里面的。
  2. 准备好一个pvc,这里我使用的是nfs storage-class提供的一个pvc:
$ kubectl get pvc            
NAME           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
flink-ha-pvc   Bound    pvc-46537a5b-2adc-442e-ae59-52af4c681f2c   500Mi      RWX            nfs-storage    16h
  1. 以Application cluster的方式提交一个任务(涉及的镜像参见之前的文章):

    $ ./bin/flink run-application \
         --target kubernetes-application \
         -Dkubernetes.cluster-id=flink-application-cluster \
         -Dhigh-availability=org.apache.flink.kubernetes.highavailability.KubernetesHaServicesFactory \
         -Dhigh-availability.storageDir=file:///opt/flink/flink-ha \
         -Dkubernetes.jobmanager.volumemount=pvc:jobmanager-ha:/opt/flink/flink-ha:flink-ha-pvc:false \
         -Dkubernetes.container.image=top-speed-windowing:1.12.0 \
         -Dkubernetes.rest-service.exposed.type=NodePort \
         local:///opt/flink/usrlib/TopSpeedWindowing.jar

检查一下:

$ kubectl get pod
NAME                                        READY   STATUS    RESTARTS   AGE
flink-application-cluster-9589dbf58-hm7xj   1/1     Running   0          76s
flink-application-cluster-taskmanager-1-1   1/1     Running   0          33s


$ kubectl describe pod flink-application-cluster-9589dbf58-hm7xj
Name:         flink-application-cluster-9589dbf58-hm7xj
Namespace:    default
Priority:     0
Node:         10.9.1.18/10.9.1.18
Start Time:   Thu, 24 Dec 2020 10:26:07 +0800
Labels:       app=flink-application-cluster
              component=jobmanager
              pod-template-hash=9589dbf58
              type=flink-native-kubernetes
Annotations:  <none>
Status:       Running
IP:           172.20.0.165
IPs:
  IP:           172.20.0.165
Controlled By:  ReplicaSet/flink-application-cluster-9589dbf58
Containers:
  flink-job-manager:
    Container ID:  docker://454fd2a6d3a913ce738f2e007f35e61d5068bfd9ad38d76bf900dbf1aaf9b70f
    Image:         top-speed-windowing:1.12.0
    Image ID:      docker://sha256:66d4aa5b13fc7c2ccce21685543fc2d079aac695d3480d9d27dbef2fc50ce875
    Ports:         8081/TCP, 6123/TCP, 6124/TCP
    Host Ports:    0/TCP, 0/TCP, 0/TCP
    Command:
      /docker-entrypoint.sh
    Args:
      native-k8s
      $JAVA_HOME/bin/java -classpath $FLINK_CLASSPATH -Xmx1073741824 -Xms1073741824 -XX:MaxMetaspaceSize=268435456 -Dlog.file=/opt/flink/log/jobmanager.log -Dlogback.configurationFile=file:/opt/flink/conf/logback-console.xml -Dlog4j.configuration=file:/opt/flink/conf/log4j-console.properties -Dlog4j.configurationFile=file:/opt/flink/conf/log4j-console.properties org.apache.flink.kubernetes.entrypoint.KubernetesApplicationClusterEntrypoint -D jobmanager.memory.off-heap.size=134217728b -D jobmanager.memory.jvm-overhead.min=201326592b -D jobmanager.memory.jvm-metaspace.size=268435456b -D jobmanager.memory.heap.size=1073741824b -D jobmanager.memory.jvm-overhead.max=201326592b
    State:          Running
      Started:      Thu, 24 Dec 2020 10:26:11 +0800
    Ready:          True
    Restart Count:  0
    Limits:
      cpu:     1
      memory:  1600Mi
    Requests:
      cpu:     1
      memory:  1600Mi
    Environment:
      _POD_IP_ADDRESS:   (v1:status.podIP)
    Mounts:
      /opt/flink/conf from flink-config-volume (rw)
      /opt/flink/flink-ha from jobmanager-ha (rw)
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-pzw5h (ro)
Conditions:
  Type              Status
  Initialized       True 
  Ready             True 
  ContainersReady   True 
  PodScheduled      True 
Volumes:
  flink-config-volume:
    Type:      ConfigMap (a volume populated by a ConfigMap)
    Name:      flink-config-flink-application-cluster
    Optional:  false
  jobmanager-ha:
    Type:       PersistentVolumeClaim (a reference to a PersistentVolumeClaim in the same namespace)
    ClaimName:  flink-ha-pvc
    ReadOnly:   false
  default-token-pzw5h:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-pzw5h
    Optional:    false
QoS Class:       Guaranteed
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type     Reason       Age                From                Message
  ----     ------       ----               ----                -------
  Normal   Scheduled    <unknown>          default-scheduler   Successfully assigned default/flink-application-cluster-9589dbf58-hm7xj to 10.9.1.18
  Warning  FailedMount  80s (x2 over 81s)  kubelet, 10.9.1.18  MountVolume.SetUp failed for volume "flink-config-volume" : configmap "flink-config-flink-application-cluster" not found
  Normal   Pulled       78s                kubelet, 10.9.1.18  Container image "top-speed-windowing:1.12.0" already present on machine
  Normal   Created      78s                kubelet, 10.9.1.18  Created container flink-job-manager
  Normal   Started      77s                kubelet, 10.9.1.18  Started container flink-job-manager

可以看到已经正确挂载了。

这个PR能不能用于生产?

能不能用于生产我觉得主要考虑的就是这个PR的可靠程度和后期维护、升级了。从这两个角度考虑我觉得是没问题的。这个PR代码量少,而且简单,实质只是增加了几项配置而已,对已有代码几乎是没有改动的,新增的配置也都是可选配置项,代码的可控性几乎是百分百的。可能更应该关心的是这个PR后面会不会被合到官方分支吧。我个人觉得不一定吧,volume mount的功能几乎肯定会支持,但未必最终使用这个PR的代码。但用了其它代码,对使用者而言,顶多也就是换个jar包,修改下创建任务的命令而已。

另外我觉得最重要的是这个改动只影响提交任务的过程,就这个过程也只影响创建容器的过程,也就是影响面仅限Kubernetes相关的东西,并没有影响任何Flink运行的功能。所以使用这个PR的时候记得只替换宿主机安装包里面的jar即可,不要替换容器里面真正运行的那个jar。

不过,如果你只想完全用官方的东西,那完全可以像之前版本一样,使用非Native的方式在Kubernetes上面部署Flink,不过我还是喜欢Native的东西,更加简单。

]]>
<![CDATA[Flink快速了解(4)——NativeKubernetes&HA]]> http://niyanchun.com/flink-quick-learning-4-native-kubernetes-ha.html 2020-12-21T23:41:00+08:00 2020-12-21T23:41:00+08:00 NYC https://niyanchun.com Flink的1.12.0版本前段时间发布了,又带来了很多新特性,其中有两个跟容器化相关的特性:

  • Native Kubernetes部署方式由之前的实验性(experimental)变为正式特性,也就是我们可以在生产环境里面放心大胆的使用了;
  • Kubernetes上面Flink的高可用除了ZooKeeper外又多了一种更轻量级的,更Native的基于ConfigMap的方案选择。

当然,这些特性目前在有些细小方面还是存在一些不足(不过瑕不掩瑜),下面的测试中会有所说明。

准备镜像

Flink 1.12已经有一段时间了,但官方的镜像到现在也还没有推上去,如果你用的是最新的1.12.0版本,看官方文档的时候一定要注意,文档里面都没有指定镜像名或者tag,这样拉取到的其实还是1.11.x版本的镜像,然后打出来的镜像在1.12的flink上面运行大概率是会报错的。

所以本次测试是我自己打的镜像。

flink 1.12.0镜像

git clone https://github.com/apache/flink-docker
# 此处可以选择你自己的Scala和JDK版本
cd 1.12/scala_2.11-java8-debian 
docker build  --tag flink:1.12.0-scala_2.11  .
Tips:Flink默认的镜像命名规则为:flink:<FLINK_VERSION>-scala_<SCALA_VERSION>",当然你也可以自定义,然后在启动的时候通过kubernetes.container.image参数指定镜像名称。

flink hadoop镜像

后面测试高可用的时候,我使用了HDFS,默认的镜像里面是不包含HDFS的依赖的,所以需要自己加进去(自行从maven下载)。如果你用的是oss、s3、swift之类的,就不需要了,默认已经包含了,只需要启动的时候配置一下即可。下面是Dockerfile:

FROM flink:1.12.0-scala_2.11
COPY flink-shaded-hadoop-2-uber-2.8.3-10.0.jar $FLINK_HOME/lib/

执行docker build --tag flink-haddop:1.12.0-scala_2.11 .

TopSpeedWindowing镜像

下面还会演示Flink Application Cluster,这种模式下需要把应用打到flink镜像里面,方法也非常简单,把应用的jar拷贝到$FLINK_HOME/usrlib即可,如果你需要定制配置文件的话,也可以加到这里。下面以flink自带的TopSpeedWindowing为例,该应用的jar在flink安装包的examples/streaming目录下。下面是Dockerfile:

FROM flink-haddop:1.12.0-scala_2.11
RUN mkdir -p $FLINK_HOME/usrlib
COPY TopSpeedWindowing.jar $FLINK_HOME/usrlib/TopSpeedWindowing.jar

执行docker build --tag top-speed-windowing:1.12.0 .

这样本文用到的所有镜像就准备完成了。

Flink Native Kubernetes

先下载并解压Flink 1.12.0版本(过程略),下面的命令都是在flink解压后的目录执行的。

Flink Session Cluster

前文介绍过了,Flink Session Cluster就是先部署一个集群,然后往集群上面提交任务。所以我们先创建一个集群:

./bin/kubernetes-session.sh \
    -Dkubernetes.container.image=flink:1.12.0-scala_2.11 \
    -Dkubernetes.rest-service.exposed.type=NodePort \
    -Dtaskmanager.numberOfTaskSlots=2 \
    -Dkubernetes.cluster-id=flink-session-cluster


# 下面是输出:
2020-12-21 22:55:35,527 INFO  org.apache.flink.configuration.GlobalConfiguration           [] - Loading configuration property: jobmanager.rpc.address, localhost
2020-12-21 22:55:35,537 INFO  org.apache.flink.configuration.GlobalConfiguration           [] - Loading configuration property: jobmanager.rpc.port, 6123
2020-12-21 22:55:35,537 INFO  org.apache.flink.configuration.GlobalConfiguration           [] - Loading configuration property: jobmanager.memory.process.size, 1600m
2020-12-21 22:55:35,537 INFO  org.apache.flink.configuration.GlobalConfiguration           [] - Loading configuration property: taskmanager.memory.process.size, 1728m
2020-12-21 22:55:35,538 INFO  org.apache.flink.configuration.GlobalConfiguration           [] - Loading configuration property: taskmanager.numberOfTaskSlots, 1
2020-12-21 22:55:35,538 INFO  org.apache.flink.configuration.GlobalConfiguration           [] - Loading configuration property: parallelism.default, 1
2020-12-21 22:55:35,540 INFO  org.apache.flink.configuration.GlobalConfiguration           [] - Loading configuration property: jobmanager.execution.failover-strategy, region
2020-12-21 22:55:35,773 INFO  org.apache.flink.client.deployment.DefaultClusterClientServiceLoader [] - Could not load factory due to missing dependencies.
2020-12-21 22:55:38,128 INFO  org.apache.flink.runtime.util.config.memory.ProcessMemoryUtils [] - The derived from fraction jvm overhead memory (160.000mb (167772162 bytes)) is less than its min value 192.000mb (201326592 bytes), min value will be used instead
2020-12-21 22:55:38,148 INFO  org.apache.flink.runtime.util.config.memory.ProcessMemoryUtils [] - The derived from fraction jvm overhead memory (172.800mb (181193935 bytes)) is less than its min value 192.000mb (201326592 bytes), min value will be used instead
2020-12-21 22:55:38,163 INFO  org.apache.flink.kubernetes.utils.KubernetesUtils            [] - Kubernetes deployment requires a fixed port. Configuration blob.server.port will be set to 6124
2020-12-21 22:55:38,163 INFO  org.apache.flink.kubernetes.utils.KubernetesUtils            [] - Kubernetes deployment requires a fixed port. Configuration taskmanager.rpc.port will be set to 6122
2020-12-21 22:55:38,259 INFO  org.apache.flink.runtime.util.config.memory.ProcessMemoryUtils [] - The derived from fraction jvm overhead memory (160.000mb (167772162 bytes)) is less than its min value 192.000mb (201326592 bytes), min value will be used instead
2020-12-21 22:55:44,134 INFO  org.apache.flink.kubernetes.KubernetesClusterDescriptor      [] - Create flink session cluster flink-session-cluster successfully, JobManager Web Interface: http://10.9.1.18:38566

这样一个集群就部署好了,Native Kubernetes的部署方式使用了Kubernetes的资源管理和分配能力,所以此时集群只有Jobmanager。TaskManager会在后面有任务时动态创建出来。输出日志的最后一行打印了Web UI地址,可以检查集群状态。

$ kubectl get pod,svc,cm,deploy
NAME                                         READY   STATUS    RESTARTS   AGE
pod/flink-session-cluster-665766d9d5-8jxmb   1/1     Running   0          13s

NAME                                 TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)             AGE
service/flink-session-cluster        ClusterIP   None          <none>        6123/TCP,6124/TCP   12s
service/flink-session-cluster-rest   NodePort    10.68.245.4   <none>        8081:38566/TCP      12s
service/kubernetes                   ClusterIP   10.68.0.1     <none>        443/TCP             38d

NAME                                           DATA   AGE
configmap/flink-config-flink-session-cluster   3      12s

NAME                                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/flink-session-cluster   1/1     1            1           13s

集群部署好了,提交一个任务:

./bin/flink run -p 4 \
    --target kubernetes-session \
    -Dkubernetes.cluster-id=flink-session-cluster \
    ./examples/streaming/TopSpeedWindowing.jar 

# 查看一下动态创建的TaskManager
kubectl get pod
NAME                                     READY   STATUS    RESTARTS   AGE
flink-session-cluster-665766d9d5-8jxmb   1/1     Running   0          2m17s
flink-session-cluster-taskmanager-1-1    1/1     Running   0          33s
flink-session-cluster-taskmanager-1-2    1/1     Running   0          33s

然后在web上面删除任务,可以看到集群依然是存在的,因为Session Cluster的生命周期是独立于Job的。但TaskManager在空闲一段时间(resourcemanager.taskmanager-timeout,默认30秒)后会被回收。

最后删除测试集群:

kubectl delete deploy/flink-session-cluster

Flink Application Cluster

前文介绍过,Flink Application Cluster就是应用创建自己专属的集群,一个应用可以包含多个Job,集群生命周期和Job同步。

# 创建集群&&启动应用
./bin/flink run-application \
    --target kubernetes-application \
    -Dkubernetes.cluster-id=flink-application-cluster \
    -Dkubernetes.container.image=top-speed-windowing:1.12.0 \
    -Dkubernetes.rest-service.exposed.type=NodePort \
    local:///opt/flink/usrlib/TopSpeedWindowing.jar

# 查看
kubectl get pod,svc,cm,deploy
NAME                                             READY   STATUS    RESTARTS   AGE
pod/flink-application-cluster-7fc5ccd899-9p7ns   1/1     Running   0          115s
pod/flink-application-cluster-taskmanager-1-1    1/1     Running   0          54s

NAME                                     TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)             AGE
service/flink-application-cluster        ClusterIP   None            <none>        6123/TCP,6124/TCP   114s
service/flink-application-cluster-rest   NodePort    10.68.147.132   <none>        8081:39463/TCP      113s
service/kubernetes                       ClusterIP   10.68.0.1       <none>        443/TCP             38d

NAME                                               DATA   AGE
configmap/flink-config-flink-application-cluster   3      113s

NAME                                        READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/flink-application-cluster   1/1     1            1           115s

和之前的Session Cluster相比,集群创建和任务提交集成在了一起,一条命令搞定了所有的事情,非常的方便。此时,如果你在Web上取消任务,你会发现整个集群都没了,符合Job Cluster生命周期与Job同步的说法。

Flink Job Cluster

目前不支持。Flink Job Cluster其实比较鸡肋,介于Session Cluster和Application Cluster之间,一般根据需要选则后面这两个即可。

基于Kubernetes的高可用

Flink的高可用主要解决JobManager的单点故障问题,之前只有一种基于Zookeeper的方案,1.12.0版本中增加了一个基于Kubernetes ConfigMap的方案(仅用于使用Kubernetes部署Flink的场景),该特性对应有一个FLIP-144: Native Kubernetes HA for Flink,对设计细节有兴趣的可以看下。这个原生的高可用方案使用也非常简单,在上的基础上再增加两个配置项即可:

# HA服务,zk的时候是zookeeper,Kubernetes的时候填下面这个类
high-availability: org.apache.flink.kubernetes.highavailability.KubernetesHaServicesFactory
# 虽然不再依赖zk了,但仍然需要高可用存储
high-availability.storageDir: hdfs:///flink/recovery

这里我使用的高可用存储是HDFS,下面分别创建两个集群:

# Flink Session Cluster
./bin/kubernetes-session.sh \
    -Dkubernetes.rest-service.exposed.type=NodePort \
    -Dkubernetes.container.image=flink-haddop:1.12.0-scala_2.11 \
    -Dhigh-availability=org.apache.flink.kubernetes.highavailability.KubernetesHaServicesFactory \
    -Dhigh-availability.storageDir=hdfs://<namenode-ip>:<port>/flink/flink-ha \
    -Dkubernetes.cluster-id=flink-session-cluster

# Flink Application Cluster
./bin/flink run-application \
    --target kubernetes-application \
    -Dkubernetes.container.image=flink-haddop:1.12.0-scala_2.11 \
    -Dkubernetes.cluster-id=flink-application-cluster \
    -Dhigh-availability=org.apache.flink.kubernetes.highavailability.KubernetesHaServicesFactory \
    -Dhigh-availability.storageDir=hdfs://<namenode-ip>:<port>/flink/flink-ha \
    -Dkubernetes.container.image=top-speed-windowing:1.12.0 \
    -Dkubernetes.rest-service.exposed.type=NodePort \
    local:///opt/flink/usrlib/TopSpeedWindowing.jar

Native的HA方案真的是非常naive,使用非常简单,没什么需要讲的。

最后解释一些关于HA的问题。

1, 为什么上述命令启动的JobManager Pod只有1个副本?答:除了用上面Flink自己的kubernetes-session.sh或者flink命令这种便捷方式容器化外,也有很多人自己写部署文件或者使用官方提供的部署文件(特别是Native Kubernetes还不成熟的时候),有些人部署HA的时候将JobManager的副本数设置为2,甚至更多。其实在Kubernetes上面,这是没有必要的。如前所述,Flink提供的HA是主JobManager挂掉后,从快速接替主的职责,从共享存储上面恢复已经提交运行的任务。对于Kubernetes,如果JobManager的Pod挂掉了,马上会有一个新的JobManager Pod被创建出来。也就是这个standby的JobManager不是事先就创建好的,而是在需要的时候动态产生的。这样也不用平时空跑一个standby的JobManager浪费资源(注意:standby的JobManager是纯备,不对外提供任务服务,也没有什么负载均衡的功能)。

2, Flink的HA模式下,如果主JobManager挂掉了,任务会重启,这还算HA吗?会丢数据吗?答:这个问题也同时适用于基于Zookeeper的HA。Flink的HA模式依赖两部分:一部分是ZK或者Kubernetes的ConfigMap,这部分主要职责是负责选举主JobManager、服务发现、少量元数据存储(比如任务的运行状态、任务相关的二进制文件在共享存储上的存储路径等);另外一部分是共享存储(比如上面的HDFS),该部分的主要职责是存储任务相关的二进制文件,比如Job Graph及其依赖等。有了这两部分,当主JobManager挂掉后,就会有新的JobManager产生,它可以依据这些信息重新恢复之前运行的任务。注意,这里是恢复。也就是主节点故障后,任务会故障,但HA模式下,Flink会保证任务快速被恢复。那这种机制算HA吗?当然要看下HA的定义:

高可用(High Availability,HA)是分布式系统架构设计中必须考虑的因素之一,它通常是指,通过设计减少系统不能提供服务的时间。假设系统一直能够提供服务,我们说系统的可用性是100%。如果系统每运行100个时间单位,会有1个时间单位无法提供服务,我们说系统的可用性是99%。在线系统和执行关键任务的系统通常要求其可用性要达到5个9标准(99.999%,年故障时间为5分15秒)。

按此定义来说,上述机制当然算HA,只是它提供的不是100%的可用性而已。另外,丢不丢数据这个其实跟HA关系不大,解决丢数据的问题是分布式中容错(Fault Tolerance)的职责,Flink提供了Checkpoint和Savepoint两种机制。也就是如果你开启了Checkpoint,那有没有HA都可以保证不丢数据;反之,没开启的话,有没有HA都可能会丢数据。

总结

可以看到,这两个新特性让Flink在Kubernetes上的使用变得简单了很多。当然,试用了一下,我个人觉得后面还是有一些工作需要完善,比如现在通过kubernetes-session.sh或者flink创建集群时,虽然已经支持很多配置项(见这里)来满足一些自定义,但还是不够。比如我刚开始想用nfs来测试高可用,但发现通过上面的方式创建集群的话,没有方式可以挂载PVC,像HDFS、OSS、S3这些不需要挂载就可以直接使用,但NFS不行,所以后面就又部署了一个HDFS进行测试。不过目前社区已经在改进这些不足了,见FLINK-15649: Support mounting volumesFLINK-15656: Support user-specified pod templates. 这部分我单独写了一篇文章Flink Native Kubernetes支持Volume Mount,有兴趣的可以查看。

参考:

]]>
<![CDATA[通过Heapster API获取Kubernetes监控数据]]> http://niyanchun.com/get-k8s-monitor-data-by-heapster-api.html 2017-04-07T14:54:00+08:00 2017-04-07T14:54:00+08:00 NYC https://niyanchun.com 在上篇文章《Kubernetes资源使用监控实践》中,我们已经介绍了如何在Kubernetes上面部署Heapster,以及结合InfluxDB和Grafana做了界面展示,但在最后我们提到很多系统可能只是获取这些监控信息,然后使用自己的方式去做展示。所以本文主要介绍如何通过Heapster提供的REST API来获取这些监控信息。

Heapster通过Metric模型来组织这些监控数据,这些数据可以通过REST API来获取,目前提供的监控维度可参考:https://github.com/kubernetes/heapster/blob/master/docs/storage-schema.md。基本上涵盖了CPU、内存、网络、文件系统、运行时间等四个维度的监控统计,但是不同的资源(Cluster、Node、Namespace、Pod、Container)一般只会覆盖一部分监控维度,比如Cluster目前只支持memory/usagecpu/requestcpu/limitmemory/requestmemory/limitcpu/usage_rate几个监控维度,这些都可以通过API获取到。

在上面给出的文档链接中,也已经给出了REST API,为了更方便的使用这些API,我写了一个简单的Library:heapster-client,通过这个库可以更方便的去使用Heapster提供的REST API。这里给一个简单的例子,实现获取Cluster支持的Metric,以及获取其中一个Metric的监控数据:

package main

import (
    "fmt"
    "github.com/niyanchun/heapster-client/client"
    "time"
    "github.com/niyanchun/heapster-client/type/v1"
)

func main() {
    HEAPSTER_URL := "http://192.168.56.101:8080/api/v1/proxy/namespaces/kube-system/services/heapster"
    client := client.NewClient(HEAPSTER_URL, "", "")

    start, end := getTimeRange()
    list, err := client.ListClusterMetric()
    checkErr(err)
    fmt.Printf("ListClusterMetric:\n %v", list)

    metrics, err := client.GetClusterMetrics(v1.CPU_USAGE_RATE, start, end)
    checkErr(err)
    fmt.Printf("ListClusterMetric:\n %v", metrics)
}

func checkErr(err error) {
    if err != nil {
        panic(err)
    }
}

func getTimeRange() (string, string) {
    now := time.Now().UTC()
    end := now.Format(time.RFC3339)
    start := now.Add(-600 * 1e9).Format(time.RFC3339) // ten minutes before
    fmt.Println(start, end)

    return start, end
}
]]>
<![CDATA[Kubernetes资源使用监控实践]]> http://niyanchun.com/kubernetes-resource-usage-monitor-practice.html 2017-04-01T15:07:00+08:00 2017-04-01T15:07:00+08:00 NYC https://niyanchun.com 对Kubernetes中的各项资源进行监控有助于我们时刻了解集群的运行状况,从而对集群进行相应的操作,比如扩容、缩容等。在Kubernetes中主要通过Heapster结合一些其它组件进行资源监控。

1. 架构分析

Heapster也是Kubernetes的一部分,主要目标就是对Kubernetes集群进行基础监控,再结合cAdvisor等工具就可以对集群进行很好的监控。而cAdvisor工具已经集成到了kubelet文件中,当我们部署好一个Kubernetes集群以后,默认监听在4194端口,且有简单的UI:

cAdvisor.png

某个容器的资源使用:

cAdvisor-2.png

Heapster和普通的应用一样,都是以Pod的方式运行在集群中,并通过kubelet进程获取Node的使用信息,而kubelet则是通过自己内置的cAdvisor来获取数据。然后Heapster根据Pod及相关的Labels将这些信息进行分组聚合,最后保存到后端。这个后端也是可配置的,在开源界最受欢迎的是InfluxDB(使用Grafana作为前端展示),其他支持的后端可参考here。整个监控的架构如下:

monitoring-architecture.png

2. 部署实践

我们以最受欢迎的Heapster+InfluxDB+Grafana作为实践,大部分涉及的东西可以在Heapster工程下面找到,包括部署需要的yaml文件,如果你可以访问Google的registry,那么整个实践还是比较容易的,否则可能会比较麻烦。

2.1 Heapster部署

heapster-deployment.yaml:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: heapster
  namespace: kube-system
spec:
  replicas: 1
  template:
    metadata:
      labels:
        task: monitoring
        k8s-app: heapster
    spec:
      containers:
      - name: heapster
        image: gcr.io/google_containers/heapster-amd64:v1.3.0-beta.1
        imagePullPolicy: IfNotPresent
        command:
        - /heapster
        - --source=kubernetes:https://kubernetes.default
        - --sink=influxdb:http://monitoring-influxdb:8086

这里需要注意一个问题,如果你部署的Kubernetes集群没有创建证书,只有http的话,那heapster会因为证书找不到而启动失败,此时,可以将上面的 --source改掉:

- --source=kubernetes:http://192.168.56.101:8080/?inClusterConfig=false

即替换为你的http地址(注意将上面的IP和端口替换为你的集群的端口和地址,即执行kubectl cluster-info的输出地址),当然你也可以自己生成证书,可参考:https://github.com/kubernetes/heapster/blob/master/docs/source-configuration.md#current-sources

heapster-service.yaml文件:

apiVersion: v1
kind: Service
metadata:
  labels:
    task: monitoring
    # For use as a Cluster add-on (https://github.com/kubernetes/kubernetes/tree/master/cluster/addons)
    # If you are NOT using this as an addon, you should comment out this line.
    kubernetes.io/cluster-service: 'true'
    kubernetes.io/name: Heapster
  name: heapster
  namespace: kube-system
spec:
  ports:
  - port: 80
    targetPort: 8082
  selector:
    k8s-app: heapster

2.2 InfluxDB部署

influxdb-deployment.yaml:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: monitoring-influxdb
  namespace: kube-system
spec:
  replicas: 1
  template:
    metadata:
      labels:
        task: monitoring
        k8s-app: influxdb
    spec:
      containers:
      - name: influxdb
        image: gcr.io/google_containers/heapster-influxdb-amd64:v1.1.1
        volumeMounts:
        - mountPath: /data
          name: influxdb-storage
      volumes:
      - name: influxdb-storage
        emptyDir: {}

influxdb-service.yaml:

apiVersion: v1
kind: Service
metadata:
  labels:
    task: monitoring
    # For use as a Cluster add-on (https://github.com/kubernetes/kubernetes/tree/master/cluster/addons)
    # If you are NOT using this as an addon, you should comment out this line.
    kubernetes.io/cluster-service: 'true'
    kubernetes.io/name: monitoring-influxdb
  name: monitoring-influxdb
  namespace: kube-system
spec:
  ports:
  - port: 8086
    targetPort: 8086
  selector:
    k8s-app: influxdb

InfluxDB默认的端口是8086,用户名和密码都是"root"。当你登录到Grafana后,可以通过http://localhost:8086来访问InfluxDB。

2.3 Grafana部署

grafana-deployment.yaml:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: monitoring-grafana
  namespace: kube-system
spec:
  replicas: 1
  template:
    metadata:
      labels:
        task: monitoring
        k8s-app: grafana
    spec:
      containers:
      - name: grafana
        image: gcr.io/google_containers/heapster-grafana-amd64:v4.0.2
        ports:
          - containerPort: 3000
            protocol: TCP
        volumeMounts:
        - mountPath: /var
          name: grafana-storage
        env:
        - name: INFLUXDB_HOST
          value: monitoring-influxdb
        - name: GRAFANA_PORT
          value: "3000"
          # The following env variables are required to make Grafana accessible via
          # the kubernetes api-server proxy. On production clusters, we recommend
          # removing these env variables, setup auth for grafana, and expose the grafana
          # service using a LoadBalancer or a public IP.
        - name: GF_AUTH_BASIC_ENABLED
          value: "false"
        - name: GF_AUTH_ANONYMOUS_ENABLED
          value: "true"
        - name: GF_AUTH_ANONYMOUS_ORG_ROLE
          value: Admin
        - name: GF_SERVER_ROOT_URL
          # If you're only using the API Server proxy, set this value instead:
          # value: /api/v1/proxy/namespaces/kube-system/services/monitoring-grafana/
          value: /
      volumes:
      - name: grafana-storage
        emptyDir: {}

grafana-service.yaml:

apiVersion: v1
kind: Service
metadata:
  labels:
    # For use as a Cluster add-on (https://github.com/kubernetes/kubernetes/tree/master/cluster/addons)
    # If you are NOT using this as an addon, you should comment out this line.
    kubernetes.io/cluster-service: 'true'
    kubernetes.io/name: monitoring-grafana
  name: monitoring-grafana
  namespace: kube-system
spec:
  # In a production setup, we recommend accessing Grafana through an external Loadbalancer
  # or through a public IP.
  # type: LoadBalancer
  # You could also use NodePort to expose the service at a randomly-generated port
  type: NodePort
  ports:
  - port: 80
    targetPort: 3000
  selector:
    k8s-app: grafana

Grafana的service文件里面默认使用的是LoadBalancer,但如果你的集群不支持的话,可以像我上面一样使用NodePort的方式。

三个应用部署好以后,我们可以通过Web去访问Grafana了:

Grafana.png

当然,如果你装了kubernetes-dashboard,你会发现dashboard上面也有资源使用的图示了。

实际使用中,我们可能一般不会使用诸如Grafana这种显示工具,而是通过InfluxDB的API去获取监控信息,然后在自己的系统里面展示监控。后面文章中我们会展示如何通过Heapster的API去获取这些监控信息。

参考:

  1. Resource Usage Monitoring
  2. https://github.com/kubernetes/heapster
]]>
<![CDATA[Ubuntu 14.04部署Kubernetes及可能遇到的问题]]> http://niyanchun.com/deploy-kubernetes-in-ubuntu-14-04.html 2017-03-28T11:14:00+08:00 2017-03-28T11:14:00+08:00 NYC https://niyanchun.com 之前已经写过文章如何在Ubuntu 16.06等使用systemd的系统上面部署Kubernetes,正好使用的是非systemd系统的Ubuntu 14.04部署了一下Kubernetes,按照官方文档遇到了很多问题,总结了一下发出来,希望可以帮助到遇到同样问题的人。

本文主要参考自Kubernetes官方文档《Manually Deploying Kubernetes on Ubuntu Nodes》,但因文档很久没有更新了,部署比较新的版本时会有很多问题,所以这里列一下部署步骤,并说明部署过程中可能遇到的问题及解决方案。

NB:该部署方法只适用于Ubuntu 14.04等使用upstart的系统,不适用于使用Systemd的系统(比如对于Ubuntu,15.04及之后版本)。

假设你有三台服务器,且规划如下:

IP AddressRole
10.10.103.223node
10.10.103.162node
10.10.103.250both master and node

请保证已经安装了docker 1.2+、bridge-utils工具。

1. 部署过程

  1. 设置多台服务器免密登录:

    $ ssh-keygen -t rsa
    $ cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys

    类似上面这样生产密钥信息,并将每台的id_rsa.pub信息加到其它节点的/root/.ssh/authorized_keys。这里使用的是root相互免密登录,如果是其他用户,注意改变目录。更多细节可参考:http://www.path8.net/tn/archives/4473

  2. 下载代码:

    git clone --depth 1 https://github.com/kubernetes/kubernetes.git
  3. 修改配置文件cluster/ubuntu/config-default.sh,如下为需要修改的地方,修改内容请更换为自己的信息:

    export nodes="vcap@10.10.103.250 vcap@10.10.103.162 vcap@10.10.103.223"
    export roles="ai i i"
    export NUM_NODES=${NUM_NODES:-3}
    export SERVICE_CLUSTER_IP_RANGE=192.168.3.0/24
    export FLANNEL_NET=172.16.0.0/16
    • nodes部分填写你机器的用户名及IP信息。
    • roles有三种类型:ai表示既是master,又是node;i表示为node节点。
    • NUM_NODES表示Node节点的个数。
    • SERVICE_CLUSTER_IP_RANGE表示service可用的IP地址范围,如果和你物理机IP不冲突的话,不建议修改默认值。
    • FLANNEL_NET表示flanneld的子网范围,如果和你物理机IP不冲突的话,不建议修改默认值。
  4. 配置完后,在cluster目录执行KUBERNETES_PROVIDER=ubuntu ./kube-up.sh命令,中途会下载很多依赖的东西,请保证网络畅通。如果出现类似下面的打印信息,则表示安装成功:

    ...
    ... calling validate-cluster
    Found 1 node(s).
    NAME            STATUS    AGE
    172.31.12.197   Ready     20s
    Validate output:
    NAME                 STATUS    MESSAGE              ERROR
    controller-manager   Healthy   ok
    scheduler            Healthy   ok
    etcd-0               Healthy   {"health": "true"}
    Cluster validation succeeded
    Done, listing cluster services:
    
    Kubernetes master is running at http://172.31.12.197:8080
    
    To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

2. 可能遇到的问题

  1. version文件缺失,报错信息类似下面这样:

    Can't determine Kubernetes release.
    /root/kubernetes/cluster/get-kube-binaries.sh should only be run from a     prebuilt Kubernetes release.
    Did you mean to use get-kube.sh instead?

    该问题见#39224,目前的解决方案是在kubernetes目录下创建version文件,内容为你想安装的Kubernetes版本,比如v1.5.5

  2. /root/kube/util.sh: line 23: /cluster/common.sh: No such file or director

    解决方案:在cluster/ubuntu/util.sh中设置KUBE_ROOT环境变量,值是下载的Kubernetes的目录。

  3. /root/kube/util.sh: line 48: nodes: unbound variable

    解决方案:在cluster/ubuntu/util.sh中执行一下config-default.sh

    source $KUBE_ROOT/cluster/ubuntu/config-default.sh
  4. kube-apiserver一直起不来,报错信息类似:

    The connection to the server 172.31.12.197:8080 was refused - did you specify the right host or port?`

    解决方案:这个原因是因为1.6版本中增加了一些插件支持,但是1.5不支持,见#43693。修改cluster/ubuntu/config-default.sh,删除export ADMISSION_CONTROL=NamespaceLifecycle,LimitRanger,ServiceAccount,DefaultStorageClass,DefaultTolerationSeconds,ResourceQuota里面的DefaultTolerationSeconds

3. kube-up.sh简要分析

这个shell脚本是Kubernetes官方提供了一个部署一个集群的脚本,里面调用了很多其他的脚本,这里不打算细述,主要有三个原因:

  • 极大多数人只是需要使用这个脚本来拉起一个Kubernetes集群,而并不需要关注其中的细节。
  • Kubernetes仍然处于快速的开发中,后续很可能就不会使用这种方法去部署集群了(毕竟个人觉得这种方法不是非常的严谨,而且感觉有点low,不是Google的风格)。目前根据官方的文档来看,该脚本也只适用于在upstart系统上面部署,而对于比较新的使用systemd的Linux系统,需要使用新的部署工具kubeadm
  • shell脚本很简单,都是Linux命令组成的,没什么需要分析的。如果你还看不懂,那就man或者Google,如果还看不懂,那应该去看《Linux从入门到放弃》orz...

但是我们仍然需要对这个部署的过程有一个大概的了解,主要有两个目的:

  • 出了问题时方便定位问题。
  • 如果我们想要修改一些默认的参数值,需要知道去哪里修改。

所以这也是我为什么要对kube-up.sh进行简要分析的原因。虽然kube-up.sh引用了一大堆其他脚本和函数,但其实如果你对于Kubernetes的组成非常清楚,特别是如果你读过我之前《手动部署Kubernetes》系列的文章:

那你看这个脚本时就会非常清楚,其实这个脚本就是把手动执行的东西都自动化了,不过是基于upstart系统而已。好吧,说了这么多废话,现在开始简要分析吧。

kube-up.sh支持在多种平台部署Kubernetes,这里以Ubuntu为例。我们在cluster目录执行KUBERNETES_PROVIDER=ubuntu ./kube-up.sh命令后,kube-up.sh知道是在Ubuntu系统上面部署,就会使用cluster/ubuntu目录下的脚本。如果是其他平台的,就会执行其他平台下面的脚本,不过结构基本是类似的。

首先涉及的就是download-release.sh这个脚本,kube-up.sh首先会检查依赖的包是否有下载下来,如果没有,就会进行下载,而具体下载过程就是这个脚本控制的。如果这个脚本里面写的地址因为一些不可抗拒的原因无法下载,你可以通过其他方式下载下来,然后修改一下脚本,注释掉下载的部分,直接使用你下载好的,或者直接将脚本里面下载地址换为其他你可以访问的地址。对于中国用户,这个往往非常重要。

其次要分析的就是config-default.sh这个脚本,其实这个是集群的配置文件,包括大部分Kubernetes进程(master的四个进程和node的两个进程以及flanneld等)的配置,所以,如果我们想要更改集群默认的配置,首先就在这个文件里面找,看是否有相关的选项。比如kube-apiserver的启动参数、kubelet的启动参数、flanneld的子网配置等都在这个文件里面。

再次要分析的就是utils.sh脚本,这个脚本里面基本上都是函数,kube-up.sh及其它脚本调用的函数基本上都是在这里定义的。这些函数主要是生成集群的配置文件,检查集群的状态等。这个脚本里面也有一部分集群的配置,如果在config-default.sh脚本里面找不到的配置,可以在这个脚本里面找一下,或者直接在这个脚本的具体函数里面添加。该脚本生成的各进程的配置文件在/etc/default/目录下面。

最后再提一下reconfDocker.sh这个脚本,这个脚本主要是负责配置docker,其实做的事情就是《Ubuntu16.04手动部署Kubernetes(2)——Flannel网络部署》讲的操作。

几个比较重要的脚本介绍完毕后,最后再介绍一个需要注意的点:Kubernetes提供了kube-up.sh来拉起一个集群,提供了kube-down.sh来关闭一个集群。我们需要清楚哪些文件是在在执行kube-up.sh时拷贝到某个目录,然后执行kube-down.sh时被删除。需要知道这个的原因是我们做一些自定义的修改时就知道在哪里修改不会被下次的kube-up.sh覆盖。这里列几个:

  • /root/kube:该目录下的文件是执行kube-up.sh时从cluster/ubuntu下面拷贝过去的,每执行一次就会拷贝一次,且执行kube-down.sh时会被删除,所以如果需要修改这个下面的内容,一定要去修改cluster/ubuntu下的脚本,然后重新执行kube-up.sh
  • /opt/bin:默认所有进程文件都会安装到这个目录下,是从cluster/ubuntu/binaries/目录下拷贝过去的。
  • /etc/default:所有Kubernetes进程的启动参数配置文件都在这个目录下,是utils.sh脚本执行时生成到这里的,如果更改了这里的文件,不能通过执行kube-up.sh使其生效,而应该改了哪个进程,就执行service xxx restart来使其生效。

最后,Kubernetes开发速度很快,部署对很多不熟悉Kubernetes的人一直是个头疼的问题,特别是不同的Linux发行版部署方法还不太一样。所以遇到问题多Google,或者直接去Github上面搜索或者提issue,很多问题其实别人可能已经问过了,且有解决方案了。个人推荐最好可以手动部署一套,部署的过程也就是学习Kubernetes的过程,而且这样部署一遍后,其他的任何方案都只是对这种最原始部署方案的封装而已。

]]>
<![CDATA[Ubuntu16.04手动部署Kubernetes(3)——Dashboard和KubeDNS部署]]> http://niyanchun.com/deploy-kubernetes-step-by-step-on-trusty-section-3.html 2017-02-25T13:15:00+08:00 2017-02-25T13:15:00+08:00 NYC https://niyanchun.com 今天继续接着前文:

继续来部署Kubernetes。今天主要来部署Dashboard和KubeDNS,主要是后者。因为Kubernetes在每个Pod内都会起一个很小的“系统”容器pause-amd64:3.0来实现Pod内的网络,而这个容器默认会去从Google的Registry拉,但国内如果没有梯子访问不了。所以我pull了一个,传到了国内的镜像仓库,我们改下每个Node节点上的kubelet的配置,增加--pod_infra_container_image选项,这个选项可以指定从哪里pull这个基础镜像,我修改后的配置如下:

KUBELET_ARGS="--api-servers=http://192.168.56.101:8080 --cluster-dns=169.169.0.2 --cluster-domain=cluster.local --hostname-override=192.168.56.101 --logtostderr=false --log-dir=/var/log/kubernetes --v=2 --pod_infra_container_image=hub.c.163.com/allan1991/pause-amd64:3.0"

然后重启Kubelet:systemctl restart kubelet.service.另外,

除了这个镜像外,还有很多镜像也会有这个问题,所以我都传到了国内的镜像仓库,本文介绍时都使用国内我自己传的。大家没有梯子的话,直接使用我用的这个就可以了。OK,下面开始今天的主题。

部署Dashboard

Kubernetes提供了一个基础的图形化界面,就是这个Dashboard。基本上使用kubectl可以实现的功能现在都可以通过界面去做了,本质上它俩都是调用Kubernetes API。而部署这个Dashboard也非常简答,就是创建一个Pod和一个Service。最新的创建dashboard的yaml文件可以去查看官方:https://rawgit.com/kubernetes/dashboard/master/src/deploy/kubernetes-dashboard.yaml。我的yaml文档如下:

# Copyright 2015 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Configuration to deploy release version of the Dashboard UI.
#
# Example usage: kubectl create -f <this_file>

kind: Deployment
apiVersion: extensions/v1beta1
metadata:
  labels:
    app: kubernetes-dashboard
  name: kubernetes-dashboard
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kubernetes-dashboard
  template:
    metadata:
      labels:
        app: kubernetes-dashboard
      # Comment the following annotation if Dashboard must not be deployed on master
      annotations:
        scheduler.alpha.kubernetes.io/tolerations: |
          [
            {
              "key": "dedicated",
              "operator": "Equal",
              "value": "master",
              "effect": "NoSchedule"
            }
          ]
    spec:
      containers:
      - name: kubernetes-dashboard
        image: hub.c.163.com/allan1991/kubernetes-dashboard-amd64:v1.5.1
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 9090
          protocol: TCP
        args:
          # Uncomment the following line to manually specify Kubernetes API server Host
          # If not specified, Dashboard will attempt to auto discover the API server and connect
          # to it. Uncomment only if the default does not work.
          - --apiserver-host=http://192.168.56.101:8080
        livenessProbe:
          httpGet:
            path: /
            port: 9090
          initialDelaySeconds: 30
          timeoutSeconds: 30
---
kind: Service
apiVersion: v1
metadata:
  labels:
    app: kubernetes-dashboard
  name: kubernetes-dashboard
  namespace: kube-system
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 9090
  selector:
    app: kubernetes-dashboard

这里需要注意的是--apiserver-host默认是注释掉的,我有次部署时虽然找到了APIServer,但是连接被决绝,但是显式的去掉这个注释,写上APIServer的地址后就好了,所以我推荐还是写上比较好。然后使用kubectl create -f <this_file>就可以创建了。待这个DeploymentService创建成功后,执行kubectl --namespace=kube-system get svc命令查看映射的端口号,当然我们也可以在yaml文件里面使用nodePort指定为我们想要的端口,这里我没有指定,让系统自己选择,因为选择好以后就会固定,不会再变了。我的创建好有如下:

Master➜  kube-system kubectl --namespace=kube-system get svc
NAME                   CLUSTER-IP       EXTERNAL-IP   PORT(S)         AGE
kubernetes-dashboard   169.169.162.93   <nodes>       80:10977/TCP    3d

这里需要注意执行命令时要加上--namespace=kube-system。前面我们也已经介绍过了,Kubernetes部署好后默认有两个namespace:defaultkube-system。用户自己的默认在default下,kubectl默认也使用这个namespace;Kubernetes自己的系统模块在kube-system下面。这样我们的Dashboard已经创建好了,通过APIServer的IP加上面的端口号10977就可以访问界面了:

Kubernetes_Dashboard.png

界面还是非常的清爽的,使用起来也很方便和简单,所以这里就不详细介绍了,可以参考官方图文教程:https://kubernetes.io/docs/user-guide/ui/

部署KubeDNS

在这之前我们先要搞清楚为什么需要DNS?是为了实现服务发现。而且DNS其实是Kubernetes 1.3开始才内置的功能,在这之前是利用Linux环境的方式来做的服务发现。这里我们举例说明一下利用环境变量做服务发现的过程:比如我们部署了几个服务,这些服务需要找到彼此。利用环境变量的方式就是在创建Pod的时候将Service的一些信息(主要是IP+端口)以环境变量的方式(环境变量按照一定的规则命名)注入到Pod上的容器内,这样就做到了服务发现。这种方式虽然简单,但显然有两个不足:

  1. 随着服务越来越多,环境变量会越来越多。
  2. Pod必须在Service之后创建。

所以后来就出现了DNS,利用这种方式去做服务发现就不会有上面所说的限制了。那DNS又是怎么做服务发现的呢,再举个例子:比如我们在Kubernetes的叫bar的namespace下面创建了一个Service叫foo。那么在bar下运行的Pod只需要做一个foo DNS查询便可获取到这个服务的IP和Port。如果是运行在其他namespace下的Pod想要查询foo这个服务,则只需要查询foo.bar即可。当然一般还会有一个域名(domain)。更详细的信息请参阅https://kubernetes.io/docs/admin/dns/

起初,Kubernetes的DNS是使用SkyDNS来实现的,网上现在的教程也基本是基于此的。但是新版本的Kubernetes已经不需要SkyDNS了,它有了自己的DNS模块——KubeDNS。这也是本文要介绍的。SkyDNS依赖于etcd,而KubeDNS不需要etcd。KubeDNS的部署也很简单,官方已经提供了基础的yaml文件:https://github.com/kubernetes/kubernetes/tree/master/cluster/addons/dns。下面是我的yaml文件:

kubedns-deployment.yaml:

# Copyright 2016 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# TODO - At some point, we need to rename all skydns-*.yaml.* files to kubedns-*.yaml.*
# Should keep target in cluster/addons/dns-horizontal-autoscaler/dns-horizontal-autoscaler.yaml
# in sync with this file.

# Warning: This is a file generated from the base underscore template file: skydns-rc.yaml.base

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: kube-dns
  namespace: kube-system
  labels:
    k8s-app: kube-dns
    kubernetes.io/cluster-service: "true"
spec:
  # replicas: not specified here:
  # 1. In order to make Addon Manager do not reconcile this replicas parameter.
  # 2. Default is 1.
  # 3. Will be tuned in real time if DNS horizontal auto-scaling is turned on.
  strategy:
    rollingUpdate:
      maxSurge: 10%
      maxUnavailable: 0
  selector:
    matchLabels:
      k8s-app: kube-dns
  template:
    metadata:
      labels:
        k8s-app: kube-dns
      annotations:
        scheduler.alpha.kubernetes.io/critical-pod: ''
        scheduler.alpha.kubernetes.io/tolerations: '[{"key":"CriticalAddonsOnly", "operator":"Exists"}]'
    spec:
      containers:
      - name: kubedns
        image: hub.c.163.com/allan1991/kubedns-amd64:1.9
        resources:
          # TODO: Set memory limits when we've profiled the container for large
          # clusters, then set request = limit to keep this container in
          # guaranteed class. Currently, this container falls into the
          # "burstable" category so the kubelet doesn't backoff from restarting it.
          limits:
            memory: 170Mi
          requests:
            cpu: 100m
            memory: 70Mi
        livenessProbe:
          httpGet:
            path: /healthz-kubedns
            port: 8080
            scheme: HTTP
          initialDelaySeconds: 60
          timeoutSeconds: 5
          successThreshold: 1
          failureThreshold: 5
        readinessProbe:
          httpGet:
            path: /readiness
            port: 8081
            scheme: HTTP
          # we poll on pod startup for the Kubernetes master service and
          # only setup the /readiness HTTP server once that's available.
          initialDelaySeconds: 3
          timeoutSeconds: 5
        args:
        - --kube-master-url=http://192.168.56.101:8080
        - --domain=cluster.local.
        - --dns-port=10053
        - --config-map=kube-dns
        # This should be set to v=2 only after the new image (cut from 1.5) has
        # been released, otherwise we will flood the logs.
        - --v=0
        # {{ pillar['federations_domain_map'] }}
        env:
        - name: PROMETHEUS_PORT
          value: "10055"
        ports:
        - containerPort: 10053
          name: dns-local
          protocol: UDP
        - containerPort: 10053
          name: dns-tcp-local
          protocol: TCP
        - containerPort: 10055
          name: metrics
          protocol: TCP
      - name: dnsmasq
        image: hub.c.163.com/allan1991/kube-dnsmasq-amd64:1.4
        livenessProbe:
          httpGet:
            path: /healthz-dnsmasq
            port: 8080
            scheme: HTTP
          initialDelaySeconds: 60
          timeoutSeconds: 5
          successThreshold: 1
          failureThreshold: 5
        args:
        - --cache-size=1000
        - --no-resolv
        - --server=127.0.0.1#10053
        - --log-facility=-
        ports:
        - containerPort: 53
          name: dns
          protocol: UDP
        - containerPort: 53
          name: dns-tcp
          protocol: TCP
        # see: https://github.com/kubernetes/kubernetes/issues/29055 for details
        resources:
          requests:
            cpu: 150m
            memory: 10Mi
      # - name: dnsmasq-metrics
      #   image: gcr.io/google_containers/dnsmasq-metrics-amd64:1.0
      #   livenessProbe:
      #     httpGet:
      #       path: /metrics
      #       port: 10054
      #       scheme: HTTP
      #     initialDelaySeconds: 60
      #     timeoutSeconds: 5
      #     successThreshold: 1
      #     failureThreshold: 5
      #   args:
      #   - --v=2
      #   - --logtostderr
      #   ports:
      #   - containerPort: 10054
      #     name: metrics
      #     protocol: TCP
      #   resources:
      #     requests:
      #       memory: 10Mi
      - name: healthz
        image: hub.c.163.com/allan1991/exechealthz-amd64:1.2
        resources:
          limits:
            memory: 50Mi
          requests:
            cpu: 10m
            # Note that this container shouldn't really need 50Mi of memory. The
            # limits are set higher than expected pending investigation on #29688.
            # The extra memory was stolen from the kubedns container to keep the
            # net memory requested by the pod constant.
            memory: 50Mi
        args:
        - --cmd=nslookup kubernetes.default.svc.cluster.local 127.0.0.1 >/dev/null
        - --url=/healthz-dnsmasq
        - --cmd=nslookup kubernetes.default.svc.cluster.local 127.0.0.1:10053 >/dev/null
        - --url=/healthz-kubedns
        - --port=8080
        - --quiet
        ports:
        - containerPort: 8080
          protocol: TCP
      dnsPolicy: Default  # Don't use cluster DNS.

这里我把dnsmasq-metrics这个container注释掉了,因为我没把这个镜像下载下来...不过没了这个也没什么影响,这个也是后来才新增的,原来是没有的。

kubedns-svc.yaml:

# Copyright 2016 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This file should be kept in sync with cluster/images/hyperkube/dns-svc.yaml

# TODO - At some point, we need to rename all skydns-*.yaml.* files to kubedns-*.yaml.*

# Warning: This is a file generated from the base underscore template file: skydns-svc.yaml.base

apiVersion: v1
kind: Service
metadata:
  name: kube-dns
  namespace: kube-system
  labels:
    k8s-app: kube-dns
    kubernetes.io/cluster-service: "true"
    kubernetes.io/name: "KubeDNS"
spec:
  selector:
    k8s-app: kube-dns
  clusterIP: 169.169.0.2
  ports:
  - name: dns
    port: 53
    protocol: UDP
  - name: dns-tcp
    port: 53
    protocol: TCP

这里需要注意下clusterIP,你可以不设置,让系统自己去选择。如果显式的设置的话,指定的IP必须在APIServer的--service-cluster-ip-range参数指定的网段内。我们可以用以下命令去检查是否创建成功:

Master➜  ~ kubectl --namespace=kube-system get deployment
NAME                   DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
kube-dns               1         1         1            1           1d
kubernetes-dashboard   1         1         1            1           3d
Master➜  ~ kubectl --namespace=kube-system get pod
NAME                                    READY     STATUS    RESTARTS   AGE
kube-dns-2528035289-r7vrq               3/3       Running   0          19h
kubernetes-dashboard-1077846755-lj7sc   1/1       Running   0          19h
Master➜  ~ kubectl --namespace=kube-system get svc
NAME                   CLUSTER-IP       EXTERNAL-IP   PORT(S)         AGE
kube-dns               169.169.0.2      <none>        53/UDP,53/TCP   1d
kubernetes-dashboard   169.169.162.93   <nodes>       80:10977/TCP    3d

Service、Deployment都OK后,我们需要在每个Node的Kubelet配置中加入DNS的信息,主要是--cluster-dns--cluster-domain,比如我的配置如下:

KUBELET_ARGS="--api-servers=http://192.168.56.101:8080 --cluster-dns=169.169.0.2 --cluster-domain=cluster.local --hostname-override=192.168.56.101 --logtostderr=false --log-dir=/var/log/kubernetes --v=2 --pod_infra_container_image=hub.c.163.com/allan1991/pause-amd64:3.0"

这里我们使用的是默认的Domain cluster.local,当然你可以在kubedns-deployment.yaml将所有的cluster.local换为你想要的,比如time-track.cn

最后我们来验证一下,首先创建一个Pod,里面运行一个busybox容器,主要是为了使用里面的nslookup命令:

busybox.yaml

apiVersion: v1
kind: Pod
metadata:
  name: busybox
  namespace: default
spec:
  containers:
  - image: hub.c.163.com/library/busybox
    command:
      - sleep
      - "3600"
    imagePullPolicy: IfNotPresent
    name: busybox
  restartPolicy: Always

然后我们再创建一个服务,比如mysql吧:

mysql.yaml

apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  ports:
    - port: 3306
  selector:
    app: mysql

然后我们在先创建的busybox里面通过DNS查询后创建的mysql服务,看是否可以查到:

Master➜  ~ kubectl exec busybox -c busybox -- nslookup mysql
Server:    169.169.0.2
Address 1: 169.169.0.2 kube-dns.kube-system.svc.cluster.local

Name:      mysql
Address 1: 169.169.96.10 mysql.default.svc.cluster.local


Master➜  ~ kubectl describe svc mysql
Name:            mysql
Namespace:        default
Labels:            <none>
Selector:        app=mysql
Type:            ClusterIP
IP:            169.169.96.10
Port:            <unset>    3306/TCP
Endpoints:        <none>
Session Affinity:    None
No events.

可以看到,成功的查到了。这样通过DNS变做到了服务发现,而且没有先后顺序的限制了。

]]>