DevOps-2-1-工具集-全-
DevOps 2.1 工具集(全)
原文:
annas-archive.org/md5/05b82b5d1e168547a5d51268d1bddd46译者:飞龙
前言
2016 年初,我发布了The DevOps 2.0 Toolkit(www.amazon.com/dp/B01BJ4V66M)。完成它花费了我很长时间,比我预想的要长得多。
我从在TechnologyConversations.com(technologyconversations.com/)上写博客文章开始。这些文章很受欢迎,我收到了很多反馈。通过这些反馈,我阐明了书籍背后的理念。目标是为那些想要实施 DevOps 实践和工具的人提供一本指南。同时,我并不想写一本适用于所有情况的材料。我只想专注于那些真正想要实施最新最佳实践的人。我希望让它超越“传统”的 DevOps。我希望展示 DevOps 运动在多年中成熟与演化,我们需要一个新的名称。是时候重置某些组织中 DevOps 的实施方式了。因此才有了书名The DevOps 2.0 Toolkit(www.amazon.com/dp/B01BJ4V66M)。
正如任何作者会告诉你的那样,基于实际操作的技术书籍不会有很长的生命周期。技术变化得如此之快,我们可以预见今天有效的工具和实践在几年后会变得过时。我预计The DevOps 2.0 Toolkit会成为两到三年的参考书(不会超过这个时间)。毕竟,一年内变化能有多大呢?嗯,Docker 让我错了。自从我公开出版这本书以来,短短六个月内就发生了巨大的变化。新的 Swarm 发布了,它现在是Docker Engine v1.12+的一部分。服务发现已被集成其中。网络功能得到了极大改善,支持负载均衡和路由网格。这些变化只是其中的一部分。我认为 1.12 版本的发布,是自第一版公开发布以来最具意义的一次。
我记得在DockerCon 2016期间,我与 Docker 工程师们一起度过的日子。那时,我没有参加公开的会议,而是与他们一起花了四天时间,深入了解将要在 1.12 版本中发布的功能以及未来的路线图。我觉得自己完全理解了这些功能背后的技术概念。然而,一周后,当我回到家并开始“玩”新版本的Docker Swarm 模式时,我才意识到我的大脑仍然依赖于旧有的工作方式。许多东西发生了变化,出现了许多新的可能性。过了几周,我的大脑才开始重置。直到那时,我才感觉自己真正理解了他们在一次发布中所引入的变化范围。那是一次巨大的变革。
与我对 Swarm Mode 的发现同时,我继续收到来自DevOps 2.0 工具包(www.amazon.com/dp/B01BJ4V66M)读者的电子邮件。他们想要更多。他们希望我涵盖新的主题,并深入探讨已经探索的内容。特别是一个请求被反复提及。“我希望你深入探讨集群。”读者们想要更详细地了解如何操作集群,以及如何与持续部署结合。他们要求我探索零停机部署的替代方法,如何更有效地监控系统,如何接近自愈系统,等等。他们希望我尽快涵盖的主题范围非常广泛。
因此,我决定开始一本新书,并结合我对 Docker 1.12 的惊叹,以及一些读者的要求。DevOps 2.1 工具包应运而生,更重要的是,DevOps 工具包系列也随之诞生。
概述
本书探讨了运行 Swarm 集群所需的实践和工具。我们不仅仅局限于简单的部署。我们将探讨如何创建持续部署流程。我们将设置多个集群。其中一个专门用于测试,另一个用于生产。我们将看到如何实现零停机部署,在故障转移时应该怎么做,如何扩展运行服务,如何监控系统以及如何使其自愈。我们将探索允许我们在笔记本电脑上(用于演示目的)以及不同提供商上运行集群的过程。我将尽可能涵盖尽可能多的不同托管解决方案。然而,时间和空间有限,你所使用的(无论是公共的还是内部的)可能不在其中。不用担心!这些过程、工具和实践几乎可以应用到任何情况中。像往常一样,本书非常注重实践,但目标不是掌握特定工具集,而是学习其背后的逻辑,以便无论你的选择最终如何,都能将其应用到工作中去。
本书并不会使The DevOps 2.0 Toolkit(www.amazon.com/dp/B01BJ4V66M)过时。我认为它背后的逻辑还会有效一段时间。The DevOps 2.1 Toolkit是基于它构建的。它深入探讨了一些之前已经讨论过的主题,并解释了其他一些无法包含在2.0中的内容。虽然我尽力以不需要 prior knowledge 的方式编写本书,但我强烈建议你先阅读The DevOps 2.0 Toolkit(www.amazon.com/dp/B01BJ4V66M)。系列中的第一本书为本书以及之后的书籍打下了基础。请把这本书视为The DevOps Toolkit 系列的第二集。对于The DevOps 2.0 Toolkit的读者来说,前几章可能乍看之下有些重复。不过,它们是非常重要的,因为它们揭示了我们在集群内工作时会遇到的一些问题,并为接下来的章节做好了铺垫。尽管你可能会想跳过这些内容,但我建议你还是读完它们。就像任何好的故事一样,开头为故事定下基调,中间推动情节发展,而结局则揭示了出人意料的结果。部分理论内容与The DevOps 2.0 Toolkit中所述相同。在这种情况下,我会明确标明,并让你选择是跳过它还是通过阅读来刷新记忆。
尽管本书包含了大量的理论内容,但它是一本实践书籍。你无法通过在地铁上读它来完成书中的内容。你需要在电脑前,动手操作时阅读这本书。最终,你可能会遇到卡住的情况并需要帮助,或者你可能想要对书中的内容写评论或反馈。请在The DevOps 2.1 Toolkit Disqus 频道(disqus.com/home/channel/thedevops21toolkit/)分享你的想法。如果你更喜欢直接交流,欢迎加入DevOps2.0(slack.devops20toolkit.com/)Slack 频道。我所写的每一本书都对我非常珍贵,我希望你在阅读时能有良好的体验。这部分体验就是你可以联系我。不要害羞。
请注意,和之前的书籍一样,这本书是自出版的。我相信没有中介的情况下,作者与读者之间的直接沟通是最好的方式。这让我可以写得更快,更频繁地更新书籍,并与您进行更直接的交流。您的反馈是这个过程的一部分。无论您是在只有少数章节或全部章节完成时购买的这本书,理念是它永远不会真正完成。随着时间的推移,它需要更新,以便与技术或流程的变化保持一致。当可能时,我会尽力保持更新,并在合适的时候发布更新。最终,情况可能会发生很大变化,以至于更新不再是一个好的选择,那时就意味着需要一本全新的书。只要我继续得到您的支持,我将继续写作。
读者
本书面向那些对完整的微服务生命周期以及持续部署和容器感兴趣的专业人士。由于范围非常广泛,目标读者可能是架构师,他们想了解如何围绕微服务设计他们的系统。也可能是DevOps,他们想了解如何应用现代配置管理实践并持续部署打包在容器中的应用程序。本书还面向开发人员,他们希望将过程掌握在自己手中,以及管理人员,他们希望更好地理解从开始到结束交付软件的过程。我们将讨论系统的扩展和监控,甚至将设计(和实现)自愈系统,能够从故障(无论是硬件还是软件)中恢复。我们将持续不断地将应用程序直接部署到生产环境中,且无需停机,并且能够随时回滚。
本书适合所有想了解软件开发生命周期的人,从需求和设计到开发和测试,直到部署和后部署阶段。我们将创建过程,并考虑由一些大公司开发的最佳实践。
第一章:使用 Docker 容器的持续集成
这是一种矛盾但真实的说法:我们知道的越多,在绝对意义上我们变得越无知,因为只有通过启蒙,我们才能意识到自己的局限性。正是知识演化最令人欣慰的成果之一,就是不断开辟新的、更广阔的前景。
—尼古拉·特斯拉
要充分理解 Docker Swarm 所带来的挑战和好处,我们需要从头开始。我们需要回到代码库,决定如何构建、测试、运行、更新以及监控我们正在开发的服务。尽管目标是实现对 Swarm 集群的持续部署,但我们需要退后一步,首先探讨 持续集成 (CI)。我们为 CI 过程定义的步骤将决定我们如何朝着 持续交付 (CD) 迈进,再从那里迈向 持续部署 (CDP),最后确保我们的服务得到监控并能够自我修复。本章将探讨持续集成,作为更高级过程的前提条件。
致《DevOps 2.0 工具包》读者的说明 以下内容与 《DevOps 2.0 工具包》 中发布的文本完全相同。如果你记得这些内容,随时可以跳到子章节 定义完全 Docker 化的手动持续集成流程。自从我写了 2.0 版后,我发现了一些更好的方式来实现 CI 过程。即使你已经是 CI 方面的老手,我希望你仍能从本章中受益。
要理解持续部署,我们首先应该定义它的前身——持续集成和持续交付。
项目开发的集成阶段往往是 软件开发生命周期 (SDLC) 中最痛苦的阶段之一。我们会花费数周、数月甚至数年时间,分别在不同团队中为各自的应用程序和服务工作。每个团队都有自己的一组需求,并尽力满足这些需求。尽管定期独立验证这些应用程序和服务并不困难,但我们都害怕那一刻的到来——团队负责人决定是时候将它们集成到一个统一的交付中。凭借之前项目的经验,我们知道集成将会很有问题。我们知道会发现问题、未满足的依赖、接口之间的通讯不畅,而管理人员将会感到失望、沮丧和紧张。花费数周甚至数个月时间来处理这个阶段并不罕见。最糟糕的是,在集成阶段发现的一个 bug 可能意味着需要回头重做几天或几周的工作。如果当时有人问我对集成的看法,我会说,这是我离永久抑郁症最接近的状态。那是不同的时代,我们曾认为那是开发应用程序的 正确 方式。
自那以后,发生了许多变化。极限编程(XP)和其他敏捷方法变得普及,自动化测试变得频繁,持续集成开始占据主导地位。今天我们知道,当时我们开发软件的方式是错误的。从那些日子起,行业已经走了很长的路。
持续集成通常指的是在开发环境中集成、构建和测试代码。它要求开发人员经常将代码集成到共享代码库中。多频繁才算“经常”可以有多种解释,这取决于团队的规模、项目的大小以及我们投入编码的时间。在大多数情况下,这意味着开发人员要么直接推送代码到共享代码库,要么将代码与共享代码库进行合并。不管是推送还是合并,在大多数情况下,这些操作应当至少每天进行几次。将代码推送到共享代码库并不够,我们需要一个流水线,至少要检查代码并运行所有与代码相关的测试(无论是直接还是间接的)。流水线执行的结果可以是红色或者绿色。即要么某些东西失败了,要么所有的测试都没有问题。在前者的情况下,最基本的行动是通知提交代码的人。
持续集成流水线应该在每次提交或推送时运行。与持续交付不同,持续集成并没有明确的流水线目标。说一个应用与其他应用集成并没有告诉我们它的生产准备情况。我们并不知道还需要做多少工作才能将代码交付到生产环境。我们唯一追求的目标是确保一次提交没有破坏任何现有的测试。然而,当做得对时,CI 是一个巨大的改进。在许多情况下,它是一个非常难以实施的实践,但一旦每个人都适应了它,通常结果会非常令人印象深刻。
集成测试需要与实现代码一起提交,或者至少在实现代码之前提交。为了获得最大效益,我们应当采用测试驱动开发(TDD)的方式来编写测试。这样,不仅测试能够和实现代码一起提交,而且我们知道测试没有问题,无论我们做什么都不会失败。TDD 带来了许多其他好处,如果你还没有采用它,我强烈建议你采纳。你可能想参考测试驱动开发(technologyconversations.com/category/test-driven-development/)部分,位于技术对话(technologyconversations.com/)博客中。
测试并不是唯一的 CI 前提条件。最重要的规则之一是,当流水线失败时,修复问题的优先级高于其他任何任务。如果这项工作被推迟,那么下一次流水线执行也会失败。人们将开始忽略失败通知,渐渐地,CI 流程将失去其意义。我们越早解决在 CI 流水线执行过程中发现的问题,我们就越好。如果立即采取纠正措施,那么关于问题潜在原因的知识仍然是新鲜的(毕竟,从提交到失败通知只有几分钟的时间),修复起来应该是微不足道的。
定义一个完全 Docker 化的手动持续集成流程
每个持续集成过程都从从仓库中检出的代码开始。本书中,我们将使用 GitHub 仓库 vfarcic/go-demo (github.com/vfarcic/go-demo)。它包含了本书中将使用的服务的代码。该服务是用 Go (golang.org/) 编写的。别担心!尽管我认为它是目前最好的编程语言之一,你并不需要学习 Go。本书将使用 go-demo 服务仅作为展示整个流程的示范。虽然我强烈推荐学习 Go,但本书不假设你有任何关于该语言的知识。所有的示例都将是与编程语言无关的。
本章节中的所有命令都可以在 01-continuous-integration.sh (gist.github.com/vfarcic/886ae97fe7a98864239e9c61929a3c7c) Gist 中找到。
Windows 用户提示
请确保你的 Git 客户端已配置为按原样检出代码 AS-IS。否则,Windows 可能会将回车符转换为 Windows 格式。
让我们开始吧,首先检出 go-demo 代码:
git clone https://github.com/vfarcic/go-demo.git
cd go-demo
有些文件将在主机文件系统和我们即将创建的 Docker Machines 之间共享。Docker Machine 使当前用户所属的整个目录可以在虚拟机内访问。因此,请确保代码已检出到用户的某个子文件夹内。
既然我们已经从仓库中检出了代码,接下来我们需要一台服务器来构建和运行测试。暂时,我们将使用 Docker Machine,因为它提供了一种在我们的笔记本上轻松创建“Docker 就绪”虚拟机的方式。
Docker Machine (docs.docker.com/machine/overview/) 是一款工具,允许你在虚拟主机上安装 Docker 引擎,并使用 docker-machine 命令来管理主机。你可以使用 Docker Machine 在本地的 Mac 或 Windows 主机、公司网络、数据中心,或者像 AWS 或 DigitalOcean 这样的云服务提供商上创建 Docker 主机。
使用docker-machine命令,你可以启动、检查、停止和重启托管主机,升级 Docker 客户端和守护进程,并配置 Docker 客户端与主机通信。
在Docker v1.12之前,Machine 是唯一在 Mac 或 Windows 上运行 Docker 的方法。从 Beta 版本和Docker v1.12开始,Docker for Mac 和 Docker for Windows 作为本地应用程序发布,是更新桌面和笔记本电脑上的更好选择。我鼓励你尝试这些新应用。Docker for Mac 和 Docker for Windows 的安装程序包括 Docker Machine 以及 Docker Compose。
以下示例假设你使用的是Docker Machine v0.9(www.docker.com/products/docker-machine),其中包括Docker Engine v1.13+(www.docker.com/products/docker-engine)。安装说明可以在Install Docker Machine(docs.docker.com/machine/install-machine/)页面找到。
Windows 用户注意
推荐使用Git Bash运行所有示例(通过Docker Toolbox以及 Git 安装)。这样,你将在本书中看到的命令与在OS X或任何Linux发行版上执行的命令相同。Linux 用户注意
在 Linux 上,Docker Machine 可能无法将主机卷挂载到虚拟机中。问题与主机和 Docker Machine 操作系统都使用/home目录有关。挂载主机的/home目录会覆盖一些必要的文件。如果你遇到挂载主机卷的问题,请导出VIRTUALBOX_SHARE_FOLDER变量:
export VIRTUALBOX_SHARE_FOLDER="$PWD:$PWD"
如果机器已经创建,你需要销毁它们并重新创建。
请注意,这个问题应该在更新版本的 Docker Machine 中得到解决,所以只有在你注意到卷没有被挂载(主机中的文件在虚拟机内不可用)时才使用这个解决方法。
让我们创建第一个名为go-demo的服务器,使用以下命令:
docker-machine create -d virtualbox go-demo
Windows 用户注意
如果你使用的是Docker for Windows而非Docker Toolbox,你需要将驱动程序从 virtualbox 更改为 Hyper-V。问题在于,Hyper-V 不允许挂载主机卷,因此在使用Docker Machine时,仍然强烈建议使用Docker Toolbox。选择在Docker Machines内运行Docker而不是直接在主机上运行的原因在于需要运行集群(将在下一章介绍)。Docker Machine是模拟多节点集群的最简单方法。
该命令应该是自解释的。我们指定了 virtualbox 作为驱动程序(或者如果你使用Docker for Windows则是 Hyper-V),并将机器命名为go-demo:
Windows 用户注意
在某些情况下,Git Bash 可能会认为它仍然以 BAT 模式运行。如果你在运行 docker-machine env 命令时遇到问题,请导出 SHELL 变量:
export SHELL=bash
现在机器已经在运行,我们应该指示本地的 Docker 引擎使用它,使用以下命令:
docker-machine env go-demo
docker-machine env go-demo命令输出本地引擎需要的环境变量,以便找到我们想要使用的服务器。在这种情况下,远程引擎位于我们通过docker-machine create命令创建的虚拟机中。
输出如下:
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://192.168.99.100:2376"
export DOCKER_CERT_PATH="/Users/vfarcic/.docker/machine/machines/go-demo"
export DOCKER_MACHINE_NAME="go-demo"
我们可以将env命令封装进一个eval命令中,该命令会评估输出,并在这种情况下,使用以下命令创建环境变量:
eval $(docker-machine env go-demo)
从现在开始,我们在本地执行的所有 Docker 命令都会通过 go-demo 机器内的引擎执行。
现在我们准备好运行 CI 流程中的前两步。我们将执行单元测试并构建服务二进制文件。
运行单元测试并构建服务二进制文件
我们将使用 Docker Compose 来执行 CI 流程。正如你很快会看到的,Docker Compose 在操作集群时几乎没有任何价值。然而,对于应该在单一机器上执行的操作,Docker Compose 仍然是最简单且最可靠的方式。
Compose 是一个定义和运行多容器 Docker 应用程序的工具。使用 Compose,你可以通过 Compose 文件来配置应用程序的服务。然后,使用一个命令,你就可以从配置中创建并启动所有服务。Compose 非常适合开发、测试、暂存环境以及 CI 工作流。
我们之前克隆的仓库已经在docker-compose-test-local.yml文件中定义了我们需要的所有服务(github.com/vfarcic/go-demo/blob/master/docker-compose-test-loc)。
让我们来看看docker-compose-test-local.yml文件的内容:(github.com/vfarcic/go-demo/blob/master/docker-compose-test-local.yml)
cat docker-compose-test-local.yml
我们将用于单元测试的服务名为unit,它如下所示:
unit:
image: golang:1.6
volumes:
- .:/usr/src/myapp
- /tmp/go:/go
working_dir: /usr/src/myapp
command: bash -c "go get -d -v -t && go test --cover -v \
./... && go build -v-o go-demo"
这是一个相对简单的定义。由于服务是用Go编写的,我们使用的是golang:1.6镜像。
接下来,我们将暴露一些卷。卷是指在这种情况下挂载到主机上的目录。它们通过两个参数定义。第一个参数是主机目录的路径,第二个表示容器内部的目录。任何已经存在于主机目录中的文件都将可以在容器内访问,反之亦然。
第一个卷用于源文件。我们将当前的主机目录 . 与容器目录 /usr/src/myapp 共享。第二个卷用于Go库。由于我们希望避免每次运行单元测试时都下载所有依赖项,因此它们将存储在主机目录 /tmp/go 中。这样,依赖项只有在第一次运行服务时才会被下载。
卷后面跟着 working_dir 指令。当容器运行时,它将使用指定的值作为起始目录。
最后,我们指定了要在容器内运行的命令。我不会详细介绍这些命令,因为它们特定于Go。简而言之,我们下载所有依赖项 go get -d -v -t,运行 unit 测试 go test --cover -v ./...,并构建 go-demo 二进制文件 go build -v -o go-demo。由于包含源代码的目录已作为卷挂载,因此二进制文件将存储在主机上并可供后续使用。
通过这个单一的 Compose 服务,我们定义了 CI 流程的两个步骤。它包含单元测试和二进制文件的构建。
请注意,尽管我们运行名为 unit 的服务,但该 CI 步骤的实际目的是运行任何不需要部署的测试。这些测试是在构建二进制文件之前,我们可以执行的测试,稍后还可以运行 Docker 镜像。
让我们运行以下代码:
docker-compose \
-f docker-compose-test-local.yml \
run --rm unit
Windows 用户注意
你可能会遇到卷未正确映射的问题。如果你看到 Invalid volume specification error 错误,请将环境变量 COMPOSE_CONVERT_WINDOWS_PATHS 导出并设置为 0:
export COMPOSE_CONVERT_WINDOWS_PATHS=0
如果这解决了卷的问题,请确保每次运行 docker-compose 时都导出该变量。
我们指定 Compose 使用 docker-compose-test-local.yml 文件(默认是 docker-compose.yml)并运行名为 unit 的服务。--rm 参数表示容器停止后应被移除。该命令适用于那些不打算长期运行的服务。它非常适合批处理任务,以及在本例中用于运行测试。
从输出中可以看到,我们拉取了 golang 镜像,下载了服务依赖项,成功运行了测试,并构建了二进制文件。
我们可以通过列出当前目录中的文件来确认二进制文件确实已构建并且可以在主机上使用。为了简便起见,我们将过滤结果:
ls -l *go-demo*
现在我们通过了第一轮测试并且有了二进制文件,我们可以继续构建 Docker 镜像。
构建服务镜像
Docker 镜像是通过存储在 Dockerfile 中的定义来构建的。除少数例外,它采用的方式类似于定义一个简单的脚本。我们不会探索定义 Dockerfile 时可以使用的所有选项,而仅会讲解 go-demo 服务使用的选项。有关更多信息,请参考 Dockerfile 参考(docs.docker.com/engine/reference/builder/)页面。
go-demo Dockerfile 如下所示:
FROM alpine:3.4
MAINTAINER Viktor Farcic <viktor@farcic.com>
RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2
EXPOSE 8080
ENV DB db
CMD ["go-demo"]
HEALTHCHECK --interval=10s CMD wget -qO- localhost:8080/demo/hello
COPY go-demo /usr/local/bin/go-demo
RUN chmod +x /usr/local/bin/go-demo
每个语句将构建为一个独立的镜像。容器是多个镜像层叠在一起的集合。
每个 Dockerfile 都以 FROM 语句开始。它定义了应使用的基础镜像。在大多数情况下,我更倾向于使用 alpine Linux。由于它的大小约为 2MB,可能是我们可以使用的最小的发行版。这与容器应该只包含所需的内容,并避免任何额外开销的理念一致。
MAINTAINER 仅用于提供信息。
RUN 语句执行作为其参数设置的任何命令。由于它非常具体于我们正在构建的服务,我就不再解释了。
EXPOSE 语句定义了服务监听的端口。接下来是环境变量 DB 的定义,它告诉服务数据库的地址。默认值为 db,如你将很快看到的,它可以在运行时进行更改。CMD 语句表示容器启动时将执行的命令。
HEALTHCHECK 指令告诉 Docker 如何测试容器,以确保它仍然正常工作。这可以检测出诸如 Web 服务器卡在无限循环中,无法处理新连接的情况,尽管服务器进程仍在运行。当容器指定了健康检查时,它将拥有健康状态,除了正常状态外。该状态最初为“启动中”。每当健康检查通过时,它就变为“健康”(无论之前处于什么状态)。在连续失败达到一定次数后,它会变为“不健康”。
在我们的例子中,健康检查将每十秒执行一次。该命令向 API 端点之一发送一个简单的请求。如果服务返回状态 200,wget 命令将返回 0,Docker 会认为服务是健康的。任何其他响应都将被视为不健康,Docker 引擎会采取某些措施来修复该情况。
最后,我们将 go-demo 二进制文件从主机复制到镜像内的 /usr/local/bin/ 目录,并使用 chmod 命令赋予其可执行权限。
对某些人来说,语句的顺序可能看起来不太合逻辑。然而,这些声明及其顺序背后是有充分理由的。那些不太可能更改的声明会先于那些容易更改的声明定义。由于 go-demo 每次构建镜像时都会是一个新的二进制文件,所以它被定义在最后。
这种顺序的原因在于 Docker 引擎创建镜像的方式。它从最上面的定义开始,检查自上次构建以来是否有变化。如果没有变化,它将跳到下一个语句。一旦找到可能会生成新镜像的语句,它和所有后续的语句都将构建成 Docker 镜像。通过将那些不太可能改变的部分放置在顶部,我们可以减少构建时间、磁盘使用量和带宽消耗。
现在我们理解了go-demo服务背后的 Dockerfile,我们可以构建镜像。
该命令非常简单,如下所示:
docker build -t go-demo .
作为替代,我们可以在 Docker Compose 文件中定义构建参数。docker-compose-test-local.yml文件中定义的服务如下:github.com/vfarcic/go-demo/blob/master/docker-compose-test-local.yml
app:
build: .
image: go-demo
在这两种情况下,我们都指定了当前目录应作为构建过程的工作目录。,并且镜像的名称为go-demo。
我们可以通过以下命令使用 Docker Compose 运行构建:
docker-compose \
-f docker-compose-test-local.yml \
build app
我们将在本书的其余部分中使用后一种方法。
我们可以通过执行docker images命令来确认镜像确实已经构建,如下所示:
docker images
输出如下:
REPOSITORY TAG IMAGE ID CREATED SIZE
go-demo latest 5e90126bebf1 49 seconds ago 23.61 MB
golang 1.6 08a89f0a4ee5 11 hours ago 744.2 MB
alpine latest 4e38e38c8ce0 9 weeks ago 4.799 MB
如你所见,go-demo是我们服务器中存储的镜像之一。
现在镜像已经构建完成,我们可以运行依赖于该服务及其依赖项在服务器上部署的预发布测试。
运行预发布测试
请注意,此步骤在 CI 流程中的真正目的是运行那些需要服务及其依赖项正在运行的测试。这些还不是需要生产环境或类似生产环境的集成测试。这些测试的目的是将服务与其直接依赖项一起运行,执行测试,并在测试完成后移除所有内容并释放资源以进行其他任务。由于这些还不是集成测试,所以某些(如果不是全部)依赖项可以是模拟对象。
由于这些测试的性质,我们需要将任务分为三个步骤:
-
运行服务及所有依赖项。
-
运行测试。
-
销毁服务及所有依赖项。
依赖项在docker-compose-test-local.yml文件中被定义为staging-dep服务。该定义如下:github.com/vfarcic/go-demo/blob/master/docker-compose-test-local.yml
staging-dep:
image: go-demo
ports:
- 8080:8080
depends_on:
- db
db:
image: mongo:3.2.10
该镜像为go-demo,并且暴露了8080端口(在主机和容器内均为该端口)。它依赖于名为db的mongo镜像。定义为depends_on的服务将在定义该依赖关系的服务之前运行。换句话说,如果我们运行staging-dep目标,Compose 会首先运行db。
让我们按照以下代码运行依赖项:
docker-compose \
-f docker-compose-test-local.yml \
up -d staging-dep
一旦命令完成,我们将有两个容器在运行(go-demo和db)。我们可以通过列出所有进程来确认这一点:
docker-compose \
-f docker-compose-test-local.yml \
ps
输出如下:
Name Command State Ports
---------------------------------------------------------------------
godemo_db_1 /entrypoint.sh mongod Up 27017/tcp
godemo_staging-dep_1 go-demo Up 0.0.0.0:8080->8080/tcp
现在,服务及其依赖的数据库正在运行,我们可以执行测试。它们被定义为服务暂存。其定义如下:
staging:
extends:
service: unit
environment:
- HOST_IP=localhost:8080
network_mode: host
command: bash -c "go get -d -v -t && go test --tags integration -v"
由于暂存测试的定义与我们作为单元测试运行的测试非常相似,暂存服务扩展了单元测试。通过扩展服务,我们继承了它的完整定义。接下来,我们定义了一个环境变量HOST_IP。测试代码使用该变量来确定待测试服务的位置。在此情况下,由于go-demo服务与测试在同一服务器上运行,因此 IP 是服务器的本地主机。由于默认情况下,容器内的 localhost 与主机上的 localhost 不相同,我们不得不将network_mode定义为host。最后,我们定义了应该执行的命令。它将下载测试依赖项go get -d -v -t并运行测试go test --tags integration -v。
让我们运行以下命令:
docker-compose \
-f docker-compose-test-local.yml \
run --rm staging
所有测试都通过了,我们离实现完全确信服务确实可以安全部署到生产环境的目标又近了一步。
我们不再需要保持服务和数据库运行,因此让我们将它们移除,释放资源以供其他任务使用:
docker-compose \
-f docker-compose-test-local.yml \
down
down命令停止并移除在该 Compose 文件中定义的所有服务。我们可以通过运行以下ps命令来验证这一点:
docker-compose \
-f docker-compose-test-local.yml \
ps
输出如下:
Name Command State Ports
------------------------------
只有一件事还缺少,才能完成 CI 流程。目前,我们有一个只在 go-demo 服务器内部可用的go-demo镜像。我们应该将其存储在一个注册表中,以便其他服务器也能访问它。
将镜像推送到注册表
在推送我们的go-demo镜像之前,我们需要一个目标位置来推送。Docker 提供了多种作为注册表的解决方案。我们可以使用Docker Hub(hub.docker.com/)、Docker Registry(docs.docker.com/registry/)和Docker Trusted Registry(docs.docker.com/docker-trusted-registry/)。除此之外,还有许多第三方供应商提供的其他解决方案。
我们应该使用哪个注册表?Docker Hub 需要用户名和密码,而我不够信任你来提供我的密码。开始编写本书之前,我定义的一个目标是只使用开源工具,因此 Docker Trusted Registry,尽管在不同的情况下是一个很好的选择,但也不适合。剩下的唯一选择(不包括第三方解决方案)是Docker Registry(docs.docker.com/registry/)。
注册表在 docker-compose-local.yml (github.com/vfarcic/go-demo/blob/master/docker-compose-local.yml) 这个 Compose 文件中被定义为其中的一个服务。定义如下:
registry:
container_name: registry
image: registry:2.5.0
ports:
- 5000:5000
volumes:
- .:/var/lib/registry
restart: always
我们将注册表设置为一个明确的容器名称,指定了镜像,并打开了 5000 端口(主机和容器内都开放)。
注册表将镜像存储在 /var/lib/registry 目录下,因此我们将其挂载为主机上的一个卷。这样,如果容器失败,数据就不会丢失。由于这是一个生产环境服务,可能会被许多人使用,我们定义了在失败时它应始终重启。
让我们运行以下命令:
docker-compose \
-f docker-compose-local.yml \
up -d registry
现在我们已经有了注册表,可以进行一次干运行。让我们确认我们能否从中拉取并 push 镜像:
docker pull alpine
docker tag alpine localhost:5000/alpine
docker push localhost:5000/alpine
Docker 使用命名约定来决定从哪里拉取和推送镜像。如果名称前面有地址,Docker 引擎将利用这个地址来确定注册表的位置。否则,系统会默认我们要使用 Docker Hub。因此,第一条命令从 Docker Hub 拉取了 alpine 镜像。
第二条命令创建了 alpine 镜像的标签。这个标签是我们注册表地址 localhost:5000 和镜像名称的组合。最后,我们将 alpine 镜像推送到了同一台服务器上的注册表。
在我们开始更正式地使用注册表之前,先确认镜像确实已经在主机上持久化:
ls -1 docker/registry/v2/repositories/alpine/
输出如下:
_layers
_manifests
_uploads
我不会详细说明这些子目录各自包含的内容。需要注意的重要事项是,注册表在主机上持久化存储镜像,因此如果它失败,或者在这种情况下,即使我们销毁了虚拟机,由于该机器目录映射到我们笔记本上的相同目录,数据也不会丢失。
我们在声明这个注册表应该用于生产时有点匆忙。即使数据得到了持久化,如果整个虚拟机崩溃,仍会有停机时间,直到有人重新启动它或创建一个新的。由于我们的目标之一是尽可能避免停机,因此稍后我们应该寻找更可靠的解决方案。目前的设置暂时可以使用。
现在我们准备将 go-demo 镜像推送到注册表:
docker tag go-demo localhost:5000/go-demo:1.0
docker push localhost:5000/go-demo:1.0
和 Alpine 示例一样,我们使用注册表前缀标记了镜像并将其推送到注册表。我们还添加了版本号 1.0。
推送是持续集成流程中的最后一步。我们运行了单元测试,构建了二进制文件,构建了 Docker 镜像,运行了预发布测试,并将镜像推送到了注册表。尽管我们完成了所有这些步骤,但我们仍然不确定服务是否已准备好进入生产环境。我们从未测试过它在部署到生产(或类似生产环境的)集群后会如何表现。我们做了很多,但还不够。
如果持续集成是我们的最终目标,那么此时应该进行手动验证。虽然手动工作中有很多需要创造力和批判性思维的价值,但我们不能对重复性任务说同样的话。将这个持续集成流程转化为持续交付,并最终实现部署的任务,确实是重复性的。
我们已经完成了 CI 流程,现在是时候再多做一步,将其转换为持续交付(Continuous Delivery)。
在进入将持续集成(Continuous Integration)过程转化为持续交付(Continuous Delivery)的步骤之前,我们需要退后一步,探索集群管理。毕竟,在大多数情况下,没有集群就没有生产环境。
我们将在每一章结束时销毁虚拟机。这样,你可以随时回到书中的任何部分并进行练习,而不必担心可能需要执行前面章节中的某些步骤。此外,这样的操作会迫使我们重复一些内容,熟能生巧。为了减少你的等待时间,我尽力将事情做到尽可能小,并将下载时间降到最低。执行以下命令:
docker-machine rm -f go-demo
下一章将专门讲解 Swarm 集群的设置和操作。
第二章:设置和操作 Swarm 集群
设计系统的组织……受到这些组织的通信结构的限制,只能生产这些组织通信结构的复制品。
–M. Conway*
许多人会告诉你他们拥有一个可扩展的系统。毕竟,扩展很简单。买一台服务器,安装 WebLogic(或者你使用的其他大型应用服务器),然后部署应用程序。接着等上几周,直到你发现一切都变得如此快速,以至于你可以点击一个按钮,喝杯咖啡,等你回到桌前,结果已经在那里等着你。你会怎么做?你扩展。你再买几台服务器,安装你那些庞大的应用服务器,然后在它们上面部署你的庞大应用程序。系统的瓶颈在哪个部分?没人知道。为什么要重复所有的东西?因为你不得不这么做。然后时间过去了,你继续扩展,直到钱花光了,同时,你的员工也都快疯了。今天我们不再像那样进行扩展。今天我们明白,扩展涉及到很多其他的事情。它关乎弹性,关乎根据流量的变化和业务的增长,能够快速、轻松地进行扩展和收缩,而且在这个过程中不至于破产。它关乎几乎每家公司都需要扩展他们的业务,而不会认为 IT 部门是一项负担。它关乎摆脱那些庞然大物。
《DevOps 2.0 工具集》读者须知
以下内容与《DevOps 2.0 工具集》中发布的文本完全相同。如果这部分内容你已经记得很清楚,可以直接跳到本章的Docker Swarm 模式部分。你会发现很多东西已经发生了变化。其中之一就是,旧版的 Swarm 作为独立容器运行的方式已经被废弃,取而代之的是Swarm 模式。在接下来的过程中,我们还会发现许多其他新内容。
可扩展性
让我们稍微退后一步,讨论一下为什么我们要扩展应用程序。主要原因是高可用性。为什么我们需要高可用性?因为我们希望我们的业务在任何负载下都能保持可用。负载越大越好(除非你遭遇了 DDoS 攻击)。这意味着我们的业务在蓬勃发展。拥有高可用性,我们的用户会很满意。我们都希望速度快,而且很多人如果网站加载太慢就会直接离开。我们希望避免宕机,因为每一分钟我们的业务无法运行都可能导致经济损失。如果一家在线商店无法访问,你会怎么做?大概会去另一个商店吧。也许不是第一次,也许不是第二次,但迟早你会受够了,换个商店。我们已经习惯了一切都很快速且响应迅速,而且有那么多替代选择,以至于我们在尝试其他东西之前不会多想。如果那个“其他”东西更好呢?一个人的损失可能是另一个人的收益。我们是否能通过扩展性解决所有问题?远远不够。还有许多其他因素决定了我们应用程序的可用性。然而,扩展性是其中的重要部分,而它恰好是本章的主题。
什么是扩展性?它是系统的一种属性,表示系统优雅地处理增加负载的能力,或在需求增加时扩展的潜力。它是接受增加的流量或工作负载的能力。
事实上,我们设计应用程序的方式决定了可用的扩展选项。如果应用程序没有设计为可扩展,它们将无法很好地扩展。这并不是说没有为扩展设计的应用程序就不能扩展。一切都可以扩展,但并不是所有的扩展都能做得好。
常见的场景如下。
我们从一个简单的架构开始,有时有负载均衡器,有时没有,设置几个应用服务器和一个数据库。一切都很好,复杂性较低,我们可以非常快速地开发新功能。运营成本低,收入高(考虑到我们刚刚起步),大家都很高兴并充满动力。
业务在增长,流量在增加。问题开始出现,性能下降。防火墙被添加,额外的负载均衡器被设置,数据库被扩展,增加了更多的应用服务器,等等。事情仍然相对简单。我们面临着新的挑战,但可以及时克服障碍。尽管复杂性在增加,但我们仍然能够相对轻松地处理它。换句话说,我们正在做的事情基本上还是一样的,只是规模变大了。业务做得很好,但仍然相对较小。
然后它发生了。你一直在等待的大事。也许某个营销活动得到了很好的反响。也许你的竞争对手发生了不利的变化。也许那个最后推出的功能确实非常关键。不管原因是什么,业务得到了大幅提升。在经历了这段由变化带来的短暂幸福之后,你的痛苦增加了十倍。增加更多的数据库似乎不够。扩展应用服务器似乎也不能满足需求。你开始加入缓存等等。你开始有一种感觉,每次你扩展某样东西时,收益并没有成比例地增加。成本在增加,但你仍然无法满足需求。数据库复制太慢。新的应用服务器已不再带来那么大的差异。运营成本的增长速度超出了你的预期。这个局面正在伤害业务和团队。你开始意识到,你曾经为之自豪的架构无法应对这种负载增长。你无法将它拆分。你无法扩展最痛苦的部分。你无法从头开始。你能做的只是继续扩展,但每次扩展的收益都在递减。
上述情况是相当常见的。一开始有效的方案,在需求增加时不一定还是对的。我们需要平衡你不需要的东西(YAGNI)原则和长期愿景。我们不能一开始就用针对大公司的系统来优化,因为它成本过高,并且在业务小的时候无法带来足够的收益。另一方面,我们也不能忽视任何业务的首要目标之一。从第一天起,我们就不能不考虑扩展性。设计可扩展的架构并不意味着我们必须从一开始就部署百台服务器的集群。也不意味着我们要从一开始就开发庞大复杂的系统。它的意思是,我们应该从小做起,但要确保当系统变大时,能够轻松扩展。虽然微服务并不是唯一能够实现这一目标的方式,但它们的确是一个很好的解决方案。成本不在于开发,而是在于运营。如果运营是自动化的,这个成本可以很快被吸收,并且不需要大量的投资。正如你已经看到的(并将在本书的其余部分继续看到),我们有许多优秀的开源工具可以使用。自动化的最大好处是,投资的维护成本往往低于手动操作时的成本。
我们已经讨论了微服务以及在小规模上的自动化部署。现在是时候将这种小规模转变为更大的规模了。在我们跳入实际部分之前,让我们探索一下人们可以采用哪些不同的方式来扩展。
我们常常受到设计的限制,选择应用程序的构建方式严重限制了我们的选择。尽管有许多不同的扩展方式,最常见的一种叫做轴扩展。
轴扩展
轴扩展最好的表示方式是通过立方体的三个维度:X 轴、Y 轴和Z 轴。每个维度描述了一种扩展类型:
-
X 轴:水平复制
-
Y 轴:功能分解
-
Z 轴:数据分区

图 2-1:扩展立方体
让我们逐一了解这些轴。
X 轴扩展
简而言之,x 轴扩展是通过运行多个应用实例或服务实例来实现的。在大多数情况下,会有一个负载均衡器在上面,确保流量均匀分配到所有实例上。x 轴扩展的最大优点是简便性。我们只需要将相同的应用部署到多个服务器上。因此,这是最常用的扩展方式。然而,当应用是单体应用时,这种扩展方式也有一系列缺点。
拥有一个大型应用通常需要一个大缓存,这就要求大量使用内存。当这样的应用被复制时,一切都会被复制,包括缓存。另一个,通常更重要的问题是资源的不当使用。性能问题几乎从不与整个应用相关。并不是所有模块都受到同等影响,但我们却将它们全部复制。这意味着,尽管我们可以通过仅扩展需要的部分应用来获得更好的效果,但我们却扩展了整个应用。尽管如此,x 轴扩展在任何架构中都是重要的。主要的区别在于这种扩展的影响。

图 2-2:在集群中扩展的单体应用
通过使用微服务,我们并不是去除对x 轴扩展的需求,而是确保由于它们的架构,使得这种扩展比传统架构方法更有效。在微服务架构中,我们有更多的选择来微调扩展。我们可以为承受重负载的服务部署多个实例,而对于使用较少或需要较少资源的服务则只部署少数实例。除此之外,由于微服务体积小,我们可能永远不会达到某个服务的限制。在大服务器上运行一个小服务,只有当流量达到极高的水平时,才会需要扩展。微服务的扩展更多地与容错能力相关,而非性能问题。我们希望有多个副本在运行,这样当某个副本宕机时,其他副本可以接管,直到恢复完成。
Y 轴扩展
Y 轴扩展完全是关于将应用程序分解成更小的服务。尽管有多种方法可以完成这种分解,但微服务可能是我们可以采取的最佳方法。当它们与不可变性和自给自足结合时,确实没有比这更好的替代方案(至少从 Y 轴扩展的角度来看)。与 X 轴扩展不同,Y 轴扩展不是通过运行多个相同应用程序的实例来实现的,而是通过将多个不同的服务分布到集群中。
Z 轴扩展
Z 轴扩展很少应用于应用程序或服务。它的主要和最常见的用途是在数据库中。此类扩展背后的理念是将数据分布到多个服务器上,从而减少每个服务器需要执行的工作量。数据被分区并分配,使得每个服务器只需要处理数据的一个子集。这种分离通常被称为分片,并且有许多数据库专门为此目的而设计。Z 轴扩展的好处在于 I/O、缓存和内存利用率上最为明显。
集群
服务器集群由一组连接的服务器组成,这些服务器共同工作,可以看作是一个单一系统。它们通常通过高速局域网(LAN)连接。集群与服务器组的显著区别在于,集群作为一个单一系统,旨在提供高可用性、负载均衡和并行处理。
如果我们将应用程序或服务部署到单独管理的服务器上,并将它们视为独立单元,那么资源的利用率将处于次优状态。我们无法预先知道哪些服务组应该部署到某台服务器上,并将资源利用到最大。此外,资源使用往往会波动。比如,早上某些服务可能需要大量内存,而下午它们的使用量可能会降低。预定义的服务器限制了我们在最优方式下平衡资源使用的灵活性。即便不需要如此高的动态性,预定义的服务器也往往在出现问题时造成麻烦,导致需要人工干预,将受影响的服务重新部署到健康的节点上:

图 2-3:部署到预定义服务器上的容器集群
实现真正的集群是当我们不再只关注单独的服务器,而是开始将所有服务器视为一个大实体。要更好地理解这一点,我们可以稍微从更低的层次来解释。当我们部署应用时,我们通常会指定它可能需要多少内存或 CPU。然而,我们并不会决定应用使用哪个内存插槽或应使用哪些 CPU。例如,我们不会指定某个应用必须使用 CPU 4、5 和 7。这既低效又可能带来风险。我们只会决定需要三个 CPU。我们应该在更高层次上采用同样的方法。我们不应关心某个应用或服务将部署到哪里,而应关心它需要什么。我们应该能够定义该服务的某些需求,并告诉某个工具将它部署到集群中的任何服务器,只要它满足我们的需求。实现这一目标的最佳(如果不是唯一的)方法是将整个集群视为一个实体。
我们可以通过添加或移除服务器来增加或减少集群的容量,但无论做什么,它仍然应该是一个单一的实体。我们定义策略,然后让我们的服务在集群的某个地方部署。那些使用云服务提供商,如Amazon Web Services(AWS)、Microsoft Azure 和Google Compute Engine(GCE)的人,已经习惯了这种方法,尽管他们可能并没有意识到这一点。
在本章的其余部分,我们将探索创建集群的方法,并探讨可以帮助我们实现这一目标的工具。我们将在本地模拟集群,但这并不意味着相同的策略无法应用于公共或私有云和数据中心。恰恰相反:

图 2-4:基于预定义策略将容器部署到服务器的集群
Docker Swarm 模式
Docker Engine v1.12 于 2016 年 7 月发布。它是自v1.9以来最重要的版本。那时,我们获得了 Docker 网络功能,最终使得容器可以在集群中使用。通过v1.12,Docker 以全新的方式重新定义了集群编排。告别依赖外部数据注册表的 Swarm 容器,迎接全新的 Docker Swarm或Swarm 模式。现在,你管理集群所需的一切都已集成进 Docker Engine。Swarm 已经在其中。服务发现已经在其中。改进的网络功能也已在其中。这并不意味着我们不需要额外的工具。我们确实需要。不过,主要的区别在于,Docker Engine 现在集成了我们所需的所有“基本”工具(甚至可以说是最小化的工具)。
旧版 Swarm(在Docker v1.12之前)采用了一发即忘原则。我们会向 Swarm 主节点发送命令,它就会执行该命令。例如,如果我们发送类似docker-compose scale go-demo=5的命令,旧版 Swarm 会评估当前集群的状态,发现例如只有一个实例在运行,然后决定应该再启动四个实例。一旦做出这个决定,旧版 Swarm 就会向 Docker Engines 发送命令。结果,我们会在集群中运行五个容器。为了让这一切正常工作,我们需要在组成集群的所有节点上设置 Swarm 代理(作为独立容器),并将它们接入到其中一个支持的数据注册中心(如 Consul、etcd 或 Zookeeper)。
问题在于,Swarm 执行我们发送的命令,但它并没有维持所需的状态。我们实际上是在告诉它我们希望发生什么(例如:扩容),而不是我们期望的状态(确保有五个实例在运行)。后来,旧版 Swarm 获得了从故障节点重新调度容器的功能。然而,该功能存在一些问题,导致它不能成为一个可靠的解决方案(例如:故障容器没有从覆盖网络中移除)。
现在我们有了全新的 Swarm。它是 Docker Engine 的一部分(无需将其作为独立容器运行),并且集成了服务发现功能(无需设置 Consul 或任何你选择的数据注册中心),它从底层开始设计,能够接受并维持所需的状态,等等。这是我们处理集群编排方式的真正重大变化。
过去,我倾向于使用旧版 Swarm 而非 Kubernetes。然而,这种倾向只是稍微的。两者各有优缺点。Kubernetes 有一些 Swarm 所缺少的功能(例如:所需状态的概念),而旧版 Swarm 则因其简洁性和低资源使用而脱颖而出。随着新版本 Swarm(即v1.12版本)的推出,我再也没有疑问该使用哪个了。新版 Swarm 往往比 Kubernetes 更优选择。它是 Docker Engine 的一部分,因此整个设置只需一个命令来指示引擎加入集群。新的网络功能也表现得非常出色。可以用 Docker Compose 文件创建的捆绑包来定义服务,因此无需维护两套配置(开发时使用 Docker Compose,编排时使用另一套配置)。最重要的是,新版 Docker Swarm 继续保持简便易用。从一开始,Docker 社区就承诺致力于简洁性,而通过这个发布,他们再次证明了这一点。
这还不是全部。新版本还带来了许多与 Swarm 直接无关的其他功能。然而,本书专注于集群管理。因此,我将专注于 Swarm,将其他内容留待下本书或博客文章中讨论。
因为我认为代码比文字更能解释问题,我们将从展示 1.12 版本 中的一些新特性开始。
设置 Swarm 集群
我们将继续使用 Docker Machine,因为它提供了一种非常方便的方式在笔记本上模拟集群。三个服务器应该足够展示 Swarm 集群的一些关键特性:
本章中的所有命令都可以在 02-docker-swarm.sh 文件中找到 (gist.github.com/vfarcic/750fc4117bad9d8619004081af171896) Gist
for i in 1 2 3; do
docker-machine create -d virtualbox node-$i
done
此时,我们有三台节点。请注意,这些服务器除了 Docker 引擎外没有运行任何其他服务。
我们可以通过执行以下 ls 命令来查看节点的状态:
docker-machine ls
输出如下(错误列已去除以简化内容):
NAME ACTIVE DRIVER STATE URL SWARM DOCKER
node-1 - virtualbox Running tcp://192.168.99.100:2376 v1.12.1
node-2 - virtualbox Running tcp://192.168.99.101:2376 v1.12.1
node-3 - virtualbox Running tcp://192.168.99.102:2376 v1.12.1

图 2-5:运行 Docker 引擎的机器
在机器启动并运行后,我们可以继续设置 Swarm 集群。
集群设置包含两种类型的命令。我们需要先初始化第一个节点,这将是我们的管理节点。请参考以下插图:
eval $(docker-machine env node-1)
docker swarm init \
--advertise-addr $(docker-machine ip node-1)
第一个命令设置了环境变量,使得本地的 Docker 引擎指向 node-1。第二个命令在该机器上初始化了 Swarm。
我们只在 swarm init 命令中指定了一个参数。--advertise-addr 是该节点将向其他节点公开的地址,用于内部通信。
swarm init 命令的输出如下:
Swarm initialized: current node (1o5k7hvcply6g2excjiqqf4ed) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join \
--token SWMTKN-1-3czblm3rypyvrz6wyijsuwtmk1ozd7giqip0m \
6k0b3hllycgmv-3851i2gays638e7unmp2ng3az \
192.168.99.100:2377
To add a manager to this swarm, run the following command:
docker swarm join \
--token SWMTKN-1-3czblm3rypyvrz6wyijsuwtmk1ozd7giqi \
p0m6k0b3hllycgmv-6oukeshmw7a295vudzmo9mv6i \
192.168.99.100:2377
我们可以看到该节点现在是管理节点,并且我们得到了可以用来将其他节点加入集群的命令。为了提高安全性,只有包含在初始化 Swarm 时生成的令牌的新节点才能加入集群。该令牌是在执行 docker swarm init 命令时作为输出结果打印出来的。您可以从输出中复制并粘贴代码,或者使用 join-token 命令。我们将使用后者。
目前,我们的 Swarm 集群仅由一个虚拟机组成。我们将把另外两个节点添加到集群中。但是,在此之前,让我们讨论一下 manager 和 worker 之间的区别。
Swarm 管理器持续监控集群的状态,并使实际状态与您设定的期望状态保持一致。例如,如果您设置了一个服务来运行十个容器副本,而托管其中两个副本的工作节点崩溃了,管理器将创建两个新的副本来替代失败的副本。Swarm 管理器将新副本分配给正在运行并可用的工作节点。管理节点具有所有工作节点的功能。
我们可以通过执行 swarm join-token 命令来获取添加额外节点到集群所需的令牌。
获取添加管理节点令牌的命令如下:
docker swarm join-token -q manager
类似地,要获取添加工作节点的令牌,我们将执行以下命令:
docker swarm join-token -q worker
在这两种情况下,我们都会得到一个长的哈希字符串。
工作节点令牌的输出如下:
SWMTKN-1-3czblm3rypyvrz6wyijsuwtmk1ozd7giqip0m6k0b3hll \ ycgmv-3851i2gays638\
e7unmp2ng3az
请注意,此令牌是在我的机器上生成的,您的令牌将会有所不同。
将令牌放入环境变量中,并将另外两个节点添加为工作节点:
TOKEN=$(docker swarm join-token -q worker)
现在令牌已存储在变量中,我们可以执行以下命令:
for i in 2 3; do
eval $(docker-machine env node-$i)
docker swarm join \
--token $TOKEN \
--advertise-addr $(docker-machine ip node-$i) \
$(docker-machine ip node-1):2377
done
我们刚刚运行的命令会遍历节点二和节点三并执行 swarm join 命令。我们设置了令牌、广告地址和管理节点的地址。结果,这两台机器作为工作节点加入了集群。我们可以通过向管理节点 node-1 发送 node ls 命令来确认这一点:
eval $(docker-machine env node-1)
docker node ls
node ls 命令的输出如下:
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS
3vlq7dsa8g2sqkp6vl911nha8 node-3 Ready Active
6cbtgzk19rne5mzwkwugiolox node-2 Ready Active
b644vkvs6007rpjre2bfb8cro * node-1 Ready Active Leader
星号告诉我们当前使用的是哪个节点。MANAGER STATUS 显示 node-1 是领导者:

图 2-6:包含三个节点的 Docker Swarm 集群
在生产环境中,我们可能会设置多个节点为管理节点,从而避免其中一个节点故障时导致部署停机。为了演示的目的,设置一个管理节点应该足够。
将服务部署到 Swarm 集群
在我们部署示例服务之前,我们应创建一个新网络,以便所有构成该服务的容器无论部署在哪个节点上,都能相互通信:
docker network create --driver overlay go-demo
下一章将更详细地探讨网络。现在,我们只讨论并完成在 Swarm 集群内高效部署服务所需的最低限度内容。
我们可以通过以下命令检查所有网络的状态:
docker network ls
network ls 命令的输出如下:
NETWORK ID NAME DRIVER SCOPE
e263fb34287a bridge bridge local
c5b60cff0f83 docker_gwbridge bridge local
8d3gs95h5c5q go-demo overlay swarm
4d0719f20d24 host host local
eafx9zd0czuu ingress overlay swarm
81d392ce8717 none null local
如您所见,我们有两个 swarm 范围的网络。一个名为 ingress,是我们设置集群时默认创建的。第二个 go-demo 是通过 network create 命令创建的。我们将把所有构成 go-demo 服务的容器分配到该网络。
下一章将深入探讨 Swarm 网络。现在,重要的是要理解所有属于同一网络的服务可以自由地相互通信。
go-demo 应用程序需要两个容器。数据将存储在 MongoDB 中。使用该 DB 的后端定义为 vfarcic/go-demo 容器。
我们首先在集群中的某个地方部署 mongo 容器。
通常,我们会使用约束条件来指定容器的要求(例如:硬盘类型、内存和 CPU 的数量等)。目前我们跳过这些,直接告诉 Swarm 在集群中的任何地方部署它:
docker service create --name go-demo-db \
--network go-demo \
mongo:3.2.10
请注意,我们没有指定 Mongo 所监听的端口 27017。这意味着该数据库将不会对除属于同一 go-demo 网络的其他服务外的任何人可访问。
如您所见,我们使用 service create 的方式与您可能已经熟悉的 Docker run 命令类似。
我们可以列出所有正在运行的服务:
docker service ls
根据service create和service ls命令之间经过的时间,你会看到REPLICAS列的值是零或一。服务创建后,值应该是*0/1*,意味着没有副本在运行,而目标是运行一个副本。一旦拉取了mongo镜像并且容器启动,值应该会变为*1/1*。
service ls命令的最终输出应该如下所示(为了简洁,已移除 ID):
NAME MODE REPLICAS IMAGE
go-demo-db replicated 1/1 mongo:3.2.10
如果我们需要更多关于go-demo-db服务的信息,我们可以运行service inspect命令:
docker service inspect go-demo-db
现在数据库已经运行,我们可以部署go-demo容器:
docker service create --name go-demo \
-e DB=go-demo-db \
--network go-demo \
vfarcic/go-demo:1.0
这个命令没有什么新鲜的内容。服务将会连接到go-demo网络。环境变量DB是go-demo服务的内部要求,告知代码数据库的地址。
到此为止,我们已经在集群中运行了两个容器(mongo和go-demo),并通过go-demo网络相互通信。请注意,它们目前还无法从网络外部访问。此时,用户无法访问服务 API。我们稍后会更详细地讨论这个问题。在此之前,我只给你一个提示:你需要一个反向代理,它能够利用新的 Swarm 网络功能。
我们再运行一次service ls命令:
docker service ls
结果是,在go-demo服务被拉取到目标节点后,应该如下所示(为了简洁,已移除 ID):
NAME MODE REPLICAS IMAGE
go-demo replicated 1/1 vfarcic/go-demo:1.0
go-demo-db replicated 1/1 mongo:3.2.10
如你所见,两个服务都以单个副本的形式运行:

图 2-8:Docker Swarm 集群中的容器通过 go-demo SDN 通信
如果我们想要扩展其中一个容器会发生什么?我们如何扩展我们的服务?
扩展服务
我们应该始终运行至少两个实例的服务。这样,它们可以共享负载,而且如果其中一个服务失败,就不会出现停机。我们很快会探讨 Swarm 的故障切换功能,并将负载均衡留到下一章。
比如说,我们可以告诉 Swarm,我们希望运行五个go-demo服务的副本:
docker service scale go-demo=5
使用service scale命令,我们安排了五个副本。Swarm 会确保在集群的某个地方运行五个go-demo实例。
我们可以通过已熟悉的service ls命令确认,确实有五个副本在运行:
docker service ls
输出如下(为了简洁,已移除 ID):
NAME MODE REPLICAS IMAGE
go-demo replicated 5/5 vfarcic/go-demo:1.0
go-demo-db replicated 1/1 mongo:3.2.10
如我们所见,五个go-demo服务的副本中有五个在运行。
service ps命令提供关于单个服务的更详细信息:
docker service ps go-demo
输出如下(为了简洁,已移除 ID 和 ERROR PORT 列):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
go-demo.1 vfarcic/go-demo:1.0 node-3 Running Running 1 minute ago
go-demo.2 vfarcic/go-demo:1.0 node-2 Running Running 51 seconds ago
go-demo.3 vfarcic/go-demo:1.0 node-2 Running Running 51 seconds ago
go-demo.4 vfarcic/go-demo:1.0 node-1 Running Running 53 seconds ago
go-demo.5 vfarcic/go-demo:1.0 node-3 Running Running 1 minute ago
我们可以看到,go-demo 服务正在运行五个实例,这些实例分布在三个节点上。由于它们都属于同一个 go-demo SDN,无论它们在集群中运行在哪个位置,都可以相互通信。同时,它们都无法从外部访问:

图 2-9:Docker Swarm 集群,其中 go-demo 服务已扩展至五个副本
如果其中一个容器停止运行,或者整个节点出现故障,会发生什么呢?毕竟,进程和节点迟早会发生故障。没有什么是完美的,我们需要为这种情况做好准备。
容错
幸运的是,容错策略是 Docker Swarm 的一部分。记住,当我们执行 service 命令时,我们并不是在告诉 Swarm 应该做什么,而是在告诉它我们希望的状态。反过来,Swarm 将尽力保持指定的状态,不管发生什么。
为了测试故障场景,我们将销毁其中一个节点:
docker-machine rm -f node-3
Swarm 需要一些时间才能检测到节点已宕机。一旦它检测到,便会重新调度容器。我们可以通过 service ps 命令来监控情况:
docker service ps go-demo
输出(在重新调度后)如下所示(ID 被省略以简化展示):

如你所见,经过短暂的时间,Swarm 在健康节点(node-1 和 node-2)之间重新调度了容器,并将那些在故障节点上运行的容器状态更改为 Shutdown。如果你的输出仍显示某些实例在 node-3 上运行,请稍等片刻并重新执行 service ps 命令。
现在怎么办?
这就结束了我们对 Docker v1.12+ 新 Swarm 特性基本概念的探索。
这就是运行一个 Swarm 集群所需了解的一切吗?远远不够!到目前为止,我们探讨的只是一个开始。还有很多问题等待解答。我们如何将服务暴露给公众?我们如何在没有停机时间的情况下部署新版本?我将在接下来的章节中尝试回答这些问题,以及其他一些问题。下一章将专门探讨我们如何将服务暴露给公众。我们将尝试将代理与 Swarm 集群集成。为此,我们需要深入了解 Swarm 网络。
现在是休息的时间,在进入下一章之前。像之前一样,我们将销毁所创建的机器并重新开始:
docker-machine rm -f node-1 node-2
第三章:Docker Swarm 网络和反向代理
大多数人购买家庭计算机的最有力理由将是将其连接到全国范围的通信网络。我们正处于这一真正的突破性进展的初期阶段——对于大多数人来说,它将像电话一样令人震惊。
–史蒂夫·乔布斯
软件定义网络(SDN)是高效集群管理的基石。没有它,分布在集群中的服务将无法找到彼此。
基于静态配置的代理并不适合高度动态调度的世界。服务会被创建、更新、在集群中移动、扩展和缩减,等等。在这种环境中,信息时刻在变化。
我们可以采用的一种方法是使用代理作为中央通信点,让所有服务通过它进行通信。这样的设置将要求我们持续监控集群中的变化,并相应地更新代理。为了简化我们的工作,监控进程可能会使用其中一个服务注册中心来存储信息,并使用一个模板解决方案,每当注册中心检测到变化时,更新代理配置。正如你可以想象的那样,构建这样的系统一点也不简单。
幸运的是,Swarm 提供了全新的网络功能。简而言之,我们可以创建网络并将它们附加到服务上。所有属于同一网络的服务可以仅通过服务名称互相通信。更进一步,如果我们扩展某个服务,Swarm 网络将执行轮询负载均衡,并将请求分配到所有实例。当这一切仍然不够时,我们有一个新的网络,名为ingress,其包含routing mesh,具备所有这些功能及一些额外特性。
高效使用 Swarm 网络本身并不足够。我们仍然需要一个反向代理,作为外部世界与我们的服务之间的桥梁。除非有特殊要求,否则代理不需要执行负载均衡(Swarm 网络已经为我们完成这项工作)。然而,代理确实需要评估请求路径并将请求转发到目标服务。即使是这种情况,Swarm 网络仍然提供了很大的帮助。只要我们理解网络是如何工作的,并能够充分利用其潜力,配置反向代理变得相对容易。
让我们来实践一下网络功能。
设置集群
我们将创建一个与上一章相似的环境。我们将有三台节点,它们将形成一个 Swarm 集群。
本章中的所有命令都可以在03-networking.sh (gist.github.com/vfarcic/fd7d7e04e1133fc3c90084c4c1a919fe) Gist 中找到。
到这个时候,你已经知道如何设置集群了,所以我们跳过解释,直接开始:
for i in 1 2 3; do
docker-machine create -d virtualbox node-$i
done
eval $(docker-machine env node-1)
docker swarm init \
--advertise-addr $(docker-machine ip node-1)
TOKEN=$(docker swarm join-token -q worker)
for i in 2 3; do
eval $(docker-machine env node-$i)
docker swarm join \
--token $TOKEN \
--advertise-addr $(docker-machine ip node-$i) \
$(docker-machine ip node-1):2377
done
eval $(docker-machine env node-1)
docker node ls
上一个命令node ls的输出如下(为了简洁,已去除 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
node-2 Ready Active
node-1 Ready Active Leader
node-3 Ready Active
如你所见,我们有一个由三个节点组成的集群,其中node-1是唯一的管理节点(因此也是领导节点)。
现在我们有了一个完全运行的集群,可以探索 Docker 网络与 Swarm 结合所提供的好处。我们在上一章中已经使用过 Swarm 网络。现在是时候深入了解我们已经看到的内容,并解锁一些新的功能和用例了。
高可用性下运行的安全且容错的服务需求
让我们快速浏览一下go-demo应用程序的内部结构。它由两个服务组成。数据存储在 MongoDB 中。数据库由名为go-demo的后端服务使用。其他服务不应直接访问数据库。如果其他服务需要数据,它应该向go-demo服务发送请求。这样,我们就有了明确的边界。数据由go-demo服务拥有和管理。它暴露了一个 API,作为访问数据的唯一入口。
系统应该能够托管多个应用程序。每个应用程序将有一个唯一的基础 URL。例如,go-demo路径以/demo开头。其他应用程序将有不同的路径(例如:/users,/products,等等)。系统将只能通过80端口(HTTP)和443端口(HTTPS)进行访问。请注意,不能有两个进程监听相同的端口。换句话说,只能配置一个服务来监听端口80。
为了应对负载波动并有效利用资源,我们必须能够单独且独立地扩展(或缩减)每个服务。任何对服务的请求都应该通过负载均衡器,负载均衡器将把负载分配到所有实例上。至少,任何服务在任意时刻都应该运行至少两个实例。这样,即使其中一个实例停止工作,我们也能确保高可用性。我们的目标应该更高,确保即使整个节点发生故障,系统整体也不会受到影响。
为了满足性能和故障转移需求,服务应分布在集群中。
我们将对每个服务应该运行多个实例的规则做出一个临时例外。Mongo 卷在 OS X 和 Windows 上与 Docker Machine 不兼容。稍后,当我们进入关于如何在主要托管提供商(例如 AWS)内部进行生产环境设置的章节时,我们将取消这一例外,并确保数据库也配置为支持多个实例运行。
综合考虑所有这些,我们可以提出以下要求:
-
负载均衡器将均匀地分配请求(轮询方式)到任何给定服务的所有实例上(代理包括在内)。它应该是容错的,并且不依赖于任何单一节点。
-
一个反向代理将负责根据请求的基础 URL 路由请求。
-
go-demo服务将能够与go-demo-db服务自由通信,并且只能通过反向代理访问。
-
数据库将与任何不属于它的服务隔离,仅与go-demo服务进行通信。
我们所尝试实现的逻辑架构可以通过接下来的图表展示:

图 3-1:go-demo 服务的逻辑架构
我们如何实现这些要求?
让我们逐一解决这四个要求。我们将从底部开始,逐步向上解决。
第一个要解决的问题是如何让数据库与它所属的服务以外的任何服务隔离运行。
在隔离中运行数据库
我们可以通过不暴露其端口来隔离数据库服务。这可以通过service create命令轻松实现:
docker service create --name go-demo-db \
mongo:3.2.10
我们可以通过检查服务来确认端口确实没有暴露:
docker service inspect --pretty go-demo-db
输出如下:
ID: rcedo70r2f1njpm0eyb3nwf8w
Name: go-demo-db
Service Mode: Replicated
Replicas: 1
Placement:
UpdateConfig:
Parallelism: 1
On failure: pause
Max failure ratio: 0
ContainerSpec:
Image: mongo:3.2.10@sha256:532a19da83ee0e4e2a2ec6bc4212fc4af\
26357c040675d5c2629a4e4c4563cef
Resources:
Endpoint Mode: vip
如你所见,未提及任何端口。我们的go-demo-db服务是完全隔离的,任何人都无法访问。然而,这种隔离性过强了。我们希望服务仅与它所属的go-demo服务隔离。我们可以通过使用 Docker Swarm 网络来实现这一点。
让我们删除我们创建的服务并从头开始:
docker service rm go-demo-db
这次,我们应该创建一个网络,并确保go-demo-db服务已附加到该网络:
docker network create --driver overlay go-demo
docker service create --name go-demo-db \
--network go-demo \
mongo:3.2.10
我们创建了一个名为go-demo的覆盖网络,并随后创建了go-demo-db服务。这次,我们使用了--network参数将服务附加到该网络。从此时起,所有附加到go-demo网络的服务将彼此可访问。
让我们检查一下服务,并确认它是否确实已附加到网络:
docker service inspect --pretty go-demo-db
service inspect命令的输出如下:
ID: ktrxcgp3gtszsjvi7xg0hmd73
Name: go-demo-db
Service Mode: Replicated
Replicas: 1
Placement:
UpdateConfig:
Parallelism: 1
On failure: pause
Max failure ratio: 0
ContainerSpec:
Image: mongo:3.2.10@sha256:532a19da83ee0e4e2a2ec6bc4212fc4af26357c040675d
5c2629a4e4c4563cef
Resources:
Networks: go-demo
Endpoint Mode: vip
如你所见,这一次有一个Networks条目,值设置为我们之前创建的go-demo网络的 ID。
让我们确认网络是否真的起作用。为了证明这一点,我们将创建一个名为util的全局服务:
docker service create --name util \
--network go-demo --mode global \
alpine sleep 1000000000
与go-demo-db类似,util服务也附加了go-demo网络。
一个新参数是--mode。当设置为 global 时,服务将会在集群中的每个节点上运行。当我们需要设置跨越整个集群的基础设施服务时,这是一个非常有用的特性。
我们可以通过执行service ps命令来确认它是否在每个节点上运行:
docker service ps util
输出如下(为了简洁,已删除 IDs 和 ERROR PORTS 列):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
util... alpine:latest node-1 Running Running 6 minutes ago
util... alpine:latest node-3 Running Running 6 minutes ago
util... alpine:latest node-2 Running Running 6 minutes ago
如你所见,util服务在所有三台节点上都在运行。
我们正在运行alpine镜像(一种微型 Linux 发行版)。我们将其置于长时间休眠状态。否则,由于没有进程在运行,服务将停止,Swarm 将重新启动它,它会再次停止,依此类推。
util服务的目的将是演示我们正在探索的一些概念。我们将进入该服务并确认网络是否确实起作用。
要进入util容器,我们需要找出在node-1(本地 Docker 所指向的节点)上运行的实例的 ID:
ID=$(docker ps -q --filter label=com.docker.swarm.service.name=util)
我们以安静模式列出了所有进程ps,以便只返回 ID-q,并将结果限制为服务名称 util:
--filter label=com.docker.swarm.service.name=util
结果被存储为环境变量 ID。
我们将安装一个名为drill的工具。它是一个旨在从 DNS 获取各种信息的工具,很快就会派上用场:
docker exec -it $ID apk add --update drill
Alpine Linux 使用名为apk的包管理工具,因此我们告诉它添加 drill。
现在我们可以检查网络是否真正起作用。由于go-demo-db和 util 服务都属于同一个网络,它们应该能够通过 DNS 名称相互通信。每当我们将一个服务连接到网络时,一个新的虚拟 IP 将被创建,并且 DNS 与服务名称匹配。
让我们按以下方式尝试:
docker exec -it $ID drill go-demo-db
我们进入了util服务的一个实例,并“钻取”了 DNS go-demo-db。输出如下:
;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 5751
;; flags: qr rd ra ; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;; go-demo-db. IN A
;; ANSWER SECTION:
go-demo-db. 600 IN A 10.0.0.2
;; AUTHORITY SECTION:
;; ADDITIONAL SECTION:
;; Query time: 0 msec
;; SERVER: 127.0.0.11
;; WHEN: Thu Sep 1 12:53:42 2016
;; MSG SIZE rcvd: 54
响应代码是NOERROR,并且ANSWER是1,这意味着 DNS go-demo-db正确响应了。它是可以访问的。
我们还可以观察到go-demo-db的 DNS 与 IP 10.0.0.2相关联。每个附加到网络的服务都会获得一个 IP。请注意,我说的是服务,而不是实例。这是一个巨大的区别,我们稍后会深入探讨。现在,重要的是要理解,所有属于同一网络的服务都可以通过服务名称访问:

图 3-2:附加到 go-demo 网络的 go-demo-db 服务
让我们向上推进,完成需求。
通过反向代理运行服务
我们希望go-demo服务能够与go-demo-db服务自由通信,并且只能通过反向代理访问。我们已经知道如何完成第一部分。我们要做的就是确保这两个服务都属于同一个go-demo网络。
我们如何完成与反向代理的集成?
我们可以从创建一个新网络开始,并将其附加到所有应通过反向代理访问的服务:
docker network create --driver overlay proxy
让我们列出当前正在运行的overlay网络:
docker network ls -f "driver=overlay"
输出如下:
NETWORK ID NAME DRIVER SCOPE
b17kzasd3gzu go-demo overlay swarm
0d7ssryojcyg ingress overlay swarm
9e4o7abyts0v proxy overlay swarm
我们列出了之前创建的go-demo和proxy网络。第三个网络称为 ingress。它是默认设置的,具有特殊的用途,我们稍后会深入探讨。
现在我们准备运行go-demo服务。我们希望它能够与go-demo-db服务通信,因此它必须附加到go-demo网络。我们还希望它可以通过proxy访问(我们很快会创建它),因此我们也会将它附加到proxy网络。
创建go-demo服务的命令如下:
docker service create --name go-demo \
-e DB=go-demo-db \
--network go-demo \
--network proxy \
vfarcic/go-demo:1.0
这与我们在上一章执行的命令非常相似,只是在其中添加了--network proxy参数:

图 3-3:包含三个节点、两个网络和若干容器的 Docker Swarm 集群
现在两个服务都在集群中的某个地方运行,并且可以通过go-demo网络互相通信。让我们将代理添加进来。我们将使用Docker Flow Proxy(https://github.com/vfarcic/docker-flow-proxy)项目,它结合了 HAProxy(www.haproxy.org/)和一些额外的功能,使其更加动态。无论你选择哪个,本文所探讨的原则都是相同的。
请注意,目前,除非与同一网络连接的用户,否则其他任何人都无法访问这些服务。
创建一个反向代理服务,负责根据其基础 URL 路由请求。
我们可以通过几种方式实现反向代理。一种方法是基于 HAProxy(hub.docker.com/_/haproxy/)创建一个新镜像,并将配置文件包含其中。如果不同服务的数量相对静态,这种方法是可行的。否则,每当有新的服务(而不是新的版本)时,我们就需要创建一个新镜像,并带有新的配置。
第二种方法是暴露一个卷。这样,在需要时,我们可以修改配置文件,而不是构建一个全新的镜像。然而,这也有缺点。当部署到集群时,我们应该避免在不必要时使用卷。正如你很快会看到的,代理是其中之一,它不需要使用卷。顺便提一下,--volume已经被docker service参数--mount取代。
第三种选择是使用专为 Docker Swarm 设计的代理之一。在这种情况下,我们将使用容器vfarcic/docker-flow-proxy(hub.docker.com/r/vfarcic/docker-flow-proxy/)。它基于 HAProxy,并增加了额外的功能,使我们能够通过发送 HTTP 请求来重新配置它。
让我们试试看。
创建proxy服务的命令如下:
docker service create --name proxy \
-p 80:80 \
-p 443:443 \
-p 8080:8080 \
--network proxy \
-e MODE=swarm \
vfarcic/docker-flow-proxy
我们开放了80和443端口,用于互联网流量(HTTP 和 HTTPS)。第三个端口是 8080。我们将使用它向代理发送配置请求。进一步地,我们指定它应该属于代理网络。这样,由于 go-demo 也连接到同一网络,代理可以通过 proxy-SDN 访问它。
通过我们刚刚运行的代理,我们可以观察到网络路由网格的一个酷炫特性。无论代理在哪台服务器上运行,都没关系。我们可以向任何节点发送请求,Docker 网络会确保将其重定向到其中一个代理。我们很快就会看到这一点。
最后的参数是环境变量MODE,它告诉proxy容器将部署到 Swarm 集群中。有关其他组合,请参阅项目的 README(github.com/vfarcic/docker-flow-proxy)。

图 3-4:带有代理服务的 Docker Swarm 集群
请注意,proxy虽然运行在某个节点内部,但它被放置在外部以更好地展示逻辑分离。
在继续之前,让我们确认proxy是否正在运行。
docker service ps proxy
如果CURRENT STATE是Running,我们可以继续。否则,请等到服务启动并运行。
现在proxy已部署,我们应该让它知道go-demo服务的存在:
curl "$(docker-machine ip node-1):8080/v1/docker-flow-\
proxy/reconfigure?serviceName=go-demo&servicePath=/demo&port=8080"
请求被发送以重新配置proxy,指定了服务名称go-demo,API 的基本 URL 路径/demo,以及服务的内部端口8080。从现在起,所有路径以/demo开头的请求将被重定向到go-demo服务。这个请求是 Docker Flow Proxy 在 HAProxy 基础上提供的额外功能之一。
请注意,我们将请求发送到了node-1。即使proxy可能在任何节点内部运行,请求仍然成功。这正是 Docker 的路由网格发挥重要作用的地方。我们稍后会更详细地探讨它。现在,重要的一点是,我们可以将请求发送到任何节点,它都会被重定向到监听同一端口的服务(在这个例子中是8080)。
请求的输出如下所示(已格式化以便于阅读):
{
"Mode": "swarm",
"Status": "OK",
"Message": "",
"ServiceName": "go-demo",
"AclName": "",
"ConsulTemplateFePath": "",
"ConsulTemplateBePath": "",
"Distribute": false,
"HttpsOnly": false,
"HttpsPort": 0,
"OutboundHostname": "",
"PathType": "",
"ReqMode": "http",
"ReqRepReplace": "",
"ReqRepSearch": "",
"ReqPathReplace": "",
"ReqPathSearch": "",
"ServiceCert": "",
"ServiceDomain": null,
"SkipCheck": false,
"TemplateBePath": "",
"TemplateFePath": "",
"TimeoutServer": "",
"TimeoutTunnel": "",
"Users": null,
"ServiceColor": "",
"ServicePort": "",
"AclCondition": "",
"FullServiceName": "",
"Host": "",
"LookupRetry": 0,
"LookupRetryInterval": 0,
"ServiceDest": [
{
"Port": "8080",
"ServicePath": [
"/demo"
],
"SrcPort": 0,
"SrcPortAcl": "",
"SrcPortAclName": ""
}
]
}
我不打算深入细节,但请注意,Status是OK,这表示proxy已正确重新配置。
我们可以通过发送 HTTP 请求来验证proxy是否如预期工作:
curl -i "$(docker-machine ip node-1)/demo/hello"
curl命令的输出如下:
HTTP/1.1 200 OK
Date: Thu, 01 Sep 2016 14:23:33 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
proxy工作正常!它响应了 HTTP 状态200,并返回了 API 响应hello, world!。和之前一样,请求不一定是发送到托管服务的节点,而是发送到了路由网格,再由它转发给proxy。
作为示例,我们将发送相同的请求,不过这次是发送到node-3:
curl -i "$(docker-machine ip node-3)/demo/hello"
结果仍然相同。
让我们来看看由proxy生成的配置。这将为我们提供有关 Docker Swarm 网络内部工作的更多见解。另一个好处是,如果你选择自己构建proxy解决方案,了解如何配置proxy并利用 Docker 新的网络功能可能会很有用。
我们将首先检查为我们创建的Docker Flow Proxy配置(github.com/vfarcic/docker-flow-proxy)。我们可以通过进入运行中的容器,快速查看/cfg/haproxy.cfg文件来实现。问题是,找到由 Docker Swarm 运行的容器有些棘手。如果我们使用 Docker Compose 部署,容器名称是可以预测的。它会采用<PROJECT>_<SERVICE>_<INDEX>格式。
docker service command运行具有哈希名称的容器。我在笔记本上创建的docker-flow-proxy容器名为proxy.1.e07jvhdb9e6s76mr9ol41u4sn。因此,要进入由 Docker Swarm 部署的运行中容器,我们需要使用筛选器,例如使用镜像名称。
首先,我们需要找出proxy运行在哪个节点,执行以下命令:
NODE=$(docker service ps proxy | tail -n +2 | awk '{print $4}')
我们列出了proxy服务进程docker service ps proxy,去掉了标题tail -n +2,并输出了第四列中的节点awk '{print $4}'。输出结果被存储为环境变量NODE。
现在,我们可以将本地 Docker 引擎指向proxy所在的节点:
eval $(docker-machine env $NODE)
最后,只剩下找到proxy容器的 ID。我们可以通过以下命令来实现:
ID=$(docker ps -q \
--filter label=com.docker.swarm.service.name=proxy)
现在我们已经将容器 ID 存储在变量中,我们可以执行命令来检索 HAProxy 配置:
docker exec -it \
$ID cat /cfg/haproxy.cfg
配置的重要部分如下:
frontend services
bind *:80
bind *:443
mode http
acl url_go-demo8080 path_beg /demo
use_backend go-demo-be8080 if url_go-demo8080
backend go-demo-be8080
mode http
server go-demo go-demo:8080
第一部分frontend应该对使用过 HAProxy 的人来说很熟悉。它接受端口80的 HTTP 请求和端口443的 HTTPS 请求。如果路径以/demo开头,系统会将请求重定向到backend go-demo-be。在里面,请求会被发送到端口8080上的go-demo地址。这个地址和我们部署的服务名称相同。由于go-demo与proxy属于同一个网络,Docker 会确保请求被重定向到目标容器。很整洁,对吧?不再需要指定 IP 和外部端口了。
接下来的问题是如何进行负载均衡。我们应该如何指定proxy,例如,执行轮询遍历所有实例?我们应该使用proxy来完成这样的任务吗?
在所有实例间进行负载均衡请求
在我们探索负载均衡之前,需要有些东西来进行负载均衡。我们需要多个实例的服务。由于我们在上一章已经探讨过扩展,所以这个命令应该不会让你感到惊讶:
eval $(docker-machine env node-1)
docker service scale go-demo=5
几分钟后,五个go-demo服务的实例将会启动:

图 3-5:Docker Swarm 集群中的 go-demo 服务扩展
要使proxy在所有实例之间负载平衡请求,我们该做什么?答案是什么也不用做。我们不需要采取任何行动。实际上,这个问题本身是错的。proxy根本不会负载平衡请求。Docker Swarm 网络会。因此,让我们重新表述问题。要使Docker Swarm 网络在所有实例之间负载平衡请求,我们该做什么?同样地,答案是什么也不用做。我们不需要采取任何行动。
要理解负载平衡,我们可能需要回到以前讨论 Docker 网络出现之前的负载平衡。
通常,如果我们没有利用 Docker Swarm 的功能,我们可能会有类似以下的proxy配置模型:
backend go-demo-be
server instance_1 <INSTANCE_1_IP>:<INSTANCE_1_PORT>
server instance_2 <INSTANCE_2_IP>:<INSTANCE_2_PORT>
server instance_3 <INSTANCE_3_IP>:<INSTANCE_3_PORT>
server instance_4 <INSTANCE_4_IP>:<INSTANCE_4_PORT>
server instance_5 <INSTANCE_5_IP>:<INSTANCE_5_PORT>
每次添加新实例时,我们需要将其添加到配置中。如果删除实例,我们需要从配置中删除它。如果实例失败了……嗯,你明白了。我们需要监控集群的状态,并在发生更改时更新proxy配置。
如果你读过《DevOps 2.0 工具包》,你可能还记得我建议使用Registrator (github.com/gliderlabs/registrator)、Consul (www.consul.io/)和Consul Template (github.com/hashicorp/consul-template)的组合。Registrator 会监控 Docker 事件,并在创建或销毁容器时更新 Consul。通过 Consul 存储的信息,我们会使用 Consul Template 更新 nginx 或 HAProxy 配置。不再需要这样的组合了。虽然这些工具仍然有价值,但对于这个特定目的,不再需要它们。
每当集群内部发生变化时(例如,扩展事件),我们不会更新proxy;而是每次创建新服务时才会更新proxy。请注意,服务更新(发布新版本)不算作服务创建。我们创建一次服务,然后每次发布新版本时更新它(还有其他原因)。因此,只有新服务需要更改proxy配置。
背后的原因在于负载平衡现在是 Docker Swarm 网络的一部分。让我们从util服务再做一轮解析:
ID=$(docker ps -q --filter label=com.docker.swarm.service.name=util)
docker exec -it $ID apk add --update drill
docker exec -it $ID drill go-demo
前面命令的输出如下所示:
;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 50359
;; flags: qr rd ra ; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;; go-demo. IN A
;; ANSWER SECTION:
go-demo. 600 IN A 10.0.0.8
;; AUTHORITY SECTION:
;; ADDITIONAL SECTION:
;; Query time: 0 msec
;; SERVER: 127.0.0.11
;; WHEN: Thu Sep 1 17:46:09 2016
;; MSG SIZE rcvd: 48
IP 10.0.0.8代表go-demo服务,而不是单独的实例。当我们发送解析请求时,Swarm 网络跨服务的所有实例执行负载平衡(LB)。更准确地说,它执行轮询 LB。
除了为每个服务创建虚拟 IP 外,每个实例也会获得自己的 IP。大多数情况下,不需要发现这些 IP(或任何 Docker 网络端点 IP),因为我们只需要一个服务名称,该名称会被转换为一个 IP,并在后台进行负载均衡。
接下来做什么?
这就结束了对 Docker Swarm 网络基础概念的探索。
这就是成功运行 Swarm 集群所需了解的全部内容吗?在本章中,我们深入探讨了 Swarm 的特性,但我们还没有结束。还有很多问题等待解答。在下一章中,我们将探索服务发现及其在 Swarm 模式中的作用。
现在是时候休息一下,准备进入下一个章节了。和之前一样,我们将销毁创建的机器,重新开始:
docker-machine rm -f node-1 node-2 node-3
第四章:Swarm 集群中的服务发现
做事并不需要多大力气,但决定做什么却需要极大的力量。
- 埃尔伯特·哈伯德
如果你使用过旧版的 Swarm,即 Docker 1.12 之前作为独立产品发布的版本,你必须为其设置一个服务注册表。你可能选择了 Consul、etcd 或 Zookeeper。没有它们,独立版的 Swarm 无法工作。为什么会这样呢?为什么会有这么强的依赖关系?
在我们讨论使用外部服务注册表的原因之前,先来讨论一下没有它的情况下,Swarm 会是什么表现。
如果没有服务注册表,Docker Swarm 会是什么样子?
假设我们有一个由三个节点组成的集群。其中两个节点运行 Swarm 管理器,一个节点是工作节点。管理器接受我们的请求,决定应该做什么,并将任务发送给 Swarm 工作节点。反过来,工作节点将这些任务转换为命令,并将其发送到本地的 Docker Engine。管理器本身也充当工作节点。
如果我们用之前的 go-demo 服务描述流程,并假设 Swarm 没有服务发现功能,情况会是这样的:
用户向其中一个管理器发送请求。这个请求不是一个声明式指令,而是对期望状态的表达。例如,我希望在集群中运行两个 go-demo 服务实例和一个 DB 实例:

图 4-1:用户向其中一个管理器发送请求
一旦 Swarm 管理器 接收到我们对期望状态的请求,它会将其与集群的当前状态进行比较,生成任务并将其发送给 Swarm 工作节点。这些任务可能是将 go-demo 服务的一个实例运行在 node-1 和 node-2 上,并将 go-demo-db 服务的一个实例运行在 node-3 上:

图 4-2:Swarm 管理器将集群的当前状态与期望状态进行比较,生成任务并将其发送到 Swarm 工作节点。
Swarm 工作节点 接收来自管理器的任务,将其转换为 Docker Engine 命令,并将其发送到本地的 Docker Engine 实例:

图 4-3:Swarm 节点将接收到的任务转换为 Docker Engine 命令
Docker Engine 接收到来自 Swarm 工作节点的命令并执行:

图 4-4:Docker Engine 管理本地容器。
接下来,假设我们向管理器发送一个新的期望状态。例如,我们可能想要将go-demo实例的数量扩展到node-3。我们将向node-1上的Swarm 管理器发送请求,它会查询内部存储的集群状态,并做出决定,比如在node-2上运行一个新实例。一旦做出决定,管理器会创建一个新任务并将其发送到node-2上的Swarm worker。然后,worker 会将任务转换为 Docker 命令,并发送到本地引擎。命令执行后,我们将在node-2上运行第三个go-demo服务实例:

图 4-5:扩展请求被发送到 Swarm 管理器
如果流程按照描述进行,我们将面临许多问题,这将使这种解决方案几乎毫无用处。
让我们试着列举一些我们可能面临的问题。
Docker 管理器使用我们发送给它的信息。这在我们始终使用相同的管理器并且集群状态未因管理器控制之外的因素而变化时有效。需要理解的关键是,关于集群的信息并非存储在一个地方,也并不完整。每个管理器只知道它自己做过的事情。这为什么会是个问题呢?
让我们探索一些替代的(但并不罕见的)路径。
如果我们向node-2上的管理器发送扩展到三个实例的请求,会发生什么?该管理器对node-1上创建的任务一无所知。因此,它会尝试运行三个新的go-demo服务实例,导致集群中总共有五个实例。我们将有两个实例由node-1上的管理器创建,三个实例由node-2上的管理器创建。
虽然始终使用相同的管理器看起来很有吸引力,但在这种情况下,我们将面临单点故障的问题。如果整个node-1故障会发生什么?我们将无法使用管理器,或者只能强制使用node-2上的管理器。
可能有许多其他因素会导致这种差异。也许其中一个容器意外停止。在这种情况下,当我们决定扩展到三个实例时,node-1上的管理器会认为有两个实例在运行,并会创建一个任务来运行另一个实例。然而,这样做不会导致集群中运行三个实例,而是两个实例。
可能出错的情况是无穷无尽的,我们不会再举更多的例子。
需要注意的重要事项是,任何单一的管理器都不应当在孤立状态下保持有状态。每个管理器都需要拥有与其他管理器相同的信息。另一方面,每个节点需要监控 Docker 引擎生成的事件,并确保任何对其服务器的更改都会传播到所有管理器。最后,我们需要监督每台服务器的状态,以防其中一台出现故障。换句话说,每个管理器都需要有整个集群的最新状态。只有这样,它才能将我们请求的目标状态转化为任务,并将任务分发给 Swarm 节点。
如何确保所有管理器都能够全面了解整个集群的状态,无论是谁对其进行了更改?
这个问题的答案取决于我们设定的要求。我们需要一个存储所有信息的地方。这个地方需要是分布式的,以便一个服务器的故障不会影响工具的正常运行。分布式提供了容错能力,但仅此并不意味着数据会在集群中同步。该工具需要保持数据在所有实例之间的复制。复制并不是什么新鲜事,唯一不同的是,在这种情况下,它需要非常快速,以便咨询它的服务能够实时(或接近实时)接收数据。此外,我们还需要一个系统来监控集群内的每个服务器,并在任何变化发生时更新数据。
总结来说,我们需要一个分布式的服务注册表和监控系统。第一个需求最好通过使用服务注册表或键值存储来实现。旧版 Swarm(Docker 1.12 之前的独立版)支持Consul (www.consul.io/)、etcd (github.com/coreos/etcd) 和 Zookeeper (zookeeper.apache.org/)。我个人偏好 Consul,但三者中的任何一个都可以。
关于服务发现以及主要服务注册表比较的更详细讨论,请参考《DevOps 2.0 工具包》中的《服务发现:分布式服务的关键》章节。
独立版 Docker Swarm 在服务发现方面是什么样子的?
现在我们对需求和使用服务发现的原因有了更清晰的理解,我们可以定义请求到 Docker Swarm 管理器的(实际)流程。
请注意,我们仍在探索旧版(独立版)Swarm 的工作原理:
-
用户向其中一台 Swarm 管理器发送带有目标状态的请求。
-
Swarm 管理器从服务注册表中获取集群信息,创建一组任务,并将其分发给 Swarm 工作节点。
-
Swarm 工作节点将任务转换为命令并发送到本地的 Docker 引擎,后者随后运行或停止容器。
-
Swarm 工作节点持续监控 Docker 事件,并更新服务注册表。
这样,整个集群的信息始终是最新的。例外情况是当某个管理节点或工作节点失败时。由于管理节点相互监控,一个管理节点或工作节点的失败被视为整个节点的失败。毕竟,没了工作节点,容器就无法在该节点上调度:

图 4-6:Docker Swarm(独立)流程
既然我们已经确定服务发现是管理集群的必备工具,那么接下来自然的问题是它在 Swarm 模式(Docker 1.12)中发生了什么变化?
Swarm 集群中的服务发现
旧版(独立)Swarm 需要一个服务注册中心,以便所有管理节点都能看到相同的集群状态。在实例化旧版 Swarm 节点时,我们必须指定服务注册中心的地址。然而,如果你查看新 Swarm(在 Docker 1.12 中引入的 Swarm 模式)的设置说明,你会注意到我们没有设置除了 Docker 引擎以外的任何东西。你不会发现有提到外部服务注册中心或键值存储。
这是否意味着 Swarm 不需要服务发现?恰恰相反。对服务发现的需求依旧强烈,Docker 决定将其集成到 Docker 引擎中。它与 Swarm 一样被捆绑在内部。其内部过程本质上仍然与独立的 Swarm 使用的方式非常相似,只是部件更少。现在,Docker 引擎充当着 Swarm 管理节点、Swarm 工作节点和服务注册中心的角色。
将所有内容捆绑到引擎中的决定引起了不同的反应。一些人认为这样的决策会导致过度耦合,增加 Docker 引擎的不稳定性。另一些人认为这种捆绑使得引擎更加强大,并为一些新可能性打开了大门。虽然双方都有有效的论点,但我更倾向于后者的观点。Docker Swarm 模式是一次巨大的进步,是否能够在不将服务注册中心捆绑到引擎中的情况下实现同样的效果,仍然值得质疑。
了解 Docker Swarm 的工作原理,尤其是其网络功能后,你可能会问,我们是否仍然需要服务发现(超出 Swarm 内部使用的范围)?在《DevOps 2.0 工具包》中,我曾认为服务发现是必须的,并建议大家设置 Consul(www.consul.io/)或 etcd(github.com/coreos/etcd)作为服务注册中心,使用 Registrator 作为在集群内注册更改的机制,并用 Consul Template 或 confd(github.com/kelseyhightower/confd)作为模板解决方案。那么我们现在还需要这些工具吗?
我们需要服务发现吗?
很难提供一个通用的建议,说明在 Swarm 集群中工作时是否需要服务发现工具。如果我们把寻找服务作为这些工具的主要用途,答案通常是否定的。我们不需要外部的服务发现来解决这个问题。只要所有需要互相通信的服务在同一个网络中,我们只需要知道目标服务的名称。例如,对于 go-demo(github.com/vfarcic/go-demo)服务,它只需要知道数据库的 DNS go-demo-db 就可以找到相关的数据库。第三章,Docker Swarm 网络与反向代理 已证明,正确的网络使用对于大多数用例来说已经足够。
然而,寻找服务和在它们之间进行负载均衡请求并不是服务发现的唯一原因。我们可能还有其他对服务注册表或键值存储的需求。我们可能需要存储一些信息,以便它是分布式的并且具有容错性。
需要键值存储的一个例子可以在Docker Flow Proxy(github.com/vfarcic/docker-flow-proxy)项目中看到。它基于 HAProxy,这是一个有状态的服务。它将配置信息加载到内存中。将有状态的服务放入动态集群中会带来挑战,必须解决这个问题。否则,当服务被扩展、在失败后重新调度等情况下,我们可能会丢失状态。
在深入探讨有状态服务的更多细节和相关问题之前,先来看一下如何将 Consul 设置为我们选择的键值存储,并了解它的基本功能。
在 Swarm 集群中设置 Consul 作为服务注册表
像以前一样,我们将首先设置一个 Swarm 集群。从那里开始,我们将继续进行 Consul 的设置,并快速概述我们可以用它做的基本操作。这将为本章的其余部分提供必要的知识。
《DevOps 2.0 工具包》读者注意
你可能会觉得可以跳过这一小节,因为你已经学会了如何设置 Consul。但我建议你继续阅读。我们将使用官方的 Consul 镜像,而在我写前一本书时,这个镜像还不可用。同时,我保证会尽量简洁地讲解这一小节,不会让新读者感到困惑。
熟能生巧,但也有一个限度,超出了这个限度,就没有理由一遍遍地重复相同的命令。我敢肯定,到目前为止,你已经厌倦了编写创建 Swarm 集群的命令。所以,我准备了scripts/dm-swarm.sh(github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm.sh)脚本,它会创建 Docker Machine 节点并将它们加入到一个 Swarm 集群中。
本章中的所有命令都可以在04-service-discovery.sh(gist.github.com/vfarcic/fa57e88faf09651c9a7e9e46c8950ef5)Gist 中找到。
让我们克隆代码并运行脚本:
一些文件将在主机文件系统和我们将很快创建的 Docker 机器之间共享。Docker Machine 会将当前用户所属的整个目录在虚拟机内共享。因此,请确保代码被克隆到用户的子文件夹中。
git clone https://github.com/vfarcic/cloud-provisioning.git
cd cloud-provisioning
scripts/dm-swarm.sh
eval $(docker-machine env swarm-1)
docker node ls
node ls命令的输出如下(为了简洁,ID 已移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-2 Ready Active Reachable
swarm-3 Ready Active Reachable
swarm-1 Ready Active Leader
请注意,这次命令有所变化。我们使用了manager令牌,因此所有三个节点都设置为管理节点。
一般来说,我们应该至少有三个 Swarm 管理节点。这样,如果其中一个节点失败,其他节点会重新调度失败的容器,并且可以作为我们访问系统的入口点。正如许多需要法定人数的解决方案一样,通常奇数是最好的。因此,我们有三个管理节点。
你可能会想要将所有节点都运行为管理节点。我建议你不要这样做。管理节点之间会同步数据。运行的管理节点越多,同步所需的时间可能越长。当管理节点不多时,这种延迟几乎是察觉不到的,但如果你运行一百个管理节点,可能会有一些延迟。毕竟,这就是我们需要工作节点的原因。管理节点是我们进入系统的入口点和任务的协调者,而工作节点则执行实际的工作。
了解这一点后,我们可以继续设置 Consul。
我们将开始下载docker-compose.yml文件(github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose.yml)来自Docker Flow Proxy(github.com/vfarcic/docker-flow-proxy)项目。该文件已经包含了定义为 Compose 服务的 Consul。
curl -o docker-compose-proxy.yml \
https://raw.githubusercontent.com/\
vfarcic/docker-flow-proxy/master/docker-compose.yml
cat docker-compose-proxy.yml
就像 Docker Swarm 节点可以作为管理节点或工作节点一样,Consul 也可以作为服务器或代理运行。我们从服务器开始。
作为服务器运行的 Consul 服务的 Compose 定义如下:
consul-server:
container_name: consul
image: consul
network_mode: host
environment:
- 'CONSUL_LOCAL_CONFIG={"skip_leave_on_interrupt": true}'
command: agent -server -bind=$DOCKER_IP -bootstrap-expect=1 -client=$DOCKER_IP
需要注意的重要事项是,我们将网络模式设置为host。这意味着容器将与运行它的主机共享相同的网络。接下来是环境变量和命令。
该命令将以服务器模式运行代理,并且最初预计它是集群中唯一的一个-bootstrap-expect=1。
你会注意到使用了DOCKER_IP环境变量。Consul 需要绑定信息和客户端地址。由于我们无法提前知道服务器的 IP 地址,因此必须使用变量。
此时你可能会想,为什么我们在 Swarm 集群中讨论 Docker Compose 服务?我们不是应该运行 docker service create 命令吗?事实上,在写这篇文章时,官方的 Consul 镜像仍然没有适应“Swarm 方式”的运行方式。大多数镜像在启动到 Swarm 集群中时不需要任何更改。Consul 是少数几个例外之一。我会尽力在情况发生变化时及时更新说明。在此之前,经典的 Compose 方式应该没问题:
export DOCKER_IP=$(docker-machine ip swarm-1)
docker-compose -f docker-compose-proxy.yml \
up -d consul-server
你会在输出中看到 WARNING: The Docker Engine you're using is running in swarm mode 消息。这只是一个友好的提醒,表明我们并没有以 Docker 服务的方式运行它。可以忽略这个警告。
现在我们已经有了一个 Consul 实例在运行,我们可以进行一些基本操作。
例如,我们可以将一些信息放入键值存储中:
curl -X PUT -d 'this is a test' \
"http://$(docker-machine ip swarm-1):8500/v1/kv/msg1"
curl 命令将一个测试值作为 msg1 键放入 Consul 中。
我们可以通过发送 GET 请求来确认键值组合确实已经存储:
curl "http://$(docker-machine ip swarm-1):8500/v1/kv/msg1"
输出如下(已格式化以提高可读性):
[
{
"LockIndex": 0,
"Key": "msg1",
"Flags": 0,
"Value": "dGhpcyBpcyBhIHRlc3Q=",
"CreateIndex": 17,
"ModifyIndex": 17
}
]
你会注意到值被编码了。如果我们在请求中添加 raw 参数,Consul 将仅返回原始格式的值:
curl "http://$(docker-machine ip swarm-1):8500/v1/kv/msg1?raw"
输出如下:
this is a test
现在,我们只有一个 Consul 实例。如果它运行的节点 swarm-1 失败,所有数据将丢失,服务注册将不可用。这种情况并不好。
我们可以通过运行更多的 Consul 实例来实现容错。这次,我们将运行代理。
就像 Consul 服务器实例一样,代理也在 docker-compose.yml 文件中定义(github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose.yml)文件中,属于 Docker Flow Proxy 项目(github.com/vfarcic/docker-flow-proxy)。记住,我们下载了名为 docker-compose-proxy.yml 的文件。让我们来看一下服务定义:
cat docker-compose-proxy.yml
输出中定义 Consul-agent 服务的部分如下:
consul-agent:
container_name: consul
image: consul
network_mode: host
environment:
- 'CONSUL_LOCAL_CONFIG={"leave_on_terminate": true}'
command: agent -bind=$DOCKER_IP -retry-join=$CONSUL_SERVER_IP \
-client=$DOCKER_IP
这几乎与我们用于运行 Consul 服务器实例的定义相同。唯一的重要区别是缺少 -server,并且我们有了 -retry-join 参数。我们使用后者来指定另一个实例的地址。Consul 使用 gossip 协议。只要每个实例至少知道一个其他实例,协议就会将信息传播到所有实例中。
让我们在其他两个节点 swarm-2 和 swarm-3 上运行代理:
export CONSUL_SERVER_IP=$(docker-machine ip swarm-1)
for i in 2 3; do
eval $(docker-machine env swarm-$i)
export DOCKER_IP=$(docker-machine ip swarm-$i)
docker-compose -f docker-compose-proxy.yml \
up -d consul-agent
done
现在我们已经在集群中运行了三个 Consul 实例(每个节点上一个),我们可以确认 gossip 协议确实有效。
让我们请求 msg1 键的值。这次,我们将从 swarm-2 上运行的 Consul 实例请求:
curl "http://$(docker-machine ip swarm-2):8500/v1/kv/msg1"
从输出中可以看出,尽管我们将信息存储到运行在 swarm-1 上的实例中,但它也可以从运行在 swarm-2 上的实例中访问。这些信息会在所有实例中传播。
我们可以再给 Gossip 协议进行一轮测试:
curl -X PUT -d 'this is another test' \
"http://$(docker-machine ip swarm-2):8500/v1/kv/messages/msg2"
curl -X PUT -d 'this is a test with flags' \
"http://$(docker-machine ip swarm-3):8500/v1/kv/messages/msg3?\ flags=1234"
curl "http://$(docker-machine ip swarm-1):8500/v1/kv/?recurse"
我们向运行在 swarm-2 上的实例发送了一个 PUT 请求,并向运行在 swarm-3 上的实例发送了另一个请求。当我们从运行在 swarm-1 上的实例请求所有键时,所有三个键都被返回。换句话说,无论我们对数据做什么,它都会在所有实例中保持同步。
类似地,我们也可以删除信息:
curl -X DELETE "http://$(docker-machine ip swarm-2):\
8500/v1/kv/?recurse"
curl "http://$(docker-machine ip swarm-3):8500/v1/kv/?recurse"
我们向 swarm-2 发送了删除所有键的请求。当我们查询运行在 swarm-3 上的实例时,得到了一个空的响应,意味着所有的东西确实都消失了。
通过类似我们探讨过的设置,我们可以拥有一种可靠、分布式且容错的方式来存储和检索我们的服务可能需要的任何信息。
我们将利用这些知识来探索在 Swarm 集群中运行有状态服务时可能出现的一些问题的解决方案。但在开始讨论解决方案之前,让我们先看看有状态服务的问题是什么。
扩展有状态实例时的问题
在 Swarm 集群中扩展服务很容易,不是吗?只需执行 docker service scale <SERVICE_NAME>=<NUMBER_OF_INSTANCES>,突然间,服务就运行了多个副本。
之前的说法只是部分正确。更准确的表述应该是:“在 Swarm 集群中扩展无状态服务很容易”。
无状态服务容易扩展的原因在于不需要考虑状态。无论实例运行多久,它都是一样的。新实例和运行一周的实例没有区别。由于状态不会随时间变化,我们可以在任何时刻创建新的副本,它们都会完全相同。
然而,世界并非无状态的。状态是我们行业中不可避免的一部分。一旦第一条信息被创建,就需要存储在某个地方。我们存储数据的地方必须是有状态的。它有一个随时间变化的状态。如果我们想要扩展这种有状态的服务,至少有两件事需要考虑:
-
我们如何将一个实例的状态变化传播到其余实例?
-
我们如何创建一个有状态服务的副本(一个新实例),并确保状态也被复制?
我们通常将无状态和有状态服务结合成一个逻辑实体。一个后端服务可以是无状态的,并依赖于数据库服务作为外部数据存储。这样,服务之间就有了明确的职责分离,每个服务的生命周期也不同。
在继续之前,我必须声明,没有一个“灵丹妙药”能让有状态的服务具备可扩展性和容错性。在本书中,我将通过几个示例来讲解,这些示例可能适用于您的使用场景,也可能不适用。一个显而易见且非常典型的有状态服务示例是数据库。尽管有一些常见的模式,但几乎每个数据库都提供不同的数据复制机制。这本身就足以阻止我们给出一个适用于所有情况的最终答案。我们将在本书后面探讨 MongoDB 的可扩展性。我们还会看到一个使用文件系统存储其状态的 Jenkins 示例。
我们将首先处理一个不同类型的情况。我们将讨论将状态存储在配置文件中的服务的可扩展性。为了使事情更复杂,配置是动态的。它随着时间的推移在服务的生命周期中不断变化。我们将探索使 HAProxy 可扩展的方法。
如果我们使用官方的HAProxy(hub.docker.com/_/haproxy/)镜像,我们面临的挑战之一是决定如何更新所有实例的状态。我们需要更改配置,并重新加载每个proxy副本。
例如,我们可以在集群中的每个节点上挂载一个 NFS 卷,并确保相同的主机卷在所有 HAProxy 容器内都被挂载。起初,这似乎能解决与状态相关的问题,因为所有实例都会共享相同的配置文件。对主机上配置文件的任何更改都会在所有实例中生效。然而,这本身并不会改变服务的状态。
HAProxy 在初始化时加载配置文件,之后对配置文件所做的任何更改它都无法察觉。为了让配置文件状态的更改反映到服务的状态中,我们需要重新加载配置文件。问题在于,实例可能在集群内的任何节点上运行。除此之外,如果我们采用动态扩展(稍后会详细讨论),我们可能甚至不知道有多少实例在运行。所以,我们需要发现我们有多少个实例,了解它们在哪些节点上运行,获取每个容器的 ID,只有这样,我们才能发送信号重新加载proxy。虽然所有这些可以通过脚本实现,但这远不是一个最优的解决方案。此外,挂载 NFS 卷是一个单点故障。如果托管卷的服务器发生故障,数据就会丢失。当然,我们可以创建备份,但这些备份只能部分恢复丢失的数据。也就是说,我们可以恢复备份,但从上次备份创建到节点故障之间生成的数据会丢失。
另一种方式是将配置嵌入到 HAProxy 镜像中。我们可以创建一个新的 Dockerfile,该文件基于 haproxy,并添加 COPY 指令来添加配置。这样,每次我们想重新配置代理时,都需要更改配置、构建一组新的镜像(即新的版本),并更新当前在集群内运行的 proxy 服务。正如你能想象的,这同样是不现实的。对于简单的代理重新配置而言,这个过程太复杂了。
Docker Flow Proxy 使用一种不同的、不太传统的方法来解决这个问题。它将其状态的副本存储在 Consul 中。它还使用了一个未记录的 Swarm 网络功能(至少在写这篇文章时是如此)。
使用服务注册表存储状态
现在我们已经设置了 Consul 实例,让我们探讨如何利用它们为我们所用。我们将研究 Docker Flow Proxy 的设计,作为展示一些你可能希望应用到自己服务中的挑战和解决方案的一种方式。
让我们创建 proxy 网络和服务:
eval $(docker-machine env swarm-1)
docker network create --driver overlay proxy
docker service create --name proxy \
-p 80:80 \
-p 443:443 \
-p 8080:8080 \
--network proxy \
-e MODE=swarm \
--replicas 3 \
-e CONSUL_ADDRESS="$(docker-machine ip swarm-1):8500 \
,$(docker-machine ip \
swarm-2):8500,$(docker-machine ip swarm-3):8500" \
vfarcic/docker-flow-proxy
我们用来创建 proxy 服务的命令与之前稍有不同。具体来说,现在我们有一个 CONSUL_ADDRESS 变量,其中包含所有三个 Consul 实例的用逗号分隔的地址。proxy 的设计方式是它会尝试第一个地址。如果该地址没有响应,它会尝试下一个,依此类推。这样,只要至少有一个 Consul 实例在运行,proxy 就能够获取和存储数据。如果 Consul 能作为 Swarm 服务运行,我们就不需要做这个循环了。在那种情况下,我们只需要将两者放在同一网络中,并使用服务名作为地址。
不幸的是,Consul 目前还不能作为 Swarm 服务运行,因此我们不得不指定所有的地址,请参考下面的图示:

图 4-7:代理扩展到三个实例
在继续之前,我们应该确保所有的 proxy 实例都在运行:
docker service ps proxy
请等待直到所有实例的当前状态设置为 Running。
让我们创建 go-demo 服务。它将作为一个催化剂,讨论我们在处理扩展的反向 proxy 时可能遇到的挑战:
docker network create --driver overlay go-demo
docker service create --name go-demo-db \
--network go-demo \
mongo:3.2.10
docker service create --name go-demo \
-e DB=go-demo-db \
--network go-demo \
--network proxy \
vfarcic/go-demo:1.0
没有必要详细解释这些命令。它们与我们在前几章中运行的命令是相同的。
请等待直到 go-demo 服务的当前状态为 Running。你可以随时使用 docker service ps go-demo 命令检查状态。
如果我们重复在 第三章 中使用的相同过程,Docker Swarm 网络和反向代理,重新配置代理的请求将如下所示(请不要执行它)。
curl "$(docker-machine ip swarm-1):8080/v1/\
proxy/reconfigure?serviceName=go-demo&servicePath=/demo&port=8080"
我们会向 proxy 服务发送一个重新配置的请求。你能猜到结果是什么吗?
用户发送请求来重新配置代理。请求由路由网格接收并在所有代理实例之间进行负载均衡。请求被转发到其中一个实例。由于代理使用Consul来存储其配置,它将信息发送到一个Consul实例,然后该实例会将数据同步到其他所有实例。
结果是我们得到了具有不同状态的代理实例。接收到请求的那个被重新配置为使用go-demo服务。其他两个实例依然对其一无所知。如果我们尝试通过代理来 ping go-demo 服务,我们将得到混合的响应。三次请求中一次会返回状态200,其余时间我们会收到404,未找到:

图 4-8:重新配置代理的请求
如果我们扩展 MongoDB,应该会得到类似的结果。路由网格会在所有实例之间进行负载均衡,它们的状态开始分歧。我们可以通过使用副本集来解决 MongoDB 的问题。这是一个允许我们在所有 DB 实例之间复制数据的机制。然而,HAProxy 并没有这样的功能。所以,我必须自己添加它。
正确的请求来重新配置运行多个实例的代理如下:
curl "$(docker-machine ip swarm-1):8080/v1/\
docker-flow-proxy/reconfigure \
serviceName=go-demo&servicePath=/demo&port=8080&distribute=true"
请注意新的参数distribute=true。当指定该参数时,代理将接受请求,重新配置自身,并将请求重新发送到所有其他实例:

图 4-9:接收请求并将其转发给所有其他实例的代理实例
这样,代理实现了类似于 MongoDB 中副本集的机制。对其中一个实例的更改会传播到所有其他实例。
让我们确认它确实按预期工作:
curl -i "$(docker-machine ip swarm-1)/demo/hello"
输出如下:
HTTP/1.1 200 OK
Date: Fri, 09 Sep 2016 16:04:05 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
响应为200,这意味着go-demo服务收到了代理服务转发的请求。由于路由网格的作用,请求进入系统后,进行了负载均衡,并再次转发到某个代理实例。接收到请求的代理实例评估了路径,决定将其转发到go-demo服务。因此,请求被重新发送到go-demo网络,再次进行负载均衡,并转发到某个go-demo实例。换句话说,任何一个proxy和go-demo实例都有可能收到请求。如果代理状态没有在所有实例之间同步,三次请求中有两次会失败。
可以随意重复curl -i $(docker-machine ip swarm-1)/demo/hello命令。结果应该始终相同。
我们可以通过查看其中一个容器来再次确认配置确实已同步。
让我们来看一下,比如说,代理实例三。
我们首先应该做的是找出实例运行所在的节点:
NODE=$(docker service ps proxy | grep "proxy.3" | awk '{print $4}')
我们列出了所有的proxy服务进程docker service ps proxy,通过第三个实例grep "proxy.3"过滤结果,并返回输出的第四列中存储的节点名称awk '{print $4}'。结果被存储在环境变量NODE中。
现在我们知道这个实例运行的服务器,我们可以进入容器并显示配置文件的内容:
eval $(docker-machine env $NODE)
ID=$(docker ps | grep "proxy.3" | awk '{print $1}')
我们将 Docker 客户端指向节点。接着执行列出所有正在运行的进程的命令docker ps,过滤出第三个实例grep "proxy.3",并输出存储在第一列的容器 IDawk '{print $1}'。结果被存储在环境变量 ID 中。
客户端指向正确的节点,ID 也被存储为环境变量 ID,我们最终可以进入容器并显示配置:
docker exec -it $ID cat /cfg/haproxy.cfg
输出的相关部分如下:
frontend services
bind *:80
bind *:443
mode http
acl url_go-demo8080 path_beg /demo
use_backend go-demo-be8080 if url_go-demo8080
backend go-demo-be8080
mode http
server go-demo go-demo:8080
如你所见,proxy的第三个实例确实已正确配置了go-demo服务。你可以随意重复这个过程,检查其他两个实例,结果应该完全相同,从而证明同步有效。
这是如何做到的?proxy实例是如何发现所有其他实例的 IP 的?毕竟,并没有一个 Registrator 提供 IP 给 Consul,而且我们也无法访问 Swarm 的内部服务发现 API。
发现组成服务的所有实例的地址
如果你浏览官方 Docker 文档,你将找不到任何提及组成服务的各个实例的地址。
当你阅读这段话时,前述句子可能不再成立。有人可能已经更新了文档。然而,在我写这章的时候,确实没有任何此类信息的痕迹。
事情没有被记录并不意味着它不存在。事实上,有一个特殊的 DNS,它会返回所有的 IP。
为了看到实际效果,我们将创建一个名为 util 的全局服务并将其附加到proxy网络:
docker service create --name util \
--network proxy --mode global \
alpine sleep 1000000000
docker service ps util
在继续之前,请等待直到当前状态设置为运行中。
接下来,我们将找到其中一个 util 实例的 ID 并安装 drill 工具,它将展示与 DNS 条目相关的信息:
ID=$(docker ps -q --filter label=com.docker.swarm.service.name=util)
docker exec -it $ID apk add --update drill
让我们从钻取 DNS 代理开始:
docker exec -it $ID drill proxy
输出如下:
;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 31878
;; flags: qr rd ra ; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;; proxy. IN A
;; ANSWER SECTION:
proxy. 600 IN A 10.0.0.2
;; AUTHORITY SECTION:
;; ADDITIONAL SECTION:
;; Query time: 0 msec
;; SERVER: 127.0.0.11
;; WHEN: Fri Sep 9 16:43:23 2016
;; MSG SIZE rcvd: 44
如你所见,尽管我们运行了三个实例,但只返回了一个 IP10.0.0.2。那是服务的 IP,而不是单个实例的 IP。更具体地说,它是proxy服务网络端点的 IP。当请求到达该端点时,Docker 网络会对所有实例进行负载均衡。
在大多数情况下,我们不需要其他任何东西。我们只需要知道服务的名称,Docker 就会为我们完成剩下的工作。然而,在某些情况下,我们可能需要更多信息。我们可能需要知道每个服务实例的 IP。这正是 Docker Flow Proxy 面临的问题。
要查找服务所有实例的 IP,我们可以使用“未文档化”的功能。我们需要在服务名称前添加 tasks 前缀。
让我们再深入了解一下:
docker exec -it $ID drill tasks.proxy
这次,输出有所不同:
;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 54408
;; flags: qr rd ra ; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;; tasks.proxy. IN A
;; ANSWER SECTION:
tasks.proxy. 600 IN A 10.0.0.4
tasks.proxy. 600 IN A 10.0.0.3
tasks.proxy. 600 IN A 10.0.0.5
;; AUTHORITY SECTION:
;; ADDITIONAL SECTION:
;; Query time: 0 msec
;; SERVER: 127.0.0.11
;; WHEN: Fri Sep 9 16:48:46 2016
;; MSG SIZE rcvd: 110
我们得到了三个答案,每个答案有一个不同的 IP:10.0.0.4, 10.0.0.3, 10.0.0.5。
知道所有实例的 IP 解决了数据同步的问题。通过 tasks.<SERVICE_NAME> 我们得到了所有需要的信息。剩下的只是一些代码,利用这些 IP。这个机制类似于同步数据库时使用的机制(稍后会详细介绍)。
我们还没有完成。我们能够按需同步数据(或事件)并不意味着服务是容错的。如果我们需要创建一个新的实例该怎么办?如果一个实例失败,Swarm 将它调度到其他地方会发生什么?
使用服务注册表或键值存储来存储服务状态
我们将继续使用 Docker Flow Proxy 作为游乐场,探索在处理有状态服务时可能采取的一些机制和决策。请注意,在本章中,我们重点讨论的是状态相对较小的服务。在接下来的章节中,我们会探讨其他的使用案例。
假设代理没有使用 Consul 来存储数据,并且我们没有使用卷。如果我们要扩展它,会发生什么呢?新的实例将会不同步。它们的状态将与我们创建的第一个实例的初始状态相同。换句话说,虽然已经运行的实例随着时间变化并生成了数据,但它们将没有状态。
这时,Consul 就派上了用场。每当代理的某个实例接收到一个请求并导致其状态发生变化时,它会将该变化传播到其他实例,以及 Consul。另一方面,代理在初始化时执行的第一步就是查询 Consul,并从其中的数据创建配置。
我们可以通过发送请求获取所有以 docker-flow 开头的键的数据,来观察存储在 Consul 中的状态:
curl "http://$(docker-machine ip swarm-1):8500/v1/kv/\
docker-flow?recurse"
输出的一部分如下:
[
...
{
"LockIndex": 0,
"Key": "docker-flow/go-demo/path",
"Flags": 0,
"Value": "L2RlbW8=",
"CreateIndex": 233,
"ModifyIndex": 245
},
...
{
"LockIndex": 0,
"Key": "docker-flow/go-demo/port",
"Flags": 0,
"Value": "ODA4MA==",
"CreateIndex": 231,
"ModifyIndex": 243
},
...
]
上面的例子显示了我们在重新配置 go-demo 服务的代理时指定的路径和端口,已被存储在 Consul 中。如果我们指示 Swarm 管理器扩展 proxy 服务,新的实例将会被创建。这些实例会查询 Consul 并使用其中的信息来生成它们的配置。
让我们试试看:
docker service scale proxy=6
我们将实例数量从三增加到六。
让我们偷偷看看第六个实例:
NODE=$(docker service ps proxy | grep "proxy.6" | awk '{print $4}')
eval $(docker-machine env $NODE)
ID=$(docker ps | grep "proxy.6" | awk '{print $1}')
docker exec -it $ID cat /cfg/haproxy.cfg
exec 命令输出的一部分如下:
frontend services
bind *:80
bind *:443
mode http
backend go-demo-be8080
mode http
server go-demo :8080
正如你所看到的,新实例从 Consul 恢复了所有信息。因此,它的状态与集群内运行的任何其他 proxy 实例的状态相同。
如果我们销毁一个实例,结果将再次是相同的。Swarm 会检测到实例崩溃并调度一个新的实例。新实例将重复相同的过程,查询 Consul 并创建与其他实例相同的状态:
docker rm -f $(docker ps \
| grep proxy.6 \
| awk '{print $1}')
我们应该稍等片刻,直到 Swarm 检测到故障并创建一个新实例。
一旦它运行起来,我们可以查看新实例的配置。它将与之前相同:
NODE=$(docker service ps \
-f desired-state=running proxy \
| grep "proxy.6" \
| awk '{print $4}')
eval $(docker-machine env $NODE)
ID=$(docker ps | grep "proxy.6" | awk '{print $1}')
docker exec -it $ID cat /cfg/haproxy.cfg
Docker Flow Proxy 内部工作原理的解释主要是出于教育目的。我想向你展示在处理有状态服务时可能的一种解决方案。我们讨论的方法仅适用于状态相对较小的情况。当状态变得更大时,例如数据库的情况,我们应该使用不同的机制来实现相同的目标。
如果我们往上一层看,在集群内运行有状态服务时,主要的要求或前提条件如下:
-
在所有实例之间同步状态的能力。
-
在初始化期间恢复状态的能力。
如果我们能够满足这两个要求,我们就走在了正确的道路上,朝着解决在集群内操作有状态服务时的主要瓶颈之一迈进。
现在怎么办?
这就结束了关于在 Swarm 集群内使用服务发现的基本概念的探索。
我们学完 Swarm 特性了吗?我们远未掌握 Docker Swarm 的所有知识。然而,到目前为止,我们已经拥有足够的知识,可以回到第一章的末尾,使用 Docker 容器进行持续集成,并迈出下一步。我们可以设计一个持续交付流程。
现在是时候休息一下,再深入下一章了。和之前一样,我们将销毁创建的机器,重新开始:
docker-machine rm -f swarm-1 swarm-2 swarm-3
第五章:使用 Docker 容器的持续交付和部署。
在软件中,当某个过程变得痛苦时,减少痛苦的方法是更加频繁地执行它,而不是减少频率。
– 大卫·法利(David Farley)
当时,我们无法将持续集成(CI)转化为持续交付(CD)过程,因为我们缺少一些关键知识。现在我们已经理解了 Docker Swarm 背后的基本原理和命令,我们可以回到第一章,使用 Docker 容器进行持续集成。我们可以定义出可以让我们执行完整 CD 过程的步骤。
我不会深入讨论持续交付的细节。相反,我将用一句话来概括它。持续交付是一个应用于每次提交到代码库的过程,每次成功构建都会准备好部署到生产环境。
CD 意味着任何人随时可以点击一个按钮,将构建部署到生产环境,而不必担心出现问题。这意味着该过程非常稳健,我们有充分的信心,"几乎"所有的问题都会在部署到生产之前被发现。毫无疑问,CD 是一个完全自动化的过程。从提交到代码库的那一刻起,一直到构建准备好部署到生产环境,过程中没有人工干预。唯一的手动操作是有人需要按下一个按钮,运行一个脚本执行部署。
持续部署(CDP)是向前迈出的一步。它是没有按钮的持续交付。持续部署是一个应用于每次提交到代码库的过程,每次成功构建都会被部署到生产环境。
无论选择哪种过程,步骤都是一样的。唯一的区别是是否有一个按钮用于将版本部署到生产环境。
在这一点上,可以安全地假设我们将尽可能方便地使用 Docker,并且我们将使用 Swarm 集群在生产和类似生产的环境中运行服务。
让我们从指定一些步骤开始,这些步骤可以定义 CD/CDP 过程的一个可能实现:
-
查看代码。
-
运行单元测试。
-
构建二进制文件和其他所需的构件。
-
将服务部署到预生产环境。
-
运行功能测试。
-
将服务部署到类似生产环境的环境中。
-
运行生产就绪性测试。
-
将服务部署到生产环境中。
-
运行生产就绪性测试。
现在,让我们开始配置练习 CD 流程所需的环境。
定义持续交付环境。
持续交付环境的最基本要求是两个集群。一个应专门用于运行测试、构建构件和镜像,以及执行所有其他 CD 任务。我们可以将其用作模拟生产集群。第二个集群将用于生产部署。
为什么我们需要两个集群?难道只用一个就能完成同样的事情吗?
虽然我们完全可以仅用一个集群,但拥有两个集群将简化许多流程,更重要的是,提供更好的生产环境与非生产环境之间的隔离。
我们越是减少对生产集群的影响,就越好。通过不在生产集群内运行非生产服务和任务,我们减少了风险。因此,我们应该将生产集群与环境的其他部分隔离开来。
现在让我们开始,启动这些集群。
设置持续交付集群
对于一个类似生产的集群,最低需要多少台服务器?我认为是两台。如果只有一台服务器,我们将无法测试节点之间的网络和存储卷是否正常工作。所以,必须是多个节点。另一方面,我不希望给你的笔记本电脑造成过大负担,所以除非必要,我们将避免增加节点数量。
对于类似生产的集群,两台节点应该足够了。我们应该再增加一个节点,用于运行测试和构建镜像。生产集群可能需要稍微大一点,因为它将运行更多的服务。我们将它设为三节点。如果需要,稍后可以增加容量。如你所见,向 Swarm 集群中添加节点非常简单。
到目前为止,我们已经多次设置了一个 Swarm 集群,因此我们将跳过解释,直接通过脚本来完成。
本章中的所有命令都可以在 05-continuous-delivery.sh (gist.github.com/vfarcic/5d08a87a3d4cb07db5348fec49720cbe) Gist 中找到。
让我们回到前一章创建的云资源配置目录,并运行 scripts/dm-swarm.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm.sh) 脚本。它将创建生产节点并将它们加入到集群中。节点将被命名为 swarm-1、swarm-2 和 swarm-3:
cd cloud-provisioning
scripts/dm-swarm.sh
eval $(docker-machine env swarm-1)
docker node ls
**node ls** 命令的输出如下(为了简洁,ID 已移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-2 Ready Active Reachable
swarm-1 Ready Active Leader
swarm-3 Ready Active Reachable
接下来,我们将创建第二个集群。我们将用它来运行持续交付任务,以及模拟生产环境。现在,三个节点应该足够了。我们将它们命名为 swarm-test-1、swarm-test-2 和 swarm-test-3。
我们将通过执行 scripts/dm-test-swarm.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-test-swarm.sh) 脚本来创建集群:
scripts/dm-test-swarm.sh
eval $(docker-machine env swarm-test-1)
docker node ls
node ls 命令的输出如下(为了简洁,ID 已移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-test-2 Ready Active Reachable
swarm-test-1 Ready Active Leader
swarm-test-3 Ready Active Reachable
现在剩下的工作就是创建 Docker 注册中心服务。我们将在每个集群中创建一个。这样,它们之间就没有直接的关联,并且可以相互独立操作。为了让运行在不同集群上的注册中心共享相同的数据,我们将把相同的主机卷挂载到两个服务上。这样,从一个集群推送的镜像将能够在另一个集群中访问,反之亦然。请注意,我们正在创建的卷仍然是一种临时解决方案。稍后我们将探索更好的挂载卷的方法。
让我们从生产集群开始。
我们已经在第一章中运行过注册中心,使用 Docker 容器进行持续集成。当时,我们有一个单一节点,并且使用 Docker Compose 来部署服务。注册中心并不是一个选项。
Windows 用户注意 Git Bash 有修改文件系统路径的习惯。为了避免这种情况,在运行代码块之前执行以下命令:
export MSYS_NO_PATHCONV=1
这次,我们将作为 Swarm 服务运行注册中心:
eval $(docker-machine env swarm-1)
docker service create --name registry \
-p 5000:5000 \
--reserve-memory 100m \
--mount "type=bind,source=$PWD,target=/var/lib/registry" \
registry:2.5.0
我们暴露了端口5000并保留了100 MB 的内存。我们使用了--mount参数来暴露一个卷。这个参数与 Docker 引擎的--volume参数或 Docker Compose 文件中的 volumes 参数有些相似。唯一显著的区别在于格式。在这种情况下,我们指定当前的主机目录source=$PWD应该挂载到容器内部target=/var/lib/registry。
请注意,从现在开始,我们将始终运行特定版本的服务。直到现在,使用最新版本作为演示是可以的,但现在我们尝试模拟 CD 过程,并且这些过程将在“真实”的集群中运行。我们应该始终明确指定要运行哪个版本的服务。这样,我们可以确保测试和部署到生产环境的是相同的服务。否则,我们可能会遇到一种情况,即在类似生产环境中部署和测试了一个版本,但生产环境中却部署了另一个版本。
使用特定版本的好处在我们使用 Docker Hub 的镜像时变得更加明显。例如,如果我们只运行注册中心的最新版本,就无法保证在以后运行时,在第二个集群中,最新版本不会被更新。我们可能会轻易地在不同的集群中得到不同版本的注册中心,这可能会导致一些非常难以检测的 bug。
我不会再多提版本控制的问题了。我相信你知道它的用途以及何时使用。
让我们回到registry服务。我们应该在第二个集群中也创建它:
eval $(docker-machine env swarm-test-1)
docker service create --name registry \
-p 5000:5000 \
--reserve-memory 100m \
--mount "type=bind,source=$PWD,target=/var/lib/registry" \
registry:2.5.0
现在,我们在两个集群中都运行了registry服务。

图 5-1:带有注册中心服务的 CD 和生产集群
目前,我们不知道注册表正在哪些服务器上运行。我们只知道每个集群中都有该服务的一个实例。通常,我们需要配置 Docker 引擎,将注册表服务视为不安全的并允许流量通过。为了做到这一点,我们需要知道注册表运行的服务器的 IP 地址。然而,由于我们将其作为 Swarm 服务运行并暴露了端口5000,路由网格将确保端口在集群中的每个节点上都开放,并将请求转发到服务。这使我们能够将注册表视为本地主机。我们可以从任何节点拉取和推送镜像,就好像注册表在每个节点上运行一样。此外,Docker 引擎的默认行为是只允许本地主机流量访问注册表。这意味着我们不需要更改它的配置。
使用节点标签来约束服务
标签被定义为键值对集合。我们将使用键env(即环境的缩写)。目前,我们不需要给用于持续交付(CD)任务的节点打标签,因为我们还没有将它们作为服务运行。我们将在接下来的章节中更改这一点。目前,我们只需要给将在生产环境中运行我们服务的节点打上标签。
我们将使用swarm-test-2和swarm-test-3节点作为我们的生产环境,所以我们将它们标记为键env,值prod-like。
让我们从节点swarm-test-2开始:
docker node update \
--label-add env=prod-like \
swarm-test-2
我们可以通过检查节点来确认标签确实已被添加:
docker node inspect --pretty swarm-test-2
节点inspect命令的输出如下:
ID: vq5hj3lt7dskh54mr1jw4zunb
Labels:
- env = prod-like
Hostname: swarm-test-2
Joined at: 2017-01-21 23:01:40.557959238 +0000 utc
Status:
State: Ready
Availability: Active
Address: 192.168.99.104
Manager Status:
Address: 192.168.99.104:2377
Raft Status: Reachable
Leader: No
Platform:
Operating System: linux
Architecture: x86_64
Resources:
CPUs: 1
Memory: 492.5 MiB
Plugins:
Network: bridge, host, macvlan, null, overlay
Volume: local
Engine Version: 1.13.0
Engine Labels:
- provider = virtualbox
如你所见,其中一个标签是env,值为prod-like。
让我们将相同的标签添加到第二个节点:
docker node update \
--label-add env=prod-like \
swarm-test-3

图 5-2:带标签节点的持续交付(CD)集群
现在我们有几个节点被标记为生产环境,我们可以创建只会在这些服务器上运行的服务。
让我们创建一个使用alpine镜像的服务,并将其约束到其中一个prod-like节点:
docker service create --name util \
--constraint 'node.labels.env == prod-like' \
alpine sleep 1000000000
我们可以列出util服务的进程,并确认它正在其中一个prod-like节点上运行:
docker service ps util
service ps命令的输出如下(为了简洁,ID 已被移除):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
util.1 alpine swarm-test-2 Running Running about a minute ago
如你所见,服务正在swarm-test-2节点中运行,该节点被标记为env=prod-like。
这本身并不能证明标签起作用。毕竟,三个节点中有两个被标记为生产环境,所以如果标签不起作用,服务有 66%的概率会运行在其中一个节点上。那么,我们来稍微增加点难度。
我们将把实例数增加到六个:
docker service scale util=6
让我们来看看util进程:
docker service ps util
输出如下(为了简洁,ID 已被移除):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
util.1 alpine swarm-test-2 Running Running 15 minutes ago
util.2 alpine swarm-test-2 Running Running 21 seconds ago
util.3 alpine swarm-test-3 Running Running 21 seconds ago
util.4 alpine swarm-test-3 Running Running 21 seconds ago
util.5 alpine swarm-test-2 Running Running 21 seconds ago
util.6 alpine swarm-test-3 Running Running 21 seconds ago
如你所见,所有六个实例都在标记为env=prod-like的节点上运行(swarm-test-2和swarm-test-3节点)。
如果我们在全局模式下运行服务,我们可以观察到类似的结果:
docker service create --name util-2 \
--mode global \
--constraint 'node.labels.env == prod-like' \
alpine sleep 1000000000
让我们来看看util-2进程:
docker service ps util-2
输出如下(为了简洁,ID 已被移除):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
util-2... alpine:latest swarm-test-3 Running Running 3 seconds ago
util-2... alpine:latest swarm-test-2 Running Running 2 seconds ago
由于我们告诉 Docker 我们希望服务是全局性的,因此期望的状态是在所有节点上都处于Running状态。然而,由于我们指定了约束node.labels.env == prod-like,副本仅在与该约束匹配的节点上运行。换句话说,服务仅在节点swarm-test-2和swarm-test-3上运行。如果我们将标签添加到节点swarm-test-1,Swarm 也会在该节点上运行服务。
在继续之前,让我们删除util服务:
docker service rm util util-2
既然我们已经知道如何将服务限制到特定节点,我们必须在继续进行持续交付步骤之前先创建一个服务。
创建服务
在继续探索持续交付步骤之前,我们应该讨论一下 Docker Swarm 引入的部署变化。我们曾经认为每次发布意味着一次新的部署,但在 Docker Swarm 中并非如此。我们现在更新的是服务,而不是每次都进行部署。在构建 Docker 镜像后,我们所要做的就是更新已经运行的服务。在大多数情况下,我们所需要做的仅仅是运行docker service update --image <IMAGE> <SERVICE_NAME>命令。服务已经拥有它所需的所有信息,我们要做的就是将镜像更改为新版本。
为了使服务更新生效,我们需要有一个服务。我们需要创建它并确保它拥有所需的所有信息。换句话说,我们只需要创建一次服务,并在每次发布时进行更新。这大大简化了发布过程。
由于服务只创建一次,因此投资回报率(ROI)太低,我们不打算自动化这一步骤。记住,我们希望自动化的是那些需要做多次的过程。那些只做一次就不再做的事情是没有自动化价值的。其中之一就是服务的创建。我们仍然手动运行所有命令,所以请将此作为下一章的说明,下一章将自动化整个过程。
让我们创建构成go-demo应用程序的服务。我们需要proxy、go-demo服务以及随附的数据库。和之前一样,我们需要创建go-demo和proxy网络。由于我们已经做过几次了,我们将通过scripts/dm-test-swarm-services.sh(github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-test-swarm-services.sh)脚本运行所有命令。它几乎以与之前相同的方式创建服务。唯一的区别是,它使用prod-like标签将服务限制仅限于那些应该用于生产类似部署的节点。
scripts/dm-test-swarm-services.sh
eval $(docker-machine env swarm-test-1)
docker service ls
service ls命令的输出如下(为了简洁起见,已删除 ID):
NAME MODE REPLICAS IMAGE
proxy replicated 2/2 vfarcic/docker-flow-proxy:latest
go-demo replicated 2/2 vfarcic/go-demo:1.0
go-demo-db replicated 1/1 mongo:3.2.10
registry replicated 1/1 registry:2.5.0

图 5-3:带有在标记为 prod-like 节点上运行的服务的 CD 集群
请注意,代理重新配置端口已设置为8090,并且在本地主机上。我们必须将其与我们在暂存环境中运行go-demo服务时将使用的8080端口区分开来。
一方面,我们希望生产环境类似集群中的服务与生产集群中的服务相似。另一方面,我们又不希望浪费资源去复制完整的生产环境。因此,我们运行了proxy和go-demo服务的两个实例(副本)。仅运行一个实例会偏离服务在生产环境中应扩展的目标。每个服务有两个实例,使我们能够测试扩展后的服务是否按预期工作。即便在生产环境中运行更多实例,两个副本也足以复制扩展行为。由于我们仍未能设置数据库复制,MongoDB 目前仅运行一个实例。
我们可以通过向go-demo发送请求来确认所有服务确实已成功创建和集成:
curl -i "$(docker-machine ip swarm-test-1)/demo/hello"
我们还将在生产集群中创建相同的服务。唯一的区别是副本数量(我们会有更多副本)以及我们不会限制它们。由于与之前的操作没有显著区别,我们将使用scripts/dm-swarm-services.sh(github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm-services.sh)脚本来加速这个过程:
scripts/dm-swarm-services.sh
eval $(docker-machine env swarm-1)
docker service ls
service ls的输出如下(为了简洁,已移除 IDs):
NAME MODE REPLICAS IMAGE
go-demo-db replicated 1/1 mongo:3.2.10
go-demo replicated 3/3 vfarcic/go-demo:1.0
registry replicated 1/1 registry:2.5.0
proxy replicated 3/3 vfarcic/docker-flow-proxy:latest

图 5-4:CD 和生产集群中的服务
现在我们已经在两个集群中创建了服务,可以开始进行持续交付步骤。
按照持续交付步骤进行操作
我们已经了解了连续交付过程所需的所有步骤。我们至少完成过一次每个步骤。在第一章《使用 Docker 容器进行持续集成》中,我们介绍了一些步骤。毕竟,持续交付是持续集成的“扩展”。如果持续集成有明确的目标,那它就是持续交付的样子。
我们在各章中完成了剩余的步骤,直到达到了这一点。我们知道如何在 Swarm 集群中创建服务,更重要的是,如何更新服务。因此,我不会详细讲解。可以将这一小节视为对我们到目前为止所做工作的一次回顾。
我们将首先检查一个我们希望通过 CD 流程迁移的服务的代码:
git clone https://github.com/vfarcic/go-demo.git
cd go-demo
接下来,我们应该运行unit测试并编译服务二进制文件:
eval $(docker-machine env swarm-test-1)
docker-compose \
-f docker-compose-test-local.yml \
run --rm unit

图 5-5:在 swarm-test-1 节点内运行单元测试
请注意,我们使用的是swarm-test-1节点。尽管它属于 Swarm 集群,但我们以“传统”模式使用它。
编译好二进制文件后,我们可以构建 Docker 镜像:
docker-compose \
-f docker-compose-test-local.yml \
build app

图 5-6:在 swarm-test-1 节点中运行的构建
构建了镜像后,我们可以运行 staging 依赖、功能测试,并在完成后销毁所有内容:
docker-compose \
-f docker-compose-test-local.yml \
up -d staging-dep
docker-compose \
-f docker-compose-test-local.yml \
run --rm staging
docker-compose \
-f docker-compose-test-local.yml \
down

图 5-7:在 swarm-test-1 节点中运行的暂存或功能测试
现在我们有信心新版本很可能会按预期工作,我们可以将结果推送到注册表:
docker tag go-demo localhost:5000/go-demo:1.1
docker push localhost:5000/go-demo:1.1
我们运行了单元测试,构建了二进制文件,构建了镜像,运行了功能测试,并将镜像推送到注册表。这个版本很可能会按预期工作。然而,唯一真正的验证是该版本在生产环境中是否能正常工作。没有比这更可靠或更值得信赖的标准了。另一方面,我们希望尽可能有信心地进入生产环境。我们将通过使用尽可能接近生产环境的 swarm-test 集群来平衡这两者需求。
目前,go-demo 服务正在 swarm-test 集群中运行版本 1.0。我们可以通过观察 service ps 命令的输出确认这一点:
docker service ps go-demo -f desired-state=running
输出如下(为了简洁,已删除 ID):
NAME IMAGE NODE DESIRED STATE
go-demo.1 vfarcic/go-demo:1.0 swarm-test-2 Running
go-demo.2 vfarcic/go-demo:1.0 swarm-test-3 Running
------------------------------------
CURRENT STATE
Running about an hour ago
Running about an hour ago
让我们将当前正在运行的版本更新为我们刚刚构建的版本 1.1:
docker service update \
--image=localhost:5000/go-demo:1.1 \
go-demo
docker service ps go-demo -f desired-state=running
请注意,服务最初是通过 --update-delay 5s 参数创建的。这意味着每次更新将在每个副本集上持续五秒钟(加上一些时间来拉取镜像和初始化容器)。
经过片刻(大约 6 秒钟),service ps 命令的输出应如下所示(为了简洁,已删除 ID):
NAME IMAGE NODE DESIRED STATE
go-demo.1 localhost:5000/go-demo:1.1 swarm-test-3 Running
go-demo.2 localhost:5000/go-demo:1.1 swarm-test-2 Running
-----------------------------------
CURRENT STATE ERROR PORTS
Running 8 seconds ago
Running 2 seconds ago
如果你在笔记本电脑上的输出不同,请稍等片刻并重复 service ps 命令。
如你所见,镜像已经更改为 localhost:5000/go-demo:1.1,表明新版本确实已经启动并运行。
请注意,由于该服务是通过 --constraint 'node.labels.env == prod-like' 参数创建的,因此新的版本仍然仅在标记为 prod-like 的节点上运行。这显示了 Docker Swarm 提供的一个大优势。我们使用所有定义其完整行为的参数创建一个服务。从那时起,我们要做的就是在每次发布时更新镜像。稍后当我们开始扩展和进行其他操作时,事情会变得更复杂。然而,逻辑本质上还是一样的。我们需要的大多数参数仅在服务创建命令中定义一次。

图 5-8:服务在 CD 集群中的 prod-like 节点内更新
现在我们已经准备好进行一些生产环境测试。我们仍然不够自信直接在生产环境中进行测试。首先,我们想看看它们在类似生产环境的集群中执行时是否能通过。
我们将像之前执行其他类型测试一样运行生产测试。我们的 Docker 客户端仍然指向swarm-test-1节点,因此我们通过 Docker Compose 运行的任何内容都会继续在该服务器内执行。
让我们快速查看docker-compose-test-local.yml文件中的生产服务定义(github.com/vfarcic/go-demo/blob/master/docker-compose-test-local.yml):
production:
extends:
service: unit
environment:
- HOST_IP=${HOST_IP}
network_mode: host
command: bash -c "go get -d -v -t && go test --tags integration -v"
production 服务extends了unit服务。这意味着它继承了unit服务的所有属性,从而避免了我们重复编写相同的内容。
接下来,我们将添加环境变量HOST_IP。我们即将运行的测试将使用该变量来推断正在测试的服务go-demo的地址。
最后,我们正在覆盖unit服务中使用的命令。新命令下载go依赖项go get -d -v -t并执行所有标记为集成测试的测试go test --tags integration -v。
让我们看看该服务是否确实在swarm-test集群中运行:
export HOST_IP=localhost
docker-compose \
-f docker-compose-test-local.yml \
run --rm production
我们指定了正在测试的服务的 IP 为 localhost。由于测试运行的节点swarm-test-1属于集群,入口网络将把请求转发到proxy服务,后者将请求转发给go-demo服务。
输出的最后几行如下:
PASS
ok _/usr/src/myapp 0.019s
所有集成测试通过,整个操作花费不到 0.2 秒。

图 5-9:在 CD 集群中运行更新后的服务时执行生产测试
从现在开始,我们应该相当自信地认为发布已经准备好进入生产环境。我们已经进行了预部署单元测试,构建了镜像,运行了暂存测试,更新了生产环境类似的集群,并执行了一套集成测试。
我们的持续交付步骤已经正式完成。发布已准备好,等待有人做出决定来更新正在生产环境中运行的服务。换句话说,此时,持续交付已经完成,我们将等待有人按下按钮来更新生产集群中的服务。
现在没有理由停下。我们已经掌握了将此过程从持续交付转为持续部署所需的所有知识。我们所需要做的只是重复在生产集群中执行的最后几个命令。
从持续交付到持续部署的额外努力
如果我们有一套全面的测试,可以让我们确信每次提交到代码仓库的代码都按预期工作,并且有一个可重复和可靠的部署过程,那么就没有理由不走那一步,自动将每个发布版本部署到生产环境。
你可能选择不做持续部署(Continuous Deployment)。也许你的流程要求我们挑选特性。也许我们的营销部门希望新特性在他们的活动开始之前不可用。有很多理由可以选择停留在持续交付(Continuous Delivery)阶段。然而,从技术角度来看,流程是相同的。唯一的区别是,持续交付要求我们按下按钮将选定的版本发布到生产环境,而持续部署则作为相同自动化流程的一部分进行部署。换句话说,我们即将执行的步骤是相同的,只是中间是否有一个按钮而已。
这可能是本书中最短的子章节。我们只需要几个命令就能将持续交付流程转变为持续部署。我们需要更新生产集群中的服务(swarm),然后返回 swarm-test-1 节点,执行另一轮测试。由于我们已经做过这些,没必要再详细讲解。我们只需继续执行:
eval $(docker-machine env swarm-1)
docker service update \
--image=localhost:5000/go-demo:1.1 \
go-demo

图 5-10:服务在生产集群内部更新
既然服务已经在生产集群中更新,我们可以执行最后一轮测试:
eval $(docker-machine env swarm-test-1)
export HOST_IP=$(docker-machine ip swarm-1)
docker-compose \
-f docker-compose-test-local.yml \
run --rm production

图 5-11:生产测试在生产集群内部更新的服务上执行
我们更新了生产集群中正在运行的版本,并进行了另一轮集成测试。没有出现失败,这表明新版本在生产环境中正确运行。
现在该做什么?
我们完成持续部署了吗?答案是否定的。我们没有创建自动化的持续部署流程,而是定义了帮助我们自动运行该流程的步骤。为了使整个过程完全自动化,并在每次提交时执行,我们需要使用其中一种 CD 工具。
我们将使用 Jenkins 将手动步骤转换为完全自动化的持续部署流程。为了使整个过程顺利进行,我们需要设置 Jenkins 主节点、一些代理节点以及部署流水线作业。
现在是休息一下的时候,再进入下一章。在开始之前,我们将销毁之前创建的机器并重新开始:
docker-machine rm -f \
swarm-1 swarm-2 swarm-3 \
swarm-test-1 swarm-test-2 swarm-test-3
第六章:使用 Jenkins 自动化持续部署流程
作为开发人员,我们最强大的工具就是自动化。
-Scott Hanselman
我们已经拥有了实现完全自动化持续部署流程所需的所有命令。现在我们需要一个工具,它可以监控代码库的变化,并在每次检测到提交时触发这些命令。
市场上有大量的 CI/CD 工具。我们选择 Jenkins。并不意味着它是唯一的选择,也不是在所有使用场景中最好的选择。我不会对不同的工具进行比较,也不会提供更多的使用 Jenkins 的决策背后的细节。这将需要一个单独的章节,甚至是一本书。相反,我们将从讨论 Jenkins 的架构开始。
Jenkins 架构
Jenkins 是一个基于主节点和代理的组合的单体应用程序。
Jenkins 的主节点可以被描述为一个协调者。它监控源代码,当满足预定条件时触发任务,存储日志和工件,并执行与 CI/CD 协调相关的许多其他任务。它不执行实际任务,而是确保任务被执行。
另一方面,Jenkins 代理执行实际的工作。当主节点触发任务执行时,实际的工作是由代理执行的。
我们无法扩展 Jenkins 主节点。至少不能像我们扩展go-demo服务那样扩展它。我们可以创建多个 Jenkins 主节点,但它们不能共享相同的文件系统。由于 Jenkins 使用文件来存储其状态,创建多个实例会导致完全独立的应用程序。由于扩展的主要原因是容错性和性能收益,通过扩展 Jenkins 主节点无法实现这些目标。
如果 Jenkins 无法扩展,我们如何满足性能要求呢?我们通过添加代理来增加容量。一个主节点可以处理许多代理。在大多数情况下,一个代理是一个完整的服务器(物理或虚拟)。一个主节点拥有几十个甚至几百个代理(服务器)并不罕见。反过来,每个代理会运行多个执行器来运行任务。
传统上,Jenkins 的主节点和代理会运行在专用服务器上。这本身就会带来一些问题。如果 Jenkins 运行在专用服务器上,当服务器出现故障时会发生什么?记住,一切都会有故障的时候。
对于许多组织来说,Jenkins 是至关重要的。如果它不可用,就无法发布新版本,无法执行计划任务,无法部署软件,等等。通常,Jenkins 的故障会通过将软件及其状态文件移动到健康的服务器来修复。如果这是手动完成的,而且通常是手动的,那么停机时间可能会非常长。
在本章中,我们将利用到目前为止获得的知识,尝试让 Jenkins 具备容错能力。我们可能无法实现零停机,但至少会尽力将停机时间缩短到最低。我们还将探讨如何应用所学知识,以几乎完全自动化的方式创建 Jenkins 主节点和代理。我们将尽力让主节点具备容错能力,代理则具备可扩展性和动态性。
够了,别再说了!让我们进入本章更实际的部分。
生产环境设置
我们将从重新创建上一章使用的生产集群开始。
本章中的所有命令都可以在 06-jenkins.sh (gist.github.com/vfarcic/9f9995f90c6b8ce136376e38afb14588) Gist 中找到:
cd cloud-provisioning
git pull
scripts/dm-swarm.sh
我们进入了之前克隆的 cloud-provisioning 仓库并拉取了最新的代码。然后执行了创建生产集群的 scripts/dm-swarm.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm.sh) 脚本。这是我们在上一章中使用的相同脚本。
让我们确认集群确实已正确创建:
eval $(docker-machine env swarm-1)
docker node ls
node ls 命令的输出如下(为了简洁,ID 已删除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-2 Ready Active Reachable
swarm-1 Ready Active Leader
swarm-3 Ready Active Reachable
现在生产集群已经启动并运行,我们可以创建 Jenkins 服务了。
Jenkins 服务
传统上,我们会在自己的服务器上运行 Jenkins。即使我们选择与其他应用程序共享服务器资源,部署仍然是静态的。我们会运行一个 Jenkins 实例(无论是否使用 Docker),并希望它永不失败。这个方法的问题在于,每个应用程序迟早都会失败。要么进程会停止,要么整个节点会崩溃。不管怎样,Jenkins 和其他任何应用程序一样,都会在某个时刻停止工作。
问题在于,Jenkins 已经成为许多组织中的关键应用。如果我们将所有自动化的执行或更准确地说,触发过程移到 Jenkins 上,我们就创建了一个强依赖关系。如果 Jenkins 没有运行,我们的代码就不能构建、不能测试、不能部署。当然,当它失败时,你可以重新启动它。如果它运行的服务器停止工作,你可以将其部署到其他地方。假设停机发生在工作时间内,停机时间不会太长。从它停止工作到有人发现、通知其他人、有人重启应用程序或配置一个新服务器,可能会过去一个小时,也许两个小时,甚至更长时间。这算长时间吗?这取决于你的组织规模。依赖某个东西的人越多,那东西出现问题时的成本就越大。即使这样的停机时间及其带来的成本并不至关重要,我们已经掌握了所有知识和工具来避免这种情况。我们要做的只是创建另一个服务,让 Swarm 处理其余部分。
Windows 用户请注意
Git Bash 有改变文件系统路径的习惯。在运行代码块之前,请执行以下命令以防止这种情况:
export MSYS_NO_PATHCONV=1
让我们创建一个 Jenkins 服务。从 cloud-provisioning 目录中运行以下命令:
mkdir -p docker/jenkins
docker service create --name jenkins \
-p 8082:8080 \
-p 50000:50000 \
-e JENKINS_OPTS="--prefix=/jenkins" \
--mount "type=bind,source=$PWD/docker/jenkins,target=/var/ \
jenkins_home"--reserve-memory 300m \
jenkins:2.7.4-alpine
docker service ps jenkins
Linux(例如:Ubuntu)用户请注意
Docker Machine 会将主机的用户目录挂载到它创建的虚拟机中。这使我们能够共享文件。然而,这个功能在运行 Linux 的 Docker Machine 中无法使用。目前最简单的解决方法是去掉 --mount 参数。稍后,当我们达到持久存储时,你将看到如何更有效地挂载卷。
好消息是这个问题很快就会解决。请参阅 issue #1376 (github.com/docker/machine/issues/1376) 以了解讨论情况。一旦 pull request #2122 (github.com/docker/machine/pull/2122) 被合并,你将能够在 Linux 上使用自动挂载功能。
Jenkins 将其状态存储在文件系统中。因此,我们首先在主机上创建了一个目录 mkdir。它将用作 Jenkins 的主目录。由于我们位于主机用户的子目录之一,docker/jenkins 目录被挂载到我们创建的所有机器上。
接下来,我们创建了服务。它将内部端口 8080 映射为 8082,并且映射端口 50000。第一个端口用于访问 Jenkins UI,第二个用于主节点与代理节点的通信。我们还定义了 URL 前缀 as/jenkins 并挂载了 jenkins 主目录。最后,我们保留了 300m 的内存。
一旦镜像下载完成,service ps 命令的输出如下(为了简洁,删除了 ID):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
jenkins.1 jenkins:2.7.4-alpine swarm-1 Running Running 52 seconds ago

图 6-1:带有 Jenkins 服务的生产集群
Jenkins 2 改变了设置流程。以前的版本允许我们在没有任何强制配置的情况下运行,但新版 Jenkins 强制我们手动执行一些步骤。不幸的是,在撰写本文时,尚无好的 API 来帮助我们自动化这个过程。尽管有一些 技巧 可以使用,但与它们带来的额外复杂性相比,得到的好处并不高。毕竟,我们只会设置 Jenkins 一次,所以没有很大的动力去自动化这个过程(至少在创建配置 API 之前)。
让我们打开 UI:
open "http://$(docker-machine ip swarm-1):8082/jenkins"
Windows 用户注意
Git Bash 可能无法使用 open 命令。如果是这种情况,可以执行 docker-machine ip <SERVER_NAME> 来找出机器的 IP 地址,并直接在你选择的浏览器中打开该 URL。例如,上述命令应该替换为以下命令:
docker-machine ip swarm-1
如果输出是 1.2.3.4,你应该在浏览器中打开 http://1.2.3.4:8082/jenkins。
你首先会注意到,系统要求你输入管理员密码。许多企业用户要求增强安全性。因此,Jenkins 不再允许在没有初始化会话的情况下访问。如果你是 Jenkins 新手,或者至少是 版本 2 的用户,你可能会想知道密码是什么。它会输出到日志中(在我们的案例中是 stdout),也会输出到文件 secrets/initialAdminPassword 中,该文件会在设置过程结束时被删除。
让我们查看 secrets/initialAdminPassword 文件的内容:
cat docker/jenkins/secrets/initialAdminPassword
输出将是一个表示临时密码的 long 字符串。请复制它,返回到 UI,粘贴到管理员密码字段中,然后点击继续按钮:

图 6-2:解锁 Jenkins 界面
解锁 Jenkins 后,你将看到一个选项,可以安装建议的插件或选择符合你需求的插件。推荐的插件适用于大多数常用场景,因此我们选择该选项。
请点击安装建议插件按钮。
一旦插件被下载并安装,我们将看到一个界面,允许我们创建第一个管理员用户。请将 admin 用作用户名和密码。可以自由填写其余字段的任何值。完成后,点击 Save and Finish 按钮:

图 6-3:创建第一个管理员用户界面
Jenkins 已经准备好了。现在剩下的,就是点击 Start using Jenkins 按钮。
现在我们可以测试 Jenkins 的故障转移是否有效。
Jenkins 故障转移
让我们停止服务并观察 Swarm 的运行情况。为此,我们需要找出它运行的节点,将 Docker 客户端指向该节点,然后删除容器:
NODE=$(docker service ps \
-f desired-state=running jenkins \
| tail -n +2 | awk '{print $4}')
eval $(docker-machine env $NODE)
docker rm -f $(docker ps -qa \
-f label=com.docker.swarm.service.name=jenkins)
我们列出了 Jenkins 进程并应用了过滤器,返回状态为运行的进程 docker service ps -f desired-state=running jenkins。输出通过 tail 命令进行管道处理,去除了头部 tail -n +2,然后再次通过 awk 命令将输出限制为第四列 awk '{print $4}',该列包含进程运行的节点。最终结果存储在 NODE 变量中。
后来,我们使用了 eval 命令来创建环境变量,这些变量将由我们的 Docker 客户端用于操作远程引擎。最后,我们通过 ps 和 rm 命令的组合来获取镜像 ID 并删除容器。
正如我们在前面的章节中已经学到的,如果一个容器失败,Swarm 会在集群内的某个地方重新启动它。当我们创建服务时,我们告诉 Swarm 期望的状态是运行一个实例,Swarm 正在尽最大努力确保我们的期望得到满足。
让我们确认服务确实正在运行:
docker service ps jenkins
如果 Swarm 决定在另一个节点上重新运行 Jenkins,可能需要一些时间来拉取镜像。稍等片刻后,service ps 命令的输出应如下所示:

我们可以通过重新打开 UI 来做最终确认:
open "http://$(docker-machine ip swarm-1):8082/jenkins"
Windows 用户注意事项
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行 docker-machine ip <SERVER_NAME> 来查找机器的 IP 地址,并直接在您选择的浏览器中打开 URL。例如,上面的命令应替换为以下命令:
docker-machine ip swarm-1
如果输出是 1.2.3.4,您应该在浏览器中打开 http://1.2.3.4:8082/jenkins。
由于 Jenkins 不允许未经身份验证的用户访问,您需要登录。请使用 admin 作为用户名和密码。
您会注意到,这次我们不需要重复设置过程。尽管在另一个节点上运行了一个全新的 Jenkins 镜像,但由于我们挂载的主机目录,状态仍然得以保留。
我们成功地使 Jenkins 容错,但未能使其在没有任何停机时间的情况下运行。由于其架构,Jenkins 主节点无法扩展。因此,当我们通过删除容器来模拟故障时,并没有第二个实例来吸收流量。即使 Swarm 将其重新调度到另一个节点,也会有一些停机时间。在短时间内,服务无法访问。虽然这不是一个完美的情况,但我们尽量将停机时间降到了最低。我们使其具有容错能力,但无法实现零停机运行。考虑到其架构,我们已经尽了最大努力。
现在是时候连接运行我们持续部署流程的 Jenkins 代理了。
Jenkins 代理
运行 Jenkins 代理的方式有很多种。大多数方法的问题在于它们迫使我们通过 Jenkins UI 单独添加代理。我们将不再逐个添加代理,而是尝试利用 Docker Swarm 的能力来扩展服务。
实现可扩展代理的一个方法是 Jenkins Swarm 插件 (wiki.jenkins-ci.org/display/JENKINS/Swarm+Plugin)。在你开始得出错误结论之前,我必须声明,这个插件与 Docker Swarm 没有任何关系。它们唯一的共同点就是名字中都包含 "Swarm"。
Jenkins Swarm 插件 (wiki.jenkins-ci.org/display/JENKINS/Swarm+Plugin) 允许我们自动发现附近的主节点并自动加入它们。我们将仅用于第二个功能。我们将创建一个 Docker Swarm 服务,作为 Jenkins 代理并自动加入主节点。
首先,我们需要安装插件。
请按照以下代码所示打开插件管理器页面:
open "http://$(docker-machine ip swarm-1):8082/jenkins/pluginManager/available"
Windows 用户须知
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行 docker-machine ip <SERVER_NAME> 来查找机器的 IP,并直接在你选择的浏览器中打开该 URL。例如,以上命令应替换为以下命令:
docker-machine ip swarm-1
如果输出是 1.2.3.4,你应该在浏览器中打开 http://1.2.3.4:8082/jenkins/pluginManager/available。
接下来,我们需要搜索 Self-Organizing Swarm Plug-in Modules 插件。最简单的方法是将插件名称输入到屏幕右上角的筛选框中。一旦找到该插件,请选择它并点击 Install without restart 按钮。
现在插件已安装,我们可以设置第二个集群,它将由三个节点组成。如前所述,我们将其命名为 swarm-test。我们将使用脚本 scripts/dm-test-swarm-2.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-test-swarm-2.sh)运行所有创建机器并将其加入集群所需的命令。
scripts/dm-test-swarm-2.sh
eval $(docker-machine env swarm-test-1)
docker node ls
node ls 命令的输出如下(为了简洁,ID 已被移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-test-2 Ready Active Reachable
swarm-test-1 Ready Active Leader
swarm-test-3 Ready Active Reachable
我们刚才运行的脚本和之前使用的 dm-test-swarm.sh 脚本之间的主要区别在于,这个脚本添加了一些标签。第一个节点被标记为 jenkins-agent,而其他两个节点则标记为 prod-like。这些标签的目的是区分用于运行诸如构建和测试 jenkins-agent 等任务的节点和用于运行模拟生产环境服务的节点 prod-like。
让我们检查一下 swarm-test-1 节点:
eval $(docker-machine env swarm-test-1)
docker node inspect swarm-test-1 --pretty
输出如下:
ID: 3rznbsuvvkw4wf7f4qa32cla3
Labels:
- env = jenkins-agent
Hostname: swarm-test-1
Joined at: 2017-01-22 08:30:26.757026595 +0000 utc
Status:
State: Ready
Availability: Active
Manager Status:
Address: 192.168.99.103:2377
Raft Status: Reachable
Leader: Yes
Platform:
Operating System: linux
Architecture: x86_64
Resources:
CPUs: 1
Memory: 492.5 MiB
Plugins:
Network: bridge, host, null, overlay
Volume: local
Engine Version: 1.13.0
Engine Labels:
- provider = virtualbox
如你所见,这个节点有一个标签,键为env,值为jenkins-agent。如果你检查其他两个节点,你会看到它们也有标签,但这次值为prod-like:

图 6-4:创建第一个管理员用户界面
现在swarm-test集群已经设置好,我们准备创建 Jenkins 代理服务。但是,在此之前,让我们快速查看一下我们将要使用的镜像定义。vfarcic/jenkins-swarm-agent Dockerfile(github.com/vfarcic/docker-jenkins-slave-dind/blob/master/Dockerfile)如下:
FROM docker:1.12.1
MAINTAINER Viktor Farcic <viktor@farcic.com>
ENV SWARM_CLIENT_VERSION 2.2
ENV DOCKER_COMPOSE_VERSION 1.8.0
ENV COMMAND_OPTIONS ""
RUN adduser -G root -D jenkins
RUN apk --update add openjdk8-jre python py-pip git
RUN wget -q https://repo.jenkins-ci.org/releases/org/jenkins-ci/plugins/swarm-client/ \
${SWARM_CLIENT_VERSION}/swarm-client-${SWARM_CLIENT_VERSION}-jar-with- \
dependencies.jar -P /home/jenkins/
RUN pip install docker-compose
COPY run.sh /run.sh
RUN chmod +x /run.sh
CMD ["/run.sh"]
它以docker作为基础镜像,接着定义了一些环境变量,指定将安装的软件版本。由于 Jenkins 作为jenkins user运行,我们也添加了它。然后是 OpenJDK、Python 和 pip 的安装。JDK 是 Jenkins Swarm 客户端所需的,其它的则是为了支持 Docker Compose。所有先决条件设置完毕后,我们下载 Swarm JAR 文件,并使用 pip 安装 Docker Compose。
最后,我们复制了run.sh脚本(github.com/vfarcic/docker-jenkins-slave-dind/blob/master/run.sh),设置其可执行权限,并定义运行命令来执行它。该脚本使用 Java 来运行 Jenkins Swarm 客户端。
在继续 Jenkins 代理服务之前,我们需要在每个代理将运行的主机上创建/workspace目录。目前,只有swarm-test-1节点符合条件。很快你就会明白为什么我们需要这个目录:
docker-machine ssh swarm-test-1
sudo mkdir /workspace && sudo chmod 777 /workspace && exit
我们进入了节点swarm-test-1,创建了目录,赋予它完全权限,然后退出了机器。
了解了vfarcic/jenkins-swarm-agent镜像(或者至少知道它包含什么)后,我们可以继续创建服务:
Windows 用户注意事项
为了使接下来的命令中的挂载工作,你需要停止 Git Bash 修改文件系统路径。按如下方式设置这个环境变量:
export MSYS_NO_PATHCONV=1
export USER=admin
export PASSWORD=admin
docker service create --name jenkins-agent \
-e COMMAND_OPTIONS="-master \
http://$(docker-machine ip swarm-1):8082/jenkins \
-username $USER -password $PASSWORD \
-labels 'docker' -executors 5" \
--mode global \
--constraint 'node.labels.env == jenkins-agent' \
--mount \
"type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \
--mount \
"type=bind,source=$HOME/.docker/machine/machines,target=/machines" \
--mount "type=bind,source=/workspace,target=/workspace" \
vfarcic/jenkins-swarm-agent
这次的service create命令比我们通常使用的要长一些。COMMAND_OPTIONS环境变量包含了代理连接到主节点所需的所有信息。我们指定了master -master http://$(docker-machine ip swarm-1):8082/jenkins的地址,定义了username和password -username $USER -password $PASSWORD,为代理打上了docker标签 -labels 'docker',并设置了执行器数量 -executors 5。
接下来,我们将服务声明为全局并限制在jenkins-agent节点上运行。这意味着它将运行在每个具有匹配标签的节点上。目前,只有一台服务器符合条件。很快我们就会看到这种设置所带来的好处。
我们挂载了 Docker 套接字。结果,发送到容器内运行的 Docker 客户端的任何命令将会对主机上的 Docker 引擎(在此情况下为 Docker Machine)执行。这样,我们就可以避免运行 Docker inside Docker 或 DinD 可能带来的陷阱。欲了解更多信息,请阅读文章《使用 Docker-in-Docker 作为 CI 或测试环境?三思而后行》(jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/)。
我们还挂载了包含密钥的主机(笔记本电脑)目录。这样,我们就可以向运行在另一个集群中的引擎发送请求。最终挂载将主机目录 /workspace 映射到容器内。所有在 Jenkins 代理中运行的构建将使用该目录:

图 6-5:作为全局服务运行的 Jenkins 代理
让我们看一下服务进程:
docker service ps jenkins-agent
输出如下(为简洁起见,已移除 ID):

如您所见,服务是全局性的,因此期望的状态是它在每个节点上运行。然而,由于我们将其限制到带有 jenkins-agent 标签的节点,因此容器仅在那些具有匹配标签的节点中运行。换句话说,服务仅在 jenkins-agent 节点上运行。
让我们打开显示已注册代理的 Jenkins 界面:
open "http://$(docker-machine ip swarm-1):8082/jenkins/computer/"
Windows 用户注意事项
Git Bash 可能无法使用 open 命令。如果是这种情况,执行 docker-machine ip <SERVER_NAME> 来查找机器的 IP 地址,并直接在您选择的浏览器中打开该 URL。例如,上面的命令应替换为以下命令:
docker-machine ip swarm-1
如果输出为 1.2.3.4,则应该在浏览器中打开 http://1.2.3.4:8082/jenkins/computer。
如您所见,已经注册了两个代理。主代理默认与每个 Jenkins 实例一起运行。在我的机器上,作为 jenkins-agent 服务运行的代理被标识为 e0961f7c1801-d9bf7835:

图 6-6:Jenkins Swarm 代理添加到主节点
由于我们使用标签将服务限制到 swarm-test-1 节点,目前只有一个代理被注册(除了主节点,在大多数情况下不应使用它)。
代理配置为使用五个执行器。这意味着可以并行执行五个构建。请注意,在这种情况下,执行器的数量被人为设定得很高。每台机器只有一个 CPU。如果没有其他信息,我可能会将执行器的数量设置为与 CPU 数量相同。这只是基本的计算,随时间变化而改变。如果我们通过这些执行器运行的任务需要大量 CPU,我们可能会降低执行器的数量。但是,对于本练习来说,五个执行器应该可以。我们只有一个服务,所以我们不会并行运行构建。
假设这是一个真实系统,有更多构建并行运行比执行器数量多的情况。在这种情况下,一些构建将排队等待执行器完成并释放资源。如果这是一个暂时的情况,我们不需要做任何事情。正在执行的构建将结束,释放资源,并运行排队的构建。然而,如果这是一个经常发生的情况,排队构建的数量可能会开始增加,并且一切都会放慢。既然我们已经确定速度是持续集成、交付和部署过程中的关键因素,当事情开始变得阻碍时,我们需要采取措施。在这种情况下,这种措施就是增加可用的执行器,从而增加代理的数量。
假设我们达到了限制并且需要增加代理的数量。了解全局 Swarm 服务的工作原理,我们只需创建一个新节点:
docker-machine create -d virtualbox swarm-test-4
docker-machine ssh swarm-test-4
sudo mkdir /workspace && sudo chmod 777 /workspace && exit
TOKEN=$(docker swarm join-token -q worker)
eval $(docker-machine env swarm-test-4)
docker swarm join \
--token $TOKEN \
--advertise-addr $(docker-machine ip swarm-test-4) \
$(docker-machine ip swarm-test-1):2377
我们创建了swarm-test-4节点,并在其中创建了/workspace目录。然后我们获取了令牌并将新创建的服务加入集群作为工作节点。
让我们确认新节点确实已添加到集群中:
eval $(docker-machine env swarm-test-1)
docker node ls
node ls命令的输出如下(为简洁起见已删除 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-test-3 Ready Active Reachable
swarm-test-2 Ready Active Reachable
swarm-test-1 Ready Active Leader
swarm-test-4 Ready Active
Jenkins 代理是否正在新创建的节点内运行?让我们来看看:
docker service ps jenkins-agent
service ps命令的输出如下(为简洁起见已删除 ID):
NAME IMAGE NODE
jenkins-agent... vfarcic/jenkins-swarm-agent:latest swarm-test-1
---------------------------------------- DESIRED STATE CURRENT STATE ERROR PORTS
Running Running
由于节点未标记为jenkins-agent,因此代理未在swarm-test-4服务器内运行。
让我们添加标签:
docker node update \
--label-add env=jenkins-agent \
swarm-test-4
docker service ps jenkins-agent
这次输出略有不同(为简洁起见已删除 ID):

Swarm 检测到新标签,运行容器,并将状态更改为运行中。
让我们回到列出连接代理的 Jenkins 界面:
open "http://$(docker-machine ip swarm-1):8082/jenkins/computer"
Windows 用户注意
Git Bash 可能无法使用打开命令。如果是这样,请执行以下命令来查找机器的 IP 并直接在您选择的浏览器中打开 URL。例如,上面的命令应替换为以下命令:
docker-machine ip swarm-1
如果输出为1.2.3.4,您应在浏览器中打开http://1.2.3.4:8082/jenkins/computer。
正如您所见,新代理 b76e943ffe6c-d9bf7835 已添加到列表中:

图 6-7:第二个 Jenkins Swarm 代理已添加到主节点
这很简单,不是吗?通常我们不仅需要创建一个新服务器,还需要运行代理并通过 UI 将其添加到 Jenkins 配置中。通过结合 Jenkins Swarm 插件和 Docker Swarm 全局服务,我们成功地自动化了大多数步骤。我们需要做的就是创建一个新节点,并将其添加到 Swarm 集群中。
在我们继续并通过 Jenkins 自动化持续部署流程之前,我们应该在生产和类生产环境中创建服务。
在生产和类生产环境中创建服务
由于服务仅在首次创建时添加,并且每当其某些方面发生变化(例如:新的镜像与新版本一起发布)时才会更新,因此没有强烈的动力将服务创建加入到持续部署流中。我们得到的只是增加复杂性而没有实际的好处。因此,我们将手动创建所有服务,之后再讨论如何在每次新版本发布时自动触发该流程。
我们已经创建了 go-demo、go-demo-db、proxy、jenkins 和 registry 服务很多次,因此我们将跳过解释并运行 scripts/dm-swarm-services-2.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm-services-2.sh),该脚本将重新创建我们在前几章中遇到的情况:
scripts/dm-swarm-services-2.sh
eval $(docker-machine env swarm-1)
docker service ls
service ls 命令的输出如下(为了简洁,已移除 ID):
NAME MODE REPLICAS IMAGE
go-demo replicated 3/3 vfarcic/go-demo:1.0
jenkins replicated 1/1 jenkins:2.7.4-alpine
go-demo-db replicated 1/1 mongo:3.2.10
registry replicated 1/1 registry:2.5.0
proxy replicated 3/3 vfarcic/docker-flow-proxy:latest
所有服务都在运行。我们现在运行的脚本与之前使用的脚本 scripts/dm-swarm-services.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm-services.sh) 唯一的不同之处是,这次我们加入了 registry。
现在生产环境已经启动并运行,我们来在 swarm-test 集群中创建相同的服务集。由于这个集群在生产环境类环境中与 Jenkins 代理共享,我们将把服务限制在 prod-like 节点上。
与生产集群一样,我们将通过脚本来运行这些服务。这次我们将使用 scripts/dm-test-swarm-services-2.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-test-swarm-services-2.sh):
scripts/dm-test-swarm-services-2.sh
eval $(docker-machine env swarm-test-1)
docker service ls
service ls 命令的输出如下(为了简洁,已移除 ID):
NAME MODE REPLICAS IMAGE
jenkins-agent global 2/2 vfarcic/jenkins-swarm-agent:latest
registry replicated 1/1 registry:2.5.0
go-demo replicated 2/2 vfarcic/go-demo:1.0
proxy replicated 2/2 vfarcic/docker-flow-proxy:latest
go-demo-db replicated 1/1 mongo:3.2.10
现在,服务已经在生产环境和类生产环境中运行,我们可以继续讨论我们将如何通过 Jenkins 实现 CD 流程自动化的方法。
使用 Jenkins 自动化持续部署流程
Jenkins 基于插件。几乎每个功能都是一个插件。如果我们需要使用 Git,有一个插件。如果我们想使用 Active Directory 进行身份验证,也有一个插件。你明白了,几乎所有东西都是插件。而且,大多数插件是由社区创建并维护的。当我们不确定如何完成某个任务时,插件目录(wiki.jenkins-ci.org/display/JENKINS/Plugins)通常是我们开始查找的第一个地方。
有超过 1200 个插件可供选择,难怪在如此多样化的插件面前,大多数用户都不得不使用插件来完成几乎所有类型的任务。Jenkins 的老手们会创建一个 Freestyle 作业,举个例子,它会克隆代码并构建二进制文件。然后是另一个作业来运行单元测试,再一个来运行功能测试,依此类推。所有这些 Freestyle 作业都会连接起来。当第一个完成时,它会调用第二个,第二个会调用第三个,以此类推。Freestyle 作业促使了大量插件的使用。
我们会选择一个适合特定任务的插件,填写一些字段,然后点击保存。这样的做法使我们能够自动化步骤,而无需了解不同工具的工作原理。需要执行一些 Gradle 任务?只需选择 Gradle 插件,填写几个字段,然后开始吧。
这种基于大量使用插件的方法可能会带来灾难。理解自动化及其背后的工具至关重要。此外,使用 Freestyle 作业违背了我们行业中的一个基本原则。所有内容都应该存储在代码仓库中,经过代码审查、版本控制,等等。没有充分的理由说明编码实践不适用于自动化代码。
我们将采取一种不同的方法。
我坚信,形成 CI/CD Pipeline 的步骤应该在像 Jenkins 这样的工具之外进行指定。我们应该能够在没有 CI/CD 工具的情况下定义所有命令,并且一旦我们确信一切按预期工作,就可以将这些命令转化为 CI/CD 友好的格式。换句话说,自动化是第一步,CI/CD 工具是后续的。
幸运的是,最近 Jenkins 引入了一个新的概念——Jenkins Pipeline。与通过 Jenkins UI 定义的 Freestyle 作业不同,Pipeline 允许我们将 CD 流程定义为代码。由于我们已经有了一套良好定义的命令,转换为 Jenkins Pipeline 应该是相对简单的。
让我们来试试看。
创建 Jenkins Pipeline 作业
我们将从定义一些环境变量开始。声明这些变量的原因是,我们希望有一个集中存储关键信息的地方。这样,当某些内容发生变化时(例如:集群入口点),我们只需修改一个或两个变量,变化就会传递到所有作业中。
我们开始吧。首先,我们需要打开 Jenkins 的全局配置页面:
open "http://$(docker-machine ip swarm-1):8082/jenkins/configure"
Windows 用户注意:
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行 docker-machine ip <SERVER_NAME> 来找出机器的 IP,并直接在你选择的浏览器中打开该 URL。例如,前面的命令应替换为以下命令:
docker-machine ip swarm-1 如果输出是 1.2.3.4,你应该在浏览器中打开 http://1.2.3.4:8082/jenkins/configure。
进入配置页面后,请勾选“Environment variables”复选框,然后点击“Add”按钮。你将看到 Name 和 Value 字段。我们将首先添加一个变量来存储生产 IP。但在输入之前,我们需要先查找它。路由网格将请求从任何节点重定向到目标服务,或者更确切地说,重定向到暴露与请求相同端口的服务。因此,我们可以使用生产集群swarm中的任何服务器作为我们的入口点。
要获取某个节点的 IP,我们可以使用 docker-machine ip 命令:
docker-machine ip swarm-1
结果会因情况而异。在我的笔记本电脑上,输出如下:
192.168.99.107
请复制 IP 并返回 Jenkins 配置页面。在 Name 字段中输入PROD_IP,并将 IP 粘贴到 Value 字段中。值得注意的是,我们刚刚引入了单点故障。如果swarm-1节点出现故障,所有使用该变量的作业也会失败。好消息是,我们可以通过更改这个环境变量的值来快速修复它。坏消息是,我们能做得更好,但不能通过 Docker 机器做到。如果我们使用 AWS,例如,我们可以利用弹性 IP。然而,我们还没有进入 AWS 章节,所以目前更改变量是最好的选择。
接下来,我们应添加另一个变量,表示生产节点的名称。我们稍后将看到这个变量的使用。现在,请创建一个新变量,Name 为PROD_NAME,Value 为swarm-1。
我们还需要为生产类集群swarm-test创建类似的变量。请分别输入变量PROD_LIKE_IP,值为swarm-test-1节点的 IP(docker-machine ip swarm-test-1),以及PROD_LIKE_NAME,值为swarm-test-1:

图 6-8:配置了环境变量的 Jenkins 全局配置页面
完成环境变量的设置后,请点击“Save”按钮。
现在环境变量已经定义完成,我们可以继续创建一个 Jenkins Pipeline 作业,用于自动化执行我们练习过的 CD 步骤。
为了创建新的作业,请点击左侧菜单中的“New Item”链接,输入go-demo作为项目名称,选择 Pipeline,然后点击 OK 按钮。
一个 Jenkins Pipeline 定义包含三个主要层次:node、stage 和 step。我们将通过逐一讲解这些层次来定义 go-demo Pipeline 代码。
定义 Pipeline 节点
在 Jenkins Pipeline DSL 中,node 是一个步骤,它通常通过请求代理上的可用执行器来完成两项任务。
节点通过将其中包含的步骤添加到 Jenkins 构建队列中来调度这些步骤。这样,当节点上的执行器槽位空闲时,相应的步骤就会被执行。
它还创建了一个工作空间,意味着一个特定于某个作业的文件目录,在这个目录中,资源密集型处理可以发生,而不会对你的 Pipeline 性能产生负面影响。节点创建的工作空间在节点声明中的所有步骤执行完成后会自动被删除。最佳实践是将所有实际工作(如构建或运行 shell 脚本)都放在节点内进行,因为阶段中的节点块告诉 Jenkins 其中的步骤是资源密集型的,足以进行调度,向代理池请求帮助,并仅在需要时锁定工作空间。
如果节点的定义让你感到困惑,可以把它理解为执行步骤的地方。它指定了一个服务器(代理),该服务器将执行任务。这个指定可以是服务器的名称(通常不是一个好主意,因为节点配置与代理的紧密耦合),也可以是一组标签,这些标签必须与代理中设置的标签匹配。如果你还记得我们用来启动 Jenkins Swarm 代理服务的命令,你会记得我们使用了-labels docker作为命令选项之一。由于 Docker 引擎和 Compose 是我们需要的唯一可执行文件,所以这就是我们作为节点规范所需的唯一标签。
请将以下代码输入到 go-demo 作业配置的 Pipeline 脚本字段中,并按下保存按钮:
node("docker") {
}
我们刚刚写了 Pipeline 的第一次迭代。现在让我们运行它。
请点击 "立即构建" 按钮。
作业开始运行并显示了消息,表示该 Pipeline 已成功运行,但没有定义任何阶段。 我们马上会更正这一点。现在,让我们看一下日志:

图 6-9:Jenkins Pipeline 作业的第一次构建
你可以通过点击构建号旁边的球形图标访问日志,在这种情况下是 #1。你也可以从位于屏幕左侧的 构建历史 小部件中访问构建记录。
输出结果如下:
Started by user admin
[Pipeline] node
Running on be61529c010a-d9bf7835 in /workspace/go-demo
[Pipeline] {
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS
在这次构建中,变化不大。Jenkins 解析了节点定义,并决定使用代理be61529c010a-d9bf7835(两个 Jenkins Swarm 服务实例之一),并在 /workspace/go-demo 目录中运行步骤。目录结构很简单。所有由构建生成的文件都位于一个与作业名称匹配的目录中。在本例中,目录名为 go-demo。
由于我们没有在节点内部指定任何步骤,Pipeline 几乎立即完成执行,结果是成功的。让我们通过添加阶段来稍微增加一点复杂度。
定义 Pipeline 阶段
阶段是执行任何任务的逻辑上不同部分,具有用于锁定、排序和标记其所属流程的参数。流水线语法通常由阶段组成。每个阶段步骤可以包含一个或多个构建步骤。在阶段内工作是一种最佳实践,因为它们通过为流水线提供逻辑划分来帮助组织工作,并且因为 Jenkins 流水线可视化功能将阶段显示为流水线的独特部分。
使用手动命令练习的流程阶段是什么?我们可以将定义的命令分成以下几组:
-
从仓库拉取最新代码。
-
运行单元测试并构建服务和 Docker 镜像。
-
部署到预备环境并运行测试。
-
对 Docker 镜像打标签并将其推送到注册表。
-
使用最新镜像更新运行在类生产环境中的服务并运行测试。
-
使用最新镜像更新运行在生产环境中的服务并运行测试。
当这些任务组被转换为流水线阶段时,节点内的代码如下:
stage("Pull") {
}
stage("Unit") {
}
stage("Staging") {
}
stage("Publish") {
}
stage("Prod-like") {
}
stage("Production") {
}
我们应该将之前定义的节点与这些阶段结合起来。更确切地说,它们都应该在节点块内定义。
请通过复制并粘贴来替换现有的流水线定义,从scripts/go-demo-stages.groovy (github.com/vfarcic/cloud-provisioning/blob/master/scripts/go-demo-stages.groovy) 中访问作业配置,您可以通过位于屏幕顶部的面包屑内的 go-demo 链接访问主作业页面。一旦进入主作业页面,请点击左侧菜单中的“配置”按钮。完成编写或粘贴新的流水线定义后,请保存并通过点击“立即构建”按钮重新运行作业。
我们仍然没有执行任何操作。但是,这次,阶段视图屏幕更加详细。它显示了我们之前定义的阶段:

图 6-10:Jenkins 流水线阶段视图屏幕
现在我们准备定义将在每个阶段内执行的步骤。
定义流水线步骤
在我们开始编写步骤之前,我必须简要提到人们用于定义 Jenkins 作业的几种不同方法。有些人喜欢充分利用 Jenkins 插件。当这种方法被推向极致时,每个操作都通过插件执行。需要运行一些 Gradle 任务吗?有一个 Gradle 插件(或两个)。需要使用 Docker 做些什么?大约有十几个 Docker 插件。用 Ansible 进行配置管理呢?也有一个插件。
我不认为过度依赖插件是件好事。我相信我们应该能够在没有 Jenkins 的情况下完成大部分甚至全部自动化。毕竟,使用一个插件来避免我们写一行命令,合理吗?我不认为这是必要的。这并不意味着我们不应该使用插件。我们应该使用插件,但只在它们带来了实际且可触及的附加价值时。例如 Git 插件。它判断代码是需要克隆还是拉取。它管理身份验证。它提供了一些可以与其他步骤结合使用的自动填充环境变量。
我们是否总是应该使用 Git 插件?我们不应该。假设我们只需要在已经克隆的仓库中执行一个简单的拉取操作,且不需要身份验证,并且在后续步骤中不会使用一些拉取信息(例如:提交 ID)。在这种情况下,最简单的解决方案可能是最好的选择。从 Git 仓库中拉取代码的最简单方法是什么?最有可能的就是通过 Shell 执行 git pull 命令。
只有当我们了解自己在做什么,并且该过程是以与 CI/CD 工具无关的方式完成时,我们才应该继续并通过 Jenkins(或者您选择的任何工具)将其整合在一起。这样,我们不仅从纯自动化的角度理解过程,还能在工具、过程和架构的选择上把握清楚。所有部分需要以有机且高效的方式协同工作。如果我们能够做到这一点,那么 Jenkins 就像是把所有部分结合起来的胶水,而不是我们开始时的基础。
让我们定义第一阶段所需的步骤。目标非常简单,就是从 Git 仓库中获取代码。为了稍微增加一些复杂度,我们可能需要克隆或拉取代码。第一次构建时没有任何内容,所以我们必须进行克隆。所有后续的构建只应当执行拉取操作,拉取已经克隆的代码。虽然编写一个执行该逻辑的脚本相对简单,但这是一个很好的使用 Jenkins 插件的案例。更具体地说,我们将使用 Jenkins Pipeline 步骤 git,它在后台使用了其中一个 Git 插件。
Pipeline 中的 Pull 阶段如下所示:
stage("Pull") { \
git "https://github.com/vfarcic/go-demo.git" \
}
git 步骤是通过 Pipeline 领域特定语言 (DSL) 提供的众多步骤之一。它会克隆代码。如果该操作已经完成,代码将会被拉取。您可以在 Pipeline 步骤参考 页面(jenkins.io/doc/pipeline/steps/)中找到更多信息。
请注意,在实际情况中,我们会在代码仓库中创建一个 webhook。当有新的提交时,它会触发此作业。现在,我们将通过手动触发作业执行来模拟一个 webhook。
请随意通过复制并粘贴来自 scripts/go-demo-pull.groovy (github.com/vfarcic/cloud-provisioning/blob/master/scripts/go-demo-pull.groovy) 的代码来替换现有的流水线定义。完成后,请运行作业并观察构建日志。
继续前进。
以下代码是我们在前几章中用于运行单元测试并构建新 Docker 镜像的命令翻译:
withEnv([
"COMPOSE_FILE=docker-compose-test-local.yml"
]) {
stage("Unit") {
sh "docker-compose run --rm unit"
sh "docker-compose build app"
}
}
我们将整个阶段包含在 withEnv 块中,定义了 COMPOSE_FILE 变量。这样,我们就不需要每次执行 docker-compose 时都重复 -f docker-compose-test-local.yml 参数。请注意,我们稍后定义的所有其他阶段也应该包含在 withEnv 块中。
单元阶段中的步骤与我们手动运行流程时练习的步骤相同。唯一的不同是,这次我们将命令放入了sh DSL步骤中。其目的很简单,就是运行一个 shell 命令。
我们将跳过运行作业,直接进入下一阶段:
stage("Staging") {
try {
sh "docker-compose up -d staging-dep"
sh "docker-compose run --rm staging"
} catch(e) {
error "Staging failed"
} finally {
sh "docker-compose down"
}
}
Staging 阶段稍微复杂一些。命令位于 try/catch/finally 块中。这样做的原因是由于 Jenkins 在出现故障时的行为。如果前一个单元阶段中的某个步骤失败,整个流水线构建将会中止。这对于没有额外操作要执行的情况很适合。但是,在 Staging 阶段,我们希望删除所有的依赖容器并释放资源供其他用途。换句话说,无论 Staging 测试的结果如何,都应该执行 docker-compose down。如果你是程序员,可能已经知道 finally 语句会在 try 语句是否产生错误时都被执行。在我们的情况下,finally 语句将关闭所有构成此 Docker Compose 项目的容器。
接下来是 发布 阶段:
stage("Publish") {
sh "docker tag go-demo \
localhost:5000/go-demo:2.${env.BUILD_NUMBER}"
sh "docker push \
localhost:5000/go-demo:2.${env.BUILD_NUMBER}"
}
这个阶段没有什么神秘的。我们重复在前几章中执行的相同命令。镜像被标记并推送到注册表。
请注意,我们使用 BUILD_NUMBER 为标签提供唯一的发布号。它是 Jenkins 内置的环境变量之一,保存当前正在执行的构建 ID 的值。
生产环境类似 阶段会带来一个额外的注意事项。具体如下:
stage("Prod-like") {
withEnv([
"DOCKER_TLS_VERIFY=1",
"DOCKER_HOST=tcp://${env.PROD_LIKE_IP}:2376",
"DOCKER_CERT_PATH=/machines/${env.PROD_LIKE_NAME}"
]) {
sh "docker service update \
--image localhost:5000/go-demo:2.${env.BUILD_NUMBER} \
go-demo"
}
withEnv(["HOST_IP=localhost"]) {
for (i = 0; i <10; i++) {
sh "docker-compose run --rm production"
}
}
}
由于我们使用滚动更新来将旧版本替换为新版本,因此必须在整个过程中运行测试。我们可以创建一个脚本来验证所有实例是否已更新,但我想保持简单(这次)。相反,我们将测试运行十次。根据测试的平均时长和更新所有实例所需的时间,你可能需要根据实际情况做一些调整。为了演示的目的,在生产环境类似的环境中进行十轮测试应该足够了。
总结一下,在这一阶段,我们正在使用新版本更新服务,并在更新过程中运行十轮测试。
请注意,我们声明了一些额外的环境变量。具体来说,我们定义了所有连接到远程主机上运行的 Docker 引擎所需的环境变量。
我们快完成了。现在服务已经在production-like环境中进行了测试,我们可以将其部署到生产集群中。
Prod阶段几乎与Prod-like相同:
stage("Production") {
withEnv([
"DOCKER_TLS_VERIFY=1",
"DOCKER_HOST=tcp://${env.PROD_IP}:2376",
"DOCKER_CERT_PATH=/machines/${env.PROD_NAME}"
]) {
sh "docker service update \
--image localhost:5000/go-demo:2.${env.BUILD_NUMBER} \
go-demo"
}
withEnv(["HOST_IP=${env.PROD_IP}"]) {
for (i = 0; i <10; i++) {
sh "docker-compose run --rm production"
}
}
}
唯一的区别是,这次,DOCKER_HOST和PROD_IP变量指向生产集群中的一台服务器。其余部分与Prod-like阶段相同。
随意用scripts/go-demo.groovy中的代码替换现有的 Pipeline 定义。完成后,请运行作业并查看构建日志。
稍等片刻,作业将完成执行,新的版本将在生产集群中运行:

图 6-11:Jenkins Pipeline 阶段视图屏幕
我们可以通过执行service ps命令来确认使用新版本的服务更新确实成功:
eval $(docker-machine env swarm-1)
docker service ps go-demo
service ps命令的输出如下(为简洁起见,ID 已被移除):

就是这样!我们已经拥有了一个完整的持续部署 Pipeline,运行顺利。如果我们为托管代码的 GitHub 仓库添加一个 Webhook,Pipeline 将在每次提交新代码时运行。因此,新的版本将被部署到生产环境,除非 Pipeline 中的某个步骤失败。
现在怎么办?
使用代码定义持续部署流程步骤的能力,比之前使用Freestyle作业时给我们带来了更多的灵活性。Docker Compose 让我们可以运行任何类型的任务,而无需设置任何特殊的基础设施。只要在容器内,任何东西都能运行。最后,Docker Swarm 大大简化了生产环境和类似生产环境的部署。
我们仅仅触及了使用 Jenkins Pipeline 来自动化持续部署流程的表面。我们还可以做出相当多的改进。例如,我们可以使用Pipeline Shared Groovy Libraries Plugin (wiki.jenkins-ci.org/display/JENKINS/Pipeline+Shared+Groovy+Libraries+Plugin),将步骤,甚至整个阶段移动到函数中,从而减少代码重复。我们还可以创建一个Jenkinsfile (jenkins.io/doc/book/pipeline/jenkinsfile/),将 Pipeline 定义从 Jenkins 移动到服务仓库中,从而将与单个服务相关的所有内容保存在一个地方。我们还可以持续运行生产测试(不仅仅是在发布新版本时),确保在服务无法正常工作或表现不符合预期时收到通知。
我们将把这些以及其他可能的改进留到以后再做。虽然它不是完美的,也不是最优的,但 go-demo 流水线现在应该足够用了。
在深入下一章之前,是时候休息一下了。如同之前一样,我们将销毁已创建的机器并重新开始:
docker-machine rm -f \
swarm-1 swarm-2 swarm-3 \
swarm-test-1 swarm-test-2 \
swarm-test-3 swarm-test-4
第七章:探索 Docker 远程 API
你可以大规模生产硬件;你不能大规模生产软件——你不能大规模生产人类的思维。
—— Michio Kaku
直到现在,我们一直通过 Docker 客户端来使用 Docker。每当我们需要某个功能时,唯一需要做的就是执行一个docker命令(例如:docker service create)。在大多数情况下,当我们仅限于从命令行操作集群时,这已经足够了。
如果我们想实现一些客户端提供的功能之外的操作会发生什么呢?如果我们希望从我们的应用程序内部操作 Docker 呢?我们能从整个集群中获取所有正在运行的容器的统计信息吗?
对这些问题以及其他许多问题的一个可能答案在于采用 Docker 公司之外的工具。我们将在接下来的章节中探讨这些工具。
另一种方法是使用 Docker 远程 API。毕竟,如果我们选择了 Docker 生态系统中的某个产品,它很可能会使用 API。Docker Compose 使用它向 Docker 引擎发送命令。即使是客户端,也使用它与远程引擎进行通信。你也许会发现它很有用。
默认情况下,Docker 守护进程监听 unix:///var/run/docker.sock,并且客户端必须拥有 root 权限才能与守护进程交互。如果你的系统上存在名为 docker 的组,Docker 会将套接字的所有权赋给该组。这并不意味着套接字是访问 API 的唯一方式。事实上,还有很多其他方式,我鼓励你尝试不同的组合。为了本章的目的,我们将坚持使用套接字,因为它是发送 API 请求最简单的方式。
环境设置
就像前几章一样,我们将从创建一个我们将用来实验的集群开始。
本章中的所有命令都可以在 07-api.sh 中找到(gist.github.com/vfarcic/bab7f89f1cbd14f9895a9e0dc7293102)Gist。
请进入我们拉取仓库的 cloud-provisioning 目录。由于我可能自上次你使用它之后更新过它,我们将执行一个 pull。最后,我们将运行已经熟悉的 script/ dm-swarm.sh,它将创建一个新的 Swarm 集群:
cd cloud-provisioning
git pull
scripts/dm-swarm.sh
集群已经启动并运行。
使用 Docker Machine 创建的虚拟机基于 Boot2Docker。它是一个专门为运行 Docker 容器而设计的轻量级 Linux 发行版。它完全运行在 RAM 中,下载大小仅为 38 MB,并且大约 5 秒内即可启动。它基于 Tiny Core Linux(tinycorelinux.net/)。与更流行的 Linux 发行版相比,它的区别在于体积。它被精简到最基本的程度。这种做法对我们来说非常适用。如果我们采用容器,大多数通常在像 Ubuntu 和 RedHat 这样的发行版中看到的内核模块其实并没有必要。
这与我们在使用容器时追求的简约方法一致。我之前讨论了使用 Alpine 作为容器基础镜像的原因。最主要的原因是它的体积(仅几个 MB)。毕竟,为什么我们要将不需要的东西打包到容器中呢?主机操作系统也可以这样说。少即是多,只要满足我们的所有需求即可。
有一个警告。Boot2Docker 当前设计和优化用于开发环境。在此时,强烈不建议将其用于任何生产负载。这并不削弱其价值,但明确区分了它适合做什么,不适合做什么。
对 Boot2Docker 和 Tiny Core Linux 的简短介绍是为了后续步骤做准备。我们即将安装一些程序,而我们需要了解所使用的发行版的包管理工具。Tiny Core Linux 使用 tce-load。
在前几章中,我们从操作系统(MacOS、Linux 或 Windows)执行了大多数命令。这一次,我们将在 Docker Machine 虚拟机中执行这些命令。原因在于我们将使用 jq (stedolan.github.io/jq/) 来格式化从 API 接收到的 JSON 输出。它在大多数平台上都可用,但我认为最好将你放入虚拟机中,以避免可能出现的问题。第二个更重要的原因是,我们选择通过机器上的 Docker 套接字向 API 发送请求。
不再多说,我们将开始安装 curl 和 jq:
docker-machine ssh swarm-1
tce-load -wi curl wget
wget https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64
sudo mv jq-linux64 /usr/local/bin/jq
sudo chmod +x /usr/local/bin/jq
我们进入了 swarm-1 机器,并使用 tce-load 安装了 curl 和 wget。由于 jq 无法通过 tce-load 获取,我们使用 wget 下载了二进制文件。最后,我们将 jq 移动到 bin 目录,并添加了执行权限。
现在我们准备好开始探索 Docker 远程 API 了。
通过 Docker 远程 API 操作 Docker Swarm
我们不会详细讲解整个 API。官方的 文档 (docs.docker.com/engine/reference/api/docker_remote_api_v1.24/) 编写得非常清晰,并提供了足够的细节。相反,我们将通过一些围绕 Docker Swarm 的基本示例来学习。我们将通过重复之前练习过的一些客户端命令来查看如何使用 API。本章的目标是获取足够的知识,以便在应用程序中使用 API,并将其作为连接不同服务的粘合剂,我们将在后续章节中探索这些服务。之后,我们将尝试利用这些知识创建一个监控系统,将有关集群的信息存储在数据库中,并执行一些操作。
让我们讨论一个非常简单的示例,展示 API 的可能使用场景。
如果某个节点出现故障,Swarm 会确保该节点内部运行的容器被重新调度。然而,这并不意味着这样就足够了。我们可能想要发送一封邮件,通知某个节点故障。收到这样的邮件后,有人将调查故障节点的原因,并可能采取一些纠正措施。这些任务并不紧急,因为 Swarm 已经缓解了问题。然而,事情不紧急并不意味着它不应当被完成。
接下来的章节将尝试让我们的集群更加强大,而 API 在其中将发挥关键作用。现在,让我们先做一个简要的概述。
我们将从一个简单的示例开始。让我们看看哪些节点构成了我们的集群:
curl \
--unix-socket /var/run/docker.sock \
http:/nodes | jq '.'
输出显示了三节点的详细信息。展示所有节点的所有信息对本书来说太多了,所以我们限制输出为一个节点。我们只需要将节点的名称附加到之前的命令后面:
curl \
--unix-socket /var/run/docker.sock \
http:/nodes/swarm-1 | jq '.'
输出如下:
[
{
"ID": "2vxiqun3wvh1l1g43utk2v5a7",
"Version": {
"Index": 23
},
"CreatedAt": "2017-01-23T20:30:00.402618571Z",
"UpdatedAt": "2017-01-23T20:30:04.026051022Z",
"Spec": {
"Labels": {
"env": "prod"
},
"Role": "manager",
"Availability": "active"
},
"Description": {
"Hostname": "swarm-1",
"Platform": {
"Architecture": "x86_64",
"OS": "linux"
},
"Resources": {
"NanoCPUs": 1000000000,
"MemoryBytes": 1044131840
},
"Engine": {
"EngineVersion": "1.13.0",
"Labels": {
"provider": "virtualbox"
},
"Plugins": [
{
"Type": "Network",
"Name": "bridge"
},
{
"Type": "Network",
"Name": "host"
},
{
"Type": "Network",
"Name": "macvlan"
},
{
"Type": "Network",
"Name": "null"
},
{
"Type": "Network",
"Name": "overlay"
},
{
"Type": "Volume",
"Name": "local"
}
]
}
},
"Status": {
"State": "ready",
"Addr": "127.0.0.1"
},
"ManagerStatus": {
"Leader": true,
"Reachability": "reachable",
"Addr": "192.168.99.100:2377"
}
},
上面的输出被截断,仅包括“Leader”节点。你的输出将包含以 ID 开头的三个节点集合。我们不会详细讨论每个字段的含义,你应该已经熟悉大部分字段。
更多信息,请参考 Docker Remote API v1.24: 节点 (docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/nodes)。
API 不仅限于查询操作。我们还可以利用它执行 Docker 客户端提供的任何操作(还有一些其他操作)。例如,我们可以创建一个新服务:
curl -XPOST \
-d '{
"Name": "go-demo-db",
"TaskTemplate": {
"ContainerSpec": {
"Image": "mongo:3.2.10"
}
}
}' \
--unix-socket /var/run/docker.sock \
http:/services/create | jq '.'
我们发送了一个 POST 请求来创建一个名为 go-demo-db 的服务。该服务的镜像是 mongo:3.2.10。结果,API 返回了该服务的 ID:
{
"ID": "7157kfo9cp2vhed4bidrc8hfi"
}
更多信息,请参考 Docker Remote API v1.24: 创建服务 (docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#create-a-service)。
我们可以通过列出所有服务来确认操作确实成功:
curl \
--unix-socket /var/run/docker.sock \
http:/services | jq '.'
输出如下:
[
{
"ID": "s73ez21cmshwu8okipejehvo0",
"Version": {
"Index": 26
},
"CreatedAt": "2017-01-23T20:47:27.247329291Z",
"UpdatedAt": "2017-01-23T20:47:27.247329291Z",
"Spec": {
"Name": "go-demo-db",
"TaskTemplate": {
"ContainerSpec": {
"Image": "mongo:3.2.10@sha256:532a19da83ee0e4e2a2ec6bc4212fc4af26357c040675d5c2\
629a4e4c4563cef"
},
"ForceUpdate": 0
},
"Mode": {
"Replicated": {
"Replicas": 1
}
}
},
"Endpoint": {
"Spec": {}
},
"UpdateStatus": {
"StartedAt": "0001-01-01T00:00:00Z",
"CompletedAt": "0001-01-01T00:00:00Z"
}
}
]
我们获得了一个服务列表(目前只有一个),其中包含一些服务的属性。我们可以看到该服务的创建时间、副本数等信息。更多信息,请参考 Docker Remote API v1.24: 列出服务 (docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/list-services)。
类似地,我们可以检索单个实例的信息:
curl \
--unix-socket /var/run/docker.sock \
http:/services/go-demo-db | jq '.'
输出几乎与我们列出所有服务时相同。唯一的显著区别是,这次我们得到了一个单一的结果,而列出服务时返回的是一个用[和]括起来的数组:
{
"ID": "s73ez21cmshwu8okipejehvo0",
"Version": {
"Index": 26
},
"CreatedAt": "2017-01-23T20:47:27.247329291Z",
"UpdatedAt": "2017-01-23T20:47:27.247329291Z",
"Spec": {
"Name": "go-demo-db",
"TaskTemplate": {
"ContainerSpec": {
"Image": "mongo:3.2.10@sha256:532a19da83ee0e4e2a2ec6bc4212fc4af26357c040675d5c2
629a4e4c4563cef"
},
"ForceUpdate": 0
},
"Mode": {
"Replicated": {
"Replicas": 1
}
}
},
"Endpoint": {
"Spec": {}
},
"UpdateStatus": {
"StartedAt": "0001-01-01T00:00:00Z",
"CompletedAt": "0001-01-01T00:00:00Z"
}
}
请查阅Docker 远程 API v1.24: 检查一个或多个服务(docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#inspect-one-or-more-services)以获取更多信息。
让我们稍微增加点挑战,将副本数扩展到三个。我们可以通过更新服务来实现这一点。然而,在发送更新请求之前,我们需要知道服务的版本和 ID。这些信息可以从我们之前发送的服务请求的输出中获得。不过,由于我们希望以便于自动化的方式执行操作,可能更好将这些值放入环境变量中。
我们可以使用jq来过滤输出并返回特定的值。
返回服务版本的命令如下:
VERSION=$(curl \
--unix-socket /var/run/docker.sock \
http:/services/go-demo-db | \
jq '.Version.Index')
echo $VERSION
$VERSION变量的输出如下:
27
类似地,我们还应该获取服务的ID:
ID=$(curl \
--unix-socket /var/run/docker.sock \
http:/services/go-demo-db | \
jq --raw-output '.ID')
echo $ID
输出如下:
7157kfo9cp2vhed4bidrc8hfi
现在我们已经拥有更新服务所需的所有信息。我们将副本数更改为三个:
curl -XPOST \
-d '{
"Name": "go-demo-db",
"TaskTemplate": {
"ContainerSpec": {
"Image": "mongo:3.2.10"
}
},
"Mode": {
"Replicated": {
"Replicas": 3
}
}
}' \
--unix-socket /var/run/docker.sock \
http:/services/$ID/update?version=$VERSION
请查阅Docker 远程 API v1.24: 更新服务(docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#update-a-service)以获取更多信息。
接下来,我们可以列出tasks并确认服务是否真的扩展到三个实例:
curl \
--unix-socket /var/run/docker.sock \
http:/tasks | jq '.'
输出如下:
[
{
"ID": "c0nev776zyuoaul51n5w9xc85",
"Version": {
"Index": 32
},
"CreatedAt": "2017-01-23T20:47:27.255828694Z",
"UpdatedAt": "2017-01-23T20:47:51.329755266Z",
"Spec": {
"ContainerSpec": {
"Image": "mongo:3.2.10@sha256:532a19da83ee0e4e2a2ec6bc4212fc4af26357c040675d5c2\
629a4e4c4563cef"
},
"ForceUpdate": 0
},
"ServiceID": "s73ez21cmshwu8okipejehvo0",
"Slot": 1,
"NodeID": "2vxiqun3wvh1l1g43utk2v5a7",
"Status": {
"Timestamp": "2017-01-23T20:47:51.295994806Z",
"State": "running",
"Message": "started",
"ContainerStatus": {
"ContainerID":
"20112c904386733c6748bc186e84255640c9dc279fd3530b771616a1ef767957",
"PID": 4238
},
"PortStatus": {}
},
"DesiredState": "running"
},
{
"ID": "ptbp594sh5k2qey6lexafr31d",
"Version": {
"Index": 37
},
"CreatedAt": "2017-01-23T21:16:55.680872244Z",
"UpdatedAt": "2017-01-23T21:16:55.960585822Z",
"Spec": {
"ContainerSpec": {
"Image": "mongo:3.2.10@sha256:532a19da83ee0e4e2a2ec6bc4212fc4af26357c040675d5c2\
629a4e4c4563cef"
},
"ForceUpdate": 0
},
"ServiceID": "s73ez21cmshwu8okipejehvo0",
"Slot": 2,
"NodeID": "skj5sjemrvnusdop3ovcv6q7h",
"Status": {
"Timestamp": "2017-01-23T21:16:55.882919942Z",
"State": "preparing",
"Message": "preparing",
"ContainerStatus": {},
"PortStatus": {}
},
"DesiredState": "running"
},
{
"ID": "rqa04bpvdcddkeia2x1d6r95r",
"Version": {
"Index": 37
},
"CreatedAt": "2017-01-23T21:16:55.6812298Z",
"UpdatedAt": "2017-01-23T21:16:55.960175605Z",
"Spec": {
"ContainerSpec": {
"Image": "mongo:3.2.10@sha256:532a19da83ee0e4e2a2ec6bc4212fc4af26357c040675d5c2\
629a4e4c4563cef"
},
"ForceUpdate": 0
},
"ServiceID": "s73ez21cmshwu8okipejehvo0",
"Slot": 3,
"NodeID": "lbgy0xih6n0w3nmzih0gnfvhd",
"Status": {
"Timestamp": "2017-01-23T21:16:55.881446699Z",
"State": "preparing",
"Message": "preparing",
"ContainerStatus": {},
"PortStatus": {}
},
"DesiredState": "running"
}
]
如你所见,返回了三个任务,每个任务代表go-demo-db服务的一个副本。
请查阅Docker 远程 API v1.24: 列出任务(docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#list-tasks)以获取更多信息。
到目前为止,我们所做的所有 API 请求都与节点和服务有关。在某些情况下,我们可能需要更深入地使用容器级别的 API。例如,我们可能想获取与单个容器相关的统计信息。
在继续之前,请确保所有任务的状态都是“正在运行”。可以随时重复http:/tasks请求来确认状态。如果它没有运行,请稍等片刻再检查。
要获取容器的统计信息,首先我们需要找出它运行在哪个节点上:
exit
eval $(docker-machine env swarm-1)
NODE=$(docker service ps \
-f desired-state=running \
go-demo-db \
| tail -n 1 \
| awk '{print $4}')
echo $NODE
我们退出了swarm-1机器,并使用 eval 命令创建了环境变量,指示主机上运行的 Docker 客户端使用在swarm-1上运行的引擎。请注意,这些环境变量告诉客户端使用我们在本章中探讨的相同 API。
进一步地,我们检索了一个容器所在的节点,这个容器是go-demo-db服务的一部分。我们已经使用过类似的命令几次,所以无需再详细解释。
$NODE变量的输出如下:
swarm-2
在我的笔记本电脑上,我们正在寻找的容器运行在 swarm-2 节点内。在你的情况下,它可能是另一个节点。
现在我们可以进入节点并获取容器的 ID:
docker-machine ssh $NODE
ID=$(docker ps -qa | tail -n 1)
echo $ID
ID 变量的输出如下:
f8f345042cf7
最后,我们准备获取统计信息。命令如下:
curl \
--unix-socket /var/run/docker.sock \
http:/containers/$ID/stats
一旦请求发送,你将看到不断流动的统计信息。当你看腻了时,请按 CTRL + C 停止流式传输。
如果我们想实现自己的监控解决方案,流式统计可能是一个非常有用的功能。在许多其他情况下,我们可能希望禁用流式传输,只检索单个记录集。我们可以通过将 stream 参数设置为 false 来实现这一点。
返回单个统计记录集的命令如下:
curl \
--unix-socket /var/run/docker.sock \
http:/containers/$ID/stats?stream=false
输出仍然太大,无法在书中展示,因此你需要从自己的屏幕上查看。
我们不会详细探讨每个统计字段的含义。你需要等到我们进入监控章节时才能深入探讨。目前,重要的是要注意,你可以为集群中的每个容器检索这些统计信息。
作为练习,创建一个脚本,检索节点上运行的所有容器。遍历每个容器以获取该虚拟机内所有容器的统计信息。
请参阅 Docker Remote API v1.24: 根据资源使用情况获取容器统计信息 (docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#get-container-stats-based-on-resource-usage) 了解更多信息。
我们已经快完成与 Swarm 服务相关的基本 API 请求的探索了,现在让我们删除我们创建的服务:
curl -XDELETE \
--unix-socket /var/run/docker.sock \
http:/services/go-demo-db
curl \
--unix-socket /var/run/docker.sock \
http:/services
我们发送了 DELETE 请求以删除 go-demo-db 服务,随后发送了请求以检索所有服务。后者的输出如下:
[]
我们的服务已经不存在了。我们将其从集群中删除,且由于这是我们创建的唯一服务,请求返回了一个空的数组 []。
请参阅 Docker Remote API v1.24: 删除服务 (docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#remove-a-service) 了解更多信息。
最后,让我们退出机器:
exit
现在你对 API 有了基本了解,我们可以探索一个可能的用例。
使用 Docker Remote API 自动化代理配置
到目前为止,我们一直向代理发送重新配置和删除请求。这大大简化了配置。我们不再自己修改 HAProxy 配置,而是让服务自行重新配置。我们使用 Consul 来持久化代理的状态。我们能否通过利用 Docker Remote API 改进现有设计?我认为我们可以。
不再发送重新配置和删除请求,我们可以有一个服务来通过 API 监视集群状态。这样的工具可以检测新创建和删除的服务,并向proxy发送与我们手动发送的相同请求。
我们可以走得更远。由于 API 允许我们检索与集群相关的任何信息,我们不再需要将其存储在 Consul 中。每当创建服务的新实例时,它可以从 API 中检索所需的所有信息。
总之,我们可以利用 API 完全自动化对proxy配置及其状态的更改。我们可以创建一个新的服务来监视集群状态。我们还可以修改proxy以在其初始化过程中查询该服务。
我考虑节省您的一些时间,因此擅自创建了这样一个服务。它背后的项目称为Docker Flow Swarm Listener (github.com/vfarcic/docker-flow-swarm-listener)。
让我们看看它的工作情况。
将 Swarm 监听器与代理结合使用
Docker Flow Swarm Listener (github.com/vfarcic/docker-flow-swarm-listener) 项目利用了 Docker Remote API。它有很多用途,但现在我们将限制在可以帮助我们完全无需手动操作来配置代理的功能上。
我们将首先创建两个网络:
eval $(docker-machine env swarm-1)
docker network create --driver overlay proxy
docker network create --driver overlay go-demo
我们已经创建了这两个网络很多次了,没有理由再去讨论它们的有用性。唯一的区别是,这次我们将有一个更多的服务附加到proxy网络。
接下来,我们将创建swarm-listener (github.com/vfarcic/docker-flow-swarm-listener) 服务。它将作为 Docker Flow Proxy 的伴侣。其目的是监视 Swarm 服务并在创建或销毁服务时向代理发送请求。
Windows 用户请注意
Git Bash 有改变文件系统路径的习惯。要停止这个行为,请执行以下操作:
export MSYS_NO_PATHCONV=1
让我们创建swarm-listener服务:
docker service create --name swarm-listener \
--network proxy \
--mount \
"type=bind,source=/var/run/docker.sock,target=/var/run/\
docker.sock" \
-e DF_NOTIF_CREATE_SERVICE_URL=http://proxy:8080/v1/
docker-flow-proxy/reconfigure \
-e DF_NOTIF_REMOVE_SERVICE_URL=http://proxy:8080/v1/
docker-flow-proxy/remove \
--constraint 'node.role==manager' \
vfarcic/docker-flow-swarm-listener
该服务附加到proxy网络,挂载 Docker 套接字,并声明环境变量DF_NOTIF_CREATE_SERVICE_URL和DF_NOTIF_REMOVE_SERVICE_URL。我们很快将看到这些变量的用途。该服务受限于管理节点。
下一步是创建proxy服务:
docker service create --name proxy \
-p 80:80 \
-p 443:443 \
--network proxy \
-e MODE=swarm \
-e LISTENER_ADDRESS=swarm-listener \
vfarcic/docker-flow-proxy
我们打开了*80*和*443*端口。外部请求将通过它们路由到目标服务。请注意,这次我们没有打开8080端口。由于proxy将从swarm-listener接收通知,因此无需为手动通知保留8080端口。
proxy连接到proxy网络,并将模式设置为 swarm。proxy必须与监听器属于同一网络。它们将在每次创建或删除服务时以及每次创建新的proxy实例时交换信息。
自动重新配置代理
让我们创建已经熟悉的示例服务:
docker service create --name go-demo-db \
--network go-demo \
mongo:3.2.10
docker service create --name go-demo \
-e DB=go-demo-db \
--network go-demo \
--network proxy \
--label com.df.notify=true \
--label com.df.distribute=true \
--label com.df.servicePath=/demo \
--label com.df.port=8080 \
vfarcic/go-demo:1.0
请注意标签。在前面的章节中我们没有使用它们。情况发生了变化,现在它们已成为服务定义中的关键部分。com.df.notify=true告诉swarm-listener服务是否在服务被创建或删除时发送通知。由于我们不想将go-demo-db服务添加到proxy中,因此该标签仅定义在go-demo服务上。其余的标签与我们手动重新配置代理时使用的查询参数相匹配。唯一的区别是,这些标签前缀为com.df。有关查询参数的列表,请参阅项目中的重新配置 (github.com/vfarcic/docker-flow-proxy#reconfigure)部分。
现在我们应该等待所有服务都在运行。您可以通过执行以下命令来查看它们的状态:
docker service ls
一旦所有副本都设置为1/1,我们可以通过向proxy发送请求来看到com.df标签的效果,该请求会通过proxy访问go-demo服务:
curl -i "$(docker-machine ip swarm-1)/demo/hello"
输出如下:
HTTP/1.1 200 OK
Date: Thu, 13 Oct 2016 18:26:18 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
我们向proxy发送了一个请求(唯一监听端口80的服务),并收到了来自go-demo服务的响应。proxy在go-demo服务创建时自动进行了配置。
该过程的工作方式如下:
Docker Flow Swarm Listener运行在一个 Swarm 管理节点内,并查询 Docker API 以寻找新创建的服务。一旦找到新服务,它会查找其标签。如果服务包含com.df.notify标签(它可以包含任何值),则会获取所有其他以com.df开头的标签。这些标签将用于形成请求参数。这些参数会附加到作为DF_NOTIF_CREATE_SERVICE_URL环境变量定义在swarm-listener服务中的地址上。最后,发送请求。在这个特定的例子中,请求是为了使用go-demo(服务名称)重新配置proxy,路径为/demo,并且运行在端口8080上。分发标签在这个例子中不是必须的,因为我们只运行了一个proxy实例。然而,在生产环境中,我们应该至少运行两个proxy实例(以实现容错),而分发参数意味着该重新配置应应用于所有实例。
请参见重新配置 (github.com/vfarcic/docker-flow-proxy#reconfigure)部分,查看可以与代理一起使用的所有参数列表。
从代理中移除服务
由于swarm-listener正在监控docker服务,如果删除了一个服务,相关的proxy配置条目也将被删除:
docker service rm go-demo
如果您像检查任何其他容器服务的日志一样检查 Swarm Listener 的日志,您将看到类似以下条目的一个条目:
Sending service removed notification to http://proxy:8080/v1/
docker-flow-proxy/remove?serviceName=go-demo
不久之后,proxy日志中会出现一个新条目:
Processing request /v1/docker-flow-proxy/remove?serviceName=go-demo
Processing remove request /v1/docker-flow-proxy/remove
Removing go-demo configuration
Removing the go-demo configuration files
Reloading the proxy
从现在开始,服务go-demo将无法通过代理访问。
Swarm Listener 检测到服务已被移除,向proxy发送了一个通知,proxy进而改变其配置并重新加载底层的 HAProxy。
现在怎么办?
除了在每次创建或删除服务时自动配置代理的潜在实用性之外,swarm-listener显示了利用 Docker 远程 API 的有用性。如果您有自己的需求,而 Docker 或其生态系统中的工具尚未完全满足您的需求,编写自己的服务并不是很困难。事实上,在撰写本章时,Swarm 模式只有几个月的历史,并且没有太多第三方工具可以用来微调或扩展其行为。即使您找到了几乎满足您需求的所有工具,编写一些自定义代码以确切地满足您的需求仍然是个好主意。
我鼓励您启动您最喜欢的编辑器,并用您选择的编程语言编写一个服务。您可以监控服务,并在您的团队成员创建或删除服务时发送电子邮件给自己。或者您可以将统计数据集成到您最喜欢的监控工具中。
如果你对自己的服务已经没有了新的想法,并且你不怕Go(golang.org/),那么你可以尝试扩展Docker Flow Swarm Listener(github.com/vfarcic/docker-flow-swarm-listener)。分叉它,添加一个新功能,并提交一个拉取请求。
记住,学习是金贵的。如果您学到了一些东西,那已经非常不错了。如果证明是有用的,那就更好了。
我们已经到了本章的结尾,您已经了解了程序。我们将销毁我们创建的机器并重新开始。
docker-machine rm -f swarm-1 swarm-2 swarm-3
第八章:使用 Docker Stack 和 Compose YAML 文件来部署 Swarm 服务
复制和粘贴是一个设计错误。
–David Parnas
在我进行与 Docker 相关的讲座和研讨会时,最常见的问题通常与 Swarm 和 Compose 有关。
某人: 我如何在 Docker Swarm 中使用 Docker Compose?
我: 你不能!你可以将你的 Compose 文件转换为一个不支持所有 Swarm 特性的 Bundle。如果你想充分利用 Swarm,请准备好使用包含无尽参数列表的 docker service create 命令。
这样的回答通常会伴随着失望。Docker Compose 向我们展示了将所有内容指定在 YAML 文件中的优点,而不是试图记住我们需要传递给 Docker 命令的所有参数。它使我们能够将服务定义存储在仓库中,从而提供了一个可重复且文档化的管理流程。Docker Compose 替代了 bash 脚本,我们喜欢它。然后,Docker v1.12 发布了,并且给我们提出了一个艰难的选择。我们应该采用 Swarm 并抛弃 Compose 吗?自 2016 年夏天以来,Swarm 和 Compose 已经不再亲密。那是一场痛苦的离婚。
但是,经过近半年的分离后,它们重新走到了一起,我们可以见证它们的第二次蜜月。算是吧……我们不再需要 Docker Compose 二进制文件来管理 Swarm 服务,但我们仍然可以使用它的 YAML 文件。
Docker Engine v1.13 引入了在 stack 命令中支持 Compose YAML 文件。与此同时,Docker Compose v1.10 引入了其格式的新 版本 3。它们使我们能够使用已熟悉的 Docker Compose YAML 格式来管理我们的 Swarm 服务。
我假设你已经熟悉 Docker Compose,并且不会详细讲解我们可以用它做的所有事情。相反,我们将通过一个创建几个 Swarm 服务的示例来进行讲解。
我们将探讨如何通过 Docker Compose 文件和 docker stack deploy 命令来创建 Docker Flow Proxy 服务 (proxy.dockerflow.com/)。
Swarm 集群设置
要使用 Docker Machine 设置一个示例 Swarm 集群,请运行以下命令。
本章中的所有命令都可以在 07-docker-stack.sh 文件中找到 (gist.github.com/vfarcic/57422c77223d40e97320900fcf76a550):
cd cloud-provisioning
git pull
scripts/dm-swarm.sh
现在我们准备部署 docker-flow-proxy 服务。
通过 Docker stack 命令创建 Swarm 服务
我们将从创建网络开始:
Windows 用户注意
你可能会遇到卷未正确映射的问题。如果你看到 Invalid volume specification 错误,请导出环境变量 COMPOSE_CONVERT_WINDOWS_PATHS 并将其设置为 0:
export COMPOSE_CONVERT_WINDOWS_PATHS=0
请确保在运行 docker-compose 或 docker stack deploy 之前导出变量。
eval $(docker-machine env swarm-1)
docker network create --driver overlay proxy
proxy 网络将专用于 proxy 容器及将要连接到它的服务。
我们将使用 vfarcic/docker-flow-proxy(github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml) 仓库中的 docker-compose-stack.yml 文件来创建 docker-flow-proxy 和 docker-flow-swarm-listener 服务。
docker-compose-stack.yml 文件的内容如下:
version: "3"
services:
proxy:
image: vfarcic/docker-flow-proxy
ports:
- 80:80
- 443:443
networks:
- proxy
environment:
- LISTENER_ADDRESS=swarm-listener
- MODE=swarm
deploy:
replicas: 2
swarm-listener:
image: vfarcic/docker-flow-swarm-listener
networks:
- proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- DF_NOTIFY_CREATE_SERVICE_URL=http://proxy:8080/v1/\
docker-flow-proxy/reconfigure
- DF_NOTIFY_REMOVE_SERVICE_URL=http://proxy:8080/v1/\
docker-flow-proxy/remove
deploy:
placement:
constraints: [node.role == manager]
networks:
proxy:
external: true
该格式采用 version 3(这是 docker stack deploy 的必需格式)。
它包含了两个服务:proxy 和 swarm-listener。由于你已经熟悉 proxy,我就不再详细解释每个参数的含义了。
与之前的 Compose 版本相比,大多数新参数都定义在 deploy 部分。你可以把这一部分看作是为 Swarm 特定参数保留的占位符。在这种情况下,我们指定 proxy 服务应有两个副本,而 swarm-listener 服务应限制为管理节点角色。对于这两个服务的其他定义,采用与早期 Compose 版本相同的格式。
在 YAML 文件的底部是网络列表,这些网络会在服务中引用。如果某个服务没有指定任何网络,则会自动创建默认网络。在这种情况下,我们选择手动创建一个网络,因为其他堆栈中的服务需要与 proxy 进行通信。因此,我们手动创建了一个网络,并在 YAML 文件中将其定义为外部网络。
让我们根据我们探讨的 YAML 文件来创建堆栈:
curl -o docker-compose-stack.yml \
https://raw.githubusercontent.com/\
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
docker stack deploy \
-c docker-compose-stack.yml proxy
第一个命令从 vfarcic/docker-flow-proxy(github.com/vfarcic/docker-flow-proxy) 仓库中下载了 Compose 文件 docker-compose-stack.yml(github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)。第二个命令创建了形成该堆栈的服务。
可以通过 stack ps 命令查看堆栈的任务:
docker stack ps proxy
输出结果如下(为了简洁,已去除 ID):
NAME IMAGE NODE
proxy_proxy.1 vfarcic/docker-flow-proxy:latest node-2
proxy_swarm-listener.1 vfarcic/docker-flow-swarm-listener:latest node-1
proxy_proxy.2 vfarcic/docker-flow-proxy:latest node-3
------------------------------------------------------------
DESIRED STATE CURRENT STATE ERROR PORTS
Running Running 2 minutes ago
Running Running 2 minutes ago
Running Running 2 minutes ago
我们运行了两个 proxy 的副本(以确保在故障发生时具有高可用性),以及一个 swarm-listener。
部署更多的堆栈
我们来部署另一个堆栈。
这次我们将使用 Compose 文件 docker-compose-stack.yml 中定义的 Docker stack(github.com/vfarcic/go-demo/blob/master/docker-compose-stack.yml),该文件位于 vfarcic/go-demo(github.com/vfarcic/go-demo/) 仓库中。具体内容如下:
version: '3'
services:
main:
image: vfarcic/go-demo
environment:
- DB=db
networks:
- proxy
- default
deploy:
replicas: 3
labels:
- com.df.notify=true
- com.df.distribute=true
- com.df.servicePath=/demo
- com.df.port=8080
db:
image: mongo
networks:
- default
networks:
default:
external: false
proxy:
external: true
这个 stack 定义了两个服务(main 和 db)。它们将通过 stack 自动创建的默认网络进行通信(无需使用 docker network create 命令)。由于主服务是 API,因此它应该能够通过 proxy 访问,所以我们也将 proxy 网络附加上。
需要注意的重要一点是,我们在部署部分使用了 Swarm-specific 参数。在本例中,主服务定义了应该有三个副本以及一些标签。和之前的 stack 一样,我们不打算详细讨论每个服务。如果您想更深入了解与主服务相关的标签,请访问 Running Docker Flow Proxy In Swarm Mode With Automatic Reconfiguration (proxy.dockerflow.com/swarm-mode-auto/) 教程。
让我们部署这个 stack:
curl -o docker-compose-go-demo.yml \
https://raw.githubusercontent.com/\
vfarcic/go-demo/master/docker-compose-stack.yml
docker stack deploy \
-c docker-compose-go-demo.yml go-demo
docker stack ps go-demo
我们下载了 stack 定义文件,执行了 stack deploy 命令,创建了服务,并运行了 stack ps 命令列出了属于 go-demo stack 的任务。输出如下(为了简洁,ID 和错误端口列已删除):
NAME IMAGE NODE DESIRED STATE
go-demo_main.1 vfarcic/go-demo:latest node-2 Running
go-demo_db.1 mongo:latest node-2 Running
go-demo_main.2 vfarcic/go-demo:latest node-2 Running
go-demo_main.3 vfarcic/go-demo:latest node-2 Running
--------------------------------------------------------
CURRENT STATE
Running 7 seconds ago
Running 21 seconds ago
Running 19 seconds ago
Running 20 seconds ago
由于 Mongo 数据库比主服务大得多,所以拉取它需要更多时间,导致了一些失败。go-demo 服务设计为如果无法连接到数据库则会失败。一旦 db 服务启动,主服务应该停止失败,我们会看到三个副本处于 Running 状态。
几秒钟后,swarm-listener 服务将检测到 go-demo stack 的主服务,并发送请求给 proxy 重新配置自己。我们可以通过发送 HTTP 请求到 proxy 来查看结果:
curl -i "http://$(docker-machine ip swarm-1)/demo/hello"
输出如下:
HTTP/1.1 200 OK
Date: Thu, 19 Jan 2017 23:57:05 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
proxy 被重新配置,并将所有带有基础路径 /demo 的请求转发到 go-demo stack 的主服务。
堆叠与不堆叠
Docker stack 是 Swarm 模式的一个重要补充。我们不再需要处理那些通常有着无穷参数列表的 docker service create 命令。通过在 Compose YAML 文件中指定服务,我们可以用一个简单的 docker stack deploy 来替代这些长长的命令。如果这些 YAML 文件存储在代码仓库中,我们可以将相同的实践应用到服务部署中,就像处理其他软件工程领域一样。我们可以追踪更改,进行代码审查,与他人共享等等。
Docker stack 命令的加入以及其使用 Compose 文件的能力,极大地丰富了 Docker 生态系统,值得欢迎。
在本书的其余部分,我们将在探索新服务时使用 docker service create 命令,而在创建我们已经熟悉的服务时使用 docker stack deploy。如果你在将 docker service create 命令转换为堆栈时遇到困难,请查看 vfarcic/docker-flow-stacks (github.com/vfarcic/docker-flow-stacks) 仓库。它包含了一些我们将使用的服务的堆栈。我希望你能贡献你所使用的堆栈。请 fork 仓库并提交 pull request。如果你在创建堆栈时遇到困难,请提出 issue。
清理
请移除我们创建的 Docker Machine 虚拟机。你可能需要这些资源来完成其他任务:
exit
docker-machine rm -f swarm-1 swarm-2 swarm-3
第九章:定义日志策略
今天的大多数软件就像埃及金字塔一样,数百万块砖头堆叠在一起,毫无结构性,只是靠蛮力和成千上万的奴隶完成的。
–艾伦·凯
我们已经达到了拥有一个完全运行的 Swarm 集群和一个定义好的持续部署管道的阶段,管道会在每次提交时更新我们的服务。现在,我们可以专注于编写代码并推送提交到我们的代码库,知道其余的流程是自动化的。我们最终可以将时间花在对组织真正有价值的任务上。我们可以把时间投入到为我们所工作的服务开发新功能。然而,当发生问题时,我们需要停止开发新功能并调查问题。
我们在发现问题时通常做的第一件事是查看日志。日志并不是我们调试问题时唯一能用的数据来源。我们还需要大量的指标(下一章会详细讲解)。然而,即使日志不是唯一需要查看的内容,它们通常是一个很好的起点。
《The DevOps 2.0 Toolkit》读者的提示
以下内容与《The DevOps 2.0 Toolkit》一书中的内容相同。如果它仍然记忆犹新,可以直接跳到设置 LogStash 和 ElasticSearch 作为日志数据库(#logging-es)子章节。自从我编写了2.0版本后,我发现了一些更好的处理日志的方法,特别是在 Swarm 集群内部。
我们对 DevOps 实践和工具的探索引导我们走向了集群化和扩展。因此,我们开发了一种系统,使我们能够以简单高效的方式将服务部署到集群中。结果是,集群内的容器数量不断增加,这些容器可能运行在多台服务器上。
监控一个服务器很简单,但在单台服务器上监控多个服务会带来一些困难。在多台服务器上监控多个服务需要全新的思维方式和一套新的工具。随着你开始拥抱微服务、容器和集群,创建的服务及其实例数量将迅速增加。对于构成集群的服务器而言,也是如此。我们不能再像以前那样登录到一个节点并查看日志了,因为日志实在是太多了。而且,它们分布在多个服务器上。昨天我们可能在单台服务器上部署了两个实例的服务,但明天我们可能会在六台服务器上部署八个实例。
我们需要系统的历史信息和(近)实时信息。这些信息可以是日志、硬件利用率、健康检查、网络流量等多种形式。存储历史数据的需求并不新鲜,早已在使用中。然而,信息传输的方向随着时间发生了变化。过去,大多数解决方案依赖于集中式数据收集器,而今天,由于服务和服务器的动态特性,我们倾向于将其去中心化。
我们对集群日志和监控的需求是一个结合了去中心化数据收集器的系统,这些收集器将信息发送到一个集中式的解析服务和数据存储。有许多专门设计用于满足这一需求的产品,从本地解决方案到云解决方案应有尽有。FluentD (www.fluentd.org/)、Loggly (www.loggly.com/)、GrayLog (www.graylog.org/)、Splunk (www.splunk.com/) 和 DataDog (www.datadoghq.com/) 只是我们可以使用的一些解决方案。我选择通过 ELK 堆栈来展示这些概念(ElasticSearch (www.elastic.co/products/elasticsearch)、LogStash (www.elastic.co/products/logstash) 和 Kibana (www.elastic.co/products/kibana))。该堆栈的优势在于它是免费的、文档完善、高效且广泛使用。ElasticSearch (www.elastic.co/products/elasticsearch) 已经证明自己是实时搜索和分析的最佳数据库之一。它是分布式的、可扩展的、高可用的,并提供了一个复杂的 API。LogStash (www.elastic.co/products/logstash) 让我们能够集中处理数据。它可以轻松扩展到自定义数据格式,并提供了许多插件,几乎可以满足任何需求。最后,Kibana (www.elastic.co/products/kibana) 是一个分析和可视化平台,具有直观的界面,基于 ElasticSearch。
我们将使用 ELK 堆栈并不意味着它比其他解决方案更好。一切都取决于特定的使用场景和需求。我将通过 ELK 堆栈的集中式日志和监控原则向您展示。一旦理解了这些原则,您应该能够轻松地将其应用到其他堆栈中,若您选择这样做的话。
我们调换了事务的顺序,并在讨论集中式日志需求之前就选择了工具。让我们来纠正这个问题。
集中式日志的需求
在大多数情况下,日志消息是写入文件的。这并不是说文件是唯一的方式,或者是存储日志最有效的方式。然而,由于大多数团队以某种形式使用基于文件的日志,因此暂时假设你也是如此。如果是这样,我们已经确定了需要修复的第一件事。容器期望我们将日志发送到stdout和stderr。只有转发到标准输出的日志条目才能通过docker logs命令检索。此外,专为容器日志设计的工具将只期待这一点。它们假设日志条目不是写入文件,而是发送到输出。即便没有容器,我相信stdout和stderr应该是我们服务记录日志的地方。不过,这是另一个话题。现在,我们将集中讨论容器,并假设你已经将日志输出到stdout和stderr。如果没有,大多数日志库会允许你将日志目标更改为标准输出和错误。
大多数时候,我们并不关心日志中写了什么。当一切正常时,没有太多必要花费宝贵的时间去浏览它们。日志不是我们用来打发时间的小说,也不是我们用来提升知识的技术书籍。日志存在的目的是在某些地方发生错误时,提供有价值的信息。
情况看起来很简单。我们把信息写入日志,而大多数时候忽略它们,当出现问题时,我们查阅日志,迅速找到问题的根源。至少,许多人是这样希望的。然而,现实远比这复杂。在除最简单的系统外,调试过程往往更加具有挑战性。应用程序和服务几乎总是相互关联的,而确定究竟是哪个服务引发了问题常常并不容易。虽然问题可能在某个应用程序中显现,但调查通常会发现,问题的根源在另一个地方。例如,一个服务可能未能实例化。经过一段时间浏览其日志后,我们可能发现根源在数据库中。该服务无法连接到数据库,从而导致启动失败。我们得到了症状,却没有得到原因。我们需要切换到数据库日志才能找出真相。通过这个简单的例子,我们已经得出结论:仅查看一个日志是远远不够的。
随着分布式服务在集群上运行,情况变得更加复杂。是哪个实例的服务出现故障?它运行在哪台服务器上?请求是由哪些上游服务发起的?故障所在节点的内存和硬盘使用情况如何?正如你可能猜到的,找到、收集并过滤出成功发现问题根源所需的信息,通常是非常复杂的。系统越大,问题就越复杂。即使是单体应用,也很容易陷入困境。
如果采用微服务架构,这些问题会成倍增加。集中式日志记录对所有系统都是必须的,除了最简单和最小的系统。相反,当问题发生时,我们中的许多人会开始从一台服务器跑到另一台服务器,从一个文件跳到另一个文件。就像一只丧失了头的鸡——到处乱跑,没有方向。我们往往接受日志所带来的混乱,并认为这是我们职业的一部分。
我们在集中式日志记录中寻找什么?实际上有很多东西,但最重要的有以下几点:
-
一种解析数据并将其发送到中央数据库的近实时方式
-
数据库处理近实时数据查询和分析的能力
-
通过过滤后的表格、仪表板等方式展示数据的可视化表示
我们已经选择了能够满足所有这些要求(甚至更多)工具。ELK 堆栈(ElasticSearch、LogStash 和 Kibana)可以做所有这些。与我们探索的其他工具一样,这个堆栈也可以轻松扩展,以满足我们设定的特定需求。
现在我们对自己想要达成的目标有了一个模糊的了解,并且也拥有了实现目标的工具,让我们来探索一下可以使用的一些日志策略。我们将从最常见的场景开始,逐步过渡到更复杂、更高效的日志策略定义方法。
话不多说,让我们创建将用于实验集中式日志记录和后续监控的环境。
将 ElasticSearch 设置为日志数据库
和之前许多情况一样,我们将从创建已经熟悉的节点(swarm-1、swarm-2 和 swarm-3)开始:
cd cloud-provisioning
git pull
scripts/dm-swarm.sh
本章中的所有命令都可以在 08-logging.sh(gist.github.com/vfarcic/c89b73ebd32dbf8f849531a842739c4d)Gist 中找到。
我们将创建的第一个服务是Elastic Search(hub.docker.com/_/elasticsearch)。由于我们需要让它能够从其他一些服务访问,我们还将创建一个名为elk的网络:
eval $(docker-machine env swarm-1)
docker network create --driver overlay elk
docker service create \
--name elasticsearch \
--network elk \
--reserve-memory 500m \
elasticsearch:2.4
几分钟后,elasticsearch 服务将启动并运行。
我们可以使用service ps命令检查状态:
docker service ps elasticsearch
输出如下(为简洁起见,ID 和 ERROR PORTS 列已被删除):
NAME IMAGE NODE DESIRED STATE
elasticsearch.1 elasticsearch:2.4 swarm-1 Running
------------------------------------------------------
CURRENT STATE
Running 19 seconds ago
如果 elasticsearch 仍未启动,请稍等片刻再继续操作。
现在我们有了一个可以存储日志的数据库,下一步是创建一个服务来解析日志条目,并将结果转发到 ElasticSearch。
将 LogStash 设置为日志解析器和转发器
我们完成了ELK堆栈中的E部分。现在我们开始处理L部分。LogStash需要一个配置文件。我们将使用一个已经存在于vfarcic/cloud-provisioning(github.com/vfarcic/cloud-provisioning)仓库中的配置文件。我们将创建一个新目录,复制conf/logstash.conf(github.com/vfarcic/cloud-provisioning/blob/master/conf/logstash.conf)配置,并在logstash服务中使用它:
mkdir -p docker/logstash
cp conf/logstash.conf \
docker/logstash/logstash.conf
cat docker/logstash/logstash.conf
logstash.conf文件的内容如下:
input {
syslog { port => 51415 }
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
}
# Remove in production
stdout {
codec => rubydebug
}
}
这是一个非常简单的LogStash配置。它将监听端口51415以接收syslog条目。
每个条目将被发送到两个输出:elasticsearch和stdout。由于logstash和elasticsearch将连接到同一网络,我们所要做的就是将服务名称作为主机名。
第二个输出将把所有内容发送到stdout。请注意,在生产环境中运行LogStash之前,应该删除这个条目。它会产生不必要的开销,如果有很多服务,可能会相当可观。我们之所以保留它,是为了展示日志如何通过 LogStash 传递。在生产环境中,你无需查看其输出,而是使用 Kibana 来浏览整个系统的日志。
让我们继续并创建第二个服务:
Windows 用户注意
为了让下一个命令中的挂载生效,你需要阻止 Git Bash 更改文件系统路径。请设置此环境变量:
export MSYS_NO_PATHCONV=1
docker service create --name logstash \
--mount "type=bind,source=$PWD/docker/logstash,target=/conf" \
--network elk \
-e LOGSPOUT=ignore \
--reserve-memory 100m \
logstash:2.4 logstash -f /conf/logstash.conf
我们创建了一个名为logstash的服务,并将主机卷docker/logstash挂载到容器中的/conf。这样,我们可以在容器内访问当前位于主机上的配置文件。
请注意,挂载卷并不是将配置文件放入容器中的最佳方式。相反,我们应该构建一个包含配置文件的镜像。我们应该创建一个 Dockerfile,示例如下:
FROM logstash
RUN mkdir /config/
COPY conf/logstash.conf /config/
CMD ["-f", "/config/logstash.conf"]
这个配置文件不应频繁更改(如果有更改的话),因此基于logstash创建新镜像要比挂载卷更好。然而,为了简单起见,我们使用了挂载。只需记住,一旦开始应用本章所学内容,务必构建你自己的镜像。
我们还定义了环境变量LOGSPOUT。目前它并不相关,我们稍后会解释它。
logStash服务现在应该已启动并运行。让我们再检查一下:
docker service ps logstash
输出应如下所示:
NAME IMAGE NODE DESIRED STATE CURRENT STATE
logstash.1 logstash:2.4 swarm-1 Running Running 2 seconds ago
如果当前状态仍未运行,请稍等片刻并重复执行service ps命令。只有当logstash正常运行后,我们才能继续。
现在我们可以确认logStash已正确初始化。我们需要找出它运行在哪个节点,获取容器的ID,并输出日志:
LOGSTASH_NODE=$(docker service ps logstash | tail -n +2 | awk '{print $4}')
eval $(docker-machine env $LOGSTASH_NODE)
LOGSTASH_ID=$(docker ps -q \
--filter label=com.docker.swarm.service.name=logstash)
docker logs $LOGSTASH_ID
上一条命令logs的输出如下:
{:timestamp=>"2016-10-19T23:08:06.358000+0000", :message=>"Pipeline \
main started"}
Pipeline main started 表示 LogStash 正在运行并等待输入。
在我们设置一个解决方案来转发集群中所有容器的日志之前,我们将进行一个中间步骤,确认 LogStash 是否能在端口 51415 上接受 syslog 条目。我们将创建一个名为 logger-test 的临时服务:
eval $(docker-machine env swarm-1)
docker service create \
--name logger-test \
--network elk \
--restart-condition none \
debian \
logger -n logstash -P 51415 hello world
该服务连接到 elk 网络,以便能够与 logstash 服务进行通信。
我们必须指定 restart-condition 为 none。否则,当进程完成时,容器会停止,Swarm 会将其视为故障并重新调度。换句话说,如果没有将重启条件设置为 none,Swarm 会进入一个无限循环,试图重新调度几乎立即停止的容器。
我们执行的命令发送了一个 syslog 消息 logger,目标是运行在端口 51415 上的 logstash。消息内容是 hello world。
让我们再一次输出 LogStash 日志:
eval $(docker-machine env $LOGSTASH_NODE)
docker logs $LOGSTASH_ID
输出如下:
{
"message" => "<5>Oct 19 23:11:47 <someone>: hello world\u0000",
"@version" => "1",
"@timestamp" => "2016-10-19T23:11:47.882Z",
"host" => "10.0.0.7",
"tags" => [
[0] "_grokparsefailure_sysloginput"
],
"priority" => 0,
"severity" => 0,
"facility" => 0,
"facility_label" => "kernel",
"severity_label" => "Emergency"
}
首先,Swarm 需要下载 debian 镜像,并且一旦发送了 logger 消息,LogStash 就必须开始接收日志条目。在 LogStash 处理第一个条目之前会稍微花一些时间,后续的条目几乎会立即处理。如果你的输出与上述不相同,请稍等片刻,并重新执行日志命令。
正如你所看到的,LogStash 收到了消息 hello world。它还记录了一些其他字段,如 timestamp 和 host。忽略错误信息 _grokparsefailure_sysloginput。我们可以配置 LogStash 正确解析 logger 消息,但由于我们以后不再使用它,配置会浪费时间。很快我们将看到一种更好的方式来转发日志。
LogStash 充当了消息的解析器,并将其转发到 ElasticSearch。目前,你只能相信我说的这些。很快我们将看到这些消息是如何存储的,以及我们如何浏览它们。
我们将移除 logger-test 服务。它的目的是仅仅演示我们有一个接受 syslog 消息的 LogStash 实例:
eval $(docker-machine env swarm-1)
docker service rm logger-test
通过调用 logger 发送消息是很好的,但这并不是我们想要实现的目标。我们的目标是转发来自集群中任何地方运行的所有容器的日志。
转发来自 Swarm 集群中任何地方运行的所有容器的日志
如何将所有容器的日志转发到指定位置,无论它们在哪个环境中运行?一种可能的解决方案是配置日志驱动程序(docs.docker.com/engine/admin/logging/overview/)。我们可以使用--log-driver参数为每个服务指定一个驱动程序。驱动程序可以是syslog或任何其他支持的选项。这样可以解决我们的日志转发问题。然而,为每个服务使用这个参数会很繁琐,更重要的是,我们可能会忘记为某些服务指定它,直到我们遇到问题并需要日志时才发现遗漏。让我们看看是否有其他方法可以实现相同的结果。
我们可以将日志驱动程序作为每个节点上 Docker 守护进程的配置选项进行指定。这样肯定能简化设置。毕竟,服务器数量可能少于服务数量。如果我们必须在创建服务时设置驱动程序与在守护进程配置中设置之间做选择,我会选择后者。然而,我们到目前为止都没有更改默认的守护进程配置,我更希望能继续在不涉及特殊配置工具的情况下进行工作。幸运的是,我们还没有用尽所有的选项。
我们可以通过名为logspout的项目来转发所有容器的日志(github.com/gliderlabs/logspout)
LogSpout 是一个用于 Docker 容器的日志路由器,运行在 Docker 内部。它附加到主机上的所有容器,然后将它们的日志路由到我们想要的地方。它还有一个可扩展的模块系统。它是一个几乎无状态的日志设备,设计并非用于管理日志文件或查看历史记录。它只是一个将日志发送到其他位置的工具,那是它们应该存在的地方。
如果你浏览项目文档,你会注意到没有关于如何将其作为 Docker 服务运行的说明。这个问题不应该影响你,因为到目前为止,你应该已经是创建服务的专家了。
我们需要什么样的服务来转发所有节点上运行的容器日志?由于我们希望将它们转发到已连接到elk网络的 LogStash,因此我们也应该将 LogSpout 连接到它。我们需要它从所有节点转发日志,所以服务应该是全局的。它需要知道目标是名为logstash的服务,并且该服务监听端口51415。最后,LogSpout 的一个要求是 Docker 主机的套接字必须挂载到服务容器内。这是它监控日志所需要的。
创建满足所有这些目标和要求的服务的命令如下:
Windows 用户注意
要使下一条命令中使用的挂载点生效,你必须停止 Git Bash 修改文件系统路径。设置这个环境变量:
export MSYS_NO_PATHCONV=1
docker service create --name logspout \
--network elk \
--mode global \
--mount \
"type=bind,source=/var/run/docker.sock,target=/var/run/\
docker.sock"
-e SYSLOG_FORMAT=rfc3164 \
gliderlabs/logspout syslog://logstash:51415
我们创建了一个名为logspout的服务,将其连接到elk网络,设置为全局模式,并挂载了 Docker 套接字。容器创建后将执行的命令是syslog://logstash:51415。这告诉 LogSpout,我们希望使用syslog协议将日志发送到运行在51415端口的logstash。
该项目展示了 Docker 远程 API 的有用性。logspout容器将使用该 API 来检索当前所有正在运行的容器的列表,并流式传输它们的日志。这已经是我们集群中第二个使用该 API 的产品(第一个是Docker Flow Swarm Listener (github.com/vfarcic/docker-flow-swarm-listener))。
让我们查看一下刚创建的服务的状态:
docker service ps logspout
输出如下(为了简洁,移除了 IDs & ERROR PORTS 列):
NAME IMAGE NODE DESIRED STATE
logspout... gliderlabs/logspout:latest swarm-3 Running
logspout... gliderlabs/logspout:latest swarm-2 Running
logspout... gliderlabs/logspout:latest swarm-1 Running
------------------------------------------------------
CURRENT STATE
Running 11 seconds ago
Running 10 seconds ago
Running 10 seconds ago
服务以全局模式运行,导致每个节点内部都有一个实例。
让我们测试一下logspout服务是否确实将所有日志发送到了 LogStash。我们只需要创建一个生成日志的服务,并从 LogStash 的输出中观察它们。我们将使用注册表来测试到目前为止我们所做的配置:
docker service create --name registry \
-p 5000:5000 \
--reserve-memory 100m \
registry
在检查 LogStash 日志之前,我们应该等到注册表正在运行:
docker service ps registry
如果当前状态仍然没有运行,请稍等片刻。
现在我们可以查看logstash日志,确认logspout是否成功发送了由registry生成的日志条目:
eval $(docker-machine env $LOGSTASH_NODE)
docker logs $LOGSTASH_ID
输出中的一条记录如下:
{
"message" => "time=\"2016-10-19T23:14:19Z\" level=info \
msg=\"listening on [::]:5000\" go.version=go1.6.3 \
instance.id=87c31e30-a747-4f70-b7c2-396dd80eb47b version=v2.5.1 \n",
"@version" => "1",
"@timestamp" => "2016-10-19T23:14:19.000Z",
"host" => "10.0.0.7",
"priority" => 14,
"timestamp8601" => "2016-10-19T23:14:19Z",
"logsource" => "c51c177bd308",
"program" => "registry.1.abszmuwq8k3d7comu504lz2mc",
"pid" => "4833",
"severity" => 6,
"facility" => 1,
"timestamp" => "2016-10-19T23:14:19Z",
"facility_label" => "user-level",
"severity_label" => "Informational"
}
正如我们之前用 logger 测试 LogStash 输入时,我们会看到timestamp、host和一些其他syslog字段。我们还会看到logsource,它保存了生成日志的容器的ID,以及program,它保存了容器的名称。这两个字段在调试哪个服务和容器产生了故障时非常有用。
如果你回顾一下我们用于创建logstash服务的命令,你会注意到环境变量LOGSPOUT=ignore。它告诉 LogSpout 忽略该服务,或者更准确地说,忽略组成该服务的所有容器。如果我们没有定义它,LogSpout 会将所有logstash日志转发到logstash,从而形成一个无限循环。正如我们之前讨论的那样,在生产环境中,我们不应该将 LogStash 条目输出到stdout。我们这样做仅仅是为了更好地理解它是如何工作的。如果从 logstash 配置中移除stdout输出,就不再需要环境变量LOGSPOUT=ignore。结果,logstash日志也会被存储在 ElasticSearch 中。
现在我们将所有日志发送到 LogStash,并从那里发送到 ElasticSearch,我们应该探索如何查询这些日志。
探索日志
将所有日志集中存储在一个数据库中是一个好的开始,但它不能让我们以简单和用户友好的方式进行探索。我们不能指望开发人员每次想要探索发生了什么错误时都去调用 ElasticSearch API。我们需要一个 UI,允许我们可视化和筛选日志。我们需要ELK堆栈中的K。
Windows 用户注意事项
你可能会遇到 Docker Compose 无法正确映射卷的问题。如果你看到Invalid volume specification错误,请将环境变量COMPOSE_CONVERT_WINDOWS_PATHS设置为0:
export COMPOSE_CONVERT_WINDOWS_PATHS=0
请确保每次运行docker-compose或docker stack deploy时都导出该变量。
让我们再创建一个服务。这一次是 Kibana。除了需要此服务与logspout和elasticsearch服务进行通信外,我们还希望通过代理暴露它,因此我们还将创建swarm-listener和proxy服务。让我们开始吧:
docker network create --driver overlay proxy
curl -o docker-compose-stack.yml \
https://raw.githubusercontent.com/\
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
docker stack deploy \
-c docker-compose-stack.yml proxy
我们创建了proxy网络,下载了包含服务定义的 Compose 文件,并部署了由swarm-listener和proxy服务组成的代理堆栈。这些命令与我们在第八章中执行的相同,使用 Docker Stack 和 Compose YAML 文件部署 Swarm 服务,因此无需再重复解释。
在我们创建kibana服务之前,唯一缺少的就是等待swarm-listener和proxy两个服务启动并运行。
请执行docker service ls命令,确认两个服务的副本已在运行。
现在我们准备创建kibana服务:
Windows 用户注意事项
要使下一个命令中使用的挂载点工作,必须阻止 Git Bash 更改文件系统路径。设置此环境变量:
export MSYS_NO_PATHCONV=1
docker service create --name kibana \
--network elk \
--network proxy \
-e ELASTICSEARCH_URL=http://elasticsearch:9200 \
--reserve-memory 50m \
--label com.df.notify=true \
--label com.df.distribute=true \
--label com.df.servicePath=/app/kibana,/bundles,/elasticsearch \
--label com.df.port=5601 \
kibana:4.6
我们将它连接到了 elk 和proxy两个网络。第一个网络是为了使其能够与elasticsearch服务通信,第二个网络则是为了与代理进行通信。我们还设置了ELASTICSEARCH_URL环境变量,用于告知 Kibana 数据库的地址,并预留了50m的内存。最后,我们定义了一些标签,这些标签将由swarm-listener用于通知代理服务的存在。这一次,com.df.servicePath标签有三个路径,与 Kibana 使用的路径相匹配。
在打开其 UI 之前,让我们确认kibana是否正在运行:
docker service ps kibana
UI 可以通过以下命令打开:
open "http://$(docker-machine ip swarm-1)/app/kibana"
Windows 用户注意事项
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行docker-machine ip <SERVER_NAME>来找出机器的 IP 地址,并直接在你选择的浏览器中打开 URL。例如,上面的命令应该替换为以下命令:
docker-machine ip swarm-1
如果输出是1.2.3.4,请在浏览器中打开http://1.2.3.4:8082/jenkins。
您应该看到一个屏幕,让您配置 ElasticSearch 索引。
现在,我们可以通过单击顶部菜单中的 Discover 按钮来探索日志。
默认情况下,Kibana 显示在最近十五分钟内生成的日志。根据生成日志以来的时间,十五分钟可能少于实际经过的时间。我们将持续时间增加到二十四小时。
请选择@timestamp作为时间字段名称,然后点击Create按钮在 ElasticSearch 中生成 LogStash 索引:

图 8-1:配置 Kibana 屏幕的索引模式
请单击右上角菜单中的“Last 15 minutes”。您将看到我们可以使用大量选项来基于时间过滤结果。
请单击“Last 24 hours”链接,并观察右上角菜单中的时间变化。现在点击“Last 24 hours”按钮以隐藏过滤器。
可在 Kibana 文档的Setting the Time Filter(www.elastic.co/guide/en/kibana/current/discover.html#set-time-filter)部分找到更多信息:

图 8-2:Kibana 的发现屏幕中的时间过滤器
目前,屏幕中央显示与给定的time-span匹配的所有日志。在“真实”的生产系统上,我们通常不会对集群中生成的所有日志感兴趣。相反,我们会根据某些标准对它们进行过滤。
假设我们想要查看由proxy服务生成的所有日志。我们通常不需要知道生成它们的程序的确切名称。这是因为 Swarm 会向容器名称添加实例编号和哈希标记,我们经常不确定确切的名称或哪个实例引起了问题。因此,我们将过滤日志以显示所有包含proxy一词的程序。
请在屏幕上部的搜索字段中键入program: "proxy_proxy",然后按 Enter。结果将只显示主屏幕中包含程序名称字段中proxy_proxy的日志。类似地,我们可以更改搜索到先前状态并列出所有与给定时间范围匹配的日志。我们只需在搜索字段中键入*,然后按 Enter。
可在Kibanas文档的Searching Your Data(www.elastic.co/guide/en/kibana/current/discover.html#search)部分找到更多信息。
当前查询匹配的所有字段列表位于左侧菜单中。我们可以通过点击某个字段来查看该字段的顶部值。例如,我们可以点击 program 字段,查看在指定时间内产生日志的所有程序。我们可以将这些值作为另一种过滤结果的方式。请点击 proxy.1.4psvagyv4bky2lftjg4a(在你的情况下,哈希值会不同)旁边的 + 号。我们刚刚达成的结果就相当于在搜索字段中输入了 program: "proxy.1.4psvagyv4bky2lftjg4a:。
更多信息可以在 Kibana 文档中的 按字段过滤 (www.elastic.co/guide/en/kibana/current/discover.html#field-filter) 部分找到。
屏幕的主体部分显示了每一行中所选的字段,并且可以深入查看并显示所有信息。事实上,默认字段(时间和 _source)并不是非常有用,因此我们将更改它们。
请点击左侧菜单中程序旁边的“添加”按钮。你会看到程序列已经添加到时间列。让我们再添加一些字段。请重复此操作,添加主机和 @timestamp 字段。
要查看特定条目的更多信息,请点击指向右边的箭头。它下面会出现一个包含所有字段的表格,你可以浏览与特定日志条目相关的所有详细信息。
更多信息可以在 Kibana 文档中的 按字段过滤 (www.elastic.co/guide/en/kibana/current/discover.html#document-data) 部分找到。
在这次简短的 Kibana 之旅中剩下的唯一任务是保存我们刚刚创建的过滤器。请点击顶部菜单中的“保存搜索”按钮,将目前创建的内容保存。为你的搜索输入一个名称并点击“保存”按钮。你的过滤器现在已保存,可以通过顶部菜单中的“加载已保存搜索”按钮访问:

图 8-3:Kibana 中的 Discover 屏幕
就这样。现在你已经了解了如何浏览存储在 ElasticSearch 中的日志。如果你想知道在可视化和仪表板屏幕上能做什么,我只想说它们对日志不太有用。不过,如果我们开始添加其他类型的信息,比如资源使用情况(例如:内存、CPU、网络流量等),它们会变得更有趣。
讨论其他日志记录解决方案
ELK 是你应该选择的日志记录解决方案吗?这是一个很难回答的问题。市场上有大量类似的工具,要给出一个普适的答案几乎是不可能的。
你偏好使用免费解决方案吗?如果是,那么 ELK(ElasticSearch(www.elastic.co/products/elasticsearch),LogStash(www.elastic.co/products/logstash),和 Kibana(www.elastic.co/products/kibana))是一个极好的选择。如果你在寻找一个同样便宜(免费的)替代方案,FluentD(www.fluentd.org/)是值得尝试的东西。还有许多其他的解决方案可能适合你的需求。一个简单的 Google 搜索会揭示大量的选择。
你是否对作为服务提供的解决方案更感兴趣?你希望有人为你管理日志基础设施吗?如果是,许多服务提供商会收费为你托管日志,并提供良好的界面供你查看。由于本书完全基于你可以自行运行的开源解决方案,因此我不会列举例子。如果你更倾向于使用由他人维护的服务,再次提醒,Google 是你的好帮手。
现在怎么办?
我们仅仅触及了 ELK 栈能做的皮毛。ElasticSearch 是一个非常强大的数据库,能够轻松扩展并存储大量数据。LogStash 提供几乎无限的可能性,使我们能够使用几乎任何数据源作为输入(在我们的案例中是 syslog),将其转换为我们认为有用的任何形式,并输出到许多不同的目标(在我们的案例中是 ElasticSearch)。当有需求时,你可以使用 Kibana 来浏览系统生成的日志。最后,促成这一切的工具是 LogSpout。它确保了在我们集群中运行的任何容器产生的所有日志都能被收集并发送到 LogStash。
本章的目标是探索一个潜在的解决方案,用以处理海量日志,并让你对如何从在 Swarm 集群中运行的服务中收集日志有一个基础的理解。你知道关于日志记录的所有知识吗?你可能并不知道。然而,我希望你能有一个良好的基础,来更深入地探索这个主题。
即使你选择使用不同的工具集,过程依然是相同的。使用一个工具从你的服务中收集日志,将其发送到某个数据库,在需要时使用用户界面进行查看。
现在我们有了日志,但它们只能提供我们找到问题原因所需信息的一部分。日志本身往往不足够。我们还需要来自系统的指标。也许我们的服务使用的内存超出了集群提供的范围。也许系统响应时间过长。或者可能我们在某个服务中有内存泄漏。这些问题通过日志很难发现。
我们不仅需要知道系统的当前指标,还需要了解其过去的表现。即使我们拥有这些指标,我们仍然需要一个能够通知我们问题的流程。查看日志和指标提供了大量的信息,帮助我们调试问题,但如果我们不知道问题的存在,根本无法开始调试。我们需要一个能够在问题发生时通知我们,或者更好的是,在问题真正发生之前就发出警告的流程。即便有了这样的系统,我们还应该进一步采取措施,尝试防止问题的发生。这种预防措施通常可以自动化。毕竟,为什么我们要手动修复所有问题,而其中一些问题可以由系统自己自动修复呢?最终目标是创建一个自我修复的系统,只有在出现意外情况时才需要人工介入。
指标、通知、自我修复系统以及我们面前的其他待办任务对于单独一章来说实在太多了,因此我们将一步步来。目前,我们已经完成了日志部分,接下来将讨论收集指标的不同方式,并使用它们来监控我们的集群及其内部运行的服务。
一如既往,我们将以一个破坏性的结尾结束:
docker-machine rm -f swarm-1 swarm-2 swarm-3
第十章:收集指标并监控集群
让我们改变传统的程序构建思维。与其想象我们的主要任务是指示计算机做什么,不如集中精力向人类解释我们希望计算机做什么。
——唐纳德·克努斯
我们成功地将集中式日志记录添加到集群中。来自任何节点中运行的任何容器的日志都会被发送到一个中央位置。它们存储在 Elasticsearch 中,并可以通过 Kibana 访问。然而,虽然我们可以轻松访问所有日志,但这并不意味着我们拥有调试问题或防止问题发生所需的所有信息。我们需要通过系统的其他信息来补充我们的日志。我们需要的远不止日志所能提供的内容。
集群监控系统的要求
迄今为止我们所做的一切,更不用说接下来在全书中要做的任务,我们正在同时增加和减少系统的复杂性。使用 Docker Swarm 比仅使用容器来扩展服务更简单且不那么复杂。事实上,Docker 已经简化了我们之前的很多过程。再加上新的网络功能和内置的服务发现,结果几乎简单到难以置信。但另一方面,复杂性也隐藏在表面之下。如果我们尝试将到目前为止使用的动态工具与为其他时代设计(并为其设计)的工具结合起来,这种复杂性就会显现出来。
以 Nagios (www.nagios.org/) 为例。我并不是说我们不能使用它来监控我们的系统(我们当然可以)。我要说的是,它会与我们迄今为止设计的新系统架构发生冲突。我们的系统变得比以前更复杂了。副本的数量在波动。今天我们有四个实例的服务,但明天早上可能会有六个,下午可能会降到三个。它们分布在集群的多个节点上,并且在不断移动。服务器正在创建和销毁。我们的集群及其内部的一切都是真正动态和弹性的。
我们正在构建的系统的动态特性不适合 Nagios,因为它期望服务和服务器相对静态。它要求我们提前定义事物。采用这种方法的问题在于我们并没有提前获得信息,而 Swarm 则做到了。即便我们获取了所需的信息,它很快就会发生变化。
我们正在构建的系统高度动态,监控这样的系统所使用的工具需要能够应对这种动态变化。
事情不止于此。大多数“传统”工具往往将整个系统视为黑盒。一方面,这样做有一定的优点。最主要的优点是,它允许我们将服务与系统的其他部分解耦。在许多(但不是所有)情况下,白盒监控意味着我们需要向服务中添加监控库,并围绕它们编写代码,以便它们能够暴露服务的内部信息。
在决定为服务添加不属于其本职工作的内容时,请三思。当我们采用微服务架构时,我们应该尽量让服务的功能限制在其主要目标上。如果它是一个购物车,它应该是一个 API,允许我们添加和移除商品。添加库和代码以扩展该服务,使其能够在服务发现存储中注册自己,或者将其度量数据暴露给监控工具,这会产生过多的耦合。一旦我们这样做了,未来的选择将变得非常有限,且系统的变更可能需要相当多的时间和精力。
我们已经成功避免了将服务发现与服务本身耦合。go-demo 服务并不具备任何服务发现的知识,但我们的系统却拥有所需的所有信息。许多组织在此过程中落入陷阱,开始将他们的服务与周围的系统耦合。在这种情况下,我们主要关心的是,是否能够在监控中做到同样的事情。我们能否避免将度量数据的创建与为服务编写的代码耦合在一起?
但是,能够进行白盒监控比黑盒监控带来更多的好处。首先,了解服务的内部结构使我们能够更细致地操作。这为我们提供了在将系统视为黑盒时无法获得的知识。
在一个为高可用性和快速响应时间设计的分布式系统中,仅仅依赖健康检查以及 CPU、内存和磁盘使用情况的监控是不够的。我们已经有了 Swarm 来确保服务的健康状态,而且我们可以轻松地编写脚本来检查基本的资源使用情况。但我们需要的远不止这些。我们需要的是白盒监控,它不会引入不必要的耦合。我们需要智能告警,当出现问题时能够及时通知我们,甚至自动修复问题。理想情况下,我们希望能够在问题发生之前就触发告警并执行自动修复。
监控系统需要满足的一些要求如下:
-
一种去中心化的度量生成方式,能够应对我们集群的高度动态特性
-
一种多维数据模型,可以跨多个维度进行查询
-
一种高效的查询语言,使我们能够利用监控数据模型,创建有效的告警和可视化
-
简便性,让(几乎)任何人都能在没有广泛培训的情况下使用该系统。
在这一章中,我们将继续前一章的工作。我们将探索导出另一组指标的方法,收集它们、查询它们并通过仪表板展示它们的方法。
在我们做这些之前,我们需要做一些选择。我们应该使用哪些工具来进行监控解决方案?
选择合适的数据库来存储系统指标
在《DevOps 2.0 工具包》中,我反对使用像Nagios(www.nagios.org/)和Icinga(www.icinga.org/)这样的“传统”监控工具。相反,我们选择使用 Elasticsearch 来处理日志和系统指标。在前一章中,我重申了选择 Elasticsearch 作为日志解决方案的理由。那么,我们可以通过存储指标来扩展它的使用吗?是的,我们可以。我们应该这么做吗?我们应该用它来存储系统指标吗?是否有更好的解决方案?
如果将 Elasticsearch 作为存储系统指标的数据库,最大的难题是它并不是一个时间序列类型的数据库。日志从 Elasticsearch 能够进行自由文本搜索并以非结构化方式存储数据的能力中受益匪浅。然而,对于系统指标,我们可能需要利用另一种类型的数据存储。我们需要一个时间序列数据库。
时间序列数据库是围绕优化存储和检索时间序列数据的方式设计的。它们的一个主要优势是将信息存储在非常紧凑的格式中,使得它们能够承载大量数据。如果将基于时间的数据存储需求与其他类型的数据库(包括 Elasticsearch)进行比较,你会发现时间序列数据库更加高效。换句话说,如果你的数据是基于时间的指标,使用专门为此类数据设计的数据库。
大多数(如果不是所有的话)时间序列数据库的最大问题是分布式存储。以复制方式运行它们几乎是不可能的,或者说,至少是一个挑战。直白地说,这种数据库是为了运行单个实例而设计的。幸运的是,我们通常不需要在这些数据库中存储长期数据,可以定期清理它们。如果必须进行长期存储,解决方案是将汇总数据导出到其他类型的数据库中,如 Elasticsearch,而 Elasticsearch 在复制和分片方面表现出色。然而,在你“疯狂”地开始导出数据之前,确保你真的需要这么做。时间序列数据库可以轻松在单个实例中存储大量信息。很可能你不会因为容量问题而需要扩展它们。另一方面,如果数据库出现故障,Swarm 会重新调度它,你只会丢失几秒钟的信息。这种情况不应成为灾难,因为我们处理的是汇总数据,而不是单个事务。
最著名的时序数据库之一是InfluxDB (www.influxdata.com/)。Prometheus (prometheus.io/)是一个常用的替代品。我们将跳过这两者的比较,唯一要提及的是我们将使用后者。两者都是值得考虑的监控解决方案,其中 Prometheus 具有我们不能忽视的潜在优势。社区计划是将 Docker 指标以原生 Prometheus 格式暴露。目前没有确切的日期表明何时会实现这一点,但我们会尽力围绕这个计划设计系统。如果你想亲自跟踪进展,请关注Docker issue 27307 (github.com/docker/docker/issues/27307)。我们将以一种方式使用 Prometheus,使得一旦 Docker 原生指标可用时,我们可以切换过去。
让我们将文字转化为行动,创建我们将在本章中使用的集群。
创建集群
这次我们将创建比之前更多的服务,因此需要一个稍大的集群。并不是因为服务本身要求很高,而是因为我们的虚拟机每台只有一个 CPU 和 1GB 内存。这类机器并不值得炫耀。此次,我们将创建一个包含五台机器的集群。除了增加集群的容量外,其他一切将与之前相同,因此没有必要再次经历整个过程。我们只需执行scripts/dm-swarm-5.sh (github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm-5.sh):
本章节中的所有命令都可以在09-monitoring.sh (gist.github.com/vfarcic/271fe5ab7eb6a3307b9f062eadcc3127) Gist 中找到。
cd cloud-provisioning
git pull
scripts/dm-swarm-5.sh
eval $(docker-machine env swarm-1)
docker node ls
docker node ls命令的输出如下(为了简洁,已移除 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-4 Ready Active
swarm-2 Ready Active Reachable
swarm-1 Ready Active Leader
swarm-5 Ready Active
swarm-3 Ready Active Reachable
我们创建了一个包含五个节点的 Swarm 集群,其中三个作为管理节点,剩下的作为工作节点。
现在我们可以创建之前使用过的服务了。由于这也是我们已经多次练习过的内容,我们将从 Compose 文件vfarcic/docker-flow-proxy/docker-compose-stack.yml (github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)和vfarcic/go-demo/docker-compose-stack.yml (github.com/vfarcic/go-demo/blob/master/docker-compose-stack.yml)创建堆栈:
Windows 用户注意事项
你可能会遇到 Docker Compose 中卷没有正确映射的问题。如果看到Invalid volume specification错误,请将环境变量COMPOSE_CONVERT_WINDOWS_PATHS设置为0并导出:
export COMPOSE_CONVERT_WINDOWS_PATHS=0
请确保每次运行 docker-compose 或 docker stack deploy 时都导出该变量。
docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/\
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
docker stack deploy \
-c proxy-stack.yml proxy
curl -o go-demo-stack.yml \
https://raw.githubusercontent.com/\
vfarcic/go-demo/master/docker-compose-stack.yml
docker stack deploy \
-c go-demo-stack.yml go-demo
docker service create --name util \
--network proxy \
--mode global \
alpine sleep 1000000000
docker service ls
一段时间后,docker service ls 命令的输出如下(为简洁起见,删除了 ID):
NAME REPLICAS IMAGE COMMAND
swarm-listener 1/1 vfarcic/docker-flow-swarm-listener
go-demo 3/3 vfarcic/go-demo:1.2
util global alpine sleep 1000000000
go-demo-db 1/1 mongo:3.2.10
proxy 3/3 vfarcic/docker-flow-proxy
我们使用从 GitHub 仓库下载的栈来创建除 util 以外的所有服务。目前,我们的集群托管着演示服务 go-demo 和 go-demo-db、proxy、swarm-listener,以及我们将用来实验监控度量的全球调度 util 服务。
我们准备开始生成一些度量数据了。
Prometheus 度量
Prometheus 将所有数据存储为时间序列。它是一串带有时间戳的值,这些值属于相同的度量和相同的标签。标签为度量提供了多个维度。
例如,如果我们想基于 proxy 的 HTTP 请求导出数据,我们可以创建一个名为 proxy_http_requests_total 的度量。这样的度量可能会包含 request 方法、status 和 path 的标签。这三者可以如下指定:
{method="GET", url="/demo/person", status="200"}
{method="PUT", url="/demo/person", status="200"}
{method="GET", url="/demo/person", status="403"}
最后,我们需要一个度量值,在我们的示例中,它将是请求的总数。
当我们将度量名称与标签和值结合时,示例结果可能如下所示:
proxy_http_requests_total{method="GET", url="/demo/person", status="200"} 654
proxy_http_requests_total{method="PUT", url="/demo/person", status="200"} 143
proxy_http_requests_total{method="GET", url="/demo/person", status="403"} 13
通过这三个度量,我们可以看到有 654 次成功的 GET 请求,143 次成功的 PUT 请求,以及 13 次失败的 GET 请求 HTTP 403。
现在格式基本清晰,我们可以讨论生成度量数据并将其提供给 Prometheus 的不同方式。
Prometheus 基于一种 拉取 机制,从配置的目标中抓取度量数据。我们可以通过两种方式生成 Prometheus 友好的数据。一种是对我们自己的服务进行监控。Prometheus 提供了适用于 Go(github.com/prometheus/client_golang)、Python(github.com/prometheus/client_python)、Ruby(github.com/prometheus/client_ruby)和 Java(github.com/prometheus/client_java)的客户端库。在这些库之上,还有许多非官方库可用于其他语言。暴露我们服务的度量数据被称为监控。对代码进行监控在某种程度上类似于日志记录。
尽管监控是提供将存储在 Prometheus 中的数据的首选方式,但我建议避免这样做。也就是说,除非无法通过其他方式获取相同的数据。这样建议的原因在于我倾向于将微服务与系统的其他部分解耦。如果我们能将服务发现保持在我们的服务之外,也许我们可以对度量数据做同样的处理。
当我们的服务无法被仪表化,或者更好的是,当我们不想对其进行仪表化时,我们可以使用 Prometheus 导出器。它们的作用是收集已经存在的度量数据并将其转换为 Prometheus 格式。正如你将看到的,我们的系统已经暴露了很多度量数据。由于不现实地期望我们的所有解决方案都提供 Prometheus 格式的度量数据,我们将使用导出器来进行转换。
当抓取(拉取)数据不足以满足需求时,我们可以改变方向并推送数据。尽管抓取是 Prometheus 获取度量数据的首选方式,但有些情况下这种方法并不适用。一个例子是短生命周期的批处理作业。它们可能存活的时间非常短,以至于 Prometheus 可能在作业结束并被销毁之前无法拉取数据。在这种情况下,批处理作业可以将数据推送到Push Gateway(github.com/prometheus/pushgateway),然后 Prometheus 可以从 Push Gateway 抓取度量数据。
有关当前支持的导出器列表,请参考 Prometheus 文档中的Exporters and Integrations(prometheus.io/docs/instrumenting/exporters/)部分。
现在,在简要介绍了度量标准之后,我们准备创建将托管导出器的服务。
导出系统级别的度量数据
我们将从Node Exporter(github.com/prometheus/node_exporter)服务开始。它将导出与我们的服务器相关的不同类型的度量数据:
Windows 用户注意事项
为了使下一个命令中使用的挂载有效,您需要阻止 Git Bash 更改文件系统路径。请设置以下环境变量:
export MSYS_NO_PATHCONV=1
本章包含许多使用挂载的docker service create命令。在执行这些命令之前,请确保环境变量MSYS_NO_PATHCONV存在并设置为1:
echo $MSYS_NO_PATHCONV
docker service create \
--name node-exporter \
--mode global \
--network proxy \
--mount "type=bind,source=/proc,target=/host/proc" \
--mount "type=bind,source=/sys,target=/host/sys" \
--mount "type=bind,source=/,target=/rootfs" \
prom/node-exporter:0.12.0 \
-collector.procfs /host/proc \
-collector.sysfs /host/proc \
-collector.filesystem.ignored-mount-points \
"^/(sys|proc|dev|host|etc)($|/)"
由于我们需要node-exporter在每台服务器上可用,因此我们指定该服务应为全局服务。通常,我们会将它连接到一个专门用于监控工具的网络(例如:monitoring)。然而,本地运行的 Docker 机器可能会遇到超过两个网络时的问题。由于我们已经通过scripts/dm-swarm-services-3.sh(github.com/vfarcic/cloud-provisioning/blob/master/scripts/dm-swarm-services-3.sh)创建了go-demo和proxy网络,因此已经达到了安全限制。因此,我们将使用现有的proxy网络来为监控服务提供网络支持。在操作“真实”集群时,您应该为监控服务创建一个单独的网络。
我们还挂载了一些卷。
/proc 目录非常特殊,它也是一个虚拟文件系统。它有时被称为进程信息伪文件系统。它不包含“真实”的文件,而是包含运行时系统信息(例如:系统内存、挂载的设备、硬件配置等)。
因此,它可以被视为内核的控制和信息中心。实际上,很多系统工具本质上只是对该目录中文件的调用。例如,lsmod 实际上就是 cat /proc/modules,而 lspci 是 cat /proc/pci 的同义词。通过更改该目录中的文件,甚至可以在系统运行时 读取/更改 内核参数 sysctl。node-exporter 服务将使用它来查找系统中运行的所有进程。
现代 Linux 发行版包括一个 /sys 目录,作为一个虚拟文件系统(sysfs,类似于 /proc,后者是 procfs),它存储并允许修改连接到系统的设备,而许多传统的 UNIX 和类 UNIX 操作系统则将 /sys 用作指向内核源代码树的符号链接。
sys 目录是 Linux 提供的虚拟文件系统。它通过从内核的设备模型将关于各种内核子系统、硬件设备及其相关设备驱动程序的信息导出到用户空间,提供一组虚拟文件。通过将其暴露为卷,服务将能够收集关于内核的信息。
最后,我们定义了镜像 prom/node-exporter 并传递了一些命令参数。我们指定了 /proc 和 /sys 的目标卷,并指示忽略容器内的挂载点。
请访问 Node Exporter 项目 (github.com/prometheus/node_exporter) 获取更多信息。
到这时,服务应该已经在集群内运行。让我们确认一下:
docker service ps node-exporter
service ps 命令的输出如下(为了简洁,已移除 ID):
NAME IMAGE NODE DESIRED STATE
node-exporter... prom/node-exporter:0.12.0 swarm-5 Running
node-exporter... prom/node-exporter:0.12.0 swarm-4 Running
node-exporter... prom/node-exporter:0.12.0 swarm-3 Running
node-exporter... prom/node-exporter:0.12.0 swarm-2 Running
node-exporter... prom/node-exporter:0.12.0 swarm-1 Running
------------------------------------------------
CURRENT STATE ERROR PORTS
Running 6 seconds ago
Running 7 seconds ago
Running 7 seconds ago
Running 7 seconds ago
Running 7 seconds ago
让我们快速查看 node-exporter 服务提供的指标。我们将使用 util 服务来检索这些指标:
UTIL_ID=$(docker ps -q --filter \
label=com.docker.swarm.service.name=util)
docker exec -it $UTIL_ID \
apk add --update curl drill
docker exec -it $UTIL_ID \
curl http://node-exporter:9100/metrics
curl 输出示例如下:
# HELP go_gc_duration_seconds A summary of the GC invocation durations.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 0
go_gc_duration_seconds{quantile="0.25"} 0
go_gc_duration_seconds{quantile="0.5"} 0
go_gc_duration_seconds{quantile="0.75"} 0
go_gc_duration_seconds{quantile="1"} 0
go_gc_duration_seconds_sum 0
go_gc_duration_seconds_count 0
...
如你所见,指标以 Prometheus 友好的格式展示。请访问 Node Exporter 收集器 (github.com/prometheus/node_exporter#collectors) 了解更多关于每个指标的含义。目前,你应该知道大多数节点信息已经可以获取,并且稍后会被 Prometheus 抓取。
由于我们通过 Docker 网络发送了请求,获得了一个负载均衡的响应,因此无法确定是哪一个节点产生了输出。当我们配置 Prometheus 时,我们需要更具体一点,并跳过网络负载均衡。
现在我们有了关于服务器的信息,我们应该添加特定于容器的指标。我们将使用 cAdvisor,也叫做 容器顾问。
cAdvisor 提供了容器用户对其运行容器的资源使用和性能特征的了解。它是一个运行中的守护进程,收集、聚合、处理并导出有关运行容器的信息。具体来说,它为每个容器保持资源隔离参数、历史资源使用情况、完整的历史资源使用直方图和网络统计数据。这些数据按容器和机器范围导出。它原生支持 Docker 容器。
让我们创建服务:
docker service create --name cadvisor \
-p 8080:8080 \
--mode global \
--network proxy \
--mount "type=bind,source=/,target=/rootfs" \
--mount "type=bind,source=/var/run,target=/var/run" \
--mount "type=bind,source=/sys,target=/sys" \
--mount "type=bind,source=/var/lib/docker,target=/var/lib/docker" \
google/cadvisor:v0.24.1
就像 node-exporter 一样,cadvisor 服务是全局的,并连接到 proxy 网络。它挂载了一些目录,使其能够监控主机上的 Docker 状态和事件。由于 cAdvisor 自带一个 Web UI,我们开放了端口 8080,这样我们就可以在浏览器中打开它。
在我们继续之前,我们应该确认服务确实在运行:
docker service ps cadvisor
service ps 的输出如下(为了简洁,省略了 ID):
NAME IMAGE NODE DESIRED STATE
cadvisor... google/cadvisor:v0.24.1 swarm-3 Running
cadvisor... google/cadvisor:v0.24.1 swarm-2 Running
cadvisor... google/cadvisor:v0.24.1 swarm-1 Running
cadvisor... google/cadvisor:v0.24.1 swarm-5 Running
cadvisor... google/cadvisor:v0.24.1 swarm-4 Running
--------------------------------------------------------
CURRENT STATE ERROR PORTS
Running 3 seconds ago
Running 3 seconds ago
Running 3 seconds ago
Running 8 seconds ago
Running 3 seconds ago
现在我们可以打开 UI:
给 Windows 用户的提示
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行 docker-machine ip <SERVER_NAME> 以查找机器的 IP,并在你选择的浏览器中直接打开该 URL。例如,下面的命令应该替换为随后的命令:
docker-machine ip swarm-1
如果输出是 1.2.3.4,你应该在浏览器中打开 http://1.2.3.4:8080。
open "http://$(docker-machine ip swarm-1):8080"

图 9-1:cAdvisor UI
随意向下滚动并探索 cAdvisor 提供的各种图表和指标。如果这些信息还不够,可以通过点击屏幕顶部的 Docker 容器链接获取有关运行中容器的更多信息。
虽然乍一看界面可能让人印象深刻,但对于除单个服务器外的任何其他用途,UI 几乎是无用的。因为它是作为一个监控单个节点的工具设计的,所以在 Swarm 集群中并没有太大作用。
首先,页面及其所有请求都是由入口网络进行负载均衡的。这不仅意味着我们无法知道是哪个服务器返回了 UI,还意味着返回指标和图表所使用的数据请求也进行了负载均衡。换句话说,来自所有服务器的不同数据被混合在一起,给我们一个非常不准确的视图。我们可以跳过使用该服务,直接使用 docker run 命令(对每个服务器重复)。然而,即使那样能让我们看到特定的服务器,解决方案仍然是不充分的,因为我们将被迫从一台服务器切换到另一台。我们的目标不同。我们需要收集并可视化整个集群的数据,而不是单个服务器。因此,UI 必须被去除。
顺便提一下,某些类型的指标在 node-exporter 和 cadvisor 服务之间有重叠。你可能会倾向于只选择其中一个。然而,它们的关注点不同,只有将两者结合起来,才能得到完整的视图。
既然我们已经确定当 UI 托管在 Swarm 集群中时是无用的,因此没有充分理由暴露端口 8080。因此,我们应将其从服务中移除。你可能会想删除该服务并重新创建它,而不暴露端口。其实不需要采取这种操作。相反,我们可以通过更新服务来消除该端口:
docker service update \
--publish-rm 8080 cadvisor
docker service inspect cadvisor --pretty
通过检查 service inspect 命令的输出,你会注意到端口没有打开(它不存在)。
现在 cadvisor 服务已运行,并且我们没有从无用的 UI 生成噪声,我们可以快速查看 cAdvisor 导出的指标:
docker exec -it $UTIL_ID \
curl http://cadvisor:8080/metrics
curl 输出的示例如下:
# TYPE container_cpu_system_seconds_total counter
container_cpu_system_seconds_total{id="/"} 22.91
container_cpu_system_seconds_total{id="/docker"} 0.32
我们进展顺利。我们正在导出服务器和容器指标。我们可能会继续无限地添加指标,并将本章扩展到无法承受的大小。我将把创建提供额外信息的服务作为你后续需要完成的练习。现在我们将进入 Prometheus。毕竟,没有能够查询和可视化指标,它们的存在没有太大意义。
抓取、查询和可视化 Prometheus 指标
Prometheus 服务器旨在从已仪表化的服务中拉取指标。然而,由于我们希望避免不必要的耦合,因此我们使用了提供所需指标的导出器。这些导出器已经作为 Swarm 服务在运行,现在我们可以通过 Prometheus 来利用它们。
要实例化 Prometheus 服务,我们应创建一个配置文件,其中包含在集群中运行的导出器。 在此之前,我们需要获取所有导出器服务实例的 IP 地址。如果你记得第四章,Swarm 集群中的服务发现,我们可以通过在服务名称前添加 tasks. 前缀来获取所有的 IP 地址。
要检索 node-exporter 服务的所有副本列表,我们可以例如从 util 服务的一个实例中提取:
docker exec -it $UTIL_ID \
drill tasks.node-exporter
输出的相关部分如下:
;; ANSWER SECTION:
tasks.node-exporter. 600 IN A 10.0.0.21
tasks.node-exporter. 600 IN A 10.0.0.23
tasks.node-exporter. 600 IN A 10.0.0.22
tasks.node-exporter. 600 IN A 10.0.0.19
tasks.node-exporter. 600 IN A 10.0.0.20
我们已获取所有当前运行的服务副本的 IP 地址。
单独列出 IP 地址是不够的。我们需要告诉 Prometheus 它应该动态地使用这些地址。每次想要拉取新数据时,它应该查询 tasks.<SERVICE_NAME>。幸运的是,Prometheus 可以通过 dns_sd_configs 配置,使用地址作为服务发现。有关可用选项的更多信息,请参阅文档中的 配置(prometheus.io/docs/operating/configuration/)部分。
了解了dns_sd_configs选项的存在后,我们可以继续定义 Prometheus 配置。我们将使用我为本章准备的配置文件。它位于conf/prometheus.yml中(github.com/vfarcic/cloud-provisioning/blob/master/conf/prometheus.yml)
让我们快速浏览一下:
cat conf/prometheus.yml
输出如下:
global:
scrape_interval: 5s
scrape_configs:
- job_name: 'node'
dns_sd_configs:
- names: ['tasks.node-exporter']
type: A
port: 9100
- job_name: 'cadvisor'
dns_sd_configs:
- names: ['tasks.cadvisor']
type: A
port: 8080
- job_name: 'prometheus'
static_configs:
- targets: ['prometheus:9090']
我们定义了三个任务。前两个node和cadvisor使用了dns_sd_configs(DNS 服务发现配置)选项。它们都定义了任务<SERVICE_NAME>,类型为 A(您可以从drill的输出中看到类型),并且定义了内部端口。最后一个prometheus将提供内部度量。
请注意,我们将scrape_interval设置为五秒。在生产环境中,您可能希望获取更精细的数据并将其更改为例如一秒的间隔。小心!间隔越短,成本越高。我们抓取度量的频率越高,所需的资源就越多,包括查询这些结果,甚至存储数据。尽量在数据粒度和资源使用之间找到平衡。创建 Prometheus 服务很容易(几乎和创建其他任何 Swarm 服务一样简单)。
我们首先创建一个目录来持久化 Prometheus 数据:
mkdir -p docker/prometheus
现在我们可以创建服务了:
docker service create \
--name prometheus \
--network proxy \
-p 9090:9090 \
--mount "type=bind,source=$PWD/conf/prometheus.yml, \
target=/etc/prometheus/prometheus.yml"
--mount "type=bind,source=$PWD/docker/\
prometheus,target=/prometheus"
prom/prometheus:v1.2.1
docker service ps prometheus
我们创建了docker/prometheus目录,用于持久化 Prometheus 状态。
该服务非常普通。它附加到proxy网络,暴露端口9090,并挂载配置文件和状态目录。
service ps命令的输出如下(为了简洁,省略了 ID 和 ERROR 列):
NAME IMAGE NODE DESIRED STATE
prometheus.1 prom/prometheus:v1.2.1 swarm-3 Running
-----------------------------------------
CURRENT STATE
Running 59 seconds ago
请注意,扩展此服务没有意义。Prometheus 被设计为单实例工作。在大多数情况下,这不是问题,因为它可以轻松存储和处理大量数据。如果它失败,Swarm 将重新调度它到其他地方,届时我们只会丢失几秒钟的数据。
让我们打开它的 UI,看看可以做什么:
Windows 用户注意
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行docker-machine ip <SERVER_NAME>以找出机器的 IP,并直接在您选择的浏览器中打开该 URL。例如,下面的命令应替换为如下命令:
docker-machine ip swarm-1
如果输出是1.2.3.4,您应该在浏览器中打开http://1.2.3.4:9090。
open "http://$(docker-machine ip swarm-1):9090"
我们首先应该检查它是否注册了所有导出的目标。
请点击顶部菜单中的“Status”按钮并选择“Targets”。您应该能看到五个cadvisor目标与形成集群的五台服务器匹配。同样,也有五个节点目标。最后,一个 prometheus 目标也已注册:

图 9-2:在 Prometheus 中注册的目标
现在我们已经确认所有目标都已注册,并且 Prometheus 已经开始抓取它们提供的指标,我们可以探索通过ad-hoc查询获取数据并将其可视化的方法。
请点击顶部菜单中的图表按钮,从- 插入光标处的指标 *-*列表中选择node_memory_MemAvailable,然后点击执行按钮。
你应该看到一个包含指标列表以及与每个指标相关的数值的表格。许多人更喜欢通过点击列表上方的“图表”选项卡来获取数据的可视化表示。请点击它。
你应该看到五台服务器的可用内存。它以指定时间段内的变化趋势显示,可以通过位于图表上方的字段和按钮进行调整。自从我们创建了prometheus服务以来,时间并不长,所以你可能需要将时间段缩短到五分钟或十五分钟。
同样的结果也可以通过在“表达式”字段中输入查询(或在这种情况下输入指标的名称)来实现。稍后,我们将做一些更复杂的查询,这些查询无法通过从*-*插入光标处的指标 *-*列表中选择单个指标来定义:

图 9-3:Prometheus 图表与可用内存
现在可能是讨论我们目前设置的系统主要缺点的好时机。我们没有能够轻松将数据与特定服务器关联的信息。由于地址列表是通过 Docker 网络获取的,而 Docker 网络为每个副本创建了一个虚拟 IP,因此这些地址并不是服务器的真实地址。对此没有简单的解决方法(据我所知),所以我们只有两个选择。一种方法是将导出程序作为“正常”容器运行(例如:docker run),而不是作为服务运行。这样做的好处是我们可以将网络类型设置为host,并获取服务器的 IP。这样做的问题是,我们需要为每个服务器单独运行导出程序。
这本来不会太糟糕,除了每次我们向集群添加新服务器时,都需要重新运行所有的导出器。更复杂的是,这也意味着我们需要更改 Prometheus 配置,或者仅为此目的添加一个单独的服务注册表。另一种选择是等待。无法从服务副本中检索主机 IP 是已知的限制。这个问题已经在多个地方记录过,其中之一是 issue 25526 (https://github.com/docker/docker/issues/25526)。同时,社区已经决定从 Docker 引擎原生地暴露 Prometheus 指标。这将消除我们作为服务创建的一些,甚至是所有导出器的需求。我相信这两者中的一个很快会实现。在那之前,你必须做出决定,要么忽略 IP 是虚拟的事实,要么将服务替换为在集群中每台服务器上单独运行的容器。无论你做出什么选择,稍后我会向你展示如何找到虚拟 IP 和主机之间的关系。
让我们回到查询 Prometheus 指标。
node_memory_MemAvailable的示例只使用了该指标,因此我们得到了它的所有时间序列。
让我们稍微增加点趣味,创建一个将返回空闲 CPU 的图表。查询将是node_cpu{mode="idle"}。使用mode="idle"将node_cpu指标限制为仅显示标记为空闲的数据。试试看,你会发现图表应该由五条几乎直线的上升曲线组成。这看起来不太对。
让我们通过引入irate函数来创建一个更精确的图像。它计算时间序列的每秒瞬时增长率,基于最后两个数据点。要使用irate函数,我们还需要指定测量的持续时间。修改后的查询如下:
irate(node_cpu{mode="idle"}[5m])
由于我们正在从cadvisor服务抓取指标,我们也可以查询不同容器的指标。例如,我们可以查看每个容器的内存使用情况。
请执行下面的查询:
container_memory_usage_bytes
请执行查询并亲自查看结果。你应该看到每个节点在 5 分钟间隔内测量的空闲 CPU 使用率:

图 9-4:Prometheus 图表展示 CPU 空闲率
如果你通过图表查看结果,你会发现cAdvisor使用了最多的内存(在我的机器上大约是800M)。这看起来不太对。该服务的内存占用应该要小得多。如果你查看它的标签,你会注意到 ID 是/。这代表的是所有通过cAdvisor的容器的总内存使用情况。我们应该用!=操作符将其从结果中排除。
请执行下面的查询:
container_memory_usage_bytes{id!="/"}
这次,结果更有意义了。使用最多内存的服务是 Prometheus 本身。
之前的查询使用标签 ID 来过滤数据。当与!=操作符结合使用时,它排除了所有 ID 设置为/的度量。
即使是如此小的集群,容器数量也可能太多,无法在一个图表中显示,因此我们可能希望将结果限制为单个服务。可以通过使用container_label_com_docker_swarm_service_name来过滤数据,完成这一操作。
让我们来看一下所有cadvisor副本的内存使用情况:
container_memory_usage_bytes{container_label_com_docker_swarm_service_\
name="cadvisor"}
这一切看起来不错,但作为监控系统并不十分有用。Prometheus 更多的是用于ad-hoc查询,而不是我们可以用来创建能够展示整个系统的仪表盘的工具。为此,我们需要在其中再添加一个服务。
使用 Grafana 创建仪表盘
Prometheus 提供了一个名为PromDash的仪表盘构建工具(github.com/prometheus/promdash)。然而,它已经不再推荐使用,并且对于 Grafana 而言,已经不再值得在我们的集群中运行,因此我们不再考虑它。
Grafana (grafana.org/) 是一个领先的时间序列度量查询和可视化工具。它具有交互式和可编辑的图表,并支持多个数据源。Graphite、Elasticsearch、InfluxDB、OpenTSDB、KairosDB,以及最重要的 Prometheus 都能开箱即用地支持。如果这些还不够,还可以通过插件添加额外的数据源。Grafana 确实是一个功能丰富的 UI,已经在市场上确立了领导地位。最棒的是,它是免费的。
让我们创建一个grafana服务:
docker service create \
--name grafana \
--network proxy \
-p 3000:3000 \
grafana/grafana:3.1.1
几分钟后,副本的状态应该显示为正在运行:
docker service ps grafana
service ps命令的输出如下(为简洁起见,已移除 ID):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
grafana.1 grafana/grafana3.1.1 swarm-1 Running Running 24 seconds ago
现在服务已经运行,我们可以打开 UI:
Windows 用户注意事项
Git Bash 可能无法使用open命令。如果是这种情况,执行docker-machine ip <SERVER_NAME>来查找机器的 IP 地址,并在你选择的浏览器中直接打开 URL。例如,下面的命令应该替换为以下命令:
docker-machine ip swarm-1
如果输出是1.2.3.4,你应该在浏览器中打开http://1.2.3.4:3000。
open "http://$(docker-machine ip swarm-1):3000"
你将看到登录界面。默认的用户名和密码是 admin。请继续登录。
用户名和密码以及其他许多设置可以通过配置文件和环境变量进行调整。由于我们在 Docker 容器内运行 Grafana,环境变量是更好的选择。更多信息,请访问官方文档的配置部分(docs.grafana.org/installation/configuration/)。
我们应该做的第一件事是将 Prometheus 添加为数据源。
请点击位于屏幕左上角的Grafana徽标,选择数据源,并点击+添加数据源按钮。
我们将其命名为Prometheus并选择相同的类型。输入http://prometheus:9090作为Url并点击添加按钮。就这样。从现在开始,我们可以可视化并查询存储在 Prometheus 中的任何度量数据。
让我们创建第一个仪表盘。
请点击Grafana标志,选择仪表盘,并点击+ 新建。在屏幕的左上角有一个绿色的垂直按钮。点击它,选择添加面板,然后选择图表。你会看到一个默认的图表,显示的是测试度量。如果你不想欣赏那些上上下下的漂亮线条,它并不是很有用。我们将面板的数据源从默认的更改为 Prometheus。输入irate(node_cpu{mode="idle"}[5m])作为查询。片刻之后,你应该看到一个显示 CPU 使用率的图表。
默认情况下,图表显示六小时的数据。在这种情况下,如果你是一个阅读速度较慢的人,花了那么多时间创建 prometheus 服务并阅读后续的文本,那么这可能是OK的。我假设你只有半小时的数据,并且想要更改图表的时间轴。
请点击位于屏幕右上角的“过去 6 小时”按钮,然后点击“过去 30 分钟”链接。图表应该类似于图 9-5:

图 9-5:从 Prometheus 获取的 CPU 使用率的 Grafana 图表
你可以自定义许多内容,使图表符合你的需求。我会把这个留给你。继续玩玩新的工具,探索它提供的不同选项。
如果你像我一样懒惰,你可能不想自己创建所有需要的图表和仪表盘,而是直接利用别人的努力。幸运的是,Grafana 社区非常活跃,并且有不少成员创建了仪表盘。
请在grafana.net(grafana.net)的仪表盘(grafana.net/dashboards)部分打开。你会在左侧看到一些筛选器以及一个通用的搜索框。我们可以例如搜索node exporter。
我鼓励你稍后去探索所有提供的 node exporter 仪表盘。目前,我们将选择Node Exporter Server Metrics(grafana.net/dashboards/405)。在页面中,你会看到下载仪表盘按钮。使用它下载包含仪表盘定义的 JSON 文件。
让我们回到我们的grafana服务:
Windows 用户注意事项
Git Bash 可能无法使用open命令。如果是这种情况,请执行docker-machine ip <SERVER_NAME>来查找机器的 IP 地址,并直接在你选择的浏览器中打开 URL。例如,下面的命令应该替换为后续的命令:
docker-machine ip swarm-1
如果输出是1.2.3.4,你应该在浏览器中打开http://1.2.3.4:3000。
open "http://$(docker-machine ip swarm-1):3000"
再次打开仪表板选项,点击隐藏在 Grafana 徽标下的选项并选择导入。点击上传 .json 文件按钮,打开你刚刚下载的文件。我们将保持名称不变,并选择 Prometheus 作为数据源。最后,点击保存并打开按钮完成操作。
奇迹发生了,我们得到了属于某个节点的几个图表。然而,由于默认的持续时间是七天,而我们只有大约一个小时的数据,这些图表大部分是空的。将时间范围改为一个小时。图表应该开始变得有意义。
让我们增加一些变化,加入更多的服务器。请点击选定节点的IP/port,选择更多节点。你应该能够看到每个节点的度量数据:

图 9-6:Grafana 仪表板,显示来自选定节点的 Prometheus 度量
虽然这个仪表板在我们想比较选定节点之间的度量时非常有用,但我认为如果我们想专注于单一节点,它的用处就不大了。在这种情况下,Node Exporter Server Stats (grafana.net/dashboards/704)仪表板可能是更好的选择。请按照相同的步骤将其导入到grafana服务中。
你仍然可以更改仪表板中显示的节点(屏幕左上角的 IP)。然而,与其他仪表板不同,这个仪表板每次只显示一个节点。
根据具体情况,这两个仪表板都非常有用。如果我们需要比较多个节点,那么Node Exporter Server Metrics (grafana.net/dashboards/405)可能是更好的选择。另一方面,当我们想集中在一个特定的服务器时,Node Exporter Server Stats (grafana.net/dashboards/704)仪表板可能是更好的选择。你应该返回并导入其余的Node Exporter仪表板并尝试它们。你可能会发现它们比我建议的更有用。
迟早,你会想创建适合自己需求的仪表板。即使是这样,我仍然建议你从导入一个社区制作的仪表板开始,并对其进行修改,而不是从头开始。也就是说,在你更熟悉 Prometheus 和 Grafana 之前,请参考以下图像:

图 9-7:Grafana 仪表板,显示来自 Prometheus 的单一节点度量
我们接下来要创建的仪表板需要来自 Elasticsearch 的日志,因此我们也需要设置日志记录。
我们不会详细讨论日志服务,因为我们在第九章中已经探讨过它们,定义日志策略:
docker service create \
--name elasticsearch \
--network proxy \
--reserve-memory 300m \
-p 9200:9200 \
elasticsearch:2.4
在继续进行LogStash服务之前,我们应该确认elasticsearch正在运行:
docker service ps elasticsearch
service ps命令的输出应该类似于以下内容(为了简洁,已移除 ID 和错误端口列):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
elasticsearch.1 elasticsearch:2.4 swarm-2 Running Running 1 seconds ago
现在我们可以创建一个 logstash 服务:
docker service create \
--name logstash \
--mount "type=bind,source=$PWD/conf,target=/conf" \
--network proxy \
-e LOGSPOUT=ignore \
logstash:2.4 \
logstash -f /conf/logstash.conf
在继续到最后的日志服务之前,让我们确认它正在运行:
docker service ps logstash
service ps 命令的输出应该类似于下面所示(为了简洁,ID 和 ERROR PORTS 列已被移除):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
logstash.1 logstash:2.4 swarm-2 Running Running 2 minutes ago
最后,我们还将创建一个 logspout 服务:
docker service create \
--name logspout \
--network proxy \
--mode global \
--mount "type=bind,source=/var/run/docker.sock,\
target=/var/run/docker.sock" \
-e SYSLOG_FORMAT=rfc3164 \
gliderlabs/logspout \
syslog://logstash:51415
…并确认它正在运行:
docker service ps logspout
service ps 命令的输出应该类似于下面所示(为了简洁,ID 和 ERROR PORTS 列已被移除):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
logspout... gliderlabs/logspout:latest swarm-1 Running Running 9 seconds ago
logspout... gliderlabs/logspout:latest swarm-5 Running Running 9 seconds ago
logspout... gliderlabs/logspout:latest swarm-4 Running Running 9 seconds ago
logspout... gliderlabs/logspout:latest swarm-3 Running Running 9 seconds ago
logspout... gliderlabs/logspout:latest swarm-2 Running Running 10 seconds ago
现在日志功能已经正常运行,我们应该添加 Elasticsearch 作为另一个 Grafana 数据源:
Windows 用户注意事项
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行 docker-machine ip <SERVER_NAME> 来查找机器的 IP 地址,并在您选择的浏览器中直接打开该 URL。例如,下面的命令应替换为以下命令:
docker-machine ip swarm-1
如果输出是 1.2.3.4,您应该在浏览器中打开 http://1.2.3.4:3000。
open "http://$(docker-machine ip swarm-1):3000"
请点击 Grafana 徽标,选择数据源。一个新屏幕将打开,显示当前定义的源(目前只有 Prometheus)。点击 + 添加数据源 按钮。
我们将使用 Elasticsearch 作为名称和类型。Url 应设置为 elasticsearch:9200,索引名称的值应设置为 "logstash-*"。完成后点击添加按钮。
现在我们可以创建或更准确地说是导入我们的第三个仪表板。这次,我们将导入一个主要聚焦于 Swarm 服务的仪表板。
请打开 Docker Swarm 和容器概览的仪表板页面 (grafana.net/dashboards/609),下载并导入到 Grafana 中。在 Grafana 的导入仪表板屏幕中,您将被要求设置一个 Prometheus 数据源和两个 Elasticsearch 数据源。点击保存并打开按钮后,您将看到一个包含与 Docker Swarm 和容器相关的各种指标的仪表板。
您会注意到仪表板中的一些图表为空。这不是错误,而是表示我们的服务尚未准备好被监控。让我们通过添加一些仪表板期望的信息来更新它们。
在 Grafana 中查看 Docker Swarm 和容器概览仪表板
仪表板缺少的一项内容是主机名。如果您选择 Hostnames 列表,您会发现它是空的。原因在于 node-exporter 服务。由于它在容器内运行,因此它无法识别底层主机的名称。
我们已经提到过,node-exporter中的 IP 地址并不是很有价值,因为它们代表的是网络端点的地址。我们真正需要的是“真实”的主机 IP 或主机名。由于我们无法从 Docker 服务中获取真实 IP,因此替代方案是使用主机名。但是,官方的Node Exporter容器并未提供这个功能,因此我们需要采用替代方法。
我们将使用 GitHub 用户bvis创建的镜像替换我们的node-exporter服务。该项目可以在bvis/docker-node-exporter (github.com/bvis/docker-node-exporter) GitHub 仓库中找到。因此,我们将移除node-exporter服务并基于basi/node-exporter (hub.docker.com/r/basi/node-exporter/)镜像创建一个新的服务:
docker service rm node-exporter
docker service create \
--name node-exporter \
--mode global \
--network proxy \
--mount "type=bind,source=/proc,target=/host/proc" \
--mount "type=bind,source=/sys,target=/host/sys" \
--mount "type=bind,source=/,target=/rootfs" \
--mount "type=bind,source=/etc/hostname,target=/etc/\
host_hostname" \
-e HOST_HOSTNAME=/etc/host_hostname \
basi/node-exporter:v0.1.1 \
-collector.procfs /host/proc \
-collector.sysfs /host/proc \
-collector.filesystem.ignored-mount-points \
"^/(sys|proc|dev|host|etc)($|/)" \
-collector.textfile.directory /etc/node-exporter/ \
-collectors.enabled="conntrack,diskstats,\
entropy,filefd,filesystem,loadavg,\
mdadm,meminfo,netdev,netstat,stat,textfile,time,vmstat,ipvs"
除了使用不同的镜像basi/node-exporter,我们还挂载了/etc/hostname目录,容器可以从该目录获取底层主机的名称。我们还添加了环境变量HOST_HOSTNAME以及一些额外的采集器。
我们不详细讲解命令,因为它与我们之前使用的命令类似。附加参数的含义可以在该项目的README文件中找到(github.com/bvis/docker-node-exporter)。
需要注意的是,新的node-exporter服务将包括hostname,以及 Docker 网络创建的虚拟 IP。我们将能够使用这一点来建立它们之间的关系。
我们本可以更新之前运行的服务,而不是创建新的服务。我决定不这么做,这样你就可以看到完整的命令,万一你选择在生产集群中使用节点度量。
请返回已经在浏览器中打开的 Grafana 仪表板并刷新页面Ctrl+R或Cmd+R。你会注意到,一些原本为空的图表现在已经变得五颜六色,展示着来自新node-exporter的度量数据。
Hostnames 列表显示了所有节点,其 IP 位于左侧,主机名位于右侧。现在我们可以选择任何主机的组合,节点的 CPU 使用情况、节点的空闲磁盘、节点的可用内存以及节点的磁盘 I/O 图表将相应更新,如下图所示:

图 9-8:Docker Swarm Grafana 仪表板与节点度量
我们不仅获得了仪表板所需的部分数据,还建立了虚拟 IP 和主机名之间的关系。现在,你将能够找出其他仪表板中使用的虚拟 IP 与主机名之间的关系。特别是,如果你监控 Node Exporter 仪表板并发现应该修复的问题,你可以返回 Swarm 仪表板并找出需要关注的主机。
使用主机名的解决方案仍然不是最好的,但应该是一个不错的变通方法,直到问题 27307(github.com/docker/docker/issues/27307)修复。选择权在你手中。通过能够将虚拟 IP 与主机名关联,我选择坚持使用 Docker 服务,而不是转向非 Swarm 解决方案。
接下来需要修复的是服务组。
如果你打开服务组列表,你会发现它是空的。这背后的原因在于仪表板的配置方式。它期望我们通过容器标签com.docker.stack.namespace来区分服务。由于我们没有指定任何标签,列表中只包含“所有”选项。
我们应该有哪些组?这个问题的答案因用例而异。随着时间的推移,你会定义最适合你组织的组。现在,我们将把我们的服务分成数据库、后台和基础设施三组。我们将把go-demo-db放入db 组,go-demo放入backend,其余的都放入 infra 组。虽然elasticsearch是数据库,但它是我们基础设施服务的一部分,因此我们会将其视为基础设施服务。
我们可以向现有服务添加标签。不需要删除它们并创建新的服务。相反,我们将执行docker service update命令,通过利用--container-label-add参数来添加com.docker.stack.namespace标签。
我们将首先把go-demo_db服务放入一个组中:
docker service update \
--container-label-add \
com.docker.stack.namespace=db \
go-demo_db
让我们确认标签确实已被添加:
docker service inspect go-demo_db \
--format \
"{{.Spec.TaskTemplate.ContainerSpec.Labels}}"
--format参数使我们避免了冗长的输出,仅显示我们关心的内容。
service inspect命令的输出如下:
map[com.docker.stack.namespace:db]
如你所见,com.docker.stack.namespace标签已经添加,并且其值为db。
我们应该对go-demo服务做同样的操作,并将其放入backend组:
docker service update \
--container-label-add \
com.docker.stack.namespace=backend \
go-demo_main
最后一个组是infra。由于应该有不少服务属于它,我们将通过一个命令更新所有服务:
for s in \
proxy_proxy \
logspout \
logstash \
util \
prometheus \
elasticsearch
do
docker service update \
--container-label-add \
com.docker.stack.namespace=infra \
$s
done
我们遍历了所有服务的名称,并对每个服务执行了service update命令。
请注意,service update命令会重新调度副本。这意味着容器会停止并重新启动,采用新的参数。可能需要一些时间才能使所有服务完全运行。请使用docker service ls列出服务,并确认它们都在运行,然后再继续。一旦所有副本启动,我们应该返回 Grafana 仪表板并刷新屏幕(Ctrl+R 或 cmd+R)。
这一次,当你打开服务组列表时,你会注意到我们创建的三个组现在都可以使用了。继续,选择一个或两个组。你会看到与服务相关的图表相应地发生了变化。
我们还可以通过Service Name来过滤结果,并将一些图表中显示的指标限制为选择的一组服务。
如果您向仪表板中部滚动,您会注意到与proxy相关的网络图表中有太多的服务,而不包括proxy的则为空。我们可以通过代理选择器进行更正。它允许我们定义哪些服务应视为proxy。请打开列表并选择proxy。

图 9-10:带有网络流量图的 Grafana 仪表板
与proxy相关的两个网络图现在仅限于proxy服务,或者更具体地说,我们确定的服务。底部现在包含来自所有其他服务的指标。分离监视外部和内部流量非常有用。通过代理图,您可以看到来自和去往外部源的流量,而另外两个则用于服务之间的内部通信。
让我们生成一些流量,并确认更改是否反映在代理图表中。我们将生成一百个请求:
for i in {1..100}
do
curl "$(docker-machine ip swarm-1)/demo/hello"
done
如果您返回proxy网络图,您应该看到流量的增加。请注意,仪表板每分钟刷新数据。如果仍然看不到峰值,请等待,点击屏幕右上角的刷新按钮,或更改刷新频率,请参考以下图片:

图 9-10:带有网络流量图的 Grafana 仪表板
我们将继续前往仪表板菜单中的下一个选项,并点击错误复选框。此复选框连接到 Elasticsearch。由于没有记录的错误,图表保持不变。
让我们生成一些错误,并查看它们在仪表板中的可视化效果。
go-demo服务具有一个 API,允许我们创建随机错误。平均而言,十次请求中大约有一次会产生错误。我们需要它们来演示 Prometheus 指标与 Elasticsearch 数据之间的集成之一。
for i in {1..100}
do
curl "$(docker-machine ip swarm-1)/demo/random-error"
done
输出样本应如下所示:
Everything is still OK
Everything is still OK
ERROR: Something, somewhere, went wrong!
Everything is still OK
Everything is still OK
如果您返回仪表板,您会注意到红色线条代表发生错误的时间点。当出现这种情况时,您可以调查系统指标,并尝试推断错误是由硬件故障、网络饱和或其他原因引起的。如果所有尝试失败,您应该转到 Kibana UI,浏览日志并尝试从中推断原因。请参考以下图片:

图 9-11:带有网络流量图的 Grafana 仪表板
很重要的是,您的系统不要报告虚假的正错误。如果您注意到日志中报告了错误,但没有需要处理的内容,最好更改代码,使得特定情况不被视为错误。否则,出现虚假正错误时,您会开始看到过多的错误,并开始忽略它们。因此,当真正的错误发生时,您可能不会注意到。
我们将跳过警报触发和警报解决选项,因为它们与X-Pack(www.elastic.co/products/x-pack)相关,这是一个商业产品。由于本书面向开源解决方案,我们将跳过它。这并不意味着您不应考虑购买它。恰恰相反,在某些情况下,X-Pack是工具集的宝贵补充。
这结束了我们对 Docker Swarm & 容器概述仪表板选项的快速探索。图表本身应该是不言自明的。请花点时间自行探索一下。
通过仪表盘指标调整服务
我们的服务是不静态的。Swarm 会在每次发布时、复制品失败时、节点变得不健康时或由于其他种种原因重新调度它们。我们应尽力为 Swarm 提供尽可能多的信息。我们描述所需服务状态得越详细,Swarm 的工作就会越好。
我们不会详细介绍通过docker service create和docker service update命令提供的所有信息。相反,我们将专注于--reserve-memory参数。稍后,您可以将类似的逻辑应用于--reserve-cpu、--limit-cpu、--limit-memory和其他参数。
我们将在 Grafana 中观察内存指标并相应地更新我们的服务。
请在 Grafana 中的每个容器内存使用(堆叠)图表上点击,并选择查看。您将看到一个显示前二十个容器内存消耗的缩放图的屏幕。让我们通过选择服务名称列表中的 prometheus 来过滤指标。
Prometheus 大约使用 175 MB 内存。让我们将该信息添加到服务中:

图 9-12:带有通过 Prometheus 服务过滤的容器内存消耗的 Grafana 图表
docker service update \
--reserve-memory 200m \
prometheus
我们通过保留200m内存来更新prometheus服务。我们可以假设随着时间的推移,其内存使用量会增加,因此我们保留了比当前需求稍多一些的内存。
请注意,--reserve-memory并不真正保留内存,而是向 Swarm 提供我们希望服务使用多少内存的提示。有了这些信息,Swarm 将更好地在集群内部分布服务。
让我们确认 Swarm 是否重新调度了服务:
docker service ps prometheus
service ps命令的输出如下(为简洁起见,已删除 ID 和 Error 列):
NAME IMAGE NODE DESIRED STATE
prometheus.1 prom/prometheus:v1.2.1 swarm-3 Running
_ prometheus.1 prom/prometheus:v1.2.1 swarm-1 Shutdown
_ prometheus.1 prom/prometheus:v1.2.1 swarm-5 Shutdown
-------------------------------------------------
CURRENT STATE
Running 5 minutes ago
Shutdown 6 minutes ago
Shutdown 5 hours ago
我们还可以确认--reserve-memory参数确实被应用了:
docker service inspect prometheus --pretty
输出如下:
ID: 6yez6se1oejvfhkvyuqg0ljfy
Name: prometheus
Mode: Replicated
Replicas: 1
Update status:
State: completed
Started: 10 minutes ago
Completed: 9 minutes ago
Message: update completed
Placement:
UpdateConfig:
Parallelism: 1
On failure: pause
ContainerSpec:
Image: prom/prometheus:v1.2.1
Mounts:
Target = /etc/prometheus/prometheus.yml
Source = /Users/vfarcic/IdeaProjects/cloud-provisioning/conf/prometheus.yml
ReadOnly = false
Type = bind
Target = /prometheus
Source = /Users/vfarcic/IdeaProjects/cloud-provisioning/docker/prometheus
ReadOnly = false
Type = bind
Resources:
Reservations:
Memory: 200 MiB
Networks: 51rht5mtx58tg5gxdzo2rzirw
Ports:
Protocol = tcp
TargetPort = 9090
PublishedPort = 9090
如您从Resources部分观察到的那样,该服务现在已保留了200 MiB内存。我们应该为logstash、go-demo、go-demo-db、elasticsearch和proxy服务重复类似的操作。
在您的笔记本电脑上可能会有不同的结果。在我这里,基于 Grafana 指标保留内存的命令如下:
docker service update \
--reserve-memory 250m logstash
docker service update \
--reserve-memory 10m go-demo_main
docker service update \
--reserve-memory 100m go-demo_db
docker service update \
--reserve-memory 300m elasticsearch
docker service update \
--reserve-memory 10m proxy_proxy
每次更新后,Swarm 将重新调度属于服务的容器。因此,它会将它们放入集群中,使得没有一个容器会因为内存消耗而过载。你应该将过程扩展到 CPU 和其他指标,并定期重复此过程。
请注意,增加内存和 CPU 限制及预留并不总是正确的做法。在许多情况下,你可能希望通过扩展服务,使资源的利用在多个副本之间分配。
在本章中,我们使用了现成的仪表盘。我认为它们是一个很好的起点,提供了良好的学习体验。随着时间的推移,你会发现什么最适合你的组织,并可能开始修改这些仪表盘或创建特别为你需求量身定制的新仪表盘。希望你能将它们回馈给社区。
如果你创建了一个补充或替代本章中使用的仪表盘,请告诉我。我很乐意将它们展示在书中。
在我们进入下一章之前,让我们讨论一些监控最佳实践。
监控最佳实践
你可能会被诱惑将尽可能多的信息放入一个仪表盘中。有那么多指标,为什么不把它们可视化出来呢?对吧?错了!数据过多会使得重要信息难以找到。它让我们忽略我们看到的内容,因为太多信息是噪音。
你真正需要的是快速浏览中央仪表盘,瞬间判断是否有任何可能需要你关注的事项。如果有需要修复或调整的地方,你可以使用更专业的 Grafana 仪表盘或在 Prometheus 中使用临时查询来深入查看细节。
创建中央仪表盘时,只需提供足够的信息以适应屏幕,并提供系统的良好概览。接下来,创建包含更多详细信息的附加仪表盘。它们应该像我们组织代码一样进行组织。通常,主函数是进入更具体类的入口点。当我们开始编写代码时,我们通常会打开主函数并从中深入,直到找到一段值得我们关注的代码。仪表盘也应该类似。我们从一个提供关键和通用信息的仪表盘开始。这样的仪表盘应该是我们的主页,并提供足够的指标来判断是否有理由进入更具体的仪表盘。
单个仪表盘的图表数量不应超过六个。通常,这是一个适合单个屏幕的大小。你不应该在中央仪表盘中上下滚动才能查看所有图表。所有重要或关键的内容应该是可见的。
每个图表应限制在不超过六个数据点的范围内。在许多情况下,超过这个数量只会产生难以解读的噪音。
确实允许不同团队有不同的仪表盘,特别是那些被视为主要或核心的仪表盘。试图创建一个适应所有人需求的仪表盘是一个不好的做法。每个团队有不同的优先事项,应该通过不同的指标可视化来满足这些需求。
我们在本章中使用的仪表盘符合这些规则吗?它们不符合。它们有太多图表和图形。这就引出了一个问题:我们为什么要使用它们?答案很简单。我想展示给你一种快速而简易的方式,让你在短时间内搭建一个监控系统。我也想尽可能多地展示不同的图表,而不会让你的大脑过载。自己看看哪些图表没有提供价值,并把它们去除。保留那些真正有用的图表,并修改那些只提供部分价值的图表。创建你自己的仪表盘,看看什么最适合你。
现在该怎么办?
将监控系统付诸实践。不要试图从一开始就做到完美。如果你这么做,你会失败的。迭代仪表盘,先从小做起,随着时间的推移逐步发展。如果你是一个大型组织,让每个团队创建自己的仪表盘,并分享哪些有效,哪些未能提供足够的价值。
监控并不是一件简单的事情,除非你愿意把所有时间都花在仪表盘前。解决方案应该设计成只需一眼就能发现系统的某个部分是否需要你的关注。
现在让我们摧毁我们所做的一切。下一章将是一个全新的主题,带来一套新的挑战和解决方案:
docker-machine rm -f swarm-1 \
swarm-2 swarm-3 swarm-4 swarm-5
第十一章:拥抱破坏:宠物与牲畜
任何在项目中没有直接促进目标、未能尽快将有价值的软件交到用户手中的角色,都应该被仔细考虑。
-斯坦·英格·莫里斯巴克
在我们开始探索那些能够帮助我们创建和运营“真正的”Swarm 集群的工具和流程之前,应该先讨论一下高层次的策略。我们应该如何对待我们的服务器?它们是宠物,还是牲畜?
如何知道你是否在将服务器视为宠物或牲畜?问问自己以下问题:如果现在你的几台服务器掉线,会发生什么?如果它们是宠物,这种情况将会对用户造成严重的影响。如果它们是牲畜,这种结果将不会引起任何注意。由于你运行的是分布在多个节点上的多个服务实例,单台服务器(或几台服务器)的故障并不会导致所有副本的失败。唯一的即时效果是一些服务将运行较少的实例,负载会更高。失败的副本会被重新调度,原始的副本数量很快会恢复。同时,失败的节点会被新虚拟机替代。几台服务器的故障唯一的负面影响是由于容量减少而导致的响应时间增加。几分钟后,所有失败的副本会被重新调度,失败的节点会被新虚拟机替换,一切都会恢复正常。最棒的是,这一切都将无需人工干预。如果你的集群现在就是这样运行的,那么你把服务器当作了牲畜。否则,你的数据中心里有宠物。
传统的系统管理是基于物理服务器的。要向数据中心添加一台新机器,我们需要先购买它,等它从供应商处到货,再在办公室进行配置,然后将它搬到数据中心,最后连接好。这整个过程可能需要相当长的时间。通常需要几周,甚至几个月,才能让一台新的、配置完全的服务器在数据中心内部运行起来。
考虑到如此漫长的等待期和成本,保持服务器尽可能健康是自然而然的事。如果其中一台开始出现故障,我们会竭尽所能尽快修复它。我们还能做什么呢?等待几周或几个月直到替代品到货?当然不能。SSH 登录故障机器,找出问题并修复它。如果某个进程死掉了,就重新启动它。如果硬盘坏了,就更换它。如果服务器过载,就增加内存。
在这种情况下,我们对每一台服务器产生情感依赖是很自然的。它从一个名字开始。每台新服务器都会得到一个名字。有 Garfield,有 Mordor,有 Spiderman,还有 Sabrina。我们甚至可能会决定一个主题。也许我们的所有服务器都将以漫画书超级英雄命名。或者你更喜欢神话生物?怎么,叫它们前男友前女友怎么样?一旦我们为服务器命名,我们就开始将它当作宠物来对待。你怎么了?需要什么吗?怎么回事?我应该带你去看兽医吗?每一台宠物服务器都是独一无二的,精心养育和照料的。
变革始于虚拟化。创建和销毁虚拟机的能力使我们能够采用不同的计算方式。虚拟化让我们不再将服务器视为宠物。如果虚拟化服务器可以随意创建和销毁,那么为它们命名就没有意义了。由于它们的生命周期可能非常短暂,所以没有情感上的依赖。现在,我们不再有Garfield,而是有vm262.ecme.com。明天,当我们尝试登录时,可能会发现它已被vm435.ecme.com替代。
随着虚拟化的发展,我们开始将服务器当作牲畜来对待。它们没有名字,只有编号。我们不再单独处理它们,而是将它们视作一群。如果有一只生病了,我们会将其杀掉。治疗它既缓慢又有可能感染其他的牲畜。如果一台服务器开始出现问题,立刻终止它,并用新的服务器替换。
这种方法的问题在于,我们在与物理硬件打交道的多年经验中积累的习惯。从宠物到牲畜的转变需要一种心理上的变化。它要求我们在转向新的工作方式之前,先要抛弃过时的做法。
尽管本地虚拟化打开了许多新的可能性,但许多人仍然以对待物理节点的方式来对待虚拟化服务器。旧习惯难以改变。即使我们的服务器变成了一群牲畜,我们仍然将每台服务器当作宠物来对待。转向更具弹性和动态计算的困难部分原因在于我们数据中心的物理限制。只有在有可用资源的情况下,才可以创建新的虚拟机。一旦达到限制,就必须销毁一台虚拟机以创建新的虚拟机。我们的物理服务器仍然是宝贵的资源。虚拟机为我们提供了弹性,但这种弹性依然受到我们所拥有的计算能力总量的限制。
我们会小心地对待我们的贵重物品,因为它们不便宜,也不容易替换。我们会好好保管它们,因为它们应该能使用很长时间。另一方面,我们对于那些便宜且易于替换的物品有完全不同的处理方式。如果一只玻璃杯破了,你大概不会试图把碎片粘起来。你会把它丢进垃圾桶。橱柜里有很多其他的玻璃杯,当它们数量减少到一定程度时,我们只需要下次去购物中心时买一套餐具。如今,我们甚至不需要去购物中心,可以在线订购一套餐具,通常当天就能送到家门口。我们应该把同样的逻辑应用到服务器上。
云计算带来了巨大的变化。服务器不再是宝贵的财富,而是商品。我们可以随时替换一个节点而无需额外费用。我们可以在几分钟内向集群中添加十几台服务器。当我们不再需要它们时,可以将其移除,减少成本。
云计算与“传统”数据中心根本不同。当云计算充分发挥其潜力时,服务器不再是不可或缺的或独一无二的。我们能做的最糟糕的事情,就是在没有改变我们的流程和架构的情况下迁移到云端。如果我们只是将本地服务器迁移到云端,而不改变我们用来维护它们的流程,唯一能达成的就是更高的成本。
随着云计算的普及,服务器的概念、价值和获取所需的时间发生了巨大变化。如此重大的变化需要一套新的流程和工具来执行它们。容错是目标,速度是关键,自动化是必须的。
现在怎么办?
到目前为止,我们使用 Docker Machine 本地创建服务器并将其加入集群。目的是教你如何创建和操作一个 Swarm 集群,而无需为托管提供商支付费用。现在你已经掌握了 Docker Swarm 模式的工作原理,接下来是时候转向“真正”的服务器了。我们仍然会保持“便宜”的做法,使用免费的或非常便宜的小型实例,只创建足够多的服务器来展示这个过程。接下来的章节将带领你完成几个设置,比较它们,选择一个最终应用到我们的生产环境。你应该更改的仅仅是虚拟机实例类型和服务器数量,其他所有内容可以与我们所使用的示例保持一致。
我们已经看到了如何通过使用 Docker Swarm 作为服务调度器来实现容错。接下来的章节将尝试在基础设施层面实现所需的速度和自动化。我们将使用不同的工具和流程,自动化地在几个云计算提供商的环境中创建集群。排在第一位的是亚马逊 Web 服务 (AWS)。
结构:宠物与牲畜
第十二章:在 Amazon Web Services 中创建和管理 Docker Swarm 集群
在快速发展的市场中,适应性远比优化更为重要。
– 拉里·康斯坦丁
终于到了设置一个更接近生产环境的 Swarm 集群的时刻。我在这里加上“更接近”一词,是因为有一些话题我们将在后续章节中探讨。稍后,一旦我们走过几个托管服务商的选项,我们将处理那些缺失的部分(例如:持久存储)。
现在,我们将限制自己创建一个类似生产环境的集群,并探索我们可以选择的不同工具。
由于 AWS 占据了主机市场的最大份额,它是我们将要探索的第一个自然选择的提供商。
我相信 AWS 不需要太多介绍。即使你没有使用过,我相信你也知道它的存在及其大致功能。
Amazon Web Services (AWS) 于 2006 年创建,提供 IT 基础设施服务。AWS 提供的服务类型后来被广泛称为云计算。有了云计算,企业和个人不再需要提前几周或几个月规划和采购服务器及其他 IT 基础设施。相反,他们可以在几分钟内立即启动成百上千台服务器。
我假设你已经拥有一个 AWS 账号。如果不是这种情况,请前往 Amazon Web Services 并注册。即便你已经决定使用其他云计算提供商或内部服务器,我仍然强烈建议你阅读这一章节。你将接触到一些你可能未曾掌握的工具,并能够将 AWS 与其他解决方案进行对比。
在进入实践操作之前,我们需要安装 AWS CLI,获取访问密钥并决定我们将运行集群的区域和可用区。
安装 AWS CLI 并设置环境变量
我们首先应该做的是获取 AWS 凭证。
请打开Amazon EC2 Console (console.aws.amazon.com/ec2/),点击右上角菜单中的你的名字,选择“我的安全凭证”。你将看到包含不同类型凭证的页面。展开“访问密钥”(Access Key ID和Secret Access Key)部分,然后点击“创建新访问密钥”按钮。展开“显示访问密钥”部分即可查看密钥。
你将无法在之后查看这些密钥,因此这是唯一一次可以下载密钥文件的机会。
本章节中的所有命令都可以在11-aws.sh (gist.github.com/vfarcic/03931d011324431f211c4523941979f8) Gist 中找到。
我们将把密钥作为环境变量,这些密钥将被本章中探索的工具所使用:
export AWS_ACCESS_KEY_ID=[...]
export AWS_SECRET_ACCESS_KEY=[...]
请将[...]替换为实际的值。
我们需要安装 AWS 命令行界面(CLI)(aws.amazon.com/cli/)并收集你的账户信息。
给 Windows 用户的提示
我发现安装 awscli 在 Windows 上最便捷的方式是使用Chocolatey(chocolatey.org/)。下载并安装 Chocolatey,然后在管理员命令提示符下运行choco install awscli。在本章后面,Chocolatey 将被用于安装 jq、packer和 terraform。
如果你还没有,请打开安装 AWS 命令行界面(docs.aws.amazon.com/cli/latest/userguide/installing.html)页面,并按照适合你操作系统的安装方法进行安装。
完成后,我们应该通过输出版本来确认安装是否成功:
aws --version
输出(来自我的笔记本电脑)如下:
aws-cli/1.11.15 Python/2.7.10 Darwin/16.0.0 botocore/1.4.72
给 Windows 用户的提示
你可能需要重新打开GitBash终端,以使环境变量path的更改生效。
现在命令行界面已经安装完成,我们可以获取集群将运行的区域和可用区。
Amazon EC2 在全球多个地点托管。这些地点由区域和可用区组成。每个区域是一个独立的地理区域,包含多个隔离的地点,这些地点被称为可用区。Amazon EC2 让你能够将资源(如实例)和数据放置在多个地点。
你可以在可用区域(docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions)部分查看当前可用的区域,页面位于区域和可用区(docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html)。
在本章中,我将使用us-east-1(美国东部(北弗吉尼亚))区域。你可以根据需要更改为离你位置最近的区域。
请将区域放入环境变量AWS_DEFAULT_REGION中:
export AWS_DEFAULT_REGION=us-east-1
选择好区域后,我们可以决定选择哪个可用区来运行我们的集群。
每个区域都是完全独立的,并由多个可用区组成。区域内的可用区是隔离的,并通过低延迟的连接相连。
一般来说,你应该将集群的所有节点放在同一个区域内,以便享受低延迟连接。节点应该分布在多个可用区内,以确保一个区域的故障不会导致整个集群故障。如果你需要跨多个区域操作,最佳选择是设置多个集群(每个区域一个)。否则,如果设置一个跨多个区域的单一集群,可能会遇到延迟问题。
第一次使用 AWS 的注意事项
如果这是你第一次执行 aws,你将收到一条消息,要求你配置凭证。请运行 aws configure 命令并按照提示操作。你将被要求输入凭证,使用我们之前生成的凭证。对于其余问题,可以直接按回车键跳过。
让我们使用 AWS CLI 查看所选区域中的可用区:
aws ec2 describe-availability-zones \
--region $AWS_DEFAULT_REGION
由于我选择了 us-east-1 作为区域,输出如下:
{
"AvailabilityZones": [
{
"State": "available",
"RegionName": "us-east-1",
"Messages": [],
"ZoneName": "us-east-1a"
},
{
"State": "available",
"RegionName": "us-east-1",
"Messages": [],
"ZoneName": "us-east-1b"
},
{
"State": "available",
"RegionName": "us-east-1",
"Messages": [],
"ZoneName": "us-east-1d"
}, {
"State": "available",
"RegionName": "us-east-1",
"Messages": [],
"ZoneName": "us-east-1e"
}
]
}
如你所见,us-east-1 区域提供了四个可用的可用区(a、b、d 和 e)。根据你选择的区域,输出可能会有所不同。
请选择可用区,并将它们放入环境变量中,每个变量对应集群中的五个服务器:
AWS_ZONE[1]=b
AWS_ZONE[2]=d
AWS_ZONE[3]=e
AWS_ZONE[4]=b
AWS_ZONE[5]=d
随意选择任何可用区的组合。在我的案例中,我决定将集群分布在 b、d 和 e 区域。
现在,我们已经完成了创建 AWS 中第一个 Swarm 集群的前提准备。由于我们在本书的大部分时间里都使用了 Docker Machine,它将是我们的首选工具。
使用 Docker Machine 和 AWS CLI 设置 Swarm 集群
我们将继续使用 vfarcic/cloud-provisioning (github.com/vfarcic/cloud-provisioning) 仓库。它包含了一些配置和脚本,可以帮助我们完成操作。你应该已经将其克隆到本地。为了安全起见,我们将拉取最新版本:
cd cloud-provisioning
git pull
让我们创建第一个 EC2 实例:
docker-machine create \
--driver amazonec2 \
--amazonec2-zone ${AWS_ZONE[1]} \
--amazonec2-tags "Type,manager" \
swarm-1
我们指定 Docker Machine 使用 amazonec2 驱动程序在我们定义的区域变量 AWS_ZONE_1 中创建一个实例。
我们为键 type 和值 manager 创建了一个标签。标签主要用于信息性目的。
最后,我们指定实例的名称为 swarm-1。
输出如下:
Running pre-create checks...
Creating machine...
(swarm-1) Launching instance...
Waiting for machine to be running, this may take a few minutes...
Detecting operating system of created instance...
Waiting for SSH to be available...
Detecting the provisioner...
Provisioning with ubuntu(systemd)...
Installing Docker...
Copying certs to the local machine directory...
Copying certs to the remote machine...
Setting Docker configuration on the remote daemon...
Checking connection to Docker...
Docker is up and running!
To see how to connect your Docker Client to the Docker Engine running on this\ virtual machine, run: docker-machine env swarm-1
Docker Machine 启动了一个 AWS EC2 实例,使用 Ubuntu 进行配置,并安装和配置了 Docker Engine。
现在我们可以初始化集群了。我们应该使用私有 IP 来进行节点间的所有通信。不幸的是,docker-machine ip 命令只返回公共 IP,因此我们需要使用其他方法来获取私有 IP。
我们可以使用aws ec2 describe-instances命令来检索所有 EC2 实例的信息。我们还通过添加Name=instance-state-name,Values=running来筛选仅显示正在运行的实例。这样可以排除正在终止或已终止的实例:
aws ec2 describe-instances \
--filter "Name=tag:Name,Values=swarm-1" \
"Name=instance-state-name,Values=running"
describe-instances 命令列出了所有 EC2 实例。我们将其与--filter结合,限制输出仅显示标记为swarm-1的实例。
输出的相关示例如下:
{
"Reservations": [
{
...
"Instances": [
{
...
"PrivateIpAddress": "172.31.51.25",
...
即使我们获取了与swarm-1 EC2 实例相关的所有信息,我们仍然需要将输出限制为PrivateIpAddress值。我们将使用jq(stedolan.github.io/jq/)来筛选输出并获取所需内容。请下载并安装适合您操作系统的发行版:
Windows 用户注意事项
使用 Chocolatey,在管理员命令提示符中通过choco install jq安装jq。
MANAGER_IP=$(aws ec2 describe-instances \
--filter "Name=tag:Name,Values=swarm-1" \
"Name=instance-state-name,Values=running" \
| jq -r ".Reservations[0].Instances[0].PrivateIpAddress")
我们使用jq检索了 Reservations 数组的第一个元素。在该元素中,我们获取了 Instances 的第一个条目,然后是PrivateIpAddress。-r选项返回其原始格式的值(此处为没有双引号的 IP)。命令的结果被存储在环境变量MANAGER_IP中。
为了安全起见,我们可以回显新创建的变量值:
echo $MANAGER_IP
输出如下:
172.31.51.25
现在我们可以像之前章节中一样执行swarm init命令:
eval $(docker-machine env swarm-1)
docker swarm init \
--advertise-addr $MANAGER_IP
让我们确认集群确实已初始化:
docker node ls
输出如下(为了简洁,已去除 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-1 Ready Active Leader
除了创建 EC2 实例,docker-machine 还创建了一个安全组。
安全组充当虚拟防火墙,控制流量。当您启动实例时,您将一个或多个安全组与实例关联。您可以向每个安全组添加规则,允许流量进出其关联的实例。
在撰写本文时,Docker Machine 尚未适配支持 Swarm 模式。因此,它创建了一个名为docker-machine的 AWS 安全组,仅开放了入口(入站)端口22和2376,所有端口的出口(出站)均已开放。
为了使 Swarm 模式正常工作,应该打开的入口端口如下:
-
用于集群管理通信的 TCP 端口 2377
-
用于节点之间通信的 TCP 和 UDP 端口 7946
-
用于覆盖网络流量的 TCP 和 UDP 端口 4789
要修改安全组,我们需要获取其 ID。我们可以使用aws ec2 describe-security-groups命令查看安全组的详细信息:
aws ec2 describe-security-groups \
--filter "Name=group-name,Values=docker-machine"
输出的一部分如下:
...
"GroupName": "docker-machine",
"VpcId": "vpc-7bbc391c",
"OwnerId": "036548781187",
"GroupId": "sg-f57bf388"
}
]
}
将 ID 分配给SECURITY_GROUP_ID环境变量的命令如下:
SECURITY_GROUP_ID=$(aws ec2 \
describe-security-groups \
--filter \
"Name=group-name,Values=docker-machine" |\
jq -r '.SecurityGroups[0].GroupId')
我们请求了关于安全组docker-machine的信息,并筛选了 JSON 输出以获取位于SecurityGroups数组第一个元素中的GroupId键。
现在我们可以使用aws ec2 authorize-security-group-ingress命令来打开 TCP 端口2377、7946和4789:
for p in 2377 7946 4789; do \
aws ec2 authorize-security-group-ingress \
--group-id $SECURITY_GROUP_ID \
--protocol tcp \
--port $p \
--source-group $SECURITY_GROUP_ID
done
我们应该执行一个类似的命令来打开 UDP 端口7946和4789:
for p in 7946 4789; do \
aws ec2 authorize-security-group-ingress \
--group-id $SECURITY_GROUP_ID \
--protocol udp \
--port $p \
--source-group $SECURITY_GROUP_ID
done
请注意,在所有情况下,我们指定了source-group应该与安全组相同。这意味着端口将仅对属于相同组的实例开放。换句话说,这些端口对公众不可用。由于它们仅用于集群内部的通信,因此没有必要通过进一步暴露这些端口来危及我们的安全。
请重复执行aws ec2 describe-security-groups命令,确认端口确实已经打开:
aws ec2 describe-security-groups \
--filter \
"Name=group-name,Values=docker-machine"
现在我们可以向集群中添加更多节点。我们将通过创建两个新的实例并将其作为管理节点加入集群来开始:
MANAGER_TOKEN=$(docker swarm join-token -q manager)
for i in 2 3; do
docker-machine create \
--driver amazonec2 \
--amazonec2-zone ${AWS_ZONE[$i]} \
--amazonec2-tags "Type,manager" \
swarm-$i
IP=$(aws ec2 describe-instances \
--filter "Name=tag:Name,Values=swarm-$i" \
"Name=instance-state-name,Values=running" \
| jq -r ".Reservations[0].Instances[0].PrivateIpAddress")
eval $(docker-machine env swarm-$i)
docker swarm join \
--token $MANAGER_TOKEN \
--advertise-addr $IP \
$MANAGER_IP:2377
done
由于我们刚才执行的命令是我们之前使用过命令的组合,因此无需再解释。
我们还将添加一些工作节点:
WORKER_TOKEN=$(docker swarm join-token -q worker)
for i in 4 5; do
docker-machine create \
--driver amazonec2 \
--amazonec2-zone ${AWS_ZONE[$i]} \
--amazonec2-tags "type,worker" \
swarm-$i
IP=$(aws ec2 describe-instances \
--filter "Name=tag:Name,Values=swarm-$i" \
"Name=instance-state-name,Values=running" \
| jq -r ".Reservations[0].Instances[0].PrivateIpAddress")
eval $(docker-machine env swarm-$i)
docker swarm join \
--token $WORKER_TOKEN \
--advertise-addr $IP \
$MANAGER_IP:2377
done
让我们确认所有五个节点确实形成了集群:
eval $(docker-machine env swarm-1)
docker node ls
输出如下(为了简洁,ID 已移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-4 Ready Active
swarm-2 Ready Active Reachable
swarm-3 Ready Active Reachable
swarm-5 Ready Active
swarm-1 Ready Active Leader
就这样。我们的集群已经准备好了。剩下的唯一事情就是部署一些服务,并确认集群按预期运行。
由于我们已经创建了很多次服务,我们将通过vfarcic/docker-flow-proxy/docker-compose-stack.yml (github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)和vfarcic/go-demo/docker-compose-stack.yml (github.com/vfarcic/go-demo/blob/master/docker-compose-stack.yml) Compose 堆栈来加速过程。它们将创建proxy、swarm-listener、go-demo-db和go-demo服务:
docker-machine ssh swarm-1
sudo docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
sudo docker stack deploy \
-c proxy-stack.yml proxy
curl -o go-demo-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/go-demo/master/docker-compose-stack.yml
sudo docker stack deploy \
-c go-demo-stack.yml go-demo
exit
docker service ls
非 Windows 用户无需进入swarm-1机器,可以直接从他们的笔记本电脑部署堆栈来实现相同的结果。
下载所有镜像需要一些时间。过了一会儿,service ls命令的输出应该如下所示(为了简洁,ID 已移除):
NAME MODE REPLICA IMAGE
go-demo_db replicated 1/1 mongo:latest
go-demo_main replicated 3/3 vfarcic/go-demo:latest
proxy_swarm-listener replicated 1/1 vfarcic/docker-flow-swarmlistener:latest
proxy_proxy replicated 2/2 vfarcic/docker-flow-proxy:latest
让我们确认go-demo服务是可以访问的:
curl "$(docker-machine ip swarm-1)/demo/hello"
输出如下:
curl: (7) Failed to connect to 54.157.196.113 port 80: Operation timed out
这真尴尬。尽管所有服务都在运行,并且我们使用了与前几章相同的命令,但我们无法访问proxy,也无法通过它访问go-demo服务。
解释很简单。我们从未打开过80和443端口。默认情况下,所有进入 AWS EC2 实例的流量都是关闭的,我们只打开了 Swarm 正常运行所需的端口。这些端口在附加到docker-machine security组的 EC2 实例内部是开放的,但在我们的 AWS VPC 外部是关闭的。
我们将使用aws ec2 authorize-security-group-ingress命令来打开80和443端口。这次我们将指定cidr而不是source-group作为源:
for p in 80 443; do
aws ec2 authorize-security-group-ingress \
--group-id $SECURITY_GROUP_ID \
--protocol tcp \
--port $p \
--cidr "0.0.0.0/0"
done
aws ec2 authorize-security-group-ingress命令执行了两次;一次是为端口80,第二次是为端口443。
让我们再发送一次请求:
curl "$(docker-machine ip swarm-1)/demo/hello"
这次的输出符合预期。我们得到了响应:
hello, world!
我们使用 Docker Machine 和 AWS CLI 在 AWS 中设置了整个 Swarm 集群。这是我们需要的全部吗?这取决于我们为集群定义的要求。我们可能还需要添加一些弹性 IP 地址。
弹性 IP 地址是一种为动态云计算设计的静态 IP 地址。它与您的 AWS 账户相关联。通过弹性 IP 地址,您可以快速将地址重新映射到您账户中的另一个实例,从而掩盖实例或软件的故障。弹性 IP 地址是一个公共 IP 地址,可以从互联网访问。如果您的实例没有公共 IP 地址,您可以将弹性 IP 地址与其关联,以便与互联网通信;例如,从本地计算机连接到您的实例。
换句话说,我们可能应该至少设置两个弹性 IP 地址,并将它们映射到集群中的两个 EC2 实例。这两个(或更多)IP 地址将作为我们的 DNS 记录。这样,当一个实例发生故障,并且我们用一个新实例替换时,我们可以重新映射弹性 IP 而不影响用户。
我们还可以做一些其他改进。然而,这将使我们陷入一个尴尬的境地。我们将使用一个并非为设置复杂集群而设计的工具。
创建虚拟机(VM)的过程相当慢。Docker Machine 花费了太多时间为其配置 Ubuntu 并安装 Docker Engine。我们可以通过创建一个预装 Docker Engine 的Amazon 机器镜像(AMI)来减少这个时间。然而,采取这样的行动会使得使用 Docker Machine 的主要原因消失。它的主要优势是简单性。一旦我们开始将设置复杂化,加入其他 AWS 资源,我们就会发现简单性被过多的临时命令取代了。
当我们处理一个小型集群时,运行docker-machine和aws命令效果很好,特别是当我们想快速创建一些东西,而且这些东西可能并不非常持久。最大的问题是,到目前为止我们所做的一切都是临时命令。很可能我们无法在第二次重复相同的步骤。我们的基础设施没有文档化,所以我们的团队不知道我们的集群由什么组成。
我的建议是使用docker-machine和aws作为一种快速且简单的方式来创建集群,主要用于演示目的。只要集群相对较小,它对生产环境也能有用。
如果我们想要设置一个复杂、更大且可能更持久的解决方案,我们应该考虑其他方案。
让我们删除我们创建的集群,并以一个全新的状态探索其他方案:
for i in 1 2 3 4 5; do
docker-machine rm -f swarm-$i
done
唯一剩下的就是删除docker-machine创建的安全组:
aws ec2 delete-security-group \
--group-id $SECURITY_GROUP_ID
如果实例尚未终止,最后一条命令可能会失败。如果是这种情况,请稍等片刻并重新执行命令。
让我们继续探索Docker for AWS。
使用 Docker for AWS 设置 Swarm 集群
在我们使用Docker for AWS创建 Swarm 集群之前,我们需要生成一个将用于 SSH 连接到 EC2 实例的密钥对。
要创建一个新的key-pair,请执行以下命令:
aws ec2 create-key-pair \
--key-name devops21 \
| jq -r '.KeyMaterial' >devops21.pem
我们执行了aws ec2 create-key-pair命令,并将devops21作为名称传入。输出通过jq过滤,以确保只返回实际的值。最后,我们将输出内容发送到devops21.pem文件。
如果有人拿到你的密钥文件,你的实例将会暴露。因此,我们应该把密钥移到一个安全的地方。
在 Linux/OSX 系统上,SSH 密钥的常见位置是$HOME/.ssh。如果你是 Windows 用户,可以根据需要更改下面的命令,将其指向你认为合适的位置:
mv devops21.pem $HOME/.ssh/devops21.pem
我们还应该更改权限,只为当前用户提供读取访问权限,并移除其他用户或组的所有权限。如果您是 Windows 用户,请随意跳过下面的命令:
chmod 400 $HOME/.ssh/devops21.pem
最后,我们将把密钥的路径放入环境变量KEY_PATH中:
export KEY_PATH=$HOME/.ssh/devops21.pem
现在我们已经准备好使用Docker for AWS创建 Swarm 堆栈了。
请打开Docker for AWS 发布说明(docs.docker.com/docker-for-aws/release-notes/)页面,并点击“为 AWS 部署 Docker 社区版”按钮。
登录到AWS 控制台后,您将看到“选择模板”页面。这是一个通用的 CloudFormation 页面,Docker for AWS 模板已被选中:

图 11-1:Docker For AWS 选择模板屏幕
这里不需要做太多操作,所以请点击“下一步”按钮。
下一屏幕允许我们指定即将启动的堆栈的详细信息。各个字段应该是自解释的。我们唯一的修改是将 Swarm 工作节点的数量从5减少到1。本节中的练习不需要超过四台服务器,因此三个管理节点和一个工作节点应该足够了。我们将实例类型保持默认值t2.micro。通过仅创建四个微型节点,整个练习的成本几乎可以忽略不计,而且你不会因为我而破产,甚至可以告诉朋友们这个费用连你喝的那罐可乐或咖啡的钱都不值。
“使用哪个 SSH 密钥?”字段应填入我们刚刚创建的devops21密钥。请选择它:

图 11-2:Docker For AWS 指定详细信息屏幕
点击“下一步”按钮。
我们不会更改 Options 屏幕中的任何内容。稍后,当你熟悉Docker for AWS时,你可能会想回到这个屏幕,调整一些额外选项。现在,我们暂时忽略它的存在:

图 11-3:Docker For AWS 选项屏幕
点击下一步按钮。
我们已经到达最后一个屏幕。请随意检查堆栈信息。完成后,点击“我确认 AWS CloudFormation 可能会创建 IAM 资源”. 复选框,然后点击创建按钮:

图 11-4:Docker For AWS 审核屏幕
你将看到一个允许你创建新堆栈的界面。请点击右上角的刷新按钮。你将看到状态为CREATE_IN_PROGRESS的Docker堆栈。
创建所有资源将需要一段时间。如果你想查看进度,请选择Docker堆栈并点击位于屏幕右下角的恢复按钮。你将看到由Docker for AWS模板生成的所有事件列表。你可以在等待堆栈创建完成时,随意查看各个选项卡的内容:

图 11-5:Docker For AWS 堆栈状态屏幕
一旦Docker堆栈的状态为CREATE_COMPLETE,我们就可以继续。
我们的集群已经准备好。我们可以进入其中一个管理节点,详细探索集群。
要查找 Swarm 管理节点的信息,请点击 Outputs 选项卡:

图 11-6:Docker For AWS 堆栈输出屏幕
你将看到两行。
我们将把 DefaultDNSTarget 的值存储在环境变量中。它很快就会派上用场:
DNS=[...]
请将[...]替换为实际的 DefaultDNSTarget 值。
如果这是一个“真实”的生产集群,你将用它来更新你的 DNS 记录。它是你系统的公共入口。
点击“管理节点”列旁边的链接。你将看到包含按管理节点过滤的结果的 EC2 实例屏幕。工作节点将被隐藏:

图 11-7:按管理节点过滤的 Docker For AWS EC2 实例
选择一个管理节点并找到其公共 IP。和 DNS 一样,我们将把它存储为一个环境变量:
MANAGER_IP=[...]
请将[...]替换为实际的公共 IP 值。
我们终于准备好通过 SSH 进入其中一个管理节点,探索我们刚创建的集群:
ssh -i $KEY_PATH docker@$MANAGER_IP
一旦进入服务器,你将看到一条欢迎信息。专为该堆栈设计的操作系统非常简约,信息也反映了这一点:
Welcome to Docker!
~ $
和往常一样,我们将通过列出形成集群的节点来开始:
docker node ls
输出如下(为了简洁,已删除 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
ip-10-0-17-154.ec2.internal Ready Active Reachable
ip-10-0-15-215.ec2.internal Ready Active Reachable
ip-10-0-31-44.ec2.internal Ready Active
ip-10-0-15-214.ec2.internal Ready Active Leader
剩下的就是创建一些服务,以确认集群按预期工作。
我们将部署与我们使用 Docker Machine 创建的集群相同的vfarcic/docker-flow-proxy/docker-compose-stack.yml(github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)和vfarcic/go-demo/docker-compose-stack.yml(github.com/vfarcic/go-demo/blob/master/docker-compose-stack.yml)堆栈:
sudo docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
docker stack deploy \
-c proxy-stack.yml proxy
curl -o go-demo-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/go-demo/master/docker-compose-stack.yml
docker stack deploy \
-c go-demo-stack.yml go-demo
我们下载了 Compose 文件并部署了堆栈。
让我们确认服务是否确实在运行:
docker service ls
一段时间后,输出应该如下所示(为了简洁,ID 已移除):
NAME MODE REPLICAS
proxy_proxy replicated 2/2
go-demo_main replicated 3/3
proxy_swarm-listener replicated 1/1
go-demo_db replicated 1/1
----------------------------------------------
IMAGE
vfarcic/docker-flow-proxy:latest
vfarcic/go-demo:latest
vfarcic/docker-flow-swarm-listener:latest
mongo:latest
让我们退出服务器并确认go-demo服务是否对公众可访问:
exit
curl $DNS/demo/hello
如预期,我们收到了确认集群正在运行并且可访问的响应:
hello, world!
如果我们的服务器过于拥挤,需要扩展容量,会发生什么?我们如何增加(或减少)形成集群的节点数量?答案就在于 AWS 自动扩展组。请点击 EC2 控制台左侧菜单中的自动扩展组链接,并选择以Docker-NodeAsg开头的组名所在的行:

图 11-8:Docker For AWS 自动扩展组
要扩展或缩减节点数量,我们只需点击操作菜单中的编辑按钮,将 Desired 字段的值从1更改为*2*,然后点击保存按钮。Desired 实例的数量将立即更改为2。不过,可能需要一些时间,直到实际的实例数量与期望的数量一致。让我们回到其中一个管理服务器,并确认我们表达的需求是否确实得到了满足:
ssh -i $KEY_PATH docker@$MANAGER_IP
docker node ls
直到新实例被创建并加入集群可能需要一些时间。最终结果应该如下所示(为了简洁,ID 已移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
ip-10-0-17-154.ec2.internal Ready Active Reachable
ip-10-0-15-215.ec2.internal Ready Active Reachable
ip-10-0-31-44.ec2.internal Ready Active
ip-10-0-15-214.ec2.internal Ready Active Leader
ip-10-0-11-174.ec2.internal Ready Active
如果其中一台服务器故障,会发生什么?毕竟,任何事情都会早晚出现故障。我们可以通过移除其中一个节点来进行测试。
请从 EC2 控制台左侧菜单中点击实例链接,选择一个Docker-worker节点,点击操作,然后将实例状态更改为终止。通过点击“是,终止”按钮确认终止:
docker node ls
一段时间后,node ls命令的输出应如下所示(为了简洁,ID 已移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
ip-10-0-17-154.ec2.internal Ready Active Reachable
ip-10-0-15-215.ec2.internal Ready Active Reachable
ip-10-0-31-44.ec2.internal Ready Active
ip-10-0-15-214.ec2.internal Ready Active Leader
ip-10-0-11-174.ec2.internal Down Active
一旦自动扩展组意识到节点已停止,它将开始创建一个新的节点并将其加入到集群中:
docker node ls
不久后,node ls命令的输出应如下所示(为了简洁,ID 已移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
ip-10-0-17-154.ec2.internal Ready Active Reachable
ip-10-0-15-215.ec2.internal Ready Active Reachable
ip-10-0-2-22.ec2.internal Ready Active
ip-10-0-31-44.ec2.internal Ready Active
ip-10-0-15-214.ec2.internal Ready Active Leader
ip-10-0-11-174.ec2.internal Down Active
我们终止的服务器仍然标记为Down,并且一个新的服务器会被创建并加入到集群中,取而代之。
Docker for AWS 堆栈的内容远不止我们所探索的那些。我希望通过这次简短的探索,你所学到的内容能为你提供足够的基础信息,帮助你独立扩展知识。
我们不会继续探索堆栈的更多细节,而是看看如何在没有 UI 的情况下完成相同的结果。到这时,你应该已经足够了解我,明白我更倾向于使用可自动化和可重复的方式来执行任务。除非必要,我通常不会使用 UI,破例仅仅是为了让你更好地理解 Docker for AWS 堆栈的工作原理。接下来将介绍一种完全自动化的方法来完成相同的任务。
在我们继续之前,我们将删除堆栈,并且随之删除集群。这将是本章中你看到 UI 的最后一次。
请点击顶部菜单中的 Services 链接,然后点击 CloudFormation 链接。选择 Docker 堆栈,并从操作菜单中点击删除堆栈选项:

图 11-9:Docker For AWS 删除堆栈屏幕
在点击删除堆栈后,点击出现的确认删除按钮,确认摧毁操作。
使用 Docker for AWS 自动设置 Swarm 集群
从 UI 创建一个 Docker for AWS 堆栈是一次很好的练习。它帮助我们更好地理解了事物的运作方式。然而,我们的任务是尽可能自动化更多的流程。通过自动化,我们可以提高速度、可靠性和质量。当我们进行一些手动操作时,比如浏览 UI 并选择不同的选项,就增加了由于人为错误导致出错的可能性。我们很慢。当需要执行可重复的步骤时,我们远不如机器高效。
由于我对手动执行可重复任务的不信任,寻求一种更自动化的方式来创建 Docker for AWS 堆栈是很自然的。通过 AWS 控制台,我们所做的只是填入一些字段,这些字段在后台生成的参数会被用来执行 CloudFormation 过程。我们可以在没有 UI 的情况下做同样的事。
我们将从定义一些环境变量开始。它们将与本章中你已创建的变量相同。如果你仍然保持相同的终端会话,可以跳过接下来的命令:
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=[...]
export AWS_SECRET_ACCESS_KEY=[...]
请将 us-east-1 更改为你选择的区域,并用实际的值替换 [...]。
如果你还记得第一个允许我们选择模板的屏幕,你会记得其中有一个字段,预填充了一个 CloudFormation 模板的 URL。在撰写本文时,该模板是 Docker.tmpl(editions-us-east-1.s3.amazonaws.com/aws/stable/Docker.tmpl)。请注意,这个地址在不同区域之间是不同的。我将使用 us-east-1 版本。
我们可以通过 curl 获取模板内容来检查它:
curl https://editions-us-east-1.s3.amazonaws.com/aws/stable/Docker.tmpl
请花点时间查看输出。即使你不熟悉 CloudFormation 语法,你也应该能识别出 AWS 资源。
我们最感兴趣的模板部分是元数据(Metadata):
curl https://editions-us-east-1.s3.amazonaws.com/aws/stable/ \
Docker.tmpl \
| jq '.Metadata'
输出如下:
{
"AWS::CloudFormation::Interface": {
"ParameterGroups": [
{
"Label": {
"default": "Swarm Size"
},
"Parameters": [
"ManagerSize",
"ClusterSize"
]
},
{
"Label": {
"default": "Swarm Properties"
},
"Parameters": [
"KeyName",
"EnableSystemPrune",
"EnableCloudWatchLogs"
]
},
{
"Label": {
"default": "Swarm Manager Properties"
},
"Parameters": [
"ManagerInstanceType",
"ManagerDiskSize",
"ManagerDiskType"
]
},
{
"Label": {
"default": "Swarm Worker Properties"
},
"Parameters": [
"InstanceType",
"WorkerDiskSize",
"WorkerDiskType"
]
}
],
"ParameterLabels": {
"ClusterSize": {
"default": "Number of Swarm worker nodes?"
},
"EnableCloudWatchLogs": {
"default": "Use Cloudwatch for container logging?"
},
"EnableSystemPrune": {
"default": "Enable daily resource cleanup?"
},
"InstanceType": {
"default": "Agent worker instance type?"
},
"KeyName": {
"default": "Which SSH key to use?"
},
"ManagerDiskSize": {
"default": "Manager ephemeral storage volume size?"
},
"ManagerDiskType": {
"default": "Manager ephemeral storage volume type"
},
"ManagerInstanceType": {
"default": "Swarm manager instance type?"
},
"ManagerSize": {
"default": "Number of Swarm managers?"
},
"WorkerDiskSize": {
"default": "Worker ephemeral storage volume size?"
},
"WorkerDiskType": {
"default": "Worker ephemeral storage volume type"
}
}
}
}
我们可以使用 ParameterLabels 来定制模板的结果。
创建与我们通过 AWS 控制台生成的相同堆栈的命令如下:
aws cloudformation create-stack \
--template-url https://editions-us-east \
-1.s3.amazonaws.com/aws/stable/Docker.tmpl \
--stack-name swarm \
--capabilities CAPABILITY_IAM \
--parameters \
ParameterKey=KeyName,ParameterValue=devops21 \
ParameterKey=InstanceType,ParameterValue=t2.micro \
ParameterKey=ManagerInstanceType,ParameterValue=t2.micro \
ParameterKey=ManagerSize,ParameterValue=3 \
ParameterKey=ClusterSize,ParameterValue=1
该命令应该是自解释的。我们使用 aws 创建了一个包含所有必需参数的 CloudFormation 堆栈。
我们可以通过执行 cloudformation describe-stack-resources 命令来监控堆栈资源的状态:
aws cloudformation describe-stack-resources \
--stack-name swarm
一段时间后,应该会创建三个管理节点实例:
aws ec2 describe-instances \
--filters "Name=tag:Name,Values=swarm-Manager" \
"Name=instance-state-name,Values=running"
现在我们可以进入其中一个管理节点并开始创建服务。我将跳过创建服务并验证它们是否正常工作的示例。最终结果是与之前通过 AWS 控制台创建的相同集群。
随时可以自行探索集群,并在完成后 delete 堆栈:
aws cloudformation delete-stack \
--stack-name swarm
除了小型集群,Docker for AWS 比使用 docker-machine 和 aws 命令的组合要好得多。它是一个更稳健、更可靠的解决方案。然而,它也有一些缺点。
Docker for AWS 仍然年轻,且容易发生频繁的变化。此外,它还非常新,文档几乎不存在。
作为一种开箱即用的解决方案,它非常容易使用且几乎不需要任何努力。这既是福音也是诅咒。如果你需要的功能与 Docker for AWS 提供的功能大致相同,那么它是一个不错的选择。然而,如果你的需求不同,在尝试将模板适应你的需求时,可能会遇到不少问题。该解决方案基于一个自定义操作系统、CloudFormation 模板和专为此目的构建的容器。强烈建议不要修改除模板之外的任何内容。
总的来说,我认为 Docker for AWS 有着非常光明的未来,并且在大多数情况下,它比 docker-machine 更好。如果这两者是唯一的选择,我会投票支持使用 Docker for AWS。幸运的是,我们可以选择许多其他选项;比一本书可以容纳的要多得多。你可能正在阅读书籍的印刷版,而我不太愿意牺牲太多的树木。因此,我只会展示我们可以用来创建 Swarm(或任何其他类型)集群的另一个工具集。
使用 Packer 和 Terraform 设置 Swarm 集群
这次我们将使用一组与 Docker 完全无关的工具。它们是 Packer(www.packer.io/) 和 Terraform (www.terraform.io/)。这两者都来自 HashiCorp (www.hashicorp.com/)。
Windows 用户注意事项
使用 Chocolatey,通过管理员命令提示符执行 choco install packer 安装 packer。对于 terraform,在管理员命令提示符中执行 choco install terraform。
Packer 允许我们创建机器镜像。使用 Terraform,我们可以创建、修改和改进集群基础设施。两者都支持几乎所有主要的提供商。它们可以与 Amazon EC2、CloudStack、DigitalOcean、Google Compute Engine(GCE)、Microsoft Azure、VMware、VirtualBox 以及许多其他平台一起使用。基础设施独立的能力使我们能够避免供应商锁定。通过最小的配置更改,我们可以轻松地将集群从一个提供商迁移到另一个提供商。Swarm 的设计旨在无缝运行,无论我们使用哪个托管提供商,只要基础设施得到妥善定义。通过 Packer 和 Terraform,我们可以以这种方式定义基础设施,使得从一个提供商过渡到另一个提供商尽可能无痛。
使用 Packer 创建 Amazon 机器镜像
vfarcic/cloud-provisioning (github.com/vfarcic/cloud-provisioning) 仓库已经包含了我们将要使用的 Packer 和 Terraform 配置文件。它们位于terraform/aws目录下:
cd terraform/aws
第一步是使用 Packer 创建一个Amazon 机器镜像(AMI)。为此,我们需要将 AWS 访问密钥设置为环境变量。它们将与您在本章中已经创建的密钥相同。如果您仍然保持当前终端会话打开,可以跳过下一组命令:
export AWS_ACCESS_KEY_ID=[...]
export AWS_SECRET_ACCESS_KEY=[...]
export AWS_DEFAULT_REGION=us-east-1
请将[...]替换为实际值。
我们将从相同的 AMI 实例化所有 Swarm 节点。它将基于 Ubuntu,并安装最新的 Docker 引擎。
我们即将构建的镜像的 JSON 定义位于terraform/aws/packer-ubuntu-docker.json (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/packer-ubuntu-docker.json):
cat packer-ubuntu-docker.json
配置由两个部分组成:builders和provisioners:
{
"builders": [{
...
}],
"provisioners": [{
...
}]
}
builders 部分定义了 Packer 构建镜像所需的所有信息。provisioners部分描述了用于安装和配置软件的命令,这些命令将用于 builders 创建的机器。唯一必需的部分是 builders。
Builders 负责创建机器并为各种平台生成镜像。例如,EC2、VMware、VirtualBox 等都有单独的 builders。Packer 默认带有许多 builders,也可以扩展以添加新的 builders。
我们将使用的builders部分如下:
"builders": [{
"type": "amazon-ebs",
"region": "us-east-1",
"source_ami_filter": {
"filters": {
"virtualization-type": "hvm",
"name": "*ubuntu-xenial-16.04-amd64-server-*",
"root-device-type": "ebs"
},
"owners": ["099720109477"],
"most_recent": true
},
"instance_type": "t2.micro",
"ssh_username": "ubuntu",
"ami_name": "devops21",
"force_deregister": true
}],
每种类型的 builder 都有特定的参数可以使用。我们指定了type为amazon-ebs。除了amazon-ebs,我们还可以使用amazon-instance和amazon-chroot builders 来构建 AMI。在大多数情况下,我们应该使用amazon-ebs。更多信息,请访问Amazon AMI Builder (www.packer.io/docs/builders/amazon.html) 页面。
请注意,在使用amazon-ebs类型时,我们必须提供 AWS 密钥。我们本可以通过access_key和secret_key字段来指定它们。然而,也有一种替代方法。如果这些字段未指定,Packer 将尝试从环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY中获取值。由于我们已经导出了这些值,因此无需在 Packer 配置中重复设置它们。此外,这些密钥应该是保密的,将它们放在配置文件中可能会导致泄露风险。
该区域非常重要,因为 AMI 只能在一个区域内创建。如果我们希望在多个区域共享相同的机器,则每个区域都需要被指定为一个单独的构建器。
我们本可以通过source_ami指定初始 AMI 的 ID,该 AMI 将作为新创建机器的基础。然而,由于 AMI 是特定于某个区域的,如果我们决定更改区域,指定 ID 会使其无法使用。因此,我们采取了不同的方法,指定了source_ami_filter,该字段将填充source_ami字段。它将过滤 AMI 并找到一张 Ubuntu 16.04镜像,要求其虚拟化类型为hvm,根设备类型设置为ebs。owners字段将限制结果为可信的 AMI 提供商。由于如果返回多个 AMI,过滤器会失败,因此most_recent字段将通过选择最新的镜像来限制结果。
instance_type字段定义了将用于构建 AMI 的 EC2 实例类型。请注意,这不会阻止我们基于此镜像实例化任何其他支持的实例类型,在本例中是由 Ubuntu 支持的类型。
与我们使用的其他字段不同,ssh_username并非特定于amazon-ebs构建器。它指定了 Packer 在创建镜像时将使用的用户。与实例类型一样,它不会阻止我们在基于此镜像实例化 VM 时指定任何其他用户。
ami_name字段是我们为此 AMI 指定的名称。
如果我们已经创建了具有相同 AMI 的镜像,则force_deregister字段将在创建新镜像之前删除它。
请访问AMI 构建器(EBS 支持)(www.packer.io/docs/builders/amazon-ebs.html)页面以获取更多信息。
第二部分是provisioners。它包含一个 Packer 应使用的所有配置器数组,用于在将机器转化为机器镜像之前,在运行中的机器上安装和配置软件。
我们可以使用相当多的配置器类型。如果你读过The DevOps 2.0 Toolkit,你会知道我推荐使用 Ansible 作为首选配置器。我们也应该在这里使用它吗?在大多数情况下,当构建用于运行 Docker 容器的镜像时,我更倾向于使用简单的 shell。之所以从 Ansible 切换到 Shell,是因为配置器在实际服务器上运行时的目标与在构建镜像时的目标不同。
与 Shell 不同,Ansible(以及大多数其他提供者)是幂等的。它们会验证实际状态,并根据需要执行不同的操作,以便达成所需的目标状态。这是一种很好的方法,因为我们可以多次运行 Ansible,而结果始终是相同的。例如,如果我们指定需要 JDK 8,Ansible 会通过 SSH 连接到目标服务器,发现 JDK 不存在并进行安装。下次运行时,它会发现 JDK 已经存在,因此什么也不做。
这种方法允许我们根据需要多次运行 Ansible playbook,每次都会安装 JDK。如果我们尝试通过 Shell 脚本实现相同的功能,我们将需要编写冗长的 if/else 语句。如果 JDK 已安装,则什么也不做;如果未安装,则安装它;如果已安装,但版本不正确,则升级它,等等。
那么,为什么不将它与 Packer 一起使用呢?答案很简单。我们不需要幂等性,因为我们在创建镜像时只会运行一次,而不会在正在运行的实例上使用它。你还记得“宠物与牛”的讨论吗?我们的虚拟机将从一个已经包含所需内容的镜像中实例化。如果该虚拟机的状态发生变化,我们会终止它并创建一个新的。
如果我们需要进行升级或安装额外的软件,我们不会在正在运行的实例中进行,而是创建一个新的镜像,销毁正在运行的实例,并基于更新后的镜像实例化新的虚拟机。
幂等性是我们使用 Ansible 的唯一原因吗?当然不是!它是一个非常方便的工具,当我们需要定义一个复杂的服务器设置时非常有用。然而,在我们的案例中,设置是简单的。我们只需要 Docker 引擎,其他不多。几乎所有内容都会在容器内运行。写几个 Shell 命令来安装 Docker 比定义 Ansible playbook 更简单、更快捷。安装 Ansible 所需的命令可能与安装 Docker 的命令差不多。
简单来说,我们将使用 shell 作为构建 AMI 的首选提供者。
我们将使用的 provisioners 部分如下:
"provisioners": [{
"type": "shell",
"inline": [
"sleep 15",
"sudo apt-get update",
"sudo apt-get install -y apt-transport-https ca-certificates \
nfs-common",
"sudo apt-key adv --keyserver hkp://ha.pool.sks-keyservers.net: \
80 --recv-keys 58118E89F3A912897C070ADBF76221572C52609D",
"echo 'deb https://apt.dockerproject.org/repo ubuntu-xenial \
main' | sudo tee /etc/apt/sources.list.d/docker.list",
"sudo apt-get update",
"sudo apt-get install -y docker-engine",
"sudo usermod -aG docker ubuntu"
]
}]
shell type 后面跟着一组命令。它们与我们在在 Ubuntu 上安装 Docker(docs.docker.com/engine/installation/linux/ubuntulinux/)页面上找到的命令相同。
现在我们大致了解了 Packer 配置的工作原理,我们可以继续构建镜像:
packer build -machine-readable \
packer-ubuntu-docker.json \
| tee packer-ubuntu-docker.log
我们运行了 packer 构建,使用 packer-ubuntu-docker.json,并将 machine-readable 输出发送到 packer-ubuntu-docker.log 文件。机器可读的输出将使我们能够轻松解析并检索我们刚创建的 AMI 的 ID。
输出的最后几行如下:
...
1480105510,,ui,say,Build 'amazon-ebs' finished.
1480105510,,ui,say,\n==> Builds finished. The artifacts of successful builds are:
1480105510,amazon-ebs,artifact-count,1
1480105510,amazon-ebs,artifact,0,builder-id,mitchellh.amazonebs
1480105510,amazon-ebs,artifact,0,id,us-east-1:ami-02ebd915
1480105510,amazon-ebs,artifact,0,string,AMIs were \
created: \n\nus-east-1: ami-02ebd915
1480105510,amazon-ebs,artifact,0,files-count,0
1480105510,amazon-ebs,artifact,0,end
1480105510,,ui,say,--> amazon-ebs: AMIs were created: \n\nus-east-1: ami-02ebd915
除了确认构建成功之外,输出的相关部分是行 ID,us-east-1:ami-02ebd915。它包含了我们需要的 AMI ID,用于基于该镜像实例化虚拟机。
你可能希望将 packer-ubuntu-docker.log 存储在你的代码仓库中,以防你需要从不同的服务器获取 ID。
我们执行的流程可以通过 图 11-10 来描述:

图 11-10:Packer 过程的流程
现在我们准备好使用基于我们构建的镜像的虚拟机来创建一个 Swarm 集群。
使用 Terraform 在 AWS 中创建一个 Swarm 集群
我们将从重新定义我们在 Packer 中使用的环境变量开始,以防你在新的终端会话中开始本节:
cd terraform/aws
export AWS_ACCESS_KEY_ID=[...]
export AWS_SECRET_ACCESS_KEY=[...]
export AWS_DEFAULT_REGION=us-east-1
请将 [...] 替换为实际的值。
Terraform 不强制要求我们使用任何特定的文件结构。我们可以在一个文件中定义所有内容。然而,这并不意味着我们应该这么做。Terraform 配置可能会变得很大,将逻辑部分分离到不同的文件中通常是一个好主意。在我们的案例中,我们将使用三个 tf 文件。terraform/aws/variables.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/variables.tf) 文件包含了所有的变量。
如果我们需要更改任何参数,我们会知道在哪里找到它。terraform/aws/common.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/common.tf) 文件包含了可能在其他场合重用的元素定义。最后,terraform/aws/swarm.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/swarm.tf) 文件包含了 Swarm 特定 资源。
我们将分别探讨每一个 Terraform 配置文件。
terraform/aws/variables.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/variables.tf) 文件的内容如下:
variable "swarm_manager_token" {
default = ""
}
variable "swarm_worker_token" {
default = ""
}
variable "swarm_ami_id" {
default = "unknown"
}
variable "swarm_manager_ip" {
default = ""
}
variable "swarm_managers" {
default = 3
}
variable "swarm_workers" {
default = 2
}
variable "swarm_instance_type" {
default = "t2.micro"
}
variable "swarm_init" {
default = false
}
swarm_manager_token 和 swarm_worker_token 将在将节点加入集群时需要使用。swarm_ami_id 将包含我们用 Packer 创建的镜像 ID。swarm_manager_ip 变量是我们需要为节点提供的一个管理节点 IP 地址,以便它们可以加入集群。swarm_managers 和 swarm_workers 定义了我们希望创建的每种节点的数量。swarm_instance_type 是我们希望创建的实例类型。如果不指定,它默认使用最小且最便宜的(通常是免费的)实例。如果你开始使用这个 Terraform 配置来创建一个“真正的”集群,可以随时将其更改为更强大的类型。
最后,
swarm_init变量允许我们指定这是否是第一次运行,并且节点应该初始化集群。我们很快就会看到它的使用方法。
terraform/aws/common.tf文件的内容如下:github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/common.tf
resource "aws_security_group" "docker" {
name = "docker"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 2377
to_port = 2377
protocol = "tcp"
self = true
}
ingress {
from_port = 7946
to_port = 7946
protocol = "tcp"
self = true
}
ingress {
from_port = 7946
to_port = 7946
protocol = "udp"
self = true
}
ingress {
from_port = 4789
to_port = 4789
protocol = "tcp"
self = true
}
ingress {
from_port = 4789
to_port = 4789
protocol = "udp"
self = true
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
每个资源都通过类型(例如:aws_security_group)和名称(例如:docker)进行定义。类型决定了应创建哪种资源,并且必须是当前支持的类型之一。
第一个资源aws_security_group包含所有应打开的入口端口的列表。端口22是 SSH 所需的。端口80和443将用于 HTTP 和 HTTPS 访问proxy。其余端口将用于 Swarm 的内部通信。TCP 端口2377用于集群管理通信,TCP 和 UDP 端口7946用于节点间的通信,TCP 和 UDP 端口4789用于覆盖网络流量。这些端口与我们使用 Docker Machine 创建集群时必须打开的端口相同。请注意,除了端口22、80和443,其余端口都分配给了 self。这意味着这些端口仅对属于同一组的其他服务器可用,任何外部访问都将被阻止。
aws_security_group中的最后一个条目是egress,它允许集群与外部世界之间的通信,且没有任何限制。
请参考AWS_SECURITY_GROUP页面获取更多信息:www.terraform.io/docs/providers/aws/d/security_group.html
现在进入“重头戏”。terraform/aws/swarm.tf文件包含我们将要创建的所有实例的定义。由于该文件的内容比其他文件稍大,我们将逐一检查每个资源。
第一行资源是类型为aws_instance、名为swarm-manager的资源。其目的是创建 Swarm 管理节点:
resource "aws_instance" "swarm-manager" {
ami = "${var.swarm_ami_id}"
instance_type = "${var.swarm_instance_type}"
count = "${var.swarm_managers}"
tags {
Name = "swarm-manager"
}
vpc_security_group_ids = [
"${aws_security_group.docker.id}"
]
key_name = "devops21"
connection {
user = "ubuntu"
private_key = "${file("devops21.pem")}"
}
provisioner "remote-exec" {
inline = [
"if ${var.swarm_init}; then docker swarm init \
--advertise-addr ${self.private_ip}; fi",
"if ! ${var.swarm_init}; then docker swarm join \
--token ${var.swarm_manager_token} --advertise-addr \
${self.private_ip} ${var.swarm_manager_ip}:2377; fi"
]
}
}
该资源包含ami,它引用了我们用 Packer 创建的镜像。实际值是一个变量,我们将在运行时定义。instance_type指定了我们希望创建的实例类型。默认值从变量swarm_instance_type中获取,默认为t2.micro。像其他任何变量一样,它可以在运行时被覆盖。
count字段定义了我们希望创建的管理节点数量。当我们第一次运行terraform时,该值应为 1,因为我们希望从一个管理节点开始初始化集群。之后,该值应为变量中定义的任何值。我们很快就会看到两种组合的使用场景。
标签仅用于信息性目的。
vpc_security_group_id字段包含我们希望附加到服务器的所有组的列表。在我们的例子中,我们只使用在terraform/aws/common.tf中定义的 docker 组。
key_name 是我们在 AWS 中存储的密钥的名称。我们在本章开始时创建了 devops21 密钥。请仔细检查是否没有删除它。如果没有它,您将无法通过 SSH 登录到机器。
连接字段定义了 SSH 连接的详细信息。用户将是 ubuntu。我们将使用 devops21.pem 密钥,而不是密码。
最后,定义了 provisioner。我们的目的是尽可能在创建镜像时进行大量的配置。这样,实例创建速度会更快,因为唯一的操作是从镜像中创建虚拟机。然而,通常有一部分配置是在创建镜像时无法完成的。swarm init 命令就是其中之一。我们不能在获取服务器的 IP 之前初始化第一个 Swarm 节点。换句话说,服务器需要运行(因此具有 IP 地址),才能执行 swarm init 命令。
由于第一个节点必须初始化集群,而其他节点则需要加入集群,我们使用了 if 语句来区分这两种情况。如果变量 swarm_init 为真,将执行 docker swarm init 命令。另一方面,如果 swarm_init 设置为假,则执行 docker swarm join 命令。在这种情况下,我们使用另一个变量 swarm_manager_ip 来告诉节点使用哪个管理节点加入集群。
请注意,IP 是通过特殊语法 self.private_ip 获取的。我们引用自身并获取 private_ip。我们可以从资源中获取许多其他属性。
请参考 AWS_INSTANCE (www.terraform.io/docs/providers/aws/r/instance.html) 页面了解更多信息。
让我们来看一下名为 swarm-worker 的 aws_instance 资源:
resource "aws_instance" "swarm-worker" {
count = "${var.swarm_workers}"
ami = "${var.swarm_ami_id}"
instance_type = "${var.swarm_instance_type}"
tags {
Name = "swarm-worker"
}
vpc_security_group_ids = [
"${aws_security_group.docker.id}"
]
key_name = "devops21"
connection {
user = "ubuntu"
private_key = "${file("devops21.pem")}"
}
provisioner "remote-exec" {
inline = [
"docker swarm join --token ${var.swarm_worker_token} \
--advertise-addr ${self.private_ip} ${var.swarm_manager_ip}:2377"
]
}
}
swarm-worker 资源几乎与 swarm-manager 相同。唯一的区别在于计数字段,它使用 swarm_workers 变量和 provisioner。由于工作节点不能初始化集群,因此不需要 if 语句,所以我们唯一想要执行的命令是 docker swarm join。
Terraform 使用一种命名约定,允许我们通过添加 TF_VAR_ 前缀来将值指定为环境变量。例如,我们可以通过设置环境变量 TF_VAR_swarm_ami_id 来指定变量 swarm_ami_id 的值。另一种方法是使用 -var 参数。我更喜欢使用环境变量,因为它们允许我只指定一次,而不需要在每个命令中添加 -var。
terraform/aws/swarm.tf 的最后部分 (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/swarm.tf) 是输出。
在构建潜在复杂的基础设施时,Terraform 会为所有资源存储数百或数千个属性值。但是,作为用户,我们可能只对一些重要的值感兴趣,例如管理节点 IP。输出是一种告诉 Terraform 哪些数据是相关的方法。这些数据在调用 apply 时会被输出,并可以通过terraform output命令查询。
我们定义的输出如下:
output "swarm_manager_1_public_ip" {
value = "${aws_instance.swarm-manager.0.public_ip}"
}
output "swarm_manager_1_private_ip" {
value = "${aws_instance.swarm-manager.0.private_ip}"
}
output "swarm_manager_2_public_ip" {
value = "${aws_instance.swarm-manager.1.public_ip}"
}
output "swarm_manager_2_private_ip" {
value = "${aws_instance.swarm-manager.1.private_ip}"
}
output "swarm_manager_3_public_ip" {
value = "${aws_instance.swarm-manager.2.public_ip}"
}
output "swarm_manager_3_private_ip" {
value = "${aws_instance.swarm-manager.2.private_ip}"
}
它们是管理节点的公共和私有 IP 地址。由于知道工作节点 IP 的理由很少(如果有的话),我们没有将它们定义为输出。有关更多信息,请参阅输出配置 (www.terraform.io/docs/configuration/outputs.html) 页面。由于我们将使用 Packer 创建的 AMI,我们需要从packer-ubuntu-docker.log中检索 ID。下面的命令解析输出并提取 ID:
export TF_VAR_swarm_ami_id=$( \
grep 'artifact,0,id' \
packer-ubuntu-docker.log \
| cut -d: -f2)
在创建我们的集群和周围的基础设施之前,我们应该让 Terraform 显示执行计划:
terraform plan
结果是一个包含资源及其属性的详细列表。由于输出过于庞大无法全部打印,我将仅限于显示资源类型和名称:
...
+ aws_instance.swarm-manager.0
...
+ aws_instance.swarm-manager.1
...
+ aws_instance.swarm-manager.2
...
+ aws_instance.swarm-worker.0
...
+ aws_instance.swarm-worker.1
...
+ aws_security_group.docker
...
Plan: 6 to add, 0 to change, 0 to destroy.
由于这是第一次执行,如果我们执行 terraform apply,所有资源都会被创建。我们将得到五个 EC2 实例:三个管理节点和两个工作节点。还将伴随一个安全组。
如果查看完整的输出,您会注意到一些属性值被设置为<computed>。这意味着 Terraform 无法知道实际值,直到它创建资源。例如,IP 地址。它们在 EC2 实例创建之前并不存在。
我们还可以使用graph命令输出计划:
terraform graph
输出如下:
digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] aws_instance.swarm-manager" [label = \
"aws_instance.swarm-manager",shape = "box"]
"[root] aws_instance.swarm-worker" [label = \
"aws_instance.swarm-worker", shape= "box"]
"[root] aws_security_group.docker" [label = \
"aws_security_group.docker", shape = "box"]
"[root] provider.aws" [label = "provider.aws", shape = \
"diamond"]
"[root] aws_instance.swarm-man ager" -> "[root] \
aws_security_group.docker"
"[root] aws_instance.swarm-manager" -> "[root] provider.aws" \
"[root] aws_instance.swarm-worker" -> "[root] \
aws_security_group.docker"
"[root] aws_instance.swarm-worker" -> "[root] provider.aws" \
"[root] aws_security_group.docker" -> "[root] provider.aws" \
}
}
这本身并不太有用。
graph命令用于生成配置或执行计划的可视化表示。输出是 DOT 格式,可以被 GraphViz 用来生成图表。
请打开Graphviz 下载 (www.graphviz.org/Download..php) 页面,下载并安装与您的操作系统兼容的发行版。
现在我们可以将graph命令与 dot 结合使用:
terraform graph | dot -Tpng > graph.png
输出应与图 11-11:中的内容相同:

图 11-11:由 Graphviz 根据 terraform graph 命令的输出生成的图像
计划的可视化使我们能够看到不同资源之间的依赖关系。在我们的案例中,所有资源都将使用aws提供者。两种实例类型将依赖于安全组 docker。
当依赖关系被定义时,我们无需显式指定所有需要的资源。
作为示例,让我们看看 Terraform 在我们仅限制为一个 Swarm 管理节点时生成的计划,以便初始化集群:
terraform plan \
-target aws_instance.swarm-manager \
-var swarm_init=true \
-var swarm_managers=1
运行时变量swarm_init和swarm_managers将被用来告诉 Terraform 我们希望用一个管理节点来初始化集群。plan命令会考虑这些变量并输出执行计划。
限制为资源类型和名称的输出如下:
+ aws_instance.swarm-manager
+ aws_security_group.docker
尽管我们指定了只希望获取swarm-manager资源的计划,Terraform 发现它依赖于安全组docker,并将其包含在执行计划中。
在开始创建 AWS 资源之前,唯一缺少的就是将 SSH 密钥devops21.pem复制到当前目录。配置要求它位于该目录中:
export KEY_PATH=$HOME/.ssh/devops21.pem
cp $KEY_PATH devops21.pem
请在复制之前将KEY_PATH值更改为正确的路径。
我们将从小做起,只创建一个管理节点实例来初始化集群。正如我们从计划中看到的,它依赖于安全组,因此 Terraform 也会创建它。
terraform apply \
-target aws_instance.swarm-manager \
-var swarm_init=true \
-var swarm_managers=1
输出过大,无法在书中展示。如果你从终端查看,会注意到安全组首先被创建,因为swarm-manager依赖于它。请注意,我们并没有显式指定依赖关系。然而,由于该资源在vpc_security_group_ids字段中已指定依赖,Terraform 理解这是一个依赖项。
一旦swarm-manager实例创建完成,Terraform 将等待直到 SSH 访问可用。当它成功连接到新实例后,会执行provisioning命令来初始化集群。
输出的最后几行如下:
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.
State path: terraform.tfstate
Outputs:
swarm_manager_1_private_ip = 172.31.49.214
swarm_manager_1_public_ip = 52.23.252.207
输出定义位于terraform/aws/swarm.tf文件的底部(github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/swarm.tf)。请注意,并非所有的输出都会列出,只有已创建资源的输出。
我们可以使用新创建的 EC2 实例的公共 IP 并通过 SSH 连接。
你可能会倾向于复制 IP 地址,但其实没有必要。Terraform 有一个命令,可以用来获取我们定义为输出的任何信息。
以下命令用于获取当前唯一的管理节点的公共 IP 地址:
terraform output swarm_manager_1_public_ip
输出如下:
52.23.252.207
我们可以利用output命令来构建SSH命令。作为示例,以下命令将通过 SSH 连接到机器并获取 Swarm 节点的列表:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker node ls
输出如下(为简洁起见,ID 已被移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
ip-172-31-49-214 Ready Active Leader
从现在开始,我们不再局限于初始化集群的单一管理节点。我们可以创建其余的所有节点。但是,在此之前,我们需要获取manager和worker令牌。出于安全原因,最好不要将它们存储在任何地方,因此我们将创建环境变量:
export TF_VAR_swarm_manager_token=$(ssh \
-i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker swarm join-token -q manager)
export TF_VAR_swarm_worker_token=$(ssh \
-i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker swarm join-token -q worker)
我们还需要设置环境变量swarm_manager_ip:
export TF_VAR_swarm_manager_ip=$(terraform \
output swarm_manager_1_private_ip)
尽管我们可以在terraform/aws/swarm.tf(github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws/swarm.tf)中使用aws_instance.swarm-manager.0.private_ip,但将其定义为环境变量是一个不错的主意。这样,如果第一个管理节点发生故障,我们可以轻松地将其更改为swarm_manager_2_private_ip,而无需修改tf文件。
现在,让我们查看创建所有缺失资源的计划:
terraform plan
不需要指定目标,因为这次我们希望创建所有缺失的资源。
输出的最后一行如下所示:
...
Plan: 4 to add, 0 to change, 0 to destroy.
我们可以看到,计划是创建四个新资源。由于我们已经有一个管理节点在运行,并且指定了期望的数量为三个,因此将会创建两个额外的管理节点和两个工作节点。
让我们应用执行计划:
terraform apply
输出的最后几行如下所示:
...
Apply complete! Resources: 4 added, 0 changed, 4 destroyed.
The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.
State path: terraform.tfstate
Outputs:
swarm_manager_1_private_ip = 172.31.49.214
swarm_manager_1_public_ip = 52.23.252.207
swarm_manager_2_private_ip = 172.31.61.11
swarm_manager_2_public_ip = 52.90.245.134
swarm_manager_3_private_ip = 172.31.49.221
swarm_manager_3_public_ip = 54.85.49.136
所有四个资源都已创建,我们得到了管理节点公共和私有 IP 的输出。
让我们进入其中一个管理节点,确认集群确实在正常工作:
ssh -i devops21.pem \
ubuntu@$(terraform \
output swarm_manager_1_public_ip)
docker node ls
node ls命令的输出如下所示(为了简洁,删除了 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
ip-172-31-61-11 Ready Active Reachable
ip-172-31-49-221 Ready Active Reachable
ip-172-31-50-78 Ready Active
ip-172-31-49-214 Ready Active Leader
ip-172-31-49-41 Ready Active
所有节点都存在,集群似乎正常工作。
为了确保一切按预期工作,我们将部署一些服务。这些服务将与我们在整个书中创建的服务相同,因此我们可以节省一些时间,直接部署vfarcic/docker-flow-proxy/docker-compose-stack.yml(github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)和vfarcic/go-demo/docker-compose-stack.yml(github.com/vfarcic/go-demo/blob/master/docker-compose-stack.ym)堆栈:
sudo docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
sudo docker stack deploy \
-c proxy-stack.yml proxy
curl -o go-demo-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/go-demo/master/docker-compose-stack.yml
sudo docker stack deploy \
-c go-demo-stack.yml go-demo
docker service ls
我们从仓库下载了脚本,授予了可执行权限,并执行了它。最后,我们列出了所有服务。
一段时间后,service ls命令的输出应该如下所示(为了简洁,删除了 ID):
NAME MODE REPLICAS
go-demo_db replicated 1/1
proxy_swarm-listener replicated 1/1
proxy_proxy replicated 2/2
go-demo_main replicated 3/3
-------------------------------------------------
IMAGE
mongo:latest
vfarcic/docker-flow-swarm-listener:latest
vfarcic/docker-flow-proxy:latest
vfarcic/go-demo:latest
最后,让我们通过proxy发送请求到go-demo服务。如果返回正确的响应,我们就能确认一切正常:
curl localhost/demo/hello
输出如下:
hello, world!
它工作正常!
我们完成了吗?我们可能完成了。作为最后的检查,让我们验证proxy是否可以从安全组外部访问。我们可以通过退出服务器并从笔记本发送请求来确认:
exit
curl $(terraform output \
swarm_manager_1_public_ip)/demo/hello
输出如下:
hello, world!
让我们看看如果模拟一个实例故障会发生什么。
我们将使用 AWS CLI 删除一个实例。我们本可以使用 Terraform 来删除实例,但使用 AWS CLI 删除实例更能模拟节点发生意外故障的情形。要删除实例,我们需要找到它的 ID。我们可以通过terraform show命令来做到这一点。假设我们想删除第二个工作节点。查找所有相关信息的命令如下:
terraform state show "aws_instance.swarm-worker[1]"
输出如下:
id = i-6a3a1964
ami = ami-02ebd915
associate_public_ip_address = true
availability_zone = us-east-1b
disable_api_termination = false
ebs_block_device.# = 0
ebs_optimized = false
ephemeral_block_device.# = 0
iam_instance_profile =
instance_state = running
instance_type = t2.micro
key_name = devops21
monitoring = false
network_interface_id = eni-322fd9cc
private_dns = ip-172-31-56-227.ec2.internal
private_ip = 172.31.56.227
public_dns = ec2-54-174-83-184.compute-1.amazonaws.com
public_ip = 54.174.83.184
root_block_device.# = 1
root_block_device.0.delete_on_termination = true
root_block_device.0.iops = 100
root_block_device.0.volume_size = 8
root_block_device.0.volume_type = gp2
security_groups.# = 0
source_dest_check = true
subnet_id = subnet-e71631cd
tags.% = 1
tags.Name = swarm-worker
tenancy = default
vpc_security_group_ids.# = 1
vpc_security_group_ids.937984769 = sg-288e1555
除了其他一些数据外,我们得到了 ID。我的情况是i-6a3a1964。在运行接下来的命令之前,请将 ID 更改为你从terraform state show命令中获得的 ID:
aws ec2 terminate-instances \
--instance-ids i-6a3a1964
输出如下:
{
"TerminatingInstances": [
{
"InstanceId": "i-6a3a1964",
"CurrentState": {
"Code": 32,
"Name": "shutting-down"
},
"PreviousState": {
"Code": 16,
"Name": "running"
}
}
]
}
AWS 将实例的状态从running更改为shutting-down。
让我们再运行一次terraform plan命令:
terraform plan
输出的最后一行如下:
Plan: 1 to add, 0 to change, 0 to destroy.
Terraform 推断出需要添加一个资源swarm-worker.1,以解决本地存储状态与集群实际状态之间的差异。
恢复集群到理想状态所需做的就是运行terraform apply:
terraform apply
输出的最后几行如下:
...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.
State path: terraform.tfstate
Outputs:
swarm_manager_1_private_ip = 172.31.60.117
swarm_manager_1_public_ip = 52.91.201.148
swarm_manager_2_private_ip = 172.31.57.177
swarm_manager_2_public_ip = 54.91.90.33
swarm_manager_3_private_ip = 172.31.48.226
swarm_manager_3_public_ip = 54.209.238.50
我们可以看到添加了一个资源。被终止的工作节点已被重新创建,集群继续以其全部容量运行。
集群的状态存储在terraform.tfstate文件中。如果你不是总是从同一台计算机运行它,可能希望将该文件与其他配置文件一起存储在你的代码库中。另一种选择是使用远程状态(www.terraform.io/docs/state/remote/index.html),例如,将其存储在 Consul 中。
改变集群的理想状态也很简单。我们所要做的就是添加更多资源并重新运行terraform apply。
我们已经完成了对 AWS 的 Terraform 简要介绍。
我们执行的过程流程可以通过图 11-12描述:

图 11-12:Terraform 流程图
在比较我们在 AWS 中创建和管理 Swarm 集群所采用的不同方法之前,让我们先销毁之前所做的:
terraform destroy -force
集群已经消失,仿佛它从未存在过,从而为我们节省了不必要的开支。
选择正确的工具来创建和管理 AWS 中的 Swarm 集群
我们尝试了三种不同的组合来在 AWS 中创建 Swarm 集群。我们使用了Docker Machine与AWS CLI,Docker for AWS与 CloudFormation 模板,以及Packer与Terraform。这当然不是我们能使用的工具的最终列表。时间有限,我曾承诺自己这本书会比战争与和平短一些,所以我必须在某个地方画上句号。按照我的观点,这三种组合是最适合作为你选择的工具。即便你选择了其他的工具,希望本章也能为你提供一个可能的方向。
你很可能不会使用所有三种组合,那么问题的关键是,应该选择哪一种?
只有你能回答这个问题。现在,你拥有了实际经验,应该结合你想要达成的目标。每个使用场景都是不同的,没有一种组合能适合所有人。
尽管如此,我将提供一个简要概述,并介绍每种组合可能适用的一些使用场景。
使用 Docker Machine 还是不使用 Docker Machine?
Docker Machine 是我们探索过的最弱的解决方案。它基于临时命令,提供的功能不多,除了创建 EC2 实例并安装 Docker 引擎外几乎没有其他功能。它使用 Ubuntu 15.10 作为基础 AMI。它不仅是旧版本,而且是一个临时发布。如果我们选择使用 Ubuntu,正确的选择应该是 16.04 长期支持(LTS)。
此外,Docker Machine 仍然不支持 Swarm 模式,因此我们需要在执行 docker swarm init 和 docker swarm join 命令之前手动打开端口。为此,我们需要将 Docker Machine 与 AWS 控制台、AWS CLI 或 CloudFormation 配合使用。
如果 Docker Machine 至少能提供 Swarm 模式的最基本配置(就像它之前对旧版 Standalone Swarm 做的那样),它可能是一个小型集群的好选择。
就目前而言,Docker Machine 在 AWS 中使用 Swarm 集群时,唯一真正的优势是远程节点上的 Docker 引擎安装,以及使用 docker-machine env 命令使本地 Docker 客户端与远程集群无缝通信的能力。Docker 引擎的安装很简单,仅凭这一点并不足够。另一方面,docker-machine env 命令不应该在生产环境中使用。这两个优势都太弱了。
当前 Docker Machine 的许多问题可以通过一些额外的参数(例如:--amazonec2-ami)和其他工具的配合来修复。然而,这样做只会削弱 Docker Machine 的主要优势。它本应简单并且开箱即用。这在 Docker 1.12 之前部分成立,但现在,至少在 AWS 上,它已经落后了。
这是否意味着我们在使用 AWS 时应该放弃 Docker Machine?并非总是如此。当我们想要创建一个临时集群用于演示或实验一些新特性时,它仍然很有用。此外,如果你不想花时间学习其他工具,而只想使用自己熟悉的工具,Docker Machine 可能是一个合适的选择。我怀疑这是不是你的情况。
你能读到这本书的这个部分,说明你确实想要探索更好的集群管理方式。
最终的建议是,当你想在本地模拟一个 Swarm 集群时,像我们在本章之前所做的那样,继续使用 Docker Machine。对于 AWS,还是有更好的选择。
使用 Docker for AWS 还是不使用 Docker for AWS?
Docker for AWS(docs.docker.com/docker-for-aws/release-notes/)与 Docker Machine 是不同的。它是一个完整的 Swarm 集群解决方案。而 Docker Machine 仅仅做的是创建 EC2 实例并安装 Docker Engine,Docker for AWS则设置了许多我们可能自己很难设置的内容。自动扩展组、VPC、子网和 ELB 只是其中的一部分。
使用Docker for AWS创建和管理 Swarm 集群几乎不需要做什么。只需选择所需的管理节点和工作节点数量,点击“创建堆栈”按钮,等待几分钟。就这么简单。
还有更多内容。Docker for AWS配备了一个专门为运行容器设计的新操作系统。
对Docker for AWS如此的赞誉是否意味着它一定是最佳选择?不一定。这取决于你的需求和使用场景。如果Docker for AWS提供的正是你所需要的,那么选择就很简单,直接使用它。另一方面,如果你想改变它的一些方面或添加未包含的功能,可能会遇到困难。修改或扩展它并不容易。
作为一个例子,Docker for AWS会将所有日志输出到Amazon CloudWatch(aws.amazon.com/cloudwatch/)。这很好,前提是 CloudWatch 是你想要保存日志的地方。另一方面,如果你更倾向于使用 ELK 堆栈、DataDog 或其他日志解决方案,你会发现更改默认设置并不那么简单。
我们来看另一个例子。如果你想添加持久化存储呢?你可能会在所有服务器上挂载一个 EFS 卷,但这不是最优解。你可能想尝试使用 RexRay 或 Flocker。如果是这样,你会发现,扩展系统并不像想象的那么简单。你可能最终需要修改 CloudFormation 模板,并冒着无法升级到新版本Docker for AWS的风险。
我有提到过Docker for AWS还很年轻吗?在撰写本文时,它或多或少已经稳定了,但仍然存在一些问题。更准确地说,它缺少一些功能,比如持久化存储。例如,虽然有一些问题,但这并不意味着你应该放弃Docker for AWS。它是一个很棒的解决方案,随着时间的推移只会变得更好。
最终建议是,如果Docker for AWS提供了你所需要的(几乎)所有功能,或者你不想从头开始构建自己的解决方案,那就使用它。最大的障碍是,如果你已经有一套需要满足的要求,无论使用什么工具,都无法满足这些要求。
如果你决定在 AWS 中托管集群,并且不想花时间了解其所有服务的工作原理,那么无需再读下去。Docker for AWS就是你所需要的。它让你无需学习安全组、VPC、弹性 IP 以及其他可能需要或不需要的服务。
要使用 Terraform 还是不使用 Terraform?
Terraform 与 Packer 结合使用时,是一个非常好的选择。HashiCorp 又成功地开发了一个改变我们配置和部署服务器方式的工具。
配置管理工具的主要目标是使服务器始终处于期望的状态。如果 Web 服务器停止运行,它将被重新启动;如果配置文件发生更改,它将被恢复。无论服务器发生什么问题,其期望状态都会被恢复。除非问题无法修复。如果硬盘损坏,配置管理工具就无能为力了。
配置管理工具的问题在于,它们是为物理服务器而设计的,而不是虚拟服务器。当我们能在几秒钟内创建一个新的虚拟服务器时,为什么还要修复一个故障的虚拟服务器呢?Terraform 比任何人都更了解云计算的工作方式,并且接受一个观念,那就是我们的服务器不再是宠物,它们是牲畜。它会确保你的所有资源都可用。当服务器出现问题时,它不会试图修复它,而是会销毁它,并基于我们选择的镜像创建一个新的服务器。
这是否意味着 Puppet、Chef、Ansible 和其他类似工具就没有用武之地了?它们在云环境中是否已经过时?有些工具比其他的更加过时。Puppet 和 Chef 设计用于在每台服务器上运行代理,不断监控其状态,并在出错时进行修改。当我们开始将服务器视为牲畜时,这些工具就没有用了。Ansible 的位置稍好一些,因为它比其他工具更有用,它是设计来配置服务器的,而不是监控服务器的。因此,在创建镜像时,它可以非常有帮助。
我们可以将 Ansible 与 Packer 结合使用。Packer 会创建一个新的虚拟机,Ansible 会为该虚拟机配置我们需要的一切,然后交给 Packer 从中创建一个镜像。如果服务器的配置比较复杂,这样做是非常有意义的。问题是,服务器的配置复杂度应该如何衡量?在 AWS 中,许多传统上运行在服务器上的资源现在已经变成了服务。我们不会在每台服务器上设置防火墙,而是使用 VPC 和安全组服务。我们也不会创建很多系统用户,因为我们不需要登录到机器上部署软件。Swarm 为我们完成了这些工作。我们也不再安装 Web 服务器和运行时依赖,它们都在容器内。那么,使用配置管理工具将一些东西安装到将被转化为镜像的虚拟机中,真的有意义吗?大多数情况下,答案是没有。我们需要的几个东西,可以通过几条 Shell 命令同样轻松地安装和配置。我们对“牲畜”的配置管理可以,也应该通过 bash 来完成。
我可能有些过于严苛了。如果你知道何时使用它以及使用它的目的,Ansible 仍然是一个很棒的工具。如果你更喜欢用它而不是 bash 来安装和配置一个服务器,直到它成为镜像,那就用吧。如果你尝试用它来控制节点并创建 AWS 资源,那你就走错路了。Terraform 在这方面做得更好。如果你认为应该配置一个正在运行的节点,而不是实例化已经包含所有内容的镜像,那你必须比我更有耐心。
既然我们已经确定了我对那些从一开始就为云环境设计的工具(而非本地物理服务器)的偏好,你可能会想知道是否应该使用 CloudFormation 而不是 Terraform。
CloudFormation 的主要问题在于它是为了将你绑定到 AWS 设计的。它只管理亚马逊的服务,几乎没有其他功能。就个人而言,我认为如果有好的替代方案,厂商锁定是不可接受的。如果你已经充分使用了 AWS 的服务,可以忽略我在这个问题上的意见。我更喜欢选择自由。通常,我尝试设计那些依赖于供应商最小化的系统,只有在某些服务在 AWS 上更好或更容易设置时,我才会使用它们。在某些情况下,这是真的,但在许多情况下并非如此。AWS 的 VPC 和安全组是提供大量价值的服务,我认为没有理由不使用它们,尤其是它们在我转移到其他提供商时很容易被替换。
CloudWatch 是一个相反的例子。ELK 是比 CloudWatch 更好的解决方案,它是免费的,且可以迁移到任何提供商。比如 ELB,也是如此。它在 Docker 网络下已经基本过时。如果你需要一个代理,选择 HAProxy 或 nginx。
对你来说,厂商锁定的问题可能无关紧要。你可能已经选择了 AWS,并且会在未来一段时间内继续使用它。也没问题。然而,Terraform 支持与众多托管服务提供商合作的能力,绝不仅仅是它的唯一优势。
与 CloudFormation 相比,Terraform 的配置更容易理解,且能够很好地与其他类型的资源(如 DNSimple (www.terraform.io/docs/providers/dnsimple/))配合使用,它在应用之前显示计划的功能能帮助我们避免许多痛苦的错误。结合 Packer 使用时,我认为它是管理云基础设施的最佳组合。
让我们回到最初的讨论。我们应该使用 Docker for AWS 还是 Terraform 配合 Packer?
与 Docker Machine 在大多数情况下容易被拒绝不同,是否使用Terraform或Docker for AWS这一难题更难解决。使用 Terraform 可能需要一段时间才能让集群达到你所需的状态。这不是一个开箱即用的解决方案,你必须自己编写配置。如果你对 AWS 有经验,这样的挑战应该不会造成太大困扰。另一方面,如果 AWS 不是你的强项,那么定义所有内容可能需要你花费相当长的时间。
然而,我不会将学习 AWS 作为选择其中一个而非另一个的理由。即便你选择了像Docker for AWS这样的开箱即用解决方案,你依然应该了解 AWS。否则,当基础设施出现问题时,你将面临无法及时应对的风险。不要以为有什么能替代你理解 AWS 的复杂性。问题只是你会在创建集群之前还是之后学习这些细节。
最终建议是,如果你想控制组成集群的所有部分,或者如果你已经有一套需要遵循的规则,使用 Terraform 配合 Packer。如果你选择这种方式,准备花些时间调试配置,直到达到最佳设置。与 Docker for AWS 不同,使用 Terraform 你不会在一小时内就定义出一个完全可用的集群。如果你需要的是这一点,那么选择 Docker for AWS。而另一方面,当你配置好 Terraform 去完成你需要的所有任务时,结果将会非常完美。
最终判决
我们应该使用什么?如何做出决策?由懂行的人创建的Docker for AWS 完全功能集群,还是由你用 Terraform 创建的完全可操作集群?Docker for AWS 和你想给自己的解决方案贴上标签的 Terraform。更多的功能(Docker for AWS)还是你所需要的资源(Terraform)。
做出选择很难。Docker for AWS仍然过于年轻,可能是一个不成熟的解决方案。Docker 的开发者将继续开发它,几乎可以肯定它在不久的将来会变得更好。Terraform 给你自由,但代价不小。
就个人而言,我会密切关注Docker for AWS的改进,并保留稍后作出最终判决的权利。在那之前,我略微倾向于选择 Terraform。我喜欢构建东西。这是一个非常微弱的胜利,值得很快重新审视。
第十三章:在 DigitalOcean 中创建和管理 Docker Swarm 集群
* 计划将一个(实现)抛弃;反正你会的。**–弗雷德·布鲁克斯*
我们已经看到了一些在 AWS 中创建和操作 Swarm 集群的方法。现在我们将尝试在DigitalOcean (www.digitalocean.com/)中做同样的事情。我们将探索一些可以与此托管服务提供商一起使用的工具和配置。
与大家熟知的 AWS 不同,DigitalOcean 相对较新,知名度较低。你可能会好奇为什么我选择了 DigitalOcean,而不是像 Azure 和 GCE 这样的其他提供商。原因在于 AWS(以及其他类似提供商)与 DigitalOcean 之间的差异。两者在许多方面不同。比较它们就像比较大卫与哥利亚。一个很小,而另一个(AWS)则庞大无比。DigitalOcean 明白自己无法在 AWS 的领域中与其竞争,因此它决定采取不同的游戏规则。
DigitalOcean 于 2011 年推出,专注于非常特定的需求。与提供 面面俱到 服务的 AWS 不同,DigitalOcean 提供虚拟机。没有多余的花哨功能。你不会在他们的服务目录中迷失,因为几乎没有目录可言。如果你需要一个地方来托管你的集群,并且不想使用那些旨在将你“锁定”的服务,那么 DigitalOcean 可能是一个合适的选择。
DigitalOcean 的主要优点是定价、出色的性能和简单性。如果这些是你所追求的,那么值得尝试一下。
让我们逐一了解这三个优点。
DigitalOcean 的定价可能是所有云服务提供商中最具竞争力的。无论你是需要仅仅几个服务器的小公司,还是寻找一个能够部署数百甚至数千台服务器的大型企业,DigitalOcean 可能比任何其他提供商都便宜。这可能会让你对质量产生疑问。毕竟,便宜的东西往往会在质量上做出牺牲。那 DigitalOcean 会是这种情况吗?
DigitalOcean 提供了非常高性能的机器。所有磁盘驱动器都是 SSD,网络速度为 1 Gbps,而且创建和初始化 Droplets(它们对虚拟机的称呼)不到一分钟。作为对比,AWS EC2 实例的启动时间可能在 1 到 3 分钟之间。
DigitalOcean 提供的最后一个优点是他们的 UI 和 API。两者都简洁易懂。与 AWS 可能具有陡峭学习曲线不同,你应该在几个小时内就能学会如何使用它们。
够了,夸奖的话说得够多了,并非一切都很完美。那么,缺点是什么呢?
DigitalOcean 提供的服务种类不多。它做几件事,但做得很好。它是一个简化的 基础设施即服务 (IaaS) 提供商。它假设你会自己设置服务。没有负载均衡、集中式日志记录、复杂的分析、托管数据库等。如果你需要这些功能,预计你需要自己进行设置。根据你的使用情况,这可能是优势,也可能是劣势。
将 DigitalOcean 与 AWS 进行比较是不公平的,因为它们的功能范围不同。DigitalOcean 并没有试图与 AWS 整体竞争。如果一定要做比较,那就是 DigitalOcean 与 AWS EC2 之间的对比。在这种情况下,DigitalOcean 完全占优势。
我假设你已经有了一个 DigitalOcean 账户。如果没有,请使用以下链接注册:m.do.co/c/ee6d08525457。你将获得 10 美元的信用额度。这应该足够用于本章中的示例。DigitalOcean 价格非常便宜,你可能会发现到本章结束时,账户余额仍然有超过 9 美元。
即使你已经决定使用其他云计算提供商或本地服务器,我还是强烈推荐你阅读本章。它将帮助你将 DigitalOcean 与你选择的提供商进行比较。
让我们尝试使用 DigitalOcean,并通过示例来判断它是否是托管我们的 Swarm 集群的一个好选择。
你可能会注意到,本章的某些部分与你在其他云计算章节中阅读的内容非常相似,甚至完全相同,比如第十二章,在亚马逊 Web 服务中创建和管理 Docker Swarm 集群。部分内容重复的原因是为了让云计算章节对那些阅读过全部内容的人有用,同时也能对跳过其他提供商直接来到这里的人有所帮助。
在我们进行实际操作之前,我们需要获取访问密钥并决定将在哪个区域运行集群。
设置环境变量
在第十二章,在亚马逊 Web 服务中创建和管理 Docker Swarm 集群,我们安装了 AWS 命令行接口 (CLI) (aws.amazon.com/cli/),它帮助我们完成了一些任务。DigitalOcean 有一个类似的接口,叫做 doctl。我们需要安装它吗?我认为我们不需要 DigitalOcean 的 CLI。它们的 API 清晰且定义明确,我们可以通过简单的 curl 请求完成 CLI 的所有功能。DigitalOcean 证明了一个设计良好的 API 能够做到这一点,并且可以成为进入系统的唯一入口,省去了我们处理像 CLI 这样的中介应用的麻烦。
在我们开始使用 API 之前,我们应该生成一个访问令牌,作为认证方法。
请打开DigitalOcean 令牌屏幕 (cloud.digitalocean.com/settings/api/tokens)并点击生成新令牌按钮。你将看到新个人访问令牌的弹出窗口,如下图所示:

图 12-1:DigitalOcean 新个人访问令牌屏幕
输入devops21作为令牌名称,然后点击生成令牌按钮。你将看到新生成的令牌。我们会将它放入环境变量DIGITALOCEAN_ACCESS_TOKEN中。
本章中的所有命令都可以在 Gist 12-digital-ocean.sh中找到 (gist.github.com/vfarcic/81248d2b6551f6a1c2bcfb76026bae5e)。
在执行接下来的命令之前,请先复制令牌:
export DIGITALOCEAN_ACCESS_TOKEN=[...]
请将[...]替换为实际的令牌。
现在我们可以决定我们的集群将在哪个区域运行。
我们可以通过向api.digitalocean.com/v2/regions发送请求来查看当前可用的区域:
curl -X GET
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN"
"https://api.digitalocean.com/v2/regions"
| jq '.'
我们向地区 API 发送了一个 HTTP GET请求。请求中包含访问令牌。响应通过管道传递给jq。
输出的一部分如下:
{
"regions": [
...
{
"name": "San Francisco 2",
"slug": "sfo2",
"sizes": [
"512mb",
"1gb",
"2gb"
],
"features": [
"private_networking",
"backups",
"ipv6",
"metadata",
"storage"
],
"available": true
},
...
],
"links": {},
"meta": {
"total": 12
}
}
从响应底部可以看出,DigitalOcean 目前支持十二个区域。每个区域都包含有关可用 Droplet 大小和支持的功能的信息。
在本章中,我将使用旧金山 2(sfo2)区域。你可以根据自己的位置随意更改为最接近的区域。如果你选择在其他区域运行示例,请确保该区域包含private_networking功能。
我们将把区域放入环境变量DIGITALOCEAN_REGION中:
export DIGITALOCEAN_REGION=sfo2
现在我们已经准备好所有前提条件,可以在 DigitalOcean 中创建第一个 Swarm 集群。由于我们在本书的大部分时间里都使用了 Docker Machine,它将是我们的首选。
使用 Docker Machine 和 DigitalOcean API 设置 Swarm 集群
我们将继续使用vfarcic/cloud-provisioning (github.com/vfarcic/cloud-provisioning) 仓库,它包含了帮助我们的配置和脚本。你已经将它克隆。为了保险起见,我们将拉取最新版本:
cd cloud-provisioning
git pull
让我们创建第一个 Droplet:
docker-machine create \
--driver digitalocean \
--digitalocean-size 1gb \
--digitalocean-private-networking \
swarm-1
我们指定Docker Machine使用digitalocean驱动程序,在我们定义为环境变量DIGITALOCEAN_REGION的区域中创建一个实例。Droplet 的大小为 1GB,并启用了私有网络。
Docker Machine 启动了一个 DigitalOcean Droplet,配置了 Ubuntu,并安装和配置了 Docker Engine。
正如你无疑已经注意到的,大家都在尝试为相同的事物想出不同的名称。DigitalOcean 也不例外。它们提出了术语droplet,即虚拟私人服务器的不同名称。是同样的东西,不同的名字。
现在我们可以初始化集群了。我们应该使用私有 IP 进行节点之间的所有通信。不幸的是,docker-machine ip 命令只返回公共 IP,因此我们必须采取不同的方法来获取私有 IP。
我们可以向 droplets API 发送 GET 请求:
curl -X GET \
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" \
"https://api.digitalocean.com/v2/droplets" \
| jq '.'
一部分输出如下:
{
"droplets": [
{
"id": 33906152,
"name": "swarm-1",
...
"networks": {
"v4": [
{
"ip_address": "138.68.11.80",
"netmask": "255.255.240.0",
"gateway": "138.68.0.1",
"type": "public"
},
{
"ip_address": "10.138.64.175",
"netmask": "255.255.0.0",
"gateway": "10.138.0.1",
"type": "private"
}
],
"v6": []
},
...
],
"links": {},
"meta": {
"total": 1
}
}
droplets API 返回了我们拥有的所有 droplet 的信息(目前只有一个)。我们只关心新创建的名为 swarm-1 实例的私有 IP。我们可以通过过滤结果,只包括名为 swarm-1 的 droplet,并选择类型为 private 的 v4 元素来获取它。
我们将使用 jq (stedolan.github.io/jq/) 来过滤输出并获取我们需要的信息。如果你还没有安装,请下载并安装适合你操作系统的 jq 版本。
发送请求、过滤结果并将私有 IP 存储为环境变量的命令如下:
MANAGER_IP=$(curl -X GET \
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" \
"https://api.digitalocean.com/v2/droplets" \
| jq -r '.droplets[]
| select(.name=="swarm-1").networks.v4[]
| select(.type=="private").ip_address')
我们向 droplets API 发送了 GET 请求,使用 jq select 语句丢弃了所有条目,除了名为 swarm-1 的条目。接着用了另一个 select 语句,只返回了私有地址。输出被存储为环境变量 MANAGER_IP。
为了保险起见,我们可以回显新创建变量的值:
echo $MANAGER_IP
输出如下:
10.138.64.175
现在我们可以像在前几章中一样执行 swarm init 命令:
eval $(docker-machine env swarm-1)
docker swarm init \
--advertise-addr $MANAGER_IP
让我们确认集群确实已经初始化:
docker node ls
输出如下(为了简洁,ID 已删除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-1 Ready Active Leader
现在集群已经初始化,我们可以添加更多节点。我们将首先创建两个新实例,并将它们作为管理节点加入:
MANAGER_TOKEN=$(docker swarm join-token -q manager)
for i in 2 3; do
docker-machine create \
--driver digitalocean \
--digitalocean-size 1gb \
--digitalocean-private-networking \
swarm-$i
IP=$(curl -X GET \
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" \
"https://api.digitalocean.com/v2/droplets" \
| jq -r ".droplets[]
| select(.name==\"swarm-$i\").networks.v4[]
| select(.type==\"private\").ip_address")
eval $(docker-machine env swarm-$i)
docker swarm join \
--token $MANAGER_TOKEN \
--advertise-addr $IP \
$MANAGER_IP:2377
done
不需要解释我们刚刚执行的命令,因为它们是之前使用过的命令的组合。
我们还将添加一些工作节点:
WORKER_TOKEN=$(docker swarm join-token -q worker)
for i in 4 5; do
docker-machine create \
--driver digitalocean \
--digitalocean-size 1gb \
--digitalocean-private-networking \
swarm-$i
IP=$(curl -X GET \
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" \
"https://api.digitalocean.com/v2/droplets" \
| jq -r ".droplets[]
| select(.name==\"swarm-$i\").networks.v4[]
| select(.type=="\private\").ip_address")
eval $(docker-machine env swarm-$i)
docker swarm join \
--token $WORKER_TOKEN \
--advertise-addr $IP \
$MANAGER_IP:2377
done
让我们确认这五个节点确实在组成集群:
eval $(docker-machine env swarm-1)
docker node ls
输出如下(为了简洁,ID 已删除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-5 Ready Active
swarm-1 Ready Active Leader
swarm-4 Ready Active
swarm-2 Ready Active Reachable
swarm-3 Ready Active Reachable
就这样。我们的集群已经准备好。剩下的就是部署几个服务,并确认集群按预期工作。
由于我们已经创建了服务很多次,我们将通过 vfarcic/docker-flow-proxy/docker-compose-stack.yml (github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml) 和 vfarcic/go-demo/docker-compose-stack.yml (github.com/vfarcic/go-demo/blob/master/docker-compose-stack.yml) Compose 堆栈加速这个过程。它们将创建 proxy、swarm-listener、go-demo-db 和 go-demo 服务:
docker-machine ssh swarm-1
sudo docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
sudo docker stack deploy \
-c proxy-stack.yml proxy
curl -o go-demo-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/go-demo/master/docker-compose-stack.yml
sudo docker stack deploy \
-c go-demo-stack.yml go-demo
exit
docker service ls
非 Windows 用户无需进入 swarm-1 机器,可以直接从他们的笔记本电脑部署堆栈来实现相同的结果。
下载所有镜像需要一些时间。过一会儿,service ls command 的输出应该如下所示(为了简洁,已去除 ID):
NAME REPLICAS IMAGE COMMAND
go-demo 3/3 vfarcic/go-demo:1.2
go-demo-db 1/1 mongo:3.2.10
proxy 3/3 vfarcic/docker-flow-proxy
swarm-listener 1/1 vfarcic/docker-flow-swarm-listener
让我们确认 go-demo 服务是否可访问:
curl -i $(docker-machine ip swarm-1)/demo/hello
输出如下:
HTTP/1.1 200 OK
Date: Wed, 07 Dec 2016 05:05:58 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
我们使用 Docker Machine 和 DigitalOcean API 设置了整个 Swarm 集群。这是我们所需的一切吗?这取决于我们为集群定义的需求。我们可能应该添加一些浮动 IP 地址。
DigitalOcean 的浮动 IP 是一个公开可访问的静态 IP 地址,可以分配给您的一个 Droplet。通过 DigitalOcean 控制面板或 API,浮动 IP 还可以立即重新映射到同一数据中心中的其他 Droplets。这种即时重映射的能力使您能够设计和创建高可用性(HA)服务器基础设施,这样就没有单点故障,通过为服务器的入口点或网关增加冗余,提升可靠性。
换句话说,我们可能应该设置至少两个浮动 IP 并将它们映射到集群中的两个 Droplets。这两个(或更多)IP 将作为我们的 DNS 记录。这样,当某个实例故障并且我们用新实例替换时,我们可以重新映射弹性 IP,而不会影响我们的用户。
还有不少其他改进我们可以做。然而,这样做会让我们陷入一个尴尬的境地。我们将使用一个并不适合用来搭建复杂集群的工具。
创建虚拟机的速度相当慢。Docker Machine 花费了太多时间来为其配置 Ubuntu 并安装 Docker 引擎。我们可以通过创建预装了 Docker 引擎的快照来减少这一时间。然而,进行这样的操作后,使用 Docker Machine 的主要理由就会消失。它的主要优势是简单性。一旦我们开始用其他资源来复杂化设置,就会发现简单性被太多的临时命令所取代。
运行 docker-machine 并结合 API 请求,当我们处理一个小型集群时效果很好,特别是当我们想要快速创建某些东西并且这些东西可能不太持久时。最大的问题是,到目前为止我们所做的一切都是临时命令。很可能我们无法在第二次重复相同的步骤。我们的基础设施没有文档记录,因此我们的团队不知道我们的集群是什么组成的。
我的建议是,在 DigitalOcean 使用 docker-machine 来快速搭建一个集群,主要用于演示目的。这对于生产环境也可以有用,只要集群相对较小。
如果我们想要设置一个更复杂、更大且可能更持久的解决方案,我们应该考虑其他替代方案。
让我们删除我们创建的集群,并从零开始探索替代方案:
for i in 1 2 3 4 5; do
docker-machine rm -f swarm-$i
done
如果你读过上一章,你可能会期待看到一个名为 Docker for DigitalOcean 的子章节。实际上并没有这样的章节。至少在我写这章的时候没有。因此,我们将直接进入 Packer 和 Terraform。
使用 Packer 和 Terraform 设置 Swarm 集群
这次我们将使用一套与 Docker 完全无关的工具。它将是Packer (www.packer.io/) 和 Terraform (www.terraform.io/)。这两个工具都来自 HashiCorp (www.hashicorp.com/)。Packer 允许我们创建机器镜像,而 Terraform 则帮助我们创建、修改和改进集群基础设施。这两个工具几乎支持所有主要的云服务提供商。
它们可以与 Amazon EC2、CloudStack、DigitalOcean、Google Compute Engine (GCE)、Microsoft Azure、VMWare、VirtualBox 等多个平台一起使用。基础设施的独立性使我们能够避免厂商锁定。只需进行最小的配置更改,我们就能轻松地将集群从一个提供商迁移到另一个。Swarm 被设计为无缝工作,无论我们使用哪个托管提供商,只要基础设施定义得当。使用 Packer 和 Terraform,我们可以以一种方式定义基础设施,使得从一个提供商切换到另一个提供商变得尽可能平滑。
使用 Packer 创建 DigitalOcean 快照
vfarcic/cloud-provisioning (github.com/vfarcic/cloud-provisioning) 仓库已经包含了我们将要使用的 Packer 和 Terraform 配置。它们位于 terraform/do 目录下:
cd terraform/do
第一步是使用 Packer 创建一个快照。为此,我们需要将 DigitalOcean 的 API 令牌设置为环境变量 DIGITALOCEAN_API_TOKEN。这与我们设置为环境变量 DIGITALOCEAN_ACCESS_TOKEN 的令牌是相同的。不幸的是,Docker Machine 和 Packer 使用了不同的命名标准:
export DIGITALOCEAN_API_TOKEN=[...]
请将 [...] 替换为实际的令牌。
我们将从同一个快照实例化所有 Swarm 节点。该快照将基于 Ubuntu,并安装最新版本的 Docker 引擎。
我们即将构建的镜像的 JSON 定义位于 terraform/do/packer-ubuntu-docker.json (github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/packer-ubuntu-docker.json):
cat packer-ubuntu-docker.json
配置由两个部分组成:builders 和 provisioners:
{
"builders": [{
...
}],
"provisioners": [{
...
}]
}
builders 部分定义了 Packer 构建快照所需的所有信息。provisioners 部分描述了用于为由 builders 创建的机器安装和配置软件的命令。唯一必须的部分是 builders。
构建器负责创建机器并生成适用于各种平台的镜像。例如,EC2、VMware、VirtualBox 等都有单独的构建器。Packer 默认包含许多构建器,并且还可以扩展以添加新的构建器。
我们将使用的构建器部分如下:
"builders": [{
"type": "digitalocean",
"region": "sfo2",
"image": "ubuntu-16-04-x64",
"size": "1gb",
"private_networking": true,
"snapshot_name": "devops21-{{timestamp}}"
}]
每种类型的构建器都有特定的参数可以使用。我们指定了type为digitalocean。有关更多信息,请访问 DigitalOcean 构建器页面(www.packer.io/docs/builders/digitalocean.html)。
请注意,当使用digitalocean类型时,我们必须提供 token。我们可以通过api_token字段指定它。不过,还有另一种方法。如果没有指定该字段,Packer 会尝试从环境变量DIGITALOCEAN_API_TOKEN中获取值。由于我们已经导出了它,因此无需在 Packer 配置中重复填写 token。此外,token 应该保密。
将其放入配置中可能会导致暴露风险。区域非常重要,因为快照只能在一个区域内创建。如果我们想要在多个区域共享相同的机器,每个区域都需要作为一个单独的构建器来指定。
我们将镜像设置为ubuntu-16-04-x64。这将是我们用来创建自己镜像的基础镜像。快照的大小与我们将要创建的 Droplet 的大小没有直接关系,因此无需将其设置得太大。我们将其设置为 1GB。
默认情况下,DigitalOcean 只启用公共网络,因此我们将private_networking设置为true。稍后,我们将设置 Swarm 通信,仅通过私有网络进行。
snapshot_name字段是我们为此快照指定的名称。由于没有选项可以覆盖现有快照,因此名称必须是唯一的,因此我们在名称中加入了timestamp。
有关更多信息,请访问 DigitalOcean 构建器页面(www.packer.io/docs/builders/digitalocean.html)。
第二部分是 provisioners。它包含了 Packer 在将机器转换为快照之前,安装和配置软件时应该使用的所有 provisioner 的数组。
我们可以使用相当多的provisioner类型。如果你读过《DevOps 2.0 工具包》(www.amazon.com/dp/B01BJ4V66M),你会知道我提倡将 Ansible 作为首选的provisioner。我们是否也应该在这里使用它?在大多数情况下,当构建用于运行 Docker 容器的镜像时,我更倾向于使用简单的 Shell。将 Ansible 更改为 Shell 的原因在于,provisioner在运行实时服务器时需要完成的目标与在构建镜像时有所不同。
与 Shell 不同,Ansible(以及大多数其他provisioners)是幂等的。它们会验证实际状态,并根据需要执行一个或另一个操作,以确保所需状态得到满足。这是一种很好的方法,因为我们可以随意运行 Ansible,结果总是相同的。例如,如果我们指定要安装 JDK 8,Ansible 会通过 SSH 连接到目标服务器,发现 JDK 没有安装,然后进行安装。下次运行时,它会发现 JDK 已经安装并不做任何操作。这样的做法允许我们随时运行 Ansible playbook,最终都会安装 JDK。如果我们试图通过 Shell 脚本实现相同的功能,就需要编写冗长的if/else语句。如果 JDK 已安装,则什么也不做;如果未安装,则安装;如果安装了但版本不对,则升级,依此类推。
那么,为什么不与 Packer 一起使用它呢?答案很简单。我们不需要幂等性,因为在创建镜像时只会运行一次。我们不会在运行的实例上使用它。你还记得 宠物与牲畜 的讨论吗?我们的虚拟机将从已经包含我们所需的一切的镜像中实例化。如果该虚拟机的状态发生变化,我们将终止它并创建一个新的。如果我们需要进行升级或安装额外的软件,我们不会在运行中的实例内完成,而是创建一个新的镜像,销毁运行中的实例,并基于更新后的镜像实例化新的虚拟机。
幂等性是我们使用 Ansible 的唯一原因吗?绝对不是!当我们需要定义复杂的服务器配置时,Ansible 是一个非常方便的工具。然而,在我们的情况下,配置非常简单。我们只需要 Docker 引擎,其他的没有太多需求。几条 Shell 命令就能轻松安装 Docker,比起定义 Ansible playbook,写 Shell 命令更简单、更快。
安装 Ansible 所需的命令数量可能与安装 Docker 相同。
简而言之,我们将使用 shell 作为构建 AMI 的provisioner工具。
我们将使用的provisioners部分如下:
"provisioners": [{
"type": "shell",
"inline": [
"sudo apt-get update",
"sudo apt-get install -y apt-transport-https ca-certificates nfs-common",
"sudo apt-key adv --keyserver hkp://ha.pool.sks-keyservers.net:80\
--recv-keys 58118E89F3A912897C070ADBF76221572C52609D",
"echo 'deb https://apt.dockerproject.org/repo ubuntu-xenial main'\
| sudo tee /etc/apt/sources.list.d/docker.list",
"sudo apt-get update",
"sudo apt-get install -y docker-engine"
]
}]
shell 类型后面跟着一组命令。这些命令与我们可以在《在 Ubuntu 上安装 Docker》(docs.docker.com/engine/installation/linux/ubuntulinux/)页面上找到的命令相同。
现在我们大致了解了 Packer 配置的工作原理,接下来可以继续构建镜像:
packer build -machine-readable \
packer-ubuntu-docker.json \
| tee packer-ubuntu-docker.log
我们运行packer build命令,使用packer-ubuntu-docker.json并将machine-readable格式的输出发送到packer-ubuntu-docker.log文件。机器可读的输出将使我们能够轻松解析,并获取刚刚创建的快照的 ID。
输出的最后几行如下:
...
1481087549,,ui,say,Build 'digitalocean' finished.
1481087549,,ui,say,n==> Builds finished. The artifacts of successful builds are:
1481087549,digitalocean,artifact-count,1
1481087549,digitalocean,artifact,0,builder-id,pearkes.digitalocean
1481087549,digitalocean,artifact,0,id,sfo2:21373017
1481087549,digitalocean,artifact,0,string,A snapshot was created: \
'devops21-1481087268' (ID: 21373017) in region 'sfo2'
1481087549,digitalocean,artifact,0,files-count,0
1481087549,digitalocean,artifact,0,end
1481087549,,ui,say,--> digitalocean: A snapshot was created:\
'devops21-1481087268' (ID: 21373017) in region 'sfo2'
除了确认构建成功外,输出的相关部分是行 id,sfo2:21373017。它包含我们需要的快照 ID,以便根据该镜像实例化虚拟机。你可能希望将 packer-ubuntu-docker.log 存储在代码库中,以防你需要从其他服务器获取该 ID。
我们执行的过程流可以通过 图 12-2 描述:

图 12-2:Packer 过程的流程
现在我们准备好使用基于我们构建的快照的虚拟机来创建一个 Swarm 集群。
使用 Terraform 在 DigitalOcean 上创建 Swarm 集群
Terraform 是“每个人都使用不同的环境变量来存储 token”俱乐部的第三个成员。它期望将 token 存储为环境变量 DIGITALOCEAN_TOKEN:
export DIGITALOCEAN_TOKEN=[...]
请将 [...] 替换为实际的 token。
Terraform 不强制要求我们采用任何特定的文件结构。我们可以在一个文件中定义所有内容。然而,这并不意味着我们应该这样做。Terraform 配置文件可能会变得很大,将逻辑部分拆分到不同的文件中通常是一个好主意。在我们的案例中,我们将有三个 tf 文件。terraform/do/variables.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/variables.tf) 文件包含所有变量。如果我们需要更改任何参数,我们知道在哪里可以找到它。terraform/do/common.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/common.tf) 文件包含可能在其他场合重复使用的元素定义。最后,terraform/do/swarm.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/swarm.tf) 文件包含与 Swarm 相关的资源。我们将分别探索每个 Terraform 配置文件。
terraform/do/variables.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/variables.tf) 文件的内容如下:
variable "swarm_manager_token" {
default = ""
}
variable "swarm_worker_token" {
default = ""
}
variable "swarm_snapshot_id" {
default = "unknown"
}
variable "swarm_manager_ip" {
default = ""
}
variable "swarm_managers" {
default = 3
}
variable "swarm_workers" {
default = 2
}
variable "swarm_region" {
default = "sfo2"
}
variable "swarm_instance_size" {
default = "1gb"
}
variable "swarm_init" {
default = false
}
swarm_manager_token和swarm_worker_token将用于将节点加入集群。swarm_snapshot_id将保存我们使用 Packer 创建的快照的 ID。swarm_manager_ip变量是我们需要提供的其中一个管理节点的 IP,以便节点能够加入集群。swarm_managers和swarm_workers定义了我们希望拥有的每种类型节点的数量。swarm_region定义了集群运行的区域,而swarm_instance_size设置为 1GB。如果您开始使用此 Terraform 配置创建一个实际的集群,可以随时更改为更大的大小。最后,swarm_init变量允许我们指定是否为第一次运行,并且节点应初始化集群。我们很快会看到它的用法。
terraform/do/common.tf(github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/common.tf)文件的内容如下:
resource "digitalocean_ssh_key" "docker" {
name = "devops21-do"
public_key = "${file("devops21-do.pub")}"
}
resource "digitalocean_floating_ip" "docker_1" {
droplet_id = "${digitalocean_droplet.swarm-manager.0.id}"
region = "${var.swarm_region}"
}
resource "digitalocean_floating_ip" "docker_2" {
droplet_id = "${digitalocean_droplet.swarm-manager.1.id}"
region = "${var.swarm_region}"
}
resource "digitalocean_floating_ip" "docker_3" {
droplet_id = "${digitalocean_droplet.swarm-manager.2.id}"
region = "${var.swarm_region}"
}
output "floating_ip_1" {
value = "${digitalocean_floating_ip.docker_1.ip_address}"
}
output "floating_ip_2" {
value = "${digitalocean_floating_ip.docker_2.ip_address}"
}
output "floating_ip_3" {
value = "${digitalocean_floating_ip.docker_3.ip_address}"
}
每个资源都定义了一个类型(例如,digitalocean_ssh_key)和一个名称(例如,docker)。类型决定了应该创建哪个资源,并且必须是当前支持的类型之一。
第一个资源digitalocean_ssh_key允许我们管理用于访问滴水(droplet)的 SSH 密钥。通过此资源创建的密钥可以通过其 ID 或指纹在滴水配置中引用。我们将其设置为稍后将创建的devops21-do.pub文件的值。
我们使用的第二个资源是digitalocean_floating_ip。它代表一个可公开访问的静态 IP 地址,可以映射到我们的一个滴水上。我们定义了三个这样的 IP 地址。它们将在我们的 DNS 配置中使用。这样,当对您的域名发出请求时,DNS 会将其重定向到其中一个浮动 IP。如果其中一个滴水宕机,DNS 应该使用其他条目之一。这样,您就有时间将浮动 IP 从失败的滴水切换到新的滴水。
请参阅DIGITALOCEAN_SSH_KEY(www.terraform.io/docs/providers/do/r/ssh_key.html)和DIGITALOCEAN_FLOATING_IP(www.terraform.io/docs/providers/do/r/floating_ip.html)页面以获取更多信息。
除了资源,我们还定义了几个输出。它们表示在执行 Terraform apply 时将显示的值,并且可以通过输出命令轻松查询。
在构建潜在的复杂基础设施时,Terraform 会为所有资源存储数百或数千个属性值。但是,作为用户,我们可能只对几个重要的值感兴趣,例如管理节点 IP。输出是告诉 Terraform 哪些数据是相关的一种方式。
在我们的案例中,输出是浮动 IP 的地址。
请参阅输出配置(www.terraform.io/docs/configuration/outputs.html)页面以获取更多信息。
现在进入真正的重点。terraform/do/swarm.tf(github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/swarm.tf)文件包含了我们将要创建的所有实例的定义。
由于这个文件的内容比其他文件稍大,我们将分别检查每个资源。
第一项资源是名为swarm-manager的digitalocean_droplet类型。它的目的是创建 Swarm 管理节点:
resource "digitalocean_droplet" "swarm-manager" {
image = "${var.swarm_snapshot_id}"
size = "${var.swarm_instance_size}"
count = "${var.swarm_managers}"
name = "${format("swarm-manager-%02d", (count.index + 1))}"
region = "${var.swarm_region}"
private_networking = true
ssh_keys = [
"${digitalocean_ssh_key.docker.id}"
]
connection {
user = "root"
private_key = "${file("devops21-do")}"
agent = false
}
provisioner "remote-exec" {
inline = [
"if ${var.swarm_init}; then docker swarm init \
--advertise-addr ${self.ipv4_address_private}; fi",
"if ! ${var.swarm_init}; then docker swarm join \
--token ${var.swarm_manager_token} --advertise-addr
${self.ipv4_address_private} ${var.swarm_manager_ip}:2377; fi"
]
}
}
该资源包含了引用我们用 Packer 创建的快照的镜像。实际的值是一个变量,我们将在运行时定义。size指定了我们想要创建的实例的大小。默认值从变量swarm_instance_size中获取,默认设置为 1 GB。像其他变量一样,它可以在运行时被重写。
count字段定义了我们想要创建的管理节点数量。当我们第一次运行 terraform 时,值应该是 1,因为我们希望从一个管理节点开始,初始化集群。之后,值应根据变量中定义的内容进行调整。我们很快就会看到这两种组合的使用情况。
name、region和private_networking应该是自解释的。ssh-keys类型是一个数组,此时仅包含一个元素;即我们在common.tf文件中定义的digitalocean_ssh_key资源的 ID。
connection字段定义了 SSH 连接的详细信息。用户将是 root。我们将使用devops21-do密钥,而不是密码。
最后,定义了provisioner。这个想法是在创建镜像的过程中尽可能多地进行预配置。这样,实例创建的速度会更快,因为唯一的操作就是从镜像创建虚拟机。然而,通常有些预配置是无法在创建镜像时完成的。swarm init命令就是其中之一。在获取服务器的 IP 之前,我们无法初始化第一个 Swarm 节点。换句话说,服务器需要运行(因此拥有 IP)后,才能执行swarm init命令。
由于第一个节点需要初始化集群,而其他节点则应加入集群,因此我们使用if语句来区分两种情况。如果变量swarm_init为true,则将执行docker swarm init命令。另一方面,如果变量swarm_init设置为false,则命令将是docker swarm join。在这种情况下,我们使用另一个变量swarm_manager_ip来告诉节点使用哪个管理节点加入集群。请注意,IP 是通过特殊语法self.ipv4_address_private获得的。我们引用了自身并获取了ipv4_address_private。从资源中我们可以获取许多其他属性。请参阅DIGITALOCEAN_DROPLET(www.terraform.io/docs/providers/do/r/droplet.html)页面获取更多信息。
让我们看一下名为swarm-worker的digitalocean_droplet资源:
resource "digitalocean_droplet" "swarm-worker" {
image = "${var.swarm_snapshot_id}"
size = "${var.swarm_instance_size}"
count = "${var.swarm_workers}"
name = "${format("swarm-worker-%02d", (count.index + 1))}"
region = "${var.swarm_region}"
private_networking = true
ssh_keys = [
"${digitalocean_ssh_key.docker.id}"
]
connection {
user = "root"
private_key = "${file("devops21-do")}"
agent = false
}
provisioner "remote-exec" {
inline = [
"docker swarm join --token ${var.swarm_worker_token} \
--advertise-addr ${self.ipv4_address_private} ${var.swarm_manager_ip}:\
2377"
]
}
}
swarm-worker资源与swarm-manager几乎相同。唯一的区别在于使用swarm_workers变量的count字段和provisioner。由于工作节点无法初始化集群,因此不需要if语句,因此我们只需要执行docker swarm join命令。Terraform 使用命名约定允许我们通过添加TF_VAR_前缀来指定值作为环境变量。例如,我们可以通过设置环境变量TF_VAR_swarm_snapshot_id来指定变量swarm_snapshot_id的值。另一种方式是使用-var参数。我更喜欢使用环境变量,因为它们允许我只指定一次,而不是在每个命令中都添加-var。terraform/do/swarm.tf(github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/swarm.tf)规范的最后一部分是输出。
我们定义的输出如下:
output "swarm_manager_1_public_ip" {
value = "${digitalocean_droplet.swarm-manager.0.ipv4_address}"
}
output "swarm_manager_1_private_ip" {
value = "${digitalocean_droplet.swarm-manager.0.ipv4_address_private}"
}
output "swarm_manager_2_public_ip" {
value = "${digitalocean_droplet.swarm-manager.1.ipv4_address}"
}
output "swarm_manager_2_private_ip" {
value = "${digitalocean_droplet.swarm-manager.1.ipv4_address_private}"
}
output "swarm_manager_3_public_ip" {
value = "${digitalocean_droplet.swarm-manager.2.ipv4_address}"
}
output "swarm_manager_3_private_ip" {
value = "${digitalocean_droplet.swarm-manager.2.ipv4_address_private}"
}
它们是管理节点的公有和私有 IP 地址。由于只有少数(如果有的话)需要知道工作节点的 IP 地址,我们没有将其定义为输出。
由于我们将使用通过 Packer 创建的快照,因此我们需要从packer-ubuntu-docker.log中检索 ID。让我们再看一遍文件:
cat packer-ubuntu-docker.log
输出中的重要行如下:
1481087549,digitalocean,artifact,0,id,sfo2:21373017
接下来的命令解析输出并提取 ID:
export TF_VAR_swarm_snapshot_id=$( \
grep 'artifact,0,id' \
packer-ubuntu-docker.log \
| cut -d: -f2)
让我们再确认一下命令是否按预期执行:
echo $TF_VAR_swarm_snapshot_id
输出如下:
21373017
我们得到了快照的 ID。在开始创建资源之前,我们需要创建在 Terraform 配置中引用的 SSH 密钥devops21-do。
我们将使用ssh-keygen创建 SSH 密钥:
ssh-keygen -t rsa
当提示输入保存密钥的文件时,请回答devops21-do。其他问题可以按您的喜好回答。我会将它们全部留空。
输出应类似于以下内容:
Generating public/private rsa key pair.
Enter file in which to save the key (/Users/vfarcic/.ssh/id_rsa): devops21-do
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in devops21-do.
Your public key has been saved in devops21-do.pub.
The key fingerprint is:
SHA256:a9BqjLkcC9eMnuKH+TZPE6E9S0w+cDQD4HTWEY9CuVk \
vfarcic@Viktors-MacBook-Pro-2.local
The key's randomart image is:
+---[RSA 2048]----+
| o.=+*o |
| o +..E= |
| . o+= . |
| oX o |
| . X S |
| O B . |
| .o* X o |
| +=+B o |
| ..=Bo. |
+----[SHA256]-----+
现在devops21-do密钥已经创建,我们可以开始使用 Terraform。在创建集群及其周围基础设施之前,我们应该要求 Terraform 显示执行计划。
给 Terraform v0.8+ 用户的注意事项。
通常,我们不需要指定目标就能查看完整的执行计划。然而,Terraform v0.8 引入了一个 bug,有时会阻止我们输出计划,特别是当资源引用了一个尚未创建的其他资源时。在这种情况下,digitalocean_floating_ip.docker_2和digitalocean_floating_ip.docker_3就是这样的资源。接下来的命令中的目标是作为一种临时解决方案,直到问题被修复:
terraform plan \
-target digitalocean_droplet.swarm-manager \
-target digitalocean_droplet.swarm-worker \
结果是一个包含资源及其属性的详细列表。由于输出过大,无法全部打印,我将仅限于资源类型和名称:
...
+ digitalocean_droplet.swarm-manager.0
...
+ digitalocean_droplet.swarm-manager.1
...
+ digitalocean_droplet.swarm-manager.2
...
+ digitalocean_droplet.swarm-worker.0
...
+ digitalocean_droplet.swarm-worker.1
...
+ digitalocean_ssh_key.docker
...
Plan: 6 to add, 0 to change, 0 to destroy.
由于这是第一次执行,如果我们执行terraform apply,所有资源将会被创建。我们将获得五个 droplet,三个管理节点和两个工作节点。与此同时,还会创建三个浮动 IP 和一个 SSH 密钥。
如果你查看完整的输出,你会注意到一些属性值被设置为<computed>。这意味着 Terraform 在创建资源之前无法知道实际的值。一个好的例子是 IP 地址。它们在 droplet 创建之前是不存在的。
我们还可以使用graph命令输出计划:
terraform graph
输出如下所示:
digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] digitalocean_droplet.swarm-manager" [label = \
"digitalocean_droplet.swarm-manager", shape = "box"]
"[root] digitalocean_droplet.swarm-worker" [label = \
"digitalocean_droplet.swarm-worker", shape = "box"]
"[root] digitalocean_floating_ip.docker_1" [label = \
"digitalocean_floating_ip.docker_1", shape = "box"]
"[root] digitalocean_floating_ip.docker_2" [label = \
"digitalocean_floating_ip.docker_2", shape = "box"]
"[root] digitalocean_floating_ip.docker_3" [label = \
"digitalocean_floating_ip.docker_3", shape = "box"]
"[root] digitalocean_ssh_key.docker" [label = \
"digitalocean_ssh_key.docker", shape = "box"]
"[root] provider.digitalocean" [label = \
"provider.digitalocean", shape = "diamond"]
"[root] digitalocean_droplet.swarm-manager" \
-> "[root] digitalocean_ssh_key.docker"
"[root] digitalocean_droplet.swarm-manager" \
-> "[root] provider.digitalocean"
"[root] digitalocean_droplet.swarm-worker" \
-> "[root] digitalocean_ssh_key.docker"
"[root] digitalocean_droplet.swarm-worker" \
-> "[root] provider.digitalocean"
"[root] digitalocean_floating_ip.docker_1"
-> "[root] digitalocean_droplet.swarm-manager"
"[root] digitalocean_floating_ip.docker_1" \
-> "[root] provider.digitalocean"
"[root] digitalocean_floating_ip.docker_2" \
-> "[root] digitalocean_droplet.swarm-manager"
"[root] digitalocean_floating_ip.docker_2" \
-> "[root] provider.digitalocean"
"[root] digitalocean_floating_ip.docker_3" \
-> "[root] digitalocean_droplet.swarm-manager"
"[root] digitalocean_floating_ip.docker_3" \
-> "[root] provider.digitalocean"
"[root] digitalocean_ssh_key.docker" \
-> "[root] provider.digitalocean"
}
}
这本身并不是很有用。
graph命令用于生成配置或执行计划的可视化表示。输出采用 DOT 格式,可以被 GraphViz 用来生成图形。
请访问Graphviz下载页面(www.graphviz.org/Download.php),下载并安装与您的操作系统兼容的发行版。
现在我们可以将graph命令与dot结合使用:
terraform graph | dot -Tpng > graph.png
输出应该与图 11-10:中的相同。

图 12-3:由 Graphviz 从 terraform graph 命令的输出生成的图像
对计划的可视化让我们能够看到不同资源之间的依赖关系。在我们的案例中,所有资源都将使用digitalocean提供程序。两种实例类型将依赖于 SSH 密钥 docker,而浮动 IP 将附加到管理节点的 droplet 上。
当定义了依赖关系时,我们不需要明确指定所有需要的资源。
作为一个例子,让我们来看一下当我们限制只有一个 Swarm 管理节点时,Terraform 生成的计划,以便初始化集群:
terraform plan \
-target digitalocean_droplet.swarm-manager \
-var swarm_init=true \
-var swarm_managers=1
运行时变量swarm_init和swarm_managers将被用来告诉 Terraform 我们想用一个管理节点来初始化集群。plan 命令会考虑这些变量并输出执行计划。
输出仅限于资源类型和名称,如下所示:
+ digitalocean_droplet.swarm-manager
...
+ digitalocean_ssh_key.docker
...
Plan: 2 to add, 0 to change, 0 to destroy.
即使我们指定只想要swarm-manager资源的计划,Terraform 仍然注意到它依赖于 SSH 密钥 docker,并将其包含在执行计划中。
我们将从小做起,首先创建一个管理节点实例来初始化集群。正如我们从计划中看到的,它依赖于 SSH 密钥,因此 Terraform 也会创建它:
terraform apply \
-target digitalocean_droplet.swarm-manager \
-var swarm_init=true \
-var swarm_managers=1
输出太大,无法在书中完全展示。如果你从终端查看,会注意到首先创建的是 SSH 密钥,因为swarm-manager依赖于它。请注意,我们没有显式指定依赖关系。然而,由于资源在ssh_keys字段中指定了它,Terraform 理解它是依赖关系。
一旦swarm-manager实例创建完成,Terraform 会等待直到 SSH 访问可用。在成功连接到新实例后,它执行了初始化集群的配置命令。
输出的最后几行如下:
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
...
Outputs:
swarm_manager_1_private_ip = 10.138.255.140
swarm_manager_1_public_ip = 138.68.57.39
输出定义在terraform/do/swarm.tf文件的底部(github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/swarm.tf)。请注意,并非所有输出都列出,而是只列出了已创建资源的输出。
我们可以使用新创建的 Droplet 的公共 IP 并通过 SSH 连接到它。
你可能会倾向于复制 IP 地址,但不需要这样做。Terraform 有一个命令可以用来检索我们定义为输出的任何信息。
检索第一个且当前唯一管理节点的公共 IP 的命令如下:
terraform output swarm_manager_1_public_ip
输出如下:
138.68.57.39
我们可以利用输出命令构建 SSH 命令。作为一个例子,接下来的命令将通过 SSH 连接到机器并获取 Swarm 节点列表:
ssh -i devops21-do \
root@$(terraform output \
swarm_manager_1_public_ip) \
docker node ls
输出如下(为了简洁,已移除 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-manager-01 Ready Active Leader
从现在开始,我们不再局限于初始化集群的单个管理节点。我们可以创建所有其他节点。然而,在此之前,我们需要获取管理节点和工作节点的令牌。出于安全原因,最好不要将其存储在任何地方,因此我们将创建环境变量:
export TF_VAR_swarm_manager_token=$(ssh \
-i devops21-do \
root@$(terraform output \
swarm_manager_1_public_ip) \
docker swarm join-token -q manager)
export TF_VAR_swarm_worker_token=$(ssh \
-i devops21-do \
root@$(terraform output \
swarm_manager_1_public_ip) \
docker swarm join-token -q worker)
我们还需要设置环境变量swarm_manager_ip:
export TF_VAR_swarm_manager_ip=$(terraform \
output swarm_manager_1_private_ip)
即使我们可以在terraform/do/swarm.tf中使用digitalocean_droplet.swarm-manager.0.private_ip(github.com/vfarcic/cloud-provisioning/blob/master/terraform/do/swarm.tf),但将其定义为环境变量是一个好主意。这样,如果第一个管理节点失败,我们可以轻松将其更改为swarm_manager_2_private_ip,而无需修改.tf文件。
现在,让我们看看创建其余 Swarm 节点的计划:
terraform plan \
-target digitalocean_droplet.swarm-manager \
-target digitalocean_droplet.swarm-worker
输出的相关行如下:
...
+ digitalocean_droplet.swarm-manager.1
...
+ digitalocean_droplet.swarm-manager.2
...
+ digitalocean_droplet.swarm-worker.0
...
+ digitalocean_droplet.swarm-worker.1
...
Plan: 4 to add, 0 to change, 0 to destroy.
我们可以看到,计划是创建四个新资源。由于我们已经有一个管理节点在运行,并且指定了所需数量为三个,所以将创建两个额外的管理节点和两个工作节点。
让我们应用执行计划:
terraform apply \
-target digitalocean_droplet.swarm-manager \
-target digitalocean_droplet.swarm-worker
输出的最后几行如下:
...
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
...
Outputs:
swarm_manager_1_private_ip = 10.138.255.140
swarm_manager_1_public_ip = 138.68.57.39
swarm_manager_2_private_ip = 10.138.224.161
swarm_manager_2_public_ip = 138.68.17.88
swarm_manager_3_private_ip = 10.138.224.202
swarm_manager_3_public_ip = 138.68.29.23
四个资源已被创建,并且我们得到了管理节点的公有和私有 IP 的输出。
让我们进入其中一个管理节点并确认集群确实工作正常:
ssh -i devops21-do \
root@$(terraform \
output swarm_manager_1_public_ip)
docker node ls
node ls命令的输出如下(为了简洁,ID 已被移除):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
swarm-manager-02 Ready Active Reachable
swarm-manager-01 Ready Active Leader
swarm-worker-02 Ready Active
swarm-manager-03 Ready Active Reachable
swarm-worker-01 Ready Active
所有节点都存在,集群似乎在正常工作。
为了完全确信一切按预期工作,我们将部署一些服务。这些将是我们在整本书中创建的相同服务,因此我们将节省一些时间并部署vfarcic/docker-flow-proxy/docker-compose-stack.yml(github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)和vfarcic/go-demo/docker-compose-stack.yml(github.com/vfarcic/go-demo/blob/master/docker-compose-stack.yml)堆栈:
sudo docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/\
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
sudo docker stack deploy \
-c proxy-stack.yml proxy
curl -o go-demo-stack.yml \
https://raw.githubusercontent.com/\
vfarcic/go-demo/master/docker-compose-stack.yml
sudo docker stack deploy \
-c go-demo-stack.yml go-demo
我们从仓库下载了堆栈并执行了stack deploy命令。
现在我们要做的就是等待片刻,执行service ls命令,确认所有副本都在运行:
docker service ls
service ls命令的输出应如下所示(为了简洁,ID 已被移除):
NAME REPLICAS IMAGE COMMAND
go-demo-db 1/1 mongo:3.2.10
proxy 3/3 vfarcic/docker-flow-proxy
go-demo 3/3 vfarcic/go-demo:1.2
swarm-listener 1/1 vfarcic/docker-flow-swarm-listener
最后,让我们通过proxy向go-demo服务发送请求。如果返回正确的响应,我们就知道一切正常:
curl -i localhost/demo/hello
输出如下:
HTTP/1.1 200 OK
Date: Wed, 07 Dec 2016 06:21:01 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
它工作正常!
我们完成了吗?我们可能完成了。作为最后的检查,让我们验证proxy是否可以从服务器外部访问。我们可以通过退出服务器并从我们的笔记本发送请求来确认:
exit
curl -i $(terraform output \
swarm_manager_1_public_ip)/demo/hello
输出如下:
HTTP/1.1 200 OK
Date: Wed, 07 Dec 2016 06:21:33 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
我们仍然缺少浮动 IP。虽然在这个演示中它们不是必须的,但如果这是生产集群,我们会创建它们并用它们来配置我们的 DNS。
这次,我们可以在不指定目标的情况下创建计划:
terraform plan
输出的相关部分如下:
...
+ digitalocean_floating_ip.docker_1
...
+ digitalocean_floating_ip.docker_2
...
+ digitalocean_floating_ip.docker_3
...
Plan: 3 to add, 0 to change, 0 to destroy.
正如你所看到的,Terraform 检测到除了浮动 IP 外,所有资源已经创建,因此它生成了一个计划,只执行三项资源的创建。
让我们应用该计划:
terraform apply
输出如下:
...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
...
Outputs:
floating_ip_1 = 138.197.232.121
floating_ip_2 = 138.197.232.119
floating_ip_3 = 138.197.232.120
swarm_manager_1_private_ip = 10.138.255.140
swarm_manager_1_public_ip = 138.68.57.39
swarm_manager_2_private_ip = 10.138.224.161
swarm_manager_2_public_ip = 138.68.17.88
swarm_manager_3_private_ip = 10.138.224.202
swarm_manager_3_public_ip = 138.68.29.23
浮动 IP 已被创建,我们可以看到它们的 IP 输出。
唯一剩下的就是确认浮动 IP 确实被创建并正确配置。我们可以通过发送请求通过其中一个 IP 来确认:
curl -i $(terraform output \
floating_ip_1)/demo/hello
如预期的那样,输出是状态200 OK:
HTTP/1.1 200 OK
Date: Wed, 07 Dec 2016 06:23:27 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
hello, world!
让我们看看如果模拟一个实例故障会发生什么。
我们将使用 DigitalOcean API 删除一个实例。我们也可以使用 Terraform 来删除实例。然而,使用 API 删除将更接近模拟一个节点的意外失败。
要删除一个实例,我们需要找到它的 ID。我们可以通过terraform show命令来做到这一点。
假设我们要移除第二个工作节点。查找其所有信息的命令如下:
terraform state show "digitalocean_droplet.swarm-worker[1]"
输出如下:
id = 33909722
disk = 30
image = 21373017
ipv4_address = 138.68.57.13
ipv4_address_private = 10.138.224.209
locked = false
name = swarm-worker-02
private_networking = true
region = sfo2
resize_disk = true
size = 1gb
ssh_keys.# = 1
ssh_keys.0 = 5080274
status = active
tags.# = 0
vcpus = 1
在其他数据中,我们获得了 ID。在我的例子中,它是33909722。
在运行接下来的命令之前,请将 ID 更改为您从terraform state show命令中获得的 ID:
curl -i -X DELETE \
-H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
"https://api.digitalocean.com/v2/droplets/33909722"
输出的相关部分如下:
HTTP/1.1 204 No Content
...
DigitalOcean 对DELETE请求不提供任何响应内容,因此状态204表示操作成功。
直到 Droplet 被完全移除大约需要几分钟。
让我们再运行一次terraform plan命令:
terraform plan
输出的相关部分如下:
...
+ digitalocean_droplet.swarm-worker.1
...
Plan: 1 to add, 0 to change, 0 to destroy.
Terraform 推断出需要添加一个资源swarm-worker.1,以解决它本地存储的状态与集群实际状态之间的差异。
要将集群恢复到期望状态,我们所要做的就是运行terraform apply:
terraform apply
输出的相关部分如下:
...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
...
Outputs:
floating_ip_1 = 138.197.232.121
floating_ip_2 = 138.197.232.119
floating_ip_3 = 138.197.232.120
swarm_manager_1_private_ip = 10.138.255.140
swarm_manager_1_public_ip = 138.68.57.39
swarm_manager_2_private_ip = 10.138.224.161
swarm_manager_2_public_ip = 138.68.17.88
swarm_manager_3_private_ip = 10.138.224.202
swarm_manager_3_public_ip = 138.68.29.23
我们可以看到添加了一个资源。已终止的工作节点已经被重新创建,集群继续以其满负荷运转。
集群的状态存储在terraform.tfstate文件中。如果你不是总是在同一台电脑上运行它,可能需要将这个文件与其他配置文件一起存储在你的代码库中。另一种选择是使用远程状态(www.terraform.io/docs/state/remote/index.html),例如将其存储在 Consul 中。
更改集群的期望状态也很简单。我们所需要做的就是添加更多资源并重新运行terraform apply。
我们已经完成了对 Terraform 在 DigitalOcean 上的简要介绍。
我们执行的过程流可以通过图 12-4来描述:

图 12-4:Terraform 过程的流程
在我们比较不同的创建和管理 Swarm 集群的方法之前,让我们销毁之前的操作:
terraform destroy -force
输出的最后一行如下:
...
Destroy complete! Resources: 9 destroyed.
集群已经消失,仿佛它从未存在过,帮助我们节省了不必要的开支。
让我们看看如何删除一个快照。
在删除我们创建的快照之前,我们需要找到它的ID。
将返回所有快照列表的请求如下:
curl -X GET \
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" \
"https://api.digitalocean.com/v2/snapshots?resource_type=droplet" \
| jq '.'
响应的输出如下:
{
"snapshots": [
{
"id": "21373017",
"name": "devops21-1481087268",
"regions": [
"sfo2"
],
"created_at": "2016-12-07T05:11:05Z",
"resource_id": "33907398",
"resource_type": "droplet",
"min_disk_size": 30,
"size_gigabytes": 1.32
}
],
"links": {},
"meta": {
"total": 1
}
}
我们将使用jq获取快照 ID:
SNAPSHOT_ID=$(curl -X GET \
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" \
"https://api.digitalocean.com/v2/snapshots?resource_type=droplet" \
| jq -r '.snapshots[].id')
我们发送了一个HTTP GET请求来获取所有快照,并使用jq仅提取 ID。结果存储在环境变量SNAPSHOT_ID中。
现在我们可以发送一个DELETE请求来删除快照:
curl -X DELETE \
-H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" \
"https://api.digitalocean.com/v2/snapshots/$SNAPSHOT_ID"
响应的相关输出如下:
HTTP/1.1 204 No Content
...
快照已被删除。DigitalOcean 账户上没有正在运行的资源,你将不会被收取超过本章练习所花费的费用。
选择合适的工具来创建和管理 DigitalOcean 中的 Swarm 集群
我们尝试了两种不同的组合来在 DigitalOcean 上创建 Swarm 集群。我们使用了Docker Machine与DigitalOcean API,以及Packer与Terraform。这绝不是我们可以使用的工具的最终清单。时间有限,我也向自己承诺,这本书会比战争与和平要短,所以我必须在某些地方划定界限。在我看来,这两种组合是作为首选工具的最佳候选。即使你选择其他工具,本章也希望能给你一些关于你可能想走的方向的启示。
很可能你不会同时使用这两种组合,那么百万美元的问题是,应该选择哪一个?
只有你能回答这个问题。现在你已经有了实践经验,这些经验应该与想要实现的目标相结合。每个使用场景都不同,没有一种组合能适合所有人。
尽管如此,我还是会提供一个简要概述,并介绍一些可能适合每种组合的使用场景。
使用 Docker Machine 还是不使用 Docker Machine?
Docker Machine 是我们探索的较弱解决方案。它基于临时命令,提供的功能仅限于创建 droplets 和安装 Docker 引擎。它使用 Ubuntu 15.10 作为基础镜像。它不仅过时,而且是一个临时版本。如果我们选择使用 Ubuntu,正确的选择应是 16.04 长期支持(LTS)版本。
如果 Docker Machine 至少能为 Swarm 模式提供最基本的设置(就像它为旧版 Standalone Swarm 提供的那样),它可能是小型集群的一个不错选择。
目前,Docker Machine 在与 DigitalOcean 上的 Swarm 集群配合使用时提供的唯一真正的好处,是能够在远程节点上安装 Docker 引擎,并使用 docker-machine env 命令,使我们的本地 Docker 客户端能够与远程集群无缝通信。Docker 引擎的安装很简单,因此仅此一点并不足够。另一方面,docker-machine env 命令不应在生产环境中使用。这两个好处都太薄弱了。
Docker Machine 当前的许多问题可以通过一些额外的参数(例如,--digitalocean-image)和与其他工具结合使用来修复。然而,这只是削弱了 Docker Machine 的主要优势。它本应该是简单且开箱即用的。这个说法在 Docker 1.12 之前部分成立。现在,至少在 DigitalOcean 上,它已经落后了。
这是否意味着在使用 DigitalOcean 时我们应该放弃 Docker Machine?并非总是如此。当我们想要创建一个临时集群用于演示或可能实验一些新功能时,Docker Machine 仍然有用。而且,如果你不想花时间学习其他工具,只是想使用自己熟悉的工具,Docker Machine 可能是合适的选择。我怀疑这就是你的情况。你能读到这本书的这一部分,说明你确实想要探索更好的集群管理方式。
最后的建议是,当你想要在本地模拟 Swarm 集群时,继续使用 Docker Machine,正如我们在前几章所做的那样。在 DigitalOcean 上有更好的选择。
要不要使用 Terraform?
当 Terraform 与 Packer 结合使用时,它是一个非常优秀的选择。HashiCorp 成功地又开发了一款工具,改变了我们配置和预置服务器的方式。
配置管理工具的主要目标是确保服务器始终处于期望的状态。如果一个 Web 服务器停止运行,它会被重新启动。如果一个配置文件被更改,它会被恢复。无论服务器发生什么,它都会恢复到期望的状态。除非问题没有修复的方法。如果硬盘出现故障,配置管理就无能为力了。
配置管理工具的问题在于,它们是为物理服务器设计的,而不是虚拟服务器。为什么要修复一个有故障的虚拟服务器,而我们可以在几秒钟内创建一个新的?Terraform 比任何人都更了解云计算的工作原理,并接受我们的服务器不再是“宠物”这一观点。它们是“牲畜”。它会确保你的所有资源都可用。
当服务器出现问题时,它不会尝试修复它。相反,它会销毁该服务器,并根据我们选择的镜像创建一个新的服务器。
这是否意味着 Puppet、Chef、Ansible 以及其他类似的工具没有用武之地?它们在云计算环境中是否已经过时?其中一些工具比其他的更为过时。Puppet 和 Chef 设计的目的是在每个服务器上运行一个代理,持续监控其状态,并在出现问题时进行修改。当我们开始把服务器当作“牲畜”来管理时,这类工具就失去了作用。而 Ansible 的处境稍微好一点,因为它更有用,它是一个用于配置服务器的工具,而不是监控服务器。因此,当我们创建镜像时,它会非常有帮助。
我们可以将 Ansible 与 Packer 结合使用。Packer 创建一个新的虚拟机,Ansible 为这个虚拟机配置所需的一切,然后交给 Packer 将其转换为镜像。如果服务器配置比较复杂,这样做非常有意义。我们不会创建很多系统用户,因为我们不需要登录到机器上部署软件。Swarm 会为我们做这件事。我们也不再安装 Web 服务器和运行时依赖项,它们都在容器内。使用配置管理工具为即将转为镜像的虚拟机安装一些东西真的有意义吗?大多数情况下,答案是否定的。我们需要的那些东西可以通过几条 Shell 命令轻松安装和配置。对我们的“牛群”进行配置管理,通常应该用 bash 来做。
我可能说得有些严厉。Ansible 依然是一个很棒的工具,前提是你知道何时使用它以及用来做什么。如果你更倾向于用它而非 bash 来安装和配置服务器,直到它变成镜像,那就去使用吧。如果你尝试用它来控制你的节点并创建 DigitalOcean 资源,那你就走错了路。Terraform 做得更好。如果你认为最好是为一个运行中的节点配置,而不是实例化已经包含一切内容的镜像,那你可能需要比我更多的耐心。
最终推荐是,如果你想要控制构成你集群的所有部分,或者你已经有一套需要遵循的规则,建议使用Terraform和Packer。准备花些时间调优配置,直到达到最佳设置。与 AWS 不同,无论好坏,它迫使我们处理许多类型的资源,DigitalOcean 则简单许多。你只需创建 Droplets,添加几个浮动 IP,就完成了。你可能想在机器上安装防火墙。如果确实要安装,最佳方式是在用 Packer 创建快照时一起完成。至于是否在使用 Swarm 网络时需要防火墙,这个问题值得另行讨论。
由于没有类似于Docker for DigitalOcean的东西,Terraform 显然是赢家。DigitalOcean 简单,这种简单性在 Terraform 配置中得到了体现。
最终结论
Terraform 在与 Docker Machine 的对比中取得了压倒性的胜利。如果有类似于Docker for DigitalOcean的东西,这个讨论可能会更长。但目前来说,选择是非常明确的。如果你选择了 DigitalOcean,就用 Packer 和 Terraform 来管理你的集群。
是否使用 DigitalOcean
一般来说,我喜欢那些专注于少数几件事并且做得很好的产品和服务。DigitalOcean 就是其中之一。它是一个基础设施即服务(IaaS)提供商,除此之外没有其他业务。它提供的服务数量很少(例如,浮动 IP),并且仅限于那些真正必要的服务。如果你正在寻找一个可以提供你所能想象的所有服务的提供商,选择亚马逊网络服务(AWS)、Azure、GCE,或者任何其他不仅提供托管服务,还提供大量附加服务的云计算提供商。从你已经读到这本书的这一点来看,意味着你很可能有兴趣自己搭建基础设施服务。如果是这样,DigitalOcean 值得一试。做得太多往往意味着什么都做不好。DigitalOcean 做了几件事,而且做得很好。它做的事情,比大多数其他云服务做得都要好。
真正的问题是你是否只需要基础设施即服务(IaaS)提供商,还是还需要平台即服务(PaaS)。在我看来,容器使 PaaS 变得过时。它将逐渐被由调度程序管理的容器(例如,Docker Swarm)或容器即服务(CaaS)所取代。你可能不同意我的看法。如果你同意,那么 AWS 的大部分服务将变得过时,剩下的只有 EC2、存储、VPC 和少数其他服务。在这种情况下,DigitalOcean 是一个强大的竞争者,也是一个非常好的选择。它做的几件事,比 AWS 做得更好,而且价格更低。它的性能令人印象深刻。只需测量创建一个 droplet 所需的时间,并将其与 AWS 创建和启动 EC2 实例所需的时间进行比较,你会发现差异巨大。我第一次创建 droplet 时,甚至觉得出了什么问题。我的大脑无法理解为什么这件事可以在不到一分钟的时间内完成。
我提到过简洁吗?DigitalOcean 很简洁,而我喜欢简洁。因此,合乎逻辑的结论是,我喜欢 DigitalOcean。真正的高手是能让复杂的事情变得易于使用。这正是 Docker 和 DigitalOcean 的闪光之处。
第十四章:在 Swarm 集群中创建和管理有状态服务
任何足够先进的技术都与魔法 indistinguishable(无法区分)。
- 阿瑟·C·克拉克
如果你参加过会议、听过播客、读过论坛,或者参与过任何与容器和云原生应用相关的辩论,你一定听过“无状态服务”这个口号。它几乎像是一种教义。只有无状态服务才是值得的,其他的都是异端。任何问题的解决方案都是移除状态。我们如何扩展这个应用程序?让它变得无状态。我们如何将其放入容器中?让它变得无状态。我们如何让它具有容错能力?让它变得无状态。无论是什么问题,解决方案都是无状态的。
我们直到现在所使用的所有服务都是无状态的吗?并不是。所以,逻辑上来说,我们还没有解决所有问题。
在我们开始探索无状态服务之前,我们应该回顾一下并讨论一下十二因素应用程序方法论。
探索十二因素应用程序方法论
如果我的记忆没错,Heroku(www.heroku.com/)大约在 2010 年左右开始流行。它向我们展示了如何利用软件即服务(SaaS)原则。它使开发者不再需要过多考虑底层基础设施,能够专注于开发,其他的交给别人来做。我们所需要做的只是将代码推送到 Heroku,它会自动检测我们使用的编程语言,创建虚拟机,安装所有依赖,构建、启动,等等。结果就是我们的应用程序在服务器上运行。
当然,在某些情况下,Heroku 自己无法自动搞定一切。当这种情况发生时,我们所要做的就是创建一个简单的配置文件,提供一些额外的信息。依然非常简单高效。
初创公司很喜欢它(有些现在仍然喜欢)。它让他们能够专注于开发新功能,把其他的交给 Heroku 来做。我们编写软件,其他人来运行它。这就是软件即服务(SaaS)的最佳体现。它背后的理念和原则变得非常流行,以至于许多人决定复制这个想法,创建自己的 Heroku 类服务。
在 Heroku 广泛被采用后不久,它的创始人意识到许多应用程序并没有如预期般运行。拥有一个解放开发者的操作平台是一回事,但实际编写能在 SaaS 提供商环境下良好运行的代码又是另一回事。因此,Heroku 的团队和一些其他人提出了 The Twelve-Factor App(12factor.net/)原则。如果你的应用满足这十二个因素,它将在 SaaS 中运行良好。这些因素大多数适用于任何现代应用程序,无论它是在本地服务器上运行,还是通过云计算提供商、PaaS、SaaS、容器等运行。每个现代应用都应该采用十二因素应用方法论,或者至少,很多人是这么说的。
让我们探索每个因素,看看我们在这些方面表现如何。也许,仅仅是也许,我们迄今为止学到的东西将使我们符合十二因素原则。我们将逐一分析所有因素,并将它们与本书中使用的服务进行比较。
- 代码库
一个代码库通过版本控制追踪,多个部署。go-demo 服务在一个 Git 仓库中。每次提交都部署到测试和生产环境中。我们创建的所有其他服务由其他人发布。 – 通过
- 依赖关系
明确声明并隔离依赖关系。所有依赖项都包含在 Docker 镜像中。除去 Docker 引擎外,没有系统级别的依赖项。Docker 镜像默认遵循此原则。– 通过
- 配置
将配置存储在环境中。
go-demo 服务没有任何配置文件。所有内容都通过环境变量进行设置。我们创建的所有其他服务也可以这样说。通过网络进行的服务发现极大地帮助了我们实现这一点,它允许我们在没有任何配置的情况下找到服务。请注意,这一原则仅适用于在不同部署间会发生变化的配置。其他任何配置,只要在服务部署时无论何时何地保持一致,都可以继续存储为文件。 – 通过
- 后端服务
将后端服务视为附加资源。
在我们的案例中,MongoDB 是一个后端服务。它通过网络连接到主服务 go-demo。 – 通过
- 构建、发布、运行
严格分离构建和运行阶段。
在这个上下文中,除运行服务外的所有内容都被视为构建阶段。在我们的情况下,构建阶段与运行阶段清晰分离。Jenkins 构建我们的服务,而 Swarm 负责运行它们。构建和运行在不同的集群中进行。 – 通过
- 进程
将应用程序作为一个或多个无状态进程执行。
我们在这一原则上严重失败。尽管 go-demo 服务是无状态的,但几乎所有其他服务(docker-flow-proxy、jenkins、prometheus 等)都不是无状态的。 – 失败
- 端口绑定
通过端口绑定导出服务。
Docker 网络和 docker-flow-proxy 负责端口绑定。在许多情况下,唯一会绑定任何端口的服务是 proxy。其他一切服务应该位于一个或多个网络中,并通过 proxy 使其可访问。– 已通过
- 并发性
通过进程模型进行扩展。
这一因素直接与无状态性相关。无状态服务(例如:go-demo)易于扩展。一些非无状态服务(例如:docker-flow-proxy)被设计为可扩展的,因此它们也符合这一原则。许多其他有状态服务(例如:Jenkins、Prometheus 等)无法进行水平扩展。即使可以,过程往往太复杂且容易出错。– 失败
- 可丢弃性
通过快速启动和优雅关机最大化系统的健壮性。
无状态服务默认是可丢弃的。它们可以随时启动和停止,并且通常具有容错能力。如果一个实例失败,Swarm 会将其重新调度到健康的节点上。我们使用的所有服务并非如此。比如 Jenkins 和 MongoDB,如果发生故障,它们会丢失状态,这使得它们根本不可丢弃。– 失败
- 开发/生产一致性
保持开发、预发布和生产环境尽可能相似。
这是 Docker 提供的主要优点之一。由于容器是从不可变的镜像创建的,因此无论是在我们的笔记本电脑、测试环境还是生产环境中运行,服务都是相同的。– 已通过
- 日志
将日志视为事件流。
ELK 堆栈和 LogSpout 实现了这一原则。只要容器内部的应用程序将日志输出到 stdout,所有容器的日志都会被流式传输到 ElasticSearch。我们运行的 Jenkins 是个例外,因为它将部分日志写入文件。不过,这个行为是可配置的,所以我们不对它提出批评。– 已通过
- 管理进程
将 admin/management 任务作为一次性进程运行。在我们的案例中,所有进程都是作为 Docker 容器执行的,显然符合这一因素。– 已通过
我们通过了十二项因素中的九项。我们应该追求全部十二项吗?实际上,这个问题本身就有问题。一个更好的提问方式是:我们是否能追求全部十二项?我们通常做不到。这个世界不是昨天才建立的,我们无法丢弃所有遗留代码从头开始。即便我们能,十二因子应用原则也有一个重大缺陷:它假设有一个完全由无状态服务组成的系统。
无论我们采用哪种架构风格(包括微服务),应用程序都有状态!在微服务架构中,每个服务可以有多个实例,每个服务实例应该设计为无状态的。无状态的意思是,服务实例在操作过程中不会存储任何数据。因此,无状态意味着任何服务实例都可以从其他地方检索执行某个行为所需的所有应用状态。这是微服务架构应用的一个重要限制,因为它使得系统具备弹性、可扩展性,并允许任何可用的服务实例执行任何任务。即使状态不在我们正在开发的服务内部,它仍然存在,并需要以某种方式进行管理。我们没有开发存储状态的数据库,并不意味着它不应遵循相同的原则,并具备可扩展性、容错性、弹性等特性。
因此,所有系统都有状态,但如果一个服务能够干净地将行为与数据分离,并能够获取执行行为所需的数据,那么该服务可以是无状态的。
十二要素应用的作者们是否会短视到认为状态不存在呢?他们显然不会。他们假设,除了我们编写的代码之外,其他一切都将是由其他人维护的服务。以 MongoDB 为例,它的主要用途是存储状态,因此它当然是有状态的。十二要素应用的作者们假设,我们愿意让其他人管理有状态的服务,而只专注于我们正在开发的那些服务。
虽然在某些情况下,我们可能会选择使用 Mongo 作为由云服务提供商维护的服务,但在许多其他情况下,这种选择并不是最有效的。无论如何,这类服务往往非常昂贵。当我们没有足够的知识或能力来维护我们的后台服务时,这种费用通常是值得支付的。然而,当我们有能力时,如果我们自己运行数据库,往往能得到更好、更便宜的结果。在这种情况下,数据库就是我们的一个服务,显然它是有状态的。我们没有编写所有服务并不意味着我们不在运行它们,因此,我们仍然对它们负责。
好消息是,我们未能实现的三个原则都与有状态性相关。如果我们能够以一种方式创建服务,使得它们的状态在关闭时被保存,并且在所有实例之间共享,那么我们就能够让整个系统变得云原生。我们将能够在任何地方运行它,根据需要扩展其服务,并使系统具备容错能力。
创建和管理有状态服务是我们目前缺失的唯一重要环节。完成本章内容后,您将能够在 Swarm 集群内运行任何类型的服务。
本章的实际部分将从创建 Swarm 集群开始。我们将仅使用 AWS 作为示范。这里探讨的原则可以应用于几乎任何云计算提供商,以及本地服务器。
设置 Swarm 集群和代理
我们将使用Packer(www.packer.io/)和Terraform(www.terraform.io/)在 AWS 上创建一个 Swarm 集群。目前,我们将使用的配置将(几乎)与第十二章中探索的配置相同,在 Amazon Web Services (AWS) 中创建和管理 Docker Swarm 集群。在后续更复杂的场景中,我们会进一步扩展该配置。
本章中的所有命令都可以在13-volumes.sh(gist.github.com/vfarcic/338e8f2baf2f0c9aa1ebd70daac31899) Gist 中找到。
我们将继续使用vfarcic/cloud-provisioning(github.com/vfarcic/cloud-provisioning) 仓库。它包含了一些配置和脚本,将帮助我们完成工作。你已经克隆了这个仓库。为了保险起见,我们将pull最新版本:
cd cloud-provisioning
git pull
Packer 和 Terraform 配置位于terraform/aws-full(github.com/vfarcic/cloud-provisioning/tree/master/terraform/aws-full)目录下:
cd terraform/aws-full
我们将定义一些环境变量,这些变量将为 Packer 提供在使用 AWS 时所需的信息:
export AWS_ACCESS_KEY_ID=[...]
export AWS_SECRET_ACCESS_KEY=[...]
export AWS_DEFAULT_REGION=us-east-1
请将[...]替换为实际值。如果你丢失了密钥并忘记了如何创建它们,请参考第十二章,在 Amazon Web Services 中创建和管理 Docker Swarm 集群。
我们准备好创建本章将使用的第一个镜像了。我们将使用的 Packer 配置位于terraform/aws-full/packer-ubuntu-docker-compose.json(github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/packer-ubuntu-docker-compose.json)。它与我们之前使用的几乎相同,因此我们只会评论相关的不同之处。具体如下:
"provisioners": [{
...
}, {
"type": "file",
"source": "docker.service",
"destination": "/tmp/docker.service"
}, {
"type": "shell",
"inline": [
"sudo mv /tmp/docker.service /lib/systemd/system/docker.service",
"sudo chmod 644 /lib/systemd/system/docker.service",
"sudo systemctl daemon-reload",
"sudo systemctl restart docker"
]
}]
文件提供器将 docker.service 文件复制到虚拟机中。来自 shell 提供器的命令将把上传的文件移动到正确的目录,赋予正确的权限,并重启docker service。
docker.service(github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/docker.service) 文件内容如下:
[Unit]
Description=Docker Application Container Engine
Documentation=https://docs.docker.com
After=network.target docker.socket
Requires=docker.socket
[Service]
Type=notify
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375
ExecReload=/bin/kill -s HUP $MAINPID
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
TimeoutStartSec=0
Delegate=yes
KillMode=process
[Install]
WantedBy=multi-user.target
Docker 服务配置与默认配置几乎相同。唯一的区别是ExecStart中的-H tcp://0.0.0.0:2375。
默认情况下,Docker Engine 不允许远程连接。如果配置不变,我们无法从一台服务器向另一台服务器发送命令。通过添加-H tcp://0.0.0.0:2375,我们告诉 Docker 接受来自任何地址0.0.0.0的请求。通常,这会带来很大的安全风险。然而,所有 AWS 的端口默认是关闭的。稍后,我们将只对属于同一安全组的服务器开放2375端口。因此,只要我们在其中一台服务器内,我们就能够控制任何 Docker Engine。正如你将很快看到的,这在接下来的几个例子中会非常有用。
让我们构建packer-ubuntu-docker-compose.json中定义的 AMI:
packer build -machine-readable \
packer-ubuntu-docker.json \
| tee packer-ubuntu-docker.log
现在我们可以将注意力转向 Terraform,它将创建我们的集群。我们将复制之前创建的 SSH 密钥devops21.pem,并声明一些环境变量,以便 Terraform 能够访问我们的 AWS 账户:
export TF_VAR_aws_access_key=$AWS_ACCESS_KEY_ID
export TF_VAR_aws_secret_key=$AWS_SECRET_ACCESS_KEY
export TF_VAR_aws_default_region=$AWS_DEFAULT_REGION
export KEY_PATH=$HOME/.ssh/devops21.pem
cp $KEY_PATH devops21.pem
export TF_VAR_swarm_ami_id=$( \
grep 'artifact,0,id' \
packer-ubuntu-docker.log \
| cut -d: -f2)
Terraform 要求环境变量以TF_VAR为前缀,因此我们不得不创建新的环境变量,尽管它们的值和我们在 Packer 中使用的一样。环境变量KEY_PATH的值仅为示例。你可能将它存储在其他地方。如果是这样,请更改为正确的路径。
最后一条命令过滤了packer-ubuntu-docker.log并将 AMI ID 存储为环境变量TF_VAR_swarm_ami_id。
现在我们可以创建一个 Swarm 集群了。接下来的练习中,三个虚拟机就足够了,因此我们只会创建管理节点。由于命令和我们在前几章中执行的一样,我们将跳过解释,直接运行它们:
terraform apply \
-target aws_instance.swarm-manager \
-var swarm_init=true \
-var swarm_managers=1
export TF_VAR_swarm_manager_token=$(ssh \
-i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker swarm join-token -q manager)
export TF_VAR_swarm_manager_ip=$(terraform \
output swarm_manager_1_private_ip)
terraform apply \
-target aws_instance.swarm-manager
我们创建了第一个服务器并初始化了 Swarm 集群。随后,我们获取了令牌和其中一个管理节点的 IP,并使用这些数据创建并加入了另外两个节点。
为了安全起见,我们将进入其中一个管理节点并列出组成集群的节点:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker node ls
输出如下(为简洁起见,已删除 ID):
HOSTNAME STATUS AVAILABILITY MANAGER STATUS
ip-172-31-16-158 Ready Active Leader
ip-172-31-31-201 Ready Active Reachable
ip-172-31-27-205 Ready Active Reachable
工作节点在哪里?
我们没有创建任何工作节点。原因很简单。对于本章中的练习,三个节点就足够了。这并不妨碍你在开始使用类似的集群设置时为你的组织添加工作节点。
要添加工作节点,请执行以下命令:
export TF_VAR_swarm_worker_token=$(ssh\ '-i devops21.pem ''ubuntu@$(terraform output ''swarm_manager_1_public_ip)' 'docker swarm join-token -q worker) terraform apply\'-target aws_instance.swarm-worker'
如果输出为1.2.3.4,你应该在浏览器中打开http://1.2.3.4/jenkins。
我们快完成了。在进入有状态性之前,唯一剩下的就是运行docker-flow-proxy和docker-flow-swarm-listener服务。由于我们已经创建过这些服务很多次,因此不需要进一步解释,我们可以通过部署vfarcic/docker-flow-proxy/docker-compose-stack.yml (github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)堆栈来加快过程:
docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/\
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
docker stack deploy \
-c proxy-stack.yml proxy
exit
无数据持久化的有状态服务运行
我们将在 Swarm 集群中开始探索有状态服务,首先看看如果像其他服务一样部署它们,会发生什么情况。
一个很好的例子是 Jenkins。我们创建的每个任务都是一个 XML 文件。我们安装的每个插件都是一个 HPI 文件。每次配置更改都会以 XML 格式存储。你可以明白了,Jenkins 中的一切操作最终都会形成一个文件。这些文件构成了它的状态。如果没有这些,Jenkins 将无法运行。Jenkins 也是我们在遗留应用中遇到问题的一个很好的例子。如果我们今天重新设计它,可能会使用数据库来存储其状态。这样做可以让我们进行扩展,因为所有实例将通过连接到同一个数据库来共享相同的状态。如果我们今天从头开始设计,可能会做出很多其他的设计选择。成为遗留系统并不一定是坏事。当然,今天的经验帮助我们避免了一些过去的陷阱。另一方面,长时间的存在意味着它经过了战斗考验,拥有高采纳率,庞大的贡献者数量,广泛的用户基础等等。一切都有权衡,我们无法得到所有的好处。
我们将暂时搁置有一个成熟且经过验证的应用与年轻且现代但往往未经验证的应用之间的优缺点。相反,我们来看看作为有状态服务的 Jenkins,在我们使用 Terraform 创建的 Swarm 集群中运行时的表现:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
docker service create --name jenkins \
-e JENKINS_OPTS="--prefix=/jenkins" \
--label com.df.notify=true \
--label com.df.distribute=true \
--label com.df.servicePath=/jenkins \
--label com.df.port=8080 \
--network proxy \
--reserve-memory 300m \
jenkins:2.7.4-alpine
我们进入了其中一台管理节点并创建了jenkins服务。
请稍等片刻,直到jenkins服务运行起来。你可以使用 docker 命令service ps jenkins来检查当前状态。
现在 Jenkins 已经运行,我们应该在浏览器中打开它:
exit
open "http://$(terraform output swarm_manager_1_public_ip)/jenkins"
Windows 用户注意 Git Bash 可能无法使用open命令。如果是这种情况,请执行terraform output swarm_manager_1_public_ip来查找管理节点的 IP,并在你选择的浏览器中直接打开该 URL。例如,上述命令应替换为以下命令:
terraform output swarm_manager_1_public_ip
如果输出是1.2.3.4,你应该在浏览器中打开http://1.2.3.4/jenkins。
正如你从第六章《使用 Jenkins 自动化持续部署流程》中记得的那样,我们需要从日志或文件系统中提取密码。然而,这次,操作会稍微复杂一些。Docker Machine 将本地(笔记本电脑)目录挂载到它创建的每个虚拟机中,因此我们可以直接获取initialAdminPassword,而无需进入虚拟机。
在 AWS 中并没有这样的事情,至少现在没有,所以我们需要找出哪个 EC2 实例托管了 Jenkins,获取容器的 ID,并进入其中以获取文件。虽然手动做这件事很容易,但由于我们坚持自动化,我们将采用更难的方式。
我们将通过进入其中一个管理界面并列出服务任务,开始寻找密码的任务:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker service ps jenkins
输出如下(为了简洁,ID 和 ERROR 列已被移除):
NAME IMAGE NODE DESIRED STATE
jenkins.1 jenkins:2.7.4-alpine ip-172-31-16-158 Running
---------------------------------------------------------------
CURRENT STATE
Running 8 minutes ago
幸运的是,AWS EC2 实例的名称中包含了内部 IP。我们可以利用这一点:
JENKINS_IP=$(docker service ps jenkins \
| tail -n 1 \
| awk '{ print $4 }' \
| cut -c 4- \
| tr "-" ".")
我们列出了服务任务并通过管道传递给tail,以便只返回最后一行。然后我们使用awk获取第四列。cut命令打印了第四个字节的结果,有效地移除了ip-。所有结果都通过tr命令替换了-,最后,结果被存储在环境变量JENKINS_IP中。
如果这对你来说太过奇怪,可以手动指定该值(在我的情况下是172.31.16.159)。
现在我们知道了哪个节点托管了 Jenkins,我们需要获取容器的 ID。由于我们修改了docker.service配置,使得可以向远程引擎发送命令,我们可以使用-H参数。
用于检索 Jenkins 容器 ID 的命令如下:
JENKINS_ID=$(docker -H tcp://$JENKINS_IP:2375 \
ps -q \
--filter label=com.docker.swarm.service.name=jenkins)
我们使用-H告诉本地客户端连接到在tcp://$JENKINS_IP:2375上运行的远程引擎。我们列出了所有正在运行的容器ps,并在安静模式-q下显示,确保只返回 ID。同时我们应用了过滤器,只检索名为 Jenkins 的服务。结果被存储在环境变量JENKINS_ID中。
现在我们可以使用 IP 和 ID 进入容器并输出存储在文件/var/jenkins_home/secrets/initialAdminPassword中的密码。
docker -H tcp://$JENKINS_IP:2375 \
exec -it $JENKINS_ID \
cat /var/jenkins_home/secrets/initialAdminPassword
在我的情况下,输出如下:
cb7483ce39894c44a48b761c4708dc7d
请复制密码,返回到 Jenkins UI 并粘贴。
在继续之前,请完成 Jenkins 的设置。你已经从第六章《使用 Jenkins 自动化持续部署流程》中了解了流程,所以我就不再多说了,让你安静地完成它。
结果应该是一个类似于图 13-1的屏幕:

图 13-1:初始设置后的 Jenkins 主屏幕
这里有一个简单的问题,我相信你知道如何回答。如果由于某种原因,Jenkins 实例失败,会发生什么情况?
让我们模拟失败并观察结果:
docker -H tcp://$JENKINS_IP:2375 \
rm -f $JENKINS_ID
我们使用了环境变量JENKINS_IP和JENKINS_ID,将强制移除rm -f命令发送到托管 Jenkins 的远程节点。
没有什么是永恒的。迟早,服务会失败。如果它不失败,运行它的节点会失败。通过移除容器,我们模拟了现实世界中可能发生的情况。
一段时间后,Swarm 会检测到 jenkins 副本失败并实例化一个新的副本。我们可以通过列出 jenkins 任务来确认这一点:
docker service ps jenkins
输出如下(为了简洁,ID 已被移除):
NAME IMAGE NODE DESIRED STATE CURRENT STATE
jenkins.1 jenkins:2.7.4-alpine ip-172-31-31-201 Running Running about 1 min
_ jenkins.1 jenkins:2.7.4-alpine ip-172-31-16-158 Shutdown Failed about 1 min
-------------------------------------------------------------
ERROR PORT
"task: non-zero exit (137)"
到目前为止,一切顺利。Swarm 正在按照我们希望的方式运行。它确保我们的服务(几乎)始终在运行。
剩下的唯一任务是返回 UI 并刷新屏幕。
屏幕应该看起来像图 13-2那样:

图 13-2:Jenkins 初始设置屏幕
这很尴尬。我们所做的一切都丢失了,我们又回到了原点。由于 Jenkins 的状态没有保存在容器外部,当 Swarm 创建了一个新的容器时,它从一个空白状态开始。
我们该如何解决这个问题?我们可以采用哪些解决方案来解决持久化问题?
请在继续之前移除jenkins服务:
docker service rm jenkins
exit
在主机上持久化有状态服务
在 Docker 早期,当人们在没有 Docker Swarm、Kubernetes 或 Mesos 等调度程序的预定义节点上运行容器时,主机上的持久化非常常见。当时,我们会选择一个节点来运行容器,并将其放在那里。升级会在同一服务器上进行。换句话说,我们将应用程序打包成容器,并在大多数情况下将其视为任何传统服务。如果一个节点发生故障……运气不好!不管有没有容器,都会是灾难。
由于服务是预定义的,我们可以在主机上持久化状态,并在主机故障时依赖备份。根据备份的频率,我们可能会丢失一分钟、一小时、一天,甚至一整周的数据。生活真是艰难。
这种方法唯一的优点是持久化很简单。我们会在容器内挂载一个主机卷。文件会保存在容器外部,因此在“正常”情况下不会丢失数据。如果容器因为故障或升级而重新启动,当我们运行新容器时,数据依然存在。
还有其他单主机的模型变体,比如数据卷、仅数据容器等。它们都有相同的缺点。它们消除了可移植性。没有可移植性,就没有容错,也没有扩展能力。没有 Swarm。
基于主机的持久化是不可接受的,因此我不会再浪费你们的时间。
如果你有系统管理员的背景,可能会想知道为什么我没有提到网络文件系统(NFS)。原因很简单。我想让你先感受一下痛苦,然后再深入探讨显而易见的解决方案。
在网络文件系统上持久化有状态服务
我们需要找到一种方法来在运行我们服务的容器之外保留状态。
我们可以在主机上挂载一个卷。这样,如果容器失败并在同一节点重新调度,它将允许我们保持状态。问题在于,这样的解决方案过于局限。除非我们加以约束,否则无法保证 Swarm 会将服务重新调度到相同的节点。如果我们做了这样的事情,就会妨碍 Swarm 确保服务的可用性。当该节点发生故障时(每个节点总有一天会故障),Swarm 无法重新调度服务。只有当我们的服务器正常运行时,我们才能保证容错能力。
我们可以通过将 NFS 挂载到每台服务器上来解决节点故障问题。这样,每台服务器都可以访问相同的数据,我们可以将 Docker 卷挂载到它上面。
我们将使用Amazon 弹性文件系统(EFS)(aws.amazon.com/efs/)。由于本书并未专门讲解 AWS,我将跳过不同 AWS 文件系统的比较,仅提到选择 EFS 是因为它可以跨多个可用区使用。
请打开EFS 首页(console.aws.amazon.com/efs/home)界面:
open "https://console.aws.amazon.com/efs/home?region=$AWS_DEFAULT_REGION"
Windows 用户注意
Git Bash 可能无法使用open命令。如果是这种情况,请将$AWS_DEFAULT_REGION替换为您的集群所在的区域(例如,us-east-1),并在浏览器中打开它。
点击“创建文件系统”按钮。在每个可用区中,将默认的安全组替换为docker(我们之前使用 Terraform 创建了它)。然后点击“下一步”按钮两次,最后点击“创建文件系统”。
我们应等待直到每个可用区的生命周期状态设置为“可用”。
现在,我们准备在每个节点上挂载 EFS。最简单的做法是点击 Amazon EC2 挂载说明链接。我们只需要复制“挂载文件系统”部分第三点中的命令。
接下来只需进入每个节点并执行挂载 EFS 卷的命令:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
sudo mkdir -p /mnt/efs
我们进入了第一个管理节点并创建了/mnt/efs目录。
粘贴您从 EC2 挂载说明界面复制的命令。在执行之前,我们需要做一个小的修改。请将目标路径从efs改为/mnt/efs,然后执行命令。
在我的案例中,命令如下(您的命令会有所不同):
sudo mount -t nfs4 \
-o nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,\
retrans=2 fs-07538d4e.efs.us-east-1.amazonaws.com:/ \
/mnt/efs
我们还应该创建一个子目录,用于存储 Jenkins 状态:
sudo mkdir -p /mnt/efs/jenkins
sudo chmod 777 /mnt/efs/jenkins
exit
我们创建了目录/mnt/efs/jenkins,并授予了所有人完全权限,然后退出了服务器。由于 Swarm 可能会选择在任意节点上创建服务,我们应在其余服务器上重复相同的过程。请注意,您的挂载路径会有所不同,因此不要直接粘贴下面的sudo mount命令:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_2_public_ip)
sudo mkdir -p /mnt/efs
sudo mount -t nfs4 \
-o nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600, \
retrans=2 fs-07538d4e.efs.us-east-1.amazonaws.com:/ \
/mnt/efs
exit
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_3_public_ip)
sudo mkdir -p /mnt/efs
sudo mount -t nfs4 \
-o nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,\
retrans=2 fs-07538d4e.efs.us-east-1.amazonaws.com:/ \
/mnt/efs
exit
现在,我们终于可以再次尝试创建 jenkins 服务。希望这次在发生故障时状态能够得到保留:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker service create --name jenkins \
-e JENKINS_OPTS="--prefix=/jenkins" \
--mount "type=bind,source=/mnt/efs/jenkins,target=/var/jenkins_home" \
--label com.df.notify=true \
--label com.df.distribute=true \
--label com.df.servicePath=/jenkins \
--label com.df.port=8080 \
--network proxy \
--reserve-memory 300m \
jenkins:2.7.4-alpine
这个命令和我们之前使用的命令唯一的区别在于--mount参数。它告诉 Docker 将主机目录 /mnt/efs/jenkins 挂载为容器内的 /var/jenkins_home。由于我们在所有节点上将 /mnt/efs 挂载为 EFS 卷,因此无论 jenkins 服务运行在哪台服务器上,它都能访问相同的文件。
现在我们应该等待直到服务启动。请执行 service ps 命令查看当前状态:
docker service ps jenkins
让我们在浏览器中打开 Jenkins UI:
exit
open "http://$(terraform output swarm_manager_1_public_ip)/jenkins"
Windows 用户注意
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行 terraform output swarm_manager_1_public_ip 查找管理节点的 IP 地址,然后直接在你选择的浏览器中打开该 URL。例如,上面的命令应该替换为以下命令:
terraform output swarm_manager_1_public_ip 如果输出是 1.2.3.4,你应该在浏览器中打开 http://1.2.3.4/jenkins。
这一次,由于 Jenkins 主目录被挂载为 /mnt/efs/jenkins,查找密码变得更加容易。我们只需要从其中一台服务器输出 /mnt/efs/jenkins/secrets/initialAdminPassword 文件的内容:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
cat /mnt/efs/jenkins/secrets/initialAdminPassword
请复制密码并将其粘贴到 Jenkins UI 中的管理员密码字段。完成设置:

图 13-3:初始设置后的 Jenkins 主屏幕
我们将再次模拟故障并观察结果。接下来的命令与我们之前执行的命令相同,所以没有理由对它们进行评论:
JENKINS_IP=$(docker service ps jenkins \
| tail -n 1 \
| awk '{ print $4 }' \
| cut -c 4- \
| tr "-" ".")
JENKINS_ID=$(docker -H tcp://$JENKINS_IP:2375 \
ps -q \
--filter label=com.docker.swarm.service.name=jenkins)
docker -H tcp://$JENKINS_IP:2375 \
rm -f $JENKINS_ID
docker service ps jenkins
请等待 Swarm 启动一个新的副本,并在浏览器中刷新 Jenkins UI 屏幕。这一次,我们看到的是登录页面,而不是返回到初始设置。状态得以保存,使得我们的 jenkins 服务具备容错能力。在最坏的情况下,当服务或整个节点失败时,我们会经历短暂的停机,直到 Swarm 重新创建失败的副本。你可能会想:为什么我强制你手动创建 EFS 并挂载它?难道这不应该通过 Terraform 自动完成吗?原因很简单:这个解决方案不值得自动化,它有很多缺点。
我们需要将所有服务的状态放置到同一个 EFS 驱动器中。更好的解决方案是为每个服务创建一个 EFS 卷。采用这种方法的问题是,每当有人向集群添加一个新的有状态服务时,我们都需要修改 Terraform 配置。在这种情况下,Terraform 的帮助有限,因为它并不是为了具有特定服务配置而设计的。它应当作为一种设置集群的方法,能够托管任何服务。即使我们接受为所有服务使用一个单独的 EFS 卷,我们仍然需要为每个服务创建一个新的子目录。如果我们将 Terraform 作为创建基础设施的工具,而将 Docker 用于所有与服务相关的任务,岂不是更好?
幸运的是,还有更好的方法来创建和挂载 EFS 卷。
在我们探索其他替代方案之前,请先移除jenkins服务并exit服务器:
docker service rm jenkins
exit
我们之前创建的 EFS 卷没有必要保留,所以请返回到EFS 控制台(console.aws.amazon.com/efs),选择文件系统,然后点击“操作”并点击“删除文件系统”按钮。其余步骤请按照屏幕上的说明进行操作。
数据卷编排
有很多存储编排解决方案通过其卷插件与 Docker 集成。我们不会对它们进行比较。这样的尝试需要整整一章内容,甚至可能一本书。
即使你选择了不同的解决方案,接下来要解释的原则也适用于(几乎)所有其他解决方案。有关当前支持的插件的完整列表,请访问卷插件(docs.docker.com/engine/extend/legacy_plugins/#/volume-plugins)部分,详情请见使用 Docker 引擎插件(docs.docker.com/engine/extend/legacy_plugins/)文档。
REX-Ray(github.com/codedellemc/rexray)是一个供应商无关的存储编排引擎。它建立在libStorage(libstorage.readthedocs.io)框架之上。它支持EMC、Oracle VirtualBox和Amazon EC2。在撰写本文时,GCE、Open Stack、Rackspace和DigitalOcean的支持正在进行中。
我发现当我看到某个事物实际操作时,更容易理解它。秉持这种精神,我们将不再长时间辩论 REX-Ray 的功能和工作原理,而是直接进入实际演示。
使用 REX-Ray 持久化有状态服务
我们将从手动设置 REX-Ray 开始。如果它证明是我们有状态服务的一个好解决方案,我们将把它转移到 Packer 和 Terraform 配置中。我们从手动设置开始的另一个原因是让你更好地理解它是如何工作的。
让我们开始吧。
除了我们已经使用过很多次的 AWS 访问密钥和区域,我们还需要通过 Terraform 创建的安全组 ID:
terraform output security_group_id
输出应该类似于下面的内容(你的输出会有所不同):
sg-d9d4d1a4
请复制该值,我们很快会用到它。
我们将进入其中一个节点,在那里安装和配置 REX-Ray:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
REX-Ray 的设置相对简单,这也是我比其他一些解决方案更喜欢它的原因之一:
curl -sSL https://dl.bintray.com/emccode/rexray/install | sh -s -- stable
输出如下:
REX-Ray
-------
Binary: /usr/bin/rexray
SemVer: 0.6.3
OsArch: Linux-x86_64
Branch: v0.6.3
Commit: 69b1f5c2d86a2103c792bec23b5855babada1c0a
Formed: Wed, 07 Dec 2016 23:22:14 UTC
libStorage
----------
SemVer: 0.3.5
OsArch: Linux-x86_64
Branch: v0.6.3
Commit: 456dd68123dd6b49da0275d9bbabd6c800583f61
Formed: Wed, 07 Dec 2016 23:21:36 UTC
我们安装了 REX-Ray 版本 0.6.3 以及其依赖 libStorage 版本 0.3.5。在你的情况下,版本可能会更新。
接下来,我们将创建所需的环境变量,用于 REX-Ray 配置:
export AWS_ACCESS_KEY_ID=[...]
export AWS_SECRET_ACCESS_KEY=[...]
export AWS_DEFAULT_REGION=[...]
export AWS_SECURITY_GROUP=[...]
请将 [...] 替换为实际值。安全组的值应与我们之前通过 terraform output security_group_id 命令获取的相同。
现在我们准备通过位于 /etc/rexray/config.yml 的 YML 配置文件来配置 REX-Ray:
echo "
libstorage:
service: efs
server:
services:
efs:
driver: efs
efs:
accessKey: ${AWS_ACCESS_KEY_ID}
secretKey: ${AWS_SECRET_ACCESS_KEY}
securityGroups: ${AWS_SECURITY_GROUP}
region: ${AWS_DEFAULT_REGION}
tag: rexray" \
| sudo tee /etc/rexray/config.yml
我们将驱动程序设置为 efs 并提供了 AWS 数据。结果输出到 /etc/rexray/config.yml 文件。
现在我们可以启动服务:
sudo rexray service start
输出如下:
rexray.service - rexray
Loaded: loaded (/etc/systemd/system/rexray.service; enabled; \
vendor preset: enabled)
Active: active (running) since Thu 2016-12-22 19:34:51 UTC; 245ms ago
Main PID: 7238 (rexray)
Tasks: 4
Memory: 10.6M
CPU: 109ms
CGroup: /system.slice/rexray.service/\
_7238 /usr/bin/rexray start -f
Dec 22 19:34:51 ip-172-31-20-98 systemd[1]: Started rexray.
REX-Ray 正在运行,我们可以 exit 节点:
exit
由于我们不知道哪个节点将托管我们的有状态服务,我们需要在集群的每个节点上设置 REX-Ray。请在 Swarm 管理节点 2 和 3 上重复这些设置步骤。
一旦 REX-Ray 在所有节点上运行,我们可以开始尝试。请进入其中一个管理节点:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
可以通过我们安装的 rexray 二进制文件直接使用 REX-Ray。例如,我们可以列出所有的卷:
sudo rexray volume get
输出如下:
ID Name Status Size
由于我们尚未创建任何卷,所以没有太多可查看的内容。我们可以使用 rexray 卷创建命令来实现这一点。然而,没有必要这么做。得益于与 Docker 的集成,实际上没有太多必要直接使用二进制文件进行任何操作。
让我们再试一次创建 jenkins 服务。这一次,我们将使用 REX-Ray 作为卷驱动:
docker service create --name jenkins \
-e JENKINS_OPTS="--prefix=/jenkins" \
--mount "type=volume,source=jenkins,target=/var/jenkins_home, \
volume-driver=rexray" \
--label com.df.notify=true \
--label com.df.distribute=true \
--label com.df.servicePath=/jenkins \
--label com.df.port=8080 \
--network proxy \
--reserve-memory 300m \
jenkins:2.7.4-alpine
我们刚执行的命令与之前尝试创建 jenkins 服务的唯一区别在于 --mount 参数。源现在只是一个名称 jenkins,它代表卷的名称。目标依然相同,表示容器内的 Jenkins 主目录。重要的区别是添加了 volume-driver 参数。这条指令告诉 Docker 使用 rexray 来挂载卷。
如果 REX-Ray 和 Docker 的集成正常,我们应该能看到一个 jenkins 卷:
sudo rexray volume get
输出如下:
ID Name Status Size
fs-0a64ba43 jenkins attached 6144
这次,rexray 卷获取命令的输出不为空。我们可以看到 jenkins 卷。如我之前提到的,实际上没有必要使用 rexray 二进制文件。我们可以通过 Docker 直接实现它的许多功能。例如,我们可以执行 docker volume ls 命令来列出所有卷:
docker volume ls
输出如下:
DRIVER VOLUME NAME
rexray jenkins
列出卷仅证明 Docker 和 REX-Ray 注册了一个新的挂载。让我们看看在 AWS 中发生了什么:
exit
open "https://console.aws.amazon.com/efs/home?region=$AWS_DEFAULT_REGION"
给 Windows 用户的提示
Git Bash 可能无法使用 open 命令。如果是这种情况,请将 $AWS_DEFAULT_REGION 替换为你集群所在的区域(例如,us-east-1),然后在浏览器中打开。
你应该看到一个类似于 图 13-4 中展示的屏幕:

图 13-4:AWS EFS 卷与 REX-Ray 一起创建并挂载
如你所见,REX-Ray 创建了一个名为 rexray/jenkins 的新 EFS 卷,并在与托管 jenkins 服务的节点相同的可用区挂载了一个目标。
唯一缺少的部分,以满足我多疑的本性,就是杀掉 Jenkins,并确认 REX-Ray 是否在 Swarm 重新调度的一个新容器上挂载了 EFS 卷。
和之前一样,我们将从设置 Jenkins 开始:
open "http://$(terraform output swarm_manager_1_public_ip)/jenkins"
给 Windows 用户的提示
Git Bash 可能无法使用 open 命令。如果是这种情况,请执行 terraform output swarm_manager_1_public_ip 以查找管理器的 IP,并直接在你选择的浏览器中打开该 URL。例如,前面的命令应替换为以下命令:
terraform output swarm_manager_1_public_ip 如果输出是 1.2.3.4,你应该在浏览器中打开 http://1.2.3.4/jenkins。
我们面临着一个反复出现的挑战。如何找到初始 Jenkins 管理员密码。从好的一面看,这个挑战作为演示不同方式访问容器内内容的示例非常有用。
这次,我们将利用 REX-Ray 访问存储在 EFS 卷中的数据,而不是尝试查找托管 jenkins 服务的节点和容器 ID:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
docker run -it --rm \
--volume-driver rexray \
-v jenkins:/var/jenkins_home \
alpine cat /var/jenkins_home/secrets/initialAdminPassword
输出应该与以下内容类似:
9c5e8e51af954d7988b310d862c3d38c
我们创建了一个新的 alpine 容器,并使用 rexray 卷驱动程序将其附加到 jenkins EFS 卷。命令输出了 /var/jenkins_home/secrets/initialAdminPassword 文件的内容,该文件包含密码。由于我们指定了 --rm 参数,Docker 在进程 cat 退出后会移除容器。最终结果是密码输出到屏幕。请复制并粘贴到 Jenkins UI 中的管理员密码字段,完成设置。
现在,我们需要经历一个痛苦的过程,找到托管 Jenkins 的节点,获取容器的 ID,并在远程引擎上执行 docker rm 命令。换句话说,我们将运行与之前尝试时相同的一组命令:
JENKINS_IP=$(docker service ps jenkins | tail -n 1 \
| awk '{ print $4 }' | cut -c 4- | tr "-" ".")
JENKINS_ID=$(docker -H tcp://$JENKINS_IP:2375 \
ps -q \
--filter label=com.docker.swarm.service.name=jenkins)
docker -H tcp://$JENKINS_IP:2375 \
rm -f $JENKINS_ID
几分钟后,Swarm 将重新调度容器,Jenkins 将再次运行。
请等待直到服务当前状态为运行中:
docker service ps jenkins
重新加载 Jenkins UI,观察你是否被重定向到登录屏幕,而不是初始设置页面。状态已被保存。
我们已经完成了这个集群。现在我们需要手动删除卷。否则,由于它不是由 Terraform 创建的,即使我们销毁集群,AWS 仍会继续收取费用。问题是,如果有一个或多个服务正在使用这个卷,它是不能被删除的,因此我们还需要销毁 jenkins 服务:
docker service rm jenkins
docker volume rm jenkins
exit
terraform destroy -force
为有状态服务选择持久化方法
我们可以使用很多其他工具来持久化状态。它们中的大多数可以归为我们探索过的几类。在我们可以采取的不同方法中,最常用的三种方法如下:
-
不持久化状态。
-
将状态持久化到主机上。
-
将状态持久化到集群外部的某个地方。
没有必要争论为什么持久化有状态服务的数据是至关重要的,因此第一个选项自动被淘汰。
由于我们正在操作一个集群,我们不能依赖任何单一主机始终可用。它可能随时发生故障。即使某个节点没有故障,迟早某个服务会故障,Swarm 会重新调度它。发生这种情况时,我们无法保证 Swarm 会在同一主机上运行新副本。即使不幸的是,节点从未发生故障,且服务永不出问题,在我们第一次执行该服务的更新(例如:发布新版本)时,Swarm 也可能会在其他地方创建新副本。总之,我们不知道服务会在哪儿运行,也不知道它会在那里停留多久。唯一能否定这个说法的方法是使用约束,将服务绑定到特定主机上。然而,如果我们这么做,那就没有必要再使用 Swarm 或者阅读这本书了。总之,状态不应持久化到特定主机上。
这让我们剩下了第三个选择。状态应该被持久化到集群外部的某个地方,可能是一个网络驱动器。传统上,系统管理员会在所有主机上挂载一个网络驱动器,从而使服务无论运行在哪里都能访问状态。这个方法有很多问题,主要的问题是需要挂载一个驱动器,并期望所有有状态服务将它们的状态持久化到这个驱动器上。理论上,我们可以为每个服务挂载一个新的驱动器,但这样的要求很快就会成为负担。例如,如果我们使用 Terraform 来管理基础设施,每当有新服务时,我们就需要更新它。你还记得十二因子应用的第一个原则吗?每个服务应该有一个代码库。服务所需的所有内容都应该放在一个单一的仓库中。因此,Terraform 或任何其他基础设施配置工具不应包含任何与服务相关的细节。
问题的解决方案是使用与管理服务类似的原则来管理数据卷。正如我们采用了调度器(例如:Swarm)来管理我们的服务,我们也应该采用卷调度器来管理我们的挂载点。
由于我们采用了 Docker 容器作为运行服务的方式,卷调度程序应该能够与它集成并提供无缝体验。换句话说,管理卷应该是管理服务的一个重要部分。Docker 卷插件正好允许我们做到这一点。它们的目的是将第三方解决方案集成到 Docker 生态系统中,使卷管理变得透明。
REX-Ray 是我们探索过的解决方案之一。还有许多其他解决方案,我将留给你去比较它们,并决定哪种卷调度器在你的使用场景中效果最好。
如果我们仅仅在本章中探索的选项中选择,REX-Ray 无疑是一个明确的赢家。它允许我们以透明的方式在集群中持久化数据。唯一的额外要求是确保安装了 REX-Ray。之后,我们像挂载常规主机卷一样挂载带有其驱动程序的卷。在背后,REX-Ray 负责繁重的工作,它会创建一个网络驱动器,挂载它并进行管理。
长话短说,我们将使用 REX-Ray 来管理所有有状态的服务。这个说法并不完全准确,让我重新表述一下。我们将使用 REX-Ray 来管理所有不使用实例间复制和同步的有状态服务。如果你在想这是什么意思,我只能说,耐心是一种美德。我们很快就会讲解到。
现在我们决定将 REX-Ray 作为我们技术栈的一部分,值得将其添加到我们的 Packer 和 Terraform 配置中。
将 REX-Ray 添加到 Packer 和 Terraform
我们已经完成了 REX-Ray 手动设置,因此将其添加到 Packer 和 Terraform 配置中应该相对容易。我们将把那些不依赖于运行时资源的静态部分添加到 Packer 中,其余的则添加到 Terraform 中。这意味着 Packer 将创建带有 REX-Ray 安装的 AMI,而 Terraform 将创建其配置并启动服务。
让我们来看看 terraform/aws-full/packer-ubuntu-docker-rexray.json 文件:(github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/packer-ubuntu-docker-rexray.json)
cat packer-ubuntu-docker-rexray.json
与我们之前使用的 terraform/aws-full/packer-ubuntu-docker.json 配置文件(github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/packer-ubuntu-docker.json)相比,唯一的区别是在 shell provisioner 中多了一个命令:
"provisioners": [{
"type": "shell",
"inline": [
...
"curl -sSL https://dl.bintray.com/emccode/rexray/install | sh -s -- stable"
...
}]
在创建一个 VM,之后它将成为 AMI 时,Packer 将执行我们安装 REX-Ray 时所运行的相同命令。
让我们来构建 AMI:
packer build -machine-readable \
packer-ubuntu-docker-rexray.json \
| tee packer-ubuntu-docker-rexray.log
export TF_VAR_swarm_ami_id=$(\
grep 'artifact,0,id' \
packer-ubuntu-docker-rexray.log \
| cut -d: -f2)
我们构建了 AMI,并将 ID 存储在环境变量 TF_VAR_swarm_ami_id 中。它将很快被 Terraform 使用。
定义 REX-Ray 设置的 Terraform 部分稍微复杂一些,因为它的配置需要动态生成,并在运行时决定。
配置定义在terraform/aws-full/rexray.tpl (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/rexray.tpl) 模板中:
cat rexray.tpl
输出如下:
libstorage:
service: efs
server:
services:
efs:
driver: efs
efs:
accessKey: ${aws_access_key}
secretKey: ${aws_secret_key}
region: ${aws_default_region}
securityGroups: ${aws_security_group}
tag: rexray
如你所见,AWS 密钥、区域和安全组被定义为变量。魔法发生在terraform/aws-full/common.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/common.tf) 文件中:
cat common.tf
输出的相关部分是template_file数据源,内容如下:
data "template_file" "rexray" {
template = "${file("rexray.tpl")}"
vars {
aws_access_key = "${var.aws_access_key}"
aws_secret_key = "${var.aws_secret_key}"
aws_default_region = "${var.aws_default_region}"
aws_security_group = "${aws_security_group.docker.id}"
}
}
模板的内容在之前探索过的rexray.tpl (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/rexray.tpl) 文件中。模板中的变量在 vars 部分定义。最后一个变量aws_security_group的值将在运行时决定,一旦名为 docker 的aws_security_group被创建。
最后,拼图的最后一块在terraform/aws-full/swarm.tf (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/swarm.tf) 文件中:
cat swarm.tf
swarm-manager和swarm-worker的 AWS 实例在remote-exec provisioners中有两行额外的配置。它们如下:
"if ${var.rexray}; then echo \"${data.template_file.rexray.rendered}\"\
| sudo tee /etc/rexray/config.yml; fi",
"if ${var.rexray}; then sudo rexray service start >/dev/null 2>/dev/null; fi"
命令位于if语句内部。这样我们可以在运行时决定是否配置并启动 REX-Ray。通常,你不需要if语句。你要么选择使用 REX-Ray,要么不使用。然而,在本章开头,我们需要一个包含 REX-Ray 的集群,我不想维护两个几乎相同的配置副本(一个包含 REX-Ray,另一个不包含 REX-Ray)。
关键部分在if语句内部。第一行将模板内容放入/etc/rexray/config.yml (github.com/vfarcic/cloud-provisioning/blob/master/terraform/aws-full/swarm.tf) 文件中。第二行启动服务。
现在已经显而易见,我们是如何在 Terraform 配置中定义 REX-Ray 的,接下来是自动创建包含 REX-Ray 的集群:
terraform apply \
-target aws_instance.swarm-manager \
-var swarm_init=true \
-var swarm_managers=1 \
-var rexray=true
初始化 Swarm 集群的第一个节点已创建,我们可以继续添加另外两个管理节点:
export TF_VAR_swarm_manager_token=$(ssh \
-i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip) \
docker swarm join-token -q manager)
export TF_VAR_swarm_manager_ip=$(terraform \
output swarm_manager_1_private_ip)
terraform apply \
-target aws_instance.swarm-manager \
-var rexray=true
我们获取了令牌和第一个管理节点的 IP,并使用这些信息创建了其余的节点。
让我们进入其中一台服务器,确认 REX-Ray 已安装:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
rexray version
rexray version命令的输出如下:
REX-Ray
-------
Binary: /usr/bin/rexray
SemVer: 0.6.3
OsArch: Linux-x86_64
Branch: v0.6.3
Commit: 69b1f5c2d86a2103c792bec23b5855babada1c0a
Formed: Wed, 07 Dec 2016 23:22:14 UTC
libStorage
----------
SemVer: 0.3.5
OsArch: Linux-x86_64
Branch: v0.6.3
Commit: 456dd68123dd6b49da0275d9bbabd6c800583f61
Formed: Wed, 07 Dec 2016 23:21:36 UTC
REX-Ray 和 libStorage 都已安装。最后,在检查是否正常工作之前,让我们快速看一下配置。
cat /etc/rexray/config.yml
输出如下:
libstorage:
service: efs
server:
services:
efs:
driver: efs
efs:
accessKey: ##########
secretKey: ##########
region: ##########
securityGroups: ##########
tag: rexray
出于显而易见的原因,我已经隐藏了我的 AWS 账户详情。尽管如此,配置看起来没问题,我们可以试试 REX-Ray。
我们将部署vfarcic/docker-flow-proxy/docker-compose-stack.yml (github.com/vfarcic/docker-flow-proxy/blob/master/docker-compose-stack.yml)堆栈,并按照我们在手动安装 REX-Ray 时所做的方式创建jenkins服务:
docker network create --driver overlay proxy
curl -o proxy-stack.yml \
https://raw.githubusercontent.com/ \
vfarcic/docker-flow-proxy/master/docker-compose-stack.yml
docker stack deploy \
-c proxy-stack.yml proxy
docker service create --name jenkins \
-e JENKINS_OPTS="--prefix=/jenkins" \
--mount "type=volume,source=jenkins,target=/var/jenkins_home,\
volume-driver=rexray" \
--label com.df.notify=true \
--label com.df.distribute=true \
--label com.df.servicePath=/jenkins \
--label com.df.port=8080 \
--network proxy \
--reserve-memory 300m \
jenkins:2.7.4-alpine
脚本完成应该只需要几分钟。现在我们可以检查 Docker 卷:
docker volume ls
输出如下:
DRIVER VOLUME NAME
rexray jenkins
如预期的那样,jenkins服务创建了一个同名的rexray卷。我们应该等到 Jenkins 运行起来,然后在浏览器中打开它:
docker service ps jenkins # Wait until finished
exit
open "http://$(terraform output swarm_manager_1_public_ip)/jenkins"
Windows 用户注意
Git Bash 可能无法使用 open 命令。如果是这样,请执行terraform output swarm_manager_1_public_ip以查找管理器的 IP,并直接在你选择的浏览器中打开 URL。例如,上面的命令应替换为以下命令:
terraform output swarm_manager_1_public_ip
如果输出为1.2.3.4,你应该在浏览器中打开http://1.2.3.4/jenkins。
剩下的就是恢复初始的管理员密码,并用它来设置 Jenkins:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
docker run -it --rm \
--volume-driver rexray \
-v jenkins:/var/jenkins_home \
alpine cat /var/jenkins_home/secrets/initialAdminPassword
剩下的交给你了。完成设置,销毁容器,等待 Swarm 重新调度它,确认状态是否得到保留,等等。花些时间与它玩耍。
除非你对 Jenkins 和 REX-Ray 有感情依赖,否则请删除服务和卷。我们以后不再需要它:
docker service rm jenkins
docker volume rm jenkins
exit
Prometheus、ElasticSearch 和 Mongo 只是存储状态的几个服务例子。我们是否应该为它们所有服务都添加 REX-Ray 挂载?不一定。有些有状态的服务已经有了保存其状态的机制。在我们开始盲目添加 REX-Ray 挂载之前,我们应该先检查服务是否已有数据复制机制。
持久化无复制的有状态服务
Jenkins 是一个很好的有状态服务例子,迫使我们保存其状态。而且,它无法在多个实例之间共享或同步状态。因此,它不能水平扩展。不能有两个 Jenkins 主节点拥有相同或复制的状态。当然,你可以创建任意多个主节点,但每个都将是完全独立的服务,与其他实例没有任何关系。
Jenkins 无法横向扩展的最明显负面副作用是性能。如果一个主节点负载过重,我们无法创建新实例来减轻原本的负担。
只有三种类型的服务能够进行扩展。它们需要是无状态的、有状态的并能够使用共享状态,或者是有状态的并能够同步状态。Jenkins 既不是这三者中的任何一种,因此无法进行水平扩展。我们能做的唯一事情就是增加 Jenkins 的资源(例如:CPU 和内存)。这样的操作可以提高其性能,但无法提供高可用性。当 Jenkins 发生故障时,Swarm 会重新调度它。尽管如此,在故障发生和新的副本完全恢复之间,仍然会有一段时间的间隔。
在这段时间里,Jenkins 将无法正常工作。如果没有扩展能力,就没有高可用性。
Jenkins 工作负载的大部分是由其代理执行的,因此许多组织无需处理其无法扩展的问题。
将 Jenkins 转变为有状态服务的原因,是为了展示有状态服务的设计方法之一。当运行没有同步机制的有状态服务时,我们能采用的最好的方法就是挂载一个来自外部驱动器的卷。这并不意味着挂载卷是我们唯一可以用来部署有状态服务的方式,但它是处理那些无法在多个副本之间共享或同步状态的服务的优选方法。
让我们探索那些实现了状态同步的有状态服务。
持久化有状态服务的同步和复制
在创建有状态服务时,自然的反应是想办法保留它们的状态。虽然在许多情况下这是正确的做法,但在某些情况下却不是。这取决于服务的架构。
在本书中,我们探讨了至少两种可以同步其状态的有状态服务。它们是 Docker Flow Proxy 和 MongoDB。简而言之,同步状态的能力意味着当一个实例中的数据发生变化时,它能够将变化传播到所有其他实例。这个过程中的最大问题是如何确保每个人都拥有相同的数据,而不牺牲可用性。我们会将这个讨论留到其他时间和地点。相反,我们将深入了解 docker-flow-proxy 和 mongo 服务,决定需要进行哪些更改(如果有的话)来实现高可用性和性能。我们将以它们为例,展示如何处理能够进行数据复制和同步的有状态服务。
并不是每个人都使用 Mongo 来存储数据,也不是每个人都认为 Docker Flow Proxy 是路由请求的最佳选择。你的数据库和代理的选择很可能不同。即使如此,我仍然强烈建议你阅读以下内容,因为它仅使用这两项服务作为如何设计复制机制的示例,以及如何设置已经内置了复制机制的第三方有状态服务。大多数数据库使用相同的复制和同步原理,你应该不会有问题将 MongoDB 的示例作为创建数据库服务的蓝图。
持久化 Docker Flow Proxy 状态
Docker Flow Proxy 是一个有状态服务。然而,这并没有阻止我们对其进行扩展。它的架构设计使得,即使它是有状态的,所有实例的数据也是相同的。实现这一点的机制有很多种叫法。我更喜欢称之为状态复制和同步。
当其中一个实例接收到一个改变其状态的新指令时,它应该找到所有其他副本并传播这一变化。
复制流程通常如下所示:
-
一个实例接收到一个改变其状态的请求。
-
它找到同一服务的所有其他实例的地址。
-
它重新将请求发送给同一服务的所有其他实例。
传播变化的能力并不足够。当一个新实例被创建时,一个带有数据复制的有状态服务需要能够从其他实例请求完整的状态。它在初始化时需要执行的第一项操作是与其他副本达到相同的状态。这可以通过拉取机制来实现。虽然一个实例的状态变化的传播通常涉及到推送到所有其他实例,但新实例的初始化通常伴随着数据拉取。
同步流程通常如下所示:
-
创建一个新的服务实例。
-
它找到同一服务的其他实例的地址。
-
它从同一服务的另一个实例中拉取数据。
你已经多次看到过 Docker Flow Proxy 的实际操作。我们对其进行了扩展,并模拟了故障,这导致了重新调度。在这两种情况下,所有副本始终保持相同的状态,或者更准确地说,保持相同的配置。你之前已经看过了,所以没有必要再进行一次代理功能的实际演示。
理解复制和同步是如何工作的,并不意味着我们应该将服务编写为有状态的并亲自实现这些机制。恰恰相反。在合适的情况下,设计你的服务为无状态,并将其状态存储在数据库中。否则,你可能很快就会遇到问题,并意识到你不得不重新发明轮子。例如,你可能会遇到已经在 Raft 和 Paxos 协议中解决的共识问题。你可能需要实现某种变体的 Gossip 协议。诸如此类。集中精力在那些能为你的项目带来价值的地方,其它问题则使用经过验证的解决方案。
推荐使用外部数据库而不是将状态存储在我们服务中的做法,可能听起来与 Docker Flow Proxy 的做法相冲突。后者是一个有状态的应用,没有任何外部数据存储(至少在 Swarm 模式下是这样的)。原因很简单。这个代理并不是从零开始编写的。它在后台使用了 HAProxy,而 HAProxy 本身没有能力将其配置(状态)存储到外部。如果我要从零开始编写一个代理,它会将状态保存到外部。或许有一天我会这么做。在此之前,HAProxy 是有状态的,Docker Flow Proxy 也是有状态的。从用户的角度来看,这不应该是问题,因为它通过数据复制和所有实例之间的同步来确保一致性。问题出在开发者身上,特别是正在参与该项目的开发者。
让我们看看另一个具有数据复制功能的有状态服务的例子。
持久化 MongoDB 状态
我们在整本书中都使用了 go-demo 服务。这帮助我们更好地理解了 Swarm 的工作原理。除了其他内容,我们还多次扩展了该服务。因为它是无状态的,所以扩展非常容易。我们可以创建任意多的副本,而不必担心数据的存储。数据存储在其他地方。
go-demo 服务将其状态外部化到 MongoDB。如果你留意过,你会发现我们从未扩展过数据库。原因很简单,MongoDB 不能通过简单的 docker service scale 命令进行扩展。
与 Docker Flow Proxy 从头开始设计,利用 Swarm 网络查找其他实例再进行数据复制不同,MongoDB 是网络无关的。它不能自动发现其副本。更复杂的是,只有一个实例可以是主节点,意味着只有一个实例可以接收写请求。这一切意味着我们不能通过 Swarm 扩展 MongoDB。我们需要一种不同的方法。让我们尝试通过创建副本集来设置三个 MongoDB 实例并实现数据复制。我们将从手动过程开始,这将帮助我们理解可能面临的问题以及可以采取的解决方案。稍后,当我们得到令人满意的结果时,我们会尝试自动化这个过程。
我们将从进入其中一个管理节点开始:
ssh -i devops21.pem \
ubuntu@$(terraform output \
swarm_manager_1_public_ip)
Mongo 副本集中的所有成员都需要能够相互通信,因此我们将创建一个老旧的 go-demo 网络:
docker network create --driver overlay go-demo
如果我们创建一个包含三个副本的单一服务,Swarm 会为该服务创建一个网络端点,并在所有实例之间进行负载均衡。这样做的问题在于 MongoDB 配置。它需要每个属于副本集的数据库的固定地址。
我们不会创建一个包含三个副本的服务,而是创建三个服务:
for i in 1 2 3; do
docker service create --name go-demo-db-rs$i \
--reserve-memory 100m \
--network go-demo \
mongo:3.2.10 mongod --replSet "rs0"
done
我们执行的命令创建了 go-demo-db-rs1、go-demo-db-rs2 和 go-demo-db-rs3 三个服务。它们都属于 go-demo 网络,因此可以自由地相互通信。该命令为所有服务指定了 mongod --replSet "rs0",使它们都属于名为 rs0 的 Mongo 副本集。请不要混淆 Swarm 副本和 Mongo 副本集。虽然它们的目标类似,但背后的逻辑有很大不同。
我们应该等待所有服务启动完成:
docker service ls
输出的相关部分如下(为了简洁,已省略 ID):
NAME REPLICAS IMAGE COMMAND
...
go-demo-db-rs2 1/1 mongo:3.2.10 mongod --replSet rs0
go-demo-db-rs1 1/1 mongo:3.2.10 mongod --replSet rs0
go-demo-db-rs3 1/1 mongo:3.2.10 mongod --replSet rs0
...
现在我们应该配置 Mongo 的副本集。我们将通过创建另一个 mongo 服务来实现:
docker service create --name go-demo-db-util \
--reserve-memory 100m \
--network go-demo \
--mode global \
mongo:3.2.10 sleep 100000
我们将服务设置为全局模式,以确保它将在我们所在的同一节点上运行。这样比试图找出其运行节点的 IP 更加简便。它属于同一个 go-demo 网络,因此可以访问其他的数据库服务。
我们不希望在这个服务中运行 Mongo 服务器。go-demo-db-util 的目的是提供一个 Mongo 客户端,我们可以使用它连接到其他数据库并进行配置。因此,我们将默认命令 mongod 替换为一个非常长的睡眠命令。
要进入 go-demo-db-util 服务的某个容器,我们需要找到其 ID:
UTIL_ID=$(docker ps -q \
--filter label=com.docker.swarm.service.name=go-demo-db-util)
现在我们已经知道了在同一服务器上运行的 go-demo-db-util 副本的 ID,可以进入容器内部:
docker exec -it $UTIL_ID sh
下一步是执行一个命令来启动 Mongo 的副本集:
mongo --host go-demo-db-rs1 --eval '
rs.initiate({
_id: "rs0",
version: 1,
members: [
{_id: 0, host: "go-demo-db-rs1" },
{_id: 1, host: "go-demo-db-rs2" },
{_id: 2, host: "go-demo-db-rs3" }
]
})
'
我们使用本地的 mongo 客户端,在运行在 go-demo-db-rs1 内的服务器上执行命令。它以 rs0 为 ID 启动了副本集,并指定了我们之前创建的三个服务作为其成员。由于 Docker Swarm 网络的存在,我们不需要知道 IP 地址,仅指定服务名称就足够了。
响应如下:
MongoDB shell version: 3.2.10
connecting to: go-demo-db-rs1:27017/test
{ "ok" : 1 }
我们不应仅仅依赖确认消息。让我们查看一下配置:
mongo --host go-demo-db-rs1 --eval 'rs.conf()'
我们向运行在 go-demo-db-rs1 的远程服务器发出了另一个命令。它检索了副本集的配置。输出的一部分如下:
MongoDB shell version: 3.2.10
connecting to: go-demo-db-rs1:27017/test
{
"_id" : "rs0",
"version" : 1,
"protocolVersion" : NumberLong(1),
"members" : [
{
"_id" : 0,
"host" : "go-demo-db-rs1:27017",
"arbiterOnly" : false,
"buildIndexes" : true,
"hidden" : false,
"priority" : 1,
"tags" : {
},
"slaveDelay" : NumberLong(0),
"votes" : 1
},
...
],
"settings" : {
"chainingAllowed" : true,
"heartbeatIntervalMillis" : 2000,
"heartbeatTimeoutSecs" : 10,
"electionTimeoutMillis" : 10000,
"getLastErrorModes" : {
},
"getLastErrorDefaults" : {
"w" : 1,
"wtimeout" : 0
},
"replicaSetId" : ObjectId("585d643276899856d1dc5f36")
}
}
我们可以看到副本集中有三个成员(两个已被省略)。
让我们再向远程运行在 go-demo-db-rs1 上的 Mongo 发送一个命令。这一次,我们将检查副本集的状态:
mongo --host go-demo-db-rs1 --eval 'rs.status()'
输出的一部分如下:
connecting to: go-demo-db-rs1:27017/test
{
"set" : "rs0",
"date" : ISODate("2016-12-23T17:52:36.822Z"),
"myState" : 1,
"term" : NumberLong(1),
"heartbeatIntervalMillis" : NumberLong(2000),
"members" : [
{
"_id" : 0,
"name" : "go-demo-db-rs1:27017",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 254,
"optime" : {
"ts" : Timestamp(1482515517, 2),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2016-12-23T17:51:57Z"),
"infoMessage" : "could not find member to sync from",
"electionTime" : Timestamp(1482515517, 1),
"electionDate" : ISODate("2016-12-23T17:51:57Z"),
"configVersion" : 1,
"self" : true
},
...
],
"ok" : 1
}
由于简洁性,已省略两个副本的信息。
我们可以看到所有 Mongo 副本都在运行。go-demo-db-rs1 服务充当主节点,而另外两个是从节点。
设置 Mongo 副本集意味着数据将被复制到所有成员中。一个始终是主节点,其余的是从节点。在当前配置下,我们只能对主节点进行读写操作。副本集可以配置为允许对所有服务器进行读取访问,但写入始终限制在主节点上。
让我们生成一些示例数据:
mongo --host go-demo-db-rs1
我们进入了运行在 go-demo-db-rs1 上的远程 Mongo。
输出如下:
MongoDB shell version: 3.2.10
connecting to: go-demo-db-rs1:27017/test
Welcome to the MongoDB shell.
For interactive help, type "help".
For more comprehensive documentation, see
http://docs.mongodb.org/
Questions? Try the support group
http://groups.google.com/group/mongodb-user
rs0:PRIMARY>
如从提示中所见,我们处于主数据库服务器内。
我们将在数据库测试中创建一些记录:
use test
db.books.insert(
{
title:"The DevOps 2.0 Toolkit"
}
)
db.books.insert(
{
title:"The DevOps 2.1 Toolkit"
}
)
db.books.find()
上一个命令从数据库测试中检索了所有记录。
输出如下:
{ "_id" : ObjectId("585d6491660a574f80478cb6"), "title" : \
"The DevOps 2.0 Toolkit" }
{ "_id" : ObjectId("585d6491660a574f80478cb7"), "title" : \
"The DevOps 2.1 Toolkit" }
现在我们已经配置了副本集并且有了一些样本记录,我们可以模拟其中一台服务器的故障并观察结果:
exit # Mongo
exit # go-demo-db-util
RS1_IP=$(docker service ps go-demo-db-rs1 \
| tail -n 1 \
| awk '{ print $4 }' \
| cut -c 4- \
| tr "-" ".")
docker -H tcp://$RS1_IP:2375 ps
我们退出了 MongoDB 和 go-demo-db-util 服务副本。然后,我们找到了 go-demo-db-rs1(Mongo 副本集的主成员)的 IP,并列出了服务器上运行的所有容器。
输出如下(为了简洁,已删除 ID 和 STATUS 列):
IMAGE COMMAND CREATED
mongo:3.2.10 "/entrypoint.sh sleep" 3 minutes ago
mongo:3.2.10 "/entrypoint.sh mongo" 6 minutes ago
vfarcic/docker-flow-proxy:latest "/docker-entrypoint.s" 13 minutes ago
-----------------------------------------------------------------------
NAMES PORTS
go-demo-db-util.0.8qcsmlzioohn3j6p78hntskj1 27017/tcp
go-demo-db-rs1.1.86sg93z9oasd43dtgoax53nuw 27017/tcp proxy.2.3tlpr1xyiu8wm70lmrffod7ui 80/tcp,443/tcp/,8080/tcp
现在我们可以找到 go-demo-db-rs1 服务副本的 ID,并通过移除它来模拟故障:
RS1_ID=$(docker -H tcp://$RS1_IP:2375 \
ps -q \
--filter label=com.docker.swarm.service.name=go-demo-db-rs1) \
docker -H tcp://$RS1_IP:2375 rm -f $RS1_ID
让我们看一下 go-demo-db-rs1 的任务:
docker service ps go-demo-db-rs1
Swarm 发现其中一个副本失败并重新调度了它。稍后会有一个新的实例运行。
service ps 命令的输出如下(为了简洁,已删除 ID):
NAME IMAGE NODE DESIRED STATE
go-demo-db-rs1.1 mongo:3.2.10 ip-172-31-16-215 Running
_ go-demo-db-rs1.1 mongo:3.2.10 ip-172-31-16-215 Shutdown
-----------------------------------------------------------------------
CURRENT STATE ERROR
Running 28 seconds ago
Failed 35 seconds ago "task: non-zero exit (137)"
我们将再次进入 go-demo-db-util 服务副本,并输出 Mongo 副本集的状态:
docker exec -it $UTIL_ID sh
mongo --host go-demo-db-rs1 --eval 'rs.status()'
输出的相关部分如下:
MongoDB shell version: 3.2.10
connecting to: go-demo-db-rs1:27017/test
{
"set" : "rs0",
"date" : ISODate("2016-12-23T17:56:08.543Z"),
"myState" : 2,
"term" : NumberLong(2),
"heartbeatIntervalMillis" : NumberLong(2000),
"members" : [
{
"_id" : 0,
"name" : "go-demo-db-rs1:27017",
...
"stateStr" : "SECONDARY",
...
},
{
"_id" : 1,
"name" : "go-demo-db-rs2:27017",
...
"stateStr" : "PRIMARY",
...
},
{
"_id" : 2,
"name" : "go-demo-db-rs3:27017",
...
"stateStr" : "SECONDARY",
...
}
],
"ok" : 1
}
我们可以看到 go-demo-db-rs2 成为了主 Mongo 副本。发生的简化流程如下:
-
Mongo 副本
go-demo-db-rs1失败 -
剩余的成员注意到它的缺失,并将
go-demo-db-rs2提升为主节点状态。 -
与此同时,Swarm 重新调度了失败的服务副本。
-
主 Mongo 副本注意到
go-demo-db-rs1服务器恢复在线并加入 Mongo 副本集作为从节点。 -
新创建的
go-demo-db-rs1从 Mongo 副本集的其他成员同步了其数据。
所有这一切能够正常工作的关键元素之一是 Docker 网络。当重新调度的服务副本重新上线时,它保持了相同的地址 go-demo-db-rs1,因此我们无需更改 Mongo 副本集的配置。
如果我们使用虚拟机,并且在 AWS 中使用 Auto Scaling Groups 托管 Mongo,当一个节点失败时,系统会创建一个新的节点来替代它。然而,新节点将会分配到一个新的 IP,并且在没有配置修改的情况下无法加入 Mongo 副本集。我们也可以在没有容器的情况下在 AWS 中实现相同的功能,但没有任何方法能像 Docker Swarm 和网络配置那样简单和优雅。
我们创建的示例数据发生了什么?记住,我们将数据写入了主 Mongo 副本go-demo-db-rs1,然后将其删除。我们没有使用 REX-Ray 或任何其他解决方案来持久化数据。
让我们进入新的主 Mongo 副本:
mongo --host go-demo-db-rs2
在你的集群中,新的主节点可能是go-demo-db-rs3。如果是这种情况,请更改上面的命令。
接下来,我们将指定要使用测试数据库并检索所有数据:
use test
db.books.find()
输出如下:
{ "_id" : ObjectId("585d6491660a574f80478cb6"), "title" : \
"The DevOps 2.0 Toolkit" }
{ "_id" : ObjectId("585d6491660a574f80478cb7"), "title" : \
"The DevOps 2.1 Toolkit" }
即使我们没有设置数据持久化,所有数据仍然存在。
Mongo 副本集的主要目的是提供容错性。如果一个数据库失败,其他成员会接管。任何数据(状态)的变更会在副本集的所有成员之间复制。
那是不是意味着我们不需要将状态保存在外部驱动器上?这取决于使用场景。如果我们操作的数据量非常庞大,可能会采用某种磁盘持久化方式来加快同步过程。在其他情况下,使用卷是浪费,因为大多数数据库都设计了数据复制和同步机制。
当前的解决方案运行良好,我们应该寻找一种更自动化(且更简单)的方法来进行配置。
我们将退出 MongoDB 和go-demo-db-util服务副本,移除所有 DB 服务并重新开始:
exit # Mongo
exit # go-demo-db-util
docker service rm go-demo-db-rs1 \
go-demo-db-rs2 go-demo-db-rs3 \
go-demo-db-util
通过 Swarm 服务初始化 MongoDB 副本集
让我们尝试定义一种更好且更简单的方式来设置 MongoDB 副本集。
我们将从创建三个mongo服务开始。稍后,每个服务都会成为 Mongo 副本集的成员:
for i in 1 2 3; do
docker service create --name go-demo-db-rs$i \
--reserve-memory 100m \
--network go-demo \
mongo:3.2.10 mongod --replSet "rs0"
MEMBERS="$MEMBERS go-demo-db-rs$i"
done
与我们之前用于创建mongo服务的命令唯一的不同之处在于增加了环境变量MEMBERS。它包含所有 MongoDB 的服务名称。我们将使用它作为下一个服务的参数。
由于官方的mongo镜像没有配置 Mongo 副本集的机制,我们将使用一个自定义的镜像。它的唯一目的是配置 Mongo 副本集。
镜像的定义在conf/Dockerfile.mongo文件中(github.com/vfarcic/cloud-provisioning/blob/master/conf/Dockerfile.mongo)。其内容如下:
FROM mongo:3.2.10
COPY init-mongo-rs.sh /init-mongo-rs.sh
RUN chmod +x /init-mongo-rs.sh
ENTRYPOINT ["/init-mongo-rs.sh"]
Dockerfile.mongo扩展了官方的mongo镜像,添加了一个自定义的init-mongo-rs.sh脚本,赋予其执行权限,并将其设置为入口点。
ENTRYPOINT定义了每当启动容器时运行的可执行文件。我们指定的任何命令参数都会附加到它后面。
conf/init-mongo-rs.sh脚本(github.com/vfarcic/cloud-provisioning/blob/master/conf/init-mongo-rs.sh)的内容如下:
#!/usr/bin/env bash
for rs in "$@"; do
mongo --host $rs --eval 'db'
while [$? -ne 0 ]; do
echo "Waiting for $rs to become available"
sleep 3
mongo --host $rs --eval 'db'
done
done
i=0
for rs in "$@"; do
if [ "$rs" != "$1" ]; then
MEMBERS="$MEMBERS ,"
fi
MEMBERS="$MEMBERS {_id: $i, host: \"$rs\" }"
i=$((i+1))
done
mongo --host $1 --eval "rs.initiate({_id: \"rs0\", version: 1, \
members: [$MEMBERS]})"
sleep 3
mongo --host $1 --eval 'rs.status()'
第一部分遍历所有DB地址(定义为脚本参数),检查它们是否可用。如果不可用,它会等待三秒钟,然后重新执行循环。
第二部分格式化了一个 JSON 字符串,定义了所有成员(ID 和主机)的列表。最后,我们初始化副本集,等待三秒钟,并输出其状态。
这个脚本是我们之前手动设置副本集时执行的命令的稍微复杂版本。它不是硬编码的值(例如:服务名称),而是以可重用的方式编写,可以用于多个 Mongo 副本集,且成员数可以变化。
剩下的就是将容器作为 Swarm 服务运行。我已经构建了镜像vfarcic/mongo-devops21并推送到 Docker Hub:
docker service create --name go-demo-db-init \
--restart-condition none \
--network go-demo \
vfarcic/mongo-devops21 $MEMBERS
当脚本完成后,容器将停止。通常,Swarm 会将停止的容器视为故障并重新调度它。这不是我们需要的行为。我们希望该服务执行一些任务(配置副本集),完成后停止。我们通过--restart-condition none参数实现了这一点。否则,Swarm 会进入一个无限循环,不断重新调度一个几秒钟后就会失败的服务副本。
服务的命令是$MEMBERS。当它附加到ENTRYPOINT时,完整的命令是init-mongo-rs.sh go-demo-db-rs1 go-demo-db-rs2 go-demo-db-rs3。
让我们确认所有服务(除了go-demo-db-init)都在运行:
docker service ls
输出如下:
ID NAME REPLICAS IMAGE
1lpus9pvxoj6 go-demo-db-rs1 1/1 mongo:3.2.10
59eox5zqfhf8 go-demo-db-rs2 1/1 mongo:3.2.10
5tchuajhi05e go-demo-db-init 0/1 vfarcic/mongo-devops21
6cmd34ezpun9 go-demo-db-rs3 1/1 mongo:3.2.10
bvfrbwdi5li3 swarm-listener 1/1 vfarcic/docker-flow-swarm-listener
djy5p4re3sbh proxy 3/3 vfarcic/docker-flow-proxy
----------------------------------------------------------------------
COMMAND
mongod --replSet rs0
mongod --replSet rs0
go-demo-db-rs1 go-demo-db-rs2 go-demo-db-rs3
mongod --replSet rs0
唯一没有运行的服务是go-demo-db-init。到这时,它已经完成了执行,并且由于我们使用了--restart-condition none参数,Swarm 没有重新调度它。
我们已经建立了一定的信任,你可能相信go-demo-db-init完成了它的工作。尽管如此,仔细检查一遍并不会有坏处。由于脚本的最后一个命令输出了副本集的状态,我们可以查看其日志,看看一切是否配置正确。这意味着我们需要再一次麻烦地找到容器的 IP 和 ID:
DB_INIT_IP=$(docker service ps go-demo-db-init \
| tail -n 1 \
| awk '{ print $4 }' \
| cut -c 4- \
| tr "-" ".")
DB_INIT_ID=$(docker -H tcp://$DB_INIT_IP:2375 \
ps -aq \
--filter label=com.docker.swarm.service.name=go-demo-db-init)
docker -H tcp://$DB_INIT_IP:2375 logs $DB_INIT_ID
logs命令的相关输出如下:
MongoDB shell version: 3.2.10
connecting to: go-demo-db-rs1:27017/test
{
"set" : "rs0",
"date" : ISODate("2016-12-23T18:18:30.723Z"),
"myState" : 1,
"term" : NumberLong(1),
"heartbeatIntervalMillis" : NumberLong(2000),
"members" : [
{
"_id" : 0,
"name" : "go-demo-db-rs1:27017",
...
"stateStr" : "PRIMARY",
...
},
{
"_id" : 1,
"name" : "go-demo-db-rs2:27017",
"...
"stateStr" : "SECONDARY",
...
},
{
"_id" : 2,
"name" : "go-demo-db-rs3:27017",
...
"stateStr" : "SECONDARY",
...
}
],
"ok" : 1
}
Mongo 副本集确实已经配置了所有三个成员。我们拥有一个工作中的故障容错 MongoDB 集群,提供高可用性。我们可以与我们的go-demo(或任何其他)服务一起使用它们:
docker service create --name go-demo \
-e DB="go-demo-db-rs1,go-demo-db-rs2,go-demo-db-rs3" \
--reserve-memory 10m \
--network go-demo \
--network proxy \
--replicas 3 \
--label com.df.notify=true \
--label com.df.distribute=true \
--label com.df.servicePath=/demo \
--label com.df.port=8080 \
vfarcic/go-demo:1.2
这个命令与我们在前几章使用的命令之间只有一个区别。如果我们继续使用主 MongoDB 的单一地址,我们将无法实现高可用性。当该数据库失败时,服务将无法处理请求。尽管 Swarm 会重新调度它,但由于副本集会选举一个新的主节点,主节点的地址会发生变化。
这次我们指定了所有三个 MongoDB 作为环境变量DB的值。服务的代码将这个字符串传递给 MongoDB 驱动程序。然后,驱动程序将使用这些地址来推断哪个数据库是主节点,并使用它来发送请求。所有 Mongo 驱动程序都有相同的机制来指定副本集的成员。
最后,让我们确认go-demo服务的三个副本确实在运行。记住,服务是以这样一种方式编写的,如果无法建立与数据库的连接,它将会失败。如果所有服务副本都在运行,那么这就是我们一切设置正确的证明:
docker service ps go-demo
输出如下(为了简洁,已删除了 ID 和 ERROR 列):
NAME IMAGE NODE DESIRED STATE
go-demo.1 vfarcic/go-demo:1.2 ip-172-31-23-206 Running
go-demo.2 vfarcic/go-demo:1.2 ip-172-31-25-35 Running
go-demo.3 vfarcic/go-demo:1.2 ip-172-31-25-35 Running
---------------------------------------------------
ERROR
Running 11 seconds ago
Running 9 seconds ago
Running 9 seconds ago
接下来怎么办?
不是所有的状态服务都应该以相同的方式对待。有些可能需要挂载外部驱动器,而其他的可能已经内建了某种形式的复制和同步。在某些情况下,你可能想将挂载和复制结合起来使用,而在另一些情况下,单纯的复制就足够了。
请记住,我们并没有探索所有的其他组合。
重要的是要理解一个服务是如何工作的,以及它是如何设计来持久化其状态的。在许多情况下,无论我们是否使用容器,解决方案的逻辑都是相同的。容器通常并不会改变事情,只是让它们变得更简单。
采用正确的方法,状态服务完全可以是云友好、容错、高可用、可扩展等。关键问题是你是否希望自己管理它们,还是更倾向于将管理工作交给云计算提供商(如果你使用的是云计算)。重要的是你已经初步了解了如何自己管理状态服务。
在继续之前,让我们销毁集群:
exit
terraform destroy -force
第十五章:在 Docker Swarm 集群中管理机密
Docker 1.13 引入了一套功能,使我们能够集中管理机密,并仅将它们传递给需要的服务。这些功能提供了一个亟需的机制,允许我们提供只有指定服务可以访问的信息。
从 Docker 的角度来看,机密是一个数据块。一个典型的使用案例可能是证书、SSH 私钥、密码等等。机密应该保持机密,意味着它们不应以未加密的形式存储或通过网络传输。
既然如此,接下来我们通过实际示例来看它们如何工作,并继续我们的讨论。
本章中的所有命令都可以在 14-secrets.sh 文件中找到,文件链接为:gist.github.com/vfarcic/906d37d1964255b40af430bb03d2a72e。
创建机密
由于单个节点就足以演示 Docker 机密,我们将从创建一个基于 Docker Machines 的单节点 Swarm 集群开始:
docker-machine create \
-d virtualbox \
swarm
eval $(docker-machine env swarm)
docker swarm init \
--advertise-addr $(docker-machine ip swarm)
Windows 用户注意事项
推荐在 Git Bash(通过 Docker Toolbox 和 Git 安装)中运行所有示例。这样,你在书中看到的命令就与应在 OS X 或任何 Linux 发行版上执行的命令相同。
我们创建了一个名为 swarm 的 Docker Machine 节点,并用它初始化了集群。
现在我们可以创建一个机密了。
Windows 用户注意事项
为了使下一个命令中使用的挂载(机密也是一种挂载)正常工作,你必须停止 Git Bash 改变文件系统路径。设置这个环境变量。
export MSYS_NO_PATHCONV=1
创建机密的命令格式如下(请不要运行):
docker secret create [OPTIONS] SECRET file|-
secret create 命令期望一个包含机密的文件。然而,创建一个未加密的机密文件违背了使用机密的初衷。任何人都可以读取该文件。我们可以在推送到 Docker 后删除该文件,但那样只会增加不必要的步骤。相反,我们将使用 -,这将允许我们将标准输出传递给管道:
echo "I like candy" \
| docker secret create my_secret -
我们刚刚执行的命令创建了一个名为 my_secret 的机密。这些信息通过 TLS 连接发送到了远程 Docker 引擎。如果我们有一个更大的集群,包含多个管理节点,机密将会在所有节点之间复制。
我们可以检查新创建的机密:
docker secret inspect my_secret
输出如下:
[
{
"ID": "9iqwc8zb7xum7krgm183t4mym",
"Version": {
"Index": 11
},
"CreatedAt": "2017-02-20T23:00:48.983267019Z",
"UpdatedAt": "2017-02-20T23:00:48.983267019Z",
"Spec": {
"Name": "my_secret"
}
}
]
机密的值是隐藏的。即使恶意用户获得了 Docker 引擎的访问权限,机密仍然无法被访问。说实话,在这种情况下,我们的担忧将远远大于保护 Docker 机密,但我会把这个话题留到以后再讨论。
现在我们已经加密了机密并将其存储在 Swarm 管理节点中,接下来我们应该探索如何在服务中使用它。
消费机密
新增了一个参数 --secret 到 docker service create 命令中。如果一个秘密被附加,它将作为文件存放在所有构成服务的容器中的 /run/secrets 目录内。
让我们看看它的实际应用:
docker service create --name test \
--secret my_secret \
--restart-condition none \
alpine cat /run/secrets/my_secret
我们创建了一个名为 test 的服务,并附加了一个名为 my_secret 的秘密。该服务基于 alpine,并将输出秘密的内容。由于这是一个快速终止的单次命令,我们将 --restart-condition 设置为 none。否则,服务会在创建后不久终止,Swarm 会重新调度它,但它会再次终止,如此循环。我们将进入一个无尽的循环。
我们来看一下日志:
docker logs $(docker container ps -qa)
输出如下:
I like candy
秘密作为 /run/secrets/my_secret 文件在容器内部可用。
在我们开始讨论一个更实际的例子之前,让我们删除我们创建的服务和秘密:
docker service rm test
docker secret rm my_secret
使用 secrets 的实际例子
Docker Flow Proxy (proxy.dockerflow.com/) 项目暴露了应仅供内部使用的统计信息。因此,它需要通过 用户名 和 密码 来保护。在 Docker v1.13 之前,这种情况通常通过允许用户通过环境变量指定用户名和密码来处理。Docker Flow Proxy 也不例外,实际上,它有 环境变量 (proxy.dockerflow.com/config/#environment-variables) STATS_USER 和 STATS_PASS。
创建具有自定义 用户名 和 密码 的服务的命令如下:
docker network create --driver overlay proxy
docker service create --name proxy \
-p 80:80 \
-p 443:443 \
-p 8080:8080 \
-e STATS_USER=my-user \
-e STATS_PASS=my-pass \
--network proxy \
-e MODE=swarm \
vfarcic/docker-flow-proxy
虽然这可以保护统计页面免受普通用户的访问,但仍然会让它暴露给任何能够检查服务的人。以下是一个简单的例子:
docker service inspect proxy --pretty
输出的相关部分如下:
...
ContainerSpec:
Image: vfarcic/docker-flow-proxy:latest@sha256:b1014afa9706413818903671086e484d98db669576b83727801637d1a3323910
Env: STATS_USER=my-user STATS_PASS=my-pass MODE=swarm
...
可以通过以下命令实现相同的结果,而不暴露机密信息:
echo "secret-user" \
| docker secret create dfp_stats_user -
echo "secret-pass" \
| docker secret create dfp_stats_pass -
docker service update \
--secret-add dfp_stats_user \
--secret-add dfp_stats_pass \
proxy
我们创建了两个秘密 dfp_stats_user 和 dfp_stats_pass,并更新了我们的服务。从现在开始,这些 secrets 将作为文件 /run/secrets/dfp_stats_user 和 /run/secrets/dfp_stats_pass 存在于服务容器内部。如果一个 secret 的名称与环境变量相同,并且是小写且以 dpf_ 前缀开头,它将被替代使用。
如果你再检查一下容器,你会发现没有任何关于 secrets 的痕迹。
我们可以在这里停止。毕竟,Docker secrets 的内容并不多。然而,我们已经习惯了 Docker stacks,如果 secrets 能在新的 YAML Compose 格式中工作,那就太好了。
在我们继续之前,让我们删除 proxy 服务:
docker service rm proxy
使用 Docker Compose 的秘密
为了确保在所有支持的版本中都具备相同的功能,Docker 在 Compose YAML 格式 版本 3.1 中引入了 secrets。
我们将继续使用 Docker Flow Proxy 来演示 secrets 如何在 Compose 文件中工作:
curl -o dfp.yml \
https://raw.githubusercontent.com/vfarcic/\
docker-flow-stacks/master/proxy/docker-flow-proxy-secrets.yml
我们从vfarcic/docker-flow-stacks(github.com/vfarcic/docker-flow-stacks)仓库下载了docker-flow-proxy-secrets.yml(github.com/vfarcic/docker-flow-stacks/blob/master/proxy/docker-flow-proxy-secrets.yml)堆栈。
堆栈定义的相关部分如下:
version: "3.1"
...
services:
proxy:
image: vfarcic/docker-flow-proxy:${TAG:-latest}
ports:
- 80:80
- 443:443
networks:
- proxy
environment:
- LISTENER_ADDRESS=swarm-listener
- MODE=swarm
secrets:
- dfp_stats_user
- dfp_stats_pass
deploy:
replicas: 3
...
secrets:
dfp_stats_user:
external: true
dfp_stats_pass:
external: true
格式版本是3.1。proxy服务附加了两个密钥。最后,还有一个单独的secrets部分,将密钥定义为external实体。另一种选择是将密钥内部指定。
一个示例如下:
secrets:
dfp_stats_user:
external: true
dfp_stats_pass:
external: true
secrets:
dfp_stats_user:
file: ./dfp_stats_user.txt
dfp_stats_pass:
file: ./dfp_stats_pass.txt
我更倾向于选择第一种方式,它通过外部指定密钥,因为这种方式没有留下任何痕迹。在某些其他情况下,密钥可能会用于非机密信息(我们会很快讨论),在这种情况下,使用作为文件指定的内部密钥可能是更好的选择。
让我们运行stack并检查它是否正常工作:
docker stack deploy -c dfp.yml proxy
如果没有数据,统计本身是没有意义的,因此我们将部署另一个服务,并在proxy中重新配置它,从而开始生成一些统计数据:
curl -o go-demo.yml \
https://raw.githubusercontent.com/vfarcic/\
go-demo/master/docker-compose-stack.yml
docker stack deploy -c go-demo.yml go-demo
请稍等片刻,直到go-demo堆栈中的服务开始运行。你可以通过执行docker stack ps go-demo来检查它们的状态。你可能会看到go-demo_main副本处于失败状态。不要惊慌。它们只会在go-demo_db启动之前持续失败。
现在我们终于可以确认,proxy已配置为使用密钥进行身份验证:
curl -u secret-user:secret-pass \
"http://$(docker-machine ip swarm)/admin?stats;csv;norefresh"
它成功了!只需额外的一步docker service create,我们就让系统更加安全。
使用密钥的常见方法
在引入密钥之前,将信息传递给容器的常见方式是通过环境变量。虽然对于非机密信息,这仍然是首选方式,但配置中应该也涉及到密钥。两者应结合使用。问题在于选择哪种方法,以及何时使用。
Docker 密钥的显而易见用法就是存储密钥。这个很明显,对吧?如果某些信息只应对特定容器可见,那么它应该通过 Docker 密钥提供。常见的模式是,允许相同的信息既可以作为环境变量,也可以作为密钥指定。如果两者都设置了,密钥应该优先。在Docker Flow Proxy中,你已经看到了这种模式。任何可以通过环境变量指定的信息,都可以通过密钥指定。
在某些情况下,你可能无法修改你的服务代码并将其调整为使用机密信息。也许这不是能力问题,而是你不愿意修改代码。如果你属于后一种情况,我暂时不会解释为什么代码应该不断重构,并假设你有非常好的理由这么做。无论哪种情况,通常的解决方案是创建一个包装脚本,将机密信息转换为你的服务所需的格式,然后调用该服务。将这个脚本作为 CMD 指令放入 Dockerfile 中,这样就完成了。机密信息保持为机密,你也不必因为重构代码而被解雇。对于某些人来说,这最后一句话听起来有些傻,但对于很多公司来说,重构代码被认为是浪费时间并不罕见。
什么应该是机密信息?没有人能够真正为你回答这个问题,因为这因组织而异。一些例子包括用户名和密码、SSH 密钥、SSL 证书等等。如果你不希望别人知道,就把它作为机密处理。
我们应该努力实现不可变性,尽力运行在任何地方都完全相同的容器。真正的不可变性意味着即使是配置在所有环境中也始终保持一致。然而,这并不总是容易实现的,有时甚至是无法完成的。在这种情况下,Docker Secrets 可能是一个很好的候选方案。它们不一定只能作为指定机密信息的手段。我们可以使用机密信息作为提供在不同集群之间有所不同的配置信息的方式。在这种情况下,应该在不同环境中有所差异的配置项(例如:预发布集群和生产集群)可以作为机密存储。
我相信还有很多我没有想到的用例。毕竟,机密信息是一个新的功能(距离本文写作时只有几周的历史)。
现在怎么办?
移除你的 Docker Machine 虚拟机,并开始将机密信息应用到你自己的 Swarm 集群中。暂时没什么更多要说的了:
docker-machine rm -f swarm
第十六章:使用 Docker 和 Prometheus 监控您的 GitHub 仓库
作者:Brian Christner
GitHub 拥有丰富的代码、信息和有趣的统计数据。GitHub 仓库中充满了统计数据,非常适合用 Grafana 绘制图表。绘制这些数据的最佳方式,当然是使用 Docker 和 Prometheus。
Prometheus 包含一个令人印象深刻的导出器列表(prometheus.io/docs/instrumenting/exporters/)。这些导出器覆盖从 API 到物联网的各个领域。它们还可以与 Prometheus 和 Grafana 集成,从而生成一些漂亮的图表。
Docker、Prometheus 和 Grafana
我的基础监控设置是 Docker、Prometheus 和 Grafana 堆栈。这是我工作的基准,并且会添加像导出器这样的组件。我已创建了 GitHub-Monitoring 仓库(github.com/vegasbrianc/github-monitoring)。该仓库包含一个 Docker Compose 文件,使得这个堆栈变得简化且易于启动。
入门
前提条件:确保您的 Docker 主机正在运行最新版本的 Docker 引擎和 Compose。接下来,将 GitHub-Monitoring(github.com/vegasbrianc/github-monitoring)项目克隆到您的 Docker 机器上。
我们可以根据您的需求开始配置项目。如果需要跟踪额外的导出器或目标,请编辑 Prometheus 目标(github.com/vegasbrianc/github-monitoring/blob/master/prometheus/prometheus.yml)。它们位于文件末尾的静态配置部分。导出器使用名为 metrics 的名称,端口为 9171:
static_configs:
- targets: ['node exporter:9100','localhost:9090', 'metrics:9171']
配置
创建一个 GitHub 令牌,用于此项目。这可以防止我们触及 GitHub 对未经身份验证的流量施加的 API 限制。
导航到 创建 GitHub 令牌(github.com/settings/tokens),我们将在这里为该项目创建一个令牌。
请按照以下步骤操作:
-
提供令牌的描述。
-
选择作用域(我们的项目仅需要
repo权限)。 -
点击生成令牌按钮。
-
复制令牌 ID 并将其存储在安全的地方。这相当于一个密码,因此请勿将其保存在公共场所。
使用您喜欢的编辑器编辑 docker-compose.yml 文件(github.com/vegasbrianc/github-monitoring/blob/master/docker-compose.yml)。滚动到文件末尾,您会找到度量服务部分。
首先,将 GITHUB_TOKEN=<GitHub API Token see README> 替换为您之前生成的令牌。接着,将 REPOS 替换为您希望跟踪的目标仓库。在我的示例中,我选择了 Docker 和 freeCodeCamp 仓库,因为它们提供了很多动态和统计数据。
配置如下:
metrics:
tty: true
stdin_open: true
expose:
- 9171
image: infinityworks/github-exporter:latest
environment:
- REPOS=freeCodeCamp/freeCodeCamp,docker/docker
- GITHUB_TOKEN=<GitHub API Token see README>
networks:
- back-tier
配置完成后,我们可以启动它。从 github-monitoring 项目 目录运行以下命令:
docker-compose up
就是这样。Docker Compose 会自动构建整个 Grafana 和 Prometheus 堆栈。Compose 文件还会将新的 GitHub Exporter 连接到我们的基础堆栈。我选择最初不使用 -d 标志运行 docker-compose。这样做有助于故障排除,因为日志条目会直接打印到终端。
Grafana Dashboard 现在可以通过以下地址访问:http://<Host IP Address>:3000(例如:http://localhost:3000)。
请使用 admin 作为用户名,foobar 作为密码(它在 config.monitoring 文件中定义,该文件设置了一些环境变量)。
配置后续步骤
现在我们需要创建 Prometheus 数据源,以将 Grafana 连接到 Prometheus:
-
点击左上角的 Grafana 菜单(看起来像一个火球)
-
点击数据源
-
点击绿色按钮 添加数据源
请参考以下图像以添加 Grafana 数据源:

图 A-1:添加 Grafana 数据源
安装仪表盘
我创建了一个仪表盘模板,可以在GitHub Stats Dashboard(grafana.net/dashboards/1559)找到。下载该仪表盘,并从 Grafana 菜单中选择 -> 仪表盘 -> 导入
这个仪表盘是帮助你开始绘制 GitHub 仓库图表的起点。如果你有任何希望在仪表盘中看到的更改,请告诉我,我也会更新 Grafana 网站。

图 A-2:GitHub Grafana 仪表盘
结论
Prometheus 与 Docker 结合,是一种强大而简单的监控不同数据源的方法。GitHub Exporter 是 Prometheus 提供的众多优秀 Exporter 之一。
关于作者
Brian Christner 来自亚利桑那州,现在居住在瑞士的阿尔卑斯山脉。Brian 曾在赌场行业工作了很长时间,他确保赌场总是赢钱。Brian 是 Docker Captain 计划的提名成员,也是一位经验丰富的云架构师。他还是 Docker、Cloud Foundry、IaaS、PaaS、DevOps、CI/CD 以及当然的容器监控等领域的云主题专家。Brian 热衷于为云和容器技术代言。当 Brian 不忙于将一切容器化时,你会发现他骑着山地自行车或在瑞士阿尔卑斯山滑雪。
Twitter - @idomyowntricks


浙公网安备 33010602011771号