# 第3章 示例:极简环境
一说起生产环境中容器的使用,大家的第一反应是那些在同样量级的宿主机上部署成千上万容器的大型公司。但实际上恰恰相反,要发挥容器的作用,并不需要构建如此庞大的系统。小规模的团队反而能从容器中获得最大收益,因为容器使构建和部署服务不仅变得简单,而且可重复、可扩展。
本章描述的就是一家名为PeerSpace的小规模公司构建系统时采取的一种极简方式。这种极简方式使他们能在短时间内使用有限的资源开辟一个新市场,并自始至终保持着极高的开发速度。
PeerSpace构建系统时的目标是既要易于开发,又要在生产环境中足够稳定。这两个目标通常是相互矛盾的,因为高速开发引起的大量变化反过来会对系统的构建和配置产生很大影响。任何一个有经验的系统管理员都知道,这样的变化率必然导致不稳定性。
Docker看起来非常适合用在刚起步的时候,因为它既对开发人员友好,又支持以敏捷的方式构建和运维系统。Docker简化了开发和系统配置的某些方面,但有时却过于简单化了。在易于开发和稳健运维之间取得平衡不是件容易的事。
PeerSpace实现开发速度和稳定的生产环境这两个目标的方法之一是拥抱简单。这里所说的简单是指系统的每个部分——容器——有且只有一个目标。这个目标就是:相同的过程,如日志收集,在任何地方都以相同的方式完成,而各部分连接的方法也是明确、静态地定义在配置文件中的。
在这种简单的系统中,开发人员可以同步地、独立地构建系统的不同部分,并确信构建的容器可组装在一起。另外,在生产环境出现问题时,简单性也让问题的排查与解决变得非常简单。
要长期保持系统的简单,需要大量的思考、折中和坚持,但最终这种简单将物有所值。
PeerSpace的系统由20个零散的微服务组成,其中有部分使用了MongoDB数据库和/或ElasticSearch搜索引擎。该系统设计遵循下列指导原则。
(1)倾向无状态服务。这可能是简化PeerSpace生产环境时最大的决策:大部分服务都是无状态的。除了用于处理当前进行中的请求的临时信息,无状态服务不需要保持任何需要持久化的数据。无状态服务的优势在于可以非常容易地对他们进行销毁、重启、复制及伸缩,所有这一切都无需考虑任何数据处理方面的逻辑。并且,无状态服务更易于编写。
(2)倾向静态配置。所有宿主机和服务的配置都是静态的:一旦给服务器推送一项配置,该配置就会一直生效,直至显式地推送来新配置。与之相对的是那些动态配置的系统,其系统的实际配置是实时生成的,并会根据不同因素(如可用宿主机和即将到达的负载)进行自主修改。尽管动态系统的伸缩性更好,并且具有一些有趣的属性,如在出现某些故障时自动恢复等,但静态配置更易于理解和排错。
(3)倾向静态的网络布局。如果在一台宿主机中找到一项服务,除非新配置被确定并提交,否则总能在那台宿主机中找到该服务。
(4)区别对待无状态和有状态服务。尽管PeerSpace的多数服务是无状态的,他们还是使用MongoDB和ElasticSearch来持久化数据。这两种类型的服务在本质上是非常不同的,应该区别处理。例如,将一个无状态服务从一台宿主机移动到另一台上非常简单,只需要启动新服务,然后停止旧服务即可。但要对一个数据库进行移动,数据也要跟着移动。移动数据可能会花费很长时间,要求在迁移过程中停止服务,或通过设备方法进行在线迁移。在开发领域,通常将无状态服务比做“牲口”,它们没有名字,很容易被代替和伸缩,而将有状态服务比做“宠物”,它们是唯一的、具名的,需要维护,并且难以伸缩。幸运的是,PeerSpace正如一个农场一样,其“牲口”数量要远远多于“宠物”。
以上这些设计原则是简化PeerSpace系统的基础。将有状态服务与无状态服务分离,可以对本质上完全不同的服务进行区别处理(如图3-1所示),因此可以对每一种情况的处理方式进行优化和尽可能地简化。使用静态配置运行无状态服务使得操作系统的流程变得非常简单:多数情况下流程被简化成文件复制和容器重启,完全不需要考虑其他因素,如对第三方系统的依赖。
![图像说明文字](https://box.kancloud.cn/0a94b6af0c4e2b3d9c3fdf87b88a37ac_700x557.jpeg)
图3-1
上述设计准则能否产生一个简单的系统,完全取决于系统操作是否同样简单。
在设计业务流程时,PeerSpace基于观察做出了如下假定:在他们的基础设施中离硬件越近的层变更越少,而越接近终端用户的层变更越频繁(如图3-2所示)。
![图像说明文字](https://box.kancloud.cn/758d4f04a85a7a6a29f1b97671b948fe_700x542.jpeg)
图3-2
根据这一观察,生产环境中的服务器数量很少变更,通常是由于缩放问题或硬件故障。而这些服务器的配置变更频次可能更高一些,通常是由于性能补丁、系统错误修复或安全问题等原因。
在这些服务器上运行的服务数量和类别变更更为频繁。通常是指移动服务、添加新类型服务或对数据进行操作。这个层级上的其他修改可能与要求重新配置或变更第三方服务的新版本部署有关。不过,这类变更仍然不是很常见。
在这样的基础设施中,多数的变更与多个服务的新版本推送有关。每天,PeerSpace都会执行很多次新版服务的部署。多数情况下,新版本的推送只是简单地将现有版本替换成运行新镜像的新版本。有时也会使用相同镜像,但对配置参数进行变更。
PeerSpace的流程建立是为了让最频繁的变更最容易也最简单进行,即便这样会造成基础设施更难以变更(实际上并未发生)。
PeerSpace运行着3个类生产环境集群:集成环境、预演环境与生产环境。每个集群包含了相同数量的服务,并使用相同的方式进行配置,唯一不同的是它们的原始性能(CPU、内存等)。开发人员同样会在自己的电脑上运行全部或部分集群。
每个集群由以下几个部分组成:
- 几台运行着CentOS 7的Docker宿主机,使用[systemd](http://www.freedesktop.org/wiki/Software/systemd)作为系统管理程序;
- 一台MongoDB服务器或一个复制集合;
- 一台ElasticSearch服务器或一个集群。
MongoDB和/或ElasticSearch服务器可能在某些环境中是Docker化的,而在其他环境中不是Docker化的(如图3-3所示)。它们也会在多个环境中共享。在生产环境中,出于运维和性能的原因,这些数据服务是不做Docker化的。
![图像说明文字](https://box.kancloud.cn/923bb029db89b00a514a40dc6e49e259_700x589.jpeg)
图3-3
每个Docker宿主机运行着一个服务的静态集合,所有这些服务都会遵循如下模式进行构建:
- 所有配置都通过环境变量进行设置,包括其他服务的地址(和端口);
- 不将数据写入磁盘;
- 将日志发送到标准输出(stdout)中;
- 生命周期由systemd管理,并定义在一个systemd单元文件中。
所有服务都由systemd管理。systemd是一个借鉴了OSX launchd的服务管理程序,此外,systemd使用普通数据文件命名单元来定义每个服务的生命周期(如图3-4所示),这与其他使用shell脚本完成这类事务的传统管理程序完全不同。
![图像说明文字](https://box.kancloud.cn/a4f4be2317dbea775b458103fd654650_700x810.jpeg)
图3-4
PeerSpace的服务只将Docker进程当作唯一的运行时的依赖。systemd的依赖管理只用来确保Docker处于运行状态,但不确保其拥有的服务以正确顺序启动。服务构建时要求它们可以以任何顺序启动。
所有服务都由以下部分组成(如图3-5所示):
- 一个容器镜像;
- 一个systemd单元文件;
- 一个该容器专用的环境变量文件;
- 一组用于全局配置参数的共享环境变量文件。
![图像说明文字](https://box.kancloud.cn/85dfa6e0f9eaa0831d89b7dfd6610418_700x520.jpeg)
图3-5
所有单元都遵循相同的结构。在服务启动之前,一系列包含环境变量的文件将被加载:
```
EnvironmentFile=/usr/etc/service-locations.env
EnvironmentFile=/usr/etc/service-config.env
EnvironmentFile=/usr/etc/cluster.env
EnvironmentFile=/usr/etc/secrets.env
EnvironmentFile=/usr/etc/%n.env
```
这确保了每个服务会加载一系列通用环境文件(`service-locations.env`、`service-config.env`、`cluster.env`及`secrets.env`),外加一个专用于该服务的文件:`%n.env`,此处的`%n`在运行时将被替换成该单元的全称。例如,一个名为`docker-search`的服务单元将被替换成`docker-search.service`。
接下来的条目是确保在启动新容器前旧容器被正确删除的:
```
ExecStartPre=-/bin/docker kill %n
ExecStartPre=-/bin/docker rm -f %n
```
通过使用`%n`,将容器命名为单元的全称。使用变量进行容器命名能让单元文件更通用并且可移植。在`docker`程序路径之前使用“`-`”可防止单元在命令失败时中止启动。这里需要忽略潜在的错误,因为如果此前不存在该容器,这些命令将执行失败,而这种情况又是合法的。
单元中主要的条目是`ExecStart`,它将告之systemd如何启动该容器。这里内容较多,但我们只关注一下其最重要的部分:
```
ExecStart=/bin/docker \
run \
-p "${APP_PORT}:${APP_PORT}" \
-e "APP_PORT=${APP_PORT}" \
-e "SERVICE_C_HOST=${SERVICE_C_HOST}" \
-e "SERVICE_D_HOST=${SERIVCE_D_HOST}" \
-e "SERVICE_M_HOST=${SERVICE_M_HOST}" \
--add-host docker01:${DOCKER01_IP} \
--add-host docker02:${DOCKER02_IP} \
--volume /usr/local/docker-data/%n/db:/data/data \
--volume /usr/local/docker-data/%n/logs:/data/logs \
--name %n \
${IMAGE_NAME}:${IMAGE_TAG}
```
(1)使用`EnvironmentFile`加载的环境变量来配置容器(如通过`-p`公开的端口)。
(2)将集群中的其他宿主机地址添加到容器的`/etc/hosts`文件中(`--add-host`)。
(3)映射用于日志和数据的数据卷。这主要是作为一个“蜜罐”(honey pot[\[1\]](part0009.xhtml#anchor31)),以便检查这些目录并确保无人对其进行写入。
(4)镜像自身(名称和版本)来自于从`/usr/etc/%n.evn`中加载的环境变量,在本示例中它将映射到`/usr/etc/docker-search.service.env`中。
最后,是一些定义如何停止容器及其他生命周期要素的条目:
```
ExecStop=-/bin/docker stop %n
Restart=on-failure
RestartSec=1s
TimeoutStartSec=120
TimeoutStopSec=30
```
PeerSpace将集群配置分成两种类型文件:环境变量文件和systemd单元文件。上面已经讲述了单元文件及其加载环境变量文件的方式,接下来看一下环境文件。
将环境变量分解到不同文件中的主要原因在于,这些文件在跨集群时是否需要修改以及如何修改,不过也有其他操作层面的原因。
- `service-locations.env`:集群中所有服务的宿主机名。这个文件在不同集群里通常是一样,不过也有例外。
- `service-config.env`:与服务自身相关的配置。如果不同集群运行的是服务的兼容性版本,这个文件应该是一样的。
- `secrets.env`:密钥信息。因其内容关系,这个文件被处理的方法与其他文件不同,而且在不同集群上也有差异。
- `cluster.env`:包括了集群间的所有不同之处,如所使用的数据库前缀、是测试还是生产环境、外部地址等。这个文件中最重要的信息是属于该集群的所有宿主机的IP地址。
下面是某些示例集群中的文件。这是`cluster.env`文件:
```
CLUSTER_ID=alpha
CLUSTER_TYPE="test"
DOCKER01_IP=x.x.x.226
DOCKER02_IP=x.x.x.144
EXTERNAL_ADDRESS=https://somethingorother.com
LOG_STORE_HOST=x.x.x.201
LOG_STORE_PORT=9200
MONGODB_PREFIX=alpha
MONGODB_HOST_01=x.x.x.177
MONGODB_HOST_02=x.x.x.299
MONGODB_REPLICA_SET_ID=rs001
```
这是`service-locations.env`文件:
```
SERVICE_A_HOST=docker01
SERVICE_B_HOST=docker03
CLIENTLOG_HOST=docker02
SERIVCE_D_HOST=docker01
...
SERVICE_Y_HOST=docker03
SERVICE_Z_HOST=docker01
```
每个systemd单元都包含集群中其他宿主机的引用,而这些引用来自于环境变量。包含服务宿主机名的变量会被装配到Docker命令中,以便容器进程使用。这是通过`-e`参数实现的,如`-e "SERVICE_D_HOST=${SERIVCE_D_HOST}"`。
Docker宿主机的IP地址也同样通过`--add-host docker01:${DOCKER01_IP}`注入到容器中。这样,只需要修改这两个文件并且保持单元文件的完好无损,就可以将容器扩散到不同数量的宿主机中。
容器级别或配置级别的修改通过3个步骤完成:第1步,在配置仓库(Git)上做修改;第2步,将配置文件复制到宿主机的预演区域(ssh);第3步,运行宿主机上的一个脚本来逐一部署每个服务,使得配置修改生效。这种方法提供了版本化配置,一次只推送一项相关配置,以及让推送配置生效的一种灵活方式。
如果需要针对一组服务进行修改,首先在Git上做修改并提交。然后运行脚本,将这个配置推送到所有宿主机的预演区域。一旦配置被推送过去,在每台宿主机上运行一个脚本来部署或重部署该宿主机上的所有容器集合。这个脚本会对在列的所有服务执行如下命令。
(1)将配置文件从预演区域复制到其最终位置:
- systemd单元文件;
- 共享的配置文件;
- 当前服务的配置文件;
- 密钥文件(解密后的)。
(2)需要的话下载镜像文件(镜像定义在服务自身的配置文件中)。
(3)重载systemd的配置,以便读取新的单元文件。
(4)重启容器对应的systemd单元。
PeerSpace具有两个部署工作流,理解这一点有助于阐述其部署流程:一个用于开发环境,另一个用于生产环境,而后者是前者的一个超集。
在开发过程中,他们会通过以下步骤将临时构建联署到集成服务器中。
(1)使用最新代码库创建一个新的容器镜像。
(2)将镜像推送到镜像仓库中。
(3)在运行该镜像的容器宿主机上运行部署脚本。
开发环境的systemd单元会追踪镜像的最新版本,所以只要配置不做修改,那我们只需推送镜像并重新部署即可。
类生产环境的服务器(生产环境和预演环境)与开发环境配置方式大体相同,主要区别在于生产环境中的容器镜像都打上了版本标签,而非`latest`。部署发布镜像到类生产环境容器的流程如下。
(1)在仓库中为容器镜像运行发布脚本。该脚本将为Git仓库打上新版本标签,然后使用这个版本号构建并推送镜像。
(2)更新每个服务环境变量文件以引用新镜像标签。
(3)将新的配置推送到各宿主机中。
(4)在运行该镜像的容器宿主机上运行部署脚本。
他们通常会批次地将服务从开发环境转移到生产环境(一般是两周一次)。在推送发行版到生产环境时,开发环境中用于该发行版的配置文件会被复制到生产目录中。多数文件可以完全照搬,因为它们是从集群的具体细节(IP地址、宿主机数量等)抽象出来的,不过`cluster.env`和`secrets.env`文件在各个集群中是不一样的,在发行时也对其进行更新。一般情况下,会一次性推送所有新版本服务。
PeerSpace使用了一组服务来支撑自己的服务。这些服务包括以下两个。
- 日志聚合:<a class="calibre5">fluentd+kibana以及docker-gen</a>的组合。docker-gen可根据宿主机中运行的容器创建和重创建一个配置文件。docker-gen为每个运行中的容器生成一个fluentd条目,用于发送日志给kibana。这个服务运行良好,且易于调试。
- 监控:Datadog——一个SaaS监控服务。Datadog代理在容器中运行,用于监控各项性能指标、API使用情况和业务事件。Datadog为标签提供了丰富的支持,通过fluentd可以使用多种方式对单一事件进行标记。数据收集起来后(如跨集群的相同服务、所有Docker服务、使用某个发行版的所有API端点等),可以利用丰富的标签对数据进行多种方式的切割。
在系统中,所有宿主机和服务的配置都非常明确,开发人员很容易理解系统的配置,并能不受干扰地工作于系统的不同部分上。每位开发人员都可以在任何时候对集成集群进行推送,并且推送到生产环境所需的协调也很少。
由于每个集群的配置都保存在Git上,很容易追踪配置的变化,并在出现配置问题时对集群进行排错。
因为配置推送的方式,一旦新配置设置妥当,该配置将保持不变。静态配置带来的是极大的稳定性。
另外,服务编写的方式,如通过环境变量进行配置、日志写入控制台、无状态等,使得它们之后可原封不动地被Mesos或Kubernetes这类集群管理工具使用。
当然,要得到这些好处是有代价的。一个最明显的缺点是配置有些繁琐、重复并且易出错。我们可以通过大量的自动化的工具来生成这些配置文件。
修改全局配置要求重启多个容器。目前是由开发人员来重启正确的容器。在生产环境中,如果推送的修改很多,通常会执行滚动重启,但这并不是一个很好的解决方法。这绝对是一个薄弱环节,但到目前为止,还是可控的。
PeerSpace正在考虑几个系统扩展的方式。其中之一是通过反向代理实现零停机时间部署。这将使得PeerSpace有能力对每个服务进行水平扩展。
另外一个方向是从集群的更高层级描述中生成所有的配置文件。这种方法能在配置发生改变后计算哪些容器需要重启。
在考虑这些未来的方向时,PeerSpace也在权衡使用Mesos或Kubernetes的可能性,因为他们认为,增加部署脚本的任何复杂度势必造成对简单模式的过度拉伸。
尽管本章讲解了一个极其简单的Docker使用方式,但我们仍希望它能成为“Docker思想”的基石。不论是使用极简方式还是集群管理系统,读者都能利用这种方式在阅读本书其他部分时获益。
当然,使用Docker还有很多其他方式,第4章将讲述RelateIQ使用Docker运行了一年多的一个真实的Web服务器生产环境。
- - - - - -
[\[1\]](part0009.xhtml#ac31) 用于隐藏宿主机的真实路径。——译者注
- 版权信息
- 版权声明
- 内容提要
- 对本书的赞誉
- 译者介绍
- 前言
- 本书面向的读者
- 谁真的在生产环境中使用Docker
- 为什么使用Docker
- 开发环境与生产环境
- 我们所说的“生产环境”
- 功能内置与组合工具
- 哪些东西不要Docker化
- 技术审稿人
- 第1章 入门
- 1.1 术语
- 1.1.1 镜像与容器
- 1.1.2 容器与虚拟机
- 1.1.3 持续集成/持续交付
- 1.1.4 宿主机管理
- 1.1.5 编排
- 1.1.6 调度
- 1.1.7 发现
- 1.1.8 配置管理
- 1.2 从开发环境到生产环境
- 1.3 使用Docker的多种方式
- 1.4 可预期的情况
- 为什么Docker在生产环境如此困难
- 第2章 技术栈
- 2.1 构建系统
- 2.2 镜像仓库
- 2.3 宿主机管理
- 2.4 配置管理
- 2.5 部署
- 2.6 编排
- 第3章 示例:极简环境
- 3.1 保持各部分的简单
- 3.2 保持流程的简单
- 3.3 系统细节
- 利用systemd
- 3.4 集群范围的配置、通用配置及本地配置
- 3.5 部署服务
- 3.6 支撑服务
- 3.7 讨论
- 3.8 未来
- 3.9 小结
- 第4章 示例:Web环境
- 4.1 编排
- 4.1.1 让服务器上的Docker进入准备运行容器的状态
- 4.1.2 让容器运行
- 4.2 连网
- 4.3 数据存储
- 4.4 日志
- 4.5 监控
- 4.6 无须担心新依赖
- 4.7 零停机时间
- 4.8 服务回滚
- 4.9 小结
- 第5章 示例:Beanstalk环境
- 5.1 构建容器的过程
- 部署/更新容器的过程
- 5.2 日志
- 5.3 监控
- 5.4 安全
- 5.5 小结
- 第6章 安全
- 6.1 威胁模型
- 6.2 容器与安全性
- 6.3 内核更新
- 6.4 容器更新
- 6.5 suid及guid二进制文件
- 6.6 容器内的root
- 6.7 权能
- 6.8 seccomp
- 6.9 内核安全框架
- 6.10 资源限制及cgroup
- 6.11 ulimit
- 6.12 用户命名空间
- 6.13 镜像验证
- 6.14 安全地运行Docker守护进程
- 6.15 监控
- 6.16 设备
- 6.17 挂载点
- 6.18 ssh
- 6.19 私钥分发
- 6.20 位置
- 第7章 构建镜像
- 7.1 此镜像非彼镜像
- 7.1.1 写时复制与高效的镜像存储与分发
- 7.1.2 Docker对写时复制的使用
- 7.2 镜像构建基本原理
- 7.2.1 分层的文件系统和空间控管
- 7.2.2 保持镜像小巧
- 7.2.3 让镜像可重用
- 7.2.4 在进程无法被配置时,通过环境变量让镜像可配置
- 7.2.5 让镜像在Docker变化时对自身进行重新配置
- 7.2.6 信任与镜像
- 7.2.7 让镜像不可变
- 7.3 小结
- 第8章 存储Docker镜像
- 8.1 启动并运行存储的Docker镜像
- 8.2 自动化构建
- 8.3 私有仓库
- 8.4 私有registry的扩展
- 8.4.1 S3
- 8.4.2 本地存储
- 8.4.3 对registry进行负载均衡
- 8.5 维护
- 8.6 对私有仓库进行加固
- 8.6.1 SSL
- 8.6.2 认证
- 8.7 保存/载入
- 8.8 最大限度地减小镜像体积
- 8.9 其他镜像仓库方案
- 第9章 CI/CD
- 9.1 让所有人都进行镜像构建与推送
- 9.2 在一个构建系统中构建所有镜像
- 9.3 不要使用或禁止使用非标准做法
- 9.4 使用标准基础镜像
- 9.5 使用Docker进行集成测试
- 9.6 小结
- 第10章 配置管理
- 10.1 配置管理与容器
- 10.2 面向容器的配置管理
- 10.2.1 Chef
- 10.2.2 Ansible
- 10.2.3 Salt Stack
- 10.2.4 Puppet
- 10.3 小结
- 第11章 Docker存储引擎
- 11.1 AUFS
- 11.2 DeviceMapper
- 11.3 BTRFS
- 11.4 OverlayFS
- 11.5 VFS
- 11.6 小结
- 第12章 Docker 网络实现
- 12.1 网络基础知识
- 12.2 IP地址的分配
- 端口的分配
- 12.3 域名解析
- 12.4 服务发现
- 12.5 Docker高级网络
- 12.6 IPv6
- 12.7 小结
- 第13章 调度
- 13.1 什么是调度
- 13.2 调度策略
- 13.3 Mesos
- 13.4 Kubernetes
- 13.5 OpenShift
- Red Hat公司首席工程师Clayton Coleman的想法
- 第14章 服务发现
- 14.1 DNS服务发现
- DNS服务器的重新发明
- 14.2 Zookeeper
- 14.3 基于Zookeeper的服务发现
- 14.4 etcd
- 基于etcd的服务发现
- 14.5 consul
- 14.5.1 基于consul的服务发现
- 14.5.2 registrator
- 14.6 Eureka
- 基于Eureka的服务发现
- 14.7 Smartstack
- 14.7.1 基于Smartstack的服务发现
- 14.7.2 Nerve
- 14.7.3 Synapse
- 14.8 nsqlookupd
- 14.9 小结
- 第15章 日志和监控
- 15.1 日志
- 15.1.1 Docker原生的日志支持
- 15.1.2 连接到Docker容器
- 15.1.3 将日志导出到宿主机
- 15.1.4 发送日志到集中式的日志平台
- 15.1.5 在其他容器一侧收集日志
- 15.2 监控
- 15.2.1 基于宿主机的监控
- 15.2.2 基于Docker守护进程的监控
- 15.2.3 基于容器的监控
- 15.3 小结
- DockOne社区简介
- 看完了