引言
前面我们已经介绍了 Kubernetes 的常用资源以及 Kubernetes 的实现原理,本文我们将介绍通过 Kubernetes 开发应用的最佳实践,更多关于 Kubernetes 的介绍均收录于<Kubernetes系列文章>中。
开发应用最佳实践
首先,我们看一看一个实际的应用都应该使用哪些 Kubernetes 资源。
一般应用 manifest 包含了一个或者多个 Deployment 和 StatefulSet 对象。这些对象中包含了一个或者多个容器的 pod 模板,每个容器都有一个存活探针,并且为容器提供的服务(如果有的话)提供就绪探针。提供服务的 pod 是通过一个或者多个服务来暴露自己的。当需要从集群外访问这些服务的时候,要么将这些服务配置为 LoadBalancer 或者 NodePort 类型的服务,要么通过 Ingress 资源来开放服务。
pod模板(从中创建 pod 的配置文件)通常会引用两种类型的私密凭据(Secret)。一种是从私有镜像仓库拉取镜像时使用的;另一种是 pod 中运行的进程直接使用的。私密凭据本身通常不是应用 manifest 的一部分,因为它们不是由应用开发者来配置,而是由运维团队来配置的。私密凭据通常会被分配给 ServiceAccount,然后 ServiceAccount 会被分配给每个单独的 pod。
一个应用还包含一个或者多个 ConfigMap 对象,可以用它们来初始化环境变量,或者在 pod 中以 configMap 卷来挂载。有一些 pod 会使用额外的卷,例如 emptyDir 或 qitRepo 卷,而需要持久化存储的 pod 则需要 persistentVolumeClaim 卷。PersistentVolumeClaim 也是一个应用 manifest 的一部分,而被 PersistentVolumeClaim 所引用的 StorageClass 则是由系统管理员事先创建的。
在某些情况下,一个应用还需要使用任务 Jobs 和定时任务 CronJobs。守护进程集(DaemonSet)通常不是应用部署的一部分,但是通常由系统管理员创建,以在全部或者部分节点上运行系统服务。水平 pod 扩容器(HorizontalPodAutoscaler)可以由开发者包含在应用 manifest 中或者后续由运维团队添加到系统中。集群管理员还会创建 LimitRange 和 ResourceQuota 对象,以控制每个 pod 和所有的 pod (作为一个整体)的计算资源使用情况。
注意 Pod 的生命周期
我们之前说过,可以将 pod 比作只运行单个应用的虚拟机。尽管在 pod 中运行的应用和虚拟机中运行的应用没什么不同,但是还是存在显著的差异。其中一个例子就是 pod 中运行的应用随时可能会被杀死,因为 Kubernetes 需要将这个 pod 调度到另外一个节点,或者是请求缩容。这就需要我们的应用能够做到如下几点:
- 预料到本地 IP 和主机名会发生变化
- 预料到写入磁盘的数据会消失
- 使用存储卷来跨容器持久化数据
- 对于 MYSQL 那类存储类应用,你可能还需要 StatefulSet 来保证 pod 重新调度后,仍与之前的存储卷绑定,进而不丢失数据
pod 中运行的应用和手动运行的应用之间的一个不同就是运维人员在手动部署应用的时候知道应用之间的依赖关系,这样他们就可以按照顺序来启动应用。当你使用 Kubernetes 来运行多个 pod 的应用的时候,Kubernetes 没有内置的方法来先运行某些 pod 然后等这些 pod 运行成功后再运行其他 pod。当然你也可以先发布第一个应用的配置,然后等待 pod 启动完毕再发布第二个应用的配置。
Kubernetes API 服务器确实是按照 YAML/JSON 文件中定义的对象的顺序来进行处理的,但是仅仅意味着它们在被写入到 etcd 的时候是有顺序的。无法确保 pod 会按照那个顺序启动。但是你可以阻止主容器的启动,直到它的预置条件被满足,这是通过在 pod 中包含一个叫 init 的容器来实现的。
一个 pod 可以拥有任意数量的 init 容器。init 容器是顺序执行的,并且仅当最后一个 init 容器执行完毕才会去启动主容器。换句话说,init 容器也可以用来延迟 pod 的主容器的启动。例如,直到满足某一个条件的时候。init 容器可以一直等待直到主容器所依赖的服务启动完成并可以提供服务。当这个服务启动并且可以提供服务之后,init 容器就执行结束了,然后主容器就可以启动了。这样主容器就不会发生在所依赖服务准备好之前使用它的情况了。
除此之外,不要忘了 Readiness 探针。如果一个应用在其中一个依赖缺失的情况下无法工作,那么它需要通过它的 Readiness 探针来通知这个情况,这样 Kubernetes 也会知道这个应用没有准备好。
我们已经讨论了如果使用 init 容器来介入pod的启动过程,另外 pod 还允许你定义两种类型的生命周期钩子:
启动后(Post-start)钩子
- 这个钩子和主进程是并行执行的,并不是完全启动结束后。
停止前(Pre-stop)钩子
- 停止前钩子是在容器被终止之前立即执行的。当一个容器需要终止运行的时候,Kubelet 在配置了停止前钩子的时候就会执行这个停止前钩子,并且仅在执行完钩子程序后才会向容器进程发送 SIGTERM 信号。
这些生命周期的钩子是基于每个容器来指定的,和 init 容器不同的是,init 容器是应用到整个 pod。这些钩子,如它们的名字所示,是在容器启动后和停止前执行的。生命周期钩子与存活探针和就绪探针相似的是它们都可以:
- 在容器内部执行一个命令
- 向一个URL发送 HTTP GET 请求
很多开发者在定义停止前钩子的时候会犯错误,他们在钩子中只向应用发送了 SIGTERM 信号。他们这样做是因为他们没有看到他们的应用接收到 Kubelet 发送的 SIGTERM 信号。应用没有接收到信号的原因并不是 Kubelet 没有发送信号,而是因为在容器内部信号没有被传递给应用的进程。如果你的容器镜像配置是通过执行一个 shell 进程,然后在shell进程内部执行应用进程,那么这个信号就被这个 shell 进程吞没了,这样就不会传递给子进程。
在这种情况下,合理的做法是让shell进程传递这个信号给应用进程,而不是添加一个停止前钩子来发送信号给应用进程。可以通过在作为主进程执行的 shell 进程内处理信号并把它传递给应用进程的方式来实现。或者如果你无法配置容器镜像执行 shell 进程,而是通过直接运行应用的二进制文件,可以通过在 DockerFile 中使用 ENTRYPOINT 或者 CMD 的 exec 方式来实现,即ENTRYPOINT ["/mybinary"]
而不是 ENTRYPOINT /mybinary
。在通过第一种方式运行二进制文件 mybinary 的容器中,这个进程就是容器的主进程,而在第二种方式中,是先运行一个shell作为主进程,然后 mybinary 进程作为shell进程的子进程运行。
当 Kubelet 意识到需要终止 pod 的时候, ]它开始终止 pod 中的每个容器。Kubelet 会给每个容器一定的时间来优雅地停止。这个时间叫作终止宽限期(Termination GracePeriod), 每个 pod 可以单独配置。在终止进程开始之后,计时器就开始计时,接着按照顺序执行以下事件:
- 执行停止前钩子(如果配置了的话),然后等待它执行完毕
- 向容器的主进程发送 SIGTERM 信号
- 等待容器优雅地关闭或者等待终止宽限期超时,默认 30 秒
- 如果容器主进程没有优雅地关闭,使用 SIGKILL 信号强制终止进程
应用应该通过启动关闭流程来响应 SIGTERM 信号,并且在流程结束后终止运行。除了处理 SIGTERM 信号,应用还可以通过停止前钩子来收到关闭通知。在这两种情况下,应用只有固定的时间来干净地终止运行。但是如果你无法预测应用需要多长时间来干净地终止运行,假设你的应用是一个分布式数据存储。在缩容的时候,其中一个 pod 的实例会被删除然后关闭。在这个关闭的过程中,这个 pod 需要将它的数据迁移到其他存活的 pod 上面以确保数据不会丢失。这时候,我推荐你创建一个专注于善后工作的 Job 资源,这个 Job 资源会运行一个新的 pod, 这个 pod 唯一的工作就是把被删除的 pod 的数据迁移到仍然存活的 pod。
但是你可能注意了,我们无法保证应用每次都能够成功创建这个 Job 对象。万一当应用要去创建Job的时候节点出现故障呢? 我们可以用一个专门的持续运行中的 pod 来持续检查是否存在孤立的数据。当这个 pod 发现孤立的数据的时候,它就可以把它们迁移到仍存活的 pod。当然不一定是一个持续运行的 pod, 也可以使用 CronJob 资源来周期性地运行这个 pod。
妥善处理客户端请求
毋庸赘言,你希望所有的客户端请求都能够得到妥善的处理。你显然不希望 pod 在启动或者关闭过程中出现断开连接的情况。Kubernetes 本身并没有避免这种事情的发生。你的应用需要遵循一些规则来避免遇到连接断开的情况。
首先我们要清楚,当一个 pod 启动的时候,它以服务端点的方式提供给所有的服务,这些服务的标签选择器和 pod 的标签匹配,我们前面说过 pod 需要发送新号给 Kubernetes 通知它自己准备好了之后,它才能变成一个服务端点,否则它不会接受到任何客户端连接。
你需要做的第一点就是当且仅当你的应用准备好处理进来的请求的时候,才去让就绪探针返回成功。
现在我们来看一下在 pod 生命周期的另一端,当 pod 被删除,如何妥善的处理客户端的连接。我们知道当要删除 Pod 时,会同时触发两条工作线,一条是关闭容器,一条是 kube-proxy 修改 iptables。
那么怎么才能让,修改 iptables 的工作先进行然后再删除 pod 容器呢?最简单有效的办法是在进行 pod 的关闭时,等待几秒钟再开始停止接受新的连接。你无法完美地解决这个问题,但是即使增加 5 秒或者 10 秒延迟也会极大提升用户体验,它能保证之后只有少量的连接会流到这个即将关闭的 pod,除此之外,我们还要关闭这个 pod 中不活跃的长连接,然后对于那些活跃的长连接,等处理完最后一个请求后,再开始关闭应用。
管理容器
为了让 Kubernetes 的容器更好管理,我们应该合理地给镜像打标签,使用多维度而不是单维度的标签,比如:
- 资源所属的应用(或者微服务) 的名称
- 应用层级(前端、后端,等等)
- 运行环境(开发、测试、预发布、生产,等等)
- 版本号
- 发布类型(稳定版、金丝雀、蓝绿开发中的绿色或者蓝色,等等)
- 分片(带分片的系统)
可以使用注解来给你的资源添加额外的信息。资源至少应该包括一个描述资源的注解和一个描述资源负责人的注解。在微服务框架中,pod 应该包含一个注解来描述该 pod 依赖的其他服务的名称。这样就很容易展现 pod 之间的依赖关系了。
在一个生产环境系统中,你希望使用一个集中式的面向集群的日志解决方案,所以你所有的日志都会被收集并且(永久地)存储在一个中心化的位置。这样你可以查看历史日志,分析趋势。你或许已经听说过由ElasticSearch、FluentD 和 Kibana 组成的 EFK 栈,它能很好地帮你处理各个 pod 中的日志并整合在一起。
当使用 EFK 作为集中式日志记录的时候,每个 Kubernetes 集群节点都会运行一个 FluentD 的代理(通过使用 DaemonSet 作为pod来部署),这个代理负责从容器搜集日志,给日志打上和 pod 相关的信息,然后把它们发送给 ElasticSearch, 然后由 ElasticSearch 来永久地存储它们。ElasticSearch 在集群中也是作为 pod 部署的。这些日志可以通过 Kibana 在Web浏览器中查看和分析,Kibana 是一个可视化 ElasticSearch 数据的工具。它经常也是作为 pod 来运行的,并且通过一个服务暴露出来。EFK的三个组件如下图所示。
FluentD 代理将日志文件的每一行当作一个条目存储在 ElasticSearch 数据存储中。这里就会有一个问题。当日志输出跨越多行的时候,例如Java 的异常堆栈,就会以不同条目存储在集中式的日志记录系统中。
为了解决这个问题,可以让应用日志输出 JSON 格式的内容而不是纯文本。这样的话,一个多行的日志输出就可以作为一个条目进行存储了,也可以在 Kbiana 中以一个条目的方式显示出来。但是这种做法会让通过 kubectl logs 命令查看日志变得不太人性化了。
为了解决这个问题,可以让输出到标准输出终端的日志仍然是用户可读的日志,但是写入日志文件供 FluentD 处理的日志是JSON格式。这就要求在节点级别合理地配置 FluentD 代理或者给每一个 pod 增加一个轻量级的日志记录容器。
文章说明
更多有价值的文章均收录于贝贝猫的文章目录
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
创作声明: 本文基于下列所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。
参考内容
[1] kubernetes GitHub 仓库
[2] Kubernetes 官方主页
[3] Kubernetes 官方 Demo
[4] 《Kubernetes in Action》
[5] 理解Kubernetes网络之Flannel网络
[6] Kubernetes Handbook
[7] iptables概念介绍及相关操作
[8] iptables超全详解
[9] 理解Docker容器网络之Linux Network Namespace
[10] A Guide to the Kubernetes Networking Model
[11] Kubernetes with Flannel — Understanding the Networking
[12] 四层、七层负载均衡的区别