标签 Kubernetes 下的文章

这也许是一个不太受欢迎的观点,但大多数主流公司最好不要再使用 k8s 了。

你知道那个古老的“以程序员技能写 Hello world ”笑话吗?—— 从一个新手程序员的 printf("hello, world\n") 语句开始,最后结束于高级软件架构工程师令人费解的 Java OOP 模式设计。使用 k8s 就有点像这样。

  • 新手系统管理员:

./binary

  • 有经验的系统管理员:

在 EC2 上的 ./binary

  • DevOp:

在 EC2 上自部署的 CI 管道运行 ./binary

  • 高级云编排工程师:

在 EC2 上通过 k8s 编排的自部署 CI 管道运行 ./binary

¯\\_(ツ)\_/¯

这不意味着 Kubernetes 或者任何这样的东西本身都是坏的,就像 Java 或者 OOP 设计本身并不是坏的一样,但是,在很多情况下,它们被严重地误用,就像在一个 hello world 的程序中可怕地误用 Java 面向对象设计模式一样。对大多数公司而言,系统运维从根本上来说并不十分复杂,此时在这上面应用 k8s 起效甚微。

复杂性本质上来说创造了工作,我十分怀疑使用 k8s 对大多数使用者来说是省时的这一说法。这就好像花一天时间来写一个脚本,用来自动完成一些你一个月进行一次,每次只花 10 分钟完成的工作。这不是一个好的时间投资(特别是你可能会在未来由于扩展或调试这个脚本而进一步投入的更多时间)。

你的部署大概应该需要自动化 – 以免你 最终像 Knightmare 那样 —— 但 k8s 通常可以被一个简单的 shell 脚本所替代。

在我们公司,系统运维团队用了很多时间来设置 k8s 。他们还不得不用了很大一部分时间来更新到新一点的版本(1.6 ➙ 1.8)。结果是如果没有真正深入理解 k8s ,有些东西就没人会真的明白,甚至连深入理解 k8s 这一点也很难(那些 YAML 文件,哦呦!)

在我能自己调试和修复部署问题之前 —— 现在这更难了,我理解基本概念,但在真正调试实际问题的时候,它们并不是那么有用。我不经常用 k8s 足以证明这点。


k8s 真的很难这点并不是什么新看法,这也是为什么现在会有这么多 “k8s 简单用”的解决方案。在 k8s 上再添一层来“让它更简单”的方法让我觉得,呃,不明智。复杂性并没有消失,你只是把它藏起来了。

以前我说过很多次:在确定一样东西是否“简单”时,我最关心的不是写东西的时候有多简单,而是当失败的时候调试起来有多容易。包装 k8s 并不会让调试更加简单,恰恰相反,它让事情更加困难了。


Blaise Pascal 有一句名言:

几乎所有的痛苦都来自于我们不善于在房间里独处。

k8s —— 略微拓展一下,Docker —— 似乎就是这样的例子。许多人似乎迷失在当下的兴奋中,觉得 “k8s 就是这么回事!”,就像有些人迷失在 Java OOP 刚出来时的兴奋中一样,所以一切都必须从“旧”方法转为“新”方法,即使“旧”方法依然可行。

有时候 IT 产业挺蠢的。

或者用 一条推特 来总结:

  • 2014 - 我们必须采用 #微服务 来解决独石应用的所有问题
  • 2016 - 我们必须采用 #docker 来解决微服务的所有问题
  • 2018 - 我们必须采用 #kubernetes 来解决 docker 的所有问题

你可以通过 [email protected] 给我发邮件或者 创建 GitHub issue 来给我反馈或提出问题等。


via: https://arp242.net/weblog/dont-need-k8s.html

作者:Martin Tournoij 选题:lujun9972 译者:beamrolling 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

零配置工具简化了信息收集,例如在某个命名空间中运行了多少个 pod。

最近我在纽约的 O'Reilly Velocity 就 Kubernetes 应用故障排除的主题发表了演讲,并且在积极的反馈和讨论的推动下,我决定重新审视这个领域的工具。结果,除了 kubernetes-incubator/spartakuskubernetes/kube-state-metrics 之外,我们还没有太多的轻量级工具来收集资源统计数据(例如命名空间中的 pod 或服务的数量)。所以,我在回家的路上开始编写一个小工具 —— 创造性地命名为 krs,它是 Kubernetes Resource Stats 的简称 ,它允许你收集这些统计数据。

你可以通过两种方式使用 mhausenblas/krs

  • 直接在命令行(有 Linux、Windows 和 MacOS 的二进制文件),以及
  • 在集群中使用 launch.sh 脚本部署,该脚本动态创建适当的基于角色的访问控制(RBAC) 权限。

提醒你,它还在早期,并且还在开发中。但是,krs 的 0.1 版本提供以下功能:

  • 在每个命名空间的基础上,它定期收集资源统计信息(支持 pod、部署和服务)。
  • 它以 OpenMetrics 格式公开这些统计。
  • 它可以直接通过二进制文件使用,也可以在包含所有依赖项的容器化设置中使用。

目前,你需要安装并配置 kubectl,因为 krs 依赖于执行 kubectl get all 命令来收集统计数据。(另一方面,谁会使用 Kubernetes 但没有安装 kubectl 呢?)

使用 krs 很简单。下载适合你平台的二进制文件,并按如下方式执行:

$ krs thenamespacetowatch
# HELP pods Number of pods in any state, for example running
# TYPE pods gauge
pods{namespace="thenamespacetowatch"} 13
# HELP deployments Number of deployments
# TYPE deployments gauge
deployments{namespace="thenamespacetowatch"} 6
# HELP services Number of services
# TYPE services gauge
services{namespace="thenamespacetowatch"} 4

这将在前台启动 krs,从名称空间 thenamespacetowatch 收集资源统计信息,并分别在标准输出中以 OpenMetrics 格式输出它们,以供你进一步处理。

 title=

krs 实战截屏

也许你会问,Michael,为什么它不能做一些有用的事(例如将指标存储在 S3 中)?因为 Unix 哲学

对于那些想知道他们是否可以直接使用 Prometheus 或 kubernetes/kube-state-metrics 来完成这项任务的人:是的,你可以,为什么不行呢? krs 的重点是作为已有工具的轻量级且易于使用的替代品 —— 甚至可能在某些方面略微互补。

本文最初发表在 Medium 的 ITNext 上,并获得授权转载。


via: https://opensource.com/article/18/11/kubernetes-resource-statistics

作者:Michael Hausenblas 选题:lujun9972 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

运行 Minikube 的分步指南。

Hello Minikube 教程页面上 Minikube 被宣传为基于 Docker 运行 Kubernetes 的一种简单方法。 虽然该文档非常有用,但它主要是为 MacOS 编写的。 你可以深入挖掘在 Windows 或某个 Linux 发行版上的使用说明,但它们不是很清楚。 许多文档都是针对 Debian / Ubuntu 用户的,比如安装 Minikube 的驱动程序

这篇指南旨在使得在基于 RHEL/Fedora/CentOS 的操作系统上更容易安装 Minikube。

先决条件

  1. 你已经安装了 Docker
  2. 你的计算机是一个基于 RHEL / CentOS / Fedora 的工作站。
  3. 你已经安装了正常运行的 KVM2 虚拟机管理程序
  4. 你有一个可以工作的 docker-machine-driver-kvm2。 以下命令将安装该驱动程序:
curl -Lo docker-machine-driver-kvm2 https://storage.googleapis.com/minikube/releases/latest/docker-machine-driver-kvm2 \
chmod +x docker-machine-driver-kvm2 \
&& sudo cp docker-machine-driver-kvm2 /usr/local/bin/ \
&& rm docker-machine-driver-kvm2

下载、安装和启动Minikube

1、为你要即将下载的两个文件创建一个目录,两个文件分别是:minikubekubectl

2、打开终端窗口并运行以下命令来安装 minikube。

curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64

请注意,minikube 版本(例如,minikube-linux-amd64)可能因计算机的规格而有所不同。

3、chmod 加执行权限。

chmod +x minikube

4、将文件移动到 /usr/local/bin 路径下,以便你能将其作为命令运行。

mv minikube /usr/local/bin

5、使用以下命令安装 kubectl(类似于 minikube 的安装过程)。

curl -Lo kubectl https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl

使用 curl 命令确定最新版本的Kubernetes。

6、chmodkubectl 加执行权限。

chmod +x kubectl

7、将 kubectl 移动到 /usr/local/bin 路径下作为命令运行。

mv kubectl /usr/local/bin

8、 运行 minikube start 命令。 为此,你需要有虚拟机管理程序。 我使用过 KVM2,你也可以使用 Virtualbox。 确保是以普通用户而不是 root 身份运行以下命令,以便为用户而不是 root 存储配置。

minikube start --vm-driver=kvm2

这可能需要一段时间,等一会。

9、 Minikube 应该下载并启动。 使用以下命令确保成功。

cat ~/.kube/config

10、 执行以下命令以运行 Minikube 作为上下文环境。 上下文环境决定了 kubectl 与哪个集群交互。 你可以在 ~/.kube/config 文件中查看所有可用的上下文环境。

kubectl config use-context minikube

11、再次查看 config 文件以检查 Minikube 是否存在上下文环境。

cat ~/.kube/config

12、最后,运行以下命令打开浏览器查看 Kubernetes 仪表板。

minikube dashboard

现在 Minikube 已启动并运行,请阅读通过 Minikube 在本地运行 Kubernetes 这篇官网教程开始使用它。


via: https://opensource.com/article/18/10/getting-started-minikube

作者:Bryant Son 选题:lujun9972 译者:Flowsnow 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

最近我一直在研究 Kubernetes 网络。我注意到一件事情就是,虽然关于如何设置 Kubernetes 网络的文章很多,也写得很不错,但是却没有看到关于如何去运维 Kubernetes 网络的文章、以及如何完全确保它不会给你造成生产事故。

在本文中,我将尽力让你相信三件事情(我觉得这些都很合理 :)):

  • 避免生产系统网络中断非常重要
  • 运维联网软件是很难的
  • 有关你的网络基础设施的重要变化值得深思熟虑,以及这种变化对可靠性的影响。虽然非常“牛x”的谷歌人常说“这是我们在谷歌正在用的”(谷歌工程师在 Kubernetes 上正做着很重大的工作!但是我认为重要的仍然是研究架构,并确保它对你的组织有意义)。

我肯定不是 Kubernetes 网络方面的专家,但是我在配置 Kubernetes 网络时遇到了一些问题,并且比以前更加了解 Kubernetes 网络了。

运维联网软件是很难的

在这里,我并不讨论有关运维物理网络的话题(对于它我不懂),而是讨论关于如何让像 DNS 服务、负载均衡以及代理这样的软件正常工作方面的内容。

我在一个负责很多网络基础设施的团队工作过一年时间,并且因此学到了一些运维网络基础设施的知识!(显然我还有很多的知识需要继续学习)在我们开始之前有三个整体看法:

  • 联网软件经常重度依赖 Linux 内核。因此除了正确配置软件之外,你还需要确保许多不同的系统控制(sysctl)配置正确,而一个错误配置的系统控制就很容易让你处于“一切都很好”和“到处都出问题”的差别中。
  • 联网需求会随时间而发生变化(比如,你的 DNS 查询或许比上一年多了五倍!或者你的 DNS 服务器突然开始返回 TCP 协议的 DNS 响应而不是 UDP 的,它们是完全不同的内核负载!)。这意味着之前正常工作的软件突然开始出现问题。
  • 修复一个生产网络的问题,你必须有足够的经验。(例如,看这篇 由 Sophie Haskins 写的关于 kube-dns 问题调试的文章)我在网络调试方面比以前进步多了,但那也是我花费了大量时间研究 Linux 网络知识之后的事了。

我距离成为一名网络运维专家还差得很远,但是我认为以下几点很重要:

  1. 对生产网络的基础设施做重要的更改是很难得的(因为它会产生巨大的混乱)
  2. 当你对网络基础设施做重大更改时,真的应该仔细考虑如果新网络基础设施失败该如何处理
  3. 是否有很多人都能理解你的网络配置

切换到 Kubernetes 显然是个非常大的更改!因此,我们来讨论一下可能会导致错误的地方!

Kubernetes 网络组件

在本文中我们将要讨论的 Kubernetes 网络组件有:

  • 覆盖网络 overlay network 的后端(像 flannel/calico/weave 网络/romana)
  • kube-dns
  • kube-proxy
  • 入站控制器 / 负载均衡器
  • kubelet

如果你打算配置 HTTP 服务,或许这些你都会用到。这些组件中的大部分我都不会用到,但是我尽可能去理解它们,因此,本文将涉及它们有关的内容。

最简化的方式:为所有容器使用宿主机网络

让我们从你能做到的最简单的东西开始。这并不能让你在 Kubernetes 中运行 HTTP 服务。我认为它是非常安全的,因为在这里面可以让你动的东西很少。

如果你为所有容器使用宿主机网络,我认为需要你去做的全部事情仅有:

  1. 配置 kubelet,以便于容器内部正确配置 DNS
  2. 没了,就这些!

如果你为每个 pod 直接使用宿主机网络,那就不需要 kube-dns 或者 kube-proxy 了。你都不需要一个作为基础的覆盖网络。

这种配置方式中,你的 pod 们都可以连接到外部网络(同样的方式,你的宿主机上的任何进程都可以与外部网络对话),但外部网络不能连接到你的 pod 们。

这并不是最重要的(我认为大多数人想在 Kubernetes 中运行 HTTP 服务并与这些服务进行真实的通讯),但我认为有趣的是,从某种程度上来说,网络的复杂性并不是绝对需要的,并且有时候你不用这么复杂的网络就可以实现你的需要。如果可以的话,尽可能地避免让网络过于复杂。

运维一个覆盖网络

我们将要讨论的第一个网络组件是有关覆盖网络的。Kubernetes 假设每个 pod 都有一个 IP 地址,这样你就可以与那个 pod 中的服务进行通讯了。我在说到“覆盖网络”这个词时,指的就是这个意思(“让你通过它的 IP 地址指向到 pod 的系统)。

所有其它的 Kubernetes 网络的东西都依赖正确工作的覆盖网络。更多关于它的内容,你可以读 这里的 kubernetes 网络模型

Kelsey Hightower 在 kubernetes 艰难之路 中描述的方式看起来似乎很好,但是,事实上它的作法在超过 50 个节点的 AWS 上是行不通的,因此,我不打算讨论它了。

有许多覆盖网络后端(calico、flannel、weaveworks、romana)并且规划非常混乱。就我的观点来看,我认为一个覆盖网络有 2 个职责:

  1. 确保你的 pod 能够发送网络请求到外部的集群
  2. 保持一个到子网络的稳定的节点映射,并且保持集群中每个节点都可以使用那个映射得以更新。当添加和删除节点时,能够做出正确的反应。

Okay! 因此!你的覆盖网络可能会出现的问题是什么呢?

  • 覆盖网络负责设置 iptables 规则(最基本的是 iptables -A -t nat POSTROUTING -s $SUBNET -j MASQUERADE),以确保那个容器能够向 Kubernetes 之外发出网络请求。如果在这个规则上有错误,你的容器就不能连接到外部网络。这并不很难(它只是几条 iptables 规则而已),但是它非常重要。我发起了一个 拉取请求,因为我想确保它有很好的弹性。
  • 添加或者删除节点时可能会有错误。我们使用 flannel hostgw 后端,我们开始使用它的时候,节点删除功能 尚未开始工作
  • 你的覆盖网络或许依赖一个分布式数据库(etcd)。如果那个数据库发生什么问题,这将导致覆盖网络发生问题。例如,https://github.com/coreos/flannel/issues/610 上说,如果在你的 flannel etcd 集群上丢失了数据,最后的结果将是在容器中网络连接会丢失。(现在这个问题已经被修复了)
  • 你升级 Docker 以及其它东西导致的崩溃
  • 还有更多的其它的可能性!

我在这里主要讨论的是过去发生在 Flannel 中的问题,但是我并不是要承诺不去使用 Flannel —— 事实上我很喜欢 Flannel,因为我觉得它很简单(比如,类似 vxlan 在后端这一块的部分 只有 500 行代码),对我来说,通过代码来找出问题的根源成为了可能。并且很显然,它在不断地改进。他们在审查拉取请求方面做的很好。

到目前为止,我运维覆盖网络的方法是:

  • 学习它的工作原理的详细内容以及如何去调试它(比如,Flannel 用于创建路由的 hostgw 网络后端,因此,你只需要使用 sudo ip route list 命令去查看它是否正确即可)
  • 如果需要的话,维护一个内部构建版本,这样打补丁比较容易
  • 有问题时,向上游贡献补丁

我认为去遍历所有已合并的拉取请求以及过去已修复的 bug 清单真的是非常有帮助的 —— 这需要花费一些时间,但这是得到一个其它人遇到的各种问题的清单的好方法。

对其他人来说,他们的覆盖网络可能工作的很好,但是我并不能从中得到任何经验,并且我也曾听说过其他人报告类似的问题。如果你有一个类似配置的覆盖网络:a) 在 AWS 上并且 b) 在多于 50-100 节点上运行,我想知道你运维这样的一个网络有多大的把握。

运维 kube-proxy 和 kube-dns?

现在,我有一些关于运维覆盖网络的想法,我们来讨论一下。

这个标题的最后面有一个问号,那是因为我并没有真的去运维过。在这里我还有更多的问题要问答。

这里的 Kubernetes 服务是如何工作的!一个服务是一群 pod 们,它们中的每个都有自己的 IP 地址(像 10.1.0.3、10.2.3.5、10.3.5.6 这样)

  1. 每个 Kubernetes 服务有一个 IP 地址(像 10.23.1.2 这样)
  2. kube-dns 去解析 Kubernetes 服务 DNS 名字为 IP 地址(因此,my-svc.my-namespace.svc.cluster.local 可能映射到 10.23.1.2 上)
  3. kube-proxy 配置 iptables 规则是为了在它们之间随机进行均衡负载。Kube-proxy 也有一个用户空间的轮询负载均衡器,但是在我的印象中,他们并不推荐使用它。

因此,当你发出一个请求到 my-svc.my-namespace.svc.cluster.local 时,它将解析为 10.23.1.2,然后,在你本地主机上的 iptables 规则(由 kube-proxy 生成)将随机重定向到 10.1.0.3 或者 10.2.3.5 或者 10.3.5.6 中的一个上。

在这个过程中我能想像出的可能出问题的地方:

  • kube-dns 配置错误
  • kube-proxy 挂了,以致于你的 iptables 规则没有得以更新
  • 维护大量的 iptables 规则相关的一些问题

我们来讨论一下 iptables 规则,因为创建大量的 iptables 规则是我以前从没有听过的事情!

kube-proxy 像如下这样为每个目标主机创建一个 iptables 规则:这些规则来自 这里

-A KUBE-SVC-LI77LBOOMGYET5US -m comment --comment "default/showreadiness:showreadiness" -m statistic --mode random --probability 0.20000000019 -j KUBE-SEP-E4QKA7SLJRFZZ2DD[b][c]  
-A KUBE-SVC-LI77LBOOMGYET5US -m comment --comment "default/showreadiness:showreadiness" -m statistic --mode random --probability 0.25000000000 -j KUBE-SEP-LZ7EGMG4DRXMY26H  
-A KUBE-SVC-LI77LBOOMGYET5US -m comment --comment "default/showreadiness:showreadiness" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-RKIFTWKKG3OHTTMI  
-A KUBE-SVC-LI77LBOOMGYET5US -m comment --comment "default/showreadiness:showreadiness" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-CGDKBCNM24SZWCMS 
-A KUBE-SVC-LI77LBOOMGYET5US -m comment --comment "default/showreadiness:showreadiness" -j KUBE-SEP-RI4SRNQQXWSTGE2Y 

因此,kube-proxy 创建了许多 iptables 规则。它们都是什么意思?它对我的网络有什么样的影响?这里有一个来自华为的非常好的演讲,它叫做 支持 50,000 个服务的可伸缩 Kubernetes,它说如果在你的 Kubernetes 集群中有 5,000 服务,增加一个新规则,将需要 11 分钟。如果这种事情发生在真实的集群中,我认为这将是一件非常糟糕的事情。

在我的集群中肯定不会有 5,000 个服务,但是 5,000 并不是那么大的一个数字。为解决这个问题,他们给出的解决方案是 kube-proxy 用 IPVS 来替换这个 iptables 后端,IPVS 是存在于 Linux 内核中的一个负载均衡器。

看起来,像 kube-proxy 正趋向于使用各种基于 Linux 内核的负载均衡器。我认为这只是一定程度上是这样,因为他们支持 UDP 负载均衡,而其它类型的负载均衡器(像 HAProxy)并不支持 UDP 负载均衡。

但是,我觉得使用 HAProxy 更舒服!它能够用于去替换 kube-proxy!我用谷歌搜索了一下,然后发现了这个 thread on kubernetes-sig-network,它说:

kube-proxy 是很难用的,我们在生产系统中使用它近一年了,它在大部分的时间都表现的很好,但是,随着我们集群中的服务越来越多,我们发现它的排错和维护工作越来越难。在我们的团队中没有 iptables 方面的专家,我们只有 HAProxy & LVS 方面的专家,由于我们已经使用它们好几年了,因此我们决定使用一个中心化的 HAProxy 去替换分布式的代理。我觉得这可能会对在 Kubernetes 中使用 HAProxy 的其他人有用,因此,我们更新了这个项目,并将它开源:https://github.com/AdoHe/kube2haproxy。如果你发现它有用,你可以去看一看、试一试。

因此,那是一个有趣的选择!我在这里确实没有答案,但是,有一些想法:

  • 负载均衡器是很复杂的
  • DNS 也很复杂
  • 如果你有运维某种类型的负载均衡器(比如 HAProxy)的经验,与其使用一个全新的负载均衡器(比如 kube-proxy),还不如做一些额外的工作去使用你熟悉的那个来替换,或许更有意义。
  • 我一直在考虑,我们希望在什么地方能够完全使用 kube-proxy 或者 kube-dns —— 我认为,最好是只在 Envoy 上投入,并且在负载均衡&服务发现上完全依赖 Envoy 来做。因此,你只需要将 Envoy 运维好就可以了。

正如你所看到的,我在关于如何运维 Kubernetes 中的内部代理方面的思路还是很混乱的,并且我也没有使用它们的太多经验。总体上来说,kube-proxy 和 kube-dns 还是很好的,也能够很好地工作,但是我仍然认为应该去考虑使用它们可能产生的一些问题(例如,”你不能有超出 5000 的 Kubernetes 服务“)。

入口

如果你正在运行着一个 Kubernetes 集群,那么到目前为止,很有可能的是,你事实上需要 HTTP 请求去进入到你的集群中。这篇博客已经太长了,并且关于入口我知道的也不多,因此,我们将不讨论关于入口的内容。

有用的链接

几个有用的链接,总结如下:

我认为网络运维很重要

我对 Kubernetes 的所有这些联网软件的感觉是,它们都仍然是非常新的,并且我并不能确定我们(作为一个社区)真的知道如何去把它们运维好。这让我作为一个操作者感到很焦虑,因为我真的想让我的网络运行的很好!:) 而且我觉得作为一个组织,运行你自己的 Kubernetes 集群需要相当大的投入,以确保你理解所有的代码片段,这样当它们出现问题时你可以去修复它们。这不是一件坏事,它只是一个事而已。

我现在的计划是,继续不断地学习关于它们都是如何工作的,以尽可能多地减少对我动过的那些部分的担忧。

一如继往,我希望这篇文章对你有帮助,并且如果我在这篇文章中有任何的错误,我非常喜欢你告诉我。


via: https://jvns.ca/blog/2017/10/10/operating-a-kubernetes-network/

作者:Julia Evans 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

简介

伙计们,请搬好小板凳坐好,下面将是一段漫长的旅程,期望你能够乐在其中。

我将基于 Kubernetes 部署一个分布式应用。我曾试图编写一个尽可能真实的应用,但由于时间和精力有限,最终砍掉了很多细节。

我将聚焦 Kubernetes 及其部署。

让我们开始吧。

应用

TL;DR

该应用本身由 6 个组件构成。代码可以从如下链接中找到:Kubenetes 集群示例

这是一个人脸识别服务,通过比较已知个人的图片,识别给定图片对应的个人。前端页面用表格形式简要的展示图片及对应的个人。具体而言,向 接收器 发送请求,请求包含指向一个图片的链接。图片可以位于任何位置。接受器将图片地址存储到数据库 (MySQL) 中,然后向队列发送处理请求,请求中包含已保存图片的 ID。这里我们使用 NSQ 建立队列。

图片处理 服务一直监听处理请求队列,从中获取任务。处理过程包括如下几步:获取图片 ID,读取图片,通过 gRPC 将图片路径发送至 Python 编写的 人脸识别 后端。如果识别成功,后端给出图片对应个人的名字。图片处理器进而根据个人 ID 更新图片记录,将其标记为处理成功。如果识别不成功,图片被标记为待解决。如果图片识别过程中出现错误,图片被标记为失败。

标记为失败的图片可以通过计划任务等方式进行重试。

那么具体是如何工作的呢?我们深入探索一下。

接收器

接收器服务是整个流程的起点,通过如下形式的 API 接收请求:

curl -d '{"path":"/unknown_images/unknown0001.jpg"}' http://127.0.0.1:8000/image/post

此时,接收器将 路径 path 存储到共享数据库集群中,该实体存储后将从数据库服务收到对应的 ID。本应用采用“ 实体对象 Entity Object 的唯一标识由持久层提供”的模型。获得实体 ID 后,接收器向 NSQ 发送消息,至此接收器的工作完成。

图片处理器

从这里开始变得有趣起来。图片处理器首次运行时会创建两个 Go 协程 routine ,具体为:

Consume

这是一个 NSQ 消费者,需要完成三项必需的任务。首先,监听队列中的消息。其次,当有新消息到达时,将对应的 ID 追加到一个线程安全的 ID 片段中,以供第二个协程处理。最后,告知第二个协程处理新任务,方法为 sync.Condition

ProcessImages

该协程会处理指定 ID 片段,直到对应片段全部处理完成。当处理完一个片段后,该协程并不是在一个通道上睡眠等待,而是进入悬挂状态。对每个 ID,按如下步骤顺序处理:

  • 与人脸识别服务建立 gRPC 连接,其中人脸识别服务会在人脸识别部分进行介绍
  • 从数据库获取图片对应的实体
  • 断路器 准备两个函数

    • 函数 1: 用于 RPC 方法调用的主函数
    • 函数 2: 基于 ping 的断路器健康检查
  • 调用函数 1 将图片路径发送至人脸识别服务,其中路径应该是人脸识别服务可以访问的,最好是共享的,例如 NFS
  • 如果调用失败,将图片实体状态更新为 FAILEDPROCESSING
  • 如果调用成功,返回值是一个图片的名字,对应数据库中的一个个人。通过联合 SQL 查询,获取对应个人的 ID
  • 将数据库中的图片实体状态更新为 PROCESSED,更新图片被识别成的个人的 ID

这个服务可以复制多份同时运行。

断路器

即使对于一个复制资源几乎没有开销的系统,也会有意外的情况发生,例如网络故障或任何两个服务之间的通信存在问题等。我在 gRPC 调用中实现了一个简单的断路器,这十分有趣。

下面给出工作原理:

当出现 5 次不成功的服务调用时,断路器启动并阻断后续的调用请求。经过指定的时间后,它对服务进行健康检查并判断是否恢复。如果问题依然存在,等待时间会进一步增大。如果已经恢复,断路器停止对服务调用的阻断,允许请求流量通过。

前端

前端只包含一个极其简单的表格视图,通过 Go 自身的 html/模板显示一系列图片。

人脸识别

人脸识别是整个识别的关键点。仅因为追求灵活性,我将这个服务设计为基于 gRPC 的服务。最初我使用 Go 编写,但后续发现基于 Python 的实现更加适合。事实上,不算 gRPC 部分的代码,人脸识别部分仅有 7 行代码。我使用的人脸识别库极为出色,它包含 OpenCV 的全部 C 绑定。维护 API 标准意味着只要标准本身不变,实现可以任意改变。

注意:我曾经试图使用 GoCV,这是一个极好的 Go 库,但欠缺所需的 C 绑定。推荐马上了解一下这个库,它会让你大吃一惊,例如编写若干行代码即可实现实时摄像处理。

这个 Python 库的工作方式本质上很简单。准备一些你认识的人的图片,把信息记录下来。对于我而言,我有一个图片文件夹,包含若干图片,名称分别为 hannibal_1.jpghannibal_2.jpggergely_1.jpgjohn_doe.jpg。在数据库中,我使用两个表记录信息,分别为 personperson_images,具体如下:

+----+----------+
| id | name     |
+----+----------+
|  1 | Gergely  |
|  2 | John Doe |
|  3 | Hannibal |
+----+----------+
+----+----------------+-----------+
| id | image_name     | person_id |
+----+----------------+-----------+
|  1 | hannibal_1.jpg |         3 |
|  2 | hannibal_2.jpg |         3 |
+----+----------------+-----------+

人脸识别库识别出未知图片后,返回图片的名字。我们接着使用类似下面的联合查询找到对应的个人。

select person.name, person.id from person inner join person_images as pi on person.id = pi.person_id where image_name = 'hannibal_2.jpg';

gRPC 调用返回的个人 ID 用于更新图片的 person 列。

NSQ

NSQ 是 Go 编写的小规模队列,可扩展且占用系统内存较少。NSQ 包含一个查询服务,用于消费者接收消息;包含一个守护进程,用于发送消息。

在 NSQ 的设计理念中,消息发送程序应该与守护进程在同一台主机上,故发送程序仅需发送至 localhost。但守护进程与查询服务相连接,这使其构成了全局队列。

这意味着有多少 NSQ 守护进程就有多少对应的发送程序。但由于其资源消耗极小,不会影响主程序的资源使用。

配置

为了尽可能增加灵活性以及使用 Kubernetes 的 ConfigSet 特性,我在开发过程中使用 .env 文件记录配置信息,例如数据库服务的地址以及 NSQ 的查询地址。在生产环境或 Kubernetes 环境中,我将使用环境变量属性配置。

应用小结

这就是待部署应用的全部架构信息。应用的各个组件都是可变更的,他们之间仅通过数据库、消息队列和 gRPC 进行耦合。考虑到更新机制的原理,这是部署分布式应用所必须的;在部署部分我会继续分析。

使用 Kubernetes 部署应用

基础知识

Kubernetes 是什么?

这里我会提到一些基础知识,但不会深入细节,细节可以用一本书的篇幅描述,例如 Kubernetes 构建与运行。另外,如果你愿意挑战自己,可以查看官方文档:Kubernetes 文档

Kubernetes 是容器化服务及应用的管理器。它易于扩展,可以管理大量容器;更重要的是,可以通过基于 yaml 的模板文件高度灵活地进行配置。人们经常把 Kubernetes 比作 Docker Swarm,但 Kubernetes 的功能不仅仅如此。例如,Kubernetes 不关心底层容器实现,你可以使用 LXC 与 Kubernetes 的组合,效果与使用 Docker 一样好。Kubernetes 在管理容器的基础上,可以管理已部署的服务或应用集群。如何操作呢?让我们概览一下用于构成 Kubernetes 的模块。

在 Kubernetes 中,你给出期望的应用状态,Kubernetes 会尽其所能达到对应的状态。状态可以是已部署、已暂停,有 2 个副本等,以此类推。

Kubernetes 使用标签和注释标记组件,包括服务、部署、副本组、守护进程组等在内的全部组件都被标记。考虑如下场景,为了识别 pod 与应用的对应关系,使用 app: myapp 标签。假设应用已部署 2 个容器,如果你移除其中一个容器的 app 标签,Kubernetes 只能识别到一个容器(隶属于应用),进而启动一个新的具有 myapp 标签的实例。

Kubernetes 集群

要使用 Kubernetes,需要先搭建一个 Kubernetes 集群。搭建 Kubernetes 集群可能是一个痛苦的经历,但所幸有工具可以帮助我们。Minikube 为我们在本地搭建一个单节点集群。AWS 的一个 beta 服务工作方式类似于 Kubernetes 集群,你只需请求节点并定义你的部署即可。Kubernetes 集群组件的文档如下:Kubernetes 集群组件

节点

节点 node 是工作单位,形式可以是虚拟机、物理机,也可以是各种类型的云主机。

Pod

Pod 是本地容器逻辑上组成的集合,即一个 Pod 中可能包含若干个容器。Pod 创建后具有自己的 DNS 和虚拟 IP,这样 Kubernetes 可以对到达流量进行负载均衡。你几乎不需要直接和容器打交道;即使是调试的时候,例如查看日志,你通常调用 kubectl logs deployment/your-app -f 查看部署日志,而不是使用 -c container_name 查看具体某个容器的日志。-f 参数表示从日志尾部进行流式输出。

部署

在 Kubernetes 中创建任何类型的资源时,后台使用一个 部署 deployment 组件,它指定了资源的期望状态。使用部署对象,你可以将 Pod 或服务变更为另外的状态,也可以更新应用或上线新版本应用。你一般不会直接操作副本组 (后续会描述),而是通过部署对象创建并管理。

服务

默认情况下,Pod 会获取一个 IP 地址。但考虑到 Pod 是 Kubernetes 中的易失性组件,我们需要更加持久的组件。不论是队列,MySQL、内部 API 或前端,都需要长期运行并使用保持不变的 IP 或更好的 DNS 记录。

为解决这个问题,Kubernetes 提供了 服务 service 组件,可以定义访问模式,支持的模式包括负载均衡、简单 IP 或内部 DNS。

Kubernetes 如何获知服务运行正常呢?你可以配置健康性检查和可用性检查。健康性检查是指检查容器是否处于运行状态,但容器处于运行状态并不意味着服务运行正常。对此,你应该使用可用性检查,即请求应用的一个特别 接口 endpoint

由于服务非常重要,推荐你找时间阅读以下文档:服务。严肃的说,需要阅读的东西很多,有 24 页 A4 纸的篇幅,涉及网络、服务及自动发现。这也有助于你决定是否真的打算在生产环境中使用 Kubernetes。

DNS / 服务发现

在 Kubernetes 集群中创建服务后,该服务会从名为 kube-proxykube-dns 的特殊 Kubernetes 部署中获取一个 DNS 记录。它们两个用于提供集群内的服务发现。如果你有一个正在运行的 MySQL 服务并配置 clusterIP: no,那么集群内部任何人都可以通过 mysql.default.svc.cluster.local 访问该服务,其中:

  • mysql – 服务的名称
  • default – 命名空间的名称
  • svc – 对应服务分类
  • cluster.local – 本地集群的域名

可以使用自定义设置更改本地集群的域名。如果想让服务可以从集群外访问,需要使用 DNS 服务,并使用例如 Nginx 将 IP 地址绑定至记录。服务对应的对外 IP 地址可以使用如下命令查询:

  • 节点端口方式 – kubectl get -o jsonpath="{.spec.ports[0].nodePort}" services mysql
  • 负载均衡方式 – kubectl get -o jsonpath="{.spec.ports[0].LoadBalancer}" services mysql

模板文件

类似 Docker Compose、TerraForm 或其它的服务管理工具,Kubernetes 也提供了基础设施描述模板。这意味着,你几乎不用手动操作。

以 Nginx 部署为例,查看下面的 yaml 模板:

apiVersion: apps/v1
kind: Deployment #(1)
metadata: #(2)
  name: nginx-deployment
  labels: #(3)
    app: nginx
spec: #(4)
  replicas: 3 #(5)
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers: #(6)
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

在这个示例部署中,我们做了如下操作:

  • (1) 使用 kind 关键字定义模板类型
  • (2) 使用 metadata 关键字,增加该部署的识别信息
  • (3) 使用 labels 标记每个需要创建的资源
  • (4) 然后使用 spec 关键字描述所需的状态
  • (5) nginx 应用需要 3 个副本
  • (6) Pod 中容器的模板定义部分
  • 容器名称为 nginx
  • 容器模板为 nginx:1.7.9 (本例使用 Docker 镜像)

副本组

副本组 ReplicaSet 是一个底层的副本管理器,用于保证运行正确数目的应用副本。相比而言,部署是更高层级的操作,应该用于管理副本组。除非你遇到特殊的情况,需要控制副本的特性,否则你几乎不需要直接操作副本组。

守护进程组

上面提到 Kubernetes 始终使用标签,还有印象吗? 守护进程组 DaemonSet 是一个控制器,用于确保守护进程化的应用一直运行在具有特定标签的节点中。

例如,你将所有节点增加 loggermission_critical 的标签,以便运行日志 / 审计服务的守护进程。接着,你创建一个守护进程组并使用 loggermission_critical 节点选择器。Kubernetes 会查找具有该标签的节点,确保守护进程的实例一直运行在这些节点中。因而,节点中运行的所有进程都可以在节点内访问对应的守护进程。

以我的应用为例,NSQ 守护进程可以用守护进程组实现。具体而言,将对应节点增加 recevier 标签,创建一个守护进程组并配置 receiver 应用选择器,这样这些节点上就会一直运行接收者组件。

守护进程组具有副本组的全部优势,可扩展且由 Kubernetes 管理,意味着 Kubernetes 管理其全生命周期的事件,确保持续运行,即使出现故障,也会立即替换。

扩展

在 Kubernetes 中,扩展是稀松平常的事情。副本组负责 Pod 运行的实例数目。就像你在 nginx 部署那个示例中看到的那样,对应设置项 replicas:3。我们可以按应用所需,让 Kubernetes 运行多份应用副本。

当然,设置项有很多。你可以指定让多个副本运行在不同的节点上,也可以指定各种不同的应用启动等待时间。想要在这方面了解更多,可以阅读 水平扩展Kubernetes 中的交互式扩展;当然 副本组 的细节对你也有帮助,毕竟 Kubernetes 中的扩展功能都来自于该模块。

Kubernetes 部分小结

Kubernetes 是容器编排的便捷工具,工作单元为 Pod,具有分层架构。最顶层是部署,用于操作其它资源,具有高度可配置性。对于你的每个命令调用,Kubernetes 提供了对应的 API,故理论上你可以编写自己的代码,向 Kubernetes API 发送数据,得到与 kubectl 命令同样的效果。

截至目前,Kubernetes 原生支持所有主流云服务供应商,而且完全开源。如果你愿意,可以贡献代码;如果你希望对工作原理有深入了解,可以查阅代码:GitHub 上的 Kubernetes 项目

Minikube

接下来我会使用 Minikube 这款本地 Kubernetes 集群模拟器。它并不擅长模拟多节点集群,但可以很容易地给你提供本地学习环境,让你开始探索,这很棒。Minikube 基于可高度调优的虚拟机,由 VirtualBox 类似的虚拟化工具提供。

我用到的全部 Kubernetes 模板文件可以在这里找到:Kubernetes 文件

注意:在你后续测试可扩展性时,会发现副本一直处于 Pending 状态,这是因为 minikube 集群中只有一个节点,不应该允许多副本运行在同一个节点上,否则明显只是耗尽了可用资源。使用如下命令可以查看可用资源:

kubectl get nodes -o yaml

构建容器

Kubernetes 支持大多数现有的容器技术。我这里使用 Docker。每一个构建的服务容器,对应代码库中的一个 Dockerfile 文件。我推荐你仔细阅读它们,其中大多数都比较简单。对于 Go 服务,我采用了最近引入的多步构建的方式。Go 服务基于 Alpine Linux 镜像创建。人脸识别程序使用 Python、NSQ 和 MySQL 使用对应的容器。

上下文

Kubernetes 使用命名空间。如果你不额外指定命名空间,Kubernetes 会使用 default 命名空间。为避免污染默认命名空间,我会一直指定命名空间,具体操作如下:

❯ kubectl config set-context kube-face-cluster --namespace=face
Context "kube-face-cluster" created.

创建上下文之后,应马上启用:

❯ kubectl config use-context kube-face-cluster
Switched to context "kube-face-cluster".

此后,所有 kubectl 命令都会使用 face 命名空间。

(LCTT 译注:作者后续并没有使用 face 命名空间,模板文件中的命名空间仍为 default,可能 face 命名空间用于开发环境。如果希望使用 face 命令空间,需要将内部 DNS 地址中的 default 改成 face;如果只是测试,可以不执行这两条命令。)

应用部署

Pods 和 服务概览:

MySQL

第一个要部署的服务是数据库。

按照 Kubernetes 的示例 Kubenetes MySQL 进行部署,即可以满足我的需求。注意:示例配置文件的 MYSQL\_PASSWORD 字段使用了明文密码,我将使用 Kubernetes Secrets 对象以提高安全性。

我创建了一个 Secret 对象,对应的本地 yaml 文件如下:

apiVersion: v1
kind: Secret
metadata:
  name: kube-face-secret
type: Opaque
data:
  mysql_password: base64codehere
  mysql_userpassword: base64codehere

其中 base64 编码通过如下命令生成:

echo -n "ubersecurepassword" | base64
echo -n "root:ubersecurepassword" | base64

(LCTT 译注:secret yaml 文件中的 data 应该有两条,一条对应 mysql_password,仅包含密码;另一条对应 mysql_userpassword,包含用户和密码。后文会用到 mysql_userpassword,但没有提及相应的生成)

我的部署 yaml 对应部分如下:

...
- name: MYSQL_ROOT_PASSWORD
  valueFrom:
    secretKeyRef:
      name: kube-face-secret
      key: mysql_password
...

另外值得一提的是,我使用卷将数据库持久化,卷对应的定义如下:

...
        volumeMounts:
        - name: mysql-persistent-storage
          mountPath: /var/lib/mysql
...
      volumes:
      - name: mysql-persistent-storage
        persistentVolumeClaim:
          claimName: mysql-pv-claim
...

其中 presistentVolumeClain 是关键,告知 Kubernetes 当前资源需要持久化存储。持久化存储的提供方式对用户透明。类似 Pods,如果想了解更多细节,参考文档:Kubernetes 持久化存储

(LCTT 译注:使用 presistentVolumeClain 之前需要创建 presistentVolume,对于单节点可以使用本地存储,对于多节点需要使用共享存储,因为 Pod 可以能调度到任何一个节点)

使用如下命令部署 MySQL 服务:

kubectl apply -f mysql.yaml

这里比较一下 createapplyapply 是一种 宣告式 declarative 的对象配置命令,而 create 命令式 imperative 的命令。当下我们需要知道的是, create 通常对应一项任务,例如运行某个组件或创建一个部署;相比而言,当我们使用 apply 的时候,用户并没有指定具体操作,Kubernetes 会根据集群目前的状态定义需要执行的操作。故如果不存在名为 mysql 的服务,当我执行 apply -f mysql.yaml 时,Kubernetes 会创建该服务。如果再次执行这个命令,Kubernetes 会忽略该命令。但如果我再次运行 create ,Kubernetes 会报错,告知服务已经创建。

想了解更多信息,请阅读如下文档:Kubernetes 对象管理命令式配置宣告式配置

运行如下命令查看执行进度信息:

# 描述完整信息
kubectl describe deployment mysql
# 仅描述 Pods 信息
kubectl get pods -l app=mysql

(第一个命令)输出示例如下:

...
  Type           Status  Reason
  ----           ------  ------
  Available      True    MinimumReplicasAvailable
  Progressing    True    NewReplicaSetAvailable
OldReplicaSets:  <none>
NewReplicaSet:   mysql-55cd6b9f47 (1/1 replicas created)
...

对于 get pods 命令,输出示例如下:

NAME                     READY     STATUS    RESTARTS   AGE
mysql-78dbbd9c49-k6sdv   1/1       Running   0          18s

可以使用下面的命令测试数据库实例:

kubectl run -it --rm --image=mysql:5.6 --restart=Never mysql-client -- mysql -h mysql -pyourpasswordhere

特别提醒:如果你在这里修改了密码,重新 apply 你的 yaml 文件并不能更新容器。因为数据库是持久化的,密码并不会改变。你需要先使用 kubectl delete -f mysql.yaml 命令删除整个部署。

运行 show databases 后,应该可以看到如下信息:

If you don't see a command prompt, try pressing enter.

mysql>
mysql>
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| kube               |
| mysql              |
| performance_schema |
+--------------------+
4 rows in set (0.00 sec)

mysql> exit
Bye

你会注意到,我还将一个数据库初始化 SQL 文件挂载到容器中,MySQL 容器会自动运行该文件,导入我将用到的部分数据和模式。

对应的卷定义如下:

  volumeMounts:
  - name: mysql-persistent-storage
    mountPath: /var/lib/mysql
  - name: bootstrap-script
    mountPath: /docker-entrypoint-initdb.d/database_setup.sql
volumes:
- name: mysql-persistent-storage
  persistentVolumeClaim:
    claimName: mysql-pv-claim
- name: bootstrap-script
  hostPath:
    path: /Users/hannibal/golang/src/github.com/Skarlso/kube-cluster-sample/database_setup.sql
    type: File

(LCTT 译注:数据库初始化脚本需要改成对应的路径,如果是多节点,需要是共享存储中的路径。另外,作者给的 sql 文件似乎有误,person_images 表中的 person_id 列数字都小 1,作者默认 id 从 0 开始,但应该是从 1 开始)

运行如下命令查看引导脚本是否正确执行:

~/golang/src/github.com/Skarlso/kube-cluster-sample/kube_files master*
❯ kubectl run -it --rm --image=mysql:5.6 --restart=Never mysql-client -- mysql -h mysql -uroot -pyourpasswordhere kube
If you don't see a command prompt, try pressing enter.

mysql> show tables;
+----------------+
| Tables_in_kube |
+----------------+
| images         |
| person         |
| person_images  |
+----------------+
3 rows in set (0.00 sec)

mysql>

(LCTT 译注:上述代码块中的第一行是作者执行命令所在路径,执行第二行的命令无需在该目录中进行)

上述操作完成了数据库服务的初始化。使用如下命令可以查看服务日志:

kubectl logs deployment/mysql -f

NSQ 查询

NSQ 查询将以内部服务的形式运行。由于不需要外部访问,这里使用 clusterIP: None 在 Kubernetes 中将其设置为 无头服务 headless service ,意味着该服务不使用负载均衡模式,也不使用单独的服务 IP。DNS 将基于服务 选择器 selectors

我们的 NSQ 查询服务对应的选择器为:

  selector:
    matchLabels:
      app: nsqlookup

那么,内部 DNS 对应的实体类似于:nsqlookup.default.svc.cluster.local

无头服务的更多细节,可以参考:无头服务

NSQ 服务与 MySQL 服务大同小异,只需要少许修改即可。如前所述,我将使用 NSQ 原生的 Docker 镜像,名称为 nsqio/nsq。镜像包含了全部的 nsq 命令,故 nsqd 也将使用该镜像,只是使用的命令不同。对于 nsqlookupd,命令如下:

command: ["/nsqlookupd"]
args: ["--broadcast-address=nsqlookup.default.svc.cluster.local"]

你可能会疑惑,--broadcast-address 参数是做什么用的?默认情况下,nsqlookup 使用容器的主机名作为广播地址;这意味着,当用户运行回调时,回调试图访问的地址类似于 http://nsqlookup-234kf-asdf:4161/lookup?topics=image,但这显然不是我们期望的。将广播地址设置为内部 DNS 后,回调地址将是 http://nsqlookup.default.svc.cluster.local:4161/lookup?topic=images,这正是我们期望的。

NSQ 查询还需要转发两个端口,一个用于广播,另一个用于 nsqd 守护进程的回调。在 Dockerfile 中暴露相应端口,在 Kubernetes 模板中使用它们,类似如下:

容器模板:

        ports:
        - containerPort: 4160
          hostPort: 4160
        - containerPort: 4161
          hostPort: 4161

服务模板:

spec:
  ports:
  - name: main
    protocol: TCP
    port: 4160
    targetPort: 4160
  - name: secondary
    protocol: TCP
    port: 4161
    targetPort: 4161

端口名称是必须的,Kubernetes 基于名称进行区分。(LCTT 译注:端口名更新为作者 GitHub 对应文件中的名称)

像之前那样,使用如下命令创建服务:

kubectl apply -f nsqlookup.yaml

nsqlookupd 部分到此结束。截至目前,我们已经准备好两个主要的组件。

接收器

这部分略微复杂。接收器需要完成三项工作:

  • 创建一些部署
  • 创建 nsq 守护进程
  • 将本服务对外公开

部署

第一个要创建的部署是接收器本身,容器镜像为 skarlso/kube-receiver-alpine

NSQ 守护进程

接收器需要使用 NSQ 守护进程。如前所述,接收器在其内部运行一个 NSQ,这样与 nsq 的通信可以在本地进行,无需通过网络。为了让接收器可以这样操作,NSQ 需要与接收器部署在同一个节点上。

NSQ 守护进程也需要一些调整的参数配置:

        ports:
        - containerPort: 4150
          hostPort: 4150
        - containerPort: 4151
          hostPort: 4151
        env:
        - name: NSQLOOKUP_ADDRESS
          value: nsqlookup.default.svc.cluster.local
        - name: NSQ_BROADCAST_ADDRESS
          value: nsqd.default.svc.cluster.local
        command: ["/nsqd"]
        args: ["--lookupd-tcp-address=$(NSQLOOKUP_ADDRESS):4160", "--broadcast-address=$(NSQ_BROADCAST_ADDRESS)"]

其中我们配置了 lookup-tcp-addressbroadcast-address 参数。前者是 nslookup 服务的 DNS 地址,后者用于回调,就像 nsqlookupd 配置中那样。

对外公开

下面即将创建第一个对外公开的服务。有两种方式可供选择。考虑到该 API 负载较高,可以使用负载均衡的方式。另外,如果希望将其部署到生产环境中的任选节点,也应该使用负载均衡方式。

但由于我使用的本地集群只有一个节点,那么使用 NodePort 的方式就足够了。NodePort 方式将服务暴露在对应节点的固定端口上。如果未指定端口,将从 30000-32767 数字范围内随机选其一个。也可以指定端口,可以在模板文件中使用 nodePort 设置即可。可以通过 <NodeIP>:<NodePort> 访问该服务。如果使用多个节点,负载均衡可以将多个 IP 合并为一个 IP。

更多信息,请参考文档:服务发布

结合上面的信息,我们定义了接收器服务,对应的模板如下:

apiVersion: v1
kind: Service
metadata:
  name: receiver-service
spec:
  ports:
  - protocol: TCP
    port: 8000
    targetPort: 8000
  selector:
    app: receiver
  type: NodePort

如果希望固定使用 8000 端口,需要增加 nodePort 配置,具体如下:

apiVersion: v1
kind: Service
metadata:
  name: receiver-service
spec:
  ports:
  - protocol: TCP
    port: 8000
    targetPort: 8000
  selector:
    app: receiver
  type: NodePort
  nodePort: 8000

(LCTT 译注:虽然作者没有写,但我们应该知道需要运行的部署命令 kubectl apply -f receiver.yaml。)

图片处理器

图片处理器用于将图片传送至识别组件。它需要访问 nslookupd、 mysql 以及后续部署的人脸识别服务的 gRPC 接口。事实上,这是一个无聊的服务,甚至其实并不是服务(LCTT 译注:第一个服务是指在整个架构中,图片处理器作为一个服务;第二个服务是指 Kubernetes 服务)。它并需要对外暴露端口,这是第一个只包含部署的组件。长话短说,下面是完整的模板:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: image-processor-deployment
spec:
  selector:
    matchLabels:
      app: image-processor
  replicas: 1
  template:
    metadata:
      labels:
        app: image-processor
    spec:
      containers:
      - name: image-processor
        image: skarlso/kube-processor-alpine:latest
        env:
        - name: MYSQL_CONNECTION
          value: "mysql.default.svc.cluster.local"
        - name: MYSQL_USERPASSWORD
          valueFrom:
            secretKeyRef:
              name: kube-face-secret
              key: mysql_userpassword
        - name: MYSQL_PORT
          # TIL: If this is 3306 without " kubectl throws an error.
          value: "3306"
        - name: MYSQL_DBNAME
          value: kube
        - name: NSQ_LOOKUP_ADDRESS
          value: "nsqlookup.default.svc.cluster.local:4161"
        - name: GRPC_ADDRESS
          value: "face-recog.default.svc.cluster.local:50051"

文件中唯一需要提到的是用于配置应用的多个环境变量属性,主要关注 nsqlookupd 地址 和 gRPC 地址。

运行如下命令完成部署:

kubectl apply -f image_processor.yaml

人脸识别

人脸识别服务的确包含一个 Kubernetes 服务,具体而言是一个比较简单、仅供图片处理器使用的服务。模板如下:

apiVersion: v1
kind: Service
metadata:
  name: face-recog
spec:
  ports:
  - protocol: TCP
    port: 50051
    targetPort: 50051
  selector:
    app: face-recog
  clusterIP: None

更有趣的是,该服务涉及两个卷,分别为 known_peopleunknown_people。你能猜到卷中包含什么内容吗?对,是图片。known_people 卷包含所有新图片,接收器收到图片后将图片发送至该卷对应的路径,即挂载点。在本例中,挂载点为 /unknown_people,人脸识别服务需要能够访问该路径。

对于 Kubernetes 和 Docker 而言,这很容易。卷可以使用挂载的 S3 或 某种 nfs,也可以是宿主机到虚拟机的本地挂载。可选方式有很多 (至少有一打那么多)。为简洁起见,我将使用本地挂载方式。

挂载卷分为两步。第一步,需要在 Dockerfile 中指定卷:

VOLUME [ "/unknown_people", "/known_people" ]

第二步,就像之前为 MySQL Pod 挂载卷那样,需要在 Kubernetes 模板中配置;相比而言,这里使用 hostPath,而不是 MySQL 例子中的 PersistentVolumeClaim

        volumeMounts:
        - name: known-people-storage
          mountPath: /known_people
        - name: unknown-people-storage
          mountPath: /unknown_people
      volumes:
      - name: known-people-storage
        hostPath:
          path: /Users/hannibal/Temp/known_people
          type: Directory
      - name: unknown-people-storage
        hostPath:
          path: /Users/hannibal/Temp/
          type: Directory

(LCTT 译注:对于多节点模式,由于人脸识别服务和接收器服务可能不在一个节点上,故需要使用共享存储而不是节点本地存储。另外,出于 Python 代码的逻辑,推荐保持两个文件夹的嵌套结构,即 known\_people 作为子目录。)

我们还需要为 known_people 文件夹做配置设置,用于人脸识别程序。当然,使用环境变量属性可以完成该设置:

        env:
        - name: KNOWN_PEOPLE
          value: "/known_people"

Python 代码按如下方式搜索图片:

        known_people = os.getenv('KNOWN_PEOPLE', 'known_people')
        print("Known people images location is: %s" % known_people)
        images = self.image_files_in_folder(known_people)

其中 image_files_in_folder 函数定义如下:

    def image_files_in_folder(self, folder):
        return [os.path.join(folder, f) for f in os.listdir(folder) if re.match(r'.*\.(jpg|jpeg|png)', f, flags=re.I)]

看起来不错。

如果接收器现在收到一个类似下面的请求(接收器会后续将其发送出去):

curl -d '{"path":"/unknown_people/unknown220.jpg"}' http://192.168.99.100:30251/image/post

图像处理器会在 /unknown_people 目录搜索名为 unknown220.jpg 的图片,接着在 known_folder 文件中找到 unknown220.jpg 对应个人的图片,最后返回匹配图片的名称。

查看日志,大致信息如下:

# 接收器
❯ curl -d '{"path":"/unknown_people/unknown219.jpg"}' http://192.168.99.100:30251/image/post
got path: {Path:/unknown_people/unknown219.jpg}
image saved with id: 4
image sent to nsq

# 图片处理器
2018/03/26 18:11:21 INF    1 [images/ch] querying nsqlookupd http://nsqlookup.default.svc.cluster.local:4161/lookup?topic=images
2018/03/26 18:11:59 Got a message: 4
2018/03/26 18:11:59 Processing image id:  4
2018/03/26 18:12:00 got person:  Hannibal
2018/03/26 18:12:00 updating record with person id
2018/03/26 18:12:00 done

我们已经使用 Kubernetes 部署了应用正常工作所需的全部服务。

前端

更进一步,可以使用简易的 Web 应用更好的显示数据库中的信息。这也是一个对外公开的服务,使用的参数可以参考接收器。

部署后效果如下:

回顾

到目前为止我们做了哪些操作呢?我一直在部署服务,用到的命令汇总如下:

kubectl apply -f mysql.yaml
kubectl apply -f nsqlookup.yaml
kubectl apply -f receiver.yaml
kubectl apply -f image_processor.yaml
kubectl apply -f face_recognition.yaml
kubectl apply -f frontend.yaml

命令顺序可以打乱,因为除了图片处理器的 NSQ 消费者外的应用在启动时并不会建立连接,而且图片处理器的 NSQ 消费者会不断重试。

使用 kubectl get pods 查询正在运行的 Pods,示例如下:

❯ kubectl get pods
NAME                                          READY     STATUS    RESTARTS   AGE
face-recog-6bf449c6f-qg5tr                    1/1       Running   0          1m
image-processor-deployment-6467468c9d-cvx6m   1/1       Running   0          31s
mysql-7d667c75f4-bwghw                        1/1       Running   0          36s
nsqd-584954c44c-299dz                         1/1       Running   0          26s
nsqlookup-7f5bdfcb87-jkdl7                    1/1       Running   0          11s
receiver-deployment-5cb4797598-sf5ds          1/1       Running   0          26s

运行 minikube service list

❯ minikube service list
|-------------|----------------------|-----------------------------|
|  NAMESPACE  |         NAME         |             URL             |
|-------------|----------------------|-----------------------------|
| default     | face-recog           | No node port                |
| default     | kubernetes           | No node port                |
| default     | mysql                | No node port                |
| default     | nsqd                 | No node port                |
| default     | nsqlookup            | No node port                |
| default     | receiver-service     | http://192.168.99.100:30251 |
| kube-system | kube-dns             | No node port                |
| kube-system | kubernetes-dashboard | http://192.168.99.100:30000 |
|-------------|----------------------|-----------------------------|

滚动更新

滚动更新 Rolling Update 过程中会发生什么呢?

在软件开发过程中,需要变更应用的部分组件是常有的事情。如果我希望在不影响其它组件的情况下变更一个组件,我们的集群会发生什么变化呢?我们还需要最大程度的保持向后兼容性,以免影响用户体验。谢天谢地,Kubernetes 可以帮我们做到这些。

目前的 API 一次只能处理一个图片,不能批量处理,对此我并不满意。

代码

目前,我们使用下面的代码段处理单个图片的情形:

// PostImage 对图片提交做出响应,将图片信息保存到数据库中
// 并将该信息发送给 NSQ 以供后续处理使用
func PostImage(w http.ResponseWriter, r *http.Request) {
...
}

func main() {
    router := mux.NewRouter()
    router.HandleFunc("/image/post", PostImage).Methods("POST")
    log.Fatal(http.ListenAndServe(":8000", router))
}

我们有两种选择。一种是增加新接口 /images/post 给用户使用;另一种是在原接口基础上修改。

新版客户端有回退特性,在新接口不可用时回退使用旧接口。但旧版客户端没有这个特性,故我们不能马上修改代码逻辑。考虑如下场景,你有 90 台服务器,计划慢慢执行滚动更新,依次对各台服务器进行业务更新。如果一台服务需要大约 1 分钟更新业务,那么整体更新完成需要大约 1 个半小时的时间(不考虑并行更新的情形)。

更新过程中,一些服务器运行新代码,一些服务器运行旧代码。用户请求被负载均衡到各个节点,你无法控制请求到达哪台服务器。如果客户端的新接口请求被调度到运行旧代码的服务器,请求会失败;客户端可能会回退使用旧接口,(但由于我们已经修改旧接口,本质上仍然是调用新接口),故除非请求刚好到达到运行新代码的服务器,否则一直都会失败。这里我们假设不使用 粘性会话 sticky sessions

而且,一旦所有服务器更新完毕,旧版客户端不再能够使用你的服务。

这里,你可能会说你并不需要保留旧代码;某些情况下,确实如此。因此,我们打算直接修改旧代码,让其通过少量参数调用新代码。这样操作操作相当于移除了旧代码。当所有客户端迁移完毕后,这部分代码也可以安全地删除。

新的接口

让我们添加新的路由方法:

...
router.HandleFunc("/images/post", PostImages).Methods("POST")
...

更新旧的路由方法,使其调用新的路由方法,修改部分如下:

// PostImage 对图片提交做出响应,将图片信息保存到数据库中
// 并将该信息发送给 NSQ 以供后续处理使用
func PostImage(w http.ResponseWriter, r *http.Request) {
    var p Path
    err := json.NewDecoder(r.Body).Decode(&p)
    if err != nil {
      fmt.Fprintf(w, "got error while decoding body: %s", err)
      return
    }
    fmt.Fprintf(w, "got path: %+v\n", p)
    var ps Paths
    paths := make([]Path, 0)
    paths = append(paths, p)
    ps.Paths = paths
    var pathsJSON bytes.Buffer
    err = json.NewEncoder(&pathsJSON).Encode(ps)
    if err != nil {
      fmt.Fprintf(w, "failed to encode paths: %s", err)
      return
    }
    r.Body = ioutil.NopCloser(&pathsJSON)
    r.ContentLength = int64(pathsJSON.Len())
    PostImages(w, r)
}

当然,方法名可能容易混淆,但你应该能够理解我想表达的意思。我将请求中的单个路径封装成新方法所需格式,然后将其作为请求发送给新接口处理。仅此而已。在 滚动更新批量图片的 PR 中可以找到更多的修改方式。

至此,我们使用两种方法调用接收器:

# 单路径模式
curl -d '{"path":"unknown4456.jpg"}' http://127.0.0.1:8000/image/post

# 多路径模式
curl -d '{"paths":[{"path":"unknown4456.jpg"}]}' http://127.0.0.1:8000/images/post

这里用到的客户端是 curl。一般而言,如果客户端本身是一个服务,我会做一些修改,在新接口返回 404 时继续尝试旧接口。

为了简洁,我不打算为 NSQ 和其它组件增加批量图片处理的能力。这些组件仍然是一次处理一个图片。这部分修改将留给你作为扩展内容。 :)

新镜像

为实现滚动更新,我首先需要为接收器服务创建一个新的镜像。新镜像使用新标签,告诉大家版本号为 v1.1。

docker build -t skarlso/kube-receiver-alpine:v1.1 .

新镜像创建后,我们可以开始滚动更新了。

滚动更新

在 Kubernetes 中,可以使用多种方式完成滚动更新。

手动更新

不妨假设在我配置文件中使用的容器版本为 v1.0,那么实现滚动更新只需运行如下命令:

kubectl rolling-update receiver --image:skarlso/kube-receiver-alpine:v1.1

如果滚动更新过程中出现问题,我们总是可以回滚:

kubectl rolling-update receiver --rollback

容器将回滚到使用上一个版本镜像,操作简捷无烦恼。

应用新的配置文件

手动更新的不足在于无法版本管理。

试想下面的场景。你使用手工更新的方式对若干个服务器进行滚动升级,但其它人并不知道这件事。之后,另外一个人修改了模板文件并将其应用到集群中,更新了全部服务器;更新过程中,突然发现服务不可用了。

长话短说,由于模板无法识别已经手动更新的服务器,这些服务器会按模板变更成错误的状态。这种做法很危险,千万不要这样做。

推荐的做法是,使用新版本信息更新模板文件,然后使用 apply 命令应用模板文件。

对于滚动扩展,Kubernetes 推荐通过部署结合副本组完成。但这意味着待滚动更新的应用至少有 2 个副本,否则无法完成 (除非将 maxUnavailable 设置为 1)。我在模板文件中增加了副本数量、设置了接收器容器的新镜像版本。

  replicas: 2
...
    spec:
      containers:
      - name: receiver
        image: skarlso/kube-receiver-alpine:v1.1
...

更新过程中,你会看到如下信息:

❯ kubectl rollout status deployment/receiver-deployment
Waiting for rollout to finish: 1 out of 2 new replicas have been updated...

通过在模板中增加 strategy 段,你可以增加更多的滚动扩展配置:

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

关于滚动更新的更多信息,可以参考如下文档:部署的滚动更新部署的更新部署的管理使用副本控制器完成滚动更新等。

MINIKUBE 用户需要注意:由于我们使用单个主机上使用单节点配置,应用只有 1 份副本,故需要将 maxUnavailable 设置为 1。否则 Kubernetes 会阻止更新,新版本会一直处于 Pending 状态;这是因为我们在任何时刻都不允许出现没有(正在运行的) receiver 容器的场景。

扩展

Kubernetes 让扩展成为相当容易的事情。由于 Kubernetes 管理整个集群,你仅需在模板文件中添加你需要的副本数目即可。

这篇文章已经比较全面了,但文章的长度也越来越长。我计划再写一篇后续文章,在 AWS 上使用多节点、多副本方式实现扩展。敬请期待。

清理环境

kubectl delete deployments --all
kubectl delete services -all

写在最后的话

各位看官,本文就写到这里了。我们在 Kubernetes 上编写、部署、更新和扩展(老实说,并没有实现)了一个分布式应用。

如果你有任何疑惑,请在下面的评论区留言交流,我很乐意回答相关问题。

希望阅读本文让你感到愉快。我知道,这是一篇相对长的文章,我也曾经考虑进行拆分;但整合在一起的单页教程也有其好处,例如利于搜索、保存页面或更进一步将页面打印为 PDF 文档。

Gergely 感谢你阅读本文。


via: https://skarlso.github.io/2018/03/15/kubernetes-distributed-application/

作者:hannibal 译者:pinewall 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

这个分步指导教程教你通过在 Kubernetes 上部署一个简单的 Python 应用程序来学习部署的流程。

Kubernetes 是一个具备部署、维护和可伸缩特性的开源平台。它在提供可移植性、可扩展性以及自我修复能力的同时,简化了容器化 Python 应用程序的管理。

不论你的 Python 应用程序是简单还是复杂,Kubernetes 都可以帮你高效地部署和伸缩它们,在有限的资源范围内滚动升级新特性。

在本文中,我将描述在 Kubernetes 上部署一个简单的 Python 应用程序的过程,它包括:

  • 创建 Python 容器镜像
  • 发布容器镜像到镜像注册中心
  • 使用持久卷
  • 在 Kubernetes 上部署 Python 应用程序

必需条件

你需要 Docker、kubectl 以及这个 源代码

Docker 是一个构建和承载已发布的应用程序的开源平台。可以参照 官方文档 去安装 Docker。运行如下的命令去验证你的系统上运行的 Docker:

$ docker info
Containers: 0
Images: 289
Storage Driver: aufs
 Root Dir: /var/lib/docker/aufs
 Dirs: 289
Execution Driver: native-0.2
Kernel Version: 3.16.0-4-amd64
Operating System: Debian GNU/Linux 8 (jessie)
WARNING: No memory limit support
WARNING: No swap limit support

kubectl 是在 Kubernetes 集群上运行命令的一个命令行界面。运行下面的 shell 脚本去安装 kubectl

curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl

部署到 Kubernetes 的应用要求必须是一个容器化的应用程序。我们来回顾一下 Python 应用程序的容器化过程。

一句话了解容器化

容器化是指将一个应用程序所需要的东西打包进一个自带操作系统的容器中。这种完整机器虚拟化的好处是,一个应用程序能够在任何机器上运行而无需考虑它的依赖项。

我们以 Roman Gaponov 的 文章 为参考,来为我们的 Python 代码创建一个容器。

创建一个 Python 容器镜像

为创建这些镜像,我们将使用 Docker,它可以让我们在一个隔离的 Linux 软件容器中部署应用程序。Docker 可以使用来自一个 Dockerfile 中的指令来自动化构建镜像。

这是我们的 Python 应用程序的 Dockerfile:

FROM python:3.6
MAINTAINER XenonStack

# Creating Application Source Code Directory
RUN mkdir -p /k8s_python_sample_code/src

# Setting Home Directory for containers
WORKDIR /k8s_python_sample_code/src

# Installing python dependencies
COPY requirements.txt /k8s_python_sample_code/src
RUN pip install --no-cache-dir -r requirements.txt

# Copying src code to Container
COPY . /k8s_python_sample_code/src/app

# Application Environment variables
ENV APP_ENV development

# Exposing Ports
EXPOSE 5035

# Setting Persistent data
VOLUME ["/app-data"]

# Running Python Application
CMD ["python", "app.py"]

这个 Dockerfile 包含运行我们的示例 Python 代码的指令。它使用的开发环境是 Python 3.5。

构建一个 Python Docker 镜像

现在,我们可以使用下面的这个命令按照那些指令来构建 Docker 镜像:

docker build -t k8s_python_sample_code .

这个命令为我们的 Python 应用程序创建了一个 Docker 镜像。

发布容器镜像

我们可以将我们的 Python 容器镜像发布到不同的私有/公共云仓库中,像 Docker Hub、AWS ECR、Google Container Registry 等等。本教程中我们将发布到 Docker Hub。

在发布镜像之前,我们需要给它标记一个版本号:

docker tag k8s_python_sample_code:latest k8s_python_sample_code:0.1

推送镜像到一个云仓库

如果使用一个 Docker 注册中心而不是 Docker Hub 去保存镜像,那么你需要在你本地的 Docker 守护程序和 Kubernetes Docker 守护程序上添加一个容器注册中心。对于不同的云注册中心,你可以在它上面找到相关信息。我们在示例中使用的是 Docker Hub。

运行下面的 Docker 命令去推送镜像:

docker push k8s_python_sample_code

使用 CephFS 持久卷

Kubernetes 支持许多的持久存储提供商,包括 AWS EBS、CephFS、GlusterFS、Azure Disk、NFS 等等。我在示例中使用 CephFS 做为 Kubernetes 的持久卷。

为使用 CephFS 存储 Kubernetes 的容器数据,我们将创建两个文件:

persistent-volume.yml

apiVersion: v1
kind: PersistentVolume
metadata:
  name: app-disk1
  namespace: k8s_python_sample_code
spec:
  capacity:
  storage: 50Gi
  accessModes:
  - ReadWriteMany
  cephfs:
  monitors:
    - "172.17.0.1:6789"
  user: admin
  secretRef:
    name: ceph-secret
  readOnly: false

persistent_volume_claim.yaml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: appclaim1
  namespace: k8s_python_sample_code
spec:
  accessModes:
  - ReadWriteMany
  resources:
  requests:
    storage: 10Gi

现在,我们将使用 kubectl 去添加持久卷并声明到 Kubernetes 集群中:

$ kubectl create -f persistent-volume.yml
$ kubectl create -f persistent-volume-claim.yml

现在,我们准备去部署 Kubernetes。

在 Kubernetes 上部署应用程序

为管理部署应用程序到 Kubernetes 上的最后一步,我们将创建两个重要文件:一个服务文件和一个部署文件。

使用下列的内容创建服务文件,并将它命名为 k8s_python_sample_code.service.yml

apiVersion: v1
kind: Service
metadata:
  labels:
  k8s-app: k8s_python_sample_code
  name: k8s_python_sample_code
  namespace: k8s_python_sample_code
spec:
  type: NodePort
  ports:
  - port: 5035
  selector:
  k8s-app: k8s_python_sample_code

使用下列的内容创建部署文件并将它命名为 k8s_python_sample_code.deployment.yml

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: k8s_python_sample_code
  namespace: k8s_python_sample_code
spec:
  replicas: 1
  template:
  metadata:
    labels:
    k8s-app: k8s_python_sample_code
  spec:
    containers:
    - name: k8s_python_sample_code
      image: k8s_python_sample_code:0.1
      imagePullPolicy: "IfNotPresent"
      ports:
      - containerPort: 5035
      volumeMounts:
        - mountPath: /app-data
          name: k8s_python_sample_code
     volumes: 
         - name: <name of application>
           persistentVolumeClaim:
             claimName: appclaim1

最后,我们使用 kubectl 将应用程序部署到 Kubernetes:

$ kubectl create -f k8s_python_sample_code.deployment.yml $ kubectl create -f k8s_python_sample_code.service.yml

现在,你的应用程序已经成功部署到 Kubernetes。

你可以通过检查运行的服务来验证你的应用程序是否在运行:

kubectl get services

或许 Kubernetes 可以解决未来你部署应用程序的各种麻烦!

想学习更多关于 Python 的知识?Nanjekye 的书,和平共处的 Python 2 和 3 提供了完整的方法,让你写的代码在 Python 2 和 3 上完美运行,包括如何转换已有的 Python 2 代码为能够可靠运行在 Python 2 和 3 上的代码的详细示例。

关于作者

Joannah Nanjekye - Straight Outta 256,只要结果不问原因,充满激情的飞行员,喜欢用代码说话。关于我的更多信息


via: https://opensource.com/article/18/1/running-python-application-kubernetes

作者:Joannah Nanjekye 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出