当前位置:   article > 正文

k8s篇-理解POD本质(实现原理与设计模式)_镜像,容器,pod原理

镜像,容器,pod原理

这一篇文章是我自己看了大神张磊《深入剖析Kubernetes》的内容后,加上了一点点自己理解,将最精华部分,按自己理解方式和语言习惯,整理成的一篇文章,这个也花了不少时间。

若想看原汁原味的,可以看《深入剖析Kubernetes》系列文章,最近貌似张磊大神也出书了,或者可以直接买本书看看呗!

一、为什么需要 Pod

1、容器的基本概念

容器的本质上是一个进程,是一个视图被隔离,资源受限的进程。容器里面 PID=1 的进程就是应用本身,管理容器等于直接管理应用本身。

放到Kubernetes中去理解就是,Kubernetes 是云时代的操作系统,容器镜像就是这个操作系统的软件安装包。

2、Pod设计(进程组概念)

在一个操作系统中,一个程序的运行通常是由多个进程的共同协助完成的,也就是说,一个程序通常是以进程组方式来管理的,进程组里面的多个进程之间,可以共享文件、信号、数据内存、甚至部分代码,从而紧密协作共同完成一个程序的职责。

比如有一个Helloworld程序,它实际上是由一组进程(Linux中的线程)组成。
通过pstree命令可看到,这组进程里面包含4个线程,分别是api、main、log、compute,也就是,这4个线程共享 Helloworld 程序的资源,共同协作,完成Helloworld程序工作。

在kubernetes中,容器就相当于是一个进程(Linux线程),Pod就是进程组(Linux线程组)

如何将 Helloworld 程序用容器跑起来?

因为PID=1进程就是应用本身,所以容器的生命周期 等同于 PID=1的进程的生命周期。

方法是启动一个Docker容器,里面运行四个进程。但问题是容器里面PID=1的进程该是谁,比如是main进程,那剩余的3个进程又如何管理。
 
这个核心问题在于,容器的设计本身是一种“单进程”模型,不是说容器里只能起一个进程,而是如果在容器里启动多个进程,只有一个可以作为PID=1的进程,容器只能去管理这个PID=1的进程,其他再起来的进程其实是一个托管状态,所以这就要求PID=1的进程本身具有“进程管理”的能力。比如拥有systemd能力,直接把容器里 PID=1 的进程直接改成systemd,否则这个容器是没有办法去管理多个进程的。

因为PID=1进程就是应用本身,一般情况下,容器应用进程并不具备进程管理能力,如果这个PID=1的进程在运行过程中挂掉或被kill掉了,那剩下三个进程的资源就没人回收,会成为孤儿进程。比如通过exec命令在容器里创建的其他进程,一旦异常退出就很容易变成孤儿进程。

PID=1的进程改成了systemd后的另一个问题是,使得管理容器,不再是管理应用本身了,而等于是管理 systemd。那么容器里面运行的应用本身是否异常,实际上就没办法直接知道了,因为容器管理的是systemd。

所以,在容器里面运行一个复杂程序往往是比较困难的。

而在 kubernetes 里面,它会把这四个独立的进程分别用四个独立的容器启动起来,然后把它们定义在一个 Pod 里面。当kubernetes将helloworld程序拉起来时,实际上会看到四个容器,它们共享了某些资源,这些资源都属于Pod。

而 Pod 在 Kubernetes 里是一个逻辑单位,在物理上是不存在的,物理上存在的就是这四个容器,或者说这四个容器的组合就叫做Pod。所以,Pod 是 Kubernetes 分配资源的一个单位,也是 Kubernetes 的原子调度单位

Pod设计,并不是 Kubernetes 项目自己想出来的,而是早在 Google 研发 Borg 的时候,就已经发现了这样一个问题,就是工程师在部署应用时,很多场景下都存在着类似于“进程与进程组”的关系。这些应用之前往往有着密切的协作关系,使得它们必须部署在同一台机器上并且共享某些信息。

为什么Pod必须是原子调度单位?

假如现在有两个容器,分别是App和LogCollector容器,它们是紧密协作的,所以它们应该被部署在一个Pod里面。

也就是,Pod = App(负责写日志文件) + LogCollector(负责把App容器写的日志文件转发到后端的ElasticSearch中)。

这两个容器的资源需求是:App容器需要1G内存,LogCollector需要0.5G内存,而当前集群环境的可用内存则是:node1有1.25G内存,node2有2G内存。

假设现在没有Pod概念,就只有两个容器,这两个容器要紧密协作、运行在一台机器上。如果调度器先把 App 调度到了node1上,这时会发现,LogCollector 实际上是没办法调度到 node1上的,因为资源不够。其实此时整个应用本身就已经出问题了,调度已经失败了,必须去重新调度。

这就是一个非常典型的成组调度失败的例子,叫做Task co-scheduling,这个问题不是说不能解,在很多项目里面,这样的问题都有解法。

比如Mesos项目,它会做一个事情,叫做资源囤积(resource hoarding):即当所有设置了 Affinity 约束的任务都达到时,才开始统一调度。也就是,App和LogCollector容器这两个容器,不会立刻调度,而是等两个容器都提交完成,才开始统一调度。这样也会带来新的问题,首先调度效率会损失,因为需要等待,而相互等待就容易产生死锁情况。这些机制在 Mesos 里都是需要解决的,也带来了额外的复杂度。

另一个是Google在Omega系统里做的一个复杂解法,叫做乐观调度。比如说:不管这些冲突的异常情况,先调度,同时设置一个非常精妙的回滚机制,这样经过冲突后,通过回滚来解决问题。这个方式相对来说要更加优雅,也更加高效,但是它的实现机制是非常复杂的。

针对Task co-scheduling 问题,在 Kubernetes 里,就直接通过 Pod 这样一个概念去解决了。因为在 Kubernetes 里, App和LogCollector这两个容器都是属于一个 Pod 的,它们在调度时必然是以一个 Pod 为单位进行调度,所以这个问题是根本不存在。


二、Pod 实现原理

Pod是由一个或多个容器组成的,同一个Pod里的多个容器之间是一种是超亲密关系,因为通常这些容器之间需要共享某些资源或数据,比如两个容器之间发生文件交换、通过localhost或socket进行本地通信等等。

因为容器之间原本是被 Linux Namespace 和 cgroups 隔开的,所以要打破这个隔离,如何让一个 Pod 里的多个容器之间最高效的共享某些资源和数据,是Pod设计要解决的核心问题所在。


这些容器之间要么共享网络资源,要么就是共享同一个存储资源,所以解决方法就分为网络和存储(Volume)两部分。

1、网络共享

第一个要解决的问题是:Pod里的多个容器怎么共享网络。

  1. ContainerA ContainerB
  2. | |
  3. '————> Infra Container <————'
  4. (pause)

比如Pod里有容器A和容器B,需要共享 Network Namespace。kubernetes做法是,会在每个 Pod 里额外创建一个 Infra container 小容器,然后其他容器再加入到 Infra container 的 Network Namespace 中,通过这种方式来实现多个容器共享整个 Pod 的 Network Namespace。

Infra container就相当于一个中间容器,其他容器再连接到这个中间容器上,实现网络共享。所以,Infra容器永远都是Pod里面第一个被创建的容器,等待其他容器的加入。Pod的生命周期只跟Infra容器一致,而与容器A和容器B无关。

Infra container 是一个非常小的镜像,叫做 k8s.gcr.io/pause,大概 100~200KB 左右,是一个汇编语言写的、永远处于“暂停”状态的容器。

因为Pod里面的所有容器借助Infra container共享同一个Network Namespace,所以,所有容器看到的网络信息都是一样的,也就是一个Pod只有一个IP。

2、存储共享

第二个要解决的问题是:Pod里的多个容器怎么共享存储。

Pod是通过Volume实现共享存储的。Pod先绑定好Volume,然后Pod里的容器只要声明挂载这个Volume,就可以共享这个Volume对应的宿主机目录,所有容器看到的这个Volume目录内容都是一样的。

  1. apiVersion: v1
  2. kind: Pod
  3. metadata:
  4. name: pod-demo
  5. spec:
  6. volumes: #Pod先绑定好Volume
  7. - name: shared-data
  8. hostPath:
  9. path: /data
  10. containers:
  11. - name: myweb
  12. image: nginx
  13. volumeMounts: #容器再挂载这个volume
  14. - name: shared-data
  15. mountPath: /usr/share/nginx/html
  16. - name: mylinux
  17. image: centos
  18. volumeMounts: #容器再挂载这个volume
  19. - name: shared-data
  20. mountPath: /mydata
  21. command: ["/bin/sh"]
  22. args: ["-c", "echo hello world > /mydata/index.html"]

三、容器设计模式

InitContainer:

从一个例子说起,比如要发布一个JAVA WEB应用。我们已经开发完一个Java Web应用,并打包成一个WAR文件了,下一步就是需要将war文件放到 Tomcat 服务器的 webapps 目录下,然后再启动服务将应用运行起来就可以了。那如果要通过docker方式运行起来,可以怎么处理呢?

有几种方式:

一种方法是,先下载好tomcat镜像,接着在tomcat镜像基础上,重新制作一个新镜像,制作时将war文件放进tomcat的webapps目录下就可以了。这时我们就可以用新镜像将WEB应用运行起来,因为镜像里已经包含了war文件,但缺点是,当要更新war文件内容或升级tomcat版本时,都要重新制作镜像,很麻烦。

另一种方法是,不用重新制作镜像,直接用tomcat镜像运行一个tomcat容器,而war文件放在宿主机上,然后通过Volume方式(hostPath类型),将这个war文件挂载进tomcat容器中webapps目录下运行起来。同样这种方式也存在缺点,就是需要预先在每一台宿主机上准备好这个war文件,这就相当于还需要另外独立维护一套分布式存储系统。


有了Pod之后,这样的问题就很容易解决了,我们可以把WAR文件和Tomcat分别做成镜像,然后把它们作为一个Pod里的两个容器运行起来。

tomcat呢,还是用tomcat镜像运行成一个普通的tomcat容器,而war文件做成的镜像,则是通过InitContainer类型容器运行起来的,这个容器有一点特殊,它不是一个普通容器。在Pod中,所有 Init Container 类型的容器,都会比spec.containers定义的用户容器先启动,且Init Container容器会按顺序逐一启动,并直到它们都启动且退出了,用户容器才会启动

在下面这个例子中,当WAR容器启动后,会将镜像中的WAR文件(sample.war) 拷贝到volume目录里(即宿主机上的目录/app),然后退出。等到Tomcat容器启动时,webapps目录下就已经存在sample.war文件了。

  1. apiVersion: v1
  2. kind: Pod
  3. metadata:
  4. name: javaweb-demo
  5. spec:
  6. initContainers:
  7. - image: sample:v1
  8. name: war
  9. command: ["cp", "/sample.war", "/app"]
  10. volumeMounts:
  11. - mountPath: /app
  12. name: app-volume
  13. containers:
  14. - image: tomcat:7.0
  15. name: tomcat
  16. command: ["sh","-c","/root/apache-tomcat-7.0.59/bin/start.sh"]
  17. volumeMounts:
  18. - mountPath: /root/apache-tomcat-7.0.59/webapps
  19. name: app-volume
  20. ports:
  21. - containerPort: 8080
  22. hostPort: 8001
  23. volumes:
  24. - name: app-volume
  25. emptyDir: {}

这种组合方式,正是容器设计模式里最常用的一种模式,叫sidecar,就是在一个Pod中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。

Sidecar:

Sidecar,也就是,在一个Pod中,定义一些专门的容器,来执行主容器所需要的一些辅助工作。比如这个Init Container,它就是一个Sidecar,它只负责把镜像里的WAR 包拷贝到Volume目录里,以便被Tomcat容器使用。

还有哪些操作可以用 init container 或另外sidecar方式去解决。比如容器里面的一些前置操作、日志收集、Debug 应用、查看其他容器的工作状态等。

sidecar方式好处是将辅助功能从主业务容器独立或解耦出来,做成一个辅助容器,这个容器也可以被重用。

例子1:应用与日志收集

主业务容器将日志写在volume里面,然后Sidecar容器通过共享该 Volume,把日志文件读出来并转发到远程存储中。

  1. POD
  2. Container (Web Server) SideCar Conatiner (Log Saving) ---> remote server
  3. \ /
  4. \_____________ Volume ________/

例子2:代理容器

Pod 需要访问一个外部服务集群。主业务容器直接访问 Proxy,然后由 Proxy 去连接外部的服务集群。因为Pod 里面多个容器是通过 localhost 直接通信的,并没有性能损耗。

  1. POD | _____ 外部服务1
  2. | /
  3. Container (Main Server) ----> SideCar Container (Proxy Server)——+——————— 外部服务2
  4. | \_____
  5. | 外部服务3

例子3:适配器(adapter)容器

业务容器需要向外提供一个API,且格式是A,但外部系统调用API去访问业务容器时,用的格式是B,Adapter作用就是负责做API格式转换,也就是将业务容器的API转换成另一种格式。同样,容器间是通过 localhost 直接通信的,并没有性能损耗。

  1. POD |
  2. |
  3. Container (Main Server) <---- SideCar Container (Monitoring Adapter) <-+----- 外部系统
  4. |

四、总结

容器的本质,即进程。

实际上,一个运行在虚拟机里的应用,哪怕再简单,也是一组进程,而不是一个进程。这跟本地物理机上应用的运行方式其实是一样的。这也是为什么,从物理机到虚拟机之间的应用迁移,往往并不困难。

容器是单进程模型,不是指容器里只能运行一个进程,而是指容器没有管理多个进程的能力。这是因为容器里PID为1的进程就是应用本身,其他的进程都是这个PID为1进程的子进程,所以容器里PID为1的应用进程,通常不像LINUX系统里的init或systemd那样,拥有进程管理的功能。

所以,对于容器来说,一个容器永远只能管理一个进程。更确切地说,一个容器就是一个进程,这是容器技术的“天性”,不可能被修改。

因此,将一个原本运行在虚拟机里的应用,“无缝迁移”到容器中的想法,实际上跟容器的本质是相悖的。这也是当初Swarm项目无法成长起来的重要原因之一,因为一旦到了真正的生产环境上,Swarm 这种单容器的工作方式,就难以描述真实世界里复杂的应用架构了。

所以,当要把一个运行在虚拟机里的应用迁移到Docker容器中时,一定要先分析一下,这个应用到底包含了哪些进程(组件)。然后,就可以把整个虚拟机想象成为一个 Pod,把这些进程分别做成容器镜像,把有顺序关系的容器,定义为Init Container。

这才是更加合理的、耦合的容器编排诀窍,也是从传统应用架构,到“微服务架构”最自然的过渡方式。

Pod这个概念,提供的是一种编排思想,而不是具体的技术方案。另外,我们也完全可以使用虚拟机来作为Pod的实现,然后把用户容器都运行在这个虚拟机里,比如Mirantis公司的virtlet项目。

相反的,如果强行把整个应用都塞到一个容器里,甚至不惜使用“Docker In Docker”这种在生产环境中后患无穷的解决方案,最后往往会得不偿失。

Pod 扮演的是传统部署环境里“虚拟机”的角色。这样的设计,是为了使用户从传统环境(虚拟机环境)向 Kubernetes(容器环境)的迁移,更加平滑。

所以“富容器”这种设计只是一种过渡形态,会培养出很多非常不好的运维习惯。更建议是将它们拆分成多个容器组成一个 Pod。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/菜鸟追梦旅行/article/detail/304946
推荐阅读
相关标签
  

闽ICP备14008679号