Istio 是什么?

Istio 是什么?

使用云平台可以为组织提供丰富的好处。然而,不可否认的是,采用云可能会给 DevOps 团队带来压力。开发人员必须使用微服务以满足应用的可移植性,同时运营商管理了极其庞大的混合和多云部署。Istio 允许您连接、保护、控制和观测服务。

在较高的层次上,Istio 有助于降低这些部署的复杂性,并减轻开发团队的压力。它是一个完全开源的服务网格,可以透明地分层到现有的分布式应用程序上。它也是一个平台,包括允许它集成到任何日志记录平台、遥测或策略系统的 API。Istio 的多样化功能集使您能够成功高效地运行分布式微服务架构,并提供保护、连接和监控微服务的统一方法。

什么是 Service Mesh?

2016 年初,原 Twitter 基础设施工程师 William Morgan 和 Oliver Gould 在 Github 上发布了 Linkerd 项目,并组建了 Buoyant 公司。同年,起源于 Buoyant 的新名字 Service Mesh 面世并迅速获得认可。

在从单体应用程序向分布式微服务架构的转型过程中,开发人员和运维人员面临诸多挑战,使用 Istio 可以解决这些问题。

服务网格(Service Mesh)这个术语通常用于描述构成这些应用程序的微服务网络以及应用之间的交互。随着规模和复杂性的增长,服务网格越来越难以理解和管理。它的需求包括服务发现、负载均衡、故障恢复、指标收集和监控以及通常更加复杂的运维需求,例如 A/B 测试、金丝雀发布、限流、访问控制和端到端认证等。

Linkerd 在面世之后,迅速获得用户的关注,并在多个用户的生产环境上成功部署、运行。2017 年,Linkerd 加入 CNCF,随后宣布完成对千亿次生产环境请求的处理,紧接着发布了 1.0 版本,并且具备一定数量的商业用户,一时间风光无限,一直持续到 Istio 横空出世。

为什么要使用 Istio?

2016 年,Lyft 开始了对现代网络代理软件 Envoy 的内部研发,并在同年 9 月将 Envoy 开源。由 C++ 语言开发而成的 Envoy 在开源之后,迅速获得了大量关注。它除了具备强大的性能,还提供了众多现代服务网格所需的功能特性,并开放了大量精雕细琢的编程接口,为后面的广泛应用埋下了伏笔。

2017 年 5 月,Google、IBM 和 Lyft 宣布了 Istio 的诞生。Istio 以 Envoy 为数据平面,通过 Sidecar 的方式让 Envoy 同业务容器一起运行,并劫持其通信,接受控制平面的统一管理,在此基础上为服务之间的通信提供丰富的连接、控制、观察、安全等特性。

Istio 一经发布,便立刻获得 Red Hat、F5 等大牌厂商的响应,虽然立足不稳,但各个合作方都展示了对社区、行业的强大影响力。于是,Istio 很快就超越了 Linkerd,成为 Service Mesh 的代表产品。

Istio 提供一种简单的方式来为已部署的服务建立网络,该网络具有负载均衡、服务间认证、监控等功能,只需要对服务的代码进行一点或不需要做任何改动。想要让服务支持 Istio,只需要在您的环境中部署一个特殊的 sidecar 代理,使用 Istio 控制平面功能配置和管理代理,拦截微服务之间的所有网络通信:

  • HTTP、gRPC、WebSocket 和 TCP 流量的自动负载均衡。
  • 通过丰富的路由规则、重试、故障转移和故障注入,可以对流量行为进行细粒度控制。
  • 可插入的策略层和配置 API,支持访问控制、速率限制和配额。
  • 对出入集群入口和出口中所有流量的自动度量指标、日志记录和追踪。
  • 通过强大的基于身份的验证和授权,在集群中实现安全的服务间通信。

Istio 旨在实现可扩展性,满足各种部署需求。

核心功能

Istio 在服务网络中统一提供了许多关键功能:

流量管理

通过简单的规则配置和流量路由,您可以控制服务之间的流量和 API 调用。Istio 简化了断路器、超时和重试等服务级别属性的配置,并且可以轻松设置 A/B测试、金丝雀部署和基于百分比的流量分割的分阶段部署等重要任务。

通过更好地了解您的流量和开箱即用的故障恢复功能,您可以在问题出现之前先发现问题,使调用更可靠,并且使您的网络更加强大——无论您面临什么条件。

安全

Istio 的安全功能使开发人员可以专注于应用程序级别的安全性。Istio 提供底层安全通信信道,并大规模管理服务通信的认证、授权和加密。使用Istio,服务通信在默认情况下是安全的,它允许您跨多种协议和运行时一致地实施策略——所有这些都很少或根本不需要应用程序更改。

虽然 Istio 与平台无关,但将其与 Kubernetes(或基础架构)网络策略结合使用,其优势会更大,包括在网络和应用层保护 pod 间或服务间通信的能力。

可观察性

Istio 强大的追踪、监控和日志记录可让您深入了解服务网格部署。通过 Istio 的监控功能,可以真正了解服务性能如何影响上游和下游的功能,而其自定义仪表板可以提供对所有服务性能的可视性,并让您了解该性能如何影响您的其他进程。

Istio 的 Mixer 组件负责策略控制和遥测收集。它提供后端抽象和中介,将 Istio 的其余部分与各个基础架构后端的实现细节隔离开来,并为运维提供对网格和基础架构后端之间所有交互的细粒度控制。

所有这些功能可以让您可以更有效地设置、监控和实施服务上的 SLO。当然,最重要的是,您可以快速有效地检测和修复问题。

平台支持

Istio 是独立于平台的,旨在运行在各种环境中,包括跨云、内部部署、Kubernetes、Mesos 等。您可以在 Kubernetes 上部署 Istio 或具有 Consul 的 Nomad 上部署。Istio 目前支持:

  • 在 Kubernetes 上部署的服务
  • 使用 Consul 注册的服务
  • 在虚拟机上部署的服务

集成和定制

策略执行组件可以扩展和定制,以便与现有的 ACL、日志、监控、配额、审计等方案集成。

架构

Istio 服务网格逻辑上分为数据平面和控制平面。

  • 数据平面由一组以 sidecar 方式部署的智能代理(Envoy)组成。这些代理可以调节和控制微服务及 Mixer 之间所有的网络通信。
  • 控制平面负责管理和配置代理来路由流量。此外控制平面配置 Mixer 以实施策略和收集遥测数据。

下图显示了构成每个面板的不同组件。

istio_arch

Envoy

Istio 使用 Envoy 代理的扩展版本,Envoy 是以 C++ 开发的高性能代理,用于调解服务网格中所有服务的所有入站和出站流量。Envoy 的许多内置功能被 Istio 发扬光大,例如:

  • 动态服务发现
  • 负载均衡
  • TLS 终止
  • HTTP/2 & gRPC 代理
  • 熔断器
  • 健康检查、基于百分比流量拆分的灰度发布
  • 故障注入
  • 丰富的度量指标

Envoy 被部署为 sidecar,和对应服务在同一个 Kubernetes pod 中。这允许 Istio 将大量关于流量行为的信号作为属性提取出来,而这些属性又可以在 Mixer 中用于执行策略决策,并发送给监控系统,以提供整个网格行为的信息。

在 Istio 的默认实现中,Istio 利用 istio-init 初始化容器中的 iptables 指令,对所在 Pod 的流量进行劫持,从而接管 Pod 中应用的通信过程,如此一来,就获得了通信的控制权,控制面的控制目的得以实现。

Sidecar 代理模型还可以将 Istio 的功能添加到现有部署中,而无需重新构建或重写代码。

注入 Sidecar 前后的通信模式变化如图所示。

istio_envoy

熟悉 Kubernetes 的人都知道,在同一个 Pod 内的多个容器之间,网络栈是共享的,而这正是 Sidecar 模式的实现基础。Sidecar 在加入之后,原有的源容器->目标容器的直接通信方式,变成了源容器->Sidecar->Sidecar->目的容器的模式。而 Sidecar 是用来接受控制面组件的操作的,这样一来,就让通信过程中的控制和观察成为可能。

理论上,只要支持 Envoy 的 xDS 协议,其它类似的反向代理软件都可以代替 Envoy。

Mixer

Mixer 是一个独立于平台的组件,负责在服务网格上执行访问控制和使用策略,并从 Envoy 代理和其他服务收集遥测数据。代理提取请求级属性,发送到 Mixer 进行评估。

Mixer 中包括一个灵活的插件模型,使其能够接入到各种主机环境和基础设施后端,从这些细节中抽象出 Envoy 代理和 Istio 管理的服务。

如图所示,Mixer 的简单工作流程如下:

  1. 用户将 Mixer 配置发送到 Kubernetes 中。
  2. Mixer 通过对 Kubernetes 资源的监听,获知配置的变化。
  3. 网格中的服务在每次调用之前,都向 Mixer 发出预检请求,查看调用是否允许执行。在每次调用之后,都发出报告信息,向 Mixer 汇报 在调用过程中产生的监控跟踪数据。

istio_mixer

Pilot

Pilot 为 Envoy sidecar 提供服务发现功能,为智能路由(例如 A/B 测试、金丝雀部署等)和弹性(超时、重试、熔断器等)提供流量管理功能。它将控制流量行为的高级路由规则转换为特定于 Envoy 的配置,并在运行时将它们传播到 sidecar。

Pilot 将平台特定的服务发现机制抽象化并将其合成为符合 Envoy 数据平面 API 的任何 sidecar 都可以使用的标准格式。这种松散耦合使得 Istio 能够在多种环境下运行(例如,Kubernetes、Consul、Nomad),同时保持用于流量管理的相同操作界面。

如图所示,Pilot 的工程流程如下:

  1. 用户通过 kubectl 或 istioctl(或 API)在 Kubernetes 上创建 CRD 资源,对 Istio 控制平面发出指令。
  2. Pilot 监听 CRD 中的 config、rbac、networking 及 authentication 资源,在检测到资源对象的变更之后,针对其中涉及的服务,发出指令给对应服务的 Sidecar。
  3. Sidecar 根据这些指令更新自身配置,根据配置修正通信行为。

istio_pilot

Citadel

Citadel 通过内置身份和凭证管理赋能强大的服务间和最终用户身份验证。可用于升级服务网格中未加密的流量,并为运维人员提供基于服务标识而不是网络控制的强制执行策略的能力。从 0.5 版本开始,Istio 支持基于角色的访问控制,以控制谁可以访问您的服务,而不是基于不稳定的三层或四层网络标识。

Galley

Galley 代表其他的 Istio 控制平面组件,用来验证用户编写的 Istio API 配置。随着时间的推移,Galley 将接管 Istio 获取配置、 处理和分配组件的顶级责任。它将负责将其他的 Istio 组件与从底层平台(例如 Kubernetes)获取用户配置的细节中隔离开来。

设计目标

Istio 的架构设计中有几个关键目标,这些目标对于使系统能够应对大规模流量和高性能地服务处理至关重要。

  • 最大化透明度:若想 Istio 被采纳,应该让运维和开发人员只需付出很少的代价就可以从中受益。为此,Istio 将自身自动注入到服务间所有的网络路径中。Istio 使用 sidecar 代理来捕获流量,并且在尽可能的地方自动编程网络层,以路由流量通过这些代理,而无需对已部署的应用程序代码进行任何改动。在 Kubernetes中,代理被注入到 pod 中,通过编写 iptables 规则来捕获流量。注入 sidecar 代理到 pod 中并且修改路由规则后,Istio 就能够调解所有流量。这个原则也适用于性能。当将 Istio 应用于部署时,运维人员会发现,为提供这些功能而增加的资源开销是很小的。
  • 可扩展性:随着运维人员和开发人员越来越依赖 Istio 提供的功能,系统必然和他们的需求一起成长。虽然我们期望继续自己添加新功能,但是我们预计最大的需求是扩展策略系统,集成其他策略和控制来源,并将网格行为信号传播到其他系统进行分析。策略运行时支持标准扩展机制以便插入到其他服务中。此外,它允许扩展词汇表,以允许基于网格生成的新信号来执行策略。
  • 可移植性:使用 Istio 的生态系统将在很多维度上有差异。Istio 必须能够以最少的代价运行在任何云或预置环境中。将基于 Istio 的服务移植到新环境应该是轻而易举的,而使用 Istio 将一个服务同时部署到多个环境中也是可行的(例如,在多个云上进行冗余部署)。
  • 策略一致性:在服务间的 API 调用中,策略的应用使得可以对网格间行为进行全面的控制,但对于无需在 API 级别表达的资源来说,对资源应用策略也同样重要。例如,将配额应用到 ML 训练任务消耗的 CPU 数量上,比将配额应用到启动这个工作的调用上更为有用。因此,策略系统作为独特的服务来维护,具有自己的 API,而不是将其放到代理/sidecar 中,这容许服务根据需要直接与其集成。

Kubernetes Pipeline

Kubernetes 集群基于 Jenkins 的 CI/CD 流程实践

通过在 Kubernetes 集群上创建并配置 Jenkins Server 实现应用开发管理的 CI/CD 流程,并且利用 Kubernetes-Jenkins-Plugin 实现动态按需扩展 jenkins-slave。

安装 Kubernetes 集群

首先,需要有一个 Kubernetes 集群,本地可以运行 Minikube。

安装 Helm。

安装 Jenkins Server

helm install stable/jenkins --set rbac.install=true

详细的配置可以下载 https://github.com/kubernetes/charts.git 仓库查看并修改。

正常启动后可以看到如下提示,获取 Jenkins 地址和初始密码等。

k8s_pipeline_jenkins01

访问 Jenkins

查看服务

kubectl get svc

k8s_pipeline_jenkins02

浏览器打开 http://NodeIP:NodePort 即可访问。

测试

登录 Jenkins 后,新建一个流水线任务,将以下代码填入“Pipeline script”中,保存后运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
podTemplate(label: 'golang-pod', containers: [
containerTemplate(
name: 'golang',
image: 'registry.cn-hangzhou.aliyuncs.com/spacexnice/golang:1.8.3-docker',
ttyEnabled: true,
command: 'cat'
),
containerTemplate(
name: 'jnlp',
image: 'registry.cn-hangzhou.aliyuncs.com/google-containers/jnlp-slave:alpine',
args: '${computer.jnlpmac} ${computer.name}',
command: ''
)
]
,volumes: [
/*persistentVolumeClaim(mountPath: '/home/jenkins', claimName: 'jenkins', readOnly: false),*/
hostPathVolume(hostPath: '/root/work/jenkins', mountPath: '/home/jenkins'),
hostPathVolume(hostPath: '/var/run/docker.sock', mountPath: '/var/run/docker.sock'),
hostPathVolume(hostPath: '/tmp/', mountPath: '/tmp/'),
])
{
node ('golang-pod') {
container('golang') {
git url: 'https://github.com/spacexnice/blog.git' , branch: 'code'
stage('Build blog project') {
sh("make")
}
}
}
}

同时在服务器上运行 watch kubectl get pods可以看到 Jenkins Server 通过 Kubernetes 启动了相应的 Pod 来执行任务。

k8s_pipeline_jenkins03

k8s_pipeline_jenkins04

k8s_pipeline_jenkins05

任务完成后,相应的 Pod 会被自动回收。

容器网络

容器技术系列分享(五)

容器网络

目录

  • CNM
  • CNI
  • CNM vs CNI
  • 第三方驱动对比

目前关于容器网络接口的配置有两种标准:容器网络模型(CNM)和容器网络接口(CNI)。

Container Network Model(CNM)

CNM 是一个被 Docker 提出的规范,现在已经被 Cisco Contiv, Kuryr, Open Virtual Networking(OVN), Porject Calico, VMware 和 Weave 这些公司和项目所采纳。

k8s_network_cnm01

Libnetwork 是 CNM 的原生实现,它为 Docker Daemon 的网络驱动程序之间提供了接口。网络控制器负责将驱动和一个网络进行对接。每个驱动程序负责管理它所拥有的网络以及为该网络提供各种服务,例如 IPAM 等。由多个驱动支撑的多个网络可以同时并存。网络驱动可以按提供方被划分为原生驱动(libnetwork 内置的或 Docker 支持的)或者远程驱动(第三方插件)。原生驱动包括 none、bridge、overlay 以及 macvlan。

k8s_network_cnm02

  • Network Sandbox:一个容器内部的网络栈
  • Endpoint:一个通常成对出现的网络接口。一端在容器网络内,另一端在网格内。一个 Endpoint 可以加入一个网络。一个容器可以有多个 Endpoint。
  • Network:一个 Endpoint 的集合,该集合内的所有 Endpoint 可以互联互通。

最后,CNM 还支持标签(labels),label 是以 key-value 定义的元数据,用户可以通过定义 label 这样的元数据来自定义 libnetwork 和驱动的行为。

Container Network Interface(CNI)

CNI 是由 CoreOS 提出的一个容器网络规范,已采纳该规范的包括 Apache Mesos, Cloud Foundry, Kubernetes, Kurma 和 rkt。另外 Contiv Networking,Project Calico 和 Weave 这些项目也为 CNI 提供插件。

k8s_network_cni01

CNI 的规范比较小巧,它规定了一个容器 runtime 和网络插件之间的简单契约,这个契约通过 JSON 的语法定义了 CNI 插件所需要提供的输入和输出。

一个容器可以被加入到被不同插件所驱动的多个网络之中,一个网络有自己对应的插件和唯一的名称。CNI 插件负责为容器配置网络,包括两个基本接口:

配置网络

AddNetwork(net NetworkConfig, rt RuntimeConf) (types.Result, error)

清理网络

DelNetwork(net NetworkConfig, rt RuntimeConf) error

CNM vs CNI

CNM 和 CNI 两种方案都使用了驱动模型或者插件模型来为容器创建网络栈,这样的设计使得用户可以自由选择,两者都支持多个网络驱动被同事使用,也允许容器加入一个或多个网络,两者也都允许容器 runtime 在它自己的命名空间中启动网络。

这种模块化驱动的方式可以说对运维人员更有吸引力,因为运维人员可以比较灵活的选择适合现有模式的驱动。两种方案都提供了独立的扩展点,也就是插件的接口,这使得网络驱动可以创建、配置和连接网络,也使得 IPAM 可以配置、发现和管理 IP 地址。这种分离让编排变得容易。

CNM 模式下的网络驱动不能访问容器的网络命名空间。这样做的好处是 libnetwork 可以为冲突解决提供仲裁。比如两个独立的网络驱动提供同样的静态路由配置,但是却指向不同的下一跳IP地址。与此不同,CNI允许驱动访问容器的网络命名空间。CNI正在研究在类似情况下如何提供仲裁。

CNI支持与第三方 IPAM 的集成,可以用于任何容器 runtime。CNM 从设计上就仅仅支持 Docker。由于 CNI 简单的设计,许多人认为编写 CNI 插件会比编写 CNM 插件来得简单。

这些模型增进了系统的模块化,增加了用户的选择。也促进了第三方网络插件以创新来提供更高级的网络功能。

第三方驱动对比

k8s_network02

除了上图 5 种网络驱动,其实还有很多,Kubernetes 官方介绍了大概 23 种网络插件。接下来详细对比其中性能较好的几种。

方案 Calico Contiv macvlan
优点 纯三层的数据中心解决方案(不依赖overlay),对 OpenStack、Kubernetes、AWS、GCE都比较友好。该方案集成简单。 能够和非容器环境兼容协作,不依赖物理网络具体细节,支持 Policy、ACI、QoS租户,支持物理网卡 sriov 和 offload。该方案配置比较复杂。 Docker 原生,性能衰减少,可控性高,隔离性好。
缺点 操作管理比较复杂,需要定制开发。 集成配置较复杂,相关资料少,学习成本较高,需要定制开发。 集群规模很大时,存在 ARP 广播风暴和交换机 MAC 表超限风险,另外需要自研 QoS 功能。
结论 Calico 基于 BGP 协议的 Overlay SDN 解决方案,企业生产环境如果可以开启 BGP 协议,可以考虑 Calico BGP 方案。 功能强型好,对 CNI 和 CNM 模型支持性好,集群配置较复杂,学习研究成本较高,可以考虑此方案。 初期较少的开发量即可上线(之后需要自研 QoS),通过合理的设计容量规划来规避相关风险时,可以考虑此方案。

Kubernetes 基础

容器技术系列分享(四)

Kubernetes 基础

目录

  • Kubernetes 是什么
  • 为什么要用 Kubernetes
  • 一个简单的例子
  • Kubernetes 基本概念和术语
    • Master
    • Node
    • Pod
    • Label
    • Replication Controller
    • Deployment
    • Horizontal Pod Autoscaler
    • StatefulSet
    • Service
    • Volume
    • Persisten Volume
    • Namespace
    • Annotation

Kubernetes 是什么

Kubernetes 是一个基于容器技术的分布式架构方案,它是谷歌内部系统 Borg 的一个开源版本。

Kubernetes是一个开放的开发平台,它不局限于任何一种语言,任何服务都可以映射为 Kubernetes 的 Service,并通过标准的 TCP 通信协议进行交互。

Kubernetes 具有完备的集群管理能力,包括多层次的安全防护和准入机制、多租户应用支撑能力、透明的服务注册和服务发现机制、内建智能负载均衡器、强大的故障发现和自我修复能力、服务滚动升级和在线扩容能力、可扩展的资源自动调度机制,以及多粒度的资源配额管理能力。同时,Kubernetes 提供了完善的管理工具,这些工具涵盖了包括开发、部署测试、运维监控在内的多个环节。

为什么要用 Kubernetes

Docker 容器化技术当前已经被很多公司所采用,其从单机走向集群已成为必然,而云计算的蓬勃发展正在加速这一过程。Kubernetes 作为当前唯一被业界广泛认可和看好的 Docker 分布式系统解决方案,可以遇见,在未来会有大量的新系统选择它。

使用 Kubernetes 可以收获哪些好处?

  • 首先,最直接的感受就是可以“轻装上阵”地开发复杂系统了。以前动不动就需要十几个人而且团队里需要不少技术达人一起分工协助才能设计实现的和运维的分布式系统,在采用 Kubernetes 解决方案后,只需要一个精悍的小团队就能轻松应付。
  • 其次,使用 Kubernetes 就是在全面拥抱微服务架构。微服务架构的核心是将一个巨大的单体应用分解为许多小的互相连接的微服务,一个微服务背后可能有多个实例副本在支撑,副本的数量可能会随着系统的负荷变化而进行调整,内嵌的负载均衡器在这里发挥了重要作用。微服务架构使得每个服务都可以由专门的开发团队来开发,开发者可以自由选择开发技术,这对于大规模团队来说很有价值,另外每个微服务独立开发、升级、扩展,因此系统具备很高的稳定性和快速迭代能力。谷歌将微服务架构的基础设施直接打包到 Kubernetes 解决方案中,让我们有机会直接应用微服务架构解决复杂业务系统的架构问题。
  • 然后,我们的系统可以随时随地整体“搬迁”到别的机房或公有云上。Kubernetes 最初的目标就是运行在谷歌自家的公有云 GCE 中,未来会支持更多的公有云及基于 OpenStack 的私有云。同时,在 Kubernetes 的架构方案中,底层网络的细节完全被屏蔽,基于服务的 ClusterIP 甚至都无需我们改变运行期的配置文件,就能将系统从物理环境中无缝迁移到公有云中,或者在服务高峰期将部分服务对应的 Pod 副本放入公有云中以提升系统的吞吐量。
  • 最后,Kubernetes 系统架构具备了超强的横向扩容能力。对于互联网公司来说,用户规模就等价于资产,谁拥有更多的用户,谁就能在竞争中胜出,因此超强的横向扩容能力是互联网业务系统的关键指标之一。不用修改代码,一个 Kubernetes 集群即可从只包含几个 Node 的小集群平滑扩展到拥有上百个 Node 的大规模集群,我们利用 Kubernetes 提供的工具,甚至可以在线完成集群扩容。只要我们的微服务设计的好,结合硬件或者公有云资源的线性增加,系统就能够承受大量用户并发访问所带来的巨大压力。

一个简单的例子

这是一个简单的 Java Web 应用,运行在 Tomcat 里。JSP 页面通过 JDBC 直接访问 MySQL 数据库并展示数据。

此应用需要启动两个容器: Web App 容器和 MySQL 容器,并且 Web App 容器需要访问 MySQL 容器。

启动 MySQL 应用

首先为 MySQL 服务创建一个 RC 定义文件: mysql-rc.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: v1
kind: ReplicationController
metadata:
name: mysql
spec:
replicas: 1
selector:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
value: "123456"

创建好 mysql-rc.yaml 文件后,将它发布到 Kubernetes 集群中:

kubectl create -f mysql-rc.yaml

接下来,我们用 kubectl 命令查看刚刚创建的 RC:

kubectl get rc

查看 Pod 的创建情况:

k8s01_javaweb01

我们通过 docker ps 命令查看正在运行的容器,发现提供 MySQL 服务的 Pod 容器以及创建并正常运行了。

创建 MySQL 服务

为 MySQL 服务创建一个 Service 定义文件 mysql-svc.yaml:

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
ports:
- port: 3306
selector:
app: mysql

运行 kubectl 命令,创建 service:

kubectl create -f mysql-svc.yaml

运行 kubectl 命令,查看刚刚创建的 service:

kubectl get svc

k8s01_javaweb02

注意到这里分配了一个 CLUSTER-IP,这是一个虚拟地址,Kubernetes 集群中其他新创建的 Pod 就可以通过 Service 的 Cluster IP + Port 来连接它了。

启动 Tomcat 应用

我们用和启动 MySQL 同样的步骤启动 Tomcat。首先,创建对应的 RC 文件 myweb-rc.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: ReplicationController
metadata:
name: myweb
spec:
replicas: 2
selector:
app: myweb
template:
metadata:
labels:
app: myweb
spec:
containers:
- name: myweb
image: java-web:v1
ports:
- containerPort: 8080

kubectl create -f myweb-rc.yaml

kubectl get rc

kubectl get pods

k8s01_javaweb03

创建 Tomcat 服务

为 Tomcat 服务创建一个 Service 定义文件 myweb-svc.yaml:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: myweb
spec:
type: NodePort
ports:
- port: 8080
nodePort: 30003
selector:
app: myweb

kubectl create -f myweb-svc.yaml

kubectl get service

k8s01_javaweb04

Kubernetes 基本概念和术语

Kubernetes 中的大部分概念如 Node、Pod、Replication Controller、Service 等都可以看作一种 “资源对象”,几乎所有的资源对象都可以通过 Kubernetes 提供的 kubectl 工具(或者 API 编程调用)执行增、删、改、查等操作并将其保存在 Etcd 中持久化存储。从这个角度看,Kubernetes 其实是一个高度自动化的资源控制系统,它通过跟踪对比 Etcd 库里保存的“资源期望状态”与当前环境中的“实际资源状态”的差异来实现自动控制和自动纠错的高级功能。

在介绍资源对象之前,我们先了解一下 Kubernetes 集群的两种管理角色:Master 和 Node。

Master

Kubernetes 里的 Master 指的是集群控制节点,每个 Kubernetes 集群里需要有一个 Master 节点来负责整个集群的管理和控制,基本上 Kubernetes 的所有控制命令都发给它,它来负责具体的执行过程。Master 节点通常会占据一个独立的服务器(高可用部署建议用3台服务器),其主要原因是它太重要了,是整个集群的“首脑”,如果宕机或不可用,那么对集群内容器应用的管理都将失效。

Master 节点上运行着以下一组关键进程:

  • Kubernetes API Server (kube-apiserver):提供了 HTTP Rest 接口的关键服务进程,是 Kubernetes 里所有资源的增、删、改、查等操作的唯一入口,也是集群控制的入口进程。
  • Kubernetes Controller Manager (kube-controller-manager):Kubernetes 里所有资源对象的自动化控制中心。
  • Kubernetes Scheduler (kube-scheduler):负责资源调度(Pod 调度)的进程。

另外,Kubernetes 集群还需要一组 Etcd 服务,因为 Kubernetes 里所有资源对象的数据都保存在 Etcd 中。

Node

除了 Master, Kubernetes 集群中的其他机器被称为 Node 节点。与 Master 一样,Node 节点可以是一台物理主机,也可以是一台虚拟机。Node 节点才是 Kubernetes 集群中的工作负载节点,每个 Node 都会被 Master 分配一些工作负载(Docker 容器),当某个 Node 宕机时,其上的工作负载会被 Master 自动转移到其他节点上去。

每个 Node 节点上运行以下一组关键进程:

  • kubelet:负责 Pod 对应的容器的创建、启停等任务,同时与 Master 节点密切协作,实现集群管理的基本功能。
  • kube-proxy:实现 Kubernetes Service 的通信与负载均衡机制的重要组件。
  • Docker Engine(docker):Docker 引擎,负责本机的容器创建和管理工作。

Node 节点可以在运行期间动态增加到 Kubernetes 集群中,前提是这个节点上已经正确安装、配置和启动了上述关键进程,在默认情况下 kubelet 会向 Master 注册自己,这也是 Kubernetes 推荐的 Node 管理方式。一旦 Node 被纳入集群管理范围,kubelet 进程就会定时向 Master 节点汇报自身的情报,例如操作系统、Docker 版本、机器的 CPU 和内存情况,以及当前有哪些 Pod 在运行等,这样 Master 可以获知每个 Node 的资源使用情况,并实现高效均衡的资源调度策略。而某个 Node 超过指定时间不上报信息时,会被 Master 判定为 “失联”,Node 的状态被标记为不可用(Not Ready),随后 Master 会触发工作负载转移的自动流程。

kubectl get nodes

kubectl describe node k8s-node-1

上述命令会展示 Node 的如下关键信息:

  • Node 基本信息:名称、标签、创建时间等。
  • Node 当前的运行状态,Node 启动后会做一系列的自检工作,比如磁盘是否满了,如果满了就标注 OutOfDisk=True,否则继续检查内存是否不足(如果内存不足,就标注 MemoryPressure=True),最后一切正常,就设置为 Ready 状态(Ready=True),该状态表示 Node 处于健康状态, Master 将可以在其上调度新的任务了(如启动 Pod)。
  • Node 的主机地址与主机名
  • Node 上的资源总量:描述 Node 可用的系统资源,包括 CPU、内存、最大可调度 Pod 数量等。
  • Node 可分配资源量:描述 Node 当前可用于分配的资源量。
  • 主机系统信息:包括主机的唯一标识 UUID、Linux Kernel 版本号、操作系统类型与版本、Kubernetes 版本号、kubelet 与 kube-proxy 版本号等。
  • 当前正在运行的 Pod 列表概要信息。
  • 已分配的资源使用概要信息,例如资源申请的最低、最大允许使用量占系统总量的百分比。
  • Node 相关的 Event 信息。
Pod

Pod 是 Kubernetes 的最重要也最基本的概念。每个 Pod 都有一个特殊的被称为“根容器” 的 Pause 容器。Pause 容器对应的镜像属于 Kubernetes 平台的一部分,除了 Pause 容器,每个 Pod 还包含一个或多个紧密相关的用户业务容器。

k8s01_pod01

为什么 Kubernetes 会设计出一个全新的 Pod 概念并且 Pod 有这样特殊的组成结构?

  • 原因之一:在一组容器作为一个单元的情况下,我们难以对“整体”简单地进行判断及有效地进行行动。比如,一个容器死亡了,此时算是整体死亡么?是 N/M 的死亡率么?引入业务无关并且不易死亡的 Pause 容器作为 Pod 的根容器,以它的状态代表整个容器组的状态,就简单、巧妙地解决了这个难题。
  • 原因之二:Pod 里的多个业务容器共享 Pause 容器的 IP,共享 Pause 容器挂载的 Volume,这样既简化了密切关联的业务容器之间的通信问题,也很好地解决了它们之间的文件共享问题。

Kubernetes 为每个 Pod 都分配了唯一的 IP 地址,称之为 PodIP,一个 Pod 里的多个容器共享 PodIP 地址。Pod 的 IP 加上 Pod 里的容器端口(containerPort),就组成了一个概念————Endpoint,它代表此 Pod 里的一个服务进程的对外通信地址。一个 Pod 也存在着具有多个 Endpoint 的情况。

k8s01_pod02

Label

Label 是 Kubernetes 系统中另外一个核心概念。一个 Label 是一个 key=value 的键值对,其中 Key 与 value 由用户自己指定。Label 可以附加到各种资源上,例如 Node、Pod、Service、RC 等,一个资源对象可以定义任意数量的 Label,同一个 Label 也可以被添加到任意数量的资源对象上去,Label 通常在资源对象定义时确定,也可以在创建对象后动态添加或者删除。

我们可以通过给指定的资源对象捆绑一个或多个不同的 Label 来实现多维度的资源分组管理功能,以便于灵活、方便地进行资源分配、调度、配置、部署等管理工作。例如:部署不同版本的应用到不同的环境中;或者监控和分析应用(日志记录、监控、告警)等。一些常用的 Label 示例如下:

  • 版本标签:”release”:”stable”,”release”:”canary”…
  • 环境标签:”environment”:”dev”,”environment”:”production”…
  • 架构标签:”tier”:”frontend”,”tier”:”backend”…
  • 分区标签:”partition”:”customerA”,”partition”:”customerB”…
  • 质量管控标签:”track”:”daily”,”track”:”weekly”…

给某个资源对象定义一个 Label,就相当于给它打了一个标签,随后可以通过 Label Selector(标签选择器)查询和筛选拥有某些 Label 的资源对象,Kubernetes 通过这种方式实现了类似 SQL 的简单又通用的查询机制。

当前有两种 Label Selector 的表达式:基于等式的(Equality-based)和基于集合的(Set-based),前者采用“等式类”的表达式匹配标签,下面是一些具体的例子:

  • name = redis-slave:匹配所有具有标签 name=redis-slave 的资源对象。
  • env != production:匹配所有不具有标签 env=production 的资源对象,比如 env=test 就满足。

使用集合操作的表达式匹配标签例子:

  • name in (redis-master, redis-slave):匹配所有具有标签 name=redis-master 或者 name=redis-slave 的资源对象。
  • name not in (php-frontend):匹配所有不具有标签 name=php-frontend 的资源对象。

可以通过多个 Label Selector 表达式的组合实现复杂的条件选择,多个表达式之间用“,”进行分割即可,几个条件之间是 “AND” 的关系,即同时满足多个条件。

新出现的管理对象如 Deployment、ReplicaSet、DaemonSet 和 Job 则可以在 Selector 中使用基于集合的筛选条件定义,例如:

1
2
3
4
5
6
selector:
matchLabels:
app: myweb
matchExpressions:
- {key: tier, operator: In, values: [frontend]}
- {key: environment, operator: NotIn, values: [dev]}

matchLabels 用于定义一组 Label,与直接写在 Selector 中作用相同;matchExpressions 用于定义一组基于集合的筛选条件,可用的条件运算符包括:In、NotIn、Exists 和 DoesNotExist。

如果同时设置了 matchLabels 和 matchExpressions,则两组条件为 “AND” 关系,即所有条件需要同时满足才能完成 Selector 的筛选。

Label Selector 在 Kubernetes 中的重要使用场景有以下几处:

  • kube-controller 进程通过资源对象 RC 上定义的 Label Selector 来筛选要监控的 Pod 副本的数量,从而实现 Pod 副本的数量始终符合预期设定的全自动控制流程。
  • kube-proxy 进程通过 Service 的 Label Selector 来选择对应的 Pod,自动建立起每个 Service 到对应 Pod 的请求转发路由表,从而实现 Service 的智能负载均衡机制。
  • 通过对某些 Node 定义特定的 Label,并且在 Pod 定义文件中使用 NodeSelector 这种标签调度策略,kube-scheduler 进程可以实现 Pod “定向调度” 的特性。
Replication Controller

RC 是 Kubernetes 系统中的核心概念之一,简单来说,它其实是定义了一个期望的场景,即声明某种 Pod 的副本数量在任意时刻都符合某个预期值,所以 RC 的定义包括如下部分:

  • Pod 期待的副本数(replicas)
  • 用于筛选目标 Pod 的 Label Selector
  • 当 Pod 的副本数量小于预期数量时,用于创建新 Pod 的 Pod 模版(template)

下面是一个完整的 RC 定义的例子,即确保拥有 tier=frontend 标签的这个 Pod(运行 Tomcat 容器)在整个 Kubernetes 集群中始终只有一个副本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: v1
kind: ReplicationController
metadata:
name: frontend
spec:
replicas: 1
selector:
tier: frontend
template:
metadata:
labels:
app: app-demo
tier: frontend
spec:
containers:
- name: tomcact-demo
image: tomcat
imagePullPolicy: IfNotPresent
env:
- name: GET_HOSTS_FROM
value: dns
ports:
- containerPort: 80

当我们定义了一个 RC 并提交到 Kubernetes 集群中以后,Master 节点上的 Controller Manager 组件就得到通知,定期巡检系统中当前存活的目标 Pod,并确保目标 Pod 实例的数量刚好等于此 RC 的期望值,如果有过多的 Pod 副本在运行,系统就会停掉一些 Pod,否则系统就会再自动创建一些 Pod。通过 RC,Kubernetes 实现了用户应用集群的高可用性,并且大大减少了系统管理员在传统 IT 环境中需要完成的许多手工运维工作(如主机监控脚本、应用监控脚本、故障恢复脚本等)。

在 RC 运行时,我们可以通过修改 RC 的副本数量,来实现 Pod 的动态缩放(Scaling)功能:

kubect scale rc frontend --replicas=3

在 Kubernetes v1.2时,Replication Controller升级成了 Replica Set,新功能支持集合的 Label selector(Set-based selector)。不过一般很少单独使用Replica Set,它主要被 Deployment 这个更高层的资源对象所使用,从而形成一整套 Pod 创建、删除、更新的编排机制。下面是等价于前面 RC 例子的 Replica Set 的定义(省去了 Pod 模版部分的内容):

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: extensions/v1beta1
kind: ReplicaSet
metadata:
name: frontend
spec:
selector:
matchLabels:
tier: frontend
matchExpressions:
- {key: tier, operator: In, values: [frontend]}
template:
......

最后我们总结一下关于 RC(Replica Set)的一些特性与作用:

  • 在大多数情况下,我们通过定义一个 RC 实现 Pod 的创建过程及副本数量的自动控制。
  • RC 里包括完整的 Pod 定义模版。
  • RC 通过 Label Selector 机制实现对 Pod 副本的自动控制。
  • 通过改变 RC 里的 Pod 副本数量,可以实现 Pod 的扩容或缩容功能。
  • 通过改变 RC 里 Pod 模版中的镜像版本,可以实现 Pod 的滚动升级功能。
Deployment

Deployment 是 Kubernetes v1.2 引入的新概念,引入的目的是为了更好地解决 Pod 的编排问题。为此,Deployment 在内部使用了 Replica Set 来实现目的,无论从 Deployment 的作用与目的、它的 YAML 定义,还是从它的具体命令行操作来看,我们都可以把它看作 RC 的一次升级,两者的相似度超过 90%。

Deployment 相对于 RC 的一个最大升级是可以随时知道当前 Pod “部署” 的进度。实际上由于一个 Pod 的创建、调度、绑定节点及在目标 Node 上启动对应的容器这一完整过程需要一定的时间,所以我们期待系统启动 N 个 Pod 副本的目标状态,实际上是一个连续变化的“部署过程”导致的最终状态。

Deployment 的典型使用场景有以下几个:

  • 创建一个 Deployment 对象来生成对应的 Replica Set 并完成 Pod 副本的创建过程。
  • 检查 Deployment 的状态来看部署动作是否完成(Pod 副本的数量是否达到预期的值)。
  • 更新 Deployment 以创建新的 Pod (比如镜像升级)。
  • 如果当前 Deployment 不稳定,则回滚到一个早先的 Deployment 版本。
  • 暂停 Deployment 以便于一次性修改多个 PodTemplateSpec 的配置项,之后再恢复 Deployment,进行新的发布。
  • 扩展 Deployment 以应对高负载。
  • 查看 Deployment 的状态,以此作为发布是否成功的指标。
  • 清理不再需要的旧版本 ReplicaSets。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: frontend
spec:
replicas: 1
selector:
matchLabels:
tier: frontend
matchExpressions:
- {key: tier, operator: In, values: [frontend]}
template:
metadata:
labels:
app: app-demo
tier: frontend
spec:
containers:
- name: tomcat-demo
image: tomcat
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080

创建 Deployment:

kubectl create -f tomcat-deployment.yaml

查看 Deployment 信息:

kubectl get deployments

k8s01_deployment

对上述输出中涉及的数量解释如下:

  • NAME: Deployment 的名字。
  • READY: Replica 状态值,包含已成功运行的 Pod 数量,和要求的值。
  • UP-TO-DATE: 最新版本的 Pod 的副本数量,用于指示在滚动升级的过程中,有多少个副本已经成功升级。
  • AVAILABLE: 当前集群中可用的 Pod 副本数量,即集群中当前存活的 Pod 数量。

查看 Replica Set,可以看到它的命名与 Deployment 的名字有关系:

kubectl get rs

查看创建的 Pod,我嗯发现 Pod 的命名以 Deployment 对应的 Replica Set 的名字为前缀,这种命名很清晰地表明了一个 Replica Set 创建了哪些 Pod,对于 Pod 滚动升级这种复杂的过程来说,很容易排查错误:

kubectl get pods

k8s01_rs

Pod 的管理对象,除了 RC 和 Deployment,还包括 ReplicaSet、DaemonSet、StatefulSet、Job 等,分别用于不同的应用场景中。

Horizontal Pod Autoscaler

前面我们提到过,通过手动执行 kubectl scale 命令,可以实现 Pod 扩容或缩容。而 Kubernetes v1.1 版本中发布了一个新特性————Horizontal Pod Autoscaling(Pod 横向自动扩容,简称 HPA)。

HPA 与之前的 RC、Deployment 一样,也属于一种 Kubernetes 资源对象。通过追踪分析 RC 控制的所有目标 Pod 的负载变化情况,来确定是否需要针对性地调整目标 Pod 的副本数。当前,HPA 可以有以下两种方式作为 Pod 负载的度量指标:

  • CPUUtilizationPercentage
  • 应用程序自定义的度量指标,比如服务在每秒内的相应的请求数(TPS 或 QPS)。

CPUUtilizationPercentage 是一个算数平均值,即目标 Pod 所有副本自身的 CPU 利用率的平均值。一个 Pod 自身的 CPU 利用率是该 Pod 当前 CPU 的使用量除以它的 Pod Request 的值,比如我们定义一个 Pod 的 Pod Request 为 0.4,而当前 Pod 的 CPU 使用量为 0.2,则它的 CPU 使用率为50%,如此一来,我们就可以算出一个 RC 控制的所有 Pod 副本的 CPU 利用率的算术平均值了。如果某一时刻 CPUUtilizationPercentage 的值超过 80%,则意味着当前的 Pod 副本数很可能不足以支撑接下来更多的请求,需要进行动态扩容,而当请求高峰时段过去后, Pod 的 CPU 利用率又会降下来,此时对应的 Pod 副本数应该自动减少到一个合理的水平。

下面是 HPA 定义的一个具体例子:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
name: php-apache
namespace: default
spec:
maxReplicas: 10
minReplicas: 1
scaleTargerRes:
kind: Deployment
name: php-apache
targerCPUUtilizationPercentage: 90
StatefulSet

在 Kubernetes 系统中,Pod 的管理对象 RC、Deployment、DaemonSet 和 Job 都是面向无状态的服务。但现实中有很多服务是有状态的,特别是一些复杂的中间件集群,例如 MySQL 集群、MongoDB 集群、ZooKeeper 集群等,这些应用集群有以下一些共同点:

  • 每个节点都有固定的身份 ID,通过这个 ID,集群中的成员可以互相发现并且通信。
  • 集群的规模是比较固定的,集群规模不能随意变动。
  • 集群里的每个节点都是有状态的,通常会持久化数据到永久存储中。
  • 如果磁盘损坏,则集群里的某个节点无法正常运行,集群功能受损。

如果用 RC/Deployment 控制 Pod 副本数的方式来实现上述有状态的集群,我们会发现第 1 点是无法满足的,因为 Pod 的名字是随机产生的,Pod 的 IP 地址也是在运行期才确定且可能有变动的,我们实现无法为每个 Pod 确定唯一不变的 ID。另外,为了能够在其他节点上恢复某个失败的节点,这种集群中的 Pod 需要挂接某种共享存储,为了解决这个问题,Kubernetes 从 v1.4 版本开始引入了 PetSet 这个新的资源对象,并且在 v1.5 版本是更名为 StatefulSet,StatefulSet 从本质上来说,可以看作 Deployment/RC 的一个特殊变种,它有如下一些特性:

  • StatefulSet 里的每个 Pod 都有稳定、唯一的网络标识,可以用来发现集群内的其他成员。假设 StatefulSet 的名字叫 kafka,那么第一个 Pod 叫 kafka-0,第二个叫 kafka-1,以此类推。
  • StatefulSet 控制的 Pod 副本的启停顺序是受控的,操作第 n 个 Pod 时,前 n-1 个 Pod 已经是运行且准备好的状态。
  • StatefulSet 里的 Pod 采用稳定的持久化存储卷,通过 PV/PVC 来实现,删除 Pod 时默认不会删除与 StatefulSet 相关的存储卷(为了保证数据的安全)。

StatefulSet 除了要与 PV 卷捆绑使用以存储 Pod 的状态数据,还要与 Headless Service 配合使用,即在每个 StatefulSet 的定义中要声明它属于哪个 Headless Service。Headless Service 与普通 Service 的关键区别在于,它没有 Cluster IP,如果解析 Headless Service 的 DNS 域名,则返回的是该 Service 对应的全部 Pod 的 Endpoint 列表。StatefulSet 在 Headless Service 的基础上又为 StatefulSet 控制的每个 Pod 实例创建了一个 DNS 域名,这个域名的格式为:$(podname).$(headless service name)

比如一个 3 节点的 Kafka 的 StatefulSet 集群对应的 Headless Service 的名字为 kafka,则 StatefulSet 里面的 3 个 Pod 的 DNS 名称分别为 kafka-0.kafka、kafka-1.kafka、kafka-2.kafka,这些 DNS 名称可以直接在集群的配置文件中固定下来。

Service

Service 也是 Kubernetes 里的最核心的资源对象之一,Kubernetes 里的每个 Service 其实就是我们经常提起的微服务架构中的一个“微服务”,之前提到的 Pod、RC 等资源对象都是为它服务的。

k8s01_service

从图中可以看到,Kubernetes 的 Service 定义了一个服务的访问入口地址,前端的应用(Pod)通过这个入口地址访问其背后的一组由 Pod 副本组成的集群实例,Service 与其后端 Pod 副本集群之间则是通过 Label Selector 来实现“无缝对接”的。而 RC 的作用实际上是保证 Service 的服务能力和服务质量始终处于预期的标准。

通过分析、识别并建模系统中的所有服务为微服务————Kubernetes Service,最终我们的系统由多个提供不同业务能力而又彼此独立的微服务单元所组成,服务之间通过 TCP/IP 进行通信,从而形成了强大而又灵活的弹性网格,拥有了强大的分布式能力、弹性扩展能力、容错能力,与此同时,我们的程序架构也变得简单和直观许多。

k8s01_microService

Kubernetes集群中,运行在每个 Node 上的 kube-proxy 进程其实就是一个智能的软件负载均衡器,它负责把对 Service 的请求转发到后端的某个 Pod 实例上,并在内部实现服务的负载均衡与会话保持机制。但 Service 不是共用一个负载均衡器的 IP 地址,而是每个 Service 分配了一个全局唯一的虚拟 IP 地址,这个虚拟 IP 被称为 ClusterIP。这样一来,每个服务就变成了具备唯一 IP 地址的“通信节点”,服务调用就变成了最基础的 TCP 网络通信问题。

我们知道,Pod 的 Endpoint 地址会随着 Pod 的销毁和重新创建而发生改变,因为新 Pod 的 IP 地址与之前旧 Pod 的不同。而 Service 一旦被创建,Kubernetes 就会自动为它分配一个可用的 Cluster IP,而且在 Service 的整个生命周期内,它的 Cluster IP 不会发生改变。于是,服务发现这个棘手的问题在 Kubernetes的架构里也得以轻松解决:只要用 Service 的 Name 与 Service 的 Cluster IP 地址做一个 DNS 域名映射即可完美解决问题。

Volume

Volume 是 Pod 中能够被多个容器访问的共享目录。Kubernetes 的 Volume 概念、用途和目的与 Docker 的 Volume 比较类似,但两者不能等价。首先,Kubernetes 中的 Volume 定义在 Pod 上,然后被一个 Pod 里的多个容器挂载到具体的文件目录下;其次,Kubernetes中的 Volume 与 Pod 的生命周期相同,但与容器的生命周期不相关,当容器终止或者重启时,Volume 中的数据也不会丢失。最后,Kubernetes 支持多种类型的 Volume,例如 GlusterFS、Ceph 等先进的分布式文件系统。

Volume 的使用也比较简单,在大多数情况下,我们先在 Pod 上声明一个 Volume,然后在容器里引用该 Volume 并 Mount 到容器里的某个目录上。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
......
template:
metadata:
labels:
app: app-demo
tier: frontend
spec:
volumes:
- name: datavol
emptyDir: {}
containers:
- name: tomcat-demo
image: tomcat
volumeMounts:
- mountPath: /mydata-data
name: datavol
imagePullPolicy: IfNotPresent
......

除了可以让一个 Pod 里的多个容器共享文件、让容器的数据写到宿主机的磁盘上或者写文件到网络存储中,Kubernetes 的 Volume 还扩展出了一种非常有实用价值的功能,即容器配置文件集中化定义与管理,这是通过 ConfigMap 这个资源对象来实现的。

Kubernetes 提供了非常丰富的Volume 类型,这里说明两个最基础的:

emptyDir

一个 emptyDir Volume 是在 Pod 分配到 Node 时创建的。从它的名字就可以看出,它的初始内容为空,并且无须指定宿主机上对应的目录文件,因为这是 Kubernetes 自动分配的一个目录,当 Pod 从 Node 上移除时,emptyDir 中的数据也会被永久删除。emptyDir 的一些用途如下:

  • 临时空间,例如用于某些应用程序运行时所需的临时目录,且无须永久保留。
  • 长时间任务的中间过程 CheckPoint 的临时保存目录。
  • 一个容器需要从另一个容器中获取数据的目录(多容器共享目录)

hostPath

hostPath 为在 Pod 上挂载宿主机上的文件或目录,它通常可以用于以下几方面:

  • 容器应用程序生成的日志文件需要永久保存时,可以使用宿主机的高速文件系统进行存储。
  • 需要访问宿主机上 Docker 引擎内部数据结构的容器应用时,可以通过定义 hostPath 为宿主机 /var/lib/docker 目录,使容器内部应用可以直接访问 Docker 的文件系统。

在使用这种类型的 Volume 时,需要注意以下几点:

  • 在不同的 Node 上具有相同配置的 Pod 可能会因为宿主机上的目录和文件不同而导致对 Volume 上目录和文件的访问结果不一致。
  • 如果使用了资源配额管理,则 Kubernetes 无法将 hostPath 在宿主机上使用的资源纳入管理。
Persistent Volume

前面说到的 Volume 是定义在 Pod 上的,属于“计算资源”的一部分。而实际上,“网络存储”是相对独立于“计算资源”而存在的一种实体资源。比如在使用虚拟机的情况下,我们通常会先定义一个网络存储,然后从中划出一个“网盘”并挂载到虚拟机上。Persistent Volume(简称 PV)和与之相关联的 Persistent Volume Claim(简称 PVC)也起到了类似的作用。

PV 可以理解成 Kubernetes 集群中的某个网络存储中对应的一块存储,它与 Volume 很类似,但有以下区别:

  • PV 只能是网络存储,不属于任何 Node,但可以在每个 Node 上访问。
  • PV 并不是定义在 Pod 上的,而是独立于 Pod 之外定义。
  • PV 目前支持的类型包括:NFS、iSCSI、RBD、CephFS、Cinder、GlusterFS 等,还有各家的公有云存储。

下面给出了 NFS 类型 PV 的一个定义文件,声明了需要 5Gi 的存储空间:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv0003
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
nfs:
path: /somepath
server: 172.17.0.2

比较重要的是 PV 的 accessModes 属性,目前有以下类型:

  • ReadWriteOnce:读写权限、并且只能被单个 Node 挂载。
  • ReadOnlyMany:只读权限、允许被多个 Node 挂载。
  • ReadWriteMany:读写权限、允许被多个 Node 挂载。

如果某个 Pod 向申请某种类型的 PV,则首先需要定义一个 PersistentVolumeClaim(PVC)对象:

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: PersistentVolumeClain
metadata:
name: myclaim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 8Gi

然后,在 Pod 的 Volume 定义中引用上述 PVC 即可:

1
2
3
4
volumes:
- name: mypd
persistentVolumeClaim:
claimName: myclaim

最后,我们说说 PV 的状态,PV 是有状态的对象,它有以下几种状态:

  • Available: 空闲状态。
  • Bound: 已经绑定到某个 PVC 上。
  • Released:对应的 PVC 已经删除,但资源还没有被集群收回。
  • Failed:PV 自动回收失败。
Namespace

Namespace(命名空间)是 Kubernetes 系统中的另一个非常重要的概念,Namespace 在很多情况下用于实现多租户的资源隔离。Namespace 通过将集群内部的资源对象“分配”到不同的 Namespace 中,形成逻辑上分组的不同项目、小组、或用户组,便于不同的分组在共享使用整个集群的资源的同时还能被分别管理。

Namespace 的定义很简单,如下所示的 yaml 定义了名为 development 的 Namespace:

1
2
3
4
apiVersion: v1
kind: Namespace
metadata:
name: development

一旦创建了 Namespace,我们在创建资源对象时就可以指定这个资源对象属于哪个 Namespace。比如在下面的例子中,我们定义了一个名为 busybox 的 Pod,放入 development 这个 Namespace里:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Pod
metadata:
name: busybox
namespace: development
spec:
containers:
- image: busybox
command:
- sleep
- "3600"
name: busybox

当我们给每个租户创建一个 Namespace 来实现多租户的资源隔离时,还能结合 Kubernetes 的资源配额管理,限定不同租户能占用的资源,例如 CPU 使用量、内存使用量等。

Annotation

Annotation(注解)与 Label 类似,也使用 key/value 键值对的形式进行定义。不同的是 Label 具有严格的命名规范,它定义的是 Kubernetes 对象的元数据(Metadata),并且用于 Label Selector。而 Annotation 则是用户任意定义的“附加”信息,以便于外部工具进行查找,很多时候,Kubernetes 的模块自身会通过 Annotation 的方式标记资源对象的一些特殊信息。

通常来说,用 Annotation来记录的信息如下:

  • build 信息、release 信息、Docker 镜像信息等,例如时间戳、release id 号、PR 号、镜像 hash 值、docker registry 地址等。
  • 日志库、监控库、分析库等资源库的地址信息。
  • 程序吊饰工具信息,例如工具名称、版本号等。
  • 团队的联系信息,例如电话号码、负责人名称、网址等。

Kubernetes Logging Architecture

Kubernetes Cluster-level logging architectures

Kubernetes 官方不提供原生的集群日志方案,但是提供了一些解决思路:

  • 在每个 Node 上运行一个日志客户端。
  • 在应用的 Pod 里以 sidecar 形式运行一个日志容器。
  • 应用直接把日志推送到后端日志存储里。

Using a node logging agent

k8s_logging_nodeAgent

一台 Node 上所有容器的 stdout/stderr 日志会被统一保存到宿主机的目录,只需要以 DaemonSet 的形式在每个 Node 上运行一个日志收集客户端即可将这台 Node 上所有容器的 标准输出/标准错误 日志收集起来存到后端存储里。

这里的日志收集客户端建议 fluentd

Using a sidecar container with the logging agent

使用 Sidecar 方式有两种方案:

  • Sidecar 容器将应用日志导流到标准输出或标准错误。

k8s_logging_sidecar_stream

这种方式有利于适配 kubelet,或每台 Node 已经运行日志收集客户端的情况。Sidecar 容器可以从文件、socket 或 journald 收集日志,为应用里的每个日志模块运行一个特定的 sidecar 容器,整个 Pod 共享一个共享目录,最终把每个日志模块的日志对应的输出到各自的 sidecar 容器的标准输出或标准错误。

这是一个在容器内部写两个日志文件的应用范例,然后是同一个 Pod 里运行两个 sidecar 容器分别对两个日志文件进行收集:

two-files-counter-pod-streaming-sidecar.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
apiVersion: v1
kind: Pod
metadata:
name: counter
spec:
containers:
- name: count
image: busybox
args:
- /bin/sh
- -c
- >
i=0;
while true;
do
echo "$i: $(date)" >> /var/log/1.log;
echo "$(date) INFO $i" >> /var/log/2.log;
i=$((i+1));
sleep 1;
done
volumeMounts:
- name: varlog
mountPath: /var/log
- name: count-log-1
image: busybox
args: [/bin/sh, -c, 'tail -n+1 -f /var/log/1.log']
volumeMounts:
- name: varlog
mountPath: /var/log
- name: count-log-2
image: busybox
args: [/bin/sh, -c, 'tail -n+1 -f /var/log/2.log']
volumeMounts:
- name: varlog
mountPath: /var/log
volumes:
- name: varlog
emptyDir: {}

然后,你就可以使用 kubectl logs counter count-log-1查看 1.log 的内容。同理2。

Node 上的日志客户端会自动采集这两个 sidecar容器的标准输出/标准错误,实际也就是采集了 1.log 和 2.log 两个日志文件。


  • Sidecar 容器运行一个日志收集客户端,主动去收集应用日志。

k8s_logging_sidecar_agent

这种方式适用于 Node 上没有日志收集客户端的情况。需要注意的是这种方式存在过多资源消耗的问题,而且无法使用 kubectl logs 命令直接查看应用日志。

这里举例使用 fluentd 作为 sidecar logging agent 收集日志:

fluentd-sidecar-config.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: v1
kind: ConfigMap
metadata:
name: fluentd-config
data:
fluentd.conf: |
<source>
type tail
format none
path /var/log/1.log
pos_file /var/log/1.log.pos
tag count.format1
</source>
<source>
type tail
format none
path /var/log/2.log
pos_file /var/log/2.log.pos
tag count.format2
</source>
<match **>
type google_cloud
</match>

two-files-counter-pod-agent-sidecar.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
apiVersion: v1
kind: Pod
metadata:
name: counter
spec:
containers:
- name: count
image: busybox
args:
- /bin/sh
- -c
- >
i=0;
while true;
do
echo "$i: $(date)" >> /var/log/1.log;
echo "$(date) INFO $i" >> /var/log/2.log;
i=$((i+1));
sleep 1;
done
volumeMounts:
- name: varlog
mountPath: /var/log
- name: count-agent
image: k8s.gcr.io/fluentd-gcp:1.30
env:
- name: FLUENTD_ARGS
value: -c /etc/fluentd-config/fluentd.conf
volumeMounts:
- name: varlog
mountPath: /var/log
- name: config-volume
mountPath: /etc/fluentd-config
volumes:
- name: varlog
emptyDir: {}
- name: config-volume
configMap:
name: fluentd-config

Exposing logs directly from the application

k8s_logging_directly

这是最简单的方式,不过涉及应用的改造。

Kubernetes CronJob

Kubernetes CronJob

范例

cronjob.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: hello
spec:
schedule: "*/1 * * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: hello
image: busybox
args:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure

创建 CronJob

kubectl create -f cronjob.yaml

查看 CronJob

kubectl get cronjob hello

k8s_cronjob01

关注 Job 状态

kubectl get jobs --watch

k8s_cronjob02

删除 CronJob

kubectl delete cronjob hello

更多详细操作

集成

针对php项目容器化改造过程中,原有crontab任务较多的问题,在迁移到 k8s 集群时可无缝替换成 CronJob 方式管理。

后续可基于 CronJob 开发客户端工具,实现简单高效的分布式任务系统。

Docker 进阶

容器技术系列分享(三)

Docker 进阶

目录

  • Docker Image
  • Docker Network
  • Docker Volume
  • Docker Process
  • Docker security

Docker Image

Docker镜像应该是小而快的。假设你在BusyBox镜像中预编译Go二进制文件,他们就会变得又大又复杂。如果不能构建一个良好的Dockerfile来帮助你提高构建缓存命中率,那么你的镜像构建过程将会变得相当的缓慢。

比如一个用于软件安装的bash脚本,里面堆砌着大量的curl、wget等命令语句,大家在写Dockerfile的时候通常就会像写这个bash脚本一样,将一系列的Docker命令堆砌在其中,这种Dockerfile在构建镜像的时候是比较低效和缓慢的。

秩序

有频率的改变Dockerfile中命令的排序,观察分析运行命令所耗费的时间及与其他镜像共享资源的方式。

比如像WORKDIR、CMD、ENV这些命令应该在底部,而RUN apt-get -y update更新应该在上面,因为它需要更长时间来运行,也可以与所有的镜像共享。

任何ADD(或其它缓存失效的命令)命令应该尽可能地在Dockerfile底部,在那里可以做出很多改变,且后续命令缓存失效。

合适的基础镜像

比如Ruby2运行Ruby应用程序,Python3运行Python应用程序,但这两个镜像使用不同的基础镜像,所以你需要下载和构建不同的基础镜像。然而,如果使用Ubuntu运行这两个程序,就只需要下载一次基础镜像。

优化层级

在一个Dockerfile中每个命令都会在原来的基础上生成一层镜像,你可以使用三十多层命令,也可以通过组合RUN命令,并使用一行EXPOSE命令列出所有的开放端口,这样可以有效减少镜像的层数。

通过将RUN命令分组,可以在容器间分享更多的层。当然如果有一组命令可以多个容器通用,那么应该创建一个独立的基础镜像,它包含所建立的所有镜像。

对于每一层来说你都可以跨多个镜像分享,这样可以节省大量的磁盘空间。

容器体积

在创建容器并考虑到体积问题的时候,不要为了节省空间去使用体积小的镜像,尽量使用要提供数据的应用程序打成的镜像,这样对实际应用程序的调试非常有用。

消耗

当你已经构建了一个镜像,在运行它的时候发现有一个package缺少了,把它添加到Dockerfile的底部,而不是添加到顶部的run apt-get命令那里。这意味着你能尽快的重新构建这个镜像。一旦你的镜像可以正常工作,你可以再提交重新优化整理过 Dockerfile。

案例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# 1 - Common Header / Packages
FROM ubuntu:trusty
MAINTAINER Jin Dingming <jindm@2345.com>
RUN apt-get -yq update \
&& apt-get -yqq install \
wget \
curl \
git \
software-properties-common
# 2 - Python
RUN \
apt-get -yqq install \
python-dev \
python-pip \
python-pysqlite2 \
python-mysqldb
# 3 - Apache
RUN \
apt-get -yqq install \
apache2 \
apache2-utils
# 4 - Apache ENVs
ENV APACHE_CONFDIR /etc/apache2
ENV APACHE_ENVVARS $APACHE_CONFDIR/envvars
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_RUN_DIR /var/run/apache2
ENV APACHE_PID_FILE $APACHE_RUN_DIR/apache2.pid
ENV APACHE_LOCK_DIR /var/lock/apache2
ENV APACHE_LOG_DIR /var/log/apache2
# 5 - Graphite and Deps
RUN \
apt-get -yqq install \
libapache2-mod-python \
python-cairo \
python-jinja2 \
sqlite3
RUN \
pip install whisper \
carbon \
graphite-web \
'Twisted<12.0' \
'django<1.6' \
django-tagging
# 6 - Other
EXPOSE 80 2003 2004 7002
WORKDIR /app
VOLUME /opt/graphite/data
# Define default command.
CMD ["/app/bin/start_graphite"]
# 7 - First use of ADD
ADD . /app
# 8 - Final setup
RUN mkdir -p /app/wsgi \
&& useradd -d /app -c 'application' -s '/bin/false' graphite \
&& chmod +x /app/bin/* \
&& chown -R graphite:graphite /app \
&& chown -R graphite:graphite /opt/graphite \
&& rm -f /etc/apache2/sites-enabled/* \
&& mv /app/apache-graphite.conf /etc/apache2/sites-enabled/apache-graphite.conf
案例分析
  1. Common Header/Packages

    这是最常见的共享层,在同一个主机上运行所有镜像应该从它开始。可以看到这里添加了一些诸如curl和git的操作,他们不是必须的,但是对调试很有用,而且因为他们在分享层,所以不会占用太多空间。

  2. Python

  3. Apache

    2-3是语言规范层。这里的python和apache,把谁放在前面并没有硬性规定,主要看业务契合度。

  4. Apache Envs

    在Apache安装好之后直接配置环境依赖,以便于其他镜像构建时尽量多应用缓存。

  5. Graphite and Deps

    这里包含一些特定的apt和pip资源包,利用&&符号连接多个单一命令能减少镜像层级数量。

  6. Other

    这部分配置镜像本身的属性,如端口映射、目录挂载等。

  7. First ADD

    最后,将需要的应用程序打包进镜像。

  8. Final setup

    容器的启动命令。

Docker Network

Docker 网络为容器安全提供了隔离,不同的网络方案在效率和复杂度上也有区别,选择一个最合适的网络方案对应用性能和后期维护成本都有至关重要的影响。

默认网络(Default Networks)

当你安装好Docker后,它会自动创建三个网络,你可以使用docker network ls命令列举它们:

docker_networks

  • bridge 网络

Docker安装好后都会提供默认的bridge网络即docker0网络。如果不指定docker run --net=<network>的话,Docker daemon会默认将容器连接到这个网络。在宿主机中可以看到这个网络。

docker_network_bridge

  • none 网络

none 网络会添加容器到一个容器自己的网络栈,但是并没有网络接口。

docker_network_none

  • host 网络

host 网络添加一个容器到宿主机的网络栈中,你会发现容器中的网络配置和宿主机一样。

自定义网络(User-defined networks)

除了Docker提供的默认网络,用户还可以创建自定义网络以便提供更好的容器网络隔离,Docker为创建自定义网络提供了一些默认的 network driver。你可以创建一个新的 bridge network 或者 overlay network,也可以创建一个network plugin或者remote network。

用户也可以创建多个网络,把容器连接到不止一个网络中。容器仅可以同网络内的容器进行通信而不能跨网络通信。

  • 自定义bridge网络

最简单的用户自定义网络就是创建一个bridge网络。

docker_network_isolatedbridge

创建完之后,就可以指定新创建的容器运行在这个网络上。

docker_network_isolatedbridgerun

overlay网络(An overlay network)

Docker的overlay网络驱动提供原生开箱即用(out-of-the-box)的跨主机网络。完成这个支持是基于 libnetworklibkv,libnetwork是一个内置基于VXLAN overlay网络驱动的一个库。

overlay网络需要一个可用的key-value存储服务,目前Docker的libkv支持Consul、Etcd、Zookeeper。

docker_network_overlay

overlay网络方案对比

Flannel Calico macvlan Open vSwitch route
方案特性 通过虚拟设备flannel0实现对docker0的管理 基于BGP协议的纯三层网路方案 基于Linux Kernel的macvlan技术 基于隧道的虚拟路由器技术 基于Linux Kernel的vRoute技术
网络要求 三层互通 三层互通 二层互通 三层互通 二层互通
配置难度 简单,基于Etcd。 简单,基于Etcd。 简单,直接使用宿主机网络,需要仔细规划IP范围。 复杂,需要手工配置各节点的bridge。 简单,使用宿主机vRoute功能,需要仔细规划每个Node的IP地址范围。
网络性能 host-gw>VXLAN>UDP BGP模式性能损失小,IPIP模式较小 性能损失可忽略 性能损失较小 性能损失小
网络限制 在不支持BGP协议的网络下无法使用 基于macvlan的容器无法与宿主机网络通信 在无法实现大二层互通的网络环境下无法使用
自定义网络插件(Custom network plugin)

你可以编写自己的网络驱动,驱动是和Docker daemon运行在同一台主机上的一个进程,并且由Docker plugin系统调用和使用。

网络插件和其他的Docker插件一样受到一些限制和安装规则。所有的插件使用Plugin API,有自己的生命周期,包含:安装、启动、停止、激活。

自定义的网络驱动安装好后,可以像使用内置的网络驱动一样使用它,例如:

docker network create --driver weave mynet

Docker内置DNS服务器(Embedded DNS Server)

Docker daemon会为每个连接到自定义网络的容器运行一个内置的DNS服务提供自动的服务发现。域名解析的请求会首先被内置的DNS服务器拦截,如果内置的DNS服务器不能解析这个请求,它才会被转发到外部的容器配置的DNS服务器。基于这个机制,容器的resolv.conf文件会将DNS服务器配置为127.0.0.1,即内置DNS服务器监听的地址。

Docker Volume

docker_volume

Volume数据卷是Docker的一个重要概念。数据卷是可供一个或多个容器使用的特殊目录,可以为容器应用存储提供有价值的特性:

  • 持久化数据与容器的生命周期解耦:在容器删除之后数据卷中的内容可以保持。Docker 1.9之后引进的named volume可以更加方便地管理数据卷的生命周期,数据卷可以被独立地创建和删除。
  • 数据卷可以用于实现容器之间的数据共享
  • 可以支持不同类型的数据存储实现

Docker缺省提供了对宿主机本地文件卷的支持,可以将宿主机的目录挂载到容器之中。由于没有容器分层文件系统带来的性能损失,本地文件卷非常适合一些需要高性能数据访问的场景,比如MySQL的数据库文件存储。

同时Docker支持通过volume plugin实现不同类型的数据卷,可以更加灵活解决不同应用负载的存储需求。

但是Docker数据卷的权限管理经常令人困惑,后面就结合实例介绍Docker数据卷权限管理中的常见问题和解决方法。

从Jenkins挂载本地数据卷错误谈起

首先,我们使用Jenkins官方镜像启动一个容器,并检查日志。

可以发现容器日志一切正常。

1
2
docker run -d -p 8080:8080 -p 50000:50000 --name jenkins jenkins
docker logs jenkins

但是,为了持久化Jenkins的数据,当我们把宿主机目录挂载到容器中时,问题出现了:

1
2
3
docker rm -f jenkins
docker run -d -p 8080:8080 -p 50000:50000 -v $(pwd)/data:/var/jenkins_home --name jenkins jenkins
docker logs jenkins

错误日志如下:

docker_volume_jenkins01

不映射本地数据卷时,查看jenkins容器的当前用户是jenkins,且”/var/jenkins_home”目录权限归属于jenkins用户。

docker_volume_jenkins02

而映射本地数据卷时,”/var/jenkins_home”目录的拥有者变成了root用户。

docker_volume_jenkins03

这就解释了为什么当jenkins用户的进程访问“/var/jenkins_home“时,会出现Permission denied的问题。

我们再检查一下宿主机上的数据卷目录,当前路径下“data”目录的拥有者是root,这是因为这个目录是Docker进程缺省创建出来的。

docker_volume_jenkins04

发现问题之后,相应的解决办法也很简单:把当前目录的拥有者赋值给 uid 1000,再启动jenkins容器就一切正常了。

虽然问题解决了,但思考并没有结束。因为当使用本地数据卷时,jenkins容器会依赖宿主机目录权限的正确性,这会给自动化部署带来额外的工作。如何让jenkins容器为数据卷自动地设置正确的权限?这个问题对很多以non-root方式运行的应用也都有借鉴意义。

为 non-root 应用正确地挂载本地数据卷

基本思路有两个:

  • 一个是利用Data Container的方法在容器见共享数据卷。这样就规避了解决宿主机上数据卷的权限问题。由于在1.9版本之后,Docker提供了named volume来取代纯数据容器,所以还需要真正地解决这个问题。
  • 另外一个思路就是让容器中进程以root用户启动,在容器启动脚本中利用chown命令来修正数据卷文件权限,之后切换到non-root用户来执行程序。

参照第二个思路来解决之前jenkins的问题。

1
2
3
4
5
6
7
8
FROM jenkins:latest
USER root
RUN GOSU_SHA=5ec5d23079e94aea5f7ed92ee8a1a34bbf64c2d4053dadf383992908a2f9dc8a \
&& curl -sSL -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.9/gosu-$(dpkg --print-architecture)" \
&& chmod +x /usr/local/bin/gosu \
&& echo "$GOSU_SHA /usr/local/bin/gosu" | sha256sum -c -
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

这是一个基于jenkins镜像的Dockerfile:它会切换到root用户并在镜像中添加gosu命令,和新的入口点”/entrypoint.sh”。

gosu 是经常出现在官方Docker镜像中的一个小工具。它是su和sudo命令的轻量级替代品,并解决了它们在tty和信号传递中的一些问题。

新入口点的entrypoint.sh内容如下:它会为JENKINS_HOME目录设置jenkins的拥有权限,并且再利用gosu命令切换到jenkins用户来执行jenkins应用。

1
2
3
4
#! /bin/bash
set -e
chown -R 1000 "$JENKINS_HOME"
exec gosu jenkins /bin/tini -- /usr/local/bin/jenkins.sh

Docker Process

Docker在进程管理上有一些特殊之处,如果不注意这些细节就会带来一些隐患。另外Docker鼓励“一个容器一个进程(one process per container)”的方式。这种方式非常适合以单进程为主的微服务架构的应用。然而由于一些传统的应用是由若干紧耦合的多个进程构成的,这些进程难以拆分到不同的容器中,所以在单个容器内运行多个进程便成了一种折衷方案;此外在一些场景中,用户期望利用Docker容器来作为轻量级的虚拟化方案,动态的安装配置应用,这也需要在容器中运行多个进程。而在Docker容器中正确运行多进程应用将会带来更多的挑战。

容器的PID namespace

在Docker中,进程管理的基础就是Linux内核中的PID名空间技术。在不同PID名空间中,进程ID是独立的;即在两个不同名空间下的进程可以有相同的PID。

Linux内核为所有的PID名空间维护了一个树状结构:最顶层的是系统初始化时创建的root namespace(根名空间),再创建的新PID namespace就称之为child namespace(子名空间),而原先的PID名空间就是新创建的PID名空间的parent namespace(父名空间)。通过这种方式,系统中的PID名空间会形成一个层级体系。父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响。反过来,子节点不能看到父节点名空间中的任何内容,也不可能通过kill或ptrace影响父节点或其他名空间中的进程。

在Docker中,每个Container都是Docker Daemon的子进程,每个Container进程缺省都具有不同的PID命名空间。通过命名空间技术,Docker实现容器间的进程隔离。另外Docker Daemon也会利用PID命名空间的树状结构,实现了对容器中的进程交互、监控和回收。注:Docker还利用了其他命名空间(UTS,IPC,USER)等实现了各种系统资源的隔离。

当创建一个Docker容器的时候,就会新建一个PID命名空间。容器启动进程在该命名空间内PID为1。当PID1进程结束之后,Docker会销毁对应的PID名空间,并向容器内所有其它的子进程发送SIGKILL。

如何指明容器PID1进程

在Docker容器中的初始化进程(PID1进程)在容器进程管理上具有特殊意义。它可以被Dockerfile中的 ENTRYPOINTCMD 所指明;也可以被docker run命令的启动参数所覆盖。了解这些细节可以帮助我们更好地了解PID1进程的行为。

关于ENTRYPOINT和CMD指令的不同,可以参见官方的Dockerfile说明和最佳实践

https://docs.docker.com/engine/reference/builder/#entrypoint

https://docs.docker.com/engine/reference/builder/#cmd

值得注意的一点是:在ENTRYPOINT和CMD指令中,提供两种不同的进程执行方式 shell 和 exec。

在shell方式中,CMD/ENTRYPOINT指令以如下方式定义:

CMD executable param1 param2

这种方式中的PID1进程是以/bin/sh -c “executable param1 param2”方式启动的

CMD ["executable","param1","param2"]

注意这里的可执行命令和参数是利用JSON字符串数组的格式定义的,这样PID1进程会以executable param1 param2方式启动的。另外,在docker run命令中指明的命令行参数也是以 exec 方式启动的。

为了解释两种不同运行方式的区别,我们利用不同的Dockerfile分别创建两个Redis镜像

“Dockerfile_shell”文件内容如下,会利用shell方式启动redis服务

1
2
3
4
FROM ubuntu:18.04
RUN apt-get update && apt-get -y install redis-server && rm -rf /var/lib/apt/lists/*
EXPOSE 6379
CMD "/usr/bin/redis-server"

“Dockerfile_exec”文件内容如下,会利用exec方式启动redis服务

1
2
3
4
FROM ubuntu:18.04
RUN apt-get update && apt-get -y install redis-server && rm -rf /var/lib/apt/lists/*
EXPOSE 6379
CMD ["/usr/bin/redis-server"]

然后给予他们构建两个镜像“myredis:shell”和“myredis:exec”

1
2
docker build -t myredis:shell -f Dockerfile_shell .
docker build -t myredis:exec -f Dockerfile_exec .

运行”myredis:shell”镜像,我们可以发现它的启动进程(PID1)是/bin/sh -c “/usr/bin/redis-server”,并且它创建了一个子进程/usr/bin/redis-server *:6379。

而运行“myredis:exec”镜像,我们可以发现它的启动进程是/usr/bin/redis-server *:6379,并没有其他子进程存在。

docker_process_redis

由此我们可以清楚的看到,以exec和shell方式执行命令可能会导致容器的PID1进程不同。然而这又有什么问题呢?

原因在于:PID1进程对于操作系统而言具有特殊意义。操作系统的PID1进程是init进程,以守护进程方式运行,是所有其他进程的祖先,具有完整的进程生命周期管理能力。在Docker容器中,PID1进程是启动进程,它也会负责容器内部进程管理的工作。而这也将导致进程管理在Docker容器内部和完整操作系统上的不同。

进程信号处理

信号是Unix/Linux中进程间异步通信机制。Docker提供了两个命令docker stop和docker kill来向容器中的PID1进程发送信号。

当执行docker stop命令时,docker会首先向容器的PID1进程发送一个SIGTERM信号,用于容器内程序的退出。如果容器在收到SIGTERM后没有结束, 那么Docker Daemon会在等待一段时间(默认是10s)后,再向容器发送SIGKILL信号,将容器杀死变为退出状态。这种方式给Docker应用提供了一个优雅的退出(graceful stop)机制,允许应用在收到stop命令时清理和释放使用中的资源。而docker kill可以向容器内PID1进程发送任何信号,缺省是发送SIGKILL信号来强制退出应用。

从Docker 1.9开始,Docker支持停止容器时向其发送自定义信号,开发者可以在Dockerfile使用STOPSIGNAL指令,或docker run命令中使用–stop-signal参数中指明,缺省是SIGTERM。

我们来看看不同的PID1进程,对进程信号处理的不同之处。首先,我们使用docker stop命令停止由 exec 模式启动的“myredis2”容器,并检查其日志。

docker_process_redis02

我们发现对“myredis2”容器的stop命令几乎立即生效;而且在容器日志中,我们看到了“Received SIGTERM scheduling shutdown”的内容,说明redis-server进程接收到了SIGTERM消息,并优雅地推出。

我们再对利用shell模式启动的“myredis”容器发出停止操作,并检查其日志。

docker_process_redis03

我们发现对”myredis”容器的stop命令暂停了一会儿才结束,而且在日志中我们没有看到任何收到SIGTERM信号的内容。原因其PID1进程sh没有对SIGTERM信号的处理逻辑,所以它忽略了所接收到的SIGTERM信号。当Docker等待stop命令执行10秒钟超时之后,Docker Daemon发送SIGKILL强制杀死sh进程,并销毁了它的PID名空间,其子进程redis-server也在收到SIGKILL信号后被强制终止。如果此时应用还有正在执行的事务或未持久化的数据,强制进程退出可能导致数据丢失或状态不一致。

通过这个示例我们可以清楚的理解PID1进程在信号管理的重要作用。所以,

  • 容器的PID1进程需要能够正确的处理SIGTERM信号来支持优雅退出。
  • 如果容器中包含多个进程,需要PID1进程能够正确的传播SIGTERM信号来结束所有的子进程之后再推出。
  • 确保PID1进程是期望的进程。缺省sh/bash进程没有提供SIGTERM的处理,需要通过shell的脚本来设置正确的PID1进程,或捕获SIGTERM信号。

另外需要注意的是:由于PID1进程的特殊性,Linux内核为他做了特殊处理。如果它没有提供某个信号的处理逻辑,那么与其在同一个PID命名空间下的进程发送给它的该信号都会被屏蔽。这个功能的主要作用是防止init进程被误杀。我们可以验证在容器内部发出的SIGKILL信号无法杀死PID1进程.

docker_process_redis04

孤儿进程与僵尸进程管理

熟悉Unix/Linux进程管理的同学对多进程应用并不陌生。

当一个子进程终止后,它首先会变成一个“失效(defunct)”的进程,也称为“僵尸(zombie)”进程,等待父进程或系统收回(reap)。在Linux内核中维护了关于“僵尸”进程的一组信息(PID,终止状态,资源使用信息),从而允许父进程能够获取有关子进程的信息。如果不能正确回收“僵尸”进程,那么他们的进程描述符仍然保存在系统中,系统资源会缓慢泄露。

大多数设计良好的多进程应用可以正确的收回僵尸子进程,比如NGINX master进程可以收回已终止的worker子进程。如果需要自己实现,则可利用如下方法:

  1. 利用操作系统的waitpid()函数等待子进程结束并清除它的僵尸进程。
  2. 由于当子进程成为“defunct”进程时,父进程会收到一个SIGCHLD信号,所以我们可以在父进程中指定信号处理的函数来忽略SIGCHLD信号,或者自定义收回处理逻辑。

下面这些文章详细介绍了对僵尸进程的处理方法

如果父进程已经结束了,那些依然在运行中的子进程会成为“孤儿(orphaned)”进程。在Linux中Init进程(PID1)作为所有进程的父进程,会维护进程树的状态,一旦有某个子进程成为了“孤儿”进程后,init就会负责接管这个子进程。当一个子进程成为“僵尸”进程之后,如果其父进程已经结束,init会收割这些“僵尸”,释放PID资源。

然而由于Docker容器的PID1进程是容器启动进程,它们会如何处理那些“孤儿”进程和“僵尸”进程?

下面我们做几个试验来验证不同的PID1进程对僵尸进程不同的处理能力

首先在myredis2容器中启动一个bash进程,并创建子进程“sleep 1000”

docker_process_redis05

在另一个终端窗口,查看当前进程,我们可以发现一个sleep进程是bash进程的子进程。

docker_process_redis06

我们杀死bash进程之后查看进程列表,这时候bash进程已经被杀死。这时候sleep进程(PID为49),虽然已经结束,而且被PID1进程(redis-server)接管,但是其没有被父进程回收,成为僵尸状态。

这是因为PID1进程“redis-server”没有考虑过作为init对僵尸子进程的回收的场景。


同样的实验对myredis容器测试,发现“bash”和“sleep 1000”进程都已经被杀死和回收。这是因为sh/bash等应用可以自动清理僵尸进程。

如果在容器中运行多个进程,PID1进程需要有能力接管“孤儿”进程并回收“僵尸”进程。Docker从1.13版本开始提供了 docker run –init 参数,可以运行一个init来启动容器,并且提供信号传播和进程回收的作用。

进程监控

在Docker中,如果docker run命令中指明了restart policy,Docker daemon会监控PID1进程,并根据策略自动重启已结束的容器。

Flannel
no 不自动重启,缺省值。
on-failure[:max-retries] 当PID1进程退出值非0时,自动重启容器;可以指定最大重试次数。
always 永远自动重启容器;当Docker daemon启东市,会自动启动容器。
unless-stopped 永远自动重启容器;当Docker daemon启动时,如果之前容器不为stoped状态就自动启动容器。

注意:为防止频繁重启故障应用导致系统过载,Docker会在每次重启过程中会延迟一段时间。Docker重启进程的延迟时间从100ms开始并每次加倍,如100ms,200ms,400ms等等。

利用Docker内置的restart策略可以大大简化应用进程监控的负担。但是Docker Daemon只是监控PID1进程,如果容器在内包含多个进程,仍然需要开发人员来处理进程监控。

还有大家熟悉的SupervisorMonit等进程监控工具,它们可以方便的在容器内部实现进程监控。Docker提供了相应的文档来介绍,网上也有很多资料。

另外利用Supervisor等工具作为PID1进程是在容器中支持多进程管理的主要实现方式;和简单利用shell脚本fork子进程相比,采用Supervisor等工具有很多好处:

  • 一些传统的服务不能以PID1进程的方式执行,利用Supervisor可以方便的适配
  • Supervisor这些监控工具大多提供了对SIGTERM的信号传播支持,可以支持子进程优雅的退出。

然而值得注意的是:Supervisor这些监控工具大多没有完全提供Init支持的进程管理能力,如果需要支持子进程回收的场景需要配合正确的PID1进程来完成

总结

进程管理在Docker容器中和在完整的操作系统有一些不同之处。在每个容器的PID1进程,需要能够正确的处理SIGTERM信号来支持容器应用的优雅退出,同时要能正确的处理孤儿进程和僵尸进程。必要的时候使用Docker新提供的 docker run –init 参数可以解决相应问题。

在Dockerfile中要注意shell模式和exec模式的不同。通常而言我们鼓励使用exec模式,这样可以避免由无意中选择错误PID1进程所引入的问题。

在Docker中“一个容器一个进程的方式”并非绝对化的要求,然而在一个容器中实现对于多个进程的管理必须考虑更多的细节,比如子进程管理,进程监控等等。所以对于常见的需求,比如日志收集,性能监控,调试程序,我们依然建议采用多个容器组装的方式来实现。

Docker security

主要从四块区域思考Docker安全:

  • Linux内核层面的安全:namespace和cgroups
  • Docker daemon的攻击面
  • 容器配置时的漏洞
  • 内核的安全强化功能,以及如何它们如何与容器交互

持续交付实践

目录

  1. 传统交付过程中遇到的问题
  2. 变革软件交付方式的技术:Docker
  3. 应用 Docker 化交付的过程实践

研发过程的困境

  • 任何一家互联网或者软件公司,随着产品规模的扩大,市场需求的变化,都会逐步的发现产品版本管理混乱,运维人员总是在兜底, 不知道开发/测试/集成/预发布/生产等等环境到底经历过几代运维人员之手,所以环境压根没人敢动。
  • 因为市场永远在变化,需求一定在变化,人员也在变化,导致了研发过程中遇到的这样那样的问题。因此,大多数企业都用CI/CD这个解决方案来应对,如下图:

continuous_cicd

  • 另外,我认为的持续交付概念如下:

在一起就是集成,每次集成都应该有反馈
只有不停的集成才是持续集成。越少持续,每次反馈代价越大
多次集成产生一次交付。


  • CI/CD 是无法提升你的代码质量的,是无法解决你代码中的Bug的,但能够提升效率和质量的原因是: 他能把问题发现在前面,让小问题提前暴露出来。
  • 我们说做持续集成最重要的是有效反馈持续,因为CI就像体检服务一样,好比有个胖子要减肥,体检服务不能让他吃的更少动的更多,但他如果每天都称一下体重,就能随时知道自己身体的状态,随时知道每天该干什么, 这就是持续的重要性。
  • 如果他不做这个事儿,很可能等到我年度体检的时候才发现,TMD脂肪肝又加重了。。 同理如果每次代码提交都能自动和其他代码集成,和测试环境集成,就不会出现最终发布的时候出现各种各样的问题,也就是刚才说的运维总在兜底的问题。
  • CI过程的有效反馈也很重要,每次集成都应该给出准确的问题定位和建议,谁的代码merge出现冲突,谁提交的commit导致UT失败,谁应该立刻去解决什么样的问题,这都是有效的反馈。就好比胖子中午没吃饭,去称一下体重,体重秤告诉他:还凑合。那这个反馈让他晚饭是吃。。还是不吃呢?。。这就是无效反馈。

简单来说,持续交付的pipeline就像下面的管道图一样:

continuous_pipeline

当然这个图里的每个节点(stage)的定义并不适用于所有应用,每个stage 是不同角色,运行需要耗费不同的成本,那么只要保证每个 Stage 是一个独立有效的反馈就是正确的持续交付pipeline。

那么,构建出能够运行这样pipeline的一个环境,都需要什么东西:

continuous_process

  • 如上图, 你需要有代码托管服务(存储),运行CI中的单元测试,编译打包服务(环境), 如果你的应用已经托管在公共云上,还要涉及到网络问题。也就是你核心要解决的除了需要服务本身,关键是解决“存储,环境和网络”这三个问题。

现在,当你辛辛苦苦做好了这些过程之后,仍然会遇到一些问题:

  • 每次build,是需要不同的build环境的

编译环境维护困难

  • 每次集成 Test,是需要依赖其他环境,被依赖的环境不受提交者的控制

依赖环境维护困难

  • 每个package, 在不同的环境,run的结果是不一样的

切换环境调试困难

  • 每个package,是无法回溯的

运行包的版本维护困难

  • 每个环境,是不同的维护者(开发环境,测试环境,生产/产品环境)

统一环境标准困难

  • 每个环境,除了维护者,是无法清楚知道环境的搭建过程的

环境回溯,更是难上加难


Why ? 为什么会遇到这样那样的问题? 为什么开发人员经常抱怨: “明明我的程序在测试环境已经调试好了,为什么一上生产环境就运行不了?”

归根结底的原因是:

开发人员交付的只是软件代码本身, 而运维人员需要维护的是一整套运行环境,以及运行环境之间的依赖关系。

continuous_confusion

变革软件交付方式的技术:Docker

  • 有人说:“交付方式的变革,改变了全球的经济格局”

continuous_box

  • 那么,在软件开发领域,Docker ( An open platform for distributed applications for developers and sysadmins) , 就是变革软件交付方式的技术。

continuous_ship


回到最初的问题, 我们找到了开发和运维之间问题的关键,找到了写代码和维护生产环境之间的核心差别, 那么我们试想一下。

如果我们能像描述代码依赖关系一样,描述代码运行所需的环境依赖呢? 如果又能像描述应用之间的依赖关系一样,描述环境之间的依赖呢?

  • 假定,我们的代码中有一个文件,定义了运行需要的环境依赖栈(就像pom.xml文件中定义了java应用的jar包依赖一样)
  • 构建时,我们能根据整个文件,将所有软件依赖栈安装到一个镜像中,镜像是只读的。任何变更都会新产生一个新的镜像而不会更改原先的镜像。
  • 并且只要这个镜像不变,镜像起来的容器之内的环境也不变。
  • 那我们是不是可以像把代码,依赖,测试脚本,环境依赖,环境描述等等这些东西装到集装箱中一样, 集装箱作为一个整体来传递, 作为一个整体在不同的平台上运行, 集装箱不变,任何平台上运行的结果都不变。 YY思路如下图:

continuous_mind


如果我们能轻松的交付整个软件依赖栈,是不是刚才说到的在不同环境调试的问题就能大大减少或者不复存在了?

这个YY过程正好被Docker技术所覆盖, 我们看一下Docker提供什么样的能力,能满足刚才的YY:

  1. 描述环境的能力

    提供了描述运行栈,并且自定义Build 过程的能力。Code中的描述文件就 Dockerfile。

  2. 分层文件系统
    Image可以像Git一样进行管理,并且每一层都是只读的,对环境的每个操作都会被记录,并且可回溯。
  3. Docker Registry
    提供了管理Image存储系统,可以存储,传递,并且对Image进行版本管理。
  4. 屏蔽Host OS 差异
    解决了环境差异,保证在任何环境下的运行都是一致的(只要满足运行docker的linux 内核)。

这几种能力天然的帮助我们解决环境描述和传递的问题,因此docker能够做到Build Once, Run EveryWhere !

  • 因此,软件的交付方式,变成了最简单的 Build – Ship – Run, 如下图:

continuous_BSR

应用Docker化交付的过程实践

首先先看个例子,用docker做持续交付能带来的好处。我用docker官方网站上的案例: BBC News。

  • 简单来说,一个全球新闻中心,内容的变化是最快的, BBC 公司内部的第一个问题是涉及10几种CI环境,26000 Jobs,500Dev人员。
  • 第二个核心问题是,CI任务需要等待,无法并行。

经过Docker化改造之后:

continuous_BBC

最明显的改变,开发可以自己定义自己的开发语言,自己所需的build,集成测试环境,以及应用运行所需的依赖环境。


既然效果这么明显, 该怎么做呢?

基本思路如下:

  • 安装好Docker环境
  • Docker 化你的应用运行环境
  • Docker 化你的应用编译,UT环境
  • Docker 化你的应用运行的依赖环境

第一步,如何安装运行一个Docker环境

官方提供了详细的文档:

docker_install


第二步,如何将自己的应用运行在Docker容器中

这句话可以翻译为: 如何将我的应用环境通过Dockerfile描述出来?

假如我的应用是一个Java Web 应用,需要Java运行环境和Tomcat 容器 ,那么大概我的环境所需下面这些东西:

  • 某Linux发行版操作系统
  • 基础软件(起码有个能解压缩包的吧)
  • openjdk 7 && 配置 Java Home 等环境变量
  • Tomcat 7 && 配置 环境变量
  • 应用包 target.war
  • 应用包 启动参数 JVM
  • Web Server 指定端口 8080
  • 启动tomcat

转化为成Dockerfile 的语言大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM buildpack-deps:jessie-curl
RUN apt-get update && apt-get install -y unzip openjdk-7-jre-headless=“$JAVA_DEBIAN_VERSION”
ENV LANG C.UTF-8
ENV JAVA_VERSION 7u91
ENV JAVA_DEBIAN_VERSION 7u91-2.6.3-1~deb8u1
ENV CATALINA_HOME /usr/local/tomcat
ENV PATH $CATALINA_HOME/bin:$PATH
RUN mkdir -p "$CATALINA_HOME"
WORKDIR $CATALINA_HOMEENV TOMCAT_VERSION 7.0.68
ENV TOMCAT_TGZ_URL https://xxxx/apache-tomcat-$TOMCAT_VERSION.tar.gz
RUN set -x \
&& curl -fSL "$TOMCAT_TGZ_URL" -o tomcat.tar.gz \
&& curl -fSL "$TOMCAT_TGZ_URL.asc" -o tomcat.tar.gz.asc \
&& gpg --batch --verify tomcat.tar.gz.asc tomcat.tar.gz \
&& tar -xvf tomcat.tar.gz --strip-components=1 \
&& rm bin/*.bat \
&& rm tomcat.tar.gz*
EXPOSE 8080
CMD ["catalina.sh", "run"]
  • 可以看出 ,Dockerfile 第一步永远是From 某个镜像, 开始安装了一些基础包(这里是Jre7), 又设置了java的环境变量, 之后安装tomat(这里是7.0),再声明启动8080端口,最后运行tomcat的启动脚本结束,在最后结束之前将我的Web 应用.war包COPY或者ADD进去即可。

我们再看一个Nodejs的环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM ubuntu:14.04
COPY sources.list /etc/apt/sources.list
COPY .npmrc /root/.npmrc
RUN apt-get update && apt-get -y install curl automake tar libtool make wget xz-utils supervisor
ENV NODE_VERSION 0.12.5
ENV NPM_VERSION 2.11.3
RUN curl -SLO "https://npm.taobao.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz" \
&& tar -xzf "node-v$NODE_VERSION-linux-x64.tar.gz" -C /usr/local --strip-components=1 \
&& npm install -g npm@"$NPM_VERSION" \
&& npm cache clear
RUN rm -rf ~/.node-gyp \
&& mkdir ~/.node-gyp \
&& tar zxf node-v$NODE_VERSION-linux-x64.tar.gz -C ~/.node-gyp \
&& rm "node-v$NODE_VERSION-linux-x64.tar.gz" \
&& mv ~/.node-gyp/node-v$NODE_VERSION-linux-x64 ~/.node-gyp/$NODE_VERSION \
&& printf "9\n">~/.node-gyp/$NODE_VERSION/installVersion
CMD ["node"]
  • 关于这个环境,COPY了本地的sources.list和.npmrc 到容器中,是更换了安装源为mirrors.aliyun.com 和 NPM源为npm.taobao.org , 国内源更快。 其他就是安装了基本的Nodejs 运行环境

那么通过这两个例子,我们发现Dockerfile 还是写起来很麻烦的(其实也不麻烦,就是刚刚说的装要装的东西,配置,运行这三步)。 那么,刚刚说到每一个Dockerfile的第一行都是FROM另一个镜像, 那么思考一下:

  • 如果有一个安装好Java的环境 ?
  • 如果有一个安装好Java和Tomcat的环境 ?
  • 如果是微服务,对环境只依赖Java/Node基础环境,是不是所有应用都可以共用1个环境?

通过这些思考,得到如下寻找docker镜像的过程:

  • 寻找java镜像 ,选择镜像版本, 检查 Dockerfile
  • 寻找tomcat镜像,选择 Tomcat & Java 版本, 检查 Dockerfile
  • 测试运行 : docker run -ti —rm -v /home/app.war:/canhin/webapp/ tomcat:7-jre7

说句题外话,这个思路同样适用于公司内部,因为Dockerfile 明确划分出了开发和运维的边界, 如果公司有统一的运维标准,比如某个操作系统的某个版本, 某种确定的Web Server, 这样开发只需要From 运维提供的镜像来描述自己的应用环境特殊的部分就好了。 如果大家的环境都一样,调试和测试的过程中,只需要把应用代码通过-v 的参数挂载进去运行就好了, 这样世界就变的很简单和清楚了。

那么当我需要一个Java 7, Tomcat 7的环境的时候, 直接选择一个官方的tomcat 7 - jre7 镜像即可 , 比如 https://hub.docker.com/_/tomcat?tab=description 这个。


第三步,用Docker描述我的编译环境

编译/CI环境往往在公司规模越来越大的时候, 变得越来越麻烦, 因为不同语言,不同类型的应用对编译环境的要求都不一样。 就像刚才说到的BBC News的例子,一个大公司几十种编译环境的存在是很正常的。

那么,编译环境Docker化最大的好处是: 自定义,可扩展,可复制。

  • 试想一下, 假如你的应用编译只需要依赖标准的Jdk 1.7 和 Maven 2, 或者你是python应用编译过程其实只是需要安装依赖, 那么你可以跟很多人共用编译镜像。
  • 但假如你的应用是Nodejs ,编译依赖特定的C库, 或者是C++之类的编译环境一定要和运行环境一致等等,那就需要定制自己的编译环境了。

这里我做一个最简单的用于编译java的镜像示例:

  • 编译镜像的Dockerfile 示例:
1
2
3
4
5
FROM registry.aliyuncs.com/acs-sample/centos:7
RUN yum update yum install -y open-jdk-1.7.0_65-49
COPY build.sh /build.sh
COPY settings.xml /home/apache-maven-2.2.1/conf/
ENTRYPOINT [“./build.sh"]
  • 上述Dockerfile的build.sh示例:

    1
    2
    cd /ws ; mvn -e -U clean package -Dmaven.test.skip=true $@
    cp target/*.war docker/ || exit 0
  • 运行方式示例:

    1
    2
    git clone git@github.com:dingmingk/myproject.git ~/myprj ; cd ~/myprj
    docker run --rm -v `pwd`:/ws -v ~/.m2/repo:/buf build_maven:1.0
  • 解释一下这个过程:

我的编译环境需要CentOS7系统, 安装JDK1.7 , 然后把maven的setting(这里主要配置指向其他私有nexus和编译脚本拷贝进去。
编译脚本也很简单,就是maven编译打包命令,并且把最终生成的war拷贝到一个定义好的docker目录下,这个目录随便定义。
最后是运行方式,即把源代码挂载到容器里进行编译,同时可以选择把本地的.m2缓存到镜像内加快编译速度


这里提两个小提示,都是经验之谈:

建议: build app 和 build docker image 建议分开进行, 即先进行应用本身的编译,再将输出物拷贝到镜像内(但脚本语言可以例外) 因为:

  • 镜像分层概念导致源码可能泄露:因为DockerImage 每一层都会保存一个版本, 即便是ADD代码进去,编译后再rm掉,也可以通过获取ADD这一层镜像拿到源码,因为镜像是运行在各个环境中,是不应该包含源代码信息的。
  • 镜像最小化原则:编译环境可能需要和运行环境不一样的东西,比如Maven的配置,Nodejs的一些C库的依赖, 都不需要在运行环境中体现,所以本着镜像应该最小化原则,不需要的东西最好都不要放进去,也应该分开进行这个步骤。
  • 所以,整个过程还是分为build app和build docker image 两个过程,类似下面这个简单流程

continuous_simple


建议: Dockerfile 不要放到代码根目录下

  • 避免大量文件传给docker deamon : docker build会先加载Dockerfile同级目录下所有文件进去,如果有不需要ADD/COPY到镜像里的文件不应该放到Dockerfile目录下, 可以试一下把Dockerfile放到系统/根目录下,这时build 十有八九就会让docker deamon挂掉。

第四步,用Docker描述UT环境

简单思路: 运行Docker 镜像环境,安装测试所需依赖 ,运行Docker容器,运行测试命令/脚本

用一个travis-ci官方的例子来说明容器测试这件事,先看下面一个ruby的镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM ubuntu:14.04
MAINTAINER carlad "https://github.com/carlad"
# Install packages for building ruby
RUN apt-get update
RUN apt-get install -y --force-yes build-essential wget git
RUN apt-get install -y --force-yes zlib1g-dev libssl-dev libreadline-dev libyaml-dev libxml2-dev libxslt-dev
RUN apt-get clean
RUN wget -P /root/src http://cache.ruby-lang.org/pub/ruby/2.2/ruby-2.2.2.tar.gz
RUN cd /root/src; tar xvf ruby-2.2.2.tar.gz
RUN cd /root/src/ruby-2.2.2; ./configure; make install
RUN gem update --system
RUN gem install bundler
RUN git clone https://github.com/travis-ci/docker-sinatra /root/sinatra
RUN cd /root/sinatra; bundle install
EXPOSE 4567
  • 简单来说就是标准的一个ruby镜像,启动4567端口。那么通过这个镜像进行的测试过程如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sudo: required
language: ruby
services:
- docker
before_install:
- docker build -t carlad/sinatra .
- docker run -d -p 127.0.0.1:80:4567 carlad/sinatra /bin/sh -c "cd /root/sinatra; bundle exec foreman start;"
- docker ps -a
- docker run carlad/sinatra /bin/sh -c "cd /root/sinatra; bundle exec rake test"
script:
- bundle exec rake test
  • 这个其实就是大家可以在本地进行的一个过程,在before install部分内可以看到过程是:

    先build出运行环境的镜像

    运行这个镜像,看看服务能否正常启动

    查看容器是否存活(保证容器不是运行一下就挂了退出)

    运行测试

再来看一个python的例子,也很好理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
language: python
python:
- 2.7
services:
- docker
install:
- docker build -t blog .
- docker run -d -p 127.0.0.1:80:80 --name blog blog
before_script:
- pip install -r requirements.txt
- pip install mock
- pip install requests
- pip install feedparser
script:
- docker ps | grep -q blog
- python tests.py

简单来说就是运行容器,安装依赖,运行测试脚本。或者直接通过下面一行命令进行

docker run -v mycode:/ws mytestimage:master /bin/sh -c "python3 djanus/manage.py test djanus mobilerpc "

tips: 这里不是说推荐大家用travis-ci ,但travis-ci 制定了一种语法标准, 非常清楚的能够看到整个过程。


用Docker-Compose描述依赖环境

刚刚说了单独一个容器运行测试的情况, 但实际情况可能是即便是运行测试,也需要依赖proxy,依赖db,依赖redis等。 简单来说一般web应用会需要下面的结构:

continuous_need

这个结构很简单也很常见, 那在传统思想里,要运行UT或者集成测试,需要依赖的组件,都是去搭建。 搭一个mysql,配置mysql ,运行mysql 这种思路。

  • 但是在docker的思想里,是声明的概念,就是说我需要一个mysql 去存一些数据进行测试, 这个mysql运行在哪里我根本不care 。 同样的思路告诉docker:

    I need 负载均衡(haproxy,Nginx)

    I need 数据库(mysql)

    I need 文件存储(通过-v , ossfs)

    I need 缓存服务(redis,kv-store)

    I need …

  • 这时,用于编排多个Docker Image 的服务,docker-compose 就出现了,官方文档里用三张最简单的图表明了compose是怎么用的:

continuous_conpose

  • 就是说,我运行一次测试, 需要mysql, 那我就启动一个mysql容器就行,通过link 的方式将我的app链接上,配置一个密码即可,至于其他的信息,我根本不需要,或者说不关心。

再举一个例子,假设一个php的Wordpress 应用, 除了应用本身还需要一个db ,他的编排文件(docker-compose.yml)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
web:
image: registry.aliyuncs.com/acs-sample/wordpress:4.3
ports:
- '80'
volumes:
- 'wp_upload:/var/www/html/wp-content/uploads'
environment:
WORDPRESS_AUTH_KEY: changeme
WORDPRESS_SECURE_AUTH_KEY: changeme
WORDPRESS_LOGGED_IN_KEY: changeme
WORDPRESS_NONCE_KEY: changeme
WORDPRESS_AUTH_SALT: changeme
WORDPRESS_SECURE_AUTH_SALT: changeme
WORDPRESS_LOGGED_IN_SALT: changeme
WORDPRESS_NONCE_SALT: changeme
WORDPRESS_NONCE_AA: changeme
command: run test script
links:
- 'db:mysql'
labels:
aliyun.logs: /var/log
aliyun.probe.url: http://container/license.txt
aliyun.probe.initial_delay_seconds: '10'
aliyun.routing.port_80: http://wordpress
aliyun.scale: '3'
db:
image: registry.aliyuncs.com/acs-sample/mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: password
restart: always
labels:
aliyun.logs: /var/log/mysql
  • 除了自身的配置,文件挂载之外,声明的mysql 就是官方5.7的版本,只需要设置一个密码即可, 这样直接运行起来无论是提供服务, 还是运行测试, 都非常的方便。

tips1: compose的好处还在于将配置从Dockerfile中提取出来,比如在测试/生产环境所需要的配置差别, 就可以放到compose里,在不同环境运行的时候换不同的compose文件即可,不用重复的编出不同环境用的docker image


tips2: 上面的wordpress示例里,启动多个应用容器之上,并没有用nginx做代理,因为阿里云容器服务提供了routing,省去了这部分, 如果是在企业内部,当启动三个应用,还是需要在compose里再声明一个nginx 或者 haproxy 在前面做应用代理和负载均衡的。


完整拼接

continuous_archeitecture

对一个公司/企业来说,将自身应用docker化,编译服务,测试集群docker化之后, 要跑通整个的过程,达到BBC News 这样的效果, 整个流程就如图中所示:

  • 代码,测试脚本,配置,Dockerfile/Compose 等从开发本地push到代码仓库中
  • 代码仓库能够hook 这个信息,通过事件trigger build 服务,通过容器进行app build,运行test, 通过后对应用进行docker image 的build
  • build 好的docker image push到远程docker registry 用于存储和传递
  • 当build test 都pass之后, 通过deploy service 告诉应用集群进行更新,从docker registry 上pull 下来新的image进行应用更新,或更新集群配置

用docker 为开发/运维人员带来的好处

Docker技术是 DevOps 的最好诠释, DevOps不是开发去做运维的事情, 而是:

  • 将编程的思想应用到运维领域

举例来说: Immutable,Copy on Write 这些思想在研发领域是耳熟能详的,好处大家秒懂。而在运维领域的Immutable,传统是怎么做的? 靠组织架构,权限管理。各种人为订制的机制,规范。 而docker 是用技术来解决了这个问题, 官方文档的介绍docker是 An open platform for distributed applications for developers and sysadmins, 很明显看到了DevOps有木有?

  • 由于应用的软件依赖栈完全由应用自己在Dockerfile中定义和维护 ,因此开发人员能够更清楚,更灵活的掌控自己的软件运行环境。 运维人员也不用为应用软件依赖栈的变更碎片化自己的时间。
  • 最最重要的一点,Dockerfile的存在,非常清晰地将研发和PE的责任和界限划分清楚了。 开发人员可以FROM 运维人员提供的基础镜像,配置自己应用的依赖栈; 运维人员可以FROM 更底层的系统工程师的基础镜像, 配置环境依赖栈; 系统工程师则定义了一个公司的基础Linux系统所需的版本和配置。

另外,从资源的角度上讲, docker化能够大大减少开发/测试环境的成本,测试或者调试的场景是当发起测试的时候才需要, 其他时候测试环境并不承担业务, 如果用虚拟机则白白的在那里空跑。 Docker 化之后可以在需要的时候随时拉起来整个环境,很快,并且不会出错, 因此阿里云持续交付平台CRP在会在将来提供集成测试环境,作为一项基础服务, 如果没有容器化,那提供整个服务的成本和可行性都是无法想象的

  • 片尾:希望大家都能运用docker技术做到被说了很久但无法落地的:DevOps

continuous_devops


花絮

  • 不是都片尾了怎么还有花絮? 因为我感觉刚才通篇说的好像把docker神话了, 为了防止大家出现过度崇拜和追捧的情况,还是要回过头来考虑一下, 到底什么样的应用适合Docker化,换句话说到底什么样的应用适合容器化?

continuous_dockerize

  • 如上图: 我们认为Web应用,微服务,这种即无状态(是指好比一个web应用,通过10台服务器提供服务,当挂掉1台的时候流量自动被其他九台分摊,不会影响到用户,这样就叫无状态应用),又生命周期很短的业务适合docker化, 反过来,每个应用都是有状态,有存储的这种情况,不太容易docker化,或者说docker化的好处不明显 。
  • 但我们认为的也不一定是对的,今天docker技术,容器技术发展的速度太快, 所以花絮里这个问题 Let’s Think out together ……

修改 Kubernetes 集群中容器的 hosts

最近工作中遇到一个问题,就是某个系统需要访问一个第三发系统,在没有私有DNS的情况下,只能通过修改hosts文件解析域名。

假设需要添加的解析为 1.2.3.4 thirdparty.com

而我们知道Docker的hosts文件是容器启动后动态加载的,所以无法在Dockerfile中设置。

而如果是使用docker run命令启动容器,可以使用`–add-host thirdparty.com:1.2.3.4`参数修改hosts;

如果是docker compose,则可以使用extra_hosts: "thirdparty.com:1.2.3.4"

但Kubernetes却没有提供类似的方法修改hosts…

刚开始尝试使用 Services,并手动创建EndPoints指向外部服务的地址。但这种方式实际提供的是一种服务,对于只是域名解析来说不合适。

还有一种最脏的办法是每次容器创建成功后通过脚本之行kubectl exec添加hosts,太脏了…

最后一种办法是我发现的最适合也相对干净的一种办法了,需要用到Kubernetes ConfigMap

创建一个ConfigMap

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: ConfigMap
metadata:
name: thirdparty-hosts
data:
hosts: |
thirdparty.com 1.2.3.4
baidu.com 2.3.4.5
google.com 3.4.5.6

集成start.sh脚本到镜像里

1
2
3
4
5
#!/bin/sh
cat /mnt/hosts.append/hosts >> /etc/hosts
exec your-app args

将ConfigMap以Volume的方式挂载并执行启动脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apiVersion: extensions/v1beta1
kind: Deployment
spec:
template:
spec:
volumes:
- name: hosts-volume
configMap:
name: thirdparty-hosts
containers:
command:
- ./start.sh
volumeMounts:
- name: hosts-volume
mountPath: /mnt/hosts.append

这种方式虽然看起来也有点麻烦,但通过维护一个ConfigMap集中管理所有hosts还不错。

另外据说 V1.4 版本中添加了一个新特性能更好的解决这个问题,"external name" (CNAME) services,还没升级暂时不评价。

Kubernetes 不同工作组共享集群案例

在一个组织内部,不同的工作组可以在同一个 Kubernetes 集群中工作,Kubernetes 通过命名空间和 Context 的设置来实现对不同工作组进行区分,使得它们既可以共享同一个 Kubernetes 集群的服务,也能够互不干扰。

假设在我们的组织中有两个工作组:开发组和生产运维组。开发组在 Kubernetes 集群中需要不断创建、修改、删除各种 Pod、RC、Service 等资源对象,以便实现敏捷开发的过程。而生产运维组则需要使用严格的权限设置来确保生产系统中的 Pod、RC、Service 处于正常运行状态且不会被误操作。

创建 namespace

为了在 Kubernetes 集群中实现这两个分组,首先需要创建两个命名空间。

namespace-development.yaml

1
2
3
4
apiVersion: v1
kind: Namespace
metadata:
name: development

namespace-production.yaml

1
2
3
4
apiVersion: v1
kind: Namespace
metadata:
name: production

使用 kubectl create 命令完成命名空间的创建:

1
2
$ kubectl create -f namespace-development.yaml
$ kubectl create -f namespace-production.yaml

查看系统中的命名空间:

1
$ kubectl get namespaces

定义 Context(运行环境)

接下来,需要为这两个工作组分别定义一个 Context,即运行环境。这个运行环境将属于某个特定的命名空间。

通过 kubectl config set-context 命令定义 Context,并将 Context 置于之前创建的命名空间中:

1
2
3
$ kubectl config set-cluster kubernetes-cluster --server=https://192.168.1.128:8080
$ kubectl config set-context ctx-dev --namespace=development --cluster=kubernetes-cluster --user=dev
$ kubectl config set-context ctx-prod --namespace=production --cluster=kubernetes-cluster --user=prod

使用 kubectl config view 命令查看已定义的 Context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ kubectl config view
apiVersion: v1
kind: Config
clusters:
- cluster:
server: http://192.168.1.128:8080
name: kubernetes-cluster
contexts:
- context:
cluster: kubernetes-cluster
namespace: development
name: ctx-dev
- context:
cluster: kubernetes-cluster
namespace: production
name: ctx-prod
current-context: ctx-dev
preferences: {}
users: []

注意,通过 kubectl config 命令在 ${HOME}/.kube 目录下生成了一个名为 config 的文件,文件内容即 kubectl config view 命令看到的内容。所以,也可以通过手工编辑该文件的方式来设置 Context。

设置工作组在特定 Context 环境中工作

使用 kubectl config use-context 命令来设置当前的运行环境。

下面的命令把当前运行环境设置为 ”ctx-dev“:

1
$ kubectl config use-context ctx-dev

通过这个命令,当前的运行环境即被设置为开发组所需的环境。之后的所有操作都将在名为 “development” 的命名空间中完成。

各工作组之间的工作将不会相互干扰,并且它们都能够在同一个 Kubernetes 集群中同时工作。