世界是 container 的,也是 microservice 的,但最终还是 serverless 的

副标题是这样的: “Hyper,Fargate,以及 Serverless infrastructure”。

世界上有两种基础设施,一种是拿来主义,另一种是自主可控。

原谅我也蹭个已经被浇灭的、没怎么火起来的热点。不过我们喜欢的是拿来主义,够用就行,不想也不需要过多的控制,也不想惹过多的麻烦,也就是 fully managed。

之所以想到写这篇文章,源于前几天看到的这篇来自微软 Azure 的博客内容: The Future of Kubernetes Is Serverless ,然后又顺手温习了一遍 AWS CTO 所撰写的 Changing the calculus of containers in the cloud 这篇文章。这两篇文章你觉得有可能有广告的嫌疑,都是在推销自家的共有云服务,但是仔细品味每一句话,我却觉得几乎没有几句废话,都很说到点子上,你可以点击进去看下原文。

有个前提需要说明的是,这里的 Serverless 指的是 Serverless infrastructure,而不是我们常听到的 AWS Lambda,Microsoft Azure Functions 或 Google Cloud Functions 等函数(功能)即服务(FaaS)技术,为了便于区分,我们将这些 FaaS 称为无服务器计算,和我们本文要介绍的无服务器基础设施还是不一样的。

IaaS:变革的开始

说到基础设施,首先来介绍下最先出现的 IaaS,即基础设施即服务。IaaS 免除了大部分硬件的 provision 工作,没人再关心机架、电源和服务器问题,使得运维工作更快捷,更轻松,感觉解放了很多人,让大家走上了富裕之路。



当然这一代的云计算服务,可不只是可以几分钟启动一台虚拟机那么简单。

除了 VM 之外, IaaS 厂商还提供了很多其他基础设施和中间件服务,这些组件被称为 building block ,比如网络和防火墙、数据库、缓存等老三样,最近还出现了非常多非常多的业务场景服务,大数据、机器学习和算法,以及IoT等,看起来就像个百货商店,使用云计算就像购物,架构设计就是购物清单,架构里的组件都可以在商店里买到。

基础设施则使用 IaaS 服务商所提供的各种服务,编写应用程序可以更专注于业务。这能带来很多好处:

  • 将精力集中投入到核心业务
  • 加快上线速度
  • 提高可用性
  • 快速扩缩容
  • 不必关心中间件的底层基础设施
  • 免去繁杂的安装、配置、备份和安全管理等运维工作

在 AWS 成为业界标准之后,各大软件公司,不管是新兴的还是老牌的,都开始着手打造自己的云,国外有微软、谷歌、IBM等,国内的 BAT 也都有自己的云,以及京东和美团这样的电商类公司也有自己的云产品,独立的厂商类似 UCloud 和青云等公司也发展的不错,甚至有开饭馆的也要出来凑热闹。而开源软件 OpenStack 和基于 OS 的创业公司和产品也层出不穷。

全民皆云。

容器:云计算的深入人心

之后在 2013 年,容器技术开始面向大众普及了。在 LXC 之前,容器对普通开发人员甚至 IT 业者来说几乎不是同一个维度的术语,那是些专业人员才能掌控的晦涩的术语和繁杂的命令集,大部分人都没有用过容器技术;但是随着 Docker 的出现,容器技术的门槛降低,也在软件行业变得普及。随着几年的发展,基本可以说容器技术已经非常成熟,已成为了开发的标配。

随着容器技术的成熟和普及,应用程序架构也出现了新的变化,可以说软件和基础设施的进化相辅相成。人们越来越多的认识到对技术栈的分层和解耦更加重要,不同层之间的技术和责任、所有权等界限清晰明了,这也和软件设计中的模块松耦合原则很相像。

在有了责权明晰的分层结构之后,每个人可以更容易集中在自己所关注的重点上。开发人员更关注应用程序本身了,在 Docker 火了的同时,也出现了 app-centric 的概念。甚至 CoreOS 还将自己对抗 OCI/runc 的标准称为 appc 。当然现在的 Docker 也不是原来的 Docker ,也是一个组件化的东西,很多组件,尤其是 runtime ,都可以替换为其他运行时。

和以应用程序为重心相对应的是传统的以基础设施为中心,即先有基础设施,围绕基础设施做架构设计和开发、部署,受基础设施的限制较多。而随着 IaaS 等服务的兴起,基础设施越来越简单,越来越多容易入手,而且还提供了编程化的接口,开发人员也可以非常方便的对基础设施进行管理,可以说云计算的出现也使得开发人员抢了一部分运维人员的饭碗(遗憾的是这种趋势太 high 了停不下来。。。)。

当然,现在以应用为中心这一概念也已经深入人心。特别是进化到极致的 FaaS ,自己只需要写几行代码,其他平台全给搞定了。

编排:兵家必争之地

容器解决了代码的可移植性的问题,也使得在云计算中出现新的基础设施应用模式成为可能。使用一个一致的、不可变的部署制品,比如镜像,可以让我们从复杂的服务器部署中解脱出来,也可以非常方便的部署到不同的运行环境中(可移植性)。

但是容器的出现也增加了新的工作内容,要想使用容器运行我们的代码,就需要一套容器管理系统,在我们编写完代码,打包到容器之后,需要选择合适的运行环境,设置好正确的扩容配置,相关的网络连接,安全策略和访问控制,以及监控、日志和分布式追踪系统。

之所以出现编排系统,就是因为一台机器已经不够用了,我们要准备很多机器,在上面跑容器,而且我不关心容器跑在哪台机器上,这个交给调度系统就行了。可以说,从一定层面上,编排系统逐渐淡化了主机这一概念,我们面对的是一个资源池,是一组机器,有多少个 CPU 和多少的内存等计算资源可用。

rkt vs Docker 的战争从开始其实就可以预料到结局,但在编排系统/集群管理上,这场“战争”则有着更多的不确定性。

Mesos(DC/OS)出来的最早,还有 Twitter 等公司做案例,也是早期容器调度系统的标配;Swarm 借助其根正苗红以及简单性、和 Docker 的亲和性,也要争一分地盘;不过现在看来赢家应该是 K8s,K8s 有 Google 做靠山,有 Google 多年调度的经验,加上 RedHat/CoreOS 这些反 Docker 公司的站队,社区又做得红红火火,总之是赢了。

据说今年在哥本哈根举办的 Kubecon 有 4300 人参加。不过当初 Dockercon 也是这声势,而现在影响力已经没那么大了,有种昨日黄花、人老色衰的感觉,不知道几年之后的 Kubernetes 将来会如何,是否会出现新的产品或服务来撼动 Kubernetes 现在的地位?虽然不一定,但是我们期待啊。

Serverless infrastructure:进化的结果

但是呢,淡化主机的存在性也只是淡化而已,并没有完全消除主机的概念,只是我们直接面向主机的机会降低了,不再直接面向主机进行部署,也不会为某些部门分配独占的主机等。主机出了问题还得重启,资源不够了还得添加新的主机,管理工作并没有完全消失。

但是管理一套集群带来了很大的复杂性,这也和使用云计算的初衷相反,更称不上云原生。

从用户的角度再次审视一下,可以发现一个长时间被我们忽略的问题:为什么只是想运行容器,非得先买一台 VM 装好 Docker,或者自己搭建一套 Kubernetes 集群,或者使用类似 EKS 这样的服务,乐此不疲的进行各种配置和调试,不仅花费固定的资产费,还增加了很多并没有任何价值的运维管理工作。

既然我们嫌弃手动在多台主机中部署容器过于麻烦,将其交给集群管理和调度系统去做,那么维护调度系统同样繁杂的工作,是不是也可以交给别人来做,外包出去呢?

按照精益思想,这些和核心业务目标无关,不能带来任何用户价值的过程,都属于浪费行为,都需要提出。

这时候,出现了 Serverless infrastructure 服务,最早的比如国内的 hyper.sh (2016.8 GA),以及去年发布的 AWS 的 Fargate(2017.12),微软的 ACI(Azure Container Instance,2017.7) 等。

以 hyper.sh 为例,使用起来和 Docker 非常类似,可以将本地的 Docker 体验原封不动的搬到云端:

$ brew install hyper 
$ hyper pull mysql 
$ hyper run mysql 
MySQL is running... 
$ hyper run --link mysql wordpress 
WordPress is running... 
$ hyper fip attach 22.33.44.55 wordpress 
22.33.44.55
$ open 22.33.44.55

大部分命令从 docker 换成 hyper 就可以了,体验如同使用 Docker 一模一样,第一次看到这样的应用给人的新奇感,并不亚于当初的 Docker 。

使用 Serverless infrastructure,我们可以再不必为如下事情烦恼:

  • 不必再去费心选择 VM 实例的类型,需要多少 CPU 和内存
  • 不必再担心使用什么版本的 Docker 和集群管理软件
  • 不必担心 VM 内中间件的安全漏洞
  • 不必担心集群资源利用率太低
  • 从为资源池付费变为为运行中的容器付费
  • 完全不可变基础设施
  • 不用因为 ps 时看到各种无聊的 agent 而心理膈应

我们需要做的就是安心写自己的业务应用,构建自己的镜像,选择合适的容器大小,付钱给 cloud 厂商,让他们把系统做好,股票涨高高。

Fargate(此处也可以换做 ACI ):大厂表态

尽管 AWS 不像 GCP 那样“热衷”于容器,但是 AWS 也还是早就提供了 ECS(Elastic Container Service)服务。

去年发布的 AWS Fargate 则是个无服务器的容器服务,Fargate 是为了实现 AWS 的容器服务,比如 ECS(Elastic Container Service) 和 EKS(Elastic Kubernetes Service) 等,将容器运行所需要的基础设施进行抽象化的技术,并且现在 ECS 已经可以直接使用 Fargate。

和提供虚拟机的 EC2 不同,Fargate 提供的是容器运行实例,用户以容器作为基本的运算单位,而不必担心底层主机实例的管理,用户只需建立容器镜像,指定所需要的 CPU 和内存大小,设置相应的网络和IAM(身分管理)策略即可。

对于前面我们的疑问,AWS 的答案是基础设施的坑我们来填,你们只需要专心写好自己的应用程序就行了,你不必担心启动多少资源,我们来帮你进行容量管理,你只需要为你的使用付费就行了。

可以说 Fargate 和 Lambda 等产品都诞生于此哲学之下。

终于可以专心编写自己最擅长的 CRUD 了,happy,happy。

Serverless infrastructure vs Serverless compute

再多说几句,主要是为了帮助大家辨别两种不同的无服务器架构:无服务器计算和无服务器基础设施。

说实话一下子从 EC2 迁移到 Lambda ,这步子确实有点大。

Lambda 等 FaaS 产品虽然更加简单,但是存在有如下很多缺点:

  • 使用场景:Lambda 更适合用户操作或事件驱动,不适合做守护服务、批处理等业务
  • 灵活性:固定的内核、AMI等,无法定制
  • 资源限制:文件系统、内存、进程线程数、请求 body 大小以及执行时间等很多限制
  • 编程语言限制
  • 很难做服务治理
  • 不方便调试和测试

Lambda 和容器相比最大的优势就是运维工作更少,基本没有,而且计费更精确,不需要为浪费的计算资源买单,而且 Lambda 响应更快,扩容效率会高一些。

可以认为 Fargate 等容器实例,就是结合了 EC2 实例和 Lambda 优点的产品,既像 Lambda 一样轻量,更关注核心的应用程序,还能像 EC2 一样带来很大的灵活性和可控性。

云原生会给用户更多的控制,但是需要用户更少的投入和负担。

Serverless infrastructure 可以让容器更加 cloud native。

fully managed:大势所趋

所谓的 fully managed,可以理解为用户花费很少的成本,就可以获得想要的产品、服务并可以进行相应的控制。

这两天,阿里云发布了 Serverless Kubernetes ,Serverless Kubernetes 与原生的 Kubernetes 完全兼容,可以采用标准的 API、CLI 来部署和管理应用,还可以继续使用各种传统资产,并且还能获得企业级的高可用和安全性保障。难道以后我们连 Kubernetes 也不用自己装了,大部分人只需要掌握 kubectl 命令就好了。

IaaS 的出现,让我们丢弃了各种 provision 工具,同时,各种 configuration management 工具如雨后春笋般的出现和普及;容器的出现,又让我们扔掉了刚买还没看几页的各种 Chef/Puppet 入门/圣经,匆忙学起 Kubernetes;有了 Serverless infrastructure,也差不多可以和各种编排工具说拜拜了。

不管你们是在单体转微服务,还是在传统上云、转容器,估计大家都会喜欢上 fully managed 的服务,人人都做 Ops,很多运维工作都可以共同分担。当然,也会有一部分运维工程师掩面而逃。

Google 发布 gVisor – 容器沙箱运行时

这是来自官方博客 Open-sourcing gVisor, a sandboxed container runtime 的摘要及翻译。

自 Docker 的普及开始,我们开发、打包和部署应用的方式发生了根本性的变化,但是由于容器的隔离技术所限,并不是所有的人都推崇使用容器技术,因为其共享内核机制,系统还存在很大的攻击面,这就会存在恶意应用程序侵入系统的威胁。

为了运行那些不可信以及存在潜在威胁的容器,人们开始更加重视沙箱容器:一种能在宿主机和应用程序之间提供更安全的隔离机制。

Google 发布的 gVisor ,就是这样一种新型的沙箱容器技术,它能为容器提供更安全的隔离,同时比虚拟机(VM)更轻量。 而且,gVisor 还能和 Docker 以及 Kubernetes 集成在一起,使得在生产环境中运行沙箱容器更简单。

传统的 Linux 容器并非沙箱

传统 Linux 容器中运行的应用程序与常规(非容器化)应用程序以相同的方式访问系统资源:直接对主机内核进行系统调用。内核以特权模式运行,允许它与必要的硬件交互并将结果返回给应用程序。



在传统的容器技术中,内核会对应用程序需要访问的资源施加一些限制。这些限制通过使用 Linux 的 cgroups 和命名空间技术来实现,然而并非所有的资源都可以通过这些机制来进行控制。此外,即使使用这些限制,内核仍然面向恶意程序暴露出过多的攻击面。

像 seccomp 这样的技术可以在应用程序和主机内核之间提供更好的隔离,但是它们要求用户创建预定义的系统调用白名单。在实际中,很难事先罗列出应用程序所需要的所有系统调用。如果你需要调用的系统调用存在漏洞,那么这类过滤器也很难发挥作用。

已有基于 VM 的容器技术

提高容器隔离性的一种方法是将容器运行在其自己的虚拟机(VM)之内。也就是为每个容器提供自己专用的“机器”,包括内核和虚拟化设备,并与主机完全分离。即使 guest 虚拟机存在漏洞,管理程序( hypervisor )仍会隔离主机以及主机上运行的其他应用程序/容器。



在不同的 VM 中运行容器提供了很好的隔离性、兼容性和性能,但也可能需要更大的资源占用。

Kata containers 是一个开源项目,它使用精简的虚拟机来尽量减少资源的占用,并最大限度地提高隔离容器的性能。与 gVisor 一样,Kata 也包含与 Docker 和 Kubernetes 兼容的 OCI (Open Container Initiative )运行时。

基于 gVisor 的沙箱容器( Sandboxed containers )

gVisor 比 VM 更轻量,同时具备相同的隔离级别。 gVisor 的核心是一个以普通非特权进程方式运行的内核,它支持大多数 Linux 系统调用。这个内核是用 Go 编写的,选择 Go 语言是由于其较小的内存占用以及类型安全等特性。和虚拟机一样,在 gVisor 沙箱中运行的应用程序也可以拥有独立于主机和其他沙箱、自己独自的内核和一组虚拟设备。



gVisor 通过拦截应用程序的系统调用,并充当 guest 内核,提供了非常强的隔离性,而所有的这些都运行在用户空间。和虚拟机在创建时需要一定的资源不同,gVisor 可以像普通 Linux 进程一样,随时调整自己的资源使用。可以将 gVisor 看做是一个完全虚拟化的操作系统,但是与完整的虚拟机相比,它具有灵活的资源占用和更低的固定成本。

但是,这种灵活性的代价是单个系统调用的消耗、应用程序的兼容性(和系统调用相关)以及其他问题。

“安全工作负载(workloads)是业界的首要任务,我们很高兴看到像 gVisor 这样的创新,并期待在规范方面进行合作,并对相关技术组件进行改进,从而为生态系统带来更大的安全性。”

  • Samuel Ortiz,Kata 技术指导委员会成员,英特尔公司首席工程师

“Hyper 非常高兴看到 gVisor 这样全新的提高容器隔离性的方法。行业需要一个强大的安全容器技术生态系统,我们期待通过与 gVisor 的合作让安全容器成为主流。“

  • Xu Wang,Kata 技术指导委员会成员,Hyper.sh CTO

和 Docker、Kubernetes 集成

gVisor 运行时可以通过 runsc(run Sandboxed Container) 和 Docker 以及 Kubernetes 进行无缝集成。

runsc 运行时与 Docker 的默认容器运行时 runc 可以互换。runsc 的安装很简单,一旦安装完成,只需要在运行 docker 的时候增加一个参数就可以使用沙箱容器:

$ docker run --runtime=runsc hello-world
$ docker run --runtime=runsc -p 3306:3306 mysql

在 Kubernetes 中,大多数资源隔离都以 Pod 为单位,因此 Pod 也自然成为了 gVisor 沙箱的边界(boundary)。Kubernetes 社区目前正在致力于实现沙箱 Pod API,但是今天 gVisor 沙箱已经可以在实验版中(experimental support)可用。

runsc 运行时可以通过 cri-o 或 cri-containerd 等项目来在 Kubernetes 群集中使用沙箱技术,这些项目会将 Kubelet 中的消息转换为 OCI 运行时命令。

gVisor 实现了大部分 Linux 系统 API ( 200 个系统调用),但并不是所有的系统调用。目前有一些系统调用和参数还没有支持,以及 /proc 和 /sys 文件系统的一些部分内容。因此,并不是说所有的应用程序都可以在 gVisor 中正常运行,但大部分应用程序应该都可以正常运行,包括 Node.js、Java 8、MySQL、Jenkins、Apache、Redis 和 MongoDB 等等。

gVisor 已经开源,可以在 https://github.com/google/gvisor 查看到其内容,相信这里将会是大家了解 gVisor 最好的开始。

向 Kubernetes 学习 – Controller manager 的高可用实现方式

这不是一系列入门级别的文章,也不是按部就班而来的,而是我看到哪里,发现有些代码写的精妙的地方,都值得我们学习下,顺手记录下来,一方面是让自己将来可以有迹可循,另外对大家应该也会有所帮助。而且记录本身成本并不是很高。

高可用部署情况下,需要部署多个controller manager (以下简称 cm ),每个 cm 需要 --leader-elect=true 启动参数,即告知 cm 以高可用方式启动,谁要想进行真正的工作,必须先抢到锁,被选举为 leader 才行,而抢不到所得只能待机,在 leader 因为异常终止的时候,由剩余的其余节点再次获得锁。

关于分布式锁的实现很多,可以自己从零开始制造。当然更简单的是基于现有中间件,比如有基于 Redis 或数据库的实现方式,最近 Zookeeper/ETCD 也提供了相关功能。但 K8s 的实现并没有使用这些方式,而是另辟蹊径使用了资源锁的概念,简单来说就是通过创建 K8s 的资源(当前的实现中实现了 ConfigMap 和 Endpoint 两种类型的资源)来维护锁的状态。

分布式锁一般实现原理就是大家先去抢锁,抢到的人成为 leader ,然后 leader 会定期更新锁的状态,声明自己的活动状态,不让其他人把锁抢走。K8s 的资源锁也类似,抢到锁的节点会将自己的标记(目前是hostname)设为锁的持有者,其他人则需要通过对比锁的更新时间和持有者来判断自己是否能成为新的 leader ,而 leader 则可以通过更新 RenewTime 来确保持续保有该锁。

大概看了下 K8s 的实现,老实说其实现方式并不算高雅,但是却给我们开拓了一种思路:K8s 里的 resource 是万能的,不要以为 Endpoint 只是 Endpoint 。不过反过来有时候也挺让人费解的,刚了解的时候容易摸不着头脑,也不是好事。而且 scheduler 和 cm 都采用了资源锁,但是实现起来却不尽相同,也值得吐槽下。不管怎么说,这个实现算是挺有意思的实现,值得我们深入了解下。

我们首先来看一下 cm 启动的时候,是如何去 初始化 抢锁的。启动的时候,如果指定了 --leader-elect=true 参数的话,则会进入下面的代码,首先获取自己的资源标志(这里是 hostname 加一串随机数字)。

id, err := os.Hostname()

// add a uniquifier so that two processes on the same host don't accidentally both become active
id = id + "_" + string(uuid.NewUUID())
rl, err := resourcelock.New(c.Generic.ComponentConfig.GenericComponent.LeaderElection.ResourceLock,
    "kube-system",                                 // 该资源所在 Namespace
    "kube-controller-manager",                     // 资源名称
    c.Generic.LeaderElectionClient.CoreV1(),
    resourcelock.ResourceLockConfig{
        Identity:      id,                         // 锁持有者标志
        EventRecorder: c.Generic.EventRecorder,
    })
}

上面创建资源锁的代码说明请参考文中中文注释。

之后,在下面的代码中,资源锁,即上面的 rl(resource lock) 变量,被用于进行 leader 选举。具体的说明也嵌入在了下面的代码中。

leaderelection.RunOrDie(leaderelection.LeaderElectionConfig{
    Lock:          rl,
    // 下面 3 个参数是一些重时间,租赁期间等的设置,不是很重要
    LeaseDuration: c.Generic.ComponentConfig.GenericComponent.LeaderElection.LeaseDuration.Duration,
    RenewDeadline: c.Generic.ComponentConfig.GenericComponent.LeaderElection.RenewDeadline.Duration,
    RetryPeriod:   c.Generic.ComponentConfig.GenericComponent.LeaderElection.RetryPeriod.Duration,
    Callbacks: leaderelection.LeaderCallbacks{
        OnStartedLeading: run,                   // cm 的主要工作函数
        OnStoppedLeading: func() {
            glog.Fatalf("leaderelection lost")
        },
    },
})

我们再来看看 LeaderElectionConfig 的内容,说明见注释(其实就是将代码的英文翻译过来而已)

type LeaderElectionConfig struct {
    // 资源锁的实现对象
    Lock rl.Interface

    // 是非 leader 在获取锁之前需要检查 leader 过期的时间
    LeaseDuration time.Duration

    // 当前 leader 尝试更新锁状态的期限。
    RenewDeadline time.Duration

    // 抢锁时尝试间隔
    RetryPeriod time.Duration

    // 锁状态发生变化的时候,需要进行处理的一组回调函数
    Callbacks LeaderCallbacks
}

这里的 Callbacks 具体如下:

Callbacks: leaderelection.LeaderCallbacks{
    OnStartedLeading: run,
    OnStoppedLeading: func() {
        glog.Fatalf("leaderelection lost")
    },
},

也就是说,在获取锁(成为leader,OnStartedLeading)之后,将会执行 run 方法,在失去锁(OnStoppedLeading)之后打印错误消息后退出。run 方法是 cm 的主要方法,和抢锁选主流程没什么关系,这里就不介绍了。

下面的 LeaderElectionRecord 结构,保存了锁的信息,包括持有者(的hostname),获取时间,更新时间,leader 切换次数等(LeaseDurationSeconds 虽然定义了,但是并没有使用的感觉)。

这个结构可以说是资源锁中最重要的信息了,大家一定先混个脸熟,多念几遍 struct 的名字。

// LeaderElectionRecord is the record that is stored in the leader election annotation.
// This information should be used for observational purposes only and could be replaced
// with a random string (e.g. UUID) with only slight modification of this code.
type LeaderElectionRecord struct {
    HolderIdentity       string      `json:"holderIdentity"`
    LeaseDurationSeconds int         `json:"leaseDurationSeconds"`
    AcquireTime          metav1.Time `json:"acquireTime"`
    RenewTime            metav1.Time `json:"renewTime"`
    LeaderTransitions    int         `json:"leaderTransitions"`
}

这个锁信息,就是存在 K8s 的 ConfigMap 或者 Endpoint 里面的,当然,存哪里可能大家已经想到了,只能存 annotation 里面,该 annotation 的 key 就是 control-plane.alpha.kubernetes.io/leader

到这里总结一下就是:LeaderElectionRecord 用于保存锁的信息,但是这一信息会以 annotation 的方式,保存到 k8s 的 ConfigMap 或者 Endpoint 等资源里面。

下面我们来看一下资源锁的实现。

资源锁接口 的定义如下:


type Interface interface { Get() (*LeaderElectionRecord, error) Create(ler LeaderElectionRecord) error Update(ler LeaderElectionRecord) error RecordEvent(string) Identity() string Describe() string }

基本实现了 CRUD 几个方法,当然这里没有 D ,即 Delete,因为也没必要 Delete, 下一次抢锁的时候,抢到的 Leader 直接 Update 就可以了。

关键的方法我们看前 3 个就够了: Get 用于获取锁的最新信息,Update 用于更新,Create 用于创建资源锁对象,估计对大多数集群来说,只有第一次的时候才会调用 Create 创建这个对象。RecordEvent 也可以关注下,这个 event 属于锁资源,里面会记录 leader 切换等事件。

这里我们以 Endpoint 为例(这也是默认的资源锁类型,该参数可以通过 leader-elect-resource-lock 来设置),来看看资源锁的具体实现。

下面的代码省略了对 error 的检查,你懂得。


// Get returns the election record from a Endpoints Annotation func (el *EndpointsLock) Get() (*LeaderElectionRecord, error) { var record LeaderElectionRecord var err error // el.e 就是一个正经的 Endpoint 资源对象。 el.e, err = el.Client.Endpoints(el.EndpointsMeta.Namespace).Get(el.EndpointsMeta.Name, metav1.GetOptions{}) // 去获取 control-plane.alpha.kubernetes.io/leader annotation。 if recordBytes, found := el.e.Annotations[LeaderElectionRecordAnnotationKey]; found { if err := json.Unmarshal([]byte(recordBytes), &record); err != nil { return nil, err } } return &record, nil }

Create 也很简单,就是一个普通的 Endpoint 对象,加上锁专用的 annotation :

el.e, err = el.Client.Endpoints(el.EndpointsMeta.Namespace).Create(&v1.Endpoints{
    ObjectMeta: metav1.ObjectMeta{
        Name:      el.EndpointsMeta.Name,
        Namespace: el.EndpointsMeta.Namespace,
        Annotations: map[string]string{
            LeaderElectionRecordAnnotationKey: string(recordBytes),
        },
    },
})

更新方法 的主体如下,将 LeaderElectionRecord 结构的对象序列化为字符串后,存到 annotation:

el.e.Annotations[LeaderElectionRecordAnnotationKey] = string(recordBytes)
el.e, err = el.Client.Endpoints(el.EndpointsMeta.Namespace).Update(el.e)

通过上面的方法,我们应该已经了解到了,锁的实现主要载体是 LeaderElectionRecord 对象,其实我们完全可以自己实现其他类型的资源锁了,比如基于 Secret ,不过好像也没啥意义。

介绍了上面的实现基础,我们最后来看看抢锁及使用锁的过程,主要的入口 如下:

// Run starts the leader election loop
func (le *LeaderElector) Run() {
    // 先去抢锁,阻塞操作
    le.acquire()
    stop := make(chan struct{})
    // 抢到锁后,执行主函数,就是我们前面提到的 run 函数,通过 Callbacks.OnStartedLeading 回调启动
    go le.config.Callbacks.OnStartedLeading(stop)
    // 抢到锁后,需要定期更新,确保自己一直持有该锁
    le.renew()
    close(stop)
}

可以看到,里面主要调用了两个方法: acquirerenew

我们先来看看 acquire 方法:

func (le *LeaderElector) acquire() {
    stop := make(chan struct{})
    wait.JitterUntil(func() {
        succeeded := le.tryAcquireOrRenew()
        le.maybeReportTransition()
        if !succeeded {
            glog.V(4).Infof("failed to acquire lease %v", desc)
            return
        }
        le.config.Lock.RecordEvent("became leader")
        glog.Infof("successfully acquired lease %v", desc)
        close(stop)
    }, le.config.RetryPeriod, JitterFactor, true, stop)
}

实现也很短,这个函数会通过 wait.JitterUntil 来定期调用 tryAcquireOrRenew 方法 来获取锁,直到成功为止,如果获取不到锁,则会以 RetryPeriod 为间隔不断尝试。如果获取到锁,就会关闭 stop 通道( close(stop) ),通知 wait.JitterUntil 停止尝试。tryAcquireOrRenew 是最核心的方法,我们会在介绍完 renew 方法之后再进行介绍。

renew 只有在获取锁之后才会调用,它会通过持续更新资源锁的数据,来确保继续持有已获得的锁,保持自己的 leader 状态。这里还是用到了很多 wait 包里的方法。

func (le *LeaderElector) renew() {
    stop := make(chan struct{})
    wait.Until(func() {
        err := wait.Poll(le.config.RetryPeriod, le.config.RenewDeadline, func() (bool, error) {
            return le.tryAcquireOrRenew(), nil
        })
        le.maybeReportTransition()
        desc := le.config.Lock.Describe()
        if err == nil {
            glog.V(4).Infof("successfully renewed lease %v", desc)
            return
        }
        le.config.Lock.RecordEvent("stopped leading")
        glog.Infof("failed to renew lease %v: %v", desc, err)
        close(stop)
    }, 0, stop)
}

这里的精妙之处在于,wait.Until 会不断的调用 wait.Poll 方法,前者是进行无限循环操作,直到 stop chan 被关闭,wait.Poll则不断的对某一条件进行检查,以 RetryPeriod 为间隔,直到该条件返回true、error或者超时(上面的 RenewDeadline 参数)。这一条件是一个需要满足 func() (bool, error) 签名的方法,比如这个例子很简单,只是调用了 le.tryAcquireOrRenew()

tryAcquireOrRenew 方法本身不是一个阻塞操作,只返回 true/false,对应为获取到锁和没有获取到锁的状态。结合 wait.Poll 来使用,该函数返回会有以下几种情况:

  • tryAcquireOrRenew 获取到锁,返回 true
  • tryAcquireOrRenew 没有获取到锁,返回 false
  • tryAcquireOrRenew 超时,返回 ErrWaitTimeout(errors.New(“timed out waiting for the condition”))

最后,我们再来重点了解下 tryAcquireOrRenew 的内容。renew有两个功能,获取锁,或者在已经获取锁的时候,对锁进行更新,确保锁不被他人抢走。

具体的说明也放到了注释里,这段代码流程上不不复杂,但是需要对前后两个状态,以及 leader 和非 leader 两个角色的不同执行流程有所分辨。

func (le *LeaderElector) tryAcquireOrRenew() bool {
    now := metav1.Now()
    // 这个 leaderElectionRecord 就是保存在 Endpoint 的 annotation 中的值。
    // 每个节点都将 HolderIdentity 设置为自己,以及关于获取和更新锁的时间。后面会对时间进行修正,才会更新到 API server
    leaderElectionRecord := rl.LeaderElectionRecord{
        HolderIdentity:       le.config.Lock.Identity(),
        LeaseDurationSeconds: int(le.config.LeaseDuration / time.Second),
        RenewTime:            now,
        AcquireTime:          now,
    }

    // 1. 获取或者创建 ElectionRecord
    oldLeaderElectionRecord, err := le.config.Lock.Get()
    // 获取记录出错,有可能是记录不存在,这种错误需要处理。
    if err != nil {
        if !errors.IsNotFound(err) {
            glog.Errorf("error retrieving resource lock %v: %v", le.config.Lock.Describe(), err)
            return false
        }
        // 记录不存在的话,则创建一条新的记录
        if err = le.config.Lock.Create(leaderElectionRecord); err != nil {
            glog.Errorf("error initially creating leader election record: %v", err)
            return false
        }
        // 创建记录成功,同时表示获得了锁,返回true
        le.observedRecord = leaderElectionRecord
        le.observedTime = time.Now()
        return true
    }

    // 2. 正常获取了锁资源的记录,检查锁持有者和更新时间。
    if !reflect.DeepEqual(le.observedRecord, *oldLeaderElectionRecord) {
        // 记录之前的锁持有者,其实有可能就是自己。
        le.observedRecord = *oldLeaderElectionRecord
        le.observedTime = time.Now()
    }
    // 在满足以下所有的条件下,认为锁由他人持有,并且还没有过期,返回 false
    // a. 当前锁持有者的并非自己
    // b. 上一次观察时间 + 观测检查间隔大于现在时间,即距离上次观测的间隔,小于 `LeaseDuration` 的设置值。
    if le.observedTime.Add(le.config.LeaseDuration).After(now.Time) &&
        oldLeaderElectionRecord.HolderIdentity != le.config.Lock.Identity() {
        glog.V(4).Infof("lock is held by %v and has not yet expired", oldLeaderElectionRecord.HolderIdentity)
        return false
    }
    // 3. 更新资源的 annotation 内容。
    // 在本函数开头 leaderElectionRecord 有一些字段被设置成了默认值,这里来设置正确的值。
    if oldLeaderElectionRecord.HolderIdentity == le.config.Lock.Identity() {
        // 如果自己持有锁,则继承之前的获取时间和 leader 切换次数
        leaderElectionRecord.AcquireTime = oldLeaderElectionRecord.AcquireTime
        leaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions
    } else {
        // 发生 leader 切换,所以 LeaderTransitions + 1
        leaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions + 1
    }

    // 更新锁资源对象
    if err = le.config.Lock.Update(leaderElectionRecord); err != nil {
        glog.Errorf("Failed to update lock: %v", err)
        return false
    }
    le.observedRecord = leaderElectionRecord
    le.observedTime = time.Now()
    return true
}

再回到 renew 方法,在被 Poll 阻塞住之后,只要 Poll 返回了,就可以继续执行下面的代码。le.maybeReportTransition() 很关键,里面会判断是否出现了 leader 的切换,进而调用 CallbacksOnNewLeader 方法,尽管 cm 初始化的时候并没有设置这个 Callback 方法。

func (l *LeaderElector) maybeReportTransition() {
    if l.observedRecord.HolderIdentity == l.reportedLeader {
        return
    }
    l.reportedLeader = l.observedRecord.HolderIdentity
    if l.config.Callbacks.OnNewLeader != nil {
        go l.config.Callbacks.OnNewLeader(l.reportedLeader)
    }
}

代码看起来比较烧脑,本文读起来也比较摸不着头,可能最好的办法就是一遍遍的阅读源代码了。

世界上只需要 Nginx 和 HAProxy 足矣

Nginx 有多好?对大多数人来说,只需要编辑下配置文件就可以了,只需要保证逻辑正确,客户端的请求能被正确的 proxy 到 合适的 upstream 就可以了。

Nignx 哪里不好用?如果有的话,估计就是配置语法太多记不住,有些坑自己不亲自趟是看不出深浅的。尽管如此,也挡不住 nginx 势如破竹的普及趋势。

就当我们利用 nginx 做各种反向代理之时,努力学习通过 lua 对 Nginx / Openresty 进行扩展的时候,咣,又出来个 service mesh 的概念,以及各种如雨后春笋般出现的 Envoy 、istio 、Cilium 和 Conduit ?

不可否认,微服务确实是趋势,K8s 也基本成了构建 PaaS 以及集群调度平台的标准实现方式,随着这些基础设施以及 cloud native 的渐入人心, service mesh 的出现也没法说完全不能理解。

好处就不说了,说说不好的地方,微服务不好调试,出了问题定位难,管理成本高,K8s 部署复杂,未知的坑比较多。Istio 就能解决这个问题?

Service mesh 的一个功能就是实现系统的可观测性,对系统整体的运行状态有所把控,其涵盖范围要大于传统的监控和报警;而且,对系统的可观测性,要求对系统内部的实现非常了解。那我的问题就来了,这个配置文件谁来写?Dev?Ops?还是 DevOps ?

商品、服务或是软件,制作者都应该让用户变成“傻瓜”,让用户用起来简单。

开发工具和框架已经正在逐渐把软件工程师变成“码农”,很多应用程序就是 CRUD 而已,基本设计的时候就能把代码生成了; IaaS 以及 PaaS 等各种 XaaS,也大大简化了运维工作以及难度,从这两点来说,开发和运维都在变得越来越傻瓜。同一件事,在其他方面都一样的情况下,一定是最简单的最后胜出。

其实我也不清楚 service mesh 到底应该做成什么样才好,但是最近对一些事挺有感触的。比如不要干一件事提供 n 种方案,让用户选择也是负担,再比如 gofmt 和 go fmt 不应该放一块更好些么?

Docker 之所以这么火,还不是把 10 条 lxc 的命令合成了一条 docker run ?简单的话入手就快,当然普及会更快。然而 K8s 并不是走的这条线,在我看来,g 家的号召力,以及其强大且全面的功能,是很多人选择的关键因素,当然其核心是社区的强大之处,这也和 Docker 公司的哲学有些相悖,所以 Docker 公司最近两年一直被唱衰。

Docker swarm 曾经是个好东西,但是我想我也不会再去研究它,至少现在没这个打算。因为我知道,跟 K8s 应该不会太差,工作好找,收入也不会低,算是软件工程师的必备技能之一,在某些公司或者职位来说,可能是关键的决定性技能。

我们真的都需要 PaaS 么?CaaS 或 Serverless ,都有其优点,简单的代价就是可控性差,灵活度低,在别人的框架里写代码。以前接触过代码生成工具,就是从设计流程,自动生成源代码,可以理解为 UML 图生成类,但是比这负责,能生成业务逻辑的代码,也许将来有一天,我们本地都不需要 IDE,直接在服务提供商的页面拖拖拽拽就能组装自己的应用程序了。

这时不得不说到 full managed service,简单来说,就是所有基础设施都由平台方提供,自己只写业务代码,比如 PaaS 和 Serverless 都是这样的,IaaS 实际上除了虚拟机实例,也提供了类似的服务。自己不需要管理数据库、缓存和消息队列,只需要出 money 就可以了。

穷惯了就不敢进行高质量的消费,编写软件也应该适时偷懒,能用钱办到的事情,尽量别自己去做,别人更专业,自己更省心。

懒人真希望世界上只有 HAProxy 。

当然,一涉及到企业级,以上都全作废。

向 Kubernetes 学习 – 如何做定时和轮询

编程中常见的场景包括定时执行任务,很多语言,比如 Java 提供了非常方便易用且功能强大的类库,但是 Golang 中并没有,虽然我们可以通过 time.Ticker 等机制实现,但是不如原生直接支持更简单。

其实参考别人的代码是最好的学习方式了,比如通过阅读 K8s 的源代码,我们可以学习到很多编程技巧。

这里我们以实现定时轮训功能为例,来看看 K8s 是怎么实现的。

首先简单介绍下 K8s 的简单架构,主要包括以下几个组件(大体流程):

  • API servier
  • Scheduler
  • Controller manager
  • Kubelet

这几个组件只是完整运行 K8s 的4个组件,并不是全部,这里我们只是为了了解而只对这几个组件进行说明。

首先客户端(API 或者 kubectl 工具)发出请求,比如创建一个Deployment,Controller manager 会负责这个 Deployment 的生命周期,Scheduler 会为 Pod 分配节点,该节点上的 Kubelet 会负责在自己的机器上启动这个 Pod,这是一个非常简单的流程。

这些组件,都是以守护进程方式运行的,即会一直无限循环的运行下去,不会退出。在其中有很多例子都是诸如每隔 5 分钟做一次同步,每隔 10 秒做一次 retry 的尝试。对于这样的需求,即使只有一处,相信从代码结构的角度,我们都会把这些功能抽象成 util 函数。

K8s 就实现了很多这样的方法,比如 “k8s.io/apimachinery/pkg/util/wait” 包就有很多非常实用的方法,值得我们借鉴。

首先,最简单的每隔 10 秒执行一个函数,永不停止,那么可以用这个方法

func Forever(f func(), period time.Duration)

如果你想在上面的基础上,在需要的时候停止循环,那么可以使用下面的方法,增加一个用于停止的 chan 即可。

func Until(f func(), period time.Duration, stopCh <-chan struct{})

上面的第三个参数 stopCh 就是用于退出无限循环的标志,停止的时候我们 close 掉这个 chan 就可以了。

K8s 的很多地方都用到了这些函数,比如这里在 Container manager 启动的时候,就启动了一个无限循环来进行实际的工作( m.doWork 函数)

func (m *containerManager) Start() error {
    // TODO: check if the required cgroups are mounted.
    if len(m.cgroupsName) != 0 {
        manager, err := createCgroupManager(m.cgroupsName)
        if err != nil {
            return err
        }
        m.cgroupsManager = manager
    }
    go wait.Until(m.doWork, 5*time.Minute, wait.NeverStop)
    return nil
}

上面只是简单的定期运行任务的例子,有时候,我们还会需要在运行前去检查先决条件,在条件满足的时候才去运行某一任务,这时候可以使用 Poll 方法

func Poll(interval, timeout time.Duration, condition ConditionFunc)

这个函数会以 interval 为间隔,不断去检查 condition 条件是否为真,如果为真则可以继续后续处理;如果指定了 timeout 参数,则该函数也可以只常识指定的时间。

一个非常常见的例子就是我们在创建资源之后,资源不会立即就位,我们需要等资源创建完成之后,才能进行后续操作,这时候使用 Poll 方法应该就会非常方便了。

此外这个函数还有两个快捷方式, PollImmediatePollInfinitePollImmediateInfinite ,具体意义从名称上面即可理解。

PollUntil 方法和上面的类似,但是没有 timeout 参数,多了一个 stopCh 参数。

此外,这个包里还有一个公开方法 WaitFor ,好像并没有直接被外部调用,而是在 PollUtil 中被使用,这个方法需要自己编写 WaitFunc 类型的方法作为第一个参数,具体可以参考 PollUntil 的实现。