基础设施即代码秘籍-全-

基础设施即代码秘籍(全)

原文:annas-archive.org/md5/61f14d1c52961cd93b31139388cefb1b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在不断发展的环境中,运营和开发团队越来越多地共同合作,使用工具和技术,分享作为 DevOps 运动一部分而流行的共同文化。从开发到生产,一个共同的工具和方法逐渐形成——这通常借鉴了开发人员和敏捷技术。

现在,API 在数据中心无处不在,自动化已经接管了曾经是系统管理员或 IT 工作的一切方面——基础设施现在基本上就是代码,在开发或分布式团队的生产环境中工作时,应该像对待代码一样对待它。

学习适合基础设施即代码描述的最重要工具、技术和工作流可能是一个艰巨的任务,许多团队可能会因需要大量信息、变更和知识来转向基础设施即代码而感到迷茫或沮丧。

本书的写作考虑到了我们过去几年在各自的工作中遇到的所有团队——这些团队对 DevOps、自动化和代码感兴趣,有些团队已经在某些方面做得相当好,但他们愿意探索其他工具和技术,发现如何通过提高代码质量、基础设施稳定性、服务可扩展性、部署速度、团队工作效率和反馈循环,来做得更好。

本书是一次谦逊的尝试,旨在涵盖与基础设施即代码相关的一切,基于我们在实际工作中的经验,从使用 Vagrant 的开发工作流,到使用 Terraform 或 Ansible 的复杂生产基础设施部署,从使用 Chef 和 Puppet 的配置管理基础,到高级的测试驱动开发(TDD)技术,以及全面的基础设施代码覆盖测试。本书还将提供深入的 Docker 技术和更多内容。只要可能或相关,我们尽力展示了使用其他工具或方法完成相同任务的替代方式,让任何对该主题有一定了解的人,在本书的任何部分都能找到学习的内容。

我们希望你能从本书中获得很多收获,并且希望使用基础设施即代码(infrastructure-as-code)来进行自动化和测试,能像我们写这本书时那样让你感到有趣。

本书内容概述

第一章,Vagrant 开发环境,专注于使用 Vagrant 进行自动化开发环境。启动简单或复杂的环境,模拟各种虚拟网络配置,结合 Vagrant 和 Docker 或 Amazon 云,将虚拟机的配置交给 Chef 和 Ansible。所有示例都是自包含的现实生活中的小项目。

第二章,使用 Terraform 配置 IaaS,提供了在 Amazon Web Services 上开始使用 Terraform 所需的所有内容,从托管数据库服务器到日志处理、存储、凭证、Docker 注册表和 EC2 实例。

第三章,深入使用 Terraform,阐述了使用 Terraform 代码的一些更高级技术,如动态数据源、独立环境、Docker、GitHub 或 StatusCake 集成、团队协作,以及代码检查工具的工作原理。

第四章,使用 Terraform 自动化完整基础设施,将展示和描述针对 Amazon Web Services、Digital Ocean、OpenStack、Heroku、Packet 和 Google Cloud 等平台的完整实际 Terraform 代码。我们将为容器部署一个 Docker Swarm 集群在裸金属 CoreOS 集群上,构建一个 n 层 Web 基础设施,或搭建一个 GitLab + CI 组合。

第五章,使用 Cloud-Init 配置最后一公里,探索了我们可以使用 cloud-init 代码做的所有事情——文件管理、服务器配置、添加用户和密钥、仓库和软件包,或如 Chef、CoreOS 和 Docker 等扩展的示例。

第六章,使用 Chef 和 Puppet 管理服务器的基础知识,展示了使用 Chef 代码自动化基础设施的要点。从工作站设置到编写我们自己的食谱,再到管理外部食谱,本章涵盖了所有内容——我们将使用代码管理软件包、服务、文件、动态模板、依赖关系、关系、共享数据等。还展示了使用 Puppet 代码执行类似操作的替代方法,以便你更好地了解这个生态系统。

第七章,使用 Chef 和 Puppet 测试和编写更好的基础设施代码,专注于测试代码质量和可持续性的高级技术。它还涵盖了单元和集成测试、代码检查工具以及 Chef 和 Puppet 的相关工具,确保你能够编写出最佳的基础设施代码。

第八章,使用 Chef 和 Puppet 维护系统,展示了 Chef 或 Puppet 代码所能实现的高级功能,如计划收敛、加密的机密、环境、实时系统信息检索、应用程序部署以及安全的工作流或实践。

第九章,使用 Docker,是从开发者的角度来使用 Docker 容器——选择基础镜像、优化、标签、版本管理、部署 Ruby-on-Rails 或 Go 应用、网络、安全、代码检查,以及使用我们自己的持久私有注册表——这一切都通过简单的 Docker 指令来实现——作为代码。

第十章,维护 Docker 容器,展示了开发人员和工程师使用更高级的 Docker 功能,如代码测试、自动构建流水线和持续集成、自动化漏洞扫描、监控和调试。

本书所需的条件

基本要求是能够运行 Linux 虚拟机的计算机和互联网连接。作者的电脑是运行 Mac OS 10.11 和 Fedora 25 的笔记本,配有 VirtualBox 5,但任何其他 Linux 发行版也可以使用。Vagrant、Terraform、Chef 开发工具包和 Docker 也可以在 Windows 平台上运行,尽管作者未对此进行测试。

由于我们在处理基础设施即服务(IaaS),因此还需要拥有有效的 Amazon Web Services (AWS)、Google Cloud、Digital Ocean、Packet、Heroku 或 OpenStack 部署的账户。

在本书的各个章节中,我们还将使用一些免费的软件即服务(SaaS)账户,如 GitHub、Travis CI、Docker Hub、Quay.io、Hosted Chef 和 StatusCake。

本书适用对象

本书面向的是那些在跨职能团队或运维部门工作的 DevOps 工程师和开发人员,适合那些希望切换到 IAC 以管理复杂基础设施的人员。

章节

在本书中,你会看到一些经常出现的标题(准备工作、如何操作……、如何运作……、还有更多……以及参见)。

为了清楚地说明如何完成一个食谱,我们使用以下几个部分:

准备工作

本节告诉你在食谱中会遇到什么,并描述如何设置所需的软件或任何前期设置。

如何操作…

本节包含完成该食谱所需的步骤。

如何运作…

本节通常详细解释上一节中发生的事情。

还有更多…

本节包含有关该食谱的附加信息,旨在让读者更深入地了解该食谱。

参见

本节提供了有关该食谱的有用链接,以帮助获取更多有用的信息。

约定

本书中,你会发现多种文本样式,它们用来区分不同类型的信息。以下是这些样式的一些例子及其含义的解释。

文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名的展示方式如下:“包含前一个食谱中的 NGINX 配置和docker-compose.yml文件,然后你就可以开始了。”

一段代码设置如下:

Vagrant.configure("2") do |config|
  # all your Vagrant configuration here
end

当我们希望引起您对代码块中特定部分的注意时,相关的行或项目会以粗体显示:

    config.vm.provision "ansible_local" do |ansible|
 ansible.version = "1.9.6"
 ansible.install_mode = :pip
      ansible.playbook = "playbook.yml"
    end

任何命令行输入或输出都按以下方式书写:

$ vagrant plugin list
vagrant-vbguest (0.13.0)

新术语重要词汇以粗体显示。在屏幕上看到的词语,例如在菜单或对话框中,文本中会这样显示:“您可以通过登录到 AWS 控制台并导航到EC2 仪表板 | 网络与安全 | 安全组来查看您新创建的安全组。”

注意

警告或重要说明通常会以这种框架显示。

小贴士

小贴士和技巧以这种方式出现。

读者反馈

我们始终欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中受益的书籍。

要向我们提供一般反馈,请直接发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书名。

如果您擅长某个主题并有兴趣编写或贡献一本书,请查看我们的作者指南:www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有很多资源可以帮助您充分利用您的购买。

下载示例代码

您可以从您的账户中下载本书的示例代码文件,网址为:www.packtpub.com。如果您是从其他地方购买的本书,可以访问www.packtpub.com/support,并注册以便将文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的SUPPORT标签上。

  3. 点击代码下载与勘误表

  4. 搜索框中输入书名。

  5. 选择您想下载代码文件的书籍。

  6. 从下拉菜单中选择您购买本书的来源。

  7. 点击代码下载

一旦文件下载完成,请确保使用最新版本的工具解压或提取文件夹:

  • Windows 版 WinRAR / 7-Zip

  • Mac 版 Zipeg / iZip / UnRarX

  • Linux 版 7-Zip / PeaZip

本书的代码包也托管在 GitHub 上,网址为:github.com/PacktPublishing/Infrastructure-as-Code-IAC-Cookbook。我们还有来自我们丰富的书籍和视频目录中的其他代码包,可以在github.com/PacktPublishing/找到。快去看看吧!

下载本书的彩色图像

我们还为您提供了本书中使用的截图/图表的彩色图片 PDF 文件。彩色图片将帮助您更好地理解输出中的变化。您可以通过以下链接下载该文件:www.packtpub.com/sites/default/files/downloads/InfrastructureasCode_IAC_Cookbook_ColorImages.pdf

勘误

尽管我们已经尽力确保内容的准确性,但错误仍然可能发生。如果您在我们的书籍中发现错误——例如文本或代码中的错误——我们非常感谢您向我们报告。通过这样做,您可以避免其他读者的困扰,并帮助我们改进后续版本的书籍。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata 提交,选择您的书籍,点击勘误提交表格链接,输入勘误的详细信息。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该书籍的勘误部分。

要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索框中输入书名。相关信息将显示在勘误部分。

盗版

互联网上的版权材料盗版问题是所有媒体中的持续问题。在 Packt,我们非常重视版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供相关地址或网站名称,以便我们采取相应的措施。

请通过电子邮件 <copyright@packtpub.com> 与我们联系,提供涉嫌盗版材料的链接。

我们感谢您帮助保护我们的作者及我们为您带来有价值内容的能力。

问题

如果您在本书的任何部分遇到问题,您可以通过 <questions@packtpub.com> 与我们联系,我们会尽力解决问题。

第一章:Vagrant 开发环境

本章将介绍以下内容:

  • 添加一个 Ubuntu Xenial (16.04 LTS) 的 Vagrant 盒子

  • 使用临时的 Ubuntu Xenial (16.04) 环境,几秒钟内完成

  • 在 Vagrant 中启用 VirtualBox 客户端附加功能

  • 使用 VMware 快速启动一个临时的 CentOS 7.x 环境

  • 扩展 VMware 虚拟机的功能

  • 启用多提供商的 Vagrant 环境

  • 自定义 Vagrant 虚拟机

  • 在 Vagrant 中使用 Docker

  • 在 Vagrant 中使用 Docker 来运行 Ghost 博客并通过 NGINX 代理

  • 使用 Vagrant 远程连接 AWS EC2 和 Docker

  • 模拟动态多主机网络环境

  • 使用 Vagrant 模拟一个三层架构的网络化应用

  • 在使用 Laravel 时,将你的工作展示到局域网中

  • 与全球共享你 Vagrant 环境的访问权限

  • 使用 Vagrant 模拟 Chef 升级

  • 使用 Ansible 和 Vagrant 创建一个 Docker 主机

  • 在 CoreOS 上使用 Vagrant 运行 Docker 容器

介绍

Vagrant 是由 Hashicorp 开发的一个免费开源工具,旨在通过简单的 Ruby 代码在虚拟机内部构建可重复的开发环境。你可以将这个简单的文件与其他人、团队成员和外部贡献者共享,只要他们的笔记本电脑支持虚拟化,就能立即拥有一个可用的运行环境。这也意味着你可以使用一台 Mac 笔记本,通过一个简单的命令启动一个完全配置的 Linux 环境以供本地使用。无论每个人的本地机器是什么配置,大家都可以在相同的环境下工作。Vagrant 还非常适用于模拟完整的生产环境,涵盖多个机器和特定操作系统版本。Vagrant 兼容大多数虚拟机监控器,如 VMware、VirtualBox 或 Parallels,并且可以通过插件大幅扩展功能。

Vagrant 使用 盒子 运行。这些盒子实际上是已经打包好的虚拟机镜像,可以从 atlas.hashicorp.com/boxes/search 等网站获取,或者你也可以使用各种工具构建自己的盒子。

Vagrant 可以通过插件进行极大的扩展。几乎任何你能想到的功能都有相应的插件,而且大多数插件都由社区支持。从特定的操作系统到远程 IaaS 提供商,再到共享、缓存或快照、网络、测试功能,甚至是 Chef/Puppet 相关的特性,都可以通过 Vagrant 的插件实现。

所有可用插件的列表,包括所有 Vagrant 提供商,已在 Vagrant wiki 上提供,链接如下:github.com/mitchellh/vagrant/wiki/Available-Vagrant-Plugins

关于所有集成提供商的更多信息,可以在 Vagrant 官网找到:www.vagrantup.com/docs/providers/

你可以从 www.vagrantup.com/downloads.html 下载适用于你平台的 Vagrant 安装程序。

注意

本书使用的 Vagrant 版本是 Vagrant 1.8.4。

添加一个 Ubuntu Xenial (16.04 LTS) Vagrant 盒子

Vagrant 盒子通过名称进行引用,通常遵循 用户名/盒子名 的命名规则。由 Ubuntu 发布的 64 位 Precise 盒子将命名为 ubuntu/precise64,而 centos/7 盒子将始终是最新的 CentOS 7 官方盒子。

准备工作

要执行此步骤,你将需要以下内容:

  • 使用免费开源的 Virtualbox 虚拟机管理程序的 Vagrant 安装实例

  • 一个互联网连接

如何操作…

打开终端并输入以下代码:

$ vagrant box add ubuntu/xenial64
==> box: Loading metadata for box 'ubuntu/xenial64'
 box: URL: https://atlas.hashicorp.com/ubuntu/xenial64
==> box: Adding box 'ubuntu/xenial64' (v20160815.0.0) for provider: virtualbox
 box: Downloading: https://atlas.hashicorp.com/ubuntu/boxes/xenial64/versions/20160815.0.0/providers/virtualbox.box
==> box: Successfully added box 'ubuntu/xenial64' (v20160815.0.0) for 'virtualbox'!

它是如何工作的…

Vagrant 知道在哪里查找请求的盒子在 Atlas 服务中的最新版本,并会自动通过互联网下载它。所有的盒子默认存储在~/.vagrant.d/boxes目录下。

还有更多内容…

如果你有兴趣创建自己的基础 Vagrant 盒子,请参考 Packer (www.packer.io/) 和 Chef Bento 项目 (chef.github.io/bento/)。

几秒钟内使用一次性 Ubuntu Xenial (16.04) 系统

我们希望尽可能快速地访问并使用 Ubuntu Xenial 系统(16.04 LTS)。

为了做到这一点,Vagrant 使用名为 Vagrantfile 的文件来描述 Vagrant 基础设施。这个文件实际上是纯 Ruby 代码,Vagrant 读取它来管理你的环境。所有与 Vagrant 相关的操作都在一个类似下面的代码块内完成:

Vagrant.configure("2") do |config|
  # all your Vagrant configuration here
end

准备工作

要执行此步骤,你将需要以下内容:

  • 一个工作的 Vagrant 安装实例

  • 一个工作的 VirtualBox 安装实例

  • 一个互联网连接

如何操作…

  1. 创建一个项目文件夹:

    $ mkdir vagrant_ubuntu_xenial_1 && cd $_
    
  2. 使用你最喜欢的编辑器,创建这个非常简单的 Vagrantfile 来启动 ubuntu/xenial64 盒子:

    Vagrant.configure("2") do |config|
      config.vm.box = "ubuntu/xenial64"
    end
    
  3. 现在你可以显式使用 Virtualbox 虚拟机管理程序执行 Vagrant:

    $ vagrant up --provider=virtualbox
    
  4. 几秒钟内,你将在主机上运行一个 Ubuntu 16.04 Vagrant 盒子,你可以对它做任何你想做的事。例如,可以通过发出以下 vagrant 命令使用 安全外壳 (SSH) 登录并正常使用系统:

    $ vagrant ssh
    Welcome to Ubuntu 16.04.1 LTS (GNU/Linux 4.4.0-34-generic x86_64)
    […]
    ubuntu@ubuntu-xenial:~$ hostname
    ubuntu-xenial
    ubuntu@ubuntu-xenial:~$ free -m
    ubuntu@ubuntu-xenial:~$ cat /proc/cpuinfo
    
    
  5. 当你完成使用 Vagrant 虚拟机时,你可以直接销毁它:

    $ vagrant destroy
    ==> default: Forcing shutdown of VM...
    ==> default: Destroying VM and associated drives...
    
    

    另外,我们可以通过使用 vagrant halt 停止 Vagrant 虚拟机,目的是稍后在当前状态下重新启动它:

    $ vagrant halt

它是如何工作的…

当你启动 Vagrant 时,它读取了 Vagrantfile,要求运行特定的盒子(Ubuntu Xenial)。如果你之前已添加过,它会通过默认的虚拟机管理程序(在这种情况下是 VirtualBox)立即启动;如果是新的盒子,它会自动为你下载。它创建了所需的虚拟网络接口,然后 Ubuntu 虚拟机获得了一个私有 IP 地址。Vagrant 负责配置 SSH,通过暴露一个可用的端口并插入默认密钥,这样你就可以通过 SSH 无问题地登录。

在 Vagrant 中启用 VirtualBox 客户端附加组件

VirtualBox Guest Additions 是一组驱动程序和应用程序,旨在部署到虚拟机中,以提高性能并启用诸如文件夹共享等功能。虽然可以将 Guest Additions 直接包含在 box 中,但并不是所有找到的 box 都包含它,即使有,也可能会很快过时。

解决方案是通过插件按需自动部署 VirtualBox Guest Additions。

注意

使用这个插件的缺点是,Vagrant box 可能需要更长的时间来启动,因为它可能需要下载并安装正确的 Guest Additions。

准备就绪

要执行此食谱,你将需要以下内容:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的 VirtualBox 安装

  • 一个互联网连接

  • 来自上一食谱的 Vagrantfile

如何操作……

按照以下步骤在 Vagrant 中启用 VirtualBox Guest Additions:

  1. 安装 vagrant-vbguest 插件:

    $ vagrant plugin install vagrant-vbguest
    Installing the 'vagrant-vbguest' plugin. This can take a few minutes...
    Installed the plugin 'vagrant-vbguest (0.13.0)'!
    
    
  2. 确认插件已安装:

    $ vagrant plugin list
    vagrant-vbguest (0.13.0)
    
    
  3. 启动 Vagrant 并查看 VirtualBox Guest Additions 是否已安装:

    $ vagrant up
    […]
    Installing Virtualbox Guest Additions 5.0.26
    […]
    Building the VirtualBox Guest Additions kernel modules
     ...done.
    Doing non-kernel setup of the Guest Additions …done.
    
    
  4. 现在,可能你不想每次启动 Vagrant box 时都做这个,因为它需要时间和带宽,或者因为你的宿主机 VirtualBox 版本和 Vagrant box 中已经安装的版本之间的小差异对你来说不是问题。在这种情况下,你可以直接从 Vagrantfile 中告诉 Vagrant 禁用自动更新功能:

    config.vbguest.auto_update = false
    
  5. 保持代码与没有安装此插件的人兼容的更好方法是,仅在 Vagrant 自身找到插件时才使用此插件配置:

    if Vagrant.has_plugin?("vagrant-vbguest") then
        config.vbguest.auto_update = false
    end
    
  6. 完整的 Vagrantfile 现在看起来是这样的:

    Vagrant.configure("2") do |config|
        config.vm.box = "ubuntu/xenial64"
        if Vagrant.has_plugin?("vagrant-vbguest") then
              config.vbguest.auto_update = false
        end
    end
    

它是如何工作的……

Vagrant 插件会自动从供应商的网站安装,并在你的系统上全局可用,供你运行的所有其他 Vagrant 环境使用。一旦虚拟机准备就绪,插件会检测操作系统,决定是否需要安装 Guest Additions,如果需要,它将安装必要的工具(编译器、内核头文件和库),最后下载并安装相应的 Guest Additions。

还有更多……

使用 Vagrant 插件还扩展了你可以通过 Vagrant CLI 做的事情。在 VirtualBox Guest Additions 插件的情况下,你可以做很多事情,如状态检查、管理安装等:

$ vagrant vbguest --status
[default] GuestAdditions 5.0.26 running --- OK.

该插件可以通过 Vagrant 直接调用;这里它触发了虚拟机中 Guest Additions 的安装:

$ vagrant vbguest --do install

使用一个一次性的 CentOS 7.x 版本和 VMware,几秒钟内即可完成

Vagrant 支持通过 Vagrant 商店中的官方插件支持 VMware Workstation 和 VMware Fusion(www.vagrantup.com/vmware)。按照官网的说明安装插件。

Vagrant 镜像依赖于虚拟化程序——VirtualBox 镜像不能在 VMware 上运行。你需要为每个你选择使用的虚拟化程序使用专门的镜像。例如,Ubuntu 官方发布的镜像仅提供 VirtualBox 镜像。如果你尝试使用一个为另一虚拟化程序构建的镜像来创建 Vagrant 镜像,你将遇到错误。

准备工作

要逐步执行此食谱,你将需要以下内容:

  • 一个有效的 Vagrant 安装

  • 一个有效的 VMware Workstation(PC)或 Fusion(Mac)安装

  • 一个有效的 Vagrant VMware 插件安装

  • 一个互联网连接

如何操作…

Chef Bento 项目提供了多种多提供商镜像,我们可以使用。例如,使用这个最简单的 Vagrantfile 来运行 CentOS 7.2 与 Vagrant(bento/centos-7.2):

Vagrant.configure("2") do |config|
  config.vm.box = "bento/centos-7.2"
end

启动你的 CentOS 7.2 虚拟环境,并指定你要运行的虚拟化程序:

$ vagrant up --provider=vmware_fusion
$ vagrant ssh

你现在正在使用 VMware 运行 CentOS 7.2 Vagrant 镜像!

它是如何工作的…

Vagrant 通过插件扩展其使用和功能。在这种情况下,Vagrant 的 VMware 插件将所有虚拟化功能委托给 VMware 安装,从而不再需要 VirtualBox。

还有更多…

如果 VMware 是你主要的虚拟化程序,你很快会厌倦每次都在命令行中指定提供商。通过将 VAGRANT_DEFAULT_PROVIDER 环境变量设置为对应的插件,你将再也无需指定提供商,VMware 将成为默认:

$ export VAGRANT_DEFAULT_PROVIDER=vmware_fusion
$ vagrant up

另见

扩展 VMware 虚拟机功能

Vagrant 镜像的硬件规格因镜像而异,因为它们在创建时指定。然而,它并非永远固定:这只是默认行为。你可以在 Vagrantfile 中设置需求,以便保持一个小的 Vagrant 镜像,并按需调整。

准备工作

要逐步执行此食谱,你将需要以下内容:

  • 一个有效的 Vagrant 安装

  • 一个有效的 VMware Workstation(PC)或 Fusion(Mac)安装

  • 一个有效的 Vagrant VMware 插件安装

  • 一个互联网连接

  • 使用 bento/centos72 镜像的 Vagrantfile,参见前面的食谱

如何操作…

VMware 提供商可以在以下配置块中进行配置:

# VMware Fusion configuration
config.vm.provider "vmware_fusion" do |vmware|
  # enter all the vmware configuration here
end

# VMware Workstation configuration
config.vm.provider "vmware_workstation" do |vmware|
  # enter all the vmware configuration here
end

如果配置相同,你将会有很多重复的代码。利用 Vagrantfile 的 Ruby 特性,使用一个简单的循环来迭代这两个值:

["vmware_fusion", "vmware_workstation"].each do |vmware|
  config.vm.provider vmware do |v|
    # enter all the vmware configuration here
  end
end

我们默认的 Bento CentOS 7.2 镜像只有 512 MB 内存和一个 CPU。为了更好的性能,我们将通过 vmx["numvcpus"]vmx["memsize"] 键来将其翻倍:

  ["vmware_fusion", "vmware_workstation"].each do |vmware|
    config.vm.provider vmware do |v|
      v.vmx["numvcpus"] = "2"
      v.vmx["memsize"] = "1024"
    end
  end

启动或重启你的 Vagrant 虚拟机以应用更改:

$ vagrant up
[…]

你的 box 现在使用了两个 CPU 和 1 GB 内存。

它是如何工作的…

虚拟机配置是 Vagrant 启动前的最后一步。这里,它仅仅告诉 VMware 为它启动的虚拟机分配两个 CPU 和 1 GB 的内存,就像你在软件内部手动操作时所做的一样。

还有更多…

Vagrant 的作者可能会在未来某个时刻将这两个插件合并成一个。目前,插件的 4.x 版本仍然是分开的。

VMware 对 VMX 格式的文档并不完善。有关 VMX 配置的可能键和值,可以在 VMware Inc. 的大多数文档中找到。

启用多提供商 Vagrant 环境

你可能在笔记本上运行 VMware,但你的同事可能没有。或者,你希望大家可以选择,或者你只是希望两种环境都能工作!我们将看到如何构建一个单一的 Vagrantfile 来支持它们所有。

准备工作

要按照这个步骤进行,你需要以下内容:

  • 一个有效的 Vagrant 安装

  • 一个有效的 VirtualBox 安装

  • 一个有效的 VMware Workstation(PC)或 Fusion(Mac)安装

  • 一个有效的 Vagrant VMware 插件安装

  • 一条互联网连接

  • 使用 bento/centos72 box 的上一个配方中的 Vagrantfile

如何操作…

一些 Vagrant box 可以用于多个虚拟化平台,例如我们之前使用的 CentOS 7 Bento box。这样,我们可以简单地选择使用哪个。

让我们从我们之前的 Vagrantfile 开始,其中包含了对 VMware 的自定义配置:

Vagrant.configure("2") do |config|
  config.vm.box = "bento/centos-7.2"
  ["vmware_fusion", "vmware_workstation"].each do |vmware|
    config.vm.provider vmware do |v|
      v.vmx["numvcpus"] = "2"
      v.vmx["memsize"] = "1024"
    end
  end
end

我们如何在 VirtualBox 上添加与 VMware 相同的配置?以下是在 Vagrantfile 中类似地自定义 VirtualBox 的方法:

  config.vm.provider :virtualbox do |vb|
    vb.memory = "1024"
    vb.cpus = "2"
  end

将此添加到你当前的 Vagrantfile,重新加载后,你将从你的虚拟化平台(无论是 VMware 还是 VirtualBox)获取所请求的资源。

这很好,但我们仍然在重复使用这些值,未来可能会导致错误、遗漏或问题。让我们再次利用 Vagrantfile 的 Ruby 特性,在文件的顶部声明一些有意义的变量:

vm_memory = 1024
vm_cpus = 2

现在,将四个值替换为它们的变量名,你就完成了:你正在集中管理你所使用和分发的 Vagrant 环境的特性,无论你使用的是哪个虚拟化平台。

它是如何工作的…

Vagrantfile 作为纯 Ruby 文件这一事实,有助于通过简单地设置变量来创建强大而动态的配置,后续可以在所有提供商中使用这些变量。

自定义 Vagrant 虚拟机

Vagrant 通过 Vagrantfile 支持许多配置选项。以下是日常使用中最有用的一些。

准备工作

要按照这个步骤进行,你需要以下内容:

  • 一个有效的 Vagrant 安装(带有虚拟化平台)

  • 一条互联网连接

  • 使用 bento/centos72 box 的上一个配方中的 Vagrantfile

如何做到…

以下是一些可能的 Vagrant 虚拟机自定义选项。

设置主机名

如果你想直接从 Vagrant 指定虚拟机名称,只需添加以下内容:

config.vm.hostname = "vagrant-lab-1"

这还将向 /etc/host 文件中添加一个包含主机名的条目。

禁用启动时的新盒子版本检查

你可能正在使用较慢的互联网连接,或者你知道你确实想使用当前安装的盒子,或者你可能很急,只想完成工作;你可以通过添加以下内容,去除启动时检查新版本盒子的选项:

config.vm.box_check_update = false

使用特定的盒子版本

如果你知道自己想使用某个特定版本的盒子(可能出于调试目的或合规性需求),而不是最新版本,你可以简单地按如下方式声明:

config.vm.box_version = "2.2.9"

向用户显示信息性消息

一个有用的功能是向启动 Vagrant 盒子的用户显示一些基本但相关的信息,比如使用说明或连接信息。别忘了转义特殊字符。由于这是 Ruby,你可以访问所有可用的变量,因此信息可以变得更加动态并对用户更有用:

config.vm.post_up_message = "Use \"vagrant ssh\" to log into the box. This VM uses #{vm_cpus} CPUs and #{vm_memory}MB of RAM."

指定最低 Vagrant 版本

Vagrant 经常更新,并定期添加新功能。如果你使用了一个已知只有在特定版本之后才能正常工作的功能,最好在 Vagrantfile 中声明,以便使用旧版本的人知道他们需要更新:

Vagrant.require_version ">= 1.8.0"

在 Vagrant 中使用 Docker

开发环境通常会混合使用虚拟机和 Docker 容器。虚拟机包含运行完整操作系统所需的所有内容,如内存、CPU、内核和所有必需的库,而容器则更加轻量,可以与宿主机共享所有这些资源,同时通过名为 cgroups 的特殊内核功能保持良好的隔离。Docker 容器帮助开发者使用、共享和发布一个包含运行应用程序所需的一切的。在这里,我们将展示如何使用 Vagrant 启动容器。由于 Docker 在 Linux 主机和其他平台上的使用略有不同,本文所参考的是原生 Docker 平台——Linux。

准备工作

要逐步执行这个过程,你将需要以下内容:

  • 一个工作正常的 Vagrant 安装(无需虚拟化管理程序)

  • 一个工作正常的 Docker 安装和基本的 Docker 知识

  • 一个互联网连接

如何做到…

我们将看到如何使用 Docker 作为提供者,在 Vagrant 中使用、访问和操作一个 NGINX 容器。

通过 Vagrant 使用 NGINX Docker 容器

从最简单的 Vagrantfile 开始,使用 nginx:stable 容器与 Docker Vagrant 提供者:

Vagrant.configure("2") do |config|
  config.vm.hostname = "vagrant-docker-1"
  config.vm.post_up_message = "HTTP access: http://localhost/"
  config.vm.provider "docker" do |docker|
      docker.image = "nginx:stable"
  end
end

只需使用以下代码启动它:

$ vagrant up --provider=docker
Bringing machine 'default' up with 'docker' provider...
==> default: Creating the container...
[…]
==> default: HTTP access: http://localhost/

通过在 Vagrantfile 顶部设置一个简单的 Ruby 环境访问代码,来消除在命令行中指定提供者的需求:

ENV['VAGRANT_DEFAULT_PROVIDER'] = 'docker'

现在你可以分发你的 Vagrantfile,而不必担心别人忘记明确指定 Docker 提供者。

在 Vagrant 中暴露 Docker 端口

好吧,之前的示例并不太有用,因为我们没有暴露任何端口。现在让我们告诉 Vagrant 暴露 Docker 容器的 HTTP(TCP/80)端口到主机的 HTTP(TCP/80)端口:

  config.vm.provider "docker" do |docker|
      docker.image = "nginx:stable"
      docker.ports = ['80:80']
  end

重启 Vagrant 并验证你是否能访问到你的 NGINX 容器:

$ curl http://localhost/

通过 Vagrant 共享 Docker 文件夹

那么,如果共享一个本地文件夹,让你可以在笔记本上编码并查看 Vagrant 环境处理后的结果呢?默认的 NGINX 配置会从/usr/share/nginx/html读取文件。我们把自己的index.html放在这里。

创建一个简单的src/index.html文件,包含一些文本:

$ mkdir src; echo "<h1>Hello from Docker via Vagrant<h1>" > src/index.html

将 Docker 卷配置添加到 Vagrant 中的 Docker 提供者块:

  config.vm.provider "docker" do |docker|
      docker.image = "nginx:stable"
      docker.ports = ['80:80']
      docker.volumes = ["#{Dir.pwd}/src:/usr/share/nginx/html"]
  end

注意

#{Dir.pwd}是 Ruby 中用于查找当前目录的命令,使用它可以避免硬编码路径,使得代码具有很好的可分发性。

重启 Vagrant 环境并查看结果:

$ curl http://localhost
<h1>Hello from Docker via Vagrant<h1>

注意

在启用了 SELinux 的系统上,你可能需要做一些配置,这超出了本书的范围。我们鼓励你使用 SELinux 来确保 Docker 系统的安全,但要禁用 SELinux,只需键入以下命令:

$ sudo setenforce 0

还有更多内容…

你可以选择不使用本地或默认的 Docker 安装,而是使用专用的虚拟机,也许是为了模拟生产环境或特定的操作系统(例如 CoreOS)。在这种情况下,你可以像下面这样指定一个专用的 Vagrantfile:

config.vm.provider "docker" do |docker|
docker.vagrant_vagrantfile = "docker_host/Vagrantfile"
end

在 Vagrant 中使用 Docker 为 NGINX 后面的 Ghost 博客提供服务

在 Docker 中使用 Vagrant 可以更有效地模拟传统的设置,比如应用程序后面有负载均衡器或反向代理。我们已经设置了 NGINX,那么可以用它作为前端反向代理,并将像 Ghost 这样的博客引擎放在后面吗?我们最后将展示如何使用 docker-compose 做类似的事情。

准备工作

要执行这个操作,你需要以下条件:

  • 一个有效的 Vagrant 安装(不需要虚拟化管理程序)

  • 一个有效的 Docker 安装和基本的 Docker 知识

  • 一个互联网连接

如何操作…

之前的示例只允许启动一个容器,这有点遗憾,因为 Docker 的强大之处就在于可以运行多个容器。让我们定义多个容器,并从创建一个front容器(我们之前的 NGINX)开始:

  config.vm.define "front" do |front|
    front.vm.provider "docker" do |docker|
      docker.image = "nginx:stable"
      docker.ports = ['80:80']
      docker.volumes = ["#{Dir.pwd}/src:/usr/share/nginx/html"]
    end
  end

现在,如何创建一个应用程序容器呢,也许是像 Ghost 这样的博客引擎?Ghost 在 Docker Hub 上发布了一个现成的容器,所以我们就使用它(本文写作时为版本 0.9.0),并将 TCP/2368 上监听的应用程序容器暴露到 TCP/8080 上:

  config.vm.define "app" do |app|
    app.vm.provider "docker" do |docker|
      docker.image = "ghost:0.9.0"
      docker.ports = ['8080:2368']
    end
  end

检查是否可以在http://localhost:8080访问博客,在http://localhost访问 NGINX:

$ curl -IL http://localhost:8080
HTTP/1.1 200 OK
X-Powered-By: Express
[…]

$ curl -IL http://localhost
HTTP/1.1 200 OK
Server: nginx/1.10.1

现在让我们使用 NGINX 来实现它的目的——为应用程序提供服务。配置 NGINX 作为反向代理超出了本书的范围,所以只需使用以下简单配置,将其保存为工作文件夹根目录下的nginx.conf文件:

server {
  listen 80;
  location / {
    proxy_set_header   X-Real-IP $remote_addr;
    proxy_set_header   Host      $http_host;
    proxy_pass         http://app:2368;
  }
}

更改 Vagrant 中front容器的配置,使用这个配置,删除旧的index.html,因为我们不再使用它,并将此容器与app容器连接:

  config.vm.define "front" do |front|
    front.vm.provider "docker" do |docker|
      docker.image = "nginx:stable"
      docker.ports = ['80:80']
      docker.volumes = ["#{Dir.pwd}/nginx.conf:/etc/nginx/conf.d/default.conf"]
      docker.link("app:app")
    end
  end

链接app容器使其可以被front容器访问,因此现在无需直接暴露 Ghost 博客容器,让我们通过反向代理将其变得更加简洁和安全:

  config.vm.define "app" do |app|
    app.vm.provider "docker" do |docker|
      docker.name = "app"
      docker.image = "ghost:0.9.0"
    end
  end

我们快完成了!但是这个设置最终会失败,原因很简单:我们的系统太快了,Vagrant 默认会并行启动虚拟机,也会并行启动容器。容器启动得太快,以至于app容器可能在 NGINX 启动时还没有准备好。为了确保顺序启动,请在 Vagrantfile 顶部使用VAGRANT_NO_PARALLEL环境变量:

ENV['VAGRANT_NO_PARALLEL'] = 'true'

现在你可以浏览到http://localhost/admin并开始使用你在容器中的 Ghost 博客,它位于 NGINX 反向代理容器后面,整个过程由 Vagrant 管理!

还有更多……

你可以通过 Vagrant 直接访问容器日志:

$ vagrant docker-logs --follow
==> app: > ghost@0.9.0 start /usr/src/ghost
==> app: > node index
==> app: Migrations: Creating tables...
[…]
==> front: 172.17.0.1 - - [21/Aug/2016:10:55:08 +0000] "GET / HTTP/1.1" 200 1547 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:48.0) Gecko/20100101 Firefox/48.0" "-"
==> app: GET / 200 113.120 ms - -
[…]

Docker Compose 等效方式

Docker Compose 是一个工具,用于从单一的 YAML 文件编排多个容器并管理 Docker 功能。所以如果你更熟悉 Docker Compose,或者你希望用这个工具做类似的事情,以下是docker-compose.yml文件中的代码:

version: '2'
services:
  front:
    image: nginx:stable
    volumes:
      - "./nginx.conf:/etc/nginx/conf.d/default.conf"
    restart: always
    ports:
      - "80:80"
    depends_on:
      - app
    links:
      - app
  app:
    image: ghost:0.9.0
    restart: always

注意

记住,使用 Vagrant,你可以混合虚拟机和 Docker 容器,而使用 docker-compose 则不行。

使用 Vagrant 远程连接 AWS EC2 和 Docker

Vagrant 的另一个强大用法是与远程 IaaS 资源结合使用,如 Amazon EC2。Amazon Web Services 弹性计算云(EC2)和类似的基础设施即服务提供商,如 Google Cloud、Azure 或 Digital Ocean 等,出售具有不同计算能力和网络带宽的虚拟机。你不一定总是能在你的笔记本上获得所需的全部 CPU 和内存,或者你需要为某个任务提供特定的计算能力,或者你只是想复制现有生产环境的一部分:这是你如何利用 Vagrant 与 Amazon EC2 结合使用的方式。

在这里,我们将在 AWS EC2 上使用 Ubuntu Xenial 16.04 和 Docker 部署一个带有 NGINX 反向代理的 Ghost 博客!这是为了模拟应用程序的实际部署,这样你就可以看到它在真实条件下是否能正常工作。

准备工作

要按照这个教程操作,你需要以下内容:

  • 一个正常工作的 Vagrant 安装(不需要虚拟化管理程序)

  • 一个 Amazon EC2 账户(如果你还没有,可以在aws.amazon.com/免费创建一个),需要有效的访问密钥、名为iac-lab的密钥对、一个名为iac-lab的安全组,允许至少 HTTP 端口和 SSH 访问。

  • 需要一个互联网连接

如何操作…

首先安装插件:

$ vagrant plugin install vagrant-aws

这个插件的一个要求是必须有一个什么都不做的虚拟 Vagrant box:

$ vagrant box add dummy https://github.com/mitchellh/vagrant-aws/raw/master/dummy.box

记得我们在前面的教程中如何配置 Docker 提供者吗?这没什么不同:

config.vm.provider :aws do |aws, override|
  # AWS Configuration
  override.vm.box = "dummy"
end

然后,定义一个应用程序虚拟机,将包括指定其使用的提供商(在我们这里是 AWS)、Amazon 机器映像AMI)(在我们这里是 Ubuntu 16.04 LTS),以及一个我们创意命名为 script.sh 的配置脚本。

您可以在 cloud-images.ubuntu.com/locator/ec2/ 找到其他 AMI ID:

config.vm.define "srv-1" do |config|
    config.vm.provider :aws do |aws|
      aws.ami = "ami-c06b1eb3"
    end
    config.vm.provision :shell, :path => "script.sh"
end

那么,我们需要填写哪些与 AWS 相关的信息,以便 Vagrant 能够在 AWS 上启动服务器呢?

我们需要 AWS 访问密钥,最好从环境变量中获取,这样就不需要在 Vagrantfile 中硬编码它们:

aws.access_key_id = ENV['AWS_ACCESS_KEY_ID']
aws.secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']

指定您希望实例启动的区域和可用区:

aws.region = "eu-west-1"
aws.availability_zone = "eu-west-1a"

包括实例类型;在这里,我们选择了 AWS 免费套餐中包含的类型,因此在新帐户下不会花费您一分钱:

aws.instance_type = "t2.micro"

指定此实例将所在的安全组(您可以根据需要调整要求):

aws.security_groups = ['iac-lab']

指定 AWS 密钥对名称,并覆盖默认的 SSH 用户名和密钥:

aws.keypair_name = "iac-lab"
override.ssh.username = "ubuntu"
override.ssh.private_key_path = "./keys/iac-lab.pem"

在某些情况下,使用 Vagrant 和 AWS EC2 时,可能会遇到 NFS 的 bug,因此我选择禁用此功能:

override.nfs.functional = false

最后,为实例打标签是一个好习惯,这样您以后就能查找它们的来源:

aws.tags = {
  'Name'   => 'Vagrant'
}

添加一个简单的 shell 脚本来安装 Docker 和 docker-compose,然后执行 docker-compose 文件:

#!/bin/sh
# install Docker
curl -sSL https://get.docker.com/ | sh
# add ubuntu user to docker group
sudo usermod -aG docker ubuntu
# install docker-compose
curl -L https://github.com/docker/compose/releases/download/1.8.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# execute the docker compose file
cd /vagrant
docker-compose up -d

包含前面配方中的 NGINX 配置和 docker-compose.yml 文件,您就可以开始了:

$ vagrant up
Bringing machine 'srv-1' up with 'aws' provider...
[…]
==> srv-1: Launching an instance with the following settings...
==> srv-1:  -- Type: t2.micro
==> srv-1:  -- AMI: ami-c06b1eb3
==> srv-1:  -- Region: eu-west-1
[…]
==> srv-1: Waiting for SSH to become available...
==> srv-1: Machine is booted and ready for use!
[…]
==> srv-1:  docker version
[…]
==> srv-1: Server:
==> srv-1:  Version:      1.12.1
[…]
==> srv-1: Creating vagrant_app_1
==> srv-1: Creating vagrant_front_1

打开浏览器并访问 http://a.b.c.d/(使用 EC2 实例的公网 IP),您将看到 Ghost 博客通过 NGINX 反向代理在 Docker 容器中运行,使用 Vagrant 在 Amazon EC2 上部署。

这种设置的常见用途是让开发人员在接近真实生产环境的条件下测试应用程序,可能是向远程产品经理展示新功能,复制仅在此设置中出现的 bug,或者在某个 CI 阶段进行测试。一旦构建了 Docker 容器,可以在 EC2 上进行烟雾测试,然后再继续其他操作。

模拟动态的多个主机网络

当用于模拟网络中的多个主机时,Vagrant 也非常有用。这样,您可以在同一个私有网络中拥有能够相互通信的完整系统,轻松测试系统间的连接性。

准备开始

要逐步完成此配方,您需要以下内容:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的 VirtualBox 安装

  • 一个互联网连接

如何做到这一点……

下面是如何创建一个 CentOS 7.2 虚拟机,配置 512 MB 内存和一个 CPU,位于私有网络并使用固定 IP 地址 192.168.50.11,输出一个简单的 shell:

vm_memory = 512
vm_cpus = 1

Vagrant.configure("2") do |config|

  config.vm.box = "bento/centos-7.2"

  config.vm.provider :virtualbox do |vb|
    vb.memory = vm_memory
    vb.cpus = vm_cpus
  end

   config.vm.define "srv-1" do |config|
     config.vm.provision :shell, :inline => "ip addr | grep \"inet\" | awk '{print $2}'"
     config.vm.network "private_network", ip: "192.168.50.11", virtualbox__intnet: "true"
   end
end

要向该网络添加新机器,我们可以简单地复制 srv-1 机器定义,如下所示:

config.vm.define "srv-2" do |config|
     config.vm.provision :shell, :inline => "ip addr | grep \"inet\" | awk '{print $2}'"
     config.vm.network "private_network", ip: "192.168.50.12", virtualbox__intnet: "true"
end

这不是很符合 DRY 原则,所以让我们利用 Vagrantfile 的 Ruby 特性创建一个循环,动态且简洁地创建我们需要的虚拟机数量。

首先,声明一个变量来指定我们想要的虚拟机数量(2):

vm_num = 2

然后遍历该值,以生成 IP 和主机名的值:

(1..vm_num).each do |n|
    # a lan lab in the 192.168.50.0/24 range
    lan_ip = "192.168.50.#{n+10}"
    config.vm.define "srv-#{n}" do |config|
      config.vm.provision :shell, :inline => "ip addr | grep \"inet\" | awk '{print $2}'"
      config.vm.network "private_network", ip: lan_ip, virtualbox__intnet: "true"
    end
  end

这将创建两个虚拟机(srv-1的 IP 为192.168.50.11srv-2的 IP 为192.168.50.12)在同一内部网络中,这样它们就可以互相通信。

现在你只需简单地更改vm_num的值,就能轻松在几秒钟内启动新的虚拟机。

还有更多内容…

我们还可以选择进一步扩展,使用以下克隆和网络功能。

通过链接克隆加速部署

链接克隆是一个特性,允许基于现有的磁盘映像创建新的虚拟机,而不需要重复所有内容。每个虚拟机只存储其增量状态,从而实现非常快速的虚拟机启动时间。

由于我们启动了多个虚拟机,你可以选择启用链接克隆以加速过程:

config.vm.provider :virtualbox do |vb|
    vb.memory = vm_memory
    vb.cpus = vm_cpus
 vb.linked_clone = true
end

使用命名的 NAT 网络

VirtualBox 提供了一个选项,让你定义自己的网络以便进一步参考或重用。你可以在首选项 | 网络 | NAT 网络下进行配置。幸运的是,Vagrant 也可以与这些命名的 NAT 网络一起使用。为了测试此功能,你可以在 VirtualBox 中创建一个网络(如 iac-lab),并将其分配给网络192.168.50.0/24

只需更改前面 Vagrantfile 中的网络配置,即可在此特定网络中启动虚拟机:

config.vm.network "private_network", ip: lan_ip, virtualbox__intnet: "iac-lab"

使用 Vagrant 模拟一个网络化的三层架构应用

Vagrant 是一个非常棒的工具,可以帮助模拟孤立网络中的系统,使我们能够轻松地模拟生产环境中的架构。多层架构的核心理念是将应用程序的不同元素的逻辑和执行分开,而不是将所有内容集中在一个地方。常见的模式是首先获取一个处理常见用户请求的层,第二层执行应用程序的任务,第三层存储和检索数据,通常是从数据库中获取。

在此模拟中,我们将使用传统的三层架构,每一层都在自己的独立网络上运行 CentOS 7 虚拟机:

  • 前端:NGINX 反向代理

  • 应用:一个在两个节点上运行的 Node.js 应用

  • 数据库:Redis

虚拟机名称 front_lan IP app_lan IP db_lan IP
front-1 10.10.0.11/24 10.20.0.101/24 N/A
app-1 N/A 10.20.0.11/24 10.30.0.101/24
app-2 N/A 10.20.0.12/24 10/30.0.102/24
db-1 N/A N/A 10.30.0.11/24

你将访问反向代理(NGINX),只有它能够与应用服务器(Node.js)进行通信,而应用服务器则是唯一能够连接到数据库的。

准备工作

要完成这个步骤,你需要以下内容:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的 VirtualBox 安装

  • 一条互联网连接

如何实现…

按照以下步骤使用 Vagrant 模拟一个网络化的三层架构应用。

第三层 – 数据库

数据库位于一个名为 db_lan 的私有网络中,IP 地址为 10.30.0.11/24。

该应用程序将使用一个简单的 Redis 安装。安装和配置 Redis 超出了本书的范围,因此我们将尽量简化(安装它,配置它监听局域网端口而不是 127.0.0.1,并启动它):

  config.vm.define "db-1" do |config|
    config.vm.hostname = "db-1"
    config.vm.network "private_network", ip: "10.30.0.11", virtualbox__intnet: "db_lan"
    config.vm.provision :shell, :inline => "sudo yum install -q -y epel-release"
    config.vm.provision :shell, :inline => "sudo yum install -q -y redis"
    config.vm.provision :shell, :inline => "sudo sed -i 's/bind 127.0.0.1/bind 127.0.0.1 10.30.0.11/' /etc/redis.conf"
    config.vm.provision :shell, :inline => "sudo systemctl enable redis"
    config.vm.provision :shell, :inline => "sudo systemctl start redis"
  end

第二级:应用程序服务器

这一层是我们应用程序所在的地方,背后是一个应用程序(Web)服务器。该应用程序可以连接到数据库层,并通过第一级代理服务器提供给最终用户。通常这就是应用程序完成所有逻辑处理的地方。

Node.js 应用程序

这将通过我能够编写的最简单的 Node.js 代码来模拟,展示服务器主机名(文件名为app.js)。

首先,它将在db_lan网络上创建与 Redis 服务器的连接:

#!/usr/bin/env node
var os = require("os");
var redis = require('redis');
var client = redis.createClient(6379, '10.30.0.11');
client.on('connect', function() {
    console.log('connected to redis on '+os.hostname()+' 10.30.0.11:6379');
});

如果一切顺利,它将创建一个在:8080上监听的 HTTP 服务器,显示服务器的主机名:

var http = require('http');
http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Running on '+os.hostname()+'\n');
}).listen(8080);
console.log('HTTP server listening on :8080');

启动应用程序,这是最简单的systemd服务文件(systemd单元文件不在本书讨论范围内):

[Unit]
Description=Node App
After=network.target

[Service]
ExecStart=/srv/nodeapp/app.js
Restart=always
User=vagrant
Group=vagrant
Environment=PATH=/usr/bin
Environment=NODE_ENV=production
WorkingDirectory=/srv/nodeapp
[Install]
WantedBy=multi-user.target

让我们逐步部署若干个应用程序服务器(在本例中为两个),以提供应用程序服务。再次强调,部署 Node.js 应用程序不在本书的讨论范围内,因此我尽量简化了——仅创建简单的目录和权限,并部署 systemd 单元。在生产环境中,这通常会通过像 Chef 或 Ansible 这样的配置管理工具来完成,并可能结合适当的部署工具:

# Tier 2: a scalable number of application servers
vm_app_num = 2
  (1..vm_app_num).each do |n|
    app_lan_ip = "10.20.0.#{n+10}"
    db_lan_ip = "10.30.0.#{n+100}"
    config.vm.define "app-#{n}" do |config|
      config.vm.hostname = "app-#{n}"
      config.vm.network "private_network", ip: app_lan_ip, virtualbox__intnet: "app_lan"
      config.vm.network "private_network", ip: db_lan_ip, virtualbox__intnet: "db_lan"
      config.vm.provision :shell, :inline => "sudo yum install -q -y epel-release"
      config.vm.provision :shell, :inline => "sudo yum install -q -y nodejs npm"
      config.vm.provision :shell, :inline => "sudo mkdir /srv/nodeapp"
      config.vm.provision :shell, :inline => "sudo cp /vagrant/app.js /src/nodeapp"
      config.vm.provision :shell, :inline => "sudo chown -R vagrant.vagrant /srv/"
      config.vm.provision :shell, :inline => "sudo chmod +x /srv/nodeapp/app.js"
      config.vm.provision :shell, :inline => "cd /srv/nodeapp; npm install redis"
      config.vm.provision :shell, :inline => "sudo cp /vagrant/nodeapp.service /etc/systemd/system"
      config.vm.provision :shell, :inline => "sudo systemctl daemon-reload"
      config.vm.provision :shell, :inline => "sudo systemctl start nodeapp"
    end
  end

第一级:NGINX 反向代理

第一级在这里由 CentOS 7 上的 NGINX 反向代理配置表示,尽可能简单,以适应本次演示。配置一个带有服务器池的 NGINX 反向代理超出了本书的讨论范围:

events {
  worker_connections 1024;
}
http {
  upstream app {
    server 10.20.0.11:8080 max_fails=1 fail_timeout=1s;
    server 10.20.0.12:8080 max_fails=1 fail_timeout=1s;
  }
  server {
    listen 80;
    server_name  _;
    location / {
      proxy_set_header   X-Real-IP $remote_addr;
      proxy_set_header   Host      $http_host;
      proxy_pass         http://app;
    }
  }
}

现在,让我们创建一个反向代理虚拟机,通过应用程序服务器池提供http://localhost:8080。这台虚拟机在自己的局域网(front_lan)上监听10.10.0.11/24,在应用程序服务器的局域网(app_lan)上监听10.20.0.101/24

  # Tier 1: an NGINX reverse proxy VM, available on http://localhost:8080
  config.vm.define "front-1" do |config|
    config.vm.hostname = "front-1"
    config.vm.network "private_network", ip: "10.10.0.11", virtualbox__intnet: "front_lan"
    config.vm.network "private_network", ip: "10.20.0.101", virtualbox__intnet: "app_lan"
    config.vm.network "forwarded_port", guest: 80, host: 8080
    config.vm.provision :shell, :inline => "sudo yum install -q -y epel-release"
    config.vm.provision :shell, :inline => "sudo yum install -q -y nginx"
    config.vm.provision :shell, :inline => "sudo cp /vagrant/nginx.conf /etc/nginx/nginx.conf"
    config.vm.provision :shell, :inline => "sudo systemctl enable nginx"
    config.vm.provision :shell, :inline => "sudo systemctl start nginx"
  end

启动它(vagrant up),并导航到http://localhost:8080,在该页面上,应用程序显示应用服务器主机名,你可以确认跨网络的负载均衡是否正常工作(同时应用服务器能够与 Redis 后端通信)。

在使用 Laravel 时展示你的工作

你正在使用 Laravel 框架(一个免费开源的 PHP 框架,https://laravel.com/)开发应用程序,并且想向你的同事展示你的工作。使用 Vagrant 开发环境可以帮助你保持工作机的整洁,并允许你使用常用工具和编辑器,同时使用接近生产环境的基础设施。

在这个示例中,我们将部署一台 CentOS 7 服务器,安装 NGINX、PHP-FPM 和 MariaDB,所有 PHP 依赖项,并安装 Composer。你可以从这个示例和本书中的其他示例中构建一个模拟生产环境的环境(三层架构、多台机器和其他特征)。

这个环境将对您网络中的所有同事开放,并且代码对您本地可访问。

准备就绪

要按步骤完成此教程,您需要以下内容:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的 VirtualBox 或 VMware 安装

  • 一个互联网连接

如何操作……

让我们从最简单的 Vagrant 环境开始:

Vagrant.configure("2") do |config|
  config.vm.box = "bento/centos-7.2"
  config.vm.define "srv-1" do |config|
    config.vm.hostname = "srv-1"
  end
end

一个适用于 Laravel 的示例 NGINX 配置

配置 NGINX 以支持 Laravel 超出了本书的范围,但作为参考,这里有一个简单的 NGINX 配置,它能很好地支持我们,监听 HTTP,服务文件位于/srv/app/public,并使用 PHP-FPM(文件名为nginx.conf):

events {
  worker_connections 1024;
}
http {
  sendfile off;
  server {
    listen 80;
    server_name  _;
    root /srv/app/public ;
    try_files $uri $uri/ /index.php?q=$uri&$args;
    index index.php;
    location / {
      try_files $uri $uri/ /index.php?$query_string;
    }
    location ~ \.php$ {
      try_files $uri /index.php =404;
      fastcgi_split_path_info ^(.+\.php)(/.+)$;
      fastcgi_pass 127.0.0.1:9000;
      fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
      fastcgi_param PATH_INFO $fastcgi_script_name;
      include fastcgi_params;
    }
  }
}

简单的 Shell 配置

我们将创建一个名为provision.sh的配置脚本,里面包含我们需要的所有步骤,以便拥有一个完整工作的 Laravel 环境。具体细节超出了本书的范围,但以下是步骤:

  1. 我们需要企业 Linux 的额外软件包EPEL):

    sudo yum install -q -y epel-release
    
    
  2. 我们需要 PHP-FPM:

    sudo yum install -q -y php-fpm
    
    
  3. 我们希望 PHP-FPM 以 Vagrant 用户身份运行,以便拥有适当的权限:

    sudo sed -i 's/user = apache/user = vagrant/' /etc/php-fpm.d/www.conf
    
    
  4. 安装一堆 PHP 依赖:

    sudo yum install -q -y php-pdo php-mcrypt php-mysql php-cli php-mbstring php-dom
    
    
  5. 安装 Composer:

    curl -sS https://getcomposer.org/installer | php
    sudo mv composer.phar /usr/local/bin/composer
    sudo chmod +x /usr/local/bin/composer
    
    
  6. 安装并配置一个足够好的 NGINX 配置:

    sudo yum install -q -y nginx
    sudo cp /vagrant/nginx.conf /etc/nginx/nginx.conf
    
    
  7. 安装 MariaDB Server:

    sudo yum install -q -y mariadb-server
    
    
  8. 启动所有服务:

    sudo systemctl enable php-fpm
    sudo systemctl start php-fpm
    sudo systemctl enable nginx
    sudo systemctl start nginx
    sudo systemctl enable mariadb
    sudo systemctl start mariadb
    
    

启用配置

要启用使用我们的脚本进行配置,请在虚拟机定义块中添加以下代码:

config.vm.provision :shell, :path => "provision.sh"

共享文件夹

要在主机和 Vagrant 虚拟机之间共享src文件夹,并将其挂载到/srv/app,您可以添加以下代码:

config.vm.synced_folder "src/", "/srv/app"

公共 LAN 网络

我们现在需要做的最后一件事是向我们的 Vagrant 虚拟机添加一个网络接口,它将连接到真实的局域网,这样我们的同事就可以通过网络轻松访问它:

config.vm.network "public_network", bridge: "en0: Wi-Fi (AirPort)"

根据需要调整您的网络适配器名称(这在 Mac 上,如您所见)。另一种解决方案是不指定适配器名称,这样会显示一个可供桥接的适配器列表:

==> srv-1: Available bridged network interfaces:
1) en0: Wi-Fi (AirPort)
[...]

启动 Vagrant 环境(vagrant up),当它可用时,您可以执行诸如获取网络信息等命令:vagrant ssh -c "ip addr"。您的情况可能不同,但在这个网络中,这个 Vagrant 盒子的公共 IP 地址是192.168.1.106,所以我们的工作是可访问的。

现在,您可以开始在./src/文件夹中编码。虽然这不是一本 Laravel 书籍,但创建新项目的方式如下:

cd /srv/app
composer create-project --prefer-dist laravel/laravel.

别忘了提前清空文件夹中的所有文件。导航到http://local-ip/,您将看到默认的 Laravel 欢迎页面。

要验证文件共享同步是否正常工作,请编辑./resources/views/welcome.blade.php文件,并重新加载浏览器查看更改是否已反映。

还有更多……

如果将 Vagrantfile 直接与项目代码一起包含,您的同事或贡献者只需运行vagrant up,即可看到它在运行。

其他 Vagrantfile 共享选项包括 Windows 共享(smb)、rsync(对于远程虚拟机,如 AWS EC2 非常有用),甚至 NFS。

使用 VirtualBox 共享功能时,一个明显的 bug 会导致文件损坏或无法更新。解决方法是禁用 Web 服务器配置中的 sendfile,使用 NGINX:

sendfile off;

使用 Apache,如下所示:

EnableSendfile Off

将你的 Vagrant 环境共享给全世界

你正在本地 Vagrant 环境中进行项目开发,并且你想向位于另一个城市的客户展示工作的状态。也许你在配置某些内容时遇到问题,需要来自地球另一端同事的远程帮助。或者,你可能希望从家里、酒店或共享办公空间访问你的工作 Vagrant 盒子?这里有一个很棒的 Vagrant 共享功能,我们将用于在 CentOS 7.2 上运行的 Ghost 博客。

准备就绪

要完成这个教程,你需要以下内容:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的 VirtualBox 安装

  • 一个免费的 HashiCorp Atlas 账户 (atlas.hashicorp.com/account/new)

  • 一个互联网连接

如何操作……

我们从这个简单的 Vagrantfile 开始:

Vagrant.configure("2") do |config|
  config.vm.box = "bento/centos-7.2"
  config.vm.define "blog" do |config|
    config.vm.hostname = "blog"
  end
end

我们知道需要安装一些包,所以让我们添加一个配置脚本来执行:

    config.vm.provision :shell, :path => "provision.sh"

我们想要在本地对 Ghost 博客进行修改,比如添加主题等,因此我们将 src/ 文件夹同步到远程 /srv/blog 文件夹:

    config.vm.synced_folder "src/", "/srv/blog"

我们需要一个本地私有网络,以便可以访问虚拟机,并将 2368 TCP 端口(Ghost 默认端口)重定向到我们主机的 8080 HTTP 端口:

    config.vm.network "private_network", type: "dhcp"
    config.vm.network "forwarded_port", guest: 2368, host: 8080

配置

  1. 为了配置我们的新盒子,首先需要启用 EPEL:

    sudo yum install -q -y epel-release
    
    
  2. 然后安装要求的工具,nodenpmunzip

    sudo yum install -q -y node npm unzip 
    
    
  3. 下载最新版本的 Ghost:

    curl -L https://ghost.org/zip/ghost-latest.zip -o ghost.zip
    
    
  4. 将其解压到 /srv/blog 文件夹中:

    sudo unzip -uo ghost.zip -d /srv/blog/
    
    
  5. 安装 Ghost 依赖项:

    cd /srv/blog && sudo npm install --production
    
    

将所有这些命令放入 provisioning.sh 脚本中,之后我们就可以开始了:vagrant up

启动 Ghost 引擎

按照通常的方式,登录到你的 Vagrant 盒子,启动节点服务器:

vagrant ssh
cd /srv/blog && sudo npm start --production
[…]
Ghost is running in production...
Your blog is now available on http://my-ghost-blog.com
Ctrl+C to shut down

将生成的 config.js 文件中的主机 IP 从 127.0.0.1 更改为 0.0.0.0,这样服务器就能监听所有接口:

server: {
 host: '0.0.0.0',
 port: '2368'
 }

重启节点服务器:

cd /srv/blog && sudo npm start --production

你现在可以通过你的盒子局域网 IP 直接访问博客(根据你的情况调整 IP):http://172.28.128.3:2368/

共享访问

现在,你可以通过你的 Vagrant 盒子本地访问你的应用,让我们通过互联网使用 vagrant share 给它提供访问权限:

HTTP

默认情况下,通过 HTTP 共享,因此你可以通过 Web 浏览器访问你的工作:

$ vagrant share
==> srv-1: Detecting network information for machine...
[...]
==> srv-1: Your Vagrant Share is running! Name: anxious-cougar-6317
==> srv-1: URL: http://anxious-cougar-6317.vagrantshare.com

这个 URL 是你可以分享给任何人以公开访问你工作的链接:Vagrant 服务器作为代理。

SSH

另一种可能的共享选项是通过 SSH(默认情况下禁用)。该程序会要求你输入密码,用以远程连接到盒子:

$ vagrant share --ssh
==> srv-1: Detecting network information for machine...
[...]
srv-1: Please enter a password to encrypt the key:
 srv-1: Repeat the password to confirm:
[...]
==> srv-1: You're sharing with SSH access. This means that another user
==> srv-1: simply has to run `vagrant connect --ssh subtle-platypus-4976`
==> srv-1: to SSH to your Vagrant machine.
[...]

现在,在家里或共享办公空间,你只需连接到你的工作 Vagrant 盒子(如果需要,默认的 Vagrant 密码是 vagrant):

$ vagrant connect --ssh subtle-platypus-4976
Loading share 'subtle-platypus-4976'...
[...]
[vagrant@srv-1 ~]$ head -n1 /srv/blog/config.js
// # Ghost Configuration

你或你的同事现在可以通过互联网远程登录到你自己的 Vagrant 盒子了!

使用 Vagrant 模拟 Chef 升级

如果能够快速模拟生产环境的变更,那不是很棒吗?很可能你在生产环境中使用的是 Chef。我们将学习如何在 Vagrant 中使用 Chef cookbook,并且如何在不同环境之间模拟 Chef 版本升级。这种配置是将基础设施即代码(Infrastructure as Code)结合得非常好的起点。

准备工作

要执行这个步骤,你需要以下资源:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的 VirtualBox 安装

  • 一个互联网连接

如何做到这一点…

让我们从一个最小的虚拟机 prod 开始,它仅启动一个 CentOS 7.2,就像我们在生产环境中的设置:

Vagrant.configure("2") do |config|
  config.vm.box = "bento/centos-7.2"
  config.vm.define "prod" do |config|
    config.vm.hostname = "prod"
    config.vm.network "private_network", type: "dhcp"
  end

end

Vagrant Omnibus Chef 插件

现在,如果我们想使用 Chef 代码(Chef 代码是以目录组织的 Ruby 文件,这些文件形成一个名为“cookbook”的单元,用于配置和维护系统的特定区域),我们首先需要在 Vagrant 虚拟机上安装 Chef。实现这一点有很多方法,从配置 Shell 脚本到使用已经安装 Chef 的盒子。一个干净、可靠且可重复的方法是使用 Vagrant 插件来实现——vagrant-omnibus。Omnibus 是一个打包好的 Chef。像其他 Vagrant 插件一样安装它:

$ vagrant plugin install vagrant-omnibus
Installing the 'vagrant-omnibus' plugin. This can take a few minutes...
Installed the plugin 'vagrant-omnibus (1.4.1)'!

然后,只需在 Vagrantfile 的虚拟机定义中添加以下配置,你就能确保始终安装最新版本的 Chef:

config.omnibus.chef_version = :latest

然而,我们的目标是模拟生产环境,也许我们仍然在使用 Chef v11.x 系列的最新版本,而不是最新的 12.x 版本,因此我们将具体指定我们需要的版本:

config.omnibus.chef_version = "11.18.12"

现在,我们使用了一个新插件,Vagrantfile 对每个人可能无法直接工作。用户必须安装 vagrant-omnibus 插件。如果你关心一致性和可重复性,可以选择在 Vagrantfile 的开头添加以下 Ruby 检查:

%w(vagrant-vbguest vagrant-omnibus).each do |plugin|
  unless Vagrant.has_plugin?(plugin)
    raise "#{plugin} plugin is not installed! Please install it using `vagrant plugin install #{plugin}`"
  end
end

这段代码会遍历每个插件名称,验证 Vagrant 是否将它们标记为 已安装。如果没有,就停止并返回一个帮助退出信息,指导如何安装所需的插件。

一个示例的 Chef 配方

本书的这一部分并不是关于编写 Chef 配方(后面章节会详细讲解!),因此我们会简化这一部分。我们的目标是在 CentOS 7 上安装 Apache 2 Web 服务器(httpd 包),并启动它。下面是我们的示例配方(cookbooks/apache2/recipes/default.rb);它做的正是用通俗的语言描述的内容:

package "httpd"

service "httpd" do
  action [ :enable, :start ]
end

Vagrant 与 Chef 集成

在我们的虚拟机定义块中,我们将告诉 Vagrant 使用 Chef Solo(一个无需 Chef 服务器即可单独运行 Chef 的方式)来配置我们的虚拟机:

    config.vm.provision :chef_solo do |chef|
      chef.add_recipe 'apache2'
    end

就这么简单。启动 Vagrant (vagrant up),你将得到一个完全配置好的虚拟机,使用的是旧版本 11.18.12,并且 Apache 2 Web 服务器正在运行。

我们的手动测试可以包括检查 chef-solo 版本是否是我们要求的版本:

$ chef-solo --version
Chef: 11.18.12

它们还可以检查我们是否已经安装了 httpd

$ httpd -v
Server version: Apache/2.4.6 (CentOS)

另外,我们可以检查 httpd 是否正在运行:

$ pidof httpd
13029 13028 13027 13026 13025 13024

注意

除 chef-solo 外,还存在其他各种选项,比如 chef-client 和 chef-zero。

测试 Chef 版本更新

因此,我们在本地模拟了我们的生产环境,使用相同的 CentOS 版本、生产环境中使用的 apache2 cookbook 和旧的 Chef 版本 11。我们的下一个任务是测试在升级到新版本 12 后,一切是否仍然运行顺利。让我们创建第二个“暂存”虚拟机,设置与生产环境非常相似,唯一不同的是我们想安装当前最新的 Chef 版本(截至本文写作时是 12.13.37,随时可以使用 :latest):

  config.vm.define "staging" do |config|
    config.vm.hostname = "staging"
    config.omnibus.chef_version = "12.13.37"
    config.vm.network "private_network", type: "dhcp"
    config.vm.provision :chef_solo do |chef|
      chef.add_recipe 'apache2'
    end
  end

启动这个新机器(vagrant up staging),看看我们的设置在新版本的 Chef 中是否仍然有效:

$ vagrant ssh staging
$ chef-solo --version
Chef: 12.13.37
$ httpd -v
Server version: Apache/2.4.6 (CentOS)
$ pidof httpd
13029 13028 13027 13026 13025 13024

因此,基于我们的测试结果,我们可以安全地假设,最新版本的 Chef 仍然能够与我们的生产 Chef 代码正常工作。

还有更多内容…

这里有更多控制 Vagrant 环境的方法,并可以在其中使用更好的 Chef 工具。

控制默认的 Vagrant 虚拟机

你可能不总是希望启动生产和暂存的 Vagrant 虚拟机,尤其是在你只想处理默认的生产环境设置时。要指定默认的虚拟机:

config.vm.define "prod", primary: true do |config|
  […]
end

要在执行 vagrant up 命令时不自动启动虚拟机:

config.vm.define "staging", autostart: false do |config|
  […]
end

Berkshelf 和 Vagrant

如果你的生产环境使用 Chef,那么你很可能也在使用 Berkshelf 来进行依赖管理,而不是完全使用本地 cookbooks(如果你没有这样做,你应该尝试!)。

Vagrant 在启用了 Berkshelf 的 Chef 环境中工作得很好,使用 vagrant-berkshelf 插件。

注意

你的工作站需要 Chef 开发工具包(Chef DK:downloads.chef.io/chef-dk/)才能正确运行。

使用 Test Kitchen 进行测试

这个设置实际上非常接近用于基础架构代码测试的方式,你会在本书的专门章节中看到许多相似之处。

使用 Ansible 与 Vagrant 创建 Docker 主机

Ansible (www.ansible.com/) 是一个非常简单且强大的开源自动化工具。虽然使用和创建 Ansible playbooks 超出了本书的主题,但我们将使用一个非常简单的 playbook 来安装和配置 CentOS 7 上的 Docker。从这里开始,你将能够逐步编写更复杂的 Ansible playbooks。

准备工作

要执行此配方,你需要以下内容:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的虚拟化管理程序

  • 你机器上的 Ansible 安装(一个简单的方法是 $ pip install ansible,或者使用你习惯的包管理器,如 APT 或 YUM/DNF)

  • 一个互联网连接

如何操作…

因为编写复杂的 Ansible playbook 超出了本书的范围,我们将使用一个非常简单的 playbook,这样你可以稍后深入了解 Ansible,并仍然能够重用这个示例。

一个简单的 Ansible Docker playbook 用于 Vagrant

我们的 playbook 文件(playbook.yml)是一个普通的 YAML 文件,我们将按照以下顺序进行操作:

  1. 安装 EPEL。

  2. 创建一个 Docker Unix 组。

  3. 将默认的 Vagrant 用户添加到新的 Docker 组中。

  4. 从 CentOS 仓库安装 Docker。

  5. 启用并启动 Docker 引擎。

这是 playbook.yml 文件的样子:

---
- hosts: all
 become: yes
 tasks:
 - name: Enable EPEL
 yum: name=epel-release state=present
 - name: Create a Docker group
 group: name=docker state=present
 - name: Add the vagrant user to Docker group
 user: name=vagrant groups=docker append=yes
 - name: Install Docker
 yum: name=docker state=present
 - name: Enable and Start Docker Daemon
 service: name=docker state=started enabled=yes

从 Vagrant 应用 Ansible

为了使用我们的 Ansible playbook,我们从一个简单的 Vagrantfile 开始,它启动一个 CentOS 7 虚拟机:

Vagrant.configure("2") do |config|
  config.vm.box = "bento/centos-7.2"
  config.vm.define "srv-1" do |config|
    config.vm.hostname = "srv-1"
    config.vm.network "private_network", type: "dhcp"
  end
end

只需将 Ansible 配置添加到虚拟机定义中,这样它将加载并应用你的 playbook.yml 文件:

    config.vm.provision "ansible" do |ansible|
      ansible.playbook = "playbook.yml"
    end

你现在可以运行 vagrant up 并立即使用 CentOS 7 Docker 引擎版本:

$ vagrant ssh
[vagrant@srv-1 ~]$ systemctl status docker
[vagrant@srv-1 ~]$ docker --version
Docker version 1.10.3, build d381c64-unsupported
[vagrant@srv-1 ~]$ docker run -it --rm alpine /bin/hostname
0f44a4d7afcd

还有更多内容…

如果由于某些原因你不能在主机上安装 Ansible,或者你需要在 Vagrant box 中安装特定版本的 Ansible 来模拟生产环境,而又不想影响本地的 Ansible 安装,你可以使用一个有趣的 Ansible 提供者变体:它将直接使用来自客机 VM 的 Ansible,如果没有安装,它会从官方仓库或 PIP 安装。你可以使用这个非常简单的默认配置:

    config.vm.provision "ansible_local" do |ansible|
      ansible.playbook = "playbook.yml"
    end

你也可以使用以下命令:

$ vagrant up
[…]
==> srv-1: Running provisioner: ansible_local...
 srv-1: Installing Ansible...
 srv-1: Running ansible-playbook...
[…]

通过 SSH 登录到 box 并检查是否已本地安装最新版本的 Ansible:

$ vagrant ssh
$ ansible --version
ansible 2.1.1.0

如果你的使用场景不同,你可以使用更精确的部署选项,通过 PIP 固定 Ansible 版本号(例如,使用 1.9.6 版本,而不是最新的 2.x 系列):

注意

启动过程会明显变慢,因为它需要在客机系统上安装许多软件包。

    config.vm.provision "ansible_local" do |ansible|
 ansible.version = "1.9.6"
 ansible.install_mode = :pip
      ansible.playbook = "playbook.yml"
    end

你也可以使用以下命令:

$ vagrant up
[…]
==> srv-1: Running provisioner: ansible_local...
 srv-1: Installing Ansible...
 srv-1: Installing pip... (for Ansible installation)
 srv-1: Running ansible-playbook...

在 Vagrant 客机中,你现在可以检查 PIP 和 Ansible 的版本:

$ pip --version
pip 8.1.2 from /usr/lib/python2.7/site-packages (python 2.7)
$ ansible --version
ansible 1.9.6

你也可以检查我们的 playbook 是否在旧版本的 1.x Ansible 上正确安装:

$ docker version

还要检查 Docker 是否已安装,并且现在可以作为 Vagrant 用户正常工作:

$ docker run -it --rm alpine ping -c2 google.com
PING google.com (216.58.211.78): 56 data bytes
64 bytes from 216.58.211.78: seq=0 ttl=61 time=22.078 ms
64 bytes from 216.58.211.78: seq=1 ttl=61 time=21.061 ms

在 CoreOS 上使用 Docker 容器与 Vagrant

Vagrant 可以帮助模拟环境,Docker 容器在 Vagrant 中也得到了支持。我们将使用一个最佳平台来运行容器——免费且开源的轻量级操作系统 CoreOS。它基于 Linux,旨在简化容器和集群的部署,并提供官方的 Vagrant box。我们将使用 Vagrant Docker 提供者(而不是 Vagrant Docker 提供者)部署官方的 WordPress 容器与 MariaDB 容器。

准备工作

为了执行这个步骤,你需要以下内容:

  • 一个正常工作的 Vagrant 安装

  • 一个正常工作的虚拟化管理程序

  • 一个互联网连接

如何操作…

CoreOS 并没有在 Atlas 的默认位置托管官方镜像,而是自行托管。所以,我们必须在 Vagrantfile 中指定 Vagrant box 的完整 URL:

Vagrant.configure("2") do |config|
  config.vm.box = https://stable.release.core-os.net/amd64-usr/current/coreos_production_vagrant.box
end

由于 CoreOS 是一个极简操作系统,它不支持任何 VirtualBox 客户机附加工具,因此我们将禁用它们。如果我们(很可能)安装了 vagrant-vbguest 插件,也不要尝试使用这些工具:

  config.vm.provider :virtualbox do |vb|
      vb.check_guest_additions = false
      vb.functional_vboxsf     = false
  end

  if Vagrant.has_plugin?("vagrant-vbguest") then
    config.vbguest.auto_update = false
  end

让我们创建一个新的虚拟机定义,使用 CoreOS Vagrant box:

  config.vm.define "core-1" do |config|
    config.vm.hostname = "core-1"
    config.vm.network "private_network", type: "dhcp" 
  end

我们现在需要运行 Docker Hub 上的 mariadbwordpress 官方容器。直接使用 Docker,我们将运行以下命令:

$ docker run -d --name mariadb -e MYSQL_ROOT_PASSWORD=h4ckm3 mariadb
$ docker run -d -e WORDPRESS_DB_HOST=mariadb -e 'WORDPRESS_DB_PASSWORD=h4ckm3 --link mariadb:mariadb -p 80:80 wordpress

让我们将其转换到我们的 Vagrantfile 中:

db_root_password = "h4ckm3"
config.vm.provision "docker" do |docker|
 docker.run "mariadb",
 args: "--name 'mariadb' -e 'MYSQL_ROOT_PASSWORD=#{db_root_password}'"
 docker.run "wordpress",
 args: "-e 'WORDPRESS_DB_HOST=mariadb' -e 'WORDPRESS_DB_PASSWORD=#{db_root_password}' --link 'mariadb:mariadb' -p '80:80'"
 end

启动 Vagrant ($ vagrant up),你将访问一个已经安装好并可以使用的 WordPress 环境,运行在 CoreOS 上:

$ curl -IL http://172.28.128.3/wp-admin/install.php
HTTP/1.1 200 OK
Date: Thu, 25 Aug 2016 10:54:17 GMT
Server: Apache/2.4.10 (Debian)
X-Powered-By: PHP/5.6.25
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0
Content-Type: text/html; charset=utf-8

还有更多……

CoreOS 团队提供了一个完整的 Vagrant 环境,用于尝试和操作一个 CoreOS 集群 github.com/coreos/coreos-vagrant。你将能够尝试所有 CoreOS 的功能和配置选项,涵盖所有发布渠道(alpha、beta 或 stable)。

其他操作系统如 Ubuntu 或 CentOS 完全支持为 Docker 容器提供配置,即使基础镜像中一开始没有安装 Docker。Vagrant 会为你安装 Docker,因此它将透明地工作,并在安装完成后立即运行容器。

第二章:使用 Terraform 提供 IaaS

在本章中,我们将覆盖以下教程:

  • 配置 Terraform AWS 提供商

  • 创建和使用一个 SSH 密钥对用于 AWS

  • 使用 Terraform 管理 AWS 安全组

  • 使用 Terraform 创建一个 Ubuntu EC2 实例

  • 使用 Terraform 生成有意义的输出

  • 使用 Terraform 的上下文默认值

  • 使用 Terraform 管理 S3 存储

  • 使用 Terraform 创建私有 Docker 仓库

  • 使用 Terraform 创建一个 PostgreSQL RDS 数据库

  • 使用 Terraform 启用 Docker 的 CloudWatch 日志

  • 使用 Terraform 管理 IAM 用户

介绍

现代基础设施通常使用多个提供商(Amazon Web ServicesAWS)、OpenStack、Google Cloud、Digital Ocean 等),并结合多个外部服务(DNS、邮件、监控等)。许多提供商提出了他们自己的自动化工具,但 Terraform 的优势在于它允许你从一个地方管理所有内容,全部使用代码。使用它,你可以根据环境动态创建两个 IaaS 提供商上的机器,在另一个 DNS 提供商处注册它们的名称,在第三方监控公司启用监控,同时配置公司 GitHub 帐户并将应用日志发送到适当的服务。此外,它还可以将配置委托给那些擅长此事的工具(如 Chef、Puppet 等),一切都能用同一个工具完成。你的基础设施状态被描述、存储、版本化并共享。

在本章中,我们将学习如何使用 Terraform 在 AWS 上引导一个完整的基础设施。你将学习如何从启动精细调优的 EC2 实例和在不同区域动态优化 RDS 数据库,到创建紧密的安全组、部署 SSH 密钥对和保护 IAM 访问密钥、启用 CloudWatch 日志存储、生成有用的输出、处理无限的简单存储服务S3)存储以及使用私有 Docker 仓库。

注意

本书使用的 Terraform 版本是 0.7.2。

配置 Terraform AWS 提供商

我们可以与许多 IaaS 提供商一起使用 Terraform,例如 Google Cloud 或 Digital Ocean。在这里,我们将配置 Terraform 以便与 AWS 一起使用,并在本章的其余部分继续使用该提供商。

为了让 Terraform 与 IaaS 交互,它需要配置一个提供商

准备工作

要执行这个教程,你需要以下内容:

  • 一个具有密钥的 AWS 账户

  • 一个工作中的 Terraform 安装

  • 一个空目录来存储你的基础设施代码

  • 一个互联网连接

如何操作…

为了在 Terraform 中配置 AWS 提供商,我们需要以下三个文件:

  • 一个声明我们变量的文件,一个可选的描述,以及每个变量的可选默认值(variables.tf

  • 一个为整个项目设置变量的文件(terraform.tfvars

  • 一个提供商文件(provider.tf

让我们在 variables.tf 文件中声明我们的变量。我们可以从声明通常称为 AWS_DEFAULT_REGIONAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY 的环境变量开始:

variable "aws_access_key" {
  description = "AWS Access Key"
}

variable "aws_secret_key" {
  description = "AWS Secret Key"
}

variable "aws_region" {
  default     = "eu-west-1"
  description = "AWS Region"
}

terraform.tfvars 文件中设置与 AWS 账户匹配的两个变量。不建议将此文件提交到源代码管理中:最好使用示例文件(即:terraform.tfvars.example)。同时,建议为 AWS 使用专门的 Terraform 用户,而不是 root 账户的密钥:

aws_access_key = "< your AWS_ACCESS_KEY >"
aws_secret_key = "< your AWS_SECRET_KEY >"

现在,让我们把所有这些整合到一个文件中,即 provider.tf

provider "aws" {
  access_key = "${var.aws_access_key}"
  secret_key = "${var.aws_secret_key}"
  region     = "${var.aws_region}"
}

应用以下 Terraform 代码:

$ terraform apply

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

这仅意味着代码有效,而不意味着它能真正与 AWS 进行身份验证(尝试使用一对无效的密钥)。为此,我们需要在 AWS 上创建一个资源。

注意

现在你有了一个名为 terraform.tfstate 的新文件,它已经在你的仓库根目录下创建。这个文件非常关键:它是你基础设施的存储状态。不要犹豫去查看它,它是一个文本文件。

它是如何工作的…

这是第一次接触HashiCorp 配置语言HCL),这是 Terraform 以及其他 HashiCorp 产品使用的语言,看起来相当熟悉:它是与 JSON 完全兼容的结构化语言。我们可以在这里找到关于 HCL 的更多信息:github.com/hashicorp/hcl。在这个例子中,我们声明了变量,并为参考提供了可选的描述。我们也可以简单地用以下方式声明它们:

variable "aws_access_key" { }

所有变量都参考以下结构:

${var.variable_name}

如果变量已经声明了默认值,就像我们的 aws_region 声明了 eu-west-1 的默认值;如果在 terraform.tfvars 文件中没有覆盖该值,它将会使用这个默认值。

如果我们没有为变量提供安全的默认值,会发生什么情况? Terraform 在执行时会要求我们输入一个值:

$ terraform apply
var.aws_region
 AWS Region

 Enter a value:

还有更多内容…

我们直接在 Terraform 代码中使用了值来配置我们的 AWS 凭证。如果你已经在命令行中使用 AWS,很可能你已经有一组标准的环境变量:

$ echo ${AWS_ACCESS_KEY_ID}
<your AWS_ACCESS_KEY_ID>
$ echo ${AWS_SECRET_ACCESS_KEY}
<your AWS_SECRET_ACCESS_KEY>
$ echo ${AWS_DEFAULT_REGION}
eu-west-1

如果没有,你可以按以下方式简单设置它们:

$ export AWS_ACCESS_KEY_ID="123"
$ export AWS_SECRET_ACCESS_KEY="456"
$ export AWS_DEFAULT_REGION="eu-west-1"

然后 Terraform 可以直接使用它们,你唯一需要输入的代码就是声明你的提供者!当使用不同工具时,这非常方便。

provider.tf 文件看起来就会像这样简单:

provider "aws" { }

创建并使用一对 SSH 密钥以在 AWS 上使用

现在我们在 Terraform 中配置了 AWS 提供者,接下来让我们为即将启动的虚拟机的默认账户添加一对 SSH 密钥。

准备就绪

要跟随这个步骤,你将需要以下内容:

  • 一个可工作的 Terraform 安装

  • 已在 Terraform 中配置的 AWS 提供者

  • 在你能记住的地方生成一对 SSH 密钥,例如,在你的仓库根目录下的 keys 文件夹中:

    $ mkdir keys
    $ ssh-keygen -q -f keys/aws_terraform -C aws_terraform_ssh_key -N ''
    
    
  • 需要一个互联网连接

如何操作…

我们想要的资源名为 aws_key_pair。让我们在 keys.tf 文件中使用它,并粘贴公钥内容:

resource "aws_key_pair" "admin_key" {
  key_name   = "admin_key"
  public_key = "ssh-rsa AAAAB3[…]"
}

这将简单地将你的公钥上传到你的 AWS 账户,名为 admin_key

$ terraform apply
aws_key_pair.admin_key: Creating...
 fingerprint: "" => "<computed>"
 key_name:    "" => "admin_key"
 public_key:  "" => "ssh-rsa AAAAB3[…]"
aws_key_pair.admin_key: Creation complete

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

如果你手动访问你的 AWS 账户,在 EC2 | 网络与安全 | 密钥对 中,你现在应该能找到你的密钥:

如何操作……

另一种使用我们的密钥与 Terraform 和 AWS 的方法是直接从文件中读取,这将展示如何在 Terraform 中使用文件插值。

为了做到这一点,让我们在 variables.tf 中声明一个新的空变量来存储我们的公钥:

variable "aws_ssh_admin_key_file" { }

terraform.tfvars 中初始化变量,指定密钥的路径:

aws_ssh_admin_key_file = "keys/aws_terraform"

现在让我们用 file() 插值替换之前的 keys.tf 代码:

resource "aws_key_pair" "admin_key" {
  key_name   = "admin_key"
  public_key = "${file("${var.aws_ssh_admin_key_file}.pub")}"
}

这是一种更清晰、更简洁的方式,来访问 Terraform 资源中的公钥内容。它也更易于维护,因为更换密钥时只需替换文件,无需其他操作。

如何运作……

我们的第一个资源 aws_key_pair 接受两个参数(一个密钥名称和公钥内容)。这就是 Terraform 中所有资源的工作方式。

我们使用了第一个 文件 插值,使用变量,展示了如何使用更动态的代码来管理我们的基础设施。

还有更多……

使用 Ansible,我们可以创建一个 角色 来做同样的工作。下面是如何使用变量来管理 EC2 密钥对,名称为 admin_key。为简化,我们使用了三个常见的环境变量——AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY,和 AWS_DEFAULT_REGION

这里是一个典型的 Ansible 文件层次结构:

├── keys
│   ├── aws_terraform
│   └── aws_terraform.pub
├── main.yml
└── roles
 └── ec2_keys
 └── tasks
 └── main.yml

在主文件(main.yml)中,让我们声明我们的主机(localhost)将应用用于管理密钥的角色:

---
- hosts: localhost
  roles:
  - ec2_keys

ec2_keys 主任务文件中,创建 EC2 密钥(roles/ec2_keys/tasks/main.yml):

---
  - name: ec2 admin key
    ec2_key:
      name: admin_key
      key_material: "{{ item }}"
    with_file: './keys/aws_terraform.pub'

使用以下命令执行代码:

$ ansible-playbook -i localhost main.yml
TASK [ec2_keys : ec2 admin key] ************************************************
ok: [localhost] => (item=ssh-rsa AAAA[…] aws_terraform_ssh)

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0

在 Terraform 中使用 AWS 安全组

亚马逊的安全组类似于传统防火墙,适用于 EC2 实例的入站(传入流量)和出站(传出流量)规则。这些规则可以按需更新。我们将创建一个初始的安全组,只允许从我们自己的 IP 地址访问 安全外壳协议SSH)流量,同时允许所有的出站流量。

正在准备中

为了执行这个配方,你需要以下资源:

  • 一个正常工作的 Terraform 安装

  • 在 Terraform 中配置的 AWS 提供者(请参阅之前的配方)

  • 一个互联网连接

如何操作……

我们使用的资源叫做 aws_security_group。这是基本的结构:

resource "aws_security_group" "base_security_group" {
  name        = "base_security_group"
  description = "Base Security Group"

  ingress { }

  egress { }

}

我们知道我们只想允许从我们自己的 IP 地址访问 TCP/22 端口(将 1.2.3.4/32 替换为你的 IP 地址!),并允许所有出站流量。它看起来是这样的:

ingress {
  from_port   = 22
  to_port     = 22
  protocol    = "tcp"
  cidr_blocks = ["1.2.3.4/32"]
 }

egress {
  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

你可以添加一个名称标签,以便以后更容易参考:

tags {
  Name = "base_security_group"
}

应用这个,你就可以开始了:

$ terraform apply
aws_security_group.base_security_group: Creating...
[…]
aws_security_group.base_security_group: Creation complete

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

你可以通过登录到 AWS 控制台并导航到 EC2 仪表盘 | 网络与安全 | 安全组 来查看你新创建的安全组:

如何操作……

另一种访问相同 AWS 控制台信息的方法是通过 AWS 命令行:

$ aws ec2 describe-security-groups --group-names base_security_group
{...}

还有更多……

我们也可以使用 Ansible 来实现相同的结果。以下是使用 Terraform 在本配方中所做操作的等效 Ansible 实现:

---
  - name: base security group
    ec2_group:
      name: base_security_group
      description: Base Security Group
      rules:
        - proto: tcp
          from_port: 22
          to_port: 22
          cidr_ip: 1.2.3.4/32

使用 Terraform 创建一个 Ubuntu EC2 实例

我们之前已经创建了在 AWS EC2 上启动标准虚拟机的必要条件(一个 SSH 密钥对和一个安全组)。现在,让我们在 EC2 上启动这个虚拟机,使用指定的 SSH 密钥对登录并将其放入安全组中,这样(在我们的案例中)SSH 只会从特定的 IP 地址可用。

注意

本示例使用的是 AWS 免费套餐中提供的 t2.micro 实例。

准备工作

要按步骤操作此方法,你需要以下内容:

  • 一个正常工作的 Terraform 安装

  • 一个 AWS 提供程序,一个 SSH 密钥对,以及在 Terraform 中配置的安全组(参见前面的配方)

  • 一个互联网连接

如何操作…

首先,你需要找到适合你机器的 AMI。AMI 就像是 AWS 的系统磁盘映像,并通过其 ID 来引用(例如:ami-df3bceb0 或 ami-f2fc9d81)。以 Ubuntu 为例,你可以通过访问其 Amazon EC2 AMI 定位器页面(cloud-images.ubuntu.com/locator/ec2/)来找到你需要的 AMI。在本例中,我选择了一个 Xenial 版本(16.04 LTS),位于欧盟西部(爱尔兰)的 eu-west-1 区域,使用 HVM 虚拟化,并且由 SSD 磁盘支持。这样我们得到了一个结果——ami-ee6b189d

如何操作…

variables.tf 文件中声明此变量开始,该文件在第一个配方中已经创建,使用一个与我们之前找到的 AMI ID 对应的默认值:

variable "ami" {
  default = "ami-ee6b189d"
}

现在让我们声明实例类型,并将其指定为默认值:

variable "aws_instance_type" {
  default = "t2.micro"
}

让我们使用这些变量来创建 Terraform aws_instance 资源。局部声明的变量可以通过 ${var.variable_name} 结构来访问,而内部资源属性则通过 ${resource_type.resource_name.attribute} 结构来访问:

resource "aws_instance" "dev" {
  ami                         = "${var.ami}"
  instance_type               = "${var.aws_instance_type}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  security_groups             = ["${aws_security_group.base_security_group.name}"]
  associate_public_ip_address = true

  tags {
    Name = "Ubuntu launched by Terraform"
  }
}

应用以下代码:

$ terraform apply
aws_key_pair.admin_key: Creating...
[…]
aws_security_group.base_security_group: Creating...
[…]
aws_instance.dev: Creating...
[…]

导航到 AWS EC2 仪表盘下的 实例 | 实例,选择你的实例并记录下公共 IP:

如何操作…

尝试登录:

$ ssh -i keys/aws_terraform ubuntu@52.210.12.27
Welcome to Ubuntu 16.04.1 LTS (GNU/Linux 4.4.0-36-generic x86_64)
ubuntu@ip-172-31-18-156:~$

你可以通过刷新其状态来应用 Terraform,因为 Terraform 知道远程和本地状态是相同的,因此它不会每次都重新创建新的虚拟机。

你已经成功使用可重复的 Terraform 代码启动了第一个 AWS EC2 实例!

扩展实例数量

如果你想启动两个相似的实例,可能是为了调试,或者是为了在负载均衡器后面进行即时操作,应该怎么做?使用 Terraform 很容易,只需在 aws_instance 资源中使用 count 选项,它会启动所需数量的实例:

count = 2

接下来,terraform apply 这个配置并观察 Terraform 根据计数器自动创建一台新机器:

$ terraform apply
aws_key_pair.admin_key: Refreshing state... (ID: admin_key)
aws_security_group.base_security_group: Refreshing state... (ID: sg-d3dbd8b4)
aws_instance.dev.0: Refreshing state... (ID: i-0018b1044953371ae)
aws_instance.dev.1: Creating...
[...]
aws_instance.dev.1: Creation complete

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

第二台服务器出现在 AWS 控制台中:

扩展实例数量

注意

记住,销毁 Terraform 基础设施的命令是 terraform destroy

还有更多……

我们可以使用 Ansible 达到类似的效果。以下是使用在前面教程中创建的 admin_keybase_security_group 的示例:

---
  - name: dev instance
    ec2:
      key_name: admin_key
      group: base_security_group
      instance_type: t2.micro
      image: ami-ee6b189d
      wait: yes

使用 Terraform 生成有意义的输出

如果 Terraform 在成功运行后能够显示一些有用的信息输出,那该多好?根据我们到目前为止所做的内容,了解如何连接到实例、查看本地和公网 IP 地址,或查看使用的安全组将是非常有帮助的。这正是 Terraform 输出的用途。

准备工作

要完成这个教程,你需要以下内容:

  • 一个有效的 Terraform 安装

  • 一个 AWS 提供者和一个 EC2 实例(使用 SSH 密钥对和安全组),都在 Terraform 中配置(参见前面的教程)

  • 一个互联网连接

如何实现……

幸运的是,我们可以使用我们已经使用的相同语法来访问变量和引用的属性,这次是在 output 资源中。

让我们通过在 outputs.tf 中简单地添加一行,来显示如何连接到我们的虚拟机,使用我们 dev EC2 实例的 public_ip 属性:

output "login" {
  value = "ssh ubuntu@${aws_instance.dev.public_ip} -i ${var.aws_ssh_admin_key_file}"
}

下次应用 Terraform 时,它将显示以下内容:

login = ssh ubuntu@52.51.242.17 -i keys/aws_terraform

毋庸置疑,这比登录 AWS 控制台,找到实例并复制粘贴 IP 到终端要快速得多。

如果我们想快速了解我们的 EC2 实例运行在哪些安全组下,怎么办?我们知道安全组可以有多个,所以它是一个数组。我们可以使用 formatlist 插值语法来访问这个数组的内容,方法如下:

output "security_groups" {
  value = "${formatlist("%v", aws_instance.dev.security_groups)}"
}

所以现在,在下次执行 terraform apply 时,我们会立即知道我们的安全组:

security_groups = [
    base_security_group
]

此外,如果我们有很多来自多个来源的信息需要显示,我们也可以使用相同的语法:

output "instance_information" {
  value = "${formatlist("instance: %v public: %v private: %v", aws_instance.dev.*.id, aws_instance.dev.*.public_ip, aws_instance.dev.*.private_ip)}"
}

这将显示实例 ID 及其本地和公网 IP 地址。

还有更多……

注意,我们在第一次输出中使用了${aws_instance.dev.public_ip},而在最后一次输出中使用了aws_instance.dev.*.public_ip。如果使用后者,输出将遍历所有可用的机器。如果你使用 aws_instance Terraform 资源的 count=n 参数启动多个实例,这将非常有用。

使用 Terraform 中的上下文默认值

我们已经看到如何在 Terraform 代码中声明和使用默认值,比如为我们区域选择的 Ubuntu AMI 或我们的虚拟机大小。Terraform 中一个有趣的功能是能够声明和使用映射值,因此,根据键的不同,变量可以有不同的值。我们将看到如何将它应用到对应 AWS 的正确 AMI 上。

准备工作

要完成这个教程,你需要以下内容:

  • 一个有效的 Terraform 安装

  • 一个 AWS 提供者和一个 EC2 实例(使用 SSH 密钥对和安全组),都在 Terraform 中配置(参见前面的教程)

  • 一个互联网连接

如何实现……

下面是我们在variables.tf文件中为eu-west-1区域声明我们希望使用的 AMI:

variable "ami" {
  default = "ami-ee6b189d"
}

我们可以像这样在instances.tf文件中轻松访问它:

ami = "${var.ami}"

一种相似但更加明确的方法是使用映射,这样我们就能知道值所指向的具体区域:

variable "ami" {
  default = {
    eu-west-1 = "ami-ee6b189d"
  }
}

下面是我们如何在映射中访问相同的值:

ami = "${var.ami["eu-west-1"]}"

现在让我们为其他区域添加更多有效的 AMI ID:

variable "ami" {
  default = {
    eu-west-1 = "ami-ee6b189d"
    us-east-1 = "ami-4f680658"
    us-west-1 = "ami-68a9e408"
  }
}

如果在instances.tf文件中正确访问,ami变量现在可以适用于这三个区域中的任何一个:

ami = "${var.ami["us-east-1"]}"

现在是时候直接在代码中管理 AWS 区域,以提高可移植性了。将以下内容添加到variables.tf中,以将eu-west-1作为默认区域:

variable "aws_region" {
  default = "eu-west-1"
}

你现在可以在provider.tf文件中使用此变量来设置区域:

provider "aws" {
  region = "${var.aws_region}"
}

现在区域变量在全局范围内可用,让我们在instances.tf中使用它来访问我们的映射:

ami = "${var.ami["${var.aws_region}"]}"

现在我们有了一个易于地理部署的基础设施,团队中的任何人都可以在不修改代码的情况下,将其部署到离他们最近的位置。

还有更多…

我们可以使用 Terraform 中的lookup()函数对映射进行动态访问:

ami = "${lookup(var.ami, var.aws_region)}"

使用 Terraform 管理 S3 存储

轻松且可扩展地存储和访问文件是现代基础设施的一个重要部分。Amazon S3 是 Amazon 对此需求的回应。S3 将“对象”存储在“桶”中,并且没有存储限制(唯一的例外是桶名称:它必须在 Amazon S3 上唯一,因为命名空间是共享的)。我们将看看如何利用 Terraform 充分使用 S3。

准备工作

要执行此步骤,你需要以下内容:

  • 一个正常工作的 Terraform 安装

  • 一个在 Terraform 中配置的 AWS 提供商(请参考之前的配方)

  • 一个互联网连接

如何操作…

我们将从在 S3 上创建一个简单且明确公开的桶 iac-book 开始,使用 aws_s3_bucket 资源(并附带一个标签以示说明):

resource "aws_s3_bucket" "iac_book" {
  bucket = "iac-book"
  acl    = "public-read"

  tags {
    Name = "IAC Book Bucket in ${var.aws_region}"
  }
}

在执行terraform apply之后,你的桶立即可用于存储对象。你可以在 AWS S3 控制台上看到它(console.aws.amazon.com/s3/):

如何操作…

现在让我们存储第一个对象,一个非常简单的文件,包含一个简单的字符串("Hello Infrastructure-as-Code Cookbook!")。该资源名为 aws_s3_bucket_object,你需要引用之前创建的桶、目标名称(index.html)及其内容。ACL 在这里再次明确为公开:

resource "aws_s3_bucket_object" "index" {
  bucket = "${aws_s3_bucket.iac_book.bucket}"
  key = "index.html"
  content = "<h1>Hello Infrastructure-as-Code Cookbook!</h1>"
  content_type = "text/html"
  acl    = "public-read"
}

你也可以直接提供一个文件,而不是其内容:

source = "index.html"

如果你访问 AWS S3 控制台,你可以看到它有一些扩展的信息:

如何操作…

如果我们能直接从 Terraform 获取到文件的 URL 并提供给其他人,那就太棒了。不幸的是,目前没有简单的函数来做到这一点。然而,我们知道 URL 是如何构建的:http://s3-<region>.amazonaws.com/bucket_name/object_name。让我们创建一个输出,包含这些信息:

output "S3" {
  value = "http://s3-${aws_s3_bucket.iac_book.region}.amazonaws.com/${aws_s3_bucket.iac _book.id}/${aws_s3_bucket_object.index.key}"
}

将链接粘贴到网页浏览器中,你将能够访问你的文件。

一种解决方法是通过简单地在你的 aws_s3_bucket 资源中添加以下内容,使用 S3 的 静态网站托管 功能:

website {
  index_document = "index.html"
}

一个可选输出将为你提供其静态托管 URL(在我们的案例中,是 iac-book.s3-website-eu-west-1.amazonaws.com 而不是 s3-eu-west-1.amazonaws.com/iac-book/index.html):

output "S3 Endpoint" {
  value = "${aws_s3_bucket.iac_book.website_endpoint}"
}

还有更多……

使用 Ansible,有很多方法可以创建一个存储桶。以下是一个简单的存储桶,具有公共读取权限,使用经典的s3模块:

---
- name: create iac-book bucket
  s3:
    bucket: iac-book
    mode: create
    permission: public-read

注意

请注意,Ansible 2.2 还带有一个s3_website模块,专门用于处理 S3 网站。

下面是我们如何使用相同的 s3 模块简单上传我们之前的 index.html 文件:

- name: create index.html file
  s3:
    bucket: iac-book
    object: index.html
    src: index.html
    mode: put
    permission: public-read

使用 Terraform 创建私有 Docker 仓库

要托管 Docker 镜像,你需要一个被称为 注册表 的东西。这个注册表可以是你自己运行的,也可以是作为服务提供的。它为你存储镜像,有时也会构建它们。Docker Hub 和 CoreOS 的 Quay.io 是你可以订阅的主要 Docker 管理注册表。它们在功能或定价上都很有吸引力。然而,一个有趣的替代方案是 AWS 弹性容器注册表 (ECR):定价不同,并且完全集成在 AWS 生态系统中。让我们通过 Terraform 简单地创建无数个仓库!

做好准备

要执行此操作,你将需要以下内容:

如何操作……

假设你想将你的应用容器存储在一个名为myapp的仓库中,以便轻松部署。使用 Terraform 非常简单。将以下代码添加到一个名为ecr.tf的文件中:

resource "aws_ecr_repository" "myapp" {
  name = "myapp"
}

如果你想知道访问新仓库的 URL,可以使用相应的导出属性创建输出:

output "ECR" {
  value = "${aws_ecr_repository.myapp.repository_url}"
}

如果你习惯了其他 Docker 注册表,第一步是进行身份验证,以便创建私有仓库。这里,AWS 不提供登录或密码。我们需要使用官方的 AWS 命令行进行身份验证,这将为我们提供临时的 Docker 凭证。此命令的输出是需要输入的 Docker 命令:

$ aws ecr get-login --region eu-west-1
docker login -u AWS -p AQECAHh... -e none https://<account_number>.dkr.ecr.eu-west-1.amazonaws.com

现在我们可以随意进行 docker buildtagpush 镜像!(关于使用 Docker 镜像的更多内容,请参阅本书的专门章节。)

一个很好的高级功能是能够为每个创建的仓库使用精细的策略。

使用 Terraform 创建 PostgreSQL RDS 数据库

Amazon 关系型数据库服务 (RDS) 是一种按需、可随时使用并可调整大小的 EC2 实例,专门定制和配置来运行所请求的数据库服务器。你可以在 RDS 上启动多种不同的关系型数据库服务器,我们在这个例子中将重点使用 PostgreSQL。

准备就绪

要完成此教程,你需要以下内容:

  • 一个有效的 Terraform 安装

  • 在 Terraform 中配置一个 AWS 提供程序(参考之前的教程)

  • 一个互联网连接

如何操作…

在数据库部署中有许多参数需要考虑,即使是一个简单的部署。为了确保我们将部署的内容,我们将从填写一个简单的表格开始,列出数据库需求,并在此基础上进行构建:

参数 变量名
RDS 数据库引擎 rds_engine postgresql
RDS 数据库引擎版本 rds_engine_version 9.5.2
RDS 实例名称 rds_identifier db
RDS 实例类型 rds_instance_type db.t2.micro
RDS 存储大小(GB) rds_storage_size 5
RDS 第一个数据库名称 rds_db_name iac_book_db
RDS 管理员用户名 rds_admin_user dbadmin
RDS 管理员密码 rds_admin_password super_secret_password
RDS 是否公开可访问 rds_publicly_accessible true

让我们在 variables.tf 文件中设置所有这些变量:

variable "rds_identifier" {
  default = "db"
}

variable "rds_instance_type" {
  default = "db.t2.micro"
}
variable "rds_storage_size" {
  default = "5"
}

variable "rds_engine" {
  default = "postgres"
}

variable "rds_engine_version" {
  default = "9.5.2"
}

variable "rds_db_name" {
  default = "iac_book_db"
}

variable "rds_admin_user" {
  default = "dbadmin"
}

variable "rds_admin_password" {
  default = "super_secret_password"
}

variable "rds_publicly_accessible" {
  default = "true"
}

由于我们运行的是 PostgreSQL 并且希望它能够通过互联网访问(虽然一般不推荐用于生产环境),我们需要一个安全组,允许仅允许我们的 IP 地址通过默认的 PgSQL 端口(TCP/5432)访问(参考 使用 AWS 安全组与 Terraform 教程),在 securitygroups.tf 文件中:

resource "aws_security_group" "rds_security_group" {
  name        = "rds_security_group"
  description = "RDS Security Group"

  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["1.2.3.4/32"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags {
    Name = "rds_security_group"
  }
}

现在我们已经具备了构建 aws_db_instance 资源所需的一切:

resource "aws_db_instance" "db" {
  engine            = "${var.rds_engine}"
  engine_version    = "${var.rds_engine_version}"
  identifier        = "${var.rds_identifier}"
  instance_class    = "${var.rds_instance_type}"
  allocated_storage = "${var.rds_storage_size}"
  name              = "${var.rds_db_name}"
  username          = "${var.rds_admin_user}"
  password          = "${var.rds_admin_password}"
  publicly_accessible    = "${var.rds_publicly_accessible}"
  vpc_security_group_ids = ["${aws_security_group.rds_security_group.id}"]
  tags {
    Name = "IAC Database in ${var.aws_region}"
  }
}

正如我们之前所做的,快速输出我们的新数据库的 FQDN 将帮助我们快速使用它,输出内容放在 outputs.tf 中:

output "RDS" {
  value = "address: ${aws_db_instance.db.address}"
}

现在让我们运行 terraform apply 并查看结果:

# psql -h <your_db_address> -d iac_book_db -U dbadmin
Password for user dbadmin:
psql (9.5.4, server 9.5.2)
[...]

iac_book_db=> \l
 List of databases
 Name     |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges
-------------+----------+----------+-------------+-------------+-----------------------
 iac_book_db | dbadmin  | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 postgres    | dbadmin  | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 rdsadmin    | rdsadmin | UTF8     | en_US.UTF-8 | en_US.UTF-8 | rdsadmin=CTc/rdsadmin
 template0   | rdsadmin | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/rdsadmin          +
 |          |          |             |             | rdsadmin=CTc/rdsadmin
 template1   | dbadmin  | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/dbadmin           +
 |          |          |             |             | dbadmin=CTc/dbadmin
(5 rows)

还有更多你可以使用或设置的有用选项,如维护窗口、备份保留期限、专用数据库子网、存储加密和主从配置等。

还有更多…

当使用 Ansible 做类似的工作,且值相同的时候,这该如何运作?和往常一样简单:

---
- name: create RDS PgSQL
  rds:
    command: create
    instance_name: db
    db_engine: postgres
    engine_version: 9.5.2
    db_name: iac_book_db
    size: 5
    instance_type: db.t2.micro
    username: dbadmin
    password: super_secure_password
    publicly_accessible: yes
    tags:
      Name: IAC Database

执行完这个 playbook 后,类似的 PostgreSQL 服务器将运行在 RDS 上,就像我们之前使用 Terraform 所做的那样。

使用 Terraform 为 Docker 启用 CloudWatch 日志

CloudWatch Logs 是 Amazon 提供的一项日志聚合服务,你可以使用它将日志发送到 CloudWatch。它非常有用,可以将一些日志集中存储,分享访问权限,收到错误发生时的警报,或者仅仅是安全地存储它们。我们将学习如何创建 CloudWatch 日志组,并使用它来流式传输来自 Docker 容器中的日志。

准备就绪

要完成此教程,你需要以下内容:

  • 一个有效的 Terraform 安装

  • 在 Terraform 中配置一个 AWS 提供程序(参考之前的教程)

  • 一个互联网连接

  • 一个在 Linux 上运行的 Docker 引擎,用于可选的使用演示

如何操作...

假设我们希望日志组命名为 docker_logs,并且希望将这些日志保存七天。在 variables.tf 文件中,这看起来是这样的:

variable "log_group_name" {
  default = "docker_logs"
}

variable "log_retention_days" {
  default = "7"
}

同样,在一个新的 cloudwatch.tf 文件中,我们可以使用简单的 aws_cloudwatch_log_group 资源:

resource "aws_cloudwatch_log_group" "docker_logs" {
  name              = "${var.log_group_name}"
  retention_in_days = "${var.log_retention_days}"
}

terraform apply 后,如果你进入 AWS CloudWatch 页面,你会在左侧的 日志组 项下看到新创建的组(eu-west-1.console.aws.amazon.com/cloudwatch/)。

如何操作...

Amazon CloudWatch Logs Docker 日志驱动程序

现在,你可以使用此组从应用程序或容器创建日志流。按照 AWS 推荐的方式使用它是有详细文档说明的,所以我们来改用 Docker。只需要给 Docker 守护进程访问 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY 环境变量的权限(配置 Docker 守护进程超出了本章的范围,但对于基于 Red Hat 的系统,如 Fedora 或 CentOS,它位于 /etc/sysconfig/docker,对于 Debian/Ubuntu 系统,它位于 /etc/default/docker)。重新启动守护进程并开始使用新的 Docker 日志驱动程序记录容器输出,使用 Terraform 中之前指定的日志组名称(docker_logs):

$ docker run -it --rm -p 80:80 --log-driver=awslogs --log-opt awslogs-region=eu-west-1 --log-opt awslogs-group=docker_logs --log-opt awslogs-stream=nginx nginx:stable

在容器上生成一些活动:

$ curl -IL http://localhost
HTTP/1.1 200 OK

刷新 AWS CloudWatch 页面,你会看到一个名为 nginx 的新条目,里面有容器日志。你可以像这样在你的基础设施中运行所有容器,并轻松获得集中式日志记录!

Amazon CloudWatch Logs Docker 日志驱动程序

使用 Terraform 管理 IAM 用户

使用 AWS 的一个关键部分是控制对资源的访问。我们在之前的所有示例中看到,我们经常需要使用 AWS 访问密钥,显然,使用单个密钥进行所有操作并不是一个好主意。试想,如果你其中一个服务被黑客入侵——入侵者将获得主 AWS 密钥,并且可以代表你执行任何操作。

一个好的安全设置是为团队中的每个人和基础设施中的每个服务分配专用密钥和专用访问权限范围。

幸运的是,身份与访问管理IAM)正是为此而存在。我们将看到如何使用它与 Terraform 配合。

准备就绪

要执行此示例,你需要以下内容:

  • 一个可用的 Terraform 安装

  • 在 Terraform 中配置的 AWS 提供程序(参考之前的示例)

  • 一个互联网连接

如何操作...

让我们从一个简单的案例开始:一个团队的两名成员(Mary 和 Joe)需要访问 AWS 上的资源。他们目前共享相同的主密钥,一旦发生泄漏,将是灾难性的。所以让我们问问他们到底需要在 AWS 空间中访问哪些内容:

Mary S3 读写权限
Joe EC2 只读

正如预期的那样,两个用户实际上都不需要完全访问权限!

亚马逊通过提供预构建的 IAM 安全策略来帮助我们。如果这些不够,你还可以定制你需要的策略:

How to do it…

注意

你可以在console.aws.amazon.com/iam/home#policies找到所有 AWS 托管的 IAM 策略。

用于 S3 访问的 IAM 用户

让我们在新的iam.tf文件中为 Mary 创建第一个 IAM 用户,使用aws_iam_user资源:

resource "aws_iam_user" "mary" {
  name = "mary"
  path = "/team/"
}

path是纯粹可选的,仅供参考,我只是建议使用结构化的路径。所以以后我们还会有/apps/路径。

现在,我们可以为 Mary 的用户创建一个 AWS 访问密钥,使用aws_iam_access_key资源并引用我们的用户:

resource "aws_iam_access_key" "mary" {
  user = "${aws_iam_user.mary.name}"
}

最后,正如我们所知,我们希望将AmazonS3FullAccess托管策略附加到这个用户上,我们使用专用资源:

resource "aws_iam_user_policy_attachment" "mary_s3full" {
  user = "${aws_iam_user.mary.name}"
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

让我们在outputs.tf中写一个output,这样我们就知道密钥的两部分:

output "mary" {
  value = "ACCESS_KEY: ${aws_iam_access_key.mary.id}, SECRET: ${aws_iam_access_key.mary.secret}"
}

此外,terraform apply此操作以创建mary用户:

[...]
Outputs:
mary = ACCESS_KEY: AKIAJPQB7HBK2KLAARRQ, SECRET: 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

测试限制

现在,terraform apply这个,然后通过 S3 浏览器确认你可以访问 S3!下面是使用s3cmd创建简单 S3 桶的示例:

$ s3cmd --access_key=<mary_access_key> --secret_key=<mary_secret_key> mb s3://iacbook-iam-bucket
Bucket 's3://iacbook-iam-bucket/' created

这个账户真的仅限于 S3 吗?就像它所声称的那样吗?让我们尝试使用 Mary 的账户通过aws命令行列出 EC2 主机(前提是你已按要求配置了aws工具):

$ aws --profile iacbook-mary ec2 describe-hosts
An error occurred (UnauthorizedOperation) when calling the DescribeHosts operation: You are not authorized to perform this operation.

所以一切看起来都很好且安全!Mary 可以安全地在 S3 上完成她的工作。

用于 EC2 只读访问的 IAM 用户

有没有类似的托管策略可以为 Joe 提供只读 EC2 访问权限?幸运的是,有!它被创意地命名为AmazonEC2ReadOnlyAccess

让我们创建第二个用户,使用iam.tf文件中的这个 IAM 策略:

resource "aws_iam_user" "joe" {
  name = "joe"
  path = "/team/"
}

resource "aws_iam_access_key" "joe" {
  user = "${aws_iam_user.joe.name}"
}

resource "aws_iam_user_policy_attachment" "joe_ec2ro" {
  user = "${aws_iam_user.joe.name}"
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
}

别忘了附带的有用输出:

output "joe" {
  value = "ACCESS_KEY: ${aws_iam_access_key.joe.id}, SECRET: ${aws_iam_access_key.joe.secret}"
}

接下来,再次terraform apply这个,那么 Joe 用户能看到 S3 上的内容吗?不,他不能:

$ s3cmd --access_key=<joe_access_key> --secret_key=<joe_secret_key> ls
ERROR: S3 error: 403 (AccessDenied): Access Denied

但是,Joe 用户能否像他需要的那样列出 EC2 虚拟机,使用 Mary 被禁止使用的同一个命令?是的,他可以:

$ aws --profile iacbook-joe ec2 describe-hosts
{
  "Hosts": []
}

我们正在通过代码安全地管理我们的基础设施访问!

应用程序用户 IAM – CloudWatch Logs

我们在之前的配方中使用了 CloudWatch 日志服务。如果你还记得,你需要在 Docker 引擎配置中再次输入你的密钥。如果你有 100 台服务器,你的主密钥将会出现在每一台服务器上。如果考虑到 Docker 中这个配置的范围只是发送日志,这就显得有些不必要了。幸运的是,有一个名为CloudWatchLogsFullAccess的托管 IAM 策略可以解决这个问题。

所以我们再创建一个用户,和之前为 Mary 和 Joe 创建的完全一样,只不过这个是为我们的 Docker 引擎创建的,而不是为一个真实用户创建的,位于iam.tf中。我建议使用不同的路径,只是为了区分真实用户和应用程序用户。然而,这完全是可选的,取决于个人偏好:

resource "aws_iam_user" "logs" {
  name = "logs"
  path = "/apps/"
}

resource "aws_iam_access_key" "logs" {
  user = "${aws_iam_user.logs.name}"
}

resource "aws_iam_user_policy_attachment" "logs_cloudwatch_full" {
  user = "${aws_iam_user.logs.name}"
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
}

outputs.tf中相关的output如下:

output "logs" {
  value = "ACCESS_KEY: ${aws_iam_access_key.logs.id}, SECRET: ${aws_iam_access_key.logs.secret}"
}

现在,使用terraform apply并再次尝试使用这些凭据而不是主密钥来执行使用 Terraform 启用 CloudWatch 日志记录配方:它仍然可以在 CloudWatch 范围内正常工作,但如果出现问题,它将永远不会让你的其余基础设施处于危险之中。在这一领域,最糟糕的情况就是日志的完全浪费。

[...]
Outputs:

joe = ACCESS_KEY: AKIAJQPSXBKSD3DY47BQ, SECRET: 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
logs = ACCESS_KEY: AKIAISIUXTG5RIJZAEYA, SECRET: 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
mary = ACCESS_KEY: AKIAJPQB7HBK2KLAARRQ, SECRET: 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

应用程序用户 IAM – CloudWatch 日志

还有更多…

如果你更愿意看到使用 Ansible 的实现方式,它稍微有些不同。IAM 的支持并不完全相同,因为没有 IAM 托管策略的支持。然而,你可以像这样简单地创建用户:

---
- name: create mary user
  iam:
    iam_type: user
    name: mary
    state: present
    access_key_state: create
    path: /team/

由于当前没有 IAM 托管策略的支持,一个解决方法是使用我们想要的 IAM 策略的 JSON 文件,例如给我们的用户 Mary 使用AmazonS3FullAccess。在 AWS 控制台的策略部分(console.aws.amazon.com/iam/home#policies)可以很容易找到。将以下 JSON 内容粘贴到Ansible文件夹根目录下的AmazonS3FullAccess.json文件中:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": "*"
    }
  ]
}

iam_policy模块中使用此本地策略:

- name: Assign a AmazonS3FullAccess policy to mary
  iam_policy:
    iam_type: user
    iam_name: mary
    policy_name: AmazonS3FullAccess
    state: present
    policy_document: AmazonS3FullAccess.json

第三章:深入了解 Terraform

在本章中,我们将涵盖以下配方:

  • 使用 Terraform 处理不同环境

  • 使用 Terraform 配置带有 Chef 的 CentOS 7 EC2 实例

  • 使用数据源、模板和本地执行

  • 使用 Terraform 在引导过程中执行远程命令

  • 在 Terraform 中使用 Docker

  • 使用 Terraform 模拟基础设施更改

  • 团队协作 —— 共享 Terraform 基础设施状态

  • 维护清晰且标准化的 Terraform 代码

  • 一个 Makefile 来管理所有

  • 团队工作流示例

  • 使用 Terraform 管理 GitHub

  • 与 StatusCake 的外部监控集成

介绍

在本章中,我们将超越在第二章中涵盖的 Terraform 使用基础知识,使用 Terraform 配置 IaaS。我们将探索许多重要的技巧,如何将 Terraform 与 Docker 和 Chef 等其他工具结合使用,如何在多个环境中使用(如开发/阶段/生产),它如何在管理基础设施以及许多 SaaS 时展现强大的能力,如何将该工具集成到团队工作流中(共享、同步、维护、协调等)。这些话题都同样重要,因为它们将决定我们日常工作的质量以及我们与他人、服务和系统的互动能力。

注意

本书使用的 Terraform 版本是 0.7.3。

使用 Terraform 处理不同环境

拥有不同的基础设施环境,并使它们具有一定的相似性是常见且推荐的设置。这些环境在公司和项目中可能有很大差异,无论是在名称上还是重点上,但通常可以找到以下几种环境:

  • 开发:开发人员可以在此实现并快速测试新功能

  • 阶段环境:在比开发环境更一致的环境中测试新功能,有时非常类似于预生产环境

  • 预生产环境:此环境与生产环境尽可能相似

  • 生产环境:完整功能的实时生产环境

我们将看到,使用基础设施即代码,尤其是 Terraform 如何从根本上帮助构建强大且可复制的环境。这一次,我们将使用 CoreOS AMI 作为改变。

准备工作

要执行此配方,您需要以下内容:

  • 一个可用的 Terraform 安装

  • 配置了 SSH 密钥的 AWS 账户并在 Terraform 中配置(参见第二章,使用 Terraform 配置 IaaS的配方)

  • 互联网连接

如何操作……

使用基础设施即代码,最简单的做法是简单地复制代码以创建所需数量的环境。然而,还有一种更强大的方式,可以充分利用 Terraform 的全部能力。

让我们定义简单目标环境的需求,并将其转换为动态的 Terraform 代码:

参数 阶段环境 生产环境
实例数量 1 3
实例类型 t2.micro t2.medium
操作系统 CoreOS Stable CoreOS Stable
eu-west-1 区域的 AMI ami-85097ff6 ami-85097ff6
us-east-1 区域的 AMI ami-0aef8e1d ami-0aef8e1d
S3 存储桶命名 iacbook-staging iacbook-production
默认环境

让我们从在 variables.tf 文件中声明这些变量开始,正如我们在第二章中看到的那样,使用 Terraform 配置 IaaS,不同的是这次我们将描述像 stagingproduction 这样的环境,而不是 AWS 区域用于集群大小和实例类型的定义。

定义 CoreOS AMI 变量:

variable "aws_coreos_ami" {
  type = "map"

  default = {
    eu-west-1 = "ami-85097ff6"
    us-east-1 = "ami-0aef8e1d"
  }
}

根据环境定义集群大小变量:

variable "cluster_size" {
  type = "map"

  default = {
    staging    = "1"
    production = "3"
  }

  description = "Number of nodes in the cluster"
}

最后,定义不同的 AWS 实例类型:

variable "aws_instance_type" {
  type = "map"

  default = {
    staging    = "t2.micro"
    production = "t2.medium"
  }

  description = "Instance type"
}

现在让我们在一个高度动态的基础设施代码(instances.tf)中使用这些变量,使用 aws_instance 资源,并根据环境自动选择正确的集群大小和实例类型,同时根据执行区域选择合适的 AMI:

resource "aws_instance" "coreos" {
  count                       = "${lookup(var.cluster_size, var.environment)}"
  ami                         = "${lookup(var.aws_coreos_ami, var.aws_region)}"
  instance_type               = "${lookup(var.aws_instance_type, var.environment)}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  associate_public_ip_address = true

  tags {
    Name        = "coreos_${var.environment}_${count.index+1}"
    Environment = "${var.environment}"
  }
}

注意

我们根据环境和实例计数的数值(即 coreos_production_2)构建了每个实例的 Name 标签。

我们的规格表还表明我们需要两个不同的 S3 存储桶。让我们在 s3.tf 文件中重用一些在第二章中做过的类似操作,使用 Terraform 配置 IaaS

resource "aws_s3_bucket" "bucket" {
  bucket = "iacbook-${var.environment}"

  tags {
    Name        = "IAC Book ${var.environment} Bucket"
    Environment = "${var.environment}"
  }
}

这里的构建方式相同,每个环境会动态生成一个与其相关联的存储桶。

保持 tfstate 文件隔离

强烈建议不要混合不同环境的 Terraform 状态文件。为了保持它们良好的隔离,执行 terraform 命令时使用以下选项是一个优雅的解决方案:

$ terraform apply -state=staging.tfstate

你的默认环境(设置为暂存环境)现在将存储在 staging.tfstate 文件中。

设置生产环境标志

现在我们的暂存基础设施运行顺利,是时候启动正式环境——生产环境了。由于我们已经在使用一个专用的 terraform 状态文件,我们也为生产环境做同样的设置,并通过命令行直接设置 environment 变量:

$ terraform plan -state=production.tfstate -var environment=production

现在,你拥有两个清晰分离的环境,使用相同的代码,但彼此独立运行。简洁而优雅!

使用 Terraform 通过 Chef 配置一个 CentOS 7 EC2 实例

一旦 Terraform 生成了底层基础设施,任务可能还没有完成。这时,像 Chef、Ansible 或 Puppet 这样的配置管理工具就派上了用场,来配置虚拟机。幸运的是,Chef 是 Terraform 中的一流配置工具。接下来我们将看到如何通过 Terraform 从头开始在 AWS 上完全引导一个 CentOS 7.2 实例,从空白到一个完全配置的节点,并且在自动部署和注册到 Hosted Chef 之后,优雅地将配置交给 Chef。

如果这是您第一次在 AWS 上启动 CentOS 7 服务器,您必须同意它们的条款和条件,地址是aws.amazon.com/marketplace/pp/B00O7WM7QW

准备工作

要执行此配方,您将需要以下内容:

  • 一个有效的 Terraform 安装

  • 需要一个配置了 SSH 密钥的 AWS 账户,并且有一个安全组允许外部 SSH 连接(参见第二章,使用 Terraform 配置 IaaS配方)

  • 一个 Chef 服务器上的账户(我们推荐使用免费的托管 Chef 账户。请参考第六章,使用 Chef 和 Puppet 管理服务器的基础知识配方),并且上传了默认的 Cookbook

  • 需要一个互联网连接

如何操作…

由于涉及到许多来源,让我们将所有所需的信息放入一个表格中(Chef 信息来自 Chef 入门套件,或您的 Chef 服务器,填写您自己的值):

主机名 centos-1
实例类型 t2.micro
eu-west-1 中的 AMI ami-7abd0209
us-east-1 中的 AMI ami-6d1c2007
SSH 用户名 centos
SSH 密钥 keys/aws_terraform
所需 TCP 端口 22
应用的 Cookbook starter
Chef 服务器 URL api.chef.io/organizations/iacbook
验证密钥 iacbook.pem
验证客户端名称 iacbook
Chef 客户端版本 12.13.37
  1. 让我们首先在variables.tf文件中声明我们的 AMI 映射:

    variable "aws_centos_ami" {
      type = "map"
    
      default = {
        eu-west-1 = "ami-7abd0209"
        us-east-1 = "ami-6d1c2007"
      }
    }
    
  2. 现在在同一个文件中添加实例类型:

    variable "aws_instance_type" {
      default     = "t2.micro"
      description = "Instance Type"
    }
    
  3. 声明我们当前在生产中使用的 Chef 版本,以便它保持稳定并不变:

    variable "chef_version" {
      default = "12.13.37"
    }
    
  4. 声明 Chef 服务器的 URL。如果您使用的是书中的托管 Chef 示例,您可以在knife.rb文件中找到正确的地址:它就是api.chef.io/organizations/<your_organization_name>,否则,使用您自己的 Chef 服务器:

    variable "chef_server_url" {
      default = "https://api.chef.io/organizations/iacbook"
    }
    
  5. 最后,添加 Chef 服务器的验证客户端名称:

    variable "chef_validation_client_name" {
      default = "iacbook"
    }
    
  6. 要连接到实例,我们知道默认的用户名是centos,但由于它可能会变化,或者您可能会使用自己的镜像,因此最好也将其固定在变量中:

    variable "ssh_user" {
      default = "centos"
    }
    

创建 EC2 实例

我们从之前的示例中知道,运行 CentOS 的基本实例在 Terraform 的 instances.tf 中看起来像这样,使用名为 base_security_group 的安全组:

resource "aws_instance" "centos" {
  ami                         = "${lookup(var.aws_centos_ami, var.aws_region)}"
  instance_type               = "${var.aws_instance_type}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  security_groups             = ["${aws_security_group.base_security_group.name}"]
  associate_public_ip_address = true

  tags {
    Name = "CentOS-${count.index+1} by Terraform"
  }
}

现在我们需要为 Terraform 文件提供两种信息:在服务器上使用 Chef 的方式,以及如何连接到它。

传递连接信息

为了告诉 Terraform 如何连接到新的 EC2 实例,我们在 aws_instance 资源内使用 connection {} 块,告诉它通过 SSH 使用哪个用户和密钥:

connection {
    type     = "ssh"
    user     = "${var.ssh_user}"
    key_file = "${var.aws_ssh_admin_key_file}"
  } 

向 Chef 提供信息

我们需要向 Terraform 提供一些信息,以便它可以传递给 Chef。这一切都会发生在 aws_instance 资源内的 provisioner "chef" {} 块中。

使用我们声明的所有变量,下面是它的样子:

resource "aws_instance" "centos" {
[...]
  provisioner "chef" {
    node_name              = "centos-${count.index+1}"
    run_list               = ["starter"]
    server_url             = "${var.chef_server_url}"
    validation_client_name = "${var.chef_validation_client_name}"
    validation_key         = "${file("chef/validator.pem")}"
    version                = "${var.chef_version}"
  }
 }

注意

别忘了使用有效的路径来验证密钥!

现在你可以运行 terraform apply,看到从实例创建到 Chef 客户端部署和 cookbook 安装的一切过程。

它是如何工作的…

首先,Terraform 创建所需的 AWS 环境(密钥、安全组和实例),一旦实例运行,它会通过 SSH 以正确的凭证连接到它,然后从官方源部署指定的 Chef 客户端版本,最后执行初始的 chef-client 运行,将节点注册到 Chef 服务器并应用请求的 cookbook。

还有更多…

对于 Terraform 内部的 Chef 提供者,还可以进行更多的配置选项。例如,可以通过 client_options 将所有可用的 chef-client 选项作为数组传递,而 Chef 环境(通常非常重要)则通过 environment 作为字符串传递。如果你使用了已经集成 Chef 客户端的自定义镜像,那么你可能会想设置 skip_installtrue,以避免重新安装。

使用数据源、模板和本地执行

当我们使用 Terraform 部署或更新基础设施时,有时会很享受动态生成一些本地内容。例如,如果你希望使用 Ansible 来配置 Terraform 启动的新虚拟机,那么你可能需要在本地的笔记本电脑上填充一个 hosts 文件,内容是该主机的公共 IP 地址。

Ansible 可以通过自身使用一些 AWS 动态库存,但在这里我们将看到如何在 Terraform 中使用模板,并动态填充所需的信息,最终实现一个有效的 Ansible 设置,这都得益于 Terraform。

准备就绪

要完成本教程,你需要以下内容:

  • 一个有效的 Terraform 安装

  • 一个 AWS 账户,已经在 Terraform 中配置了 SSH 密钥,并且安全组允许来自外部的 SSH 连接(参考 第二章,使用 Terraform 配置 IaaS 章节)

  • 一个互联网连接

如何实现…

让我们首先在 AWS 上启动一个标准的 CentOS 7.2,并使用 variables.tf 中的一组标准变量:

variable "aws_centos_ami" {
  type = "map"

  default = {
    eu-west-1 = "ami-7abd0209"
    us-east-1 = "ami-6d1c2007"
  }
}

variable "aws_instance_type" {
  default     = "t2.micro"
  description = "Instance Type"
}

这是启动实例的最简单的 instances.tf 文件:

resource "aws_instance" "centos" {
  ami                         = "${lookup(var.aws_centos_ami, var.aws_region)}"
  instance_type               = "${var.aws_instance_type}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  security_groups             = ["${aws_security_group.base_security_group.name}"]
  associate_public_ip_address = true

  tags {
    Name = "CentOS"
  }
}

数据和模板

那么,Ansible 的典型 hosts 文件是什么样的呢?它看起来像这样:

[section_name_1]
1.2.3.4
[section_name_2]
5.6.7.8
a.server.fqdn

所以,稍后,Ansible 会根据每个部分的每台服务器应用需要的角色。

在我们的案例中,我们需要一个简单的部分,命名为 centos7_hosts,并包含服务器的 IP 地址,如下所示:

[centos7_hosts]
1.2.3.4

让我们构建第一个名为 hosts.tpl 的模板,模板中有一个变量 host_public_ipv4,最终会被我们稍后启动的主机的真实 IP 地址替换:

[centos7_hosts]
${host_public_ipv4}

为了生成这个文件,我们将使用一个包含变量的模板,Terraform 会为我们生成它,使用 data 资源在 data.tf 中——它简单地包含了我们模板的文件插值,并从我们的 AWS 实例中传递所需的变量:

data "template_file" "ansible_hosts" {
  template = "${file("hosts.tpl")}"

  vars {
    host_public_ipv4 = "${aws_instance.centos.public_ip}"
  }
}

本地执行的 Terraform 配置器

这个过程在内部生成模板,意味着数据已经可用,但不会被直接输出到任何地方。此时,local-exec 配置器就派上用场了,它通过简单地将渲染后的模板从数据源回显到我们想要的文件中(在 data.tf 中):

resource "null_resource" "generate_ansible_hosts" {
  provisioner "local-exec" {
    command = "echo '${data.template_file.ansible_hosts.rendered}' > hosts"
  }
}

注意

我们使用 "null_resource" 来实现这一目的,这样模板的生成就不依赖于任何其他执行的资源。在其他情况下,我们可以直接在标准资源内部使用 "local-exec" { } 配置器。

现在我们可以运行 terraform apply 来应用这个配置。我们的 hosts 文件看起来如何?像这样:

$ cat hosts
[centos7_hosts]
52.17.172.231

它已正确填充!

应用配置好的 Ansible

我们的代码仓库现在已经准备好供 Ansible 使用了。这里是一个示例 Ansible 角色,简单地安装并启动 Docker,这样我们就可以开始使用它,文件位于 ansible/main.yml

---
- hosts: centos7_hosts
  become: yes
  tasks:
    - name: Install EPEL
      yum: name=epel-release state=present
    - name: Install Docker
      yum: name=docker state=present
    - name: Start docker
      service: name=docker state=started enabled=yes

现在,只需要在你需要的时候执行 Ansible,一切都已准备好并配置完成!

$ ansible-playbook -i hosts -u centos ansible/main.yml
PLAY [centos7_hosts] ***********************************************************
[...]
PLAY RECAP *********************************************************************
52.17.172.231              : ok=4    changed=0    unreachable=0    failed=0

使用 Terraform 执行远程命令以进行引导

在启动后立即执行一组初始命令是非常常见的做法,甚至在正确的配置管理系统(如 Chef 或 Ansible)接管之前。它可以包括操作系统的完整更新、在发现系统(如 Consul)上的初始注册,或初步添加本地 DNS 服务器。它的目的是将系统交付到一个更高级别和预期的状态,以便下一个配置系统接管。绝对不应替代合适的配置管理工具。

在这个食谱中,我们将启动一个 CentOS 7.2 系统,然后对其进行完全更新,使其尽可能安全,安装 EPEL 以获得更丰富的包库,添加 Puppet Labs Yum 仓库并安装 Puppet 代理,并添加一个不同的名称服务器,以便我们的系统准备好进行下一步(我们在这里不涵盖这个步骤,因为它可能是在执行 Puppet 代码)。

准备工作

要逐步完成这个食谱,你将需要以下内容:

  • 一个正常工作的 Terraform 安装

  • 一个配置了 SSH 密钥的 AWS 账户,在 Terraform 中,并且安全组允许来自外部的 SSH 连接(参见第二章,使用 Terraform 提供 IaaS 配方)

  • 一条互联网连接

如何操作…

在深入到提供部分之前,让我们先在 instances.tf 中描述一个经典的 CentOS 7.2 AMI:

resource "aws_instance" "centos" {
  ami                         = "${lookup(var.aws_centos_ami, var.aws_region)}"
  instance_type               = "${var.aws_instance_type}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  security_groups             = ["${aws_security_group.base_security_group.name}"]
  associate_public_ip_address = true

  tags {
    Name = "CentOS"
  }
}

variables.tf 文件中的变量如下:

variable "aws_centos_ami" {
  type = "map"

  default = {
    eu-west-1 = "ami-7abd0209"
    us-east-1 = "ami-6d1c2007"
  }
}

variable "aws_instance_type" {
  default     = "t2.micro"
  description = "Instance Type"
}

现在,我们对这个系统的直接目标是什么?:

  • 完全更新:sudo yum install -y

  • 启用 EPEL 仓库:sudo yum install epel-release -y

  • 添加自定义名称服务器:echo "nameserver 8.8.8.8" | sudo tee -a /etc/resolv.conf

  • 添加 Puppet Labs 仓库:sudo yum install https://yum.puppetlabs.com/puppetlabs-release-pc1-el-7.noarch.rpm -y

  • 安装 Puppet agent:sudo yum install puppet-agent -y

  • 显示 Puppet 版本:sudo /opt/puppetlabs/bin/puppet agent --version

让我们将这些命令添加到 aws_instance 资源中的 remote-exec 提供者中,并将默认用户名更改为 centos

provisioner "remote-exec" {
    inline = [
      "echo \"nameserver 8.8.8.8\" | sudo tee -a /etc/resolv.conf",
      "sudo yum update -y",
      "sudo yum install epel-release -y",
      "sudo yum install https://yum.puppetlabs.com/puppetlabs-release-pc1-el-7.noarch.rpm -y",
      "sudo yum install puppet-agent -y",
      "sudo /opt/puppetlabs/bin/puppet agent --version"
    ]
    connection {
        user = "centos"
      }
  }

当你执行 terraform apply 时,你将得到一个完全更新的 CentOS 7.2 系统,EPEL 可用,添加了自定义 DNS 服务器,并且安装了 Puppet agent。

为 Puppet 的下一个部署阶段做好准备!

使用 Docker 和 Terraform

Terraform 也可以用于操作 Docker。传统的用法是与已经运行的 Docker 服务器进行交互,但在本地使用你自己的 Docker 安装时也可以完全一样。使用 Terraform 控制 Docker,我们将能够动态触发 Docker 镜像更新,执行带有各种选项的容器,操作 Docker 网络,并使用 Docker 卷。

在这里,我们将部署一个孤立的博客容器(Ghost),它将通过 nginx-proxy 容器公开服务,并通过 HTTP 提供服务。这个非常有用的 nginx-proxy 容器由 InfluxDB 的 Jason Wilder 在他的 GitHub 上提出:github.com/jwilder/nginx-proxy

准备工作

要执行此配方,你将需要以下内容:

  • 一个正常工作的 Terraform 安装。

  • 一个正常工作的 Docker 安装(Mac 的原生 Docker,Linux 上的 Docker 引擎,运行 Docker 的远程服务器等)。本配方使用 Docker 1.12。

  • 一条互联网连接。

如何操作…

在开始使用 Terraform 编写代码之前,确保你能够连接到任何类型的 Docker 引擎,无论是本地还是远程:

$ docker version
Client:
 Version:      1.12.0
 API version:  1.24
 Go version:   go1.6.3
 Git commit:   8eab29e
 Built:        Thu Jul 28 21:15:28 2016
 OS/Arch:      darwin/amd64

Server:
 Version:      1.12.0
 API version:  1.24
 Go version:   go1.6.3
 Git commit:   8eab29e
 Built:        Thu Jul 28 21:15:28 2016
 OS/Arch:      linux/amd64

如果此时遇到问题,需要在继续之前解决这些问题。

我们的目标是通过 nginx-proxy 容器提供一个博客容器(Ghost),该容器不会直接在网络上提供服务。

如果你连接的是远程 Docker 服务器,你需要配置 Docker 提供者(可能在 provider.tf 中)。另外,它也可以使用 DOCKER_HOST 环境变量,或者如果没有指定,则使用本地守护进程。对于本练习中的本地使用,你可以直接忽略包含提供者:

provider "docker" {
  host = "tcp://1.2.3.4:2375"
}

首先,我们在docker.tf中声明两个数据源,分别对应我们的 Docker 镜像。ghost镜像将使用其0.10版本标签,而nginx-proxy将使用0.4.0版本标签。使用数据源有助于我们稍后操作镜像:

data "docker_registry_image" "ghost" {
  name = "ghost:0.10"
}

data "docker_registry_image" "nginx-proxy" {
  name = "jwilder/nginx-proxy:0.4.0"
}

现在我们可以访问镜像了,接下来就照做,使用docker_image资源。我们将重用我们的数据源所提供的所有信息,如镜像名称或其 SHA256 值,这样我们就知道是否有新镜像可供拉取:

resource "docker_image" "ghost" {
  name         = "${data.docker_registry_image.ghost.name}"
  pull_trigger = "${data.docker_registry_image.ghost.sha256_digest}"
}

resource "docker_image" "nginx-proxy" {
  name         = "${data.docker_registry_image.nginx-proxy.name}"
  pull_trigger = "${data.docker_registry_image.nginx-proxy.sha256_digest}"
}

现在让我们声明私人 Ghost 容器(没有任何端口映射),使用docker_container资源。我们将使用刚才声明的docker_image资源中的镜像,并导出一个名为VIRTUAL_HOST的环境变量,供 nginx-proxy 容器使用(更多信息请参考 nginx-proxy 文档)。如果你不是在本地 Docker 主机上运行,请替换为你想使用的主机:

resource "docker_container" "ghost" {
  name  = "ghost"
  image = "${docker_image.ghost.latest}"
  env   = ["VIRTUAL_HOST=localhost"]
}

现在让我们启动nginx-proxy容器。我们从它的文档中知道,它需要以只读模式共享 Docker 套接字(/var/run/docker.sock),以动态访问正在运行的容器,并且我们希望它运行在默认的 HTTP 端口(tcp/80)上。我们来实现这一点:

resource "docker_container" "nginx-proxy" {
  name  = "nginx-proxy"
  image = "${docker_image.nginx-proxy.latest}"

  ports {
    internal = 80
    external = 80
    protocol = "tcp"
  }

  volumes {
    host_path      = "/var/run/docker.sock"
    container_path = "/tmp/docker.sock"
    read_only      = true
  }
}

现在如果你执行terraform apply,你可以访问http://localhost/admin(将localhost替换为你使用的 Docker 服务器地址),并设置你的 Ghost 博客!

如何操作…

使用 Terraform 模拟基础设施变更

在之前的教程中,你已经学会了如何使用 Terraform 管理不同的环境,这很好。但我们如何在应用更改之前测试它们呢?

Terraform 有一个很棒的内部机制,可以通过比较我们的基础设施代码期望的内容和远程状态包含的内容来规划变更。这样,我们就能安全地检查,我们认为代码中的小改动是否实际上会带来破坏性影响(有时候,资源中的某些参数会触发资源的完全销毁!)。

我们将介绍如何通过不同的方式预测、模拟和定位基础设施中的变更,这是在正式应用更改之前的一种额外安全检查。

准备工作

要按照这个教程进行操作,你需要以下内容:

  • 一个可用的 Terraform 安装

  • 配置了 SSH 密钥的 AWS 账户(请参阅第二章,使用 Terraform 配置 IaaS教程)

  • 一个互联网连接

如何做到这一点…

我们从一个简单的 CoreOS 机器开始,这台机器部署在 AWS 上。我们知道 AMI ID,我们需要一个单独的t2.micro主机。我们把这些信息放到variables.tf文件中:

variable "aws_coreos_ami" {
  default = "ami-85097ff6"
}

variable "cluster_size" {
  default     = "1"
  description = "Number of nodes in the cluster"
}

variable "aws_instance_type" {
  default     = "t2.micro"
  description = "Instance type"
}

我们可以创建的最简单的aws_instance资源如下,位于instances.tf中:

resource "aws_instance" "coreos" {
  count                       = "${var.cluster_size}"
  ami                         = "${var.aws_coreos_ami}"
  instance_type               = "${var.aws_instance_type}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  associate_public_ip_address = true

  tags {
    Name = "coreos_${count.index+1}"
  }
}

规划

到现在为止,我们已经使用terraform apply进行即时操作。还有另一个命令:terraform plan。它正如其名所示。它会计划变更,但不会应用这些变更:

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but
will not be persisted to local or remote state storage.
The Terraform execution plan has been generated and is shown below.
[...]

+ aws_instance.coreos
 ami:                         "ami-85097ff6"
 [...]

+ aws_key_pair.admin_key
 [...]

Plan: 2 to add, 0 to change, 0 to destroy.

因此,通过在应用之前进行计划,我们可以知道我们的基础设施将会发生什么变化。我们对将创建一个使用正确 AMI 的实例感到满意,所以让我们执行terraform apply

现在基础设施已经创建完成,如果你再次运行计划,它会说没有需要修改的内容:

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but
will not be persisted to local or remote state storage.

aws_key_pair.admin_key: Refreshing state... (ID: admin_key)
aws_instance.coreos: Refreshing state... (ID: i-0f9106905e74a29f7)

No changes. Infrastructure is up-to-date. This means that Terraform
could not detect any differences between your configuration and
the real physical resources that exist. As a result, Terraform
doesn't need to do anything.

一个正常运行的基础设施应该始终处于一个状态,terraform plan不需要更改任何内容。

现在假设我们需要让我们的基础设施发展,并创建一个 S3 存储桶。这在名为s3.tf的文件中应该是这样的:

resource "aws_s3_bucket" "bucket" {
  bucket = "iacbook"

  tags {
    Name = "IAC Book Bucket"
  }
}

我们不确定即将发生什么,所以让我们使用 Terraform 进行规划,这样它会准确告诉我们它打算做什么:

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
[...]

aws_key_pair.admin_key: Refreshing state... (ID: admin_key)
aws_instance.coreos: Refreshing state... (ID: i-0f9106905e74a29f7)

[...]

+ aws_s3_bucket.bucket
 bucket:              "iacbook"
 tags.Name:           "IAC Book Bucket"
 [...]

Plan: 1 to add, 0 to change, 0 to destroy.

计划看起来不错——它似乎想创建一个名为我们想要的名字的 S3 存储桶!让我们执行terraform apply,然后继续进行。

快速模拟更改

我们现在想知道如果我们更改实例数量会发生什么。这是cluster_size变量,目前设置为1。我们可以直接从命令行测试更改该值的影响,而无需更改代码:

$ terraform plan -var 'cluster_size="2"'
[...]
+ aws_instance.coreos.1
 ami:                         "ami-85097ff6"
 instance_type:               "t2.micro"
 tags.Name:                   "coreos_2"
 [...]
Plan: 1 to add, 0 to change, 0 to destroy.

好消息!看起来增加cluster_size值达到了预期效果:创建了一个新实例。

现在,我们正合理地想知道将实例类型从t2.micro更改为t2.medium会产生什么影响:

$ terraform plan -var aws_instance_type="t2.medium"
[...]
-/+ aws_instance.coreos
 [...]
 instance_type:               "t2.micro" => "t2.medium" (forces new resource)

Plan: 1 to add, 0 to change, 1 to destroy.

哎呀!更改实例类型似乎是一个破坏性操作。我们稍后再处理这个问题,并将更改添加到一个名为plan.tfvars的新文件中:

aws_instance_type="t2.medium"

我们知道我们想要提议将实例数量更改为2,所以让我们将其添加到同一个文件中:

aws_instance_type="t2.medium"
cluster_size="2"

现在我们可以使用包含所有更改的文件,使用-var-file选项进行测试:

$ terraform plan -var-file=plan.tfvars
-/+ aws_instance.coreos.0
 instance_type:               "t2.micro" => "t2.medium" (forces new resource)
 tags.Name:                   "coreos_1" => "coreos_1"
 [...]

+ aws_instance.coreos.1
 instance_type:               "t2.medium"
 tags.Name:                   "coreos_2"
 [...]
Plan: 2 to add, 0 to change, 1 to destroy.

很好!你已经了解到我们的第一个实例将会被销毁并重新创建,从t2.micro升级到t2.medium,并且第二个实例将会使用相同的值被创建。我们暂时不应用这个更改,因为会产生额外费用。

定向进行特定更改

我们的同事问我们是否确定我们提出的更改不会对 S3 存储桶产生影响。Terraform 允许我们在计划阶段通过直接定位资源来非常具体地回答这个问题:

$ terraform plan -var-file=plan.tfvars -target="aws_s3_bucket.bucket"
[...]
aws_s3_bucket.bucket: Refreshing state... (ID: iacbook)
[...]
No changes. Infrastructure is up-to-date.
[...]

我们的同事很高兴,我们现在确定这个更改将完全按预期进行。我们可以提交这个更改进行审核。

团队合作 – 共享 Terraform 基础设施状态

你可能与团队一起工作,现在你正在使用 Terraform 管理基础设施,你将面临一个问题:你的团队如何在基础设施即代码方面协同工作?对此有许多答案,一个关键问题需要解决的是:Terraform 状态是如何传递或同步的?

在这里我们将看到如何使用 Git(一个版本控制系统,开发人员可以用来存储代码)、AWS S3(一个使用 HTTP 的亚马逊 Web 服务存储系统)或 Consul(一个服务发现工具和键值存储),在众多其他解决方案中选择一种来共享状态。

正在准备中

要执行这个操作,你需要以下内容:

  • 一个工作正常的 Terraform 安装

  • 一个配置了 SSH 密钥的 AWS 账户在 Terraform 中(参考 第二章,使用 Terraform 提供 IaaS 配方)

  • 用于 Consul 仿真解决方案的工作 Docker 安装(可选)

  • 一条互联网连接

如何操作……

让我们从启动一个初始基础设施开始(本例中为一台虚拟机)。以下是 instances.tf 中的 aws_instance 资源,它基于之前的配方使用了 CoreOS 稳定版本:

resource "aws_instance" "coreos" {
  count                       = "${var.cluster_size}"
  ami                         = "${var.aws_coreos_ami}"
  instance_type               = "${var.aws_instance_type}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  associate_public_ip_address = true

  tags {
    Name = "coreos_${count.index+1}"
  }
}

以下是 variables.tf 中的示例变量;可以根据需要进行调整:

variable "aws_coreos_ami" {
  default = "ami-85097ff6"
}

variable "cluster_size" {
  default     = "1"
  description = "Number of nodes in the cluster"
}

variable "aws_instance_type" {
  default     = "t2.micro"
  description = "Instance type"
}

Terraform 默认将其状态存储在名为 terraform.tfstate 的文件中,并且有一个名为 terraform.tfstate.backup 的备份文件:

$ ls terraform.tfstate*
terraform.tfstate        terraform.tfstate.backup

通过 Git 共享

所有选项中最简单的是通过 Git 来共享状态文件:你本来就应该对你的基础设施代码进行版本管理!去某个平台创建一个账户。GitHub (github.com) 不提供免费的私有仓库,但 GitLab (gitlab.com) 或 BitBucket (bitbucket.org) 提供。按照说明操作,使你的 Git 仓库本地工作。

现在,添加 tfstate 文件:

$ git add *.tfstate*

提交文件:

$ git commit -m "initial state creating the infrastructure"
[master (root-commit) 6f7e2ba] initial state creating the infrastructure
 2 files changed, 193 insertions(+)
 create mode 100644 terraform.tfstate
 create mode 100644 terraform.tfstate.backup

推送提交:

$ git push

现在,你的同事在应用任何操作之前必须拉取更改,否则灾难可能很快就会发生:

coworker@host $ git pull

使用 S3 进行远程共享

通过 Git 共享状态文件在某种程度上是可行的。最终你会遇到某种情况,有人忘记推送或拉取。在状态文件中合并冲突真的不是一件愉快的事情。

一个解决方案是使用 S3 来共享状态文件,并使用 Terraform 的远程状态功能。

从在 s3.tf 中创建一个专门用于此的 S3 存储桶开始,启用版本控制(这样你就可以回滚到基础设施的早期版本):

resource "aws_s3_bucket" "tfstate" {
  bucket = "iacbook-tfstate"

  versioning {
    enabled = true
  }

  tags {
    Name = "IAC Book TFState Bucket"
  }
}

让我们 terraform apply 这个 S3 存储桶,并继续使用我们的信息进行远程配置:

$ terraform remote config -backend=s3 -backend-config="bucket=iacbook-tfstate" -backend-config="key=terraform.tfstate"
Remote state management enabled
Remote state configured and pulled. 

你现在可以在 S3 浏览器中看到 Terraform 状态文件:

使用 S3 进行远程共享

现在对基础设施进行任何更改,例如添加一个新的 S3 存储桶,以便看到文件变化的效果:

resource "aws_s3_bucket" "bucket" {
  bucket = "iacbook-bucket"

  tags {
    Name = "IAC Book Bucket"
  }
}

terraform apply 后,直接推送更改:

$ terraform remote push
State successfully pushed!

在 S3 浏览器中查看历史记录:

使用 S3 进行远程共享

同事必须配置他们的环境并拉取信息:

coworker@host $ terraform remote config -backend=s3 -backend-config="bucket=iacbook-tfstate" -backend-config="key=terraform.tfstate"
Initialized blank state with remote state enabled!
Remote state configured and pulled.

本地副本现在位于 .terraform 文件夹中:

$ head .terraform/terraform.tfstate

使用 Consul 进行远程共享

通过使用 Consul 共享状态文件是一种非常好的方式,Consul 是 Hashicorp 提供的强大键值存储服务(consul.io/))。使用 Consul 存储 Terraform 状态使得与团队协作变得更容易,因为只有一个单一的复制状态。如果我们忘记同步 Git 仓库,就不会有使用旧状态文件的风险。

配置生产环境中的正确 Consul 集群超出了本书的范围,但如果你手头没有 Consul 集群来尝试,可以使用 Docker 和 Consul 镜像快速创建一个:

$ docker run -it --rm -p 8400:8400 -p 8500:8500 -p 8600:53/udp -h node1 progrium/consul -server -bootstrap

现在,让我们为 Consul 配置 Terraform 远程,并命名为 terraform/my_customer,以便可以同时管理多个客户:

$ terraform remote config -backend=consul -backend-config="path=terraform/my_customer"
Remote state management enabled
Remote state configured and pulled.

工作完成!你的同事现在可以从 Consul 源中推送和拉取了!在生产环境的 Consul 集群中,这意味着每个节点的状态都被复制和同步,同时增加了隐私性。

其他状态共享选项

还有许多其他方式可以共享状态,例如在 Azure 上,使用 OpenStack Swift,任何支持 REST 的 HTTP 服务器,CoreOS 自己的 etcd 键值存储,Google Cloud 存储或 Hashicorp 提供的商业解决方案 Atlas。

维护一个干净且标准化的 Terraform 代码

每个人都有自己的编码风格,但强制执行标准化和常见的可读风格是顺利协作的关键。这就是为什么 Terraform 提供了一个命令来确保格式和风格都正确。

我鼓励读者广泛使用它,甚至将其集成到 持续集成 (CI) 系统和 Makefile 中。

准备工作

要完成这个教程,你将需要以下内容:

  • 一个正常工作的 Terraform 安装

  • 一个互联网连接

如何操作……

我们故意编写一个风格非标准且带有错误(缺失变量)的简单 Terraform 代码。这将帮助我们操作 Terraform 提供的各种工具,以确保代码的最一致性和同质性,从而更快地实现更高质量和更高水平的代码标准化。

让我们在 provider.tf 中这样编写一个 AWS 的 provider(故意写在一行中):

provider "aws" { region = "${var.aws_region}" }

语法验证

尝试验证该文件,它会通知我们缺少一个变量:

$ terraform validate
Error validating: 1 error(s) occurred:

* provider config 'aws': unknown variable referenced: 'aws_region'. define it with 'variable' blocks

验证失败,返回代码是 1

$ echo $?
1

让我们将这个变量添加到 variables.tf 文件中:

variable "aws_region" { default = "eu-west-1" }

好极了!terraform validate 现在运行正常:

$ terraform validate
$ echo $?
0

样式验证

问题是,我们解决了明显的问题(缺失变量),但样式如何呢?前面的样式完全可行,但可能并不是标准样式。

让我们使用 fmt 选项检查样式问题,显示屏幕上的 diff,但不自动写入文件:

$ terraform fmt -write=false -diff=true
provider.tf
diff a/provider.tf b/provider.tf
--- /var/folders/zn/bx_20cp90bq5_fqqmlvx3tq40000gn/T/598506546  2016-09-10 22:40:35.000000000 +0200
+++ /var/folders/zn/bx_20cp90bq5_fqqmlvx3tq40000gn/T/407676393  2016-09-10 22:40:35.000000000 +0200
@@ -1 +1,3 @@
-provider "aws" { region = "${var.aws_region}" }
+provider "aws" {
+  region = "${var.aws_region}"
+}
variables.tf
diff a/variables.tf b/variables.tf
--- /var/folders/zn/bx_20cp90bq5_fqqmlvx3tq40000gn/T/743564340  2016-09-10 22:40:35.000000000 +0200
+++ /var/folders/zn/bx_20cp90bq5_fqqmlvx3tq40000gn/T/095288323  2016-09-10 22:40:35.000000000 +0200
@@ -1 +1,3 @@
-variable "aws_region" { default = "eu-west-1" }
+variable "aws_region" {
+  default = "eu-west-1"
+}

我们发现我们的样式与指南相差甚远。让我们修复它并自动正确地格式化文件:

$ terraform fmt
provider.tf
variables.tf

我们的两个文件现在已正确格式化!

我强烈建议将这两个命令放入你的 CI 测试中(你是否在 CI 中运行基础设施代码测试?),甚至在到达 CI 之前,如果它在项目的 Makefile 中,那会更好。

这是一个简单的 Makefile 示例:

.DEFAULT_GOAL := all

all:
  terraform validate
  terraform fmt

现在,你只需要在 Terraform 目录中输入 make,就可以确保你的代码既通过验证,又符合一致的风格。

一个 Makefile 控制所有

一些编程语言有环境或版本管理工具,如 Ruby 的 RVM、Node 的 NVM,甚至是 Rackspace 的 Docker 版本管理工具 DVM。

强烈建议锁定 Terraform 的版本,这样团队中的每个人都使用相同的版本,并且更新可以无痛处理。为了做到这一点,我建议使用一个 Terraform 容器,所以我们这里使用的是我自己用的那个:sjourdan/terraform:<version>(来自 github.com/sjourdan/terraform-docker)。但我理解用 docker run -it --rm -v pwd:/data sjourdan/terraform:0.7.3 替代简单的 terraform 命令可能感觉不太吸引人。这就是为什么我们可以为每个使用 Terraform 的项目使用一个通用的 Makefile

使用一个通用的入口点来操作基础设施代码有助于很多共享实践,执行政策并集成第三方服务,如 CI 系统。

准备工作

要执行这个食谱,你将需要以下内容:

  • 一个可用的 Terraform 安装

  • 配置了 SSH 密钥的 AWS 账户,并在 Terraform 中进行设置(请参考第二章,使用 Terraform 提供 IaaS的食谱)

  • 一个互联网连接

如何做……

让我们首先在 Makefile 中设置我们希望使用的 Terraform 版本,这样以后更新时可以轻松操作:

TERRAFORM_VERSION = 0.7.3

现在让我们创建一个 TERRAFORM_BIN 变量,它将包含完整的 Docker 命令,并共享我们的本地文件夹:

TERRAFORM_BIN = docker run -it --rm -v "$(PWD)":/data sjourdan/terraform:$(TERRAFORM_VERSION)

我喜欢自动化记录我的 Makefile,并且我提议使用一种流行的技巧:默认情况下,make 会调用 make help,然后解析 Makefile 中的注释并显示它们。这样,我可以通过简单地添加注释来选择输出内容。它是这样工作的:

.DEFAULT_GOAL := help

help:
  @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m   %s\n", $$1, $$2}'

现在只需使用这个功能为之前食谱中的验证和格式化创建一个条目:

validate: terraform-fmt terraform-validate  ## Validate syntax and format
terraform-fmt:
 $(TERRAFORM_BIN) fmt -list
terraform-validate:
 $(TERRAFORM_BIN) validate

如果你只输入 make,你将看到自动帮助信息:

$ make
validate                       Validate syntax and format

现在,简单的 make validate 将同时验证语法并格式化代码。

如果能同时拥有 planapply 命令会更好,如果你跟随了 Terraform 环境管理的食谱,如果它能直接从 Makefile 工作,那就太棒了,这样我们可以节省很多时间。

从创建 Makefile 主要的 "help" 入口开始:

plan: terraform-validate terraform-plan ## Plan changes
apply: terraform-validate terraform-apply ## Apply Changes

注意

我们在每一步都加入了验证步骤,这样我们始终可以确保它通过了完整的验证(你也可以添加自己的验证步骤)。

我们来检查一个名为 env 的环境变量,它是在 make 执行时传递的(例如 make plan env=staging),如果没有设置则返回错误:

ifndef env
getenv=$(error var:"env=" is not set)
else
getenv=$(env)
endif

现在我们可以编写 terraform-planterraform-apply 实际执行的内容,带有独立的 Terraform 状态和环境:

terraform-plan:
  $(TERRAFORM_BIN) plan -state=$(call getenv).tfstate -var environment=$(call getenv)

terraform-apply:
  $(TERRAFORM_BIN) apply -state=$(call getenv).tfstate -var environment=$(call getenv)

顺便说一句,你可以为我们之前的 terraform-validate 示例添加环境支持:

terraform-validate:
  $(TERRAFORM_BIN) validate -var environment=$(call getenv)

根据你的需要向项目的 Makefile 添加任意功能;你很快会发现这个简单的工具非常有帮助。

例如,我总是添加一个 make destroy 命令,这样我就可以轻松销毁测试基础设施(不过要小心!):

destroy: terraform-destroy  ## Destroy (careful!)
terraform-destroy:
  $(TERRAFORM_BIN) destroy -state=$(call getenv).tfstate -var environment=$(call getenv)

我们的 Makefile 现在是这样的:

$ make
apply                          Apply Changes
destroy                        Destroy (careful!)
plan                           Plan changes
validate                       Validate syntax and format

同样,它也可以像这样使用:

$ make plan env=staging
$ make apply env=staging

注意

添加任何能让你们的工作更轻松的内容,比如发布、测试等等。

另请参见

团队工作流示例

与基础设施代码的工作与软件代码的工作非常相似。关于这一主题有无数书籍和方法,而且通常每种方法都有很强的个人观点。

我提出的用于基础设施即代码的简单工作流是基于所谓的 GitHub Flowguides.github.com/introduction/flow/):

团队工作流示例

准备工作

要按照这个方法步骤执行,你需要以下内容:

  • 在某些 Git 托管服务上有一个账户(自托管或商业服务)

  • 一个工作中的 Terraform 安装

  • 配置了 SSH 密钥的 AWS 账户(参见 第二章,使用 Terraform 配置 IaaS 配方)

  • 一个互联网连接

如何操作…

从为你们的团队创建一个新的仓库开始。使用任何适合你的服务:GitLab、GitHub、BitBucket 等等。这个示例使用的是 GitHub。

一个简单的 Git 仓库

在 GitHub 上创建一个新的仓库:

注意

我们可能会在该仓库中存储一些机密信息,如 SSH 私钥或密码。现在创建一个私有 Git 仓库可能是一个更安全的选择。

一个简单的 Git 仓库

现在在你的工作站上导入这个新的空仓库,放在一个专用文件夹里:

$ git clone <your_git_repostory_address>
Cloning into 'my_infrastructure_code'...
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (3/3), done.
Checking connectivity... done.

初始基础设施代码

创建一个新的 Git 分支来处理初步基础设施:

$ git checkout -b new_infrastructure
Switched to a new branch 'new_infrastructure'

从之前的配方中添加一些 Terraform 代码,例如单个 CoreOS 实例。为了记录,下面是 variables.tf 文件:

variable "aws_region" {
  default = "eu-west-1"
}

variable "aws_ssh_admin_key_file" {
  default = "keys/aws_terraform"
}

variable "aws_coreos_ami" {
  default = "ami-85097ff6"
}

variable "cluster_size" {
  default     = "1"
  description = "Number of nodes in the cluster"
}

variable "aws_instance_type" {
  default     = "t2.micro"
  description = "Instance type"
}

这里有一个故意格式不正确的 provider.tf 文件:

provider "aws" { region = "${var.aws_region}" }

此外,这里有一个在instances.tf中的 CoreOS 实例:

resource "aws_instance" "coreos" {
  count                       = "${var.cluster_size}"
  ami                         = "${var.aws_coreos_ami}"
  instance_type               = "${var.aws_instance_type}"
  key_name                    = "${aws_key_pair.admin_key.key_name}"
  associate_public_ip_address = true

  tags {
    Name = "coreos_${count.index+1}"
  }
}

Terraform 代码验证

确保我们的代码是有效的:

$ terraform validate

幸运的是,它确实有效!

这段代码是否按我们希望的方式执行?看看:

$ terraform plan
[...]
+ aws_instance.coreos
[...]
+ aws_key_pair.admin_key
[...]
Plan: 2 to add, 0 to change, 0 to destroy.

这看起来正是我们目标的实现。让我们继续。

基础设施代码提交

在这个分支上有哪些新文件还没有出现在 master 上?让我们找出来:

$ git status
[...]
 instances.tf
 keys.tf
 keys/
 provider.tf
 variables.tf

很好,那些是我们刚创建的文件。让我们把它们加入到一个commit中:

$ git add .
$ git commit -m "an initial infrastructure"
[new_infrastructure 2415ad4] an initial infrastructure
 6 files changed, 65 insertions(+)
 create mode 100644 instances.tf
 create mode 100644 keys.tf
 create mode 100644 keys/aws_terraform
 create mode 100644 keys/aws_terraform.pub
 create mode 100644 provider.tf
 create mode 100644 variables.tf

现在让我们把分支推送到上游,这样我们的同事们就能看到我们的工作,尽管它还没有投入生产:

$ git push --set-upstream origin new_infrastructure
Counting objects: 9, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (9/9), 2.60 KiB | 0 bytes/s, done.
Total 9 (delta 0), reused 0 (delta 0)
To git@github.com:sjourdan /my_infrastructure_code.git
 * [new branch]      new_infrastructure -> new_infrastructure
Branch new_infrastructure set up to track remote branch new_infrastructure from origin.

提交一个拉取请求

进入你的仓库,你会看到类似下面的截图,显示新推送的分支信息。GitHub 提议轻松创建拉取请求。拉取请求是一个请求,将一个分支的内容合并到另一个分支。在我们的案例中,我们希望请求同事将我们的new_infrastructure分支合并到 master 分支,以便进行讨论:

创建拉取请求

当你打开一个拉取请求时,GitHub 会自动尝试合并请求(在我们的例子中,从我们的分支到 master)。这里没有发现冲突,因此我们可以写一条消息,解释我们的请求内容。拉取请求通常由多个提交组成,所以提供一个总结是非常欢迎的:

创建拉取请求

现在,你团队中的每个人都可以访问你的工作,并且在必要时可以直接从 GitHub 上进行讨论:

创建拉取请求

几分钟后,你的一个同事审查了你的代码,并给你发来了评论:

创建拉取请求

她可能是对的;我们用 Terraform 格式化工具来验证一下:

$ terraform fmt
provider.tf

看起来是格式化问题!使用git diff查看差异:

$ git diff
diff --git a/provider.tf b/provider.tf
index 59cdf2a..b54eb94 100644
--- a/provider.tf
+++ b/provider.tf
@@ -1 +1,3 @@
-provider "aws" { region = "${var.aws_region}" }
+provider "aws" {
+  region = "${var.aws_region}"
+}

我们对此感到满意,现在可以addcommit、并push。推送到远程分支将自动将我们的提交添加到拉取请求中:

$ git add provider.tf
$ git commit -m "fixed bad formatting"
[new_infrastructure b027825] fixed bad formatting
 1 file changed, 3 insertions(+), 1 deletion(-)
$ git push

现在,我们的同事可以实时看到我们已经考虑了她的评论,因为 GitHub 会自动将其标记为过时:

创建拉取请求

现在,我们的同事已经在她那边拉取了更改,并用 Terraform 尝试规划这些更改,她宣布自己也对结果满意:

创建拉取请求

应用这些更改

那我们现在就来做这个:

$ terraform apply
aws_key_pair.admin_key: Creating...
[...]
aws_instance.coreos: Creating...[...]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

我们的仓库里有什么新东西吗?看看:

$ git status
terraform.tfstate

当然,现在我们必须将我们的基础设施状态推送到拉取请求中:

$ git add terraform.tfstate
$ git commit -m "initial terraform state"
$ git push

我们的同事看到一切正常,并且她也检查了服务器运行状况。所以,现在她可以合并我们的分支,关闭拉取请求并附上消息,然后删除现在不再需要的分支:

应用更改应用更改

现在我们的代码和修复已经合并到 master 分支,同时更新了基础设施的状态,所有这些都与同事充分协作完成。

对于任何新功能,任何添加到基础设施的内容都应该遵循相同的模式:创建一个分支,插入更改,打开拉取请求,与同事讨论更改,应用更改并合并到 master。master 现在再次成为参考。

使用 Terraform 管理 GitHub

使用 Terraform 有很多服务提供商。GitHub 就是其中之一,我们将看到如何通过基础设施代码管理组织成员、不同团队以及控制仓库访问权限。这样,我们就能自动记录谁访问了什么内容。

准备中

要完成这个步骤,您需要以下内容:

  • 一个正常工作的 Terraform 安装

  • 一个 GitHub 账户(带 API 令牌)

  • 一个互联网连接

如何操作…

我们想要管理一个名为 ACME 的 GitHub 组织。以下是用户及其组别:

GitHub 用户名 GitHub 团队名称 会员级别 团队隐私
John 文档 成员 已关闭
Jane 工程 管理员 秘密

这是我们决定的有关名为 infrastructure-repository 的 Git 仓库的政策:

GitHub 团队名称 仓库权限
文档 拉取
工程 管理员

配置 GitHub

我们从创建一个 github 提供程序开始,就像在前面的配方中使用 aws 提供程序一样。文档列出了要求:一个 API 令牌和一个组织名称:

provider "github" {
  token        = "${var.github_token}"
  organization = "${var.github_organization}"
}

variables.tf 文件中设置通用变量:

variable "github_token" {
  default = "1a2b3c4d5"
  description = "GitHub API Token"
}

variable "github_organization" {
  default = "ACME Inc."
  description = "GitHub Organization Name"
}

不要忘记在 terraform.tfvars 文件中覆盖这些变量,以适应您自己的设置。

将用户添加到 GitHub 组织中

我们想要将用户名 john 添加为成员,将 jane 添加为管理员,文件名可以是 github.tf(随着组织的增长,可以将管理的 GitHub 功能拆分成多个更小的文件):

// john is a simple member of the organization
resource "github_membership" "membership_for_john" {
  username = "john"
  role     = "member"
}

// jane is an administrator of the organization
resource "github_membership" "membership_for_jane" {
  username = "jane"
  role     = "admin"
}

John 和 Jane 现在是 GitHub 组织的一部分(他们将通过电子邮件收到邀请)。

添加 GitHub 团队

我们来创建两个团队——技术写作和工程——并设置各自的隐私设置:

// An engineering team
resource "github_team" "engineering" {
  name        = "Engineering Team"
  description = "Our awesome engineers"
  privacy     = "secret"
}

// A documentation team
resource "github_team" "documentation" {
  name        = "Technical Writers Team"
  description = "Our awesome technical writers"
  privacy     = "closed"
}

将我们的两个成员分别添加到他们的团队中——Jane 在工程组,John 在文档组:

// Jane is a member of the engineering team
resource "github_team_membership" "eng_membership_jane" {
  team_id  = "${github_team.engineering.id}"
  username = "jane"
  role     = "member"
}

// John is a member of the documentation team
resource "github_team_membership" "doc_membership_john" {
  team_id  = "${github_team.documentation.id}"
  username = "john"
  role     = "member"
}

设置 Git 仓库访问权限

我们设定的政策是,工程组的成员是仓库的管理员,而技术写作人员只能拉取代码:

// technical writers can pull the repo
resource "github_team_repository" "infrastructure_doc" {
  team_id    = "${github_team.documentation.id}"
  repository = "infrastructure-repository"
  permission = "pull"
}

// engineers are admin on the repo
resource "github_team_repository" "infrastructure_eng" {
  team_id    = "${github_team.engineering.id}"
  repository = "infrastructure-repository"
  permission = "admin"
}

您刚刚设置了管理 GitHub 组织的基础配置,直接通过 Terraform!

外部监控与 StatusCake 的集成

外部监控非常有用,因为它提供了从外部、也许是从世界各地多个地方看到的您基础设施的性能洞察。我们可以建立自己的可用性监控系统,也可以使用第三方服务。StatusCake 对我们来说是一个很好的例子,因为它们有良好的 API,并且提供一个免费的服务层供我们用 Terraform 试用。我们将监控两项内容:主机延迟和 HTTP 可用性。

准备工作

要完成这个步骤,您需要以下内容:

  • 一个正常工作的 Terraform 安装

  • 一个 StatusCake 账户 (statuscake.com)

  • 可选的,由 Terraform 管理的基础设施(请参见前面的配方)

  • 一个互联网连接

如何操作…

从设置新的 statuscake 提供程序开始,就像我们之前设置 AWS 或 GitHub 一样,使用用户名和 API 密钥:

provider "statuscake" {
  username = "${var.statuscake_username}"
  apikey   = "${var.statuscake_apikey}"
}

variables.tf 中声明变量:

variable "statuscake_username" {
  default     = "changeme"
  description = "Sets the StatusCake Username"
}

variable "statuscake_apikey" {
  default     = "hackme"
  description = "Sets the StatusCake API Key"
}

同时,不要忘记将这些变量设置为您自己的值,并存入 terraform.tfvars 文件。

创建一个自动化的 ping 监控测试

我们创建一个初步的测试,一个简单的 ICMP ping 到 IP 为 1.2.3.4 的服务器,每 5 分钟一次:

resource "statuscake_test" "latency" {
  website_name = "My Server Latency"
  website_url  = "1.2.3.4"
  test_type    = "PING"
  check_rate   = 300
  paused       = false
}

注意

website_namewebsite_url 可以引用现有的 Terraform 资源。如果我们的 AWS 实例资源名为 centos,你可以像这样动态访问该值,而不是使用静态值:

website_url = "${aws_instance.centos.public_ip}"

如果你的资源有一个 count 数量,你可以遍历它,这样所有可用的实例就会自动被监控。它的工作原理是这样的:

resource "statuscake_test" "another_latency" {
  website_name = "${element(aws_instance.centos.*.public_ip, count.index)}"
  website_url  = "${element(aws_instance.centos.*.public_ip, count.index)}"
  test_type    = "PING"
  check_rate   = 300
  paused       = false
}

另一个有用的功能是在计划的停机时间中将 paused 的值切换为 true,这样你就不会收到已经知道的警报了。

创建 HTTPS 测试

一个我们非常常见的测试是 HTTP 可用性测试。它与 ICMP 检查没有太大区别;

resource "statuscake_test" "http" {
  website_name = "www.myweb.com Availability"
  website_url  = "https://www.myweb.com:443"
  test_type    = "HTTP"
  check_rate   = 300
}

第四章:使用 Terraform 自动化完整基础设施

在本章中,我们将介绍以下配方:

  • 在 Digital Ocean 上使用 Terraform 部署完整的 CoreOS 基础设施

  • 在 Google Compute Engine 上部署三层基础设施

  • 在 OpenStack 上部署 GitLab CE + CI 运行器

  • 使用 Terraform 管理 Heroku 应用和插件

  • 在裸金属上使用 Packet 创建可扩展的 Docker Swarm 集群

介绍

在本章中,我们将描述使用 Terraform 的完整基础设施,展示当一切都结合在一起时的样子,并以实际项目为例。前几章中大多数 Terraform 示例都使用了亚马逊 Web 服务(AWS),因此为了更加多样化和完整,本章将专注于其他基础设施服务,即 Digital Ocean、Google Cloud、Heroku 和 Packet。在 Digital Ocean 上,我们将构建一个完全可用并且实时监控的 CoreOS 集群,DNS 动态更新。在 Google Cloud 上,我们将构建一个三层基础设施,包含两个 HTTP 节点在负载均衡器后面以及一个独立的 MySQL 管理数据库。使用 OpenStack,我们将部署 GitLab CE 和两个 GitLab CI 运行器,使用不同的存储解决方案。我们将看到如何整合和自动化 Heroku 环境。最后,我们将使用 Packet 在裸金属上构建一个强大且可扩展的 Docker Swarm 集群,能够扩展至数百个容器。

注意

本书使用的 Terraform 版本是 0.7.4。

在 Digital Ocean 上使用 Terraform 部署完整的 CoreOS 基础设施

在此配方中,我们将从零开始构建一个完全可用的 CoreOS 集群,部署在 Digital Ocean 的纽约数据中心,使用 Terraform 和 cloud-init。我们还将使用 StatusCake 添加一些延迟监控,因此我们为在 Digital Ocean 上使用 Terraform 打下了一个良好的基础。

准备工作

要执行此配方,你将需要以下内容:

  • 一个可用的 Terraform 安装

  • 一个 Digital Ocean 账户

  • 一个 StatusCake 账户

  • 一个互联网连接

如何执行此操作…

首先,我们从在名为 providers.tf 的文件中创建 digitalocean 提供者(它只需要一个 API 密钥)开始:

provider "digitalocean" {
  token = "${var.do_token}"
}

在名为 variables.tf 的文件中声明 do_token 变量:

variable "do_token" {
  description = "Digital Ocean Token"
}

同时,别忘了在私有的 terraform.tfvars 文件中设置它:

do_token = "a1b2c3d4e5f6"

处理 SSH 密钥

我们知道,我们将需要一个 SSH 密钥来登录集群成员。对于 Digital Ocean,该资源名为 digitalocean_ssh_key。我建议我们将 SSH 密钥文件命名为 iac_admin_sshkey,并将其放置在 keys 目录中,但由于你可能喜欢其他名称,我们也可以为此使用一个变量。让我们在 keys.tf 文件中编写此内容:

resource "digitalocean_ssh_key" "default" {
  name       = "Digital Ocean SSH Key"
  public_key = "${file("${var.ssh_key_file}.pub")}"
}

variables.tf 中创建相关变量,并使用我们建议的默认值:

variable "ssh_key_file" {
  default     = "keys/iac_admin_sshkey"
  description = "Default SSH Key file"
}

如果你愿意,现在是有效覆盖 terraform.tfvars 文件中的值的时候了:

ssh_key_file = "./keys/my_own_key"

创建 CoreOS 集群成员

这是我们基础设施的核心:三个节点运行在纽约市数据中心 NYC1,启用了私有网络,未激活备份(如果需要,可以设置为 true!),我们之前创建的 SSH 密钥,以及一个用于初始化配置的 cloud-init 文件。在 Digital Ocean 中,虚拟机被称为 droplet,所以启动 droplet 的资源是 digitalocean_droplet。所有变量名都与我们刚才列举的内容相关:

resource "digitalocean_droplet" "coreos" {
  image              = "${var.coreos_channel}"
  count              = "${var.cluster_nodes}"
  name               = "coreos-${count.index+1}"
  region             = "${var.do_region}"
  size               = "${var.do_droplet_size}"
  ssh_keys           = ["${digitalocean_ssh_key.default.id}"]
  private_networking = true
  backups            = false
  user_data          = "${file("cloud-config.yml")}"
}

variables.tf 文件中声明所有变量,并设置一些良好的默认值(最小 512 MB droplet,三节点集群),以及我们想要覆盖的一些默认值(AMS3 数据中心或稳定版 CoreOS 渠道):

variable "do_region" {
  default     = "ams3"
  description = "Digital Ocean Region"
}

variable "do_droplet_size" {
  default     = "512mb"
  description = "Droplet Size"
}

variable "coreos_channel" {
  default     = "coreos-stable"
  description = "CoreOS Channel"
}

variable "cluster_nodes" {
  default     = "3"
  description = "Number of nodes in the cluster"
}

这是我们在 terraform.tfvars 中的覆盖值(但你可以随意使用自己的值,比如使用不同的数据中心或 CoreOS 版本):

do_region = "nyc1"
coreos_channel = "coreos-beta"

添加有用的输出

如果能自动生成一些关于如何连接到我们的 CoreOS 集群的文档行,那就太棒了。由于我们可以通过 Terraform 输出做到这一点,让我们从 outputs.tf 中这个例子开始。它构造了一个带有动态信息的 SSH 命令行,我们可以轻松使用(它只是遍历每个可用的 digitalocean_droplet.coreos.*):

output "CoreOS Cluster Members" {
 value = "${formatlist("ssh core@%v -i ${var.ssh_key_file}", digitalocean_droplet.coreos.*.ipv4_address)}"
}

输出将如下所示:

CoreOS Cluster Members = [
 ssh core@192.241.128.44 -i ./keys/iac_admin_sshkey,
 ssh core@192.241.130.33 -i ./keys/iac_admin_sshkey,
 ssh core@198.199.120.212 -i ./keys/iac_admin_sshkey
]

动态 DNS 集成

Digital Ocean 的一个吸引人的特点是简单的 DNS 集成。例如,如果我们的域名是 infrastructure-as-code.org,而我们启动了一个 blog droplet,我们将自动将其注册为公有 DNS 名称 blog.infrastructure-as-code.org。非常简单和动态!为了让 Digital Ocean 管理我们的域名,我们需要访问我们的域名注册商(即购买域名的地方),并配置我们的域名由 Digital Ocean 管理,使用他们的自有 DNS 服务器,具体如下:

  • ns1.digitalocean.com

  • ns2.digitalocean.com

  • ns3.digitalocean.com

完成此先决条件后,我们在 dns.tf 文件中声明我们的域名,使用 digitalocean_domain 资源,自动使用 cluster_domainname 变量作为域名,并进行初始 IP 地址匹配,这个 IP 地址我们可以设置为已知值或任意一个 droplet:

resource "digitalocean_domain" "cluster_domainname" {
  name       = "${var.cluster_domainname}"
  ip_address = "${digitalocean_droplet.coreos.0.ipv4_address}"
}

variables.tf 中添加新变量:

variable "cluster_domainname" {
  default     = "infrastructure-as-code.org"
  description = "Domain to use"
}

别忘了在 terraform.tfvars 中根据需要覆盖它。

下一步是自动将每个 droplet 注册到 DNS。通过遍历每个 droplet,提取它们的 nameipv4_address 属性,我们将把这个 digitalocean_record 资源加入到配置中:

resource "digitalocean_record" "ipv4" {
  count  = "${var.cluster_nodes}"
  domain = "${digitalocean_domain.cluster_domainname.name}"
  type   = "A"
  name   = "${element(digitalocean_droplet.coreos.*.name, count.index)}"
  value  = "${element(digitalocean_droplet.coreos.*.ipv4_address, count.index)}"
}

这将自动将每个 droplet 注册为名称为 core-[1,2,3].mydomain.com,以便更方便地访问和引用。

如果你愿意,你可以直接在输出文件(outputs.tf)中访问该资源的 fqdn 属性:

output "CoreOS Cluster Members DNS" {
  value = "${formatlist("ssh core@%v -i ${var.ssh_key_file}", digitalocean_record.ipv4.*.fqdn)}"
}

集成 cloud-init

我们需要为 CoreOS 集群构建一个完全可用的 cloud-config.yml 文件。有关 cloud-config.yml 文件的更多信息,特别是如何配置 CoreOS,参考本书的 第五章,使用 Cloud-Init 配置最后一公里

我们需要的完全可用的 CoreOS 集群包括以下内容:

  • 在本地网络接口($private_ipv4)上的工作 etcd 集群

  • 在本地网络接口($private_ipv4)上的工作 fleet 集群

    Fleet 是一个分布式初始化系统。你可以把它看作是整个集群的 systemd。

要配置 etcd,首先需要获取一个新的令牌。这个令牌是唯一的,可以通过不同的渠道分发。可以通过 coreos.com/os/docs/latest/cluster-discovery.html 等等服务轻松获取。然后我们将启动两个单元——etcd 和 fleet。

$ curl -w "\n" 'https://discovery.etcd.io/new?size=3'
https://discovery.etcd.io/b04ddb7ff454503a66ead486b448afb7

仔细注意这个 URL,并将其复制粘贴到以下 cloud-config.yml 文件中:

#cloud-config
# https://coreos.com/validate/
coreos:
  etcd2:
    discovery: "https://discovery.etcd.io/b04ddb7ff454503a66ead486b448afb7"
    advertise-client-urls: "http://$private_ipv4:2379"
    initial-advertise-peer-urls: "http://$private_ipv4:2380"
    listen-client-urls: http://0.0.0.0:2379
    listen-peer-urls: http://$private_ipv4:2380
  units:
    - name: etcd2.service
      command: start
    - name: fleet.service
      command: start
  fleet:
    public-ip: "$public_ipv4"
    metadata: "region=ams,provider=digitalocean"

这就足够在 CoreOS 上启动一个 etcd + fleet 集群。第五章,配置最后一公里

与 Cloud-Init 配置最后一公里,深入了解 cloud-init。

集成动态 StatusCake 监控

我们可以复用之前章节中的知识,通过使用免费的 StatusCake 账户 (statuscake.com),轻松地为 CoreOS 集群的主机集成完整的延迟监控。

首先在 providers.tf 中配置提供商:

provider "statuscake" {
  username = "${var.statuscake_username}"
  apikey   = "${var.statuscake_apikey}"
}

variables.tf 中声明所需的变量:

variable "statuscake_username" {
  default     = "changeme"
  description = "StatusCake Account Username"
}

variable "statuscake_apikey" {
  default     = "hackme"
  description = "StatusCake Account API Key"
}

此外,在 terraform.tfvars 中用你自己的值进行覆盖。

现在,我们可以使用 statuscake_test 资源通过遍历每个 digitalocean_droplet.coreos.* 资源值,在每个 Droplet 上启用即时延迟(ping)监控:

resource "statuscake_test" "coreos_cluster" {
  count        = "${var.cluster_nodes}"
  website_name = "${element(digitalocean_droplet.coreos.*.name, count.index)}.${var.cluster_domainname}"
  website_url  = "${element(digitalocean_droplet.coreos.*.ipv4_address, count.index)}"
  test_type    = "PING"
  check_rate   = 300
  paused       = false
}

是时候运行 terraform apply 了:

$ terraform apply
[...]

CoreOS Cluster Members = [
 ssh core@159.203.189.142 -i ./keys/iac_admin_sshkey,
 ssh core@159.203.189.146 -i ./keys/iac_admin_sshkey,
 ssh core@159.203.189.131 -i ./keys/iac_admin_sshkey
]
CoreOS Cluster Members DNS = [
 ssh core@coreos-1.mydomain.com -i ./keys/iac_admin_sshkey,
 ssh core@coreos-2.mydomain.com -i ./keys/iac_admin_sshkey,
 ssh core@coreos-3.mydomain.com -i ./keys/iac_admin_sshkey
]

确认我们可以通过命令行连接到一个成员,查看输出:

$ ssh core@159.203.189.142 -i ./keys/iac_admin_sshkey

验证 etcd 集群的健康状况:

$ core@coreos-1 ~ $ etcdctl cluster-health
member 668f889d5f96b578 is healthy: got healthy result from http://10.136.24.178:2379
member c8e8906e0f3f63be is healthy: got healthy result from http://10.136.24.176:2379
member f3b53735aca3062e is healthy: got healthy result from http://10.136.24.177:2379
cluster is healthy

检查所有 fleet 成员是否正常:

core@coreos-1 ~ $ fleetctl list-machines
MACHINE         IP              METADATA
24762c02...     159.203.189.146 provider=digitalocean,region=ams
3b4b0792...     159.203.189.142 provider=digitalocean,region=ams
59e15b88...     159.203.189.131 provider=digitalocean,region=ams

享受吧,在不到一分钟的时间里,你就可以通过完全自动化的 Terraform 代码,使用带有基本监控的 CoreOS 集群!

在 Google Compute Engine 上配置三层基础设施

我们将在 Google Compute Engine 上配置一个三层负载均衡的 web 基础设施,使用两个 CentOS 7.2 服务器作为 web 服务器,以及一个主 Google MySQL 实例。MySQL 实例只允许来自这两个 web 服务器的连接(并且需要有效的凭证),所有三个实例(SQL 和 HTTP)都将通过一个单一的 公司 网络(我们公司的网络)进行访问。拓扑结构如下:

在 Google Compute Engine 上配置三层基础设施

准备工作

要按步骤执行这个配方,你需要以下内容:

  • 一个可用的 Terraform 安装

  • 一个 Google Compute Engine 项目账号

  • 一条互联网连接

如何执行...

我们需要首先从控制台获取我们的凭据。

为 Google 项目生成 API 凭据。

转到您的 Google Cloud 项目,在 API 管理器 中选择 凭据 | 创建凭据 | 服务帐号密钥。现在从下拉列表中选择 Compute Engine 默认服务帐号,格式选择 JSON。将此文件保存为 account.json,放在基础设施存储库的根目录。

variables.tf 文件中创建变量来定义我们的凭据文件,存储我们正在运行的区域和 Google Compute 项目名称:

variable "credentials_file" {
  default     = "account.json"
  description = "API credentials JSON file"
}
variable "region" {
  default     = "europe-west"
  description = "Region name"
}
variable "project_name" {
  default     = "default-project"
  description = "Project ID to use"
}

不要忘记在 terraform.tfvars 中覆盖这些值(如果需要):

project_name = "iac-book-infra"
region = "us-east1"

现在,在 providers.tf 文件中,添加 google 供应商:

provider "google" {
  credentials = "${file("${var.credentials_file}")}"
  project     = "${var.project_name}"
  region      = "${var.region}"
}

我们的 google 供应商现在已配置完成!

创建 Google Compute HTTP 实例。

这是我们为这些 HTTP 主机的需求清单:

  • 我们需要两个实例。

  • 它们的类型是 n1-standard-1(3.75 GB RAM,1 vCPU)。

  • 它们的区域和区域是:us-east1-d。

  • 它们正在运行 CentOS 7.2(官方镜像为:centos-cloud/centos 7)。

  • 默认的 SSH 用户名是 centos

  • 我们已知的 SSH 密钥是 (keys/admin_key)。

  • 我们需要一个完全更新的系统,并安装并运行 Docker。

让我们在 variables.tf 文件中为所有这些要求定义通用变量:

variable "machine_type" {
  default     = "f1-micro"
  description = "Machine type"
}

variable "zone" {
  default     = "c"
  description = "Region Zone"
}

variable "disk_image" {
  default     = "centos-cloud/centos-7"
  description = "Disk image"
}

variable "ssh_key" {
  default     = "keys/admin_key"
  description = "SSH key"
}

variable "ssh_username" {
  default     = "root"
  description = "The SSH username to use"
}

variable "www_servers" {
  default = "2"
  description = "Amount of www servers"
}

现在让我们在 terraform.tfvars 中覆盖刚设置的通用值:

machine_type = "n1-standard-1"
zone = "d"
ssh_username = "centos"

Google Cloud 实例通过 google_compute_instance 资源从 Terraform 调用:

让我们在此资源中添加我们已知的内容:

resource "google_compute_instance" "www" {
  count        = "${var.www_servers}"
  name         = "www-${count.index+1}"
  machine_type = "${var.machine_type}"
  zone         = "${var.region}-${var.zone}"

  disk {
    image = "${var.disk_image}"
  }

  metadata {
    ssh-keys = "${var.ssh_username}:${file("${var.ssh_key}.pub")}"
  }
}

这可能已经足够了,但我们希望走得更远。

例如,稍后我们将添加一个防火墙,其规则将应用于由其标签定义的目标。现在让我们立即添加一个标签,以便稍后使用它:

tags         = ["www"]

我们必须配置网络。在我们的情况下需要一个公共 IPv4 地址,因为我们需要从外部通过 SSH 访问服务器。我们也可以选择不公开暴露服务器而使用堡垒主机。要在默认网络中创建一个网络接口,映射到公共 IPv4 后面,请将以下内容添加到 google_compute_instance 资源中:

  network_interface {
    network = "default"

    access_config {
      nat_ip = ""
    }
  }

让我们最后通过 remote-exec 配置项自动连接到每个实例并完全更新它,然后安装、启用和启动 Docker。我们将正确配置 remote-exec 与正确的 SSH 用户名和私钥:

provisioner "remote-exec" {
    connection {
      user        = "${var.ssh_username}"
      private_key = "${file("${var.ssh_key}")}"
    }

    inline = [
      "sudo yum update -y",
      "sudo yum install -y docker",
      "sudo systemctl enable docker",
      "sudo systemctl start docker",
    ]
  }

我们终于完成了,我们的两个实例已自动配置完成!

创建一个 Google Compute 防火墙规则。

我们的目标很简单:我们希望允许任何人(0.0.0.0/0)通过 HTTP(TCP 端口 80)访问任何标记为 www 的默认网络中的实例。为此,让我们使用 google_compute_firewall 资源:

resource "google_compute_firewall" "fw" {
  name    = "www-firewall"
  network = "default"

  allow {
    protocol = "tcp"
    ports    = ["80"]
  }

  source_ranges = ["0.0.0.0/0"]
  target_tags   = ["www"]
}

负载均衡 Google Compute 实例。

为了在我们的两个实例之间负载均衡请求,我们需要创建一个主机池,其成员资格将通过简单的健康检查来管理:每秒对 / 进行一次 HTTP GET 请求,立即超时(1 秒),并在发生 3 次错误后移除。我们可以在名为 pool.tf 的文件中使用 google_compute_http_health_check 资源来实现:

resource "google_compute_http_health_check" "www" {
  name                = "http"
  request_path        = "/"
  check_interval_sec  = 1
  healthy_threshold   = 1
  unhealthy_threshold = 3
  timeout_sec         = 1
}

随意将这些值转换为变量,以便在你的环境中进行更好的调优!

现在,让我们定义池,池是由健康检查的结果和实例的包含情况定义的。这可以通过 google_compute_target_pool 资源来实现:

resource "google_compute_target_pool" "www" {
  name          = "www-pool"
  instances     = ["${google_compute_instance.www.*.self_link}"]
  health_checks = ["${google_compute_http_health_check.www.name}"]
}

注意

self_link 属性返回资源的 URI。

现在我们已经有了带健康检查的主机池,接下来创建负载均衡器本身。我们可以使用 google_compute_forwarding_rule 资源来实现,只需指向我们之前创建的主机池即可。将以下内容添加到 loadbalancer.tf 文件中:

resource "google_compute_forwarding_rule" "http" {
  name       = "http-lb"
  target     = "${google_compute_target_pool.www.self_link}"
  port_range = "80"
}

创建一个 Google MySQL 数据库实例

我们的典型目标应用需要一个数据库来存储和访问数据。这里我们不深入讨论数据库复制,但它也可以通过 Terraform 在 Google Cloud 上轻松实现。

注意

仔细检查你是否在 Google Cloud 控制台中激活了 SQL API:console.cloud.google.com/apis/library。默认情况下,它是没有激活的。

这是我们关于 MySQL 数据库的检查清单:

  • 它运行在 us-east1 区域

  • 它运行 MySQL 5.6

  • 它的类型是D2(1 GB 内存)

  • 我们自己的网络和两个 HTTP 服务器都可以访问它

  • 我们希望创建一个名为 app_db 的数据库

  • 我们希望一个具有密码的用户能够从 HTTP 服务器连接

让我们把所有这些变量放在 variables.tf 文件中:

variable "db_type" {
  default     = "D0"
  description = "Google SQL DB type"
}

variable "db_authorized_network" {
  default     = "0.0.0.0/0"
  description = "A corporate network authorized to access the DB"
}

variable "db_username" {
  default     = "dbadmin"
  description = "A MySQL username"
}

variable "db_password" {
  default     = "changeme"
  description = "A MySQL password"
}

variable "db_name" {
  default     = "db_name"
  description = "MySQL database name"
}

别忘了在 terraform.tfvars 中覆盖每个通用值:

db_authorized_network = "163.172.161.158/32"
db_username = "sqladmin"
db_password = "pwd1970"
db_name = "app_db"
db_type = "D2"

现在我们可以使用 google_sql_database_instance 资源在 db.tf 文件中构建数据库:

resource "google_sql_database_instance" "master" {
  name             = "mysql-mastr-1"
  region           = "${var.region}"
  database_version = "MYSQL_5_6"

  settings = {
    tier              = "${var.db_type}"
    activation_policy = "ALWAYS"         // vs "ON_DEMAND"
    pricing_plan      = "PER_USE"        // vs "PACKAGE"

    ip_configuration {
      ipv4_enabled = true

      authorized_networks {
        name  = "authorized_network"
        value = "${var.db_authorized_network}"
      }

      authorized_networks {
        name  = "${google_compute_instance.www.0.name}"
        value = "${google_compute_instance.www.0.network_interface.0.access_config.0.assigned_nat_ip}"
      }

      authorized_networks {
        name  = "${google_compute_instance.www.1.name}"
        value = "${google_compute_instance.www.1.network_interface.0.access_config.0.assigned_nat_ip}"
      }
    }
  }
}

注意

pricing_plan "PACKAGE" 更适合长期使用的数据库。同时,authorized_network 块当前不支持 count 值,因此我们无法动态迭代每个 HTTP 主机。目前,我们必须复制该块,但在未来的 Terraform 版本中,这种情况可能会改变。

现在,让我们使用 google_sql_database 资源创建一个数据库:

resource "google_sql_database" "db" {
  name     = "${var.db_name}"
  instance = "${google_sql_database_instance.master.name}"
}

最后,通过创建具有主机限制的 SQL 用户来完成。与 authorized_network 块类似,google_sql_user 资源当前还不支持 count 值,因此我们暂时需要为每个 HTTP 服务器复制代码:

resource "google_sql_user" "user_www_1" {
  name     = "${var.db_username}"
  password = "${var.db_password}"
  instance = "${google_sql_database_instance.master.name}"
  host     = "${google_compute_instance.www.0.network_interface.0.access_config.0.assigned_nat_ip}"
}

resource "google_sql_user" "user_www_2" {
  name     = "${var.db_username}"
  password = "${var.db_password}"
  instance = "${google_sql_database_instance.master.name}"
  host     = "${google_compute_instance.www.1.network_interface.0.access_config.0.assigned_nat_ip}"
}

添加一些有用的输出

拥有一些有用的信息,如所有实例和服务的 IP、用户名和密码,会非常棒。让我们在 outputs.tf 中添加一些输出:

output "HTTP Servers" {
  value = "${join(" ", google_compute_instance.www.*.network_interface.0.access_config.0.assigned_nat_ip)}"
}

output "MySQL DB IP" {
  value = "${google_sql_database_instance.master.ip_address.0.ip_address}"
}

output "Load Balancer Public IPv4" {
  value = "${google_compute_forwarding_rule.http.ip_address}"
}

output "DB Credentials" {
  value = "Username=${var.db_username} Password=${var.db_password}"
}

到这里了!

$ terraform apply
[...]
Outputs:

DB Credentials = Username=sqladmin Password=pwd1970
HTTP Servers = 104.196.180.192 104.196.157.246
Load Balancer Public IPv4 = 104.196.45.46
MySQL DB IP = 173.194.111.120

只需在 HTTP 服务器上部署我们的应用程序,任务就完成了!为了测试负载均衡器和 HTTP 实例,您可以简单地在每个服务器上部署 NGINX 容器并查看流量:

$ sudo docker run -it --rm -p 80:80 --name web nginx

在 OpenStack 上部署 GitLab CE 和 CI 运行器

OpenStack 是一个非常流行的开源云计算解决方案。许多提供商都基于它,你也可以在自己的数据中心中构建它。在这个示例中,我们将使用 OVH 提供的公共 OpenStack,位于加拿大蒙特利尔,但你也可以使用任何其他 OpenStack。每个自定义部署在实现上有所不同,但我们将坚持使用非常稳定的功能。

我们将启动一个运行 Ubuntu LTS 16.04 的计算实例来托管 GitLab,使用一个专用的块设备来存储 Docker,并且还会启动两个计算实例来作为 GitLab CI 运行器。安全组将允许所有人访问 HTTP,但仅允许来自公司网络已知 IP 的 SSH 访问。为了存储我们的构建或发布,我们将创建一个 容器,在 OpenStack 术语中,这就是对象存储。AWS S3 的等效物是 存储桶

准备工作

要执行这个操作步骤,你将需要以下资源:

  • 一个有效的 Terraform 安装。

  • 一个 OpenStack 账户,使用任何 OpenStack 提供商(公有或私有)。这个配方使用的是 OVH 公共 OpenStack 的账户(www.ovh.com/us/)。

  • 一个互联网连接。

如何操作…

我们将创建:

  • 三个计算实例(虚拟机)

  • 一个密钥对

  • 一个块存储设备

  • 一个安全组

  • 一个对象存储桶

配置 OpenStack 提供商

让我们先配置 OpenStack 提供商。我们需要四个信息:用户名、密码、OpenStack 租户名称和 OpenStack 认证端点 URL。为了使代码更加动态,我们将在 variables.tf 文件中为这些信息创建变量:

variable "user_name" {
  default     = "changeme"
  description = "OpenStack username"
}

variable "password" {
  default     = "hackme"
  description = "OpenStack password"
}

variable "tenant_name" {
  default     = "123456"
  description = "OpenStack Tenant name"
}

variable "auth_url" {
  default     = "https://openstack.url/v2.0"
  description = "OpenStack Authentication Endpoint"
}

别忘了在 terraform.tfvars 文件中用你自己的值覆盖默认值!

user_name   = "***"
tenant_name = "***"
password    = "***"
auth_url    = "https://auth.cloud.ovh.net/v2.0/"

现在我们准备好开始了。

在 OpenStack 上创建一个密钥对

为了在实例上进行身份验证,我们需要将密钥对的公钥部分提供给 OpenStack。可以使用 openstack_compute_keypair_v2 资源来完成此操作,指定我们希望在哪个区域生成密钥,以及密钥存放的位置。让我们在 variables.tf 中添加这两个变量:

variable "region" {
  default     = "GRA1"
  description = "OpenStack Region"
}

variable "ssh_key_file" {
  default     = "keys/admin_key"
  description = "Default SSH key"
}

接下来,在 terraform.tfvars 文件中覆盖它们:

region      = "BHS1"

现在我们可以在 keys.tf 文件中构建我们的资源:

resource "openstack_compute_keypair_v2" "ssh" {
  name       = "Admin SSH Public Key"
  region     = "${var.region}"
  public_key = "${file("${var.ssh_key_file}.pub")}"
}

在 OpenStack 上创建安全组

我们知道我们的要求是允许来自任何地方的 HTTP(TCP/80)访问,但仅允许来自一个公司网络的 SSH(TCP/22)访问。现在就将其添加到 variables.tf 中,以便我们可以使用:

variable "allowed_network" {
  default = "1.2.3.4/32"
  description = "The Whitelisted Corporate Network"
}

别忘了在 terraform.tfvars 中覆盖为你自己的网络配置。

让我们创建一个安全组,允许我们区域中的所有人访问 HTTP,使用 openstack_compute_secgroup_v2 资源,并将其放在 security.tf 文件中:

 resource "openstack_compute_secgroup_v2" "http-sg" {
  name        = "http-sg"
  description = "HTTP Security Group"
  region      = "${var.region}"

  rule {
    from_port   = 80
    to_port     = 80
    ip_protocol = "tcp"
    cidr        = "0.0.0.0/0"
  }
}

按照相同的模式,再创建一个安全组,仅允许来自我们公司网络的 SSH 访问:

resource "openstack_compute_secgroup_v2" "base-sg" {
  name        = "base-sg"
  description = "Base Security Group"
  region      = "${var.region}"

  rule {
    from_port   = 22
    to_port     = 22
    ip_protocol = "tcp"
    cidr        = "${var.allowed_network}"
  }
}

在 OpenStack 上创建块存储卷

根据我们的需求,我们希望为 GitLab 实例提供一个专用的卷,用于 Docker。我们决定这个卷的大小为 10 GB。该卷将由计算实例挂载为一个专用设备(可能是 /dev/vdb)。整个过程将使用 openstack_blockstorage_volume_v2 资源完成:

resource "openstack_blockstorage_volume_v2" "docker" {
  region      = "${var.region}"
  name        = "docker-vol"
  description = "Docker volume"
  size        = 10
}

outputs.tf 中添加一个简单的输出,以便我们知道存储卷的描述、名称和大小:

output "Block Storage" {
  value = "${openstack_blockstorage_volume_v2.docker.description}: ${openstack_blockstorage_volume_v2.docker.name}, ${openstack_blockstorage_volume_v2.docker.size}GB"
}

我们现在已经具备启动计算实例的所有要求。

在 OpenStack 上创建计算实例

现在是创建实例的时候了。我们知道它们必须是 Ubuntu 16.04,并且我们决定了一个 flavor 名称:flavor 是机器的类型,每个 OpenStack 安装的类型可能不同。在我们的情况下,它被命名为 vps-ssd-1。我们来在 variables.tf 文件中定义一些默认值:

variable "image_name" {
  default     = "CentOS"
  description = "Default OpenStack image to boot"
}

variable "flavor_name" {
  default     = "some_flavor"
  description = "OpenStack instance flavor"
}

同时,在 terraform.tfvars 中使用合适的值覆盖它们:

image_name  = "Ubuntu 16.04"
flavor_name = "vps-ssd-1"

要创建一个计算实例,我们使用名为 openstack_compute_instance_v2 的资源。此资源需要我们之前声明的所有参数(名称、镜像、flavor、SSH 密钥和安全组)。让我们在 instances.tf 中尝试一下:

resource "openstack_compute_instance_v2" "gitlab" {
  name            = "gitlab"
  region          = "${var.region}"
  image_name      = "${var.image_name}"
  flavor_name     = "${var.flavor_name}"
  key_pair        = "${openstack_compute_keypair_v2.ssh.name}"
  security_groups = ["${openstack_compute_secgroup_v2.base-sg.name}", "${openstack_compute_secgroup_v2.http-sg.name}"]
}

要附加我们创建的块存储卷,我们需要在资源中添加一个 volume {} 块:

  volume {
    volume_id = "${openstack_blockstorage_volume_v2.docker.id}"
    device    = "/dev/vdb"
  }

现在,稍显有趣但可选的部分是需要的命令,用于格式化卷、将其挂载到正确的位置、全面更新系统、安装 Docker,并运行 GitLab CE 容器。这是通过 remote-exec 提供器完成的,并且需要 SSH 用户名。我们将其设置为 variables.tf

variable "ssh_username" {
  default     = "ubuntu"
  description = "SSH username"
}

现在我们可以输入所有命令,当实例准备好时它们将被执行:

  provisioner "remote-exec" {
    connection {
      user        = "${var.ssh_username}"
      private_key = "${file("${var.ssh_key_file}")}"
    }

    inline = [
      "sudo mkfs.ext4 /dev/vdb",
      "sudo mkdir /var/lib/docker",
      "sudo su -c \"echo '/dev/vdb /var/lib/docker ext4 defaults 0 0' >> /etc/fstab\"",
      "sudo mount -a",
      "sudo apt update -y",
      "sudo apt upgrade -y",
      "sudo apt install -y docker.io",
      "sudo systemctl enable docker",
      "sudo systemctl start docker",
      "sudo docker run -d -p 80:80 --name gitlab gitlab/gitlab-ce:latest",
    ]
  }

outputs.tf 文件中添加一个简单的输出,以便我们可以轻松查看 GitLab 实例的公网 IP:

output "GitLab Instance" {
  value = "gitlab: http://${openstack_compute_instance_v2.gitlab.access_ip_v4}"
}

这些 runner 实例是相同的,但稍微简单一些,因为它们不需要本地存储卷。不过,我们需要在 variables.tf 中设置所需的 runner 数量:

variable "num_runners" {
  default     = "1"
  description = "Number of GitLab CI runners"
}

terraform.tfvars 中覆盖值,以便有更多的 runners:

num_runners = "2"

现在我们可以使用 openstack_compute_instance_v2 资源来创建我们的 runner 实例:

resource "openstack_compute_instance_v2" "runner" {
  count           = "${var.num_runners}"
  name            = "gitlab-runner-${count.index+1}"
  region          = "${var.region}"
  image_name      = "${var.image_name}"
  flavor_name     = "${var.flavor_name}"
  key_pair        = "${openstack_compute_keypair_v2.ssh.name}"
  security_groups = ["${openstack_compute_secgroup_v2.base-sg.name}", "${openstack_compute_secgroup_v2.http-sg.name}"]

  provisioner "remote-exec" {
    connection {
      user        = "${var.ssh_username}"
      private_key = "${file("${var.ssh_key_file}")}"
    }

    inline = [
      "sudo apt update -y",
      "sudo apt upgrade -y",
      "sudo apt install -y docker.io",
      "sudo systemctl enable docker",
      "sudo systemctl start docker",
      "sudo docker run -d --name gitlab-runner -v /var/run/docker.sock:/var/run/docker.sock gitlab/gitlab-runner:latest",
    ]
  }
}

这将启动一个 GitLab CI runner,因此可以通过 GitLab 触发构建!(不过还有最后一步配置,这超出了本书的范围,但我们需要通过执行 docker exec -it gitlab-runner gitlab-runner register 来将每个 runner 注册到主 GitLab 实例,并回答相应问题)。

outputs.tf 中添加以下输出,以便我们知道所有 runner 的 IP 地址:

output "GitLab Runner Instances" {
  value = "${join(" ", openstack_compute_instance_v2.runner.*.access_ip_v4)}"
}

在 OpenStack 上创建对象存储容器

这个非常简单:它只需要一个名称和一个区域。由于它是用来存储发布的,我们将其命名为 releases,使用 openstack_objectstorage_container_v1 资源,在 objectstorage.tf 文件中:

resource "openstack_objectstorage_container_v1" "releases" {
  region = "${var.region}"
  name   = "releases"
}

outputs.tf 中添加一个简单的输出,以便我们记住 Object Storage 容器的名称:

output "Object Storage" {
  value = "Container name: ${openstack_objectstorage_container_v1.releases.name}"
}

应用

最后,执行 terraform apply

$ terraform apply
[...]

Outputs:

Block Storage = Docker volume: docker-vol, 10GB
GitLab Instance = gitlab: http://158.69.95.202
GitLab Runner Instances = 158.69.95.200 158.69.95.201
Object Storage = Container name: releases

连接到 GitLab 实例,享受运行的过程(在 GitLab token 注册之后)!

使用 Terraform 管理 Heroku 应用和附加组件

Heroku 是一个流行的 平台即服务 (PaaS),在这里你对基础设施没有任何控制权。但即便是这样的平台,Terraform 也能为你自动化和管理许多工作,剩下的交给 Heroku 来处理。我们将创建一个应用程序(一个简单的 GitHub Hubot:hubot.github.com/),但你也可以使用自己的应用程序。在这个应用程序的基础上,我们将自动添加一个 Heroku 插件(redis)并部署所有内容。

准备工作

要完成此教程,你需要以下资源:

  • 一个工作正常的 Terraform 安装

  • 一个 Heroku 账户 (www.heroku.com/)

  • 一个可选的 Slack 令牌

  • 需要互联网连接

如何操作…

首先:我们需要定义 Heroku 提供者。它由一个电子邮件地址和 API 密钥组成。让我们在 variables.tf 中为此创建通用变量:

variable "heroku_email" {
  default     = "user@mail.com"
  description = "Heroku account email"
}

variable "heroku_api_key" {
  default     = "12345"
  description = "Heroku account API key"
}

别忘了在 terraform.tfvars 中重写这些变量:

heroku_email = "me@gmail.com"
heroku_api_key = "52eef461-5e34-47d8-8191-ede7ef6cf9bg"

现在我们可以用已有的信息创建 Heroku 提供者:

provider "heroku" {
  email   = "${var.heroku_email}"
  api_key = "${var.heroku_api_key}"
}

使用 Terraform 创建 Heroku 应用程序

我们不通过点击 Heroku 来创建应用程序,而是直接从 Terraform 中完成。我们希望在欧洲运行我们的应用程序,并且希望 Hubot 连接到 Slack,因此我们还需要提供一个 Slack 令牌。让我们从在 variables.tf 中创建默认值开始:

variable "heroku_region" {
  default = "us"
  description = "Heroku region"
}

variable "slack_token" {
  default = "xoxb-1234-5678-1234-5678"
  description = "Slack Token"
}

现在我们可以使用 heroku_app 资源,在 heroku.tf 中创建我们的第一个 Heroku 应用程序及其变量:

resource "heroku_app" "hubot" {
  name   = "iac-book-hubot"
  region = "${var.heroku_region}"

  config_vars {
    HUBOT_SLACK_TOKEN = "${var.slack_token}"
  }
}

就这样!看起来很简单。

outputs.tf 中添加一些输出,以便我们能获取更多关于应用程序的信息,比如 Heroku 应用程序 URL 和环境变量:

output "heroku URL" {
  value = "${heroku_app.hubot.web_url}"
}

output "heroku_vars" {
  value = "${heroku_app.hubot.all_config_vars}"
}

output "heroku Git URL" {
  value = "${heroku_app.hubot.git_url}"
}

使用 Terraform 添加 Heroku 插件

一些插件需要 Redis 来存储数据。我们不通过 Web 应用程序启用插件,而是使用 heroku_addon 资源。它需要一个应用程序的引用来链接插件,并且还需要一个计划(hobby-dev 是免费的,所以我们使用这个计划):

resource "heroku_addon" "redis" {
  app  = "${heroku_app.hubot.name}"
  plan = "heroku-redis:hobby-dev"
}

使用 Heroku 和 Terraform

本书不涉及 Heroku 的使用,但让我们应用这段 terraform 代码:

$ terraform apply
[...]
Outputs:

heroku Git URL = https://git.heroku.com/iac-book-hubot.git
heroku URL = https://iac-book-hubot.herokuapp.com/
heroku_vars = {
  HUBOT_SLACK_TOKEN = xoxb-1234-5678-91011-00e4dd
}

如果你还没有准备好要在 Heroku 上部署的应用程序,那就尝试部署 GitHub 的聊天机器人 Hubot 吧。它是一个可以在 Heroku 上直接使用的简单应用程序。快速浏览 Hubot 文档后,让我们安装 Hubot 生成器:

$ npm install -g yo generator-hubot

创建一个新的 hubot

$ mkdir src; cd src
$ yo hubot

回答问题,完成后,使用常规的 heroku 命令,为我们的 Heroku 应用程序添加 Heroku git 远程连接:

$ heroku git:remote --app iac-book-hubot

现在你可以使用 git push heroku 来看到应用程序被部署,整个过程都在 Terraform 的控制下。

在裸金属服务器上通过 Packet 创建可扩展的 Docker Swarm 集群

IaaS 云服务通过广泛使用虚拟机已经变得非常流行。最近的举措针对裸金属服务器提供 API 服务,使得我们可以同时享受两者的优势——通过 API 按需获取服务器,并通过直接访问硬件获得卓越的性能。www.packet.net/是一个裸金属 IaaS 提供商(www.scaleway.com/是另一个),并且 Terraform 对此提供了极好的支持,拥有强大的全球网络。几分钟内,我们就能获得新的硬件并将其连接到网络中。

我们将构建一个完全自动化且可扩展的 Docker Swarm 集群,以便在裸金属上运行高度可扩展和高性能的工作负载:这个设置可以在几分钟内扩展数千个容器。该集群由类型 0机器组成(4 核和 8GB RAM),包括 1 个管理节点和 2 个工作节点,总共 12 核和 24GB RAM,但如果需要,我们可以使用更高性能的机器:同样的集群如果使用类型 2机器,将拥有 72 核和 768GB RAM(不过价格会相应调整)。

准备工作

要逐步完成这个步骤,你将需要以下内容:

  • 一个可用的 Terraform 安装环境

  • 一个 Packet.net 账户和 API 密钥

  • 需要一个互联网连接

如何操作……

首先,我们通过 API 密钥(身份验证 token)创建packet提供者。在variables.tf文件中创建变量:

variable "auth_token" {
  default     = "1234"
  description = "API Key Auth Token"
}

同时,确保在terraform.tfvars中覆盖真实的 token 值:

auth_token = "JnN7e6tPMpWNtGcyPGT93AkLuguKw2eN"

使用 Terraform 创建 Packet 项目

Packet 和一些其他 IaaS 提供商一样,使用项目的概念来组织机器。让我们在projects.tf文件中创建一个名为Docker Swarm Bare Metal Infrastructure的项目,因为这正是我们想做的:

resource "packet_project" "swarm" {
  name = "Docker Swarm Bare Metal Infrastructure"
}

这样,如果你需要管理多个项目或客户,你可以将它们分配到各自的项目中。

使用 Terraform 管理 Packet SSH 密钥

要通过 SSH 连接到机器,我们需要至少上传一个公钥到 Packet 账户。我们在variables.tf文件中创建一个变量来存储它:

variable "ssh_key" {
  default     = "keys/admin_key"
  description = "Path to SSH key"
}

如果你使用了不同的密钥名称,别忘了在terraform.tfvars中覆盖其值。

我们将使用packet_ssh_key资源在 Packet 账户中创建 SSH 密钥:

resource "packet_ssh_key" "admin" {
  name       = "admin_key"
  public_key = "${file("${var.ssh_key}.pub")}"
}

使用 Terraform 在 Packet 上启动 Docker Swarm 管理节点

我们将为这个 Docker Swarm 集群创建两种类型的服务器:管理节点和工作节点。管理节点控制在工作节点上执行的任务。我们将从引导 Docker Swarm 管理节点服务器开始,使用 Packet 服务(Packet API 提供了更多的选择):

  • 我们希望选择最便宜的服务器(baremetal_0

  • 我们希望服务器位于阿姆斯特丹(ams1

  • 我们希望服务器运行 Ubuntu 16.04(ubuntu_16_04_image

  • 默认 SSH 用户是root

  • 计费将按小时计算,但也可以选择月度计费

我们将把一些通用信息放入variables.tf,以便之后进行操作:

variable "facility" {
  default     = "ewr1"
  description = "Packet facility (us-east=ewr1, us-west=sjc1, eu-west=ams1)"
}

variable "plan" {
  default     = "baremetal_0"
  description = "Packet machine type"
}

variable "operating_system" {
  default     = "coreos_stable"
  description = "Packet operating_system"
}

variable "ssh_username" {
  default     = "root"
  description = "Default host username"
}

同样,在terraform.tfvars中覆盖它们,以确保与我们的值匹配:

facility = "ams1"
operating_system = "ubuntu_16_04_image"

要使用 Packet 创建服务器,我们可以使用packet_device资源,指定所选计划、设施、操作系统、计费方式以及运行项目:

resource "packet_device" "swarm_master" {
  hostname         = "swarm-master"
  plan             = "${var.plan}"
  facility         = "${var.facility}"
  operating_system = "${var.operating_system}"
  billing_cycle    = "hourly"
  project_id       = "${packet_project.swarm.id}"
}

现在,让我们创建两个脚本,当服务器准备好后执行。第一个脚本将更新 Ubuntu(update_os.sh),而第二个脚本将安装 Docker(install_docker.sh)。

#!/usr/bin/env bash
# file: ./scripts/update_os.sh
sudo apt update -yqq
sudo apt upgrade -yqq

该脚本将安装并启动 Docker:

#!/usr/bin/env bash
# file: ./scripts/install_docker.sh
curl -sSL https://get.docker.com/ | sh
sudo systemctl enable docker
sudo systemctl start docker

现在,我们可以将这些脚本作为remote-exec配置器,在packet_device资源内调用:

  provisioner "remote-exec" {
    connection {
      user        = "${var.ssh_username}"
      private_key = "${file("${var.ssh_key}")}"
    }

    scripts = [
      "scripts/update_os.sh",
      "scripts/install_docker.sh",
    ]
  }

此时,系统已完全配置并正常运行,Docker 也正在运行。

要初始化 Docker Swarm 集群,从 Docker 1.12 开始,我们只需要执行以下命令:

$ docker swarm init --advertise-addr docker.manager.local.ip

Packet 上的服务器有一个接口,既共享公共 IP 地址,也共享私有 IP 地址。私有 IP 是第二个,且可以通过以下导出的属性访问:${packet_device.swarm_master.network.2.address}。我们来创建另一个remote-exec配置器,这样 Swarm 管理器将在启动后自动初始化:

  provisioner "remote-exec" {
    connection {
      user        = "${var.ssh_username}"
      private_key = "${file("${var.ssh_key}")}"
    }

    inline = [
      "docker swarm init --advertise-addr ${packet_device.swarm_master.network.2.address}",
    ]
  }

到此为止,我们已经有一个正在运行的 Docker 集群,只有一个节点——即管理节点本身。

最后一步是存储 Swarm 令牌,以便节点能够加入。可以使用以下命令获取令牌:

$ docker swarm join-token worker -q

我们将把此令牌存储在基础设施存储库中的一个简单文件(worker.token)中,这样我们可以访问并版本化它。我们来创建一个变量,将令牌存储在variables.tf中的文件中:

variable "worker_token_file" {
  default     = "worker.token"
  description = "Worker token file"
}

当其他操作完成后,我们将通过 SSH 执行先前的docker swarm命令,使用local-exec配置器。由于我们无法与进程进行交互,所以跳过主机密钥检查和其他初始的 SSH 检查:

  provisioner "local-exec" {
    command = "ssh -t -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i ${var.ssh_key} ${var.ssh_username}@${packet_device.swarm_master.network.0.address} \"docker swarm join-token worker -q\" > ${var.worker_token_file}"
  }

我们现在完成了 Docker Swarm 管理器的配置!

使用 Terraform 在 Packet 上启动 Docker Swarm 节点

我们需要节点加入 Swarm 集群,以便工作负载能够分散。为了方便起见,节点的机器规格将与主节点相同。以下是将要发生的情况:

  • 创建了两个节点

  • 令牌文件已发送到每个节点

  • 操作系统已更新,Docker 已安装

  • 节点加入 Swarm 集群

让我们从在variables.tf中创建一个用于指定节点数量的变量开始:

variable "num_nodes" {
  default     = "1"
  description = "Number of Docker Swarm nodes"
}

随着集群的增长,在terraform.tfvars中重写该值:

num_nodes = "2"

使用与主节点相同的packet_device资源来创建节点:

resource "packet_device" "swarm_node" {
  count            = "${var.num_nodes}"
  hostname         = "swarm-node-${count.index+1}"
  plan             = "${var.plan}"
  facility         = "${var.facility}"
  operating_system = "${var.operating_system}"
  billing_cycle    = "hourly"
  project_id       = "${packet_project.swarm.id}"
}

添加一个file配置器来复制令牌文件:

  provisioner "file" {
    source      = "${var.worker_token_file}"
    destination = "${var.worker_token_file}"
  }

使用与主节点相同的更新和 Docker 安装脚本,创建相同的remote-exec配置器:

  provisioner "remote-exec" {
    connection {
      user        = "${var.ssh_username}"
      private_key = "${file("${var.ssh_key}")}"
    }

    scripts = [
      "scripts/update_os.sh",
      "scripts/install_docker.sh",
    ]
  }

操作系统现已完全更新,Docker 正在运行。

现在我们想加入 Docker Swarm 集群。为此,我们需要两项信息:token 和主节点的本地 IP 地址。我们已经在本地文件中有了 token,Terraform 也知道 Swarm 管理节点的本地 IP 地址。所以一个技巧是创建一个简单的脚本(建议你写一个更健壮的脚本!),它读取本地的 token,并将本地管理节点的 IP 地址作为参数传递。在名为 scripts/join_swarm.sh 的文件中,输入以下内容:

#!/usr/bin/env bash
# file: scripts/join_swarm.sh
MASTER=$1
SWARM_TOKEN=$(cat worker.token)
docker swarm join --token ${SWARM_TOKEN} ${MASTER}:2377

现在我们只需要使用 file 提供者将这个文件发送到各个节点:

  provisioner "file" {
    source      = "scripts/join_swarm.sh"
    destination = "join_swarm.sh"
  }

作为最后一步,通过 remote-exec 提供者使用它,将本地 Docker 主节点 IP (${packet_device.swarm_master.network.2.address}") 作为参数传递给脚本:

  provisioner "remote-exec" {
    connection {
      user        = "${var.ssh_username}"
      private_key = "${file("${var.ssh_key}")}"
    }

    inline = [
      "chmod +x join_swarm.sh",
      "./join_swarm.sh ${packet_device.swarm_master.network.2.address}",
    ]
  }//.

启动整个基础设施:

$ terraform apply
Outputs:

Swarm Master Private IP = 10.80.86.129
Swarm Master Public IP = 147.75.100.19
Swarm Nodes = Public: 147.75.100.23,147.75.100.3, Private: 10.80.86.135,10.80.86.133

我们的集群正在运行。

使用 Docker Swarm 集群

使用我们的 Docker Swarm 集群超出了本书的范围,但现在我们已经有了它,让我们快速看看如何将容器扩展到数千个!

验证我们是否有 3 个节点:

# docker node ls
ID                           HOSTNAME                STATUS  AVAILABILITY  MANAGER STATUS
9sxqi2f1pywmofgf63l84n7ps *  swarm-master.local.lan  Ready   Active        Leader
ag07nh1wzsbsvnef98sqf5agy    swarm-node-1.local.lan  Ready   Active
cppk5ja4spysu6opdov9f3x8h    swarm-node-2.local.lan  Ready   Active

我们需要为我们的容器创建一个共享网络,并且希望能够扩展到数千个容器。所以一个典型的 /24 网络是不够的(那是 docker network 的默认设置)。我们来创建一个 /16 的覆盖网络,这样就有足够的扩展空间了!

# docker network create -d overlay --subnet 172.16.0.0/16 nginx-network

创建一个 Docker 服务,它将在这个新的覆盖网络上启动一个 nginx 容器,并且有 3 个副本(容器的 3 个实例同时运行):

# docker service create --name nginx --network nginx-network --replicas 3 -p 80:80/tcp nginx

验证它是否正常工作:

# docker service ls
ID            NAME   REPLICAS  IMAGE  COMMAND
aeq9lspl0mpg  nginx  3/3       nginx

现在,通过 HTTP 访问集群的任何公共 IP 地址,任何节点上的容器都可以响应:我们可以向节点-1 发起 HTTP 请求,而响应可能来自节点-2 上的一个容器。很棒!

现在我们来扩展我们的服务,从 3 个副本扩展到 100 个:

# docker service scale nginx=100
nginx scaled to 100
# docker service ls
ID            NAME   REPLICAS  IMAGE  COMMAND
aeq9lspl0mpg  nginx  100/100   nginx

我们在几秒钟内将容器扩展到一百个,并将它们分布到所有 3 台裸金属机器上。

现在,你知道可以进行扩展,并且通过这样的配置,你可以将 nginx 服务扩展到 500 个、1000 个,甚至更多!

第五章:使用 Cloud-Init 配置最后一公里

本章将涵盖以下内容:

  • 在 AWS、Digital Ocean 或 OpenStack 上使用 cloud-init

  • 使用 cloud-init 处理文件

  • 使用 cloud-init 配置服务器的时区

  • 使用 cloud-init 管理用户、密钥和凭证

  • 使用 cloud-init 管理仓库和软件包

  • 在启动过程中使用 cloud-init 运行命令

  • 使用 cloud-init 配置 CoreOS

  • 使用 cloud-init 从头到尾部署 Chef 客户端

  • 使用 cloud-init 部署远程 Docker 服务器

引言

Cloud-init 是一种云实例初始化系统,几乎所有 Linux 发行版都支持它。所有最近的发行版(Ubuntu、Arch、CentOS/Red Hat、Fedora 等)以及 CoreOS 系统中的一个变种都支持它。

使用 cloud-init 时,在启动云实例(无论是新实例还是已有实例)时,会执行多个操作:安装软件包、复制文件或 SSH 密钥、部署 Chef、定义仓库或重启(完成后)。

cloud-init 的作用范围确实是针对初始化阶段的;它不是一个配置管理工具,也不是用于后续更新配置的工具,像 Ansible 或 Chef 那样会反复运行。它仅用于确保实例已正确配置以进行下一步操作,并确保在启动时按顺序执行一组命令。换句话说,Terraform(在第二章,使用 Terraform 配置 IaaS,第三章,深入 Terraform,以及第四章,用 Terraform 自动化完整基础设施 中讨论的工具)非常适合定义底层基础设施的所有方面,但 cloud-init 是一个简单而强大的解决方案,用于处理首次启动以及后续启动,之后让像 Chef 或 Ansible 这样的完整配置管理工具发挥作用,持续管理实例的生命周期。

Cloud-init 被定义为一个简单的 YAML 文件(cloud-config),该文件通过云实例的 user-data 字段传递。接下来我们将看到它是如何工作的。

本章将展示 cloud-init 的最常见使用案例,例如复制文件、创建用户、管理 SSH 密钥、添加仓库和安装软件包、运行任意命令、引导 Chef 客户端或使用它管理 CoreOS 和 Docker。

在 AWS、Digital Ocean 或 OpenStack 上使用 cloud-init

由于 cloud-init 是为云实例提供初始化服务的系统,我们需要找到一种方法,将 cloud-config YAML 文件传递给引导过程。在所有支持 cloud-init 的 IaaS 提供商中,都有一个字段可以粘贴我们的文件。我们将回顾 cloud-init 在三个重要的 IaaS 提供商(AWS、Digital Ocean 和 OpenStack)上的工作原理。

准备工作

要完成此食谱,你需要在 Amazon Web Services、Digital Ocean 或某个 OpenStack 部署上有一个账户,或者如果你想尝试所有平台的话,可以在它们所有平台上都有账户!

如何操作…

为了说明 cloud-init 的使用,我们将在 Ubuntu 16.04 和 CentOS 7.2 上创建最简单的 cloud-config 文件,安装 htoptcpdumpdockernmap 等包,这些包通常不会在大多数 Linux 发行版中默认安装。这是一个非常简单的 cloud-config 文件示例:

#cloud-config
# Install packages on first boot
packages:
  - tcpdump
  - docker
  - nmap

在 Amazon Web Services 上使用 cloud-init

使用 AWS 控制台时,在启动实例时,点击 高级详细信息,我们就能粘贴我们的示例(和简单的)cloud-config YAML 文件,或者甚至可以直接上传它:

在 Amazon Web Services 上使用 cloud-init

在这种情况下,我们刚启动的 Ubuntu 16.04 实例已经安装了 htoptcpdump 系统工具,以及该 Linux 发行版支持的 Docker 版本:

ubuntu@ip-172-31-40-77:~$ which htop
/usr/bin/htop
ubuntu@ip-172-31-40-77:~$ which tcpdump
/usr/sbin/tcpdump
ubuntu@ip-172-31-40-77:~$ docker --version
Docker version 1.11.2, build b9f10c9

注意

我们可以通过关闭实例手动更新某个实例的 cloud-config.yml 文件,然后在 操作 菜单下,进入 实例设置 | 查看/更改用户数据。重新启动 EC2 实例后,更新的配置将生效。

在 Digital Ocean 上使用 cloud-init

在 Digital Ocean 上的情况类似。在创建新的 droplet 时,确保在 选择附加选项 部分勾选 用户数据 复选框,并粘贴 cloud-config 文件内容:

在 Digital Ocean 上使用 cloud-init

在几秒钟的启动时间和软件包安装后,我们定制的 Ubuntu 发行版就可以使用了:

root@ubuntu-512mb-nyc3-01:~# which htop
/usr/bin/htop
root@ubuntu-512mb-nyc3-01:~# which tcpdump
/usr/sbin/tcpdump
root@ubuntu-512mb-nyc3-01:~# docker --version
Docker version 1.11.2, build b9f10c9

在 OpenStack 上使用 cloud-init

在 OpenStack 上创建实例时,使用 Horizon 控制面板,点击 Post-Creation 标签,并将 cloud-config YAML 内容粘贴到文本框中。或者,也可以上传文件:

在 OpenStack 上使用 cloud-init

验证所请求的软件包是否已安装,这次是在 CentOS 7.2 系统上:

[centos@cloud-init-demo ~]$ which nmap
/usr/bin/nmap
[centos@cloud-init-demo ~]$ docker --version
Docker version 1.10.3, build cb079f6-unsupported
[centos@cloud-init-demo ~]$ which tcpdump
/usr/sbin/tcpdump

将 cloud-init 和 Terraform 结合用于任何 IaaS

在之前的 Terraform 章节中,我们实际上已经使用过几次 cloud-init 文件。

在 Amazon Web Services 上,使用 aws_instance 资源启动 EC2 虚拟机时,我们通过 user_data 参数传递 cloud-config 文件内容,在这种情况下,使用 file() 插值:

resource "aws_instance" "vm" {
  ami           = "ami-643d4217"
  instance_type = "t2.micro"
  key_name      = "manual cloud init"
  user_data     = "${file("cloud-config.yml")}"
}

Digital Ocean 虚拟机的等效参数也是 user_data

resource "digitalocean_droplet" "vm" {
  image              = "ubuntu-14-04-x64"
  name               = "ubuntu"
  region             = "ams3"
  size               = "512mb"
  ssh_keys           = ["keys/admin_key"]
  user_data          = "${file("cloud-config.yml")}"
}

使用 cloud-init 处理文件

我们每个人都会面临的一个早期需求是,在实例启动的初期就有一个文件、许可证或脚本。Cloud-init 提供了多种方法来将这些文件发送到新的实例中。我们将看看如何使用纯文本和 base64 数据编码来发送文件。

准备工作

要完成此食谱,你需要:

  • 访问启用 cloud-config 的基础设施

如何操作…

我们将编写的第一个文件是MOTD(即今日消息),其根目录具有读写权限,其他用户只有只读权限。该文件的内容将直接从 cloud-config 文件中声明:

#cloud-config
write_files:
  - path: /etc/motd
    content: |
      This server is configured using cloud-init.
      Welcome.
    owner: root:root
    permissions: '0644'

当该机器启动时,将会有/etc/motd文件,并在登录时显示该字符串:

$ ssh ubuntu@server_ip
Welcome to Ubuntu 16.04.1 LTS (GNU/Linux 4.4.0-36-generic x86_64)
[...]
This server is configured using cloud-init.
Welcome.
[...]
ubuntu@ip-172-31-44-177:~$

另一种包括文件内容的方法是将其编码为 base64。假设我们想要创建一个名为/etc/server-id的文件,内容为abc-123,并且权限为0600。首先获取该文件的 base64 版本:

$ base64 server-id
YWJjLTEyMwo=

这是我们将集成到 cloud-config 文件content字段中的输出:

 - path: /etc/server-id
 content: YWJjLTEyMwo=
 encoding: b64
 permissions: '0600'

让我们验证远程内容是否符合预期:

$ ls -al /etc/server-id
-rw------- 1 root root 8 Sep 20 10:15 /etc/server-id
$ sudo cat /etc/server-id
abc-123

它工作了!我们的文件仅对所有者可读写,内容是abc-123

另一种方法是使用gzip压缩文件,甚至将压缩后的 gzip 文件进行 base64 编码。

使用 cloud-init 配置服务器的时区

在新的实例上,一个非常常见的配置步骤是设置时区。这次我们将明确设置服务器的 EDT(纽约)时区(即使服务器运行在欧洲或其他地方)。有时候,尽早正确设置日期和时间非常重要(例如用于注册时间、延迟和其他依赖日期和时间的问题)。

注意

在大多数设置中,我个人更喜欢确保所有系统都设置为 GMT,无论它们位于地球的哪个地方,是否处于 GMT 时区。这样,当出现故障时,更容易调试、比较日志或行为,而不必浪费时间进行时区的计算。

准备就绪

要按此配方进行操作,你需要:

  • 访问启用 cloud-config 的基础设施

如何操作…

要自动将服务器的时区设置为America/New_York,使用timezone指令:

#cloud-config
timezone: "America/New_York"

就是这样!我们的服务器现在已从一开始就配置为使用正确的时区:

$ date
Sun Sep 25 10:48:32 EDT 2016

事实上,这只是将/etc/timezone文件设置为正确的值:

$ cat /etc/timezone
America/New_York

使用 cloud-init 管理用户、密钥和凭证

我们很可能不会打算使用默认的 root 账户,甚至不会使用我们发行版中的默认用户账户(那些 ubuntu 或 centos 用户)。更有可能的是,我们在过程的早期就需要一个 Unix 账户,甚至在适当的配置管理工具介入之前。

假设我们的 IT 安全政策要求我们为 IT 安全团队创建一个名为emergency的用户账户,该账户位于一个名为infosec的组中,拥有无密码的sudo权限,并且只有简单的/bin/sh shell。此账户将自动填充一个授权的公钥。政策还要求删除默认的ubuntu账户。

准备就绪

要按此配方进行操作,你需要:

  • 访问启用 cloud-config 的基础设施

如何操作…

要创建一个组,我们使用一个简单名为groups的指令,接受一个组列表。任何组都可以包含一个用户子列表,将这些用户添加到该组:

#cloud-config
groups:
  - infosec: [emergency]

要创建一个用户,我们使用名为users的指令,它接受一个用户列表。这个用户列表有一组键,例如用户所在的groupssudo权限,默认使用的shell,或者用于授权的 SSH 公钥。以下是我们为用户emergency设置的示例:

users:
 - name: emergency
 groups: sudo
 shell: /bin/sh
 sudo: ['ALL=(ALL) NOPASSWD:ALL']
 ssh-authorized-keys:
 - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+fAfzjw5+mUZ7nGokB0tzO9fOLKrjHGVlabpRUxvsIN/dRRmiBA9NDh5YRZ/ThAhn+RvPKGTBrXmuv3qWd/iWc3nie0fc2zDX1/Dc8EAIF9ybXfSxT2DXOWWLOvNdUVOZNifmsmCQ1z0p9hg3bo65c0ZEBpXHIk+l75uFWAIYZ/4jnXyFWz1ptmQR7gnAk2KBK19sj1Ii0pNjGyVbl5bNitWb3ulaviIT3FCswZoOsYvcLpOwQrMA3k12kEAb30CYpesGcq6WDHAZSpWkFvc3Cd/AET4/SjtyYpQVEhUn84v106WbNeDyJpUX6cz2WG2UaEqZc0VqZVhI63jG7wUR emergency@host

登录为emergency用户,使用私钥后,让我们验证 cloud-init 是否完成了任务:

$ whoami
emergency
$ groups emergency
emergency : emergency sudo
$ echo $SHELL
/bin/sh
$ sudo whoami
root

注意

我们从未明确要求删除默认的ubuntu用户账户:一旦创建初始用户,这一操作会自动完成。

然而,如果我们希望保留 Linux 发行版的默认用户,只需将以下default用户添加到users指令中:

users:
 - default

使用 cloud-init 管理仓库和软件包

除非我们需要一个非常特定版本的 Linux 发行版,否则很可能我们会希望尽快得到一个完全更新的系统(考虑到安全补丁和其他 bug 修复)。同样,我们通常期望新系统中可以使用一组工具。然而,事情可能会发生变化,默认工具可能会被移除——最好小心为上。如果我们的引导脚本需要wgetcurlnmap,我们应该确保在适当的配置管理工具(如 Chef 或 Puppet)开始工作之前,这些工具已经存在。我们还可能希望在应用关键的初始软件包(如内核)后重启服务器,或者添加自定义的包仓库。

准备工作

要执行这个步骤,你需要:

  • 访问启用了 cloud-config 的基础设施

如何操作……

在引导完成后立即升级所有软件包,只需将package_upgrade指令设置为true

#cloud-config
package_upgrade: true

另一个有用的指令是,如果包管理器要求重新启动系统(通常是内核更新时的常见情况),就会执行重启。通常最好尽早重启,以确保使用最安全的内核,但根据你自己的环境小心操作(你可能不希望在进行其他操作时重启,比如运行 Chef 或类似的管理软件):

apt_reboot_if_required: true

为确保安装了所需的软件包,请使用packages指令:

packages:
  - htop
  - nmap
  - curl
  - wget

我们还可以使用apt_sources添加自定义的 APT 仓库:

apt_sources:
  -  source: "ppa:nginx/stable"

让我们启动一个新的实例,并验证它已完全更新,以便无法应用任何更新:

$ sudo apt-get dist-upgrade
Reading package lists... Done
Building dependency tree
Reading state information... Done
Calculating upgrade... Done
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.

验证我们所需的工具是否可用:

$ which nmap
/usr/bin/nmap
$ which htop
/usr/bin/htop
$ which curl
/usr/bin/curl
$ which wget
/usr/bin/wget

好消息!现在我们可以确保从一开始就始终拥有一个完全更新的系统,并且安装了所需的工具集,甚至是我们自己的工具。

在启动过程中使用 cloud-init 运行命令

在引导新服务器或实例时,第一次引导通常与实例生命周期中的其他引导有很大不同,我们通常希望在引导过程中非常早或非常晚执行一些命令。例如,假设我们的云实例启动时附带了一个块存储。我们可能想要格式化这个存储空间并确保它挂载到主机上,但虽然我们始终希望磁盘能够挂载,我们可能不希望它在每次引导时都被格式化!bootcmd指令用于处理引导过程中非常早期需要执行的命令,而runcmd指令则在引导过程中较晚执行(且仅执行一次)。

注意

bootcmd将在每次实例引导时执行。

准备工作

要执行这个步骤,你需要:

  • 访问启用 cloud-config 的基础设施

如何操作…

我们将在引导过程中启动三个命令。第一个是一个包含动态内容的简单文件(由 cloud-init 提供的$INSTANCE_ID变量),它将在每次引导时无论如何都会被重写。第二个命令是将日期打印到日志中(这样我们可以知道引导过程何时开始)。最后一个命令是格式化附加到/dev/xvdb上的块设备为ext4格式。为了演示,我们还将在主机上将新设备挂载到/srv/www下。

要在每次机器启动时尽可能早地执行某个命令,只需将其添加到bootcmd指令的命令列表中:

#cloud-config
bootcmd:
  - echo bootcmd started at $(date)
  - echo $INSTANCE_ID > /etc/instance_id

如果我们删除或修改这个文件,下次重启时它将被覆盖。

另一方面,如果我们只想在bootcmd指令中运行一次命令,可以使用辅助脚本cloud-init-per。你可以选择每次boot时或每个instance时执行一次命令。在我们的例子中,我们想要格式化/dev/xvdb设备(因此,除非我们希望每次重启时都格式化磁盘,否则我们可能只希望在此实例上执行一次。让我们将instance参数添加到cloud-init-per辅助脚本中):

#cloud-config
bootcmd:
  - cloud-init-per instance mkfs-xvdb mkfs -t ext4 /dev/xvdb

最后,让我们使用mounts指令将现在格式化的/dev/xvdb挂载到/srv/www文件夹:

mounts:
  - [ /dev/xvdb, /srv/www ]

启动后,让我们验证块设备是否已挂载:

# df -h /srv/www/
Filesystem      Size  Used Avail Use% Mounted on
/dev/xvdb       4.8G   10M  4.6G   1% /srv/www

我们还可以测试我们创建的文件是否存在:

# cat /etc/instance_id
i-03005dd324599df11

尝试删除这个文件并重启服务器:文件将再次出现。

现在,让我们来看一下runcmd指令有何不同。我们将向bootcmd指令中的日期输出添加一个非常相似的命令:

runcmd:
 - 'echo runcmd started at $(date)'

启动一个新实例,并观察时间戳的变化:

$ grep "started at" /var/log/cloud-init-output.log
bootcmd started at Fri Sep 23 07:02:35 UTC 2016
+ echo runcmd started at Fri Sep 23 07:02:47 UTC 2016
runcmd started at Fri Sep 23 07:02:47 UTC 2016

runcmd指令比bootcmd指令晚 12 秒启动。

现在重新启动实例,并观察到runcmd没有再次执行:

$ grep "started at" /var/log/cloud-init-output.log
bootcmd started at Fri Sep 23 07:04:31 UTC 2016

现在我们知道在每种情况下该使用哪个指令了。

使用 cloud-init 配置 CoreOS

CoreOS 支持其自有版本的 cloud-init,并增强了对 CoreOS 环境的支持,同时不包含任何与其环境不兼容的部分,这样我们就可以启动一个完全配置好的系统和集群。

我们将看看 CoreOS 的特殊性,参考之前的提示,学习如何管理用户、文件、授权的 SSH 密钥以及其他标准的 cloud-init 指令。在这部分结束时,您将学会如何配置 etcd 键值存储、fleet 集群管理器、flannel 覆盖网络、控制更新机制,并确保 systemd 单元尽早启动。

注意

CoreOS 提供了一个非常有用的云配置文件验证工具,位于 coreos.com/validate/。当我们不确定某个指令是否被发行版支持时,这个工具非常有用。

准备工作

要完成本配方,您将需要:

  • 访问启用云配置的基础设施

如何操作……

我们将介绍可以为 CoreOS 配置的最重要选项。这包括 etcd 分布式键值存储、fleet 调度器、fleet 网络、更新策略以及一些 systemd 单元配置。

使用 cloud-init 配置 etcd

etcd 键值存储在 CoreOS 中用于在同一集群的成员之间共享多个配置数据。首先,我们需要一个发现令牌,可以从discovery.etcd.io/new获得。

$ curl -w "\n" 'https://discovery.etcd.io/new'
https://discovery.etcd.io/638d980c4edf94d6ddff8d6e862bc7d9

注意

我们可以通过在 URL discovery.etcd.io/new?size=3 中添加 size= 参数来指定 CoreOS 集群的最小所需大小。

现在我们有了有效的发现令牌,让我们将其添加到 cloud-config.yml 文件中的 etcd2 指令下:

#cloud-config
coreos:
  etcd2:
    discovery: "https://discovery.etcd.io/638d980c4edf94d6ddff8d6e862bc7d9"

下一步是配置 etcd:

  • etcd 应该如何监听对等流量?(listen-peer-urls)。我们希望使用本地接口,并使用默认端口(TCP/2380)。

  • etcd 应该如何监听客户端流量?(listen-client-urls)。我们希望使用所有可用的接口,并使用默认端口(TCP/2379)。

  • etcd 应该如何初步向集群的其他节点通告?(initial-advertise-peer-urls)。我们希望使用本地接口,并使用相同的对等流量端口(TCP/2380)。

  • etcd 应该如何向集群的其他节点通告客户端 URL?(advertise-client-urls)。我们希望使用本地接口,并使用相同的客户端流量端口(TCP/2379)。

为了使配置更具动态性,我们可以使用兼容大多数 IaaS 提供商的变量——$private_ipv4$public_ipv4

这就是我们包含所有 etcd 配置的 cloud-config.yml 文件:

#cloud-config
coreos:
  etcd2:
    discovery: "https://discovery.etcd.io/b8724b9a1456573f4d527452cba8ebdb"
    advertise-client-urls: "http://$private_ipv4:2379"
    listen-client-urls: "http://0.0.0.0:2379"
    initial-advertise-peer-urls: "http://$private_ipv4:2380"
    listen-peer-urls: "http://$private_ipv4:2380"

这将生成在 /run/systemd/system/etcd2.service.d/20-cloudinit.conf 中找到的 systemd 单元文件中的正确变量。

$ cat /run/systemd/system/etcd2.service.d/20-cloudinit.conf
[Service]
Environment="ETCD_ADVERTISE_CLIENT_URLS=http://172.31.15.59:2379"
Environment="ETCD_DISCOVERY=https://discovery.etcd.io/b8724b9a1456573f4d527452cba8ebdb"
Environment="ETCD_INITIAL_ADVERTISE_PEER_URLS=http://172.31.15.59:2380"
Environment="ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379"
Environment="ETCD_LISTEN_PEER_URLS=http://172.31.15.59:2380"

当我们的集群准备好后,我们可以通过指定端口以客户端身份请求信息:

$ etcdctl cluster-health
member 7466dcc2053a98a4 is healthy: got healthy result from http://172.31.15.59:2379
member 8f9bd8a78e0cca38 is healthy: got healthy result from http://172.31.8.96:2379
member e0f77aacba6888fc is healthy: got healthy result from http://172.31.1.27:2379
cluster is healthy

我们还可以浏览 etcd 键值存储,以确认是否能够访问它:

$ etcdctl ls
/coreos.com

使用 cloud-init 配置 fleet

Fleet 是基于 systemd 的分布式初始化管理器,我们使用它在我们的 CoreOS 集群上安排服务。

最重要的配置参数如下:

  • public_ip:这指定要用于与其他主机通信的接口。我们希望主机的公共 IP,以便我们可以直接从我们的工作站与 fleet 互动。

  • metadata: 这是与我们需求相关的任何关键值,因此我们可以相应地安排单元。我们想要存储提供者(aws),地区(eu-west-1)以及集群的名称(mycluster)。这完全是任意的;请根据您自己的需求调整键和值。

这是在cloud-config.yml文件中的样子:

coreos:
  fleet:
    public-ip: "$public_ipv4"
    metadata: "region=eu-west-1,provider=aws,cluster=mycluster"

这将在/run/systemd/system/fleet.service.d/20-cloudinit.conf中的 systemd 单元中生成正确的变量:

$ cat /run/systemd/system/fleet.service.d/20-cloudinit.conf
[Service]
Environment="FLEET_METADATA=region=eu-west-1,provider=aws,cluster=mycluster"
Environment="FLEET_PUBLIC_IP=52.209.159.4"

使用 fleet 超出了本书的范围,但我们至少可以从实例验证与 fleet 集群管理器的连接是否正常:

$ fleetctl list-machines
MACHINE         IP              METADATA
441bf02a...     52.31.10.18     cluster=mycluster,provider=aws,region=eu-west-1
b95a5262...     52.209.159.4    cluster=mycluster,provider=aws,region=eu-west-1
d9fa1d18...     52.31.109.156   cluster=mycluster,provider=aws,region=eu-west-1

现在我们可以提交并启动我们工作中的 fleet 集群上的服务!

使用 cloud-init 配置更新策略

CoreOS 可以以各种方式处理更新,包括在新的 CoreOS 版本可用后立即重新启动,使用 etcd 进行理想时间的调度,使集群永不中断,两者混合(默认),甚至永不重新启动。我们还可以明确指定要使用的 CoreOS 通道(稳定版、测试版或 Alpha 版)。我们希望使用etcd-lock策略确保集群永不中断,并确保使用稳定的发布版:

coreos:
  update:
    reboot-strategy: "etcd-lock"
    group: "stable"

此部分生成/etc/coreos/update.conf文件:

$ cat /etc/coreos/update.conf
GROUP=stable
REBOOT_STRATEGY=etcd-lock

我们可以强制执行更新检查以验证其是否正常工作(从具有可用更新的系统中获取的示例):

$ sudo update_engine_client -update
[0924/131749:INFO:update_engine_client.cc(243)] Initiating update check and install.
[0924/131750:INFO:update_engine_client.cc(248)] Waiting for update to complete.
CURRENT_OP=UPDATE_STATUS_UPDATE_AVAILABLE
[...]

使用 cloud-init 配置 locksmith

现在我们确信更新系统已正确触发,我们面临一个新问题:当更新可用时,我们的集群节点可以随时重新启动。在高负载环境中可能不太理想。因此,我们可以配置locksmith,仅允许在特定时间范围内重新启动,例如“每周五到周六的夜间,从凌晨 4 点到 6 点”。我们不限于一天,所以我们也可以允许在凌晨 4 点时的任何一天重新启动:

coreos:
  locksmith:
    window-start: Sat 04:00
    window-length: 2h 

这将在/run/systemd/system/locksmithd.service.d/20-cloudinit.conf中生成以下内容:

$ cat /run/systemd/system/locksmithd.service.d/20-cloudinit.conf
[Service]
Environment="REBOOT_WINDOW_START=04:00"
Environment="REBOOT_WINDOW_LENGTH=2h"

随时可以使用locksmithctl命令检查重启槽的可用性:

$ locksmithctl status
Available: 1
Max: 1

如果另一台机器当前正在重新启动,其 ID 将显示出来,以便我们知道是谁在重新启动。

使用 cloud-init 配置 systemd 单元

我们可以轻松地从 cloud-init 管理单元,因此系统的关键部分在需要时立即启动。例如,我们知道我们希望 etcd2 和 fleet 服务在每次启动时启动:

  coreos: 
units:
    - name: etcd2.service
      command: start
    - name: fleet.service
      command: start 

使用 cloud-init 配置 flannel

Flannel 用于在集群中的所有主机之间创建一个覆盖网络,这样容器就可以通过网络相互通信,无论它们在哪个节点上运行。为了在启动 Flannel 之前进行配置,我们可以向 cloud-config 文件中添加更多配置内容。我们知道希望我们的 Flannel 网络在 10.1.0.0/16 网络上运行,因此我们可以创建一个 drop-in systemd 配置文件,其中包含将在flanneld服务之前执行的内容。在这种情况下,设置 Flannel 网络的操作是通过将键/值组合写入 etcd 的/coreos.com/network/config下完成的:

coreos:
  units:
    - name: flanneld.service
      drop-ins:
        - name: 50-network-config.conf
          content: |
            [Service]
            ExecStartPre=/usr/bin/etcdctl set /coreos.com/network/config '{ "Network": "10.1.0.0/16" }'

这将仅创建文件/etc/systemd/system/flanneld.service.d/50-network-config.conf

$ cat /etc/systemd/system/flanneld.service.d/50-network-config.conf
[Service]
ExecStartPre=/usr/bin/etcdctl set /coreos.com/network/config '{ "Network": "10.1.0.0/16" }'

验证我们是否在正确的 IP 网络范围内有一个正确的flannel0接口:

$ ifconfig flannel0
flannel0: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST>  mtu 8973
        inet 10.1.19.0  netmask 255.255.0.0  destination 10.1.19.0
[...]

启动一个容器,以验证它是否也在 10.1.0.0/16 网络中运行:

$ docker run -it --rm alpine ifconfig eth0
eth0      Link encap:Ethernet  HWaddr 02:42:0A:01:13:02
          inet addr:10.1.19.2  Bcast:0.0.0.0  Mask:255.255.255.0
[...]

一切都运行得非常顺利!

注意

请注意,启动接口可能需要一些时间,这取决于主机的网络连接速度,因为 flannel 是从一个容器中运行的,首先需要下载该容器(截至目前为 51 MB)。

我们现在了解了使用 cloud-init 自动引导 CoreOS 集群的最有用配置选项。

从头到尾使用 cloud-init 部署 Chef 客户端

我们可以通过 cloud-init 使用官方的omnibus安装程序来部署 Chef。这个安装程序包含了部署 Chef 及其所有依赖项所需的所有内容。接着,我们将配置 Chef 客户端,以便与 Chef 服务器组织进行安全的身份验证,并最终应用初始的 cookbook。

注意

警告:当前与 Ubuntu 16.04 LTS 和 CentOS 7 一起发布的 cloud-init 版本在安装 Chef 时存在问题。此步骤使用的是 Ubuntu 14.04 LTS,等待该问题修复。

准备工作

要按照此步骤进行操作,您将需要以下内容:

  • 访问启用了 cloud-config 的基础设施

  • 一个可用的 Chef 服务器和组织设置

如何操作…

与 Chef 相关的所有内容都在名为chef的指令下进行配置。

使用 cloud-init 部署 Chef omnibus 安装程序

由于我们希望使用官方的 omnibus 构建(其他选择包括通过 Ruby gem 安装 Chef——这种方法已经不推荐使用,并且过于依赖本地安装的 Ruby 版本,或者通过已记录的包进行安装),让我们将安装类型定义为omnibus,并确保即使出于某种原因,Chef 客户端已经安装在系统上,也能安装该版本。最后,我们显式定义安装程序的完整 URL,以确保我们安装的是正确的版本(可能将其指向您自己服务器上的本地版本)。

#cloud-config
chef:
  install_type: "omnibus"
  force_install: true
  omnibus_url: "https://www.getchef.com/chef/install.sh"

这将在 cloud-init 日志中输出类似以下内容:

Getting information for chef stable  for ubuntu...
downloading https://omnitruck-direct.chef.io/stable/chef/metadata?v=&p=ubuntu&pv=14.04&m=x86_64
  to file /tmp/install.sh.1294/metadata.txt
[...]
version 12.14.89
[...]
Installing chef
[...]
Unpacking chef (12.14.89-1) ...
Setting up chef (12.14.89-1) ...
Thank you for installing Chef!

此时,您将会在/opt/chef目录下拥有一个有效的 Chef 安装,尽管尚未进行配置。

使用 cloud-init 配置 Chef 与 Chef 服务器组织

chef 客户端需要以下三项信息才能正确地在现有的 Chef Server 组织中进行身份验证:Chef 服务器的 URL(api.chef.io/organizations/iacbook)、允许您向该组织添加节点的私钥,以及与此密钥关联的名称(默认为组织名称,如 iacbook)。这些信息在 cloud-config 文件中表示如下:

#cloud-config
chef:
  server_url: "https://api.chef.io/organizations/iacbook"
  validation_name: "iacbook"
  validation_cert: |
    -----BEGIN RSA PRIVATE KEY-----
    MIIEowIBAAKCAQEAuR[...]
    -----END RSA PRIVATE KEY-----

有了这些信息,初次运行 chef-client 时将能够在 Chef 组织中进行身份验证并添加节点。在 cloud-init 日志中,此步骤出现在以下时刻:

[...]
Starting Chef Client, version 12.14.89
Creating a new client identity for i-0913e870fb28af4bd using the validator key.
[...]

使用 cloud-init 在引导时应用 Chef 食谱

我们当然希望至少应用一个初步的食谱来配置实例。在这种情况下,我们将简单地应用随启动套件一起提供的初始食谱,但我们可以根据需要添加任意数量的角色和食谱。有关获取此食谱的更多信息,请参阅本书的专门章节:

#cloud-config
chef:
  run_list:
  - "recipe[starter]"

在日志中,我们将看到它以这种方式应用:

[...]
Loading cookbooks [starter@1.0.0]
Storing updated cookbooks/starter/attributes/default.rb in the cache.
Storing updated cookbooks/starter/recipes/default.rb in the cache.
Storing updated cookbooks/starter/templates/default/sample.erb in the cache.
Storing updated cookbooks/starter/files/default/sample.txt in the cache.
Storing updated cookbooks/starter/metadata.rb in the cache.
Processing log[Welcome to Chef, Sam Doe!] action write (starter::default line 4)
Welcome to Chef, Sam Doe!
Chef Run complete in 2.625856409 seconds

我们的实例现在已经自动注册并配置完成,只需要在 cloud-config 文件中写几行代码即可。

使用 cloud-init 部署远程 Docker 服务器

拥有一个远程 Docker 服务器而非工作站默认的本地配置可能非常有用,原因包括带宽问题、测试生产环境、客户演示或远程团队协作。能够向远程服务器发送常规 Docker 命令有很多优势。为了速度和舒适性,我们将部署一个基本的 CoreOS 系统,添加一个用户(Jane)及其公钥。Docker 将被修改为通过类似 socket 的 systemd 服务监听网络,我们还将把服务器时区配置为纽约。

准备工作

要执行这个食谱,您需要:

  • 访问启用 cloud-config 的基础设施

如何实现...

我们先从简单地将这个服务器命名为 "docker" 开始:

#cloud-config
hostname: "docker"

在最终系统中,这将把主机名设置为正确的值:

$ hostname
docker
$ cat /etc/hostname
docker

现在,让我们创建 Jane 用户,这样她就可以登录实例并远程帮助我们。她需要加入 docker 组,以便能够操作容器,并且她已经提供了她的 SSH 公钥。这是如何在 cloud-config 文件中表示的:

#cloud-config
users:
  - name: "jane"
    gecos: "Jane Docker"
    groups:
      - "docker"
    ssh-authorized-keys:
      - "ssh-rsa AAAAB[...] jane"

在最终系统中,Jane 可以使用她的私钥登录,并且由于她是 docker 组的成员,可以与 docker 守护进程进行交互:

jane@docker ~ $ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

SSH 公钥最终会出现在以下文件中:

jane@docker ~ $ cat .ssh/authorized_keys.d/coreos-cloudinit
ssh-rsa AAAAB [..] jane

使用 cloud-init 设置 CoreOS 的时区

CoreOS 使用一个基于 NTP(网络时间协议)系统,通过 timedatectl 命令进行控制。我们在 CoreOS 上找不到通常的 /etc/timezone 文件,因此我们在本书中之前看到的 cloud-init 的默认 timezone 指令将不起作用。要将时区设置为纽约,我们可以这样设置:

$ /usr/bin/timedatectl set-timezone America/New_York

很简单!所以我们通过 cloud-config 文件中的 systemd 单元来启动该命令,以确保时区已经设置。对 systemd 的深入了解超出了本书的范围,但为了做到这一点,我们需要为该单元添加两个选项:一个告诉 systemd 即使命令退出了也不要认为该单元崩溃了(RemainAfterExit=yes),另一个告诉该单元类型不是执行长期运行的进程,而是一个应该在继续之前退出的短期进程(Type=oneshot)。

这是 cloud-config.yml 文件中的单元:

coreos:
  units:
  - name: settimezone.service
    command: start
    content: |
      [Unit]
      Description=Setting the timezone

      [Service]
      ExecStart=/usr/bin/timedatectl set-timezone America/New_York
      RemainAfterExit=yes
      Type=oneshot

启用 Docker TCP 套接字以进行网络访问

我们的最终目标是能够从工作站远程使用 Docker 引擎。默认的 Docker 配置是监听 Unix 套接字(/var/run/docker.sock),而我们希望它监听 TCP 套接字上的 2375 端口(默认的非加密端口,强烈建议配置 TLS 加密;按照惯例,这将使用 TCP/2376)。为了配置这一点,我们将使用 systemd 的一项功能——套接字激活。简而言之,这会创建一个 systemd 服务,监听 2375 端口,并同时启动常规的 docker.service 单元和套接字描述。这样,这个特定的 Docker 引擎将会响应 TCP 套接字上的请求,而不是 Unix 套接字(同时可以激活更多 TCP 套接字,或保持默认的 docker.service 清洁)。以下是配置示例:

coreos:
  units:
  - name: docker-tcp.socket
    command: start
    enable: true
    content: |
      [Unit]
      Description=Docker Socket for the API

      [Socket]
      ListenStream=2375
      BindIPv6Only=both
      Service=docker.service

      [Install]
      WantedBy=sockets.target

让我们启动一个远程服务器,使用完整的配置并稍作演示(在这个例子中,Docker 远程主机是 52.211.117.98,我们将启动一个带有 HTTP 端口转发的 nginx 容器)。有关使用的命令行选项的更多信息,请参考本书的 Docker 部分:

user@workstation $ docker -H 52.211.117.98 run -it --rm -p 80:80 nginx
Unable to find image 'nginx:latest' locally
latest: Pulling from library/nginx
6a5a5368e0c2: Pull complete
4aceccff346f: Pull complete
c8967f302193: Pull complete
Digest: sha256:1ebfe348d131e9657872de9881fe736612b2e8e1630e0508c354acb0350a4566
Status: Downloaded newer image for nginx:latest  
1.2.3.4 - - [25/Sep/2016:16:06:30 +0000] "GET / HTTP/1.1" 200 612 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36" "-"

在远程 Docker 主机的 HTTP 端口上进行一些请求,它将会响应。现在我们拥有了一个完全按需的 CoreOS 主机,能够通过巧妙的 systemd 配置功能远程控制 Docker 引擎!

还有更多...

当连接到不同的远程 Docker 引擎时,我们迟早会遇到连接到一个服务器,而这个服务器的版本与我们客户端的版本不同。在这种情况下,我们会收到以下错误:

Error response from daemon: client is newer than server (client API version: 1.24, server API version: 1.22)

一个简单的解决方法是重写 DOCKER_API_VERSION 环境变量,并将其设置为与服务器相同的值(在这个例子中为 1.22):

$ DOCKER_API_VERSION=1.22 docker -H 52.211.117.98 ps

注意

Docker 1.13 大大改善了这种情况,通过在 CLI 中直接管理客户端和服务器之间的版本/功能协商。

另见

第六章。使用 Chef 和 Puppet 管理服务器的基础知识

在本章中,我们将介绍以下配方:

  • 入门(概念和工具)

  • 安装 Chef 开发工具包和 Puppet 集合

  • 创建一个免费的托管服务器 Chef 帐户和一个 Puppet 服务器

  • 自动引导 Chef 客户端和 Puppet 代理

  • 安装软件包

  • 管理服务

  • 管理文件,目录和模板

  • 处理依赖关系

  • 使用通知实现更动态的代码

  • 使用 Chef 数据包和 Puppet 中的 Hiera 集中共享数据

  • 创建功能角色

  • 管理外部 Chef cookbooks 和 Puppet 模块

介绍

Chef 是一个用于自动化系统配置的开源工具,与大多数 IaaS(如 Amazon Web Services,OpenStack 或 Google Cloud)很好地集成。使用 Chef,我们在 Ruby 中编写基础设施代码,描述系统的每个方面预期如何根据多种条件行为,然后通过各种客户端工具应用它,以确保应用定义的状态。

在本章中,您将了解使用Chef 开发工具包Chef DK)管理服务器的基本知识。您将学习如何在新服务器上引导工作中的 Chef 环境,如何安装软件包和管理服务,如何通过文件和模板生成动态配置,创建有用的功能角色,集中共享数据以动态生成内容,并展示如何表达服务之间的依赖关系,帮助它们互相通知其状态,使整个部署链有序工作。我们还将介绍如何轻松管理这些依赖关系,这将揭示如何处理由 Chef 管理的更复杂的基础设施。

为了说明所有这些特性,在本章中,我们将从头开始在 CentOS 7.x 上构建一个经典的LAMPLinuxApacheMySQLPHP)服务器,完全自动化,使用 Chef。这样,我们将逐步构建我们的最终项目——一个带有外部依赖关系的工作 LAMP 服务器,最新社区 MySQL 5.7 版本,以及更多功能。

所有的配方都基于 Chef。然而,在可能的情况下,我们将尝试展示与 Puppet 类似工作的方式,作为 Chef 的直接替代。

入门(概念和工具)

Chef 是一个非常复杂的系统,有许多概念和术语,一开始可能会令人望而却步。在本章中,我们将介绍所有最重要的概念,因此它也可以作为一个快速查阅表或提醒。

运行 Chef

Chef 可以以多种方式使用,最重要的方式如下:

  • 客户端/服务器模式:每个托管客户端上都运行一个代理,定期从服务器获取更新并应用它们。在此模式下,所有 Chef 代码都从 Chef 服务器分发。

  • Chef-Solo:在这种模式下,Chef 服务器的需求被去除,但代价是失去了一些功能,包括搜索、API、节点信息的持久存储等重要功能。所有 Chef 代码需要以某种方式传输,并手动应用。

注意

还存在其他模式,例如 Chef Zero,但它们超出了本书的范围。

多平台客户端是用 Ruby 编写的,而其服务器端则是用 Erlang 编写的。Chef 服务器是开源的(在撰写本书时采用 Apache 许可证),任何人都可以自行托管,Chef 背后的公司也提供了他们自己的托管版本,附加了更多功能和支持。

注意

Chef 服务器是多种技术的组合,如 PostgreSQL、RabbitMQ、Redis、Nginx 等。考虑到维护、备份和性能,部署自己的服务器时需要特别留意。

Chef 插件

Chef 也具有高度的模块化,提供大量插件,这些插件要么来自 Chef 本身,要么来自供应商或社区。插件包括 IaaS 支持,如 AWS、OpenStack、VMware 或 Digital Ocean,到硬件管理,如 Dell、HP 或 IPMI 接口,团队工作流集成,或与系统相关的事务,如日志处理、安全性以及其他类似功能。

Chef 组织

在 Chef 层次结构的最顶端,我们会找到一个组织。不同组织之间不能共享资源,通常这里会定义一个公司、不同的业务单元,甚至是故意隔离的企业部门。这实际上是每个人需要了解什么需要与谁共享,以便知道 Chef 组织将是什么样子的。

Chef 节点

在 Chef 的术语中,节点是指任何由 Chef 管理的东西,无论是物理的还是虚拟的,每个节点都有一些特征或参数,这些特征或参数将在节点的生命周期中被设置或更改。

Chef 环境

每个节点都运行在一个环境中。环境通常对应一些概念,如开发预生产生产,但也不乏创新用法来管理不同的应用程序或其他兴趣小组。环境还有一套已设置的特征。

Chef 角色

角色通常是功能性和通用的,而不是围绕某个产品进行组织的。例如,我们会比看到MySQL角色更多看到数据库角色。其他角色可以是监控服务器负载均衡器

Chef 资源

这是 Chef 中最重要的概念:资源是任何需要被设置为目标状态的系统部分。这包括需要安装或移除的软件包、需要启用或启动的服务、从模板生成的文件、需要创建或禁用的用户以及系统中的其他预期元素。

Chef 配方

配方实际上就是普通的 Ruby 文件,其中包含一些 Chef 资源,这些资源描述了一个连贯的目标状态,比如需要安装的软件包、配置文件的编写和需要重启的服务。

Chef 食谱

Chef 烹饪书用于将多个食谱组合成一个一致的集合,以及使其正常工作的所有其他文件。一个示例烹饪书可以是mysql,而这个烹饪书中的两个食谱可以是mysql::server来管理服务器,以及mysql::client来管理客户端。

Chef 运行列表

运行列表是节点必须应用的角色或食谱的列表。这是由 Chef 服务器根据 chef 客户端的请求发送的。

还有更多…

Puppet 是由 Puppet Labs 发布的配置工具,是 Chef 的替代品。

Puppet 也可以像 Chef 一样在独立模式下工作,但我们将重点介绍客户端/服务器架构。

Puppet 基础设施主要由以下部分组成:

  • 一个作为主配置服务器的 Puppet 服务器,包含所有的配置代码

  • 一个运行在所有基础设施节点上的 Puppet 代理,应用配置

代理与服务器之间的通信通过 HTTPS 进行,Puppet 有自己的 PKI 用于服务器证书和客户端证书(客户端证书用于认证节点到服务器)。

Puppet 拥有自己的领域特定语言DSL)。至于 Chef,Puppet 使用资源来安装软件包、管理服务、创建文件等。Puppet 中的一段代码叫做清单,它是一个.pp扩展名的文件。代码通过模块进行结构化。例如,我们可以想象一个apache模块,包含用于 Apache 安装和服务管理的资源。我们还可以有一个mysql模块,用于 MySQL 服务器,并包含它自己的资源。

还有一个主清单,位于任何模块之外,它是基础设施节点的列表。对于每个节点,我们可以指定使用哪些模块来执行完整的节点安装。当一个节点从服务器请求其配置时,服务器会编译该节点的目录,然后 Puppet 代理应用这个目录。

我们可以编写自己的模块,或使用来自 GitHub 和 Puppet Forge 的现有模块。Puppet Forge 托管了大量社区模块,其中一些由 Puppet Labs 支持。

在本章中,我们将首先编写自己的代码,以学习一些 Puppet DSL 的基础知识。然后,我们将使用来自 Puppet Forge 的模块。

安装 Chef 开发工具包和 Puppet 集合

Chef 生态系统的丰富程度与 Chef 本身的复杂性一样;有成千上万的工具,几乎填补了我们能想到的每一个任务。由于 Chef 是用 Ruby 编写的,所以很多这些工具也都是用 Ruby 编写的,多年来,工具、插件、代码和各种 Ruby 版本之间的依赖地狱导致了一个简单的解决方案——Chef DK。Chef DK 还带来了一些很好的工具和环境,这些工具和环境能够很好地协同工作。

我们将看到如何安装 Chef DK,并快速描述它包含的内容。

注意

当前的 Chef DK 版本是 1.1.16。

准备工作

要完成这个食谱,你将需要以下内容:

  • 一个互联网连接

  • 一台物理机或虚拟机

如何操作…

Chef DK 可以从 downloads.chef.io/chef-dk/ 下载。大多数平台都有对应的版本:Debian、基于 Red Hat 的系统、Ubuntu 和 Windows。只需下载与你的平台相对应的安装包并安装。例如,在使用最新版 Fedora 系统,并安装 Red Hat 包时,安装过程如下:

$ sudo dnf install chefdk-1.1.16-1.el7.x86_64.rpm

验证安装是否按预期工作:

$ chef --version
Chef Development Kit Version: 1.1.16

就是这样!我们开始编写 Chef 配方所需的一切都已准备好。

Chef DK 内容

Chef DK 包含了一些最好的工具,包括以下内容:

  • Chef:一个工作流工具

  • Berkshelf:一个做得比管理食谱依赖项更多的工具

  • Test Kitchen:一个功能齐全的集成测试框架

  • ChefSpec:用于 Chef 代码的简易单元测试

  • FoodCritic:用于质量和一致性的静态代码分析

Chef DK 还包含所有标准的 Chef 命令(例如,使用 chef-solochef-client 在节点上应用食谱,或使用 knife 操作开发者工作站上的 Chef 资源,以及其他工具)。

它是如何工作的…

整个 Chef 环境及其依赖项都部署在 /opt/chefdk 下。我们安装的包在该目录下创建了符号链接到 /usr/bin,该路径已在 $PATH 中:

$ ls -al /usr/bin/chef
lrwxrwxrwx. 1 root root 20 Oct  5 16:36 /usr/bin/chef -> /opt/chefdk/bin/chef

这种软件打包方式包括了所有依赖项,由于 Chef 强烈依赖 Ruby,因此 Chef DK 内嵌了一个 Ruby 版本,这个版本与系统中可能已安装的 Ruby 版本不会发生冲突:

$ /opt/chefdk/embedded/bin/ruby --version
ruby 2.3.1p112 (2016-04-26 revision 54768) [x86_64-linux]

还有更多内容…

从 Puppet 4.x 开始,Puppet Labs 提供了用于代理和服务器软件包的仓库。这些仓库被称为 Puppet Collections。至于 Chef,提供的包内嵌了 Ruby 版本。

本书中的所有示例都使用 Puppet 4.8(开源版本)开发。软件包可以从 docs.puppet.com/puppet/4.8/puppet_collections.html 下载。

首先,你需要在工作站上安装来自 Puppet Collectionspuppet-agent 包。即使我们不会使用 Puppet 管理它,这些包也会安装一些在接下来的示例中需要的命令。

一旦安装了该包,所有文件都将部署在 /opt/puppetlabs 下:

$ ls -la /opt/puppetlabs/bin/puppet
lrwxrwxrwx 1 root root 20 Sep 22 18:42 /opt/puppetlabs/bin/puppet -> ../puppet/bin/puppet
$ /opt/puppetlabs/puppet/bin/ruby -version
ruby 2.1.9p490 (2016-03-30 revision 54437) [x86_64-linux]

为了更方便地使用嵌入式 Ruby 版本,你需要将 /opt/puppetlabs/puppet/bin 添加到 $PATH 环境变量中。例如,在 Linux 系统上,可以通过在家目录中的 .bashrc 文件中添加以下行来实现:

export PATH=/opt/puppetlabs/puppet/bin:$PATH

另见

创建一个免费的托管服务器 Chef 账户和 Puppet 服务器

在首选的 Chef 客户端/服务器模式中,我们需要一个 Chef 服务器来集中管理所有信息和操作。我们可以自己搭建一个服务器,无论是用于测试还是生产(当然这会有维护开销),或者我们可以使用由 Chef 开发公司托管的Hosted Chef服务器。这里你将学习如何创建一个免费的 Hosted Chef 账户,这样我们就可以尽快开始使用 Chef 编程,而无需担心服务器部分。完成这第一步后,我们将下载 Chef 入门套件,这是一个包含完整工作 Chef 仓库的压缩包,内含示例角色和食谱,我们可以直接使用——我们将通过发送这个示例食谱到服务器,使用第一个knife命令。

注意

记住:knife是开发人员从工作站使用的命令,用于操作 Chef 服务器上的信息和资源。knife命令从未在 Chef 节点上使用。

准备工作

为了完成这个配方,你将需要以下内容:

  • 需要一个互联网连接

  • 在工作站上安装一个可用的 Chef DK

如何操作…

请按照以下步骤创建免费的托管服务器 Chef 账户以及 Puppet 服务器:

  1. 访问manage.chef.io/signup

  2. 填写相关信息,使用有效的电子邮件地址并进行验证。

  3. 点击电子邮件中的链接以验证你的账户。

  4. 创建一个你能记住的密码。

  5. 创建一个新的 Chef 组织。

  6. 下载入门套件

  7. 将入门套件解压到一个安全的位置:

    $ unzip chef-starter.zip
    Archive:  chef-starter.zip
     inflating: chef-repo/README.md
     inflating: chef-repo/cookbooks/starter/files/default/sample.txt
     inflating: chef-repo/cookbooks/starter/recipes/default.rb
     inflating: chef-repo/cookbooks/starter/attributes/default.rb
     inflating: chef-repo/cookbooks/starter/metadata.rb
     inflating: chef-repo/cookbooks/starter/templates/default/sample.erb
     inflating: chef-repo/cookbooks/chefignore
     inflating: chef-repo/.gitignore
     inflating: chef-repo/.chef/knife.rb
     inflating: chef-repo/roles/starter.rb
     inflating: chef-repo/.chef/iacbook.pem
    
    
  8. 使用knife命令验证与 Hosted Chef 的连接,并请求,例如,列出所有用户(这将返回你的用户):

    $ cd chef-repo
    $ knife user list
    iacbook
    
    
  9. 上传初始的starter食谱,仍然使用knife命令:

    $ knife upload cookbooks/starter
    Created cookbooks/starter
    
    

还有更多内容…

没有提供托管的 Puppet 服务器服务。我们需要部署自己的 Puppet 服务器。为了模拟一个小型基础架构,我们将使用 Vagrant 和 Ubuntu 虚拟机(有关 Vagrant 的更多信息,请参阅第一章,Vagrant 开发环境)。我们从一个单节点基础架构开始,仅包含一个 Puppet 服务器。以下是我们的 Vagrantfile:

vm_memory = 2048
vm_cpus = 2

unless Vagrant.has_plugin?("vagrant-hostmanager")
  raise 'vagrant-hostmanager is not installed!'
end 

Vagrant.configure("2") do |config|

    config.hostmanager.enabled = true
    config.hostmanager.manage_guest = true
    config.hostmanager.manage_host = true

    config.vm.define "puppet.pomes.pro" do |puppet|
        puppet.vm.box="bento/ubuntu-16.04"
        puppet.vm.hostname="puppet.pomes.pro"

        puppet.vm.provider :virtualbox do |vb|
                vb.memory = vm_memory
                vb.cpus = vm_cpus
        end

        puppet.vm.network :private_network, ip: "192.168.50.10"
        puppet.hostmanager.aliases = %w(puppet)
        puppet.vm.provision :shell, :path => "puppet_master.sh"

        puppet.vm.synced_folder "puppetcode", "/etc/puppetlabs/code/environments/production"
    end
end

这个 Vagrant 文件依赖于vagrant-hostmaster插件。如果你还没有安装该插件,你需要手动使用vagrant plugin install vagrant-hostmanager来安装它。这个 Vagrant 插件用于在托管的虚拟机和工作站中的/etc/hosts文件中创建主机条目。将使用共享文件夹直接从工作站编辑代码。

puppet_master.sh配置脚本如下:

#!/usr/bin/env bash

# Exit immediately if a command exits with a non-zero status
set -e

# puppetlabs URL
DEBREPO="https://apt.puppetlabs.com/puppetlabs-release-pc1-xenial.deb"

# Install the PuppetLabs repo
echo "Configuring PuppetLabs repo..."
debrepo=$(mktemp)
wget --output-document=${debrepo} ${DEBREPO}
dpkg -i ${debrepo}
apt-get update

# Install Puppet Server from puppetlabs
# This will remove puppet-common package provided by the vagrant box (if any)
echo "Installing Puppet..."
apt-get install -y puppetserver

# For tests, limit memory usage. 512m is enough
sed -i 's/2g/512m/g' /etc/default/puppetserver

# For tests, enable autosign for all csr
echo "autosign=true" | tee --append /etc/puppetlabs/puppet/puppet.conf

# Restart puppetserver
service puppetserver restart

# Ensure puppetserver is running and enable it on boot
/opt/puppetlabs/bin/puppet resource service puppetserver ensure=running enable=true

echo "Puppet server installed!"

在此示例中,我们使用的是来自 Puppet Labs 的Puppet Collections仓库中的捆绑 Puppet 服务器。为了简化操作并遵循本章中的配方,我们启用了自动签名功能。这意味着,当 Puppet 节点第一次连接服务器时,节点会生成一个 CSR(证书签名请求),并且 Puppet 服务器会自动签署它:随后的请求将被认证和加密。

让我们创建共享文件夹并启动 Vagrant:

mkdir puppetcode
vagrant up

我们现在有一个监听在 192.168.50.10 上的 Ubuntu Puppet 服务器,FQDN 为 puppet.pomes.pro。一个简短的名称 puppet 也已可用,并由 vagrant-hostmanager 插件填充。

注意

根据你的sudo配置,Vagrant 可能会要求你输入密码。这是由 vagrant-hostmanager 插件请求的,用于在你的工作站的/etc/hosts文件中创建条目。

自动引导 Chef 客户端和 Puppet 代理

当我们开始使用 Chef 时,首先要做的事情是确保 Chef 客户端已经在目标远程服务器上启动。为了让 Chef 客户端能够应用 Chef 代码,它首先需要在 Chef 服务器上进行配置和注册。幸运的是,这个过程非常简单。

准备中

要完成这个食谱,你需要以下内容:

  • 一台有 SSH 访问权限的远程服务器和用户

  • 工作站上需要有一个有效的 Chef DK 安装

如何操作……

假设我们已经有一个在某个地方运行的服务器,并且有一个可用的用户。我们可以构建的最小命令行如下:

  • 我们想要配置的主机的 IP 或 FQDN(1.2.3.4

  • 在 Chef 服务器上注册节点的名称(my_node_hostname

  • 用于连接到服务器的用户名(如果不是 root,则使用 sudoer)。

在工作站上导航到 Chef 仓库:

$ cd chef-repo

现在,让我们从工作站上通过远程方式在远程主机上安装 Chef 客户端,以一个示例的vagrant用户为例:

$ knife bootstrap 1.2.3.4 -N my_node_hostname -x vagrant --sudo

这将首先下载最新版本的 Chef 并安装它。然后,它将执行一次初始的chef-client运行,将节点在指定的名称下注册到 Chef 服务器。到这里将停止。

如果我们希望在引导完成后立即运行一个 cookbook(我们大概会想这样做),只需使用 -r 选项将 cookbooks 添加到运行列表,这样它们会立即执行。我们可以使用本章之前上传的 starter cookbook,但也可以使用任何其他你可能已经同步到 Chef 服务器上的 cookbook。

$ knife bootstrap 1.2.3.4 -N my_node_hostname -x vagrant --sudo -r "starter" 
[...]
192.168.146.129 resolving cookbooks for run list: ["starter"]
[...]
192.168.146.129 Recipe: starter::default
192.168.146.129   * log[Welcome to Chef, Sam Doe!] action write

还有更多……

使用 Puppet,在创建节点后我们需要安装 Puppet 代理。让我们在之前用于 Puppet 服务器的 Vagrantfile 中添加一个新节点:

vm_memory = 2048
vm_cpus = 2

unless Vagrant.has_plugin?("vagrant-hostmanager")
  raise 'vagrant-hostmanager is not installed!'
end 

Vagrant.configure("2") do |config|

    config.hostmanager.enabled = true
    config.hostmanager.manage_guest = true
    config.hostmanager.manage_host = true

    config.vm.define "puppet.pomes.pro" do |puppet|
        puppet.vm.box="bento/ubuntu-16.04"
        puppet.vm.hostname="puppet.pomes.pro"

        puppet.vm.provider :virtualbox do |vb|
                vb.memory = vm_memory
                vb.cpus = vm_cpus
        end

        puppet.vm.network :private_network, ip: "192.168.50.10"
        puppet.hostmanager.aliases = %w(puppet)
        puppet.vm.provision :shell, :path => "puppet_master.sh"

        puppet.vm.synced_folder "puppetcode", "/etc/puppetlabs/code/environments/production"
    end

 config.vm.define "web.pomes.pro" do |web|
 web.vm.box="bento/ubuntu-16.04"
 web.vm.hostname="web.pomes.pro"

 web.vm.network :private_network, ip: "192.168.50.11"

 web.vm.provision :shell, :path => "puppet_node.sh"
 end
end

如你所见,现在有另一个 shell 脚本 puppet_node.sh 用于新节点的配置:

#!/usr/bin/env bash

# Exit immediately if a command exits with a non-zero status
set -e

# puppetlabs URL
DEBREPO="https://apt.puppetlabs.com/puppetlabs-release-pc1-xenial.deb"

# Install the PuppetLabs repo
echo "Configuring PuppetLabs repo..."
debrepo=$(mktemp)
wget --output-document=${debrepo} ${DEBREPO}
dpkg -i ${debrepo}
apt-get update

# Install Puppet Agent from puppetlabs
# This will remove puppet-common package provided by the vagrant box
echo "Installing Agent..."
apt-get install -y puppet-agent

# Ensure puppet agent is stopped for our tests
/opt/puppetlabs/bin/puppet resource service puppet ensure=stopped enable=false

echo "Puppet agent installed!"

我们现在还拥有一个 Ubuntu Puppet 节点,其 FQDN 为 web.pomes.pro,IP 地址为 192.168.50.11。默认情况下,Puppet 代理会寻找名为 puppet 的服务器——这就是为什么这个名称被定义为 Puppet 服务器的别名。

注意

Puppet 代理已经被明确停止;在示例过程中,我们将根据需要启动它,以查看所有更改。

安装软件包

我们需要为服务器安装一些软件包。现在我们的服务器已经配置为使用 Chef 并与 Chef 服务器通信,接下来让我们安装一些软件包,如 Apache 服务器、PHP 和 MariaDB,在 CentOS 7.2 服务器上搭建经典的 LAMP 服务器。

准备中

要完成这个食谱,你需要以下内容:

  • 工作站上安装了可用的 Chef DK

  • 远程主机上的工作 Chef 客户端配置

如何操作……

要在基于 Red Hat 的系统上安装软件包,我们将使用yum(直到 CentOS 7)或dnf(Fedora 22 及其之后版本)。由于我们使用的是 CentOS 7 服务器,Apache2 HTTP 服务器包的名称是httpd(在基于 Debian 的系统中是apache2)。手动安装时,我们会输入以下内容:

$ dnf install httpd
$ yum install httpd

让我们看看这如何转化为一个可重复的过程,使用 Chef 食谱。

生成一个空的 Apache 食谱

让我们从 Chef 仓库的cookbooks文件夹中开始创建一个空的食谱,使用chef命令安装 Apache2:

$ cd chef-repo/cookbooks
$ chef generate cookbook apache
Generating cookbook apache
[...]
Your cookbook is ready. Type `cd apache` to enter it.
[...]
If you'd prefer to dive right in, the default recipe can be found at:
recipes/default.rb

现在我们需要告诉 Chef 使用package资源安装一个软件包。

打开apache/recipes/default.rb文件并输入以下内容:

package "httpd"

这是我们告诉 Chef 安装软件包的最基本方法。默认情况下,这将执行install操作。为了更加全面一些,我们可以使用完整的代码块来做同样的事:

package "httpd" do
  action :install
end

上传食谱

仍然在 Chef 仓库内,我们现在需要将这个新的apache食谱上传到 Chef 服务器,以便我们的服务器可以访问它。为此,我们在工作站上使用knife命令:

$ knife cookbook upload apache
Uploading apache         [0.1.0]
Uploaded 1 cookbook.

我们刚刚在 Chef 服务器上上传了我们的第一个食谱!

让我们确认食谱已经在 Chef 服务器上远程可用:

$ knife cookbook list
apache    0.1.0
starter   1.0.0

应用食谱

现在我们已经远程获得了apache食谱,让我们告诉 Chef 服务器,特定的节点必须运行它。这里有两个选项:

  • 在 Chef 服务器 UI 中,选择主机并点击编辑按钮,在运行列表框中,然后将正确的食谱名称拖放到当前运行列表列中:应用食谱

  • 从工作站的knife命令行界面,运行以下命令:

    $ knife node run_list add <nodename> apache
    nodename:
     run_list: recipe[apache]
    
    

无论如何,我们刚刚告诉 Chef 服务器在这台特定的服务器上应用apache食谱。让我们在远程节点上启动 Chef 客户端:

$ sudo chef-client
Starting Chef Client, version 12.15.19
resolving cookbooks for run list: ["apache"]
Synchronizing Cookbooks:
 - apache (0.1.0)
Installing Cookbook Gems:
Compiling Cookbooks...
Converging 1 resources
Recipe: apache::default
 * yum_package[httpd] action install
 - install version 2.4.6-40.el7.centos.4 of package httpd

Running handlers:
Running handlers complete
Chef Client finished, 1/1 resources updated in 32 seconds

Chef 已经为我们安装了 Apache HTTP 服务器包!如果我们启动 Chef 客户端,它不会再次安装,因为它知道已经安装了(看看执行时间的巨大差异):

$ sudo chef-client
[...]
Recipe: apache::default
 * yum_package[httpd] action install (up to date)
[...]
Chef Client finished, 0/1 resources updated in 04 seconds

验证软件包是否真的安装了:

$ which httpd
/usr/sbin/httpd
$ httpd -v
Server version: Apache/2.4.6 (CentOS)
Server built:   Jul 18 2016 15:30:14

创建 MariaDB 食谱

让我们利用我们的知识,像部署 Apache 一样从 Chef 仓库中创建一个 MariaDB 食谱:

$ chef generate cookbook cookbooks/mariadb

我们想要安装两个包:mariadb用于客户端和库,mariadb-server用于服务器。在mariadb/recipes/default.rb文件中添加以下内容:

package "mariadb" do
  action :install
end

package "mariadb-server" do
  action :install
end

或者,由于我们正在编写纯 Ruby 代码,让我们以更符合习惯的方式重写它:

%w(mariadb mariadb-server).each do |name|
  package name do
    action :install
  end
end

从工作站上传食谱:

$ knife cookbook upload mariadb

从工作站将mariadb食谱添加到远程节点的运行列表中:

$ knife node run_list add <nodename> mariadb

在远程主机上运行 Chef 客户端:

$ sudo chef-client
Starting Chef Client, version 12.15.19
resolving cookbooks for run list: ["apache", "mariadb"]
Synchronizing Cookbooks:
 - apache (0.1.0)
 - mariadb (0.1.0)
[...]
Recipe: mariadb::default
 * yum_package[mariadb] action install
 - install version 5.5.50-1.el7_2 of package mariadb
 * yum_package[mariadb-server] action install
 - install version 5.5.50-1.el7_2 of package mariadb-server
[...]
Chef Client finished, 2/3 resources updated in 25 seconds

验证 MariaDB 包是否正确安装:

$ which mysql
/usr/bin/mysql
$ mysql --version
mysql  Ver 15.1 Distrib 5.5.50-MariaDB, for Linux (x86_64) using readline 5.1

创建一个 PHP 食谱

让我们复用我们的知识,创建一个食谱来安装 PHP 支持所需的软件包:

$ chef generate cookbook cookbooks/php

让我们添加 Chef 代码,在 cookbooks/php/recipes/default.rb 文件中安装以下三个包:phpphp-cliphp-mysql 包(分别支持 PHP、命令行和 PHP/MySQL 支持):

%w(php php-mysql).each do |name|
  package name do
    action :install
  end
end

从工作站上传这个新的 php cookbook:

$ knife cookbook upload php

从工作站将 php cookbook 添加到远程节点的运行列表:

$ knife node run_list add vagrant php

在远程节点上运行 Chef 客户端:

$ sudo chef-client
$ php --version
PHP 5.4.16 (cli) (built: Aug 11 2016 21:24:59)

我们现在已经掌握了部署 cookbooks 和在远程节点上安装包的基本知识,使用 Chef!

还有更多……

使用 Puppet,通过 package 资源指令进行包的安装。以下示例展示了如何在 Ubuntu 系统上安装 Apache 2.x 服务器:

  package { 
      'apache2:
          ensure => installed;
  }

为了在 web.pomes.pro 服务器上部署 LAMP 服务器,我们需要 Apache2、PHP 和 MariaDB 服务器。为了做一个真实的示例,请执行以下步骤:

  1. 使用上一节的 Vagrantfile 启动 Vagrant。

  2. 进入 puppetcode 目录,这是工作站与 Puppet 服务器之间的共享文件夹:cd puppetcode

  3. 我们将创建三个模块(apachephpmariadb),因此让我们为它们创建一个简洁的模块布局:

    mkdir modules/apache
    mkdir modules/apache/manifests
    mkdir modules/apache/templates
    mkdir modules/php
    mkdir modules/php/manifests
    mkdir modules/php/templates
    mkdir modules/mariadb
    mkdir modules/mariadb/manifests
    mkdir modules/mariadb/templates
    
    
  4. 创建一个 module/apache/manifests/init.pp 清单文件,内容如下:

    class apache {
            package {'apache2':
               ensure => present,
          }
    }
    
  5. 创建一个 module/php/manifests/init.pp 清单文件,内容如下:

    class php {
            package {['php','php-mysql','libapache2-mod-php']:
               ensure => present,
          }
    }
    
  6. 创建一个 module/mariadb/manifests/init.pp 清单文件,内容如下:

    class mariadb {
            package {'mariadb-server':
               ensure => present,
          }
    }
    
  7. 最后,创建主要的清单 manifests/site.pp,内容如下:

    node 'web.pomes.pro' {
        class {
          'apache':;
          'php':;
          'mariadb':;
        }
    }
    

就这些!只需几行代码,所有必要的二进制文件就会被安装。现在我们可以使用 puppet agent --test 应用更改:

$ vagrant ssh web.pomes.pro
Welcome to Ubuntu 16.04.1 LTS (GNU/Linux 4.4.0-51-generic x86_64)
...
...
vagrant@web:~$ sudo -i
root@web:~# puppet agent --test
Info: Creating a new SSL key for web.pomes.pro
Info: Caching certificate for ca
Info: csr_attributes file loading from /etc/puppetlabs/puppet/csr_attributes.yaml
Info: Creating a new SSL certificate request for web.pomes.pro
Info: Certificate Request fingerprint (SHA256): 12:9E:DD:E5:85:C9:F2:56:92:1B:92:93:0A:3C:7B:00:DE:2A:45:C0:D9:F8:F6:D0:EC:9D:0B:6E:42:7E:74:33
Info: Caching certificate for web.pomes.pro
Info: Caching certificate_revocation_list for ca
Info: Caching certificate for ca
Info: Using configured environment 'production'
Info: Retrieving pluginfacts
Info: Retrieving plugin
Info: Caching catalog for web.pomes.pro
Info: Applying configuration version '1477085080'
Notice: /Stage[main]/Apache/Package[apache2]/ensure: created
Notice: /Stage[main]/Php/Package[php]/ensure: created
Notice: /Stage[main]/Php/Package[php-mysql]/ensure: created
Notice: /Stage[main]/Php/Package[libapache2-mod-php]/ensure: created
Notice: /Stage[main]/Mariadb/Package[mariadb-server]/ensure: created
Notice: Applied catalog in 59.77 seconds

注意

与你想象的不同,--test 选项确实会应用更改。此选项用于在更改后立即测试代码,并且包含其他选项,如 --no-daemonize--onetime--verbose。如果你只需要进行干运行(dry-run),可以使用 --noop 选项并结合 –test 使用。

另见

管理服务

我们已经看到如何使用 package 资源安装系统包。在本节中,你将学习如何使用名为 service 的资源来管理系统服务。我们将继续在上一节中开始构建的 LAMP 服务器,通过 Chef 来管理 Apache HTTP 和 MariaDB 服务。这样,我们就可以管理任何可用的服务。

准备工作

要完成此食谱,你需要以下内容:

  • 在工作站上安装一个正常工作的 Chef DK

  • 在远程主机上安装并配置一个正常工作的 Chef 客户端

  • 上一节中的 Chef 代码

如何做……

service资源的结构与package资源非常相似。我们希望对服务执行两个操作:启用它们在启动时运行,并立即启动它们。这转换为一个简单的 Chef 资源,包含一系列操作:

service "service_name" do
  action [:enable, :start]
end

启用并启动 Apache 服务

将此service资源添加到apache/recipes/default.rb文件中,紧接在package资源之后:

service "httpd" do
  action [:enable, :start]
end

apache/metadata.rb文件中更新食谱版本号:

version '0.2.0'

这样,Chef 服务器将始终保持版本0.1.0,只包含软件包安装,而新版本0.2.0则包括服务支持。我们还可以轻松回滚到先前运行的版本。

注意

当 Chef 客户端运行时,它默认总是下载并应用最新版本。建议在适当情况下固定(或锁定)版本——特别是在生产环境中。

上传新的食谱版本:

$ knife cookbook upload apache

现在我们有两个版本的食谱都可用在 Chef 服务器上:

$ knife cookbook show apache
apache   0.2.0  0.1.0

在远程主机上应用:

$ sudo chef-client

验证 Apache 服务是否确实在运行:

$ systemctl status httpd

你还可以通过 HTTP 访问站点的 IP 地址,查看显示的默认页面。

启用并启动 MariaDB 服务

mariadb/recipes/default.rb中的 MariaDB 的mariadb服务执行完全相同的操作:

service "mariadb" do
  action [:enable, :start]
end

不要忘记在mariadb/metadata.rb中也更新食谱版本:

version '0.2.0'

将更新后的食谱发送到 Chef 服务器:

$ knife cookbook upload mariadb

应用新的食谱:

$ sudo chef-client

确认 MariaDB 服务现在正在运行:

$ systemctl status mariadb

确认我们可以从节点访问 MariaDB 服务器:

$ mysql -e "show databases;"
+--------------------+
| Database           |
+--------------------+
| information_schema |
| test               |
+--------------------+

我们刚刚介绍了如何使用 Chef 管理系统服务,现在你知道如何轻松且反复地部署软件包并管理相应的服务。

还有更多…

使用 Puppet 时,服务也通过专门的资源指令进行管理。使用前面的示例,我们现在需要确保相应的服务正在运行。

这个资源需要在 Apache 和 MariaDB 模块中都添加。Apache 模块的新清单是:

class apache {
        package {'apache2':
           ensure => present,
        }

 service {'apache2':
 ensure => running,
 enable => true
 }
}

MariaDB 模块的新清单是:

class mariadb {
        package {'mariadb-server':
           ensure => present,
        }

 service {'mysql':
 ensure => running,
 enable => true
 }
}

ensure=>running属性用于检查服务是否正在运行(如果需要则启动它),而enable=>true用于在启动时启动服务。

注意

service资源中使用的服务名称与在根 Shell 中停止/启动/重载服务时使用的名称相同。

另见

管理文件、目录和模板

Chef 的一个非常有用的功能是能够直接通过 Chef 代码管理文件。可以复制普通文件,或通过模板生成动态文件。我们将利用这个功能来创建一个示例 PHP 测试文件,并动态生成 Apache 虚拟主机配置,用于我们的 LAMP 服务器,这样你就可以在其他地方复用它。

准备工作

要执行此配方,你需要以下内容:

  • 工作站上的有效 Chef DK 安装

  • 远程主机上的工作 Chef 客户端配置

  • 可选的,之前食谱中的 Chef 代码

如何操作…

我们将以两种不同的方式管理两种不同的文件:一个静态文件和一个从模板生成的动态文件,以便涵盖最常见的用例。

管理一个简单的静态文件

让我们开始创建一个基本的 PHP 文件,它将仅显示 phpinfo() 结果。这是通过简单的 file 资源实现的,使用文件路径作为参数,内容直接写在其中。file 资源的其他可选属性包括文件的所有者信息或文件权限。将一个 file 资源添加到 php/recipes/default.rb 配方中:

file '/var/www/html/phpinfo.php' do
  content '<?php phpinfo(); ?>'
  mode '0644'
  owner 'root'
  group 'root'
end

别忘了在 php/metadata.rb 中更新版本号:

version '0.2.0'

从你的工作站上传新的食谱:

$ knife cookbook upload php

使用远程节点上的 Chef 客户端进行部署:

$ sudo chef-client

如果你现在访问 node-hostname/phpinfo.php,你将看到 PHP 信息被展示出来。

这是发布普通文件的最静态方式。

从模板中管理动态文件和目录

现在让我们创建一个通用的 Apache 虚拟主机,以便全面控制 LAMP 服务器的配置,而不仅仅是使用我们 Linux 发行版自带的默认配置。我们希望网站的根文件夹是 /var/www/<sitename>,配置文件将位于 /etc/httpd/conf.d/<sitename>.conf。我们还将发布一个示例 HTML 索引文件,以验证我们是否正在运行正确的虚拟主机。

首先,使用 chef 命令在 Apache 食谱中生成一个新食谱,以管理默认的虚拟主机:

$ chef generate recipe cookbooks/apache virtualhost 

现在创建了一个名为 apache/recipes/virtualhost.rb 的新文件。

为了存储我们的虚拟主机名称,我们来创建一个 attribute。属性类似于持久化的节点设置,它在一个食谱中声明,并存放在 attribute 目录下的文件中,之后可以通过许多机制进行覆盖,这些机制我们将稍后学习。首先,使用 chef 命令生成一个属性文件:

$ chef generate attribute cookbooks/apache default

这将创建一个新文件,位于 apache/attributes/default.rb。要设置默认值为 defaultsitesitename 属性,请在此文件中添加以下内容:

default["sitename"] = "defaultsite"

要创建一个新目录,我们可以使用位于 apache/recipes/virtualhost.rb 文件中的名为 directory 的资源,并设置标准的访问权限。注意 Ruby 语法 #{node["sitename"]},它用于从字符串内访问节点属性,从现在开始将会频繁使用:

directory "/var/www/#{node["sitename"]}" do
  owner 'root'
  group 'root'
  mode '0755'
  action :create
end

让我们重新利用 file 资源,在 apache/recipes/virtualhost.rb 文件中创建一个基本的 index.html 文件,文件内容可以是类似 Hello from Chef! 的简单字符串,或者是你认为更合适的内容:

file "/var/www/#{node["sitename"]}/index.html" do
  owner 'root'
  group 'root'
  mode '0644'
  content '<html><h1>Hello from Chef!</h1></html>'
end

让我们再次使用 chef 生成器来创建一个新的模板,用于我们的 Apache 虚拟主机配置文件:

$ chef generate template cookbooks/apache virtualhost

这将创建一个位于 apache/templates/ 下名为 virtuahost.erb 的模板。这是一个标准的 ERB嵌入式 Ruby)模板。这个模板文件将包含我们站点的虚拟主机 Apache 配置。

让我们从用一个最小的 Apache 配置文件填充这个 ERB 文件开始,使用一个新的 website 变量,我们稍后会设置它。

注意

在 ERB 模板中,变量以 @ 字符作为前缀。

<VirtualHost *:80>
        ServerName <%= @website %>
        DocumentRoot /var/www/<%= @website %>
        ErrorLog /var/log/httpd/error-<%= @website %>.log
        CustomLog /var/log/httpd/access-<%= @website %>.log combined
        <Directory /var/www/<%= @website %>/ >
          Options Indexes FollowSymLinks MultiViews
          AllowOverride All
          Order allow,deny
          allow from all
        </Directory>
</VirtualHost>

这样,整个配置是动态的;我们可以为任何我们选择的站点名称实例化这个食谱,并且它将专门为该站点服务。

现在让我们使用 template 资源,从我们刚刚创建的模板生成一个文件,位于 apache/recipes/virtualhost.rb 文件中。这个资源接受一个 source 参数,即我们刚刚创建的模板文件,以及需要注入的变量。在我们的例子中,我们希望注入 sitename 属性的值,这样模板就可以通过 @website 访问它:

template "/etc/httpd/conf.d/#{node["sitename"]}.conf" do
  source "virtualhost.erb"
  owner 'root'
  group 'root'
  mode '0644'
  variables(
    :website => "#{node["sitename"]}"
  )
end

别忘了在 apache/metadata.rb 中更新食谱版本:

version '0.3.0'

从工作站上传食谱到 Chef 服务器:

$ knife cookbook upload apache

将新创建的食谱添加到远程节点的运行列表中:

$ knife node run_list add <node name> apache::virtualhost

在远程主机上应用新的食谱:

$ sudo chef-client

手动重启 Apache 服务器,以便让更改生效(放心,我们会在接下来的页面中自动化这个过程):

$ sudo systemctl restart httpd

验证所提供的页面是否是我们添加的那个:

$ curl http://node_ip_or_hostname/
<html><h1>Hello from Chef!</h1></html>

干得好!我们刚刚讲解了如何使用 Chef 的纯 Ruby 代码管理文件、目录以及动态模板。

还有更多内容……

现在我们已经拥有了一个配置了 Puppet 的 LAMP 服务器,让我们创建一个虚拟主机吧!我们的目标如下:

  • 移除 Ubuntu 提供的默认虚拟主机

  • 创建我们自己的虚拟主机,指定 DocumentRoot 和专用的日志文件

  • 部署一个简单的 PHP 页面,显示 phpinfo() 函数的结果。

这三个操作将通过 file 指令完成。

在 Ubuntu 上,我们需要移除默认的网站,以便让虚拟主机正常运行。这可以在 Apache 清单中轻松完成;通过 file 指令删除 /etc/apache2/site-enabled/000-default.conf,这将移除符号链接并禁用该站点:

class apache {
    package {'apache2':
       ensure => present,
    }

    service {'apache2':
       ensure => running,
       enable => true
    }

 file {'/etc/apache2/sites-enabled/000-default.conf':
 ensure => absent,
 }
}

现在让我们为虚拟主机生成创建代码。新虚拟主机的创建必须在 /etc/apache2/sites-available 中进行,并将从模板生成。有两种语言可用:

  • ERB 用于嵌入式 Ruby。

  • EPP 用于嵌入式 Puppet(Puppet 4 及以上版本)。让我们选择这个。

我们的 EPP 模板将使用两个参数:站点名称和文档根目录。让我们在 modules/apache/templates 目录中创建一个 vhost.epp 文件:

<VirtualHost *:80>
  ServerName <%=$website%>
  DocumentRoot <%=$docroot%>
  <Directory <%=$docroot%>>
    Order deny,allow
    Allow from all
    AllowOverride All
  </Directory>
  ErrorLog /var/log/apache2/error-<%=$website%>.log
  CustomLog /var/log/apache2/access-<%=$website%>.log combined
</VirtualHost>

现在我们需要实例化这个模板。最佳的做法是思考一些我们可以根据需要多次重用的东西(以防我们想要添加更多的站点)。

我们之前使用了一个class语句,但 Puppet 中的每个class在编译过程中每个清单中只能使用一次(记住,清单是节点编译的结果)。幸运的是,define语句用于定义一个可以多次使用的代码块。

所以让我们定义一个文件,module/apache/manifest/vhost.pp,它将使用这样的语句:

define apache::vhost (
     $website,
     $docroot
) {

  file { "/etc/apache2/sites-available/$website.conf":
     ensure  => present,
     owner   => 'root',
     group   => 'root',
     mode    => '0640',
     content => epp('apache/vhost.epp',
                     {'website' => $website, 
                      'docroot'=>$docroot}),
  }

  file { "/etc/apache2/sites-enabled/$website.conf":
     ensure  => link,
     target  => "/etc/apache2/sites-available/$website.conf",
     require => File["/etc/apache2/sites-available/$website.conf"],
  }
}

网站名称和文档根目录是我们apache::vhost语句的两个参数,并作为第一个file指令中与模板文件名一起传递给epp函数。

在 Ubuntu 中,要启用一个站点,必须在/etc/apache2/site-enabled中创建一个链接;第二个file指令将处理这一操作。

最后,我们需要在DocumentRoot目录下部署我们的 PHP 文件。这可以直接在主清单中使用file指令来创建DocumentRoot目录和文件本身:

node 'web.pomes.pro' {
    $website=$fqdn;
    $docroot="/var/www/$fqdn";

    class {
      'apache':;
      'php':;
      'mariadb':;
    }
    apache::vhost {$website:
       website => $website,
       docroot => $docroot,
    }
 file { $docroot:
 ensure => directory,
 owner  => 'www-data',
 group  => 'www-data',
 mode   => '0755',
 }
 file {"$docroot/index.php":
 ensure  => present,
 owner   => 'www-data',
 group   => 'www-data',
 mode    => '0644',
 content => "<?php phpinfo() ?>",
 }
}

我们现在可以再次运行 Puppet 代理。暂时,我们需要手动重启 Apache,以便我们的虚拟主机能够运行(至于 Chef,我们将在接下来的页面中自动化这一过程):

root@web:~# service apache2 reload

现在,你应该能在http://web.pomes.pro看到 phpinfo 页面。

另见

处理依赖关系

Chef 的一个非常巧妙的功能是能够从一个食谱中包含另一个食谱。通过这种方式,我们可以创建具有特定目的的食谱,比如一个产品或一个最终结果。例如,这样的食谱可以是一个名为MyCloudApp的应用程序食谱,里面包含对其他食谱(如 Apache、MySQL 等)的调用或引用。

到目前为止,我们一个接一个地将食谱添加到我们主机的运行列表中。这并不理想,特别是在管理大量节点时不太方便。这里的想法是创建一个新的食谱,专门用于一个假想的 MySite 应用程序,这个食谱会引用并依赖于所有其他食谱,这样我们只需要加载这个 MySite 食谱,就能完成所有操作。

准备就绪

要执行这个教程,你需要以下内容:

  • 工作站上安装了 Chef DK

  • 远程主机上有效的 Chef 客户端配置

  • 可选地,来自前一个食谱的 Chef 代码

如何做到这一点……

我们知道我们想创建一个新的食谱,命名为mysite,以便将与使这个应用程序运行相关的所有内容集中在同一个地方。让我们使用chef命令来实现这一点:

$ chef generate cookbook cookbooks/mysite

为了在我们的默认食谱中包含来自另一个食谱的内容,我们将使用include_recipe方法,位于mysite/recipes/default.rb中:

include_recipe "apache"
include_recipe "apache::virtualhost"
include_recipe "mariadb"
include_recipe "php"

这告诉 Chef 加载并执行每个食谱的内容。

为了让 Chef 知道这个位置在哪里,我们需要创建对这些食谱的依赖关系。可以在mysite/metadata.rb文件中完成:

depends "apache"
depends "mariadb"
depends "php"

现在,我们的 MySite 食谱有了一个不错的依赖关系图:为了完全工作,它需要 Apache、MariaDB 和 PHP。这个食谱详细列出了需要运行的内容。

由于我们有一个专门为我们的应用程序制作的食谱,让我们尝试为其添加一些自定义。还记得apache食谱中的默认sitename属性吗?让我们通过在文件顶部(在 apache 食谱包含之前)添加以下内容来覆盖它,以匹配我们自己的值:

node.override["sitename"] = "mysite"

将食谱上传到 Chef 服务器:

$ knife cookbook upload mysite

使用knife node run_list remove <node name> <recipe name>从节点的运行列表中移除之前的食谱:

$ knife node run_list remove vagrant "recipe[mariadb]" "recipe[php]" "recipe[apache]" "recipe[apache::virtualhost]"

节点的运行列表现在为空。只需添加新的mysite食谱,它包含了运行所需的一切:

$ knife node run_list add vagrant mysite

下次 Chef 客户端运行时不会更改任何内容,但将来管理起来会更容易!

还有更多……

使用 Puppet 时,模块可以在其他模块中使用。根据之前的示例,我们可以考虑一个mysite模块,具有以下清单:

class mysite (
   $website,
   $docroot
){
    class {
      'apache':;
      'php':;
      'mariadb':;
    }
    apache::vhost {$website:
       website => $website,
       docroot => $docroot,
    }
    file { $docroot:
      ensure => directory,
      owner   => 'www-data',
      group   => 'www-data',
      mode    => '0755',
    }
    file {"$docroot/index.php":
      ensure => present,
      owner   => 'www-data',
      group   => 'www-data',
      mode    => '0644',
      content => "<?php phpinfo() ?>",
    }
}

我们节点的主要清单如下所示:

node 'web.pomes.pro' {
    class {
      'mysite':
         website     => $fqdn,
         docroot  => "/var/www/$fqdn",
    }
}

另见

使用通知的更动态的代码

如果 Chef 知道在发生变化时如何以及需要重启什么,自动完成重启,这不是很好吗?在之前的示例中,我们向节点添加了一个新的虚拟主机,并且我们不得不手动重启 Apache 以使更改生效。幸运的是,Chef 中有一个名为notifications的机制,当资源发生变化时,它可以帮助触发一个操作。通过这种方式,改变虚拟主机可以自动触发 Apache HTTP 服务器的重启。

准备工作

要完成这个食谱,你需要以下内容:

  • 工作站上有效的 Chef DK 安装

  • 远程主机上有效的 Chef 客户端配置

  • 来自前一个食谱的 Chef 代码

如何做到这一点……

我们将从apache食谱开始,继续使用其 0.3.0 版本。现在将其版本提升到0.4.0,以便在apache/metadata.rb中从头开始:

version '0.4.0'

每个资源都可以在其状态变化时通知另一个资源执行某些操作,任何资源也可以订阅另一个资源的状态变化。在我们的案例中,我们希望 template 资源在虚拟主机模板变化时通知 httpd 系统服务重新启动,这样可以确保更改会自动生效。httpd 服务来自默认的 Apache 配方,因此最好现在就在 apache/recipes/virtualhost.rb 文件中包含它,这样我们可以确保这个特定的配方独立工作,而不是通过之前的包含副作用产生效果:

include_recipe 'apache::default'

  1. apache/recipes/virtualhost.rb 文件中,添加以下突出显示的通知部分:

    template "/etc/httpd/conf.d/#{node["sitename"]}.conf" do
      source "virtualhost.erb"
      owner 'root'
      group 'root'
      mode '0644'
      variables(
        :website => "#{node["sitename"]}"
      )
      notifies :restart, resources(:service => "httpd")
    end
    

    注意

    默认情况下,操作会在 Chef 运行结束时延迟执行。如果我们希望操作立即发生,尽管有可能会破坏系统状态,我们可以在行末加上 :immediately 定时器。

  2. 为了验证其工作原理,我们需要更改 apache/templates/virtualhost.erb 中的某些内容。在这个示例中,我只是设置了节点正在监听的本地 IP,但你可以根据自己的情况进行调整:

    <VirtualHost 192.168.146.129:80>
            ServerName <%= @website %>
            DocumentRoot /var/www/<%= @website %>
            ErrorLog /var/log/httpd/error-<%= @website %>.log
            CustomLog /var/log/httpd/access-<%= @website %>.log combined
    </VirtualHost>
    
  3. 现在上传更新后的食谱(我们已经更新了它):

    $ knife cookbook upload apache
    
    
  4. 在节点上运行 Chef 客户端,看看魔法是如何发生的:

    $ sudo chef-client
    [...]
     * template[/etc/httpd/conf.d/defaultsite.conf] action create
     - update content in file /etc/httpd/conf.d/defaultsite.conf from 6f4d47 to 05ea5b
     --- /etc/httpd/conf.d/defaultsite.conf   2016-10-17 01:05:49.243799676 +0000
     +++ /etc/httpd/conf.d/.chef-defaultsite20161017-14052-1xt951m.conf       2016-10-17 01:10:27.452670052 +0000
     @@ -1,4 +1,4 @@
     -<VirtualHost *:80>
     +<VirtualHost 192.168.146.129:80>
     ServerName defaultsite
     DocumentRoot /var/www/defaultsite
     ErrorLog /var/log/httpd/error-defaultsite.log
    [...]
    Recipe: apache::default
     * service[httpd] action reload
     - reload service service[httpd]
    
    

有趣的是,我们甚至可以在日志中看到更改的差异,这样我们始终知道发生了什么更改,还可以看到 httpd 服务在更改发生后被重新加载。

我们的系统现在完全动态化,并且可以在每次变化时随意重新加载其配置。

还有更多…

Puppet 具有完全相同的功能,使用 notify 属性。当 /etc/apache2/sites-enabled 的内容被修改时,Apache 配置需要重新加载。

让我们更改 Apache 清单来实现这一点。

当删除默认虚拟主机时,需要重新加载 Apache 配置,因此我们需要在 modules/apache/manifests/init.pp 中修改相应的 notify 属性:

class apache {
    package {'apache2':
       ensure => present,
    }

    service {'apache2':
       ensure => running,
       enable => true
    }

    file {'/etc/apache2/sites-enabled/000-default.conf':
       ensure => absent,
 notify => Service['apache2'],
    }
}

同样的逻辑适用于虚拟主机创建(modules/apache/manifests/vhost.pp):

define apache::vhost (
     $website,
     $docroot
) {

  file { "/etc/apache2/sites-available/$website.conf":
     ensure  => present,
     owner   => 'root',
     group   => 'root',
     mode    => '0640',
     content => epp('apache/vhost.epp', 
                      {'website' => $website, 
                       'docroot'=>$docroot}),
  }

  file { "/etc/apache2/sites-enabled/$website.conf":
     ensure  => link,
     target  => "/etc/apache2/sites-available/$website.conf",
     require => File["/etc/apache2/sites-available/$website.conf"],
 notify  => Service['apache2'],
  }
}

让我们尝试在全新的 Vagrant 虚拟机上运行 Puppet Agent,我们将看到这两项修改会安排重新加载配置,这将在 Puppet Agent 运行结束时执行。(参考包含 Scheduling refresh of Service[apache2]Triggered 'refresh' from 2 events 的行):

Notice: /Stage[main]/Apache/File[/etc/apache2/sites-enabled/000-default.conf]/ensure: removed
Info: /Stage[main]/Apache/File[/etc/apache2/sites-enabled/000-default.conf]: Scheduling refresh of Service[apache2]
...
...
Notice: /Stage[main]/Main/Node[web.pomes.pro]/Apache::Vhost[web.pomes.pro]/File[/etc/apache2/sites-enabled/web.pomes.pro.conf]/ensure: created
Info: /Stage[main]/Main/Node[web.pomes.pro]/Apache::Vhost[web.pomes.pro]/File[/etc/apache2/sites-enabled/web.pomes.pro.conf]: Scheduling refresh of Service[apache2]
Notice: /Stage[main]/Apache/Service[apache2]: Triggered 'refresh' from 2 events
Notice: Applied catalog in 45.46 seconds

现在我们可以访问 http://web.pomes.pro 上的 phpinfo 页面,而无需手动重启 Apache。

另见

使用 Chef 数据包和 Puppet 的 Hiera 中央共享数据

现在我们已经搭建好了 LAMP 基础设施,让我们通过创建一个 htaccess 文件并在其中添加一些授权用户来对其进行一定的安全加固。为了实现这个目标,我们可以使用不同的技术,但 Chef 中的 数据包 功能对我们的目标非常方便。数据包就是存储在 Chef 服务器上的 JSON 文件中的数据,可以从食谱中进行搜索。它特别适合存储需要从中心点全局访问的数据(例如用户、服务凭据、版本号、URL,甚至功能标志和其他类似的功能,具体取决于你的使用场景)。

准备工作

要完成这个食谱,你需要以下内容:

  • 工作站上的 Chef DK 安装

  • 远程主机上的工作 Chef 客户端配置

  • 前面食谱中的 Chef 代码

如何做...

我们的目标是创建两个用户——John 和 Mary。以下是所需信息的表格:

用户 密码 哈希值
John p4ssw0rd $apr1$AUI2Y5pj$0v0PaSlLfc6QxZx1Vx5Se
Mary s3cur3 $apr1$eR7H0C5r$OrhOQUTXfUEIdvWyeGGGy/

注意

要生成加密的密码,你可以使用简单的 htpasswd 工具:

$ htpasswd -n -b mary s3cur3
mary:$apr1$eR7H0C5r$OrhOQUTXfUEIdvWyeGGGy/

我们想将这些信息(用户名和密码)存储在一个实体中:这就是数据包。我们将其命名为 webusers,并将用户信息存储在该目录下。

  1. 让我们在 Chef 仓库中创建这个目录,以便于我们的版本控制系统(RCS,例如 git):

    $ mkdir -p data_bags/webusers
    
    
  2. 要在 Chef 服务器上创建数据包条目,请使用以下 knife 命令:

    $ knife data bag create webusers
    
    
  3. 正如我们所知,条目是简单的 JSON 结构化数据。让我们将用户 John 的数据包内容写入 data_bags/webusers/john.json

    {
      "id": "john",
      "htpasswd": "$apr1$AUI2Y5pj$0v0PaSlLfc6QxZx1Vx5Se."
    }
    
  4. 我们也要在 data_bags/webusers/mary.json 中对 Mary 进行相同操作

    {
      "id": "mary",
      "htpasswd": "$apr1$eR7H0C5r$OrhOQUTXfUEIdvWyeGGGy/"
    }
    
  5. 现在让我们使用 knife 命令将这些数据发送到 Chef 服务器:

    $ knife data bag from file webusers mary.json
    Updated data_bag_item[webusers::mary]
    $ knife data bag from file webusers john.json
    Updated data_bag_item[webusers::john]
    
    

注意

你可以使用 knife 命令查看数据包中的当前条目:

$ knife data bag show webusers
john
mary

现在数据在 Chef 服务器上全局可用,我们如何从代码内部动态访问它呢?这就是 Chef 中的 search 功能有用的地方,它可以帮助我们创建动态生成的内容。

在开始 mysite 食谱中的任何工作之前,让我们先在 mysite/metadata.rb 中更新版本号,确保不会破坏任何内容:

version '0.2.0'
  1. 让我们在 mysite 食谱下创建一个新的食谱,命名为 htaccess.rb,以便我们可以创建 /etc/httpd/htaccess 下的 htaccess 文件(这是一个任意位置,可以根据需要进行调整),并在 web 根目录下创建 Apache 配置文件:

    $ chef generate recipe cookbooks/mysite htaccess
    
    
  2. 为了使我们的条目自动填充到 htaccess 文件中,我们必须遍历所有现有条目。这是通过 Chef 中的 search 来完成的,指定数据包和搜索范围(在我们的情况下是所有内容)。这只是简单地添加到 mysite/recipes/htaccess.rb 文件中:

    users = search(:webusers, "*:*")
    
  3. 这个变量users将被传递到模板文件中,生成内容,就像我们之前做的那样——不过这次我们有多个条目,而不仅仅是一个。我们将使用htpasswd.erb文件作为源文件,稍后会创建它:

    template "/etc/httpd/htpasswd" do
      source "htpasswd.erb"
      owner 'root'
      group 'root'
      mode '0660'
      variables(
        :users => users
      )
    end
    
  4. 使用chef命令生成一个新的htpasswd文件模板:

    $ chef generate template cookbooks/mysite htpasswd
    
    
  5. mysite/templates/htpasswd.erb中的这个 ERB 文件中,输入以下内容:

    <% @users.each do |user| -%>
    <%= user["id"] %>:<%= user["htpasswd"] %>
    <% end -%>
    

    .each方法循环遍历我们通过模板传递的users变量,依次迭代user,并提取我们感兴趣的两个值:idhtpasswd

    在此过程中,让我们为.htaccess文件创建模板,放在我们的网站根目录下:

    $ chef generate template cookbooks/mysite htaccess
    
    

    它的内容是我们能找到的最基础的:

    AuthType Basic
    AuthName "Restricted Area"
    AuthUserFile /etc/httpd/htpasswd
    Require valid-user
    

    注意

    目前这个模板中没有变量。因为我知道文件通常最终会变得动态,我总是倾向于将它们作为模板开始,即使当前内容是静态的。很有可能在不久的将来,我们会想为AuthUserFile使用变量。

  6. 回到我们的mysite/recipes/htaccess.rb食谱,接下来我们添加刚刚创建的模板:

    template "/var/www/mysite/.htaccess" do
      source "htaccess.erb"
      owner 'root'
      group 'root'
      mode '0644'
    end
    

别忘了最后一步:我们必须从主食谱default.rb中调用这个新食谱!在mysite/recipes/default.rb中包含我们的新食谱,这样客户端就会加载它:

include_recipe "mysite::htaccess"

只需上传新版的食谱即可:

$ knife cookbook upload mysite

在你的节点上运行完chef-client后,网站将会被保护,用户maryjohn将能够使用基本的 HTTP 认证。

还有更多内容…

使用 Puppet 时,我们可以通过 Hiera 来实现。Hiera 可以看作是一个数据存储库,将站点信息保存在清单之外。Hiera 可以定制数据的存储方式,但这超出了本章的范围;我们将使用默认配置。

首先,我们需要在 Hiera 中定义数据。这将通过在 Hiera 树中创建web.pomes.pro.yaml来完成:

$ cd puppetcode/hieradata
$ mkdir nodes
$ cat > nodes/web.pomes.pro.yaml
webusers:
 - id: john
 htpasswd: $apr1$AUI2Y5pj$0v0PaSlLfc6QxZx1Vx5Se
 - id: mary
 htpasswd: $apr1$eR7H0C5r$OrhOQUTXfUEIdvWyeGGGy/
^D

这个文件现在包含了授权用户的哈希数组,适用于节点web.pomes.pro

从主清单文件中,我们需要使用以下代码查找 Hiera 数据:

$users=hiera('webusers');

现在很容易使用新的apache::htpasswd define语句生成密码文件,我们需要在modules/apache/manifests/htpasswd.pp中创建它:

define apache::htpasswd (
     $filepath,
     $users
) {

  file { "$filepath":
     ensure  => present,
     owner   => 'root',
     group   => 'root',
     mode    => '0644',
     content => template('apache/htpasswd.erb'),
  }
}

对应的模板这次我们使用一个 ERB 模板,位于modules/apache/templates/htpasswd.erb

<% @users.each do |user| -%>
<%= user['id'] %>:<%= user['htpasswd'] %>
<% end -%>

从主清单文件中,我们现在可以创建密码文件:

apache::htpasswd{'htpasswd':
       filepath => '/etc/apache2/htpasswd',
       users    => hiera('webusers'),
}

我们还需要创建一个.htaccess文件。让我们在modules/apache/manifests/htaccess.pp中创建一个新的apache::htaccess语句:

define apache::htaccess (
     $filepath,
     $docroot
) {

  file { "$docroot/.htaccess":
     ensure  => present,
     owner   => 'root',
     group   => 'root',
     mode    => '0644',
     content => template('apache/htaccess.erb'),
  }
}

关联的模板文件位于modules/apache/templates/htaccess.erb

AuthType Basic
AuthName "Restricted Area"
AuthUserFile <%= @filepath %>
Require valid-user

从主清单文件中,我们现在可以创建.htaccess文件:

apache::htaccess{"$docroot-htaccess":
   filepath => '/etc/apache2/htpasswd',
     docroot  => $docroot,
  }

结果是,以下是web.pomes.pro节点的主清单文件:

node 'web.pomes.pro' {
    $website=$fqdn;
    $docroot="/var/www/$fqdn";
 $users=hiera('webusers');

    class {
      'apache':;
      'php':;
      'mariadb':;
    }
    apache::vhost{$website:
       website => $website,
       docroot => $docroot,
    }
 apache::htpasswd{'htpasswd':
 filepath => '/etc/apache2/htpasswd',
 users    => hiera('webusers'),
 }
 apache::htaccess{"$docroot-htaccess":
 filepath => '/etc/apache2/htpasswd',
 docroot  => $docroot,
 }
    file { $docroot:
      ensure => directory,
      owner   => 'www-data',
      group   => 'www-data',
      mode    => '0755',
    }
    file {"$docroot/index.php":
      ensure => present,
      owner   => 'www-data',
      group   => 'www-data',
      mode    => '0644',
      content => "<?php phpinfo() ?>",
    }
}

运行 Puppet 代理后,http://web.pomes.pro现在会要求你输入登录名和密码。

另见

创建功能性角色

到目前为止,我们已经基于特定技术创建了食谱。我们为 MariaDB 创建了一个食谱,为 Apache HTTPd 创建了一个食谱,也为我们的应用程序(包括所有依赖项)创建了一个食谱。那么这些基础设施元素各自的角色是什么呢?一个数据库角色可以包括目前运行我们数据库的 MariaDB,但也许明天它可以运行其他内容(迁移回 MySQL,或切换到 PostgreSQL)。由于 Chef 中的角色有专门的运行列表,通常可以看到一个角色包括产品食谱及与之相关的所有内容,比如监控。例如,角色可以做更多的事情,比如覆盖属性或为每个环境提供不同的运行列表。在这里,我们将创建两个通用的数据库webserver角色,稍后可能会简单地在另一个仅需要这些服务的项目中复用它们,以及一个mysite角色,它将包括这两个角色。角色可以包括其他角色以及食谱。这样,从功能角度来看,mysite 角色就足够运行我们的基础设施了。

准备工作

要完成此配方,您需要以下内容:

  • 工作站上的 Chef DK 安装

  • 远程主机上的 Chef 客户端配置

  • 前面食谱中的 Chef 代码

如何做……

创建功能性角色的步骤:

  1. 我们可以使用普通的 JSON 或 Ruby 来编写角色。让我们尝试在roles/webserver.rb中为webserver角色使用 Ruby。它需要一个名称、描述和运行列表。这是最基本的要求:

    name "webserver"
    description "An HTTP server for our application"
    run_list "recipe[apache]"
    
  2. 对于我们的database角色做同样的事情;目前我们想使用我们的mariadb食谱。所以让我们把它写入roles/database.rb

    name "database"
    description "A database server for our application"
    run_list "recipe[mariadb]"
    
  3. 最后,让我们写下mysite角色,它将包括一个 webserver、一个数据库以及它自己的食谱,写入roles/mysite.rb

    name "mysite"
    description "MySite role"
    run_list(
      "role[webserver]",
      "role[database]",
      "recipe[mysite]"
    )
    
  4. 使用knife命令将角色发送到 Chef 服务器:

    $ knife role from file database.rb
    Updated Role database
    $ knife role from file webserver.rb
    Updated Role webserver
    $ knife role from file mysite.rb
    Updated Role mysite
    
    
  5. 现在,您可以编辑当前节点的运行列表(如果有的话),只使用这个角色(role[mysite]),或者如果您要为服务器引导系统,添加-r "role[mysite]"选项将引导 Chef 并执行带有此运行列表的 Chef:

    $ knife bootstrap 192.168.146.129 -N vagrant -x vagrant --sudo -r "role[mysite]"
    
    

现在,我们可以自由地为我们的角色添加更复杂的功能!

还有更多内容……

Puppet 没有提供角色功能。不过,可以通过使用角色和配置文件设计模式来添加一层抽象。在此模式中:

  • 角色是定义行为的类(例如,web 服务器)。这个类需要包括创建该角色所需的所有配置文件

  • 配置文件是用于管理基础技术的类(例如,安装 Apache)。

  • 在主清单中,节点只使用角色

使用此模式,当技术需要更改时,只重构配置文件类就更容易了。

另请参见

管理外部 Chef 食谱和 Puppet 模块

到目前为止,我们已经编写了自己的 cookbook,它们在当前状态下相当简单。实际上,我们可能会在真实的基础设施中遇到更复杂的配置。为了帮助我们,有两种外部 cookbook 我们可以使用:社区支持的 cookbook 和由 Chef 团队直接编写和维护的 官方 cookbook。要浏览可用的 cookbook,请访问 Chef Supermarket(可以把它看作是一个 cookbook 商店):supermarket.chef.io/

问题是,随着我们下载的 cookbook 越来越多,它们每一个都有各自的依赖项,我们的生活会变得越来越复杂。幸运的是,Chef DK 配备了一个非常适合这个用例的工具——Berkshelf。

Berkshelf 允许我们在一个文件中声明 cookbook 的依赖项、版本和位置,并通过一条命令上传运行 cookbook 所需的所有内容。

在这一部分,我们将从发行版默认的、未配置的 MariaDB 包迁移到一个完全配置的 MySQL 5.7——这个工作流非常接近生产环境中使用 Chef 的日常生活。

准备就绪

要完成这个配方,你需要以下内容:

  • 在工作站上安装一个正常工作的 Chef DK

  • 远程主机上配置好的 Chef 客户端

  • 之前配方中的 Chef 代码

如何操作…

我们从了解 Berkshelf 的工作方式开始。在 cookbooks/mysite/ 目录下,我们找到一个名为 Berksfile 的文件(如果你没有使用 chef 工具创建 cookbook,请手动创建该文件)。由于 Berkshelf 是按 cookbook 工作的,我们将在此处声明所有要运行该特定 cookbook 的依赖项,在我们的例子中,当前所有依赖项都是本地的。在这个 Berksfile 中,输入以下内容:

source 'https://supermarket.chef.io'

metadata

cookbook 'apache', path: '../apache'
cookbook 'php', path: '../php'
cookbook 'mariadb', path: '../mariadb'

这告诉我们三件重要的事情:

  • 如何找到未知的 cookbooks(在官方的 supermarket 上,我们可以使用我们自己的内部 supermarket,如果我们运行一个的话)

  • 找到依赖项的位置:在我们 cookbook 的 metadata 文件中

  • 每个 cookbook 存放的位置:在我们的例子中,是本地相对路径

metadata.rb 中更新 mysite cookbook 的版本,这样我们就不会干扰之前的工作,然后在 mysite cookbook 目录下,一次性上传我们所有 cookbook 的依赖项:

$ berks upload
Uploaded apache (0.5.0) to: 'https://api.chef.io:443/organizations/iacbook'
Uploaded mariadb (0.2.0) to: 'https://api.chef.io:443/organizations/iacbook'
Uploaded mysite (0.3.0) to: 'https://api.chef.io:443/organizations/iacbook'
Uploaded php (0.2.0) to: 'https://api.chef.io:443/organizations/iacbook'

现在我们开始意识到,它比手动逐一上传所有 cookbook 要快得多!

使用官方的 MySQL cookbook 及其依赖项与 Berkshelf

如我们所知,我们没有对 MariaDB 做任何特殊配置;我们只是从发行版的仓库中安装了它。这个需要改变!我们想要一个完整的 MySQL 部署。在查看 Chef Supermarket 时,我们注意到有一个官方的 MySQL cookbook 由 Chef 团队维护,目前是版本 8.0.4:supermarket.chef.io/cookbooks/mysql。它看起来做得很好,有很多配置选项,还有很多其他的功能。页面和页面的经过测试的、可靠的代码可以直接使用!不错。

根据 README 文件的描述,必须使用两个其他 cookbook 作为依赖项——selinuxyum-mysql-community。第一个是为了临时绕过 SELinux,第二个用于管理 RHEL 的官方 MySQL 社区仓库。我们可以手动解决这些依赖项,但我们有一个更好的主意:使用 Berksfile

让我们从 mysite/Berksfile 开始,将我们对自己 mariadb cookbook 的依赖替换为这个 cookbook:

查找以下代码:

cookbook 'mariadb', path: '../mariadb'

用以下代码替换先前的代码:

cookbook 'mysql', '8.0.4'

这样,我们可以确保始终只运行这个特定版本的 cookbook(8.0.4),而不会运行可能在生产环境中导致问题的新版本。

  1. 然后,从 mysql cookbook 中添加以下两个依赖项:

    cookbook 'selinux'
    cookbook 'yum-mysql-community', '~> 1.0'
    

    在这种情况下,我们声明了对任何版本的 selinux cookbook 的依赖,默认使用的是最新版本,并且对 yum-mysql-community 1.0 cookbook 的任何次要修订进行了宽松的约束。

  2. 在 cookbook 的 mysite/metadata.rb 文件中,做相同的操作,并将 mariadb 依赖项替换为三个新的依赖项:

    depends "mysql" , '~> 8.0'
    depends "selinux"
    depends "yum-mysql-community", '~> 1.0'
    
  3. 从 cookbook 目录中运行 berks 命令,获取新的 cookbook 依赖项:

    $ berks install
    Resolving cookbook dependencies...
    [...]
    Fetching cookbook index from https://supermarket.chef.io...
    Using mysql (8.0.4)
    Using selinux (0.9.0)
    Using yum-mysql-community (1.0.0)
    [...]
    
    
  4. 很好!它已自动下载我们的依赖项。现在上传它们:

    $ berks upload
    
    
  5. 现在,我们在 mysite cookbook 下创建一个新的配方,命名为 mysql,以便为我们的应用部署所需的 MySQL。在我们的例子中,我们需要的是最新的 MySQL 5.7,管理员密码为:super_secure_password

  6. 首先,在 metadata.rb 文件中将 cookbook 的版本提升到次要版本:

    version '0.3.1'
    
  7. 现在生成新的 mysql 配方,以便我们可以使用它:

    $ chef generate recipe cookbooks/mysite mysql
    
    
  8. 在新创建的 mysite/recipes/mysql.rb 文件中,首先包括文档中描述的所需新配方:

    include_recipe "selinux::disabled"
    include_recipe "yum-mysql-community::mysql57"
    
  9. 然后,按照文档说明,添加以下代码块,以便在默认端口(TCP/3306)上完全部署 MySQL 5.7:

    mysql_service 'default' do
      port '3306'
      version '5.7'
      initial_root_password 'super_secure_password'
      action [:create, :start]
    end
    

    注意

    这里发生的情况是,官方的 mysql cookbook 并没有在 cookbook 中创建任何内容。事实上,它通过提供一个 mysql_service 资源扩展了 Chef 的功能。在 Chef 的术语中,这称为 LWRP轻量级资源和提供者)。

  10. 最后,删除默认 mysite 配方中对 mariadb 配方的引用,并将其替换为对新 mysql 配方的调用:

    include_recipe "mysite::mysql"
    

在角色中包含依赖项

为了使我们的环境与 cookbook 中所做的完全匹配,让我们从 database 角色中删除对旧的 mariadb cookbook 的调用,且由于没有配方可调用(如我们所说,这个 cookbook 只是扩展了 Chef 功能),让我们改为添加文档中所述的两个 cookbook 依赖项,在 roles/database.rb 中进行更改:

name "database"
description "A database server for our application"
run_list(
  "recipe[selinux::disabled]",
  "recipe[yum-mysql-community::mysql57]"
)

使用 knife 命令上传更新后的角色:

$ knife role from file database.rb
Updated Role database

我们运行 mysite 角色的节点,调用数据库角色将没有问题。如果我们选择只运行包含 mysite::default 配方的节点,它也会正常工作。

使用 Berkshelf 上传 cookbook 依赖项

现在导航到mysite食谱目录,并使用 Berkshelf 中的upload功能,这样它将一次性上传所有必要的食谱:

$ berks upload

注意

使用 Berkshelf 时,依赖项的依赖项也会被上传!

测试 MySQL 部署

在节点上运行 chef-client,完成后确保我们可以使用提供的密码连接到本地 MySQL 服务器:

$ mysql -h 127.0.0.1 -uroot -psuper_secure_password -e "show databases;"
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

还有更多…

使用 Puppet 时,也有很多现成的代码可以使用。我们可以使用 Puppet Forge 或 GitHub 上的模块。例如,可以使用puppet module search命令搜索 Puppet Forge 上的模块:

$ puppet module search mysql | head -10
Notice: Searching https://forgeapi.puppetlabs.com ...
NAME                  DESCRIPTION          AUTHOR            KEYWORDS
puppetlabs-mysql      Installs, con...     @puppetlabs       mysql
example42-mysql       Puppet module...     @example42        mysql
gousto-mysql          Installs, con...     @gousto 
ULHPC-mysql           Configure and...     @ULHPC            mysql
aco-mysql_yumrepo     Puppet module...     @aco              mysql
BoxUpp-mysql          A puppet modu...     @BoxUpp           mysql
rgevaert-mysql        Manage your p...     @rgevaert         mysql
rocha-mysql           Resources to ...     @rocha            mysql

我们可以安装其中之一:

$ puppet module install puppetlabs-mysql
Notice: Downloading from https://forgeapi.puppetlabs.com ...
Notice: Installing -- do not interrupt ...
/Users/me/.puppetlabs/etc/code/modules
└─┬ puppetlabs-mysql (v3.9.0)
 ├── puppet-staging (v2.0.1)
 └── puppetlabs-stdlib (v4.13.1)

默认情况下,安装会在主目录下的隐藏文件夹中进行。我们可以看到 Puppet Labs 的 MySQL 模块依赖于另外两个模块。

可以使用多种工具来管理软件包,r10k 就是其中之一。它还可以管理环境(如 staging、development、production),但我们本章将重点讨论软件包管理。

我们需要做的第一件事是安装 r10k。在之前的示例中,我们直接在 Vagrant 使用的共享文件夹中的工作站上编辑代码,因此我们需要直接在工作站上安装 r10k:

$ sudo puppet resource package r10k  provider=puppet_gem 
Notice: /Package[r10k]/ensure: created
package { 'r10k':
 ensure => ['2.5.1'],
}
$ r10k version
r10k 2.5.1

r10k 使用一个名为Puppetfile的文件,在其中声明所有必要的模块。以下是Puppetfile的示例:

forge 'http://forge.puppetlabs.com'

mod 'puppetlabs/mysql'

不幸的是,在写作时,r10k 不支持依赖项,因此我们需要在Puppetfile中发现并添加它们。我们可以通过使用puppet module install手动安装模块来发现依赖项,就像我们之前所做的那样。不过,这不是很方便,幸运的是,我们可以使用外部工具,例如github.com/rnelson0/puppet-generate-puppetfile

让我们安装它:

$ sudo puppet resource package generate-puppetfile provider=puppet_gem
Notice: /Package[generate-puppetfile]/ensure: created
package { 'generate-puppetfile':
 ensure => ['0.10.0'],
}

现在,让我们发现 Puppet Labs MySQL 模块的依赖关系:

$ generate-puppetfile puppetlabs/mysql

Installing modules. This may take a few minutes.

Your Puppetfile has been generated. Copy and paste between the markers:

=======================================================================
forge 'http://forge.puppetlabs.com'

# Modules discovered by generate-puppetfile
mod 'puppet/staging', '2.1.0'
mod 'puppetlabs/mysql', '3.10.0'
mod 'puppetlabs/stdlib', '4.15.0'
=======================================================================

我们现在已经拥有了所有的依赖关系。

假设我们想要使用之前的示例,使用我们为 Apache 编写的代码和官方的 Puppet Labs MySQL 包。

为了做到这一点,让我们调整Puppetfile,以便下载 Puppet Labs 的官方 Mysql 模块,并保留我们现有的模块。我们需要通知 r10k 哪些模块是本地的。如果不这样做,r10k 将在删除模块目录中的所有内容后进行完整安装。以下是Puppetfile

forge 'http://forge.puppetlabs.com'

# Local modules
mod 'apache', :local =>true
mod 'php', :local =>true
mod 'mariadb', :local =>true

# Modules discovered by generate-puppetfile
mod 'puppet/staging', '2.0.1'
mod 'puppetlabs/mysql', '3.9.0'
mod 'puppetlabs/stdlib', '4.13.1'

现在我们需要运行 r10k 来安装软件包:

$ ls modules/
apache/  mariadb/ php/
$ r10k puppetfile install
$ ls modules/
apache/  concat/  mariadb/ mysql/   php/     stdlib/

让我们修改主清单以使用官方的 MySQL 包;我们需要删除对 MariaDB 模块的引用,并使用官方 MySQL 包提供的类:

node 'web.pomes.pro' {
    $website=$fqdn;
    $docroot="/var/www/$fqdn";
    $users=hiera('webusers');

    class {
      'apache':;
      'php':;
    }
 class { 'mysql::server':
 root_password => 'super_secure_password',
 }
    apache::vhost{$website:
       website => $website,
       docroot => $docroot,
    }
    apache::htpasswd{'htpasswd':
       filepath => '/etc/apache2/htpasswd',
       users    => hiera('webusers'),
    }
    apache::htaccess{"$docroot-htaccess":
       filepath => '/etc/apache2/htpasswd',
       docroot  => $docroot,
    }
    file { $docroot:
      ensure => directory,
      owner  => 'www-data',
      group  => 'www-data',
      mode   => '0755',
    }
    file {"$docroot/index.php":
      ensure => present,
      owner  => 'www-data',
      group   => 'www-data',
      mode    => '0644',
      content => "<?php phpinfo() ?>",
    }
}

让我们开始一个全新的 Vagrant 设置。在应用 Puppet 之后,我们现在可以使用我们在主清单中指定的 root 凭证连接 MySQL 服务器:

vagrant@web:~$ mysql -h 127.0.0.1 -uroot -psuper_secure_password -e "show databases;"
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
+--------------------+

如果需要,你可以浏览该模块的在线文档来创建自定义数据库和权限。

另见

第七章:使用 Chef 和 Puppet 测试和编写更好的基础设施代码

在本章中,我们将介绍以下配方:

  • 使用 Foodcritic 进行 Chef 代码的 Lint 检查,使用 puppet-lint 进行 Puppet 代码的 Lint 检查

  • 使用 ChefSpec 和 rspec-puppet 进行单元测试

  • 使用 Test Kitchen 对 Chef 进行基础设施测试,并使用 Beaker 对 Puppet 进行基础设施测试

  • 使用 ServerSpec 进行集成测试

介绍

在开发领域,良好的软件测试实践广泛应用,例如单元测试和集成测试。Linters(代码检查工具)也是软件开发人员每天使用的工具,几乎适用于大多数语言。幸运的是,这些技术通过我们使用的工具进入了基础设施领域;现在,基础设施基本上就是代码,它可以被分析、测试和报告!结合持续集成(CI)系统,编写经过充分测试的基础设施代码在不同层级上能够极大地帮助实现高质量的可持续代码,并防止出现本来会在之后破坏事物的意外回归。

在本章中,您将发现各种技术,通过使用 linter 和样式工具编写更简洁的代码,以确保我们的代码遵循高标准。您将学习如何对基础设施代码进行单元测试,比如 Chef 资源,并实现尽可能高的代码覆盖率,以确保代码中没有错误,也不会被无意修改。然后,我们将配置测试环境 Test Kitchen,它通过 Vagrant(或其他系统)利用虚拟机(VM)来执行测试套件。这将是我们的基础,以便编写集成测试,确保我们通过多个 cookbook 和代码来源实现预期的目标,并在真实系统上完成任务。

这些工具和技术对编写最佳基础设施代码至关重要,它们不仅强大,而且使用起来非常有趣!

所有配方都基于 Chef。然而,在可能的情况下,我们会尽量展示如何在 Puppet 中类似地工作,它是 Chef 的直接替代品。

使用 Foodcritic 进行 Chef 代码的 Lint 检查,使用 puppet-lint 进行 Puppet 代码的 Lint 检查

由于我们主要使用 Ruby 进行编码,我们可以在 Ruby 领域使用像 Rubocop 这样的常用 linter 工具。然而,Rubocop 默认是针对软件开发的,并没有特别针对 Chef cookbook 的开发进行优化。因此,Chef 开发了自己的 Rubocop 版本,命名为 Cookstyle。同时,Foodcritic 工具与规则结合,检查我们的代码是否符合社区公认的良好实践。我们将一起深入了解这些工具,最终让代码变得更加简洁、优雅。

准备工作

要执行这个配方,您需要以下工具:

  • 在工作站上安装 Chef DK

  • 远程主机上的 Chef 客户端配置

  • 来自第六章, 使用 Chef 和 Puppet 管理服务器的基础,或者任何自定义的 Chef 代码。

如何操作…

我们将研究并遵循两个互补工具——Cookstyle 和 Foodcritic 的建议。它们都能提供关于代码质量和可移植性的宝贵且互补的建议。让我们从最快最简单的——Cookstyle 开始。

Cookstyle

导航到 cookbook 根目录并输入以下命令:

$ cookstyle

这将输出所有关于清理代码的建议。

要获取更多关于建议的信息,包括带有更多信息的网址,请使用以下选项:

$ cookstyle -DES

如果你对建议感到满意,并希望自动将所有建议直接应用到代码中,请使用以下开关:

$ cookstyle -a

如果我们对 第六章 使用 Chef 和 Puppet 管理服务器的基础知识,我们编写的 Chef cookbook 应用 cookstyle,我们将得到两个不错的建议:

  • 当不需要插值时,使用单引号表示字符串

  • Ruby 1.9 版本中哈希的语法

由于这些都是有价值且推荐的改动,我们将在所有相关的 metadata.rb 文件中更新我们的 cookbook 版本,应用这些建议,并将新的小版本上传到 Chef 服务器。

Foodcritic

Foodcritic 的检查远远超过 Cookstyle,除了检查 Chef 代码中的不兼容、非幂等、重复或废弃的代码外,还会检查缺失的模板、文件、依赖关系或变量。所有规则都在 Foodcritic 网站上描述,网址是 www.foodcritic.io,同时提供示例和解释。

执行 foodcritic,进入 Chef 仓库并输入以下命令:

$ foodcritic <cookbook path>

例如,对于测试我们之前的 mysite cookbook(排除自动生成的 test 目录,因为它本身不是一个 cookbook),我们输入以下命令:

$ foodcritic --exclude test cookbooks/mysite
FC003: Check whether you are running with chef server before using server-specific features: cookbooks/mysite/recipes/htaccess.rb:7
FC033: Missing template: cookbooks/mysite/recipes/htaccess.rb:9
FC033: Missing template: cookbooks/mysite/recipes/htaccess.rb:19
FC064: Ensure issues_url is set in metadata: cookbooks/mysite/metadata.rb:1
FC065: Ensure source_url is set in metadata: cookbooks/mysite/metadata.rb:1

有趣!让我们从 FC003 开始 (www.foodcritic.io/#FC003)。我们的代码确实无法与其他 Chef 模式(如 chef-solo)一起使用,因为我们在代码中直接使用了 Chef 搜索,而 chef-solo 无法与 Chef 服务器交互。这里有两种选择:要么我们不在乎 chef-solo 的兼容性并排除此规则,或者我们关心并相应地修改代码。

要排除 FC003 规则,请使用 -t 选项:

$ foodcritic -t ~FC003 --exclude test cookbooks/mysite/
FC033: Missing template: cookbooks/mysite/recipes/htaccess.rb:9
FC033: Missing template: cookbooks/mysite/recipes/htaccess.rb:19
FC064: Ensure issues_url is set in metadata: cookbooks/mysite/metadata.rb:1
FC065: Ensure source_url is set in metadata: cookbooks/mysite/metadata.rb:1

或者,如果我们关心 chef-solo 的兼容性,让我们按照 FC003 规则提出的建议修改代码。更新 mysite cookbook 中的 mysite/metadata.rb 文件,并编辑 mysite/recipes/htaccess.rb 文件中的 users 搜索,加入对是否运行 chef-solo 的判断:

if Chef::Config[:solo]
  Chef::Log.warn('This recipe uses search. Chef Solo does not support search.')
else
  users = search(:webusers, '*:*')
end

使用 Berkshelf 上传 cookbook 的新版本:

$ berks upload

重新运行 foodcritic,警告信息消失了:

$ foodcritic --exclude test cookbooks/mysite

让我们继续检查建议。FC033www.foodcritic.io/#FC033)是关于缺少模板的。然而,我们的模板是通过chef工作流命令放置在mysite/templates目录下的。这就是为什么理解建议仅仅是“建议”如此重要的原因。Foodcritic 团队建议在 FC033 中强制要求在templates/default目录中存在默认模板。最终由你和你的团队决定是否遵循 Chef 或 Foodcritic 的推荐行为。我们决定遵循 Chef 并忽略此警告:

$ foodcritic -t ~FC033 --exclude test cookbooks/mysite/
FC064: Ensure issues_url is set in metadata: cookbooks/mysite/metadata.rb:1
FC065: Ensure source_url is set in metadata: cookbooks/mysite/metadata.rb:1

前两个警告(FC064FC065)仅与 Chef 超市发布的食谱相关,而这不适用于我们。让我们通过-t ~supermarket开关全局排除所有与超市相关的警告:

$ foodcritic -t ~FC033 -t ~supermarket --exclude test cookbooks/mysite/

现在没有更多的警告了;我们的食谱遵循了 Chef 和 Foodcritic 社区的最佳建议!

强烈建议将这些测试添加到自动化测试过程中。假设我们使用一个全局的Makefile来完成这一任务。请在 Chef 仓库的根目录创建它:

$ cat Makefile
tests:
 foodcritic -t ~FC033 -t ~supermarket --exclude test cookbooks/mysite

现在,你或某个 CI 系统可以自动检查代码的质量或质量回归。

还有更多…

使用 Puppet 时,puppet-lint 将帮助我们清理代码。我们需要使用以下命令安装 puppet-lint:

$ sudo puppet resource package puppet-lint provider=puppet_gem 

如果你已经熟悉 Puppet,你可能会看到我们在上一章中编写的代码不符合标准。让我们基于最新的 Apache 模块食谱,发现一些 puppet-lint 检测到的问题:

$ puppet-lint modules/apache/manifests/init.pp
WARNING: class not documented on line 1
ERROR: two-space soft tabs not used on line 3
...
$ puppet-lint modules/apache/manifests/vhost.pp
WARNING: defined type not documented on line 1
WARNING: variable not enclosed in {} on line 6
...
ERROR: trailing whitespace found on line 11
...
ERROR: two-space soft tabs not used on line 2
...
WARNING: indentation of => is not properly aligned
 (expected in column 34, but found it in column 31) on line 12
...
$ puppet-lint modules/apache/manifests/htpasswd.pp
WARNING: defined type not documented on line 1
WARNING: string containing only a variable on line 6
WARNING: variable not enclosed in {} on line 6
ERROR: two-space soft tabs not used on line 2
...
$ puppet-lint modules/apache/manifests/htaccess.pp
WARNING: defined type not documented on line 1
WARNING: variable not enclosed in {} on line 6
ERROR: two-space soft tabs not used on line 2

我们可以看到两类错误:

  • Puppet 编码风格警告/错误

  • 缺少文档

让我们尝试修复它们!

Puppet 编码风格

对于我们这里关注的问题,基本规则是:

  • 缩进需要使用两个空格字符。

  • 无尾随空格

  • 在字符串插值中,变量应被大括号括起来;例如,"$docroot/.htaccess"是错误的,必须是"${docroot}/.htaccess"

文档

文档应使用 Markdown 进行编写。如果你从未听说过 Markdown,它是一种用于格式化纯文本文档的语言,目的是将其导出为 HTML。使用 Markdown,你可以轻松地添加标题、链接、项目符号和字体效果。可以在www.markdowntutorial.com找到一个简短且互动的教程。

一个带有实时预览模式的 Markdown 编辑器可以在stackedit.io找到。

我们需要在模块的顶层目录创建一个README.md文件。该文件应包含简短的描述和一些使用示例。为了提高可读性,我们将重点关注安装和虚拟主机的定义。完整文档可以在代码包中找到。以下是modules/apache/README.md的摘录:

# Apache module

## Table of Contents

1\. Description
1\. Usage
    * Apache installation
    * Defining a vhost

## Description

Sample module for Apache on Ubuntu systems

## Usage

### installation

To install apache2:

class {

'apache':;

}


### vhost

To create a vhost:

apache::vhost{'mysite':

website => 'www.example.com',

docroot => '/var/www/example',

}

我们还需要记录所有语句及其参数,在每个清单顶部的注释中使用 @param 标签。遵循 puppet-lint 推荐的新代码如下:

  • 对于 modules/apache/manifests/init.pp

    # See README
    class apache {
      package {'apache2':
        ensure => present,
      }
    
      service {'apache2':
        ensure => running,
        enable => true
      }
    
      file {'/etc/apache2/sites-enabled/000-default.conf':
        ensure => absent,
        notify => Service['apache2'],
      }
    }
    
  • 对于 modules/apache/manifests/htpasswd.pp

    # @param filepath Path of the htpasswd database
    # @param users Array of hash containing users
    # See README
    define apache::htpasswd (
      $filepath,
      $users
    ) {
      file { $filepath:
        ensure  => present,
        owner   => 'root',
        group   => 'root',
        mode    => '0644',
        content => template('apache/htpasswd.erb'),
      }
    }
    
  • 对于 modules/apache/manifests/htaccess.pp

    # @param filepath Path of the htpasswd database
    # @param docroot DocumentRoot where the .htaccess should be generated
    # See README
    define apache::htaccess (
      $filepath,
      $docroot
    ) {
      file { "${docroot}/.htaccess":
        ensure  => present,
        owner   => 'root',
        group   => 'root',
        mode    => '0644',
        content => template('apache/htaccess.erb'),
      }
    }
    
  • 对于 modules/apache/manifests/vhost.pp

    # @param website Site name
    # @param docroot DocumentRoot
    # See README
    define apache::vhost (
      $website,
      $docroot
    ) {
      file { "/etc/apache2/sites-available/${website}.conf":
        ensure  => present,
        owner   => 'root',
        group   => 'root',
        mode    => '0644',
        content => epp('apache/vhost.epp', {
          'website'    => $website,
          'docroot' => $docroot}
        ),
      }
    
      file { "/etc/apache2/sites-enabled/${website}.conf":
        ensure  => link,
        target  => "/etc/apache2/sites-available/${website}.conf",
        require => File["/etc/apache2/sites-available/${website}.conf"],
        notify  => Service['apache2'],
      }
    }
    

文档可以自动生成一组 HTML 页面。为此,我们需要安装 yardpuppet-strings 包:

$ sudo puppet resource package yard provider=puppet_gem

$ sudo puppet resource package puppet-strings provider=puppet_gem

现在,从我们模块的顶级目录,可以生成文档:

$ puppet strings
Files:                    4
Modules:                  0 (    0 undocumented)
Classes:                  0 (    0 undocumented)
Constants:                0 (    0 undocumented)
Attributes:               0 (    0 undocumented)
Methods:                  0 (    0 undocumented)
Puppet Classes:           1 (    0 undocumented)
Puppet Defined Types:     3 (    0 undocumented)
Puppet Types:             0 (    0 undocumented)
Puppet Providers:         0 (    0 undocumented)
Puppet Functions:         0 (    0 undocumented)
 100.00% documented
$ ls -1 doc
_index.html
css/
file.README.html
frames.html
index.html
js/
puppet_class_list.html
puppet_classes/
puppet_defined_type_list.html
puppet_defined_types/
top-level-namespace.html

文档位于 doc 目录下。我们现在可以通过在浏览器中打开 index.html 来查看它。

另见

Puppet 语言风格:

使用 ChefSpec 和 rspec-puppet 进行单元测试

ChefSpec 是由伟大的 Seth Vargo(Opscode Chef,Hashicorp)编写的 Chef 食谱 RSpec 单元测试框架。ChefSpec 帮助创建快速反馈循环,在本地模拟 Chef 运行(独立或服务器模式),并为每个使用的资源生成代码覆盖率报告。它与 Berkshelf 集成得非常好,因此在测试过程中可以轻松处理食谱依赖项。

我们将为 第六章中创建的食谱编写单元测试,使用 Chef 和 Puppet 管理服务器的基础,涵盖最常见的测试,例如收敛问题、软件包安装、服务状态检查、文件和模板创建、访问权限、配方包含、模拟数据包搜索,甚至是拦截预期错误。这些测试非常通用,我们将能够在所有未来的配方中重复使用它们,并开始进行更多的测试。

准备工作

要逐步执行此配方,你将需要以下内容:

  • 工作站上的有效 Chef 安装

  • 远程主机上的有效 Chef 客户端配置

  • 来自第六章的 Chef 代码,使用 Chef 和 Puppet 管理服务器的基础,或任何自定义的 Chef 代码

如何实现……

ChefSpec 单元测试位于每个 Chef 食谱的 spec/unit/recipes 文件夹中。根据我们创建食谱的方式,这个文件夹可能已经存在。

为了说明这一点,我们从 第六章中的 apache 食谱开始,使用 Chef 和 Puppet 管理服务器的基础,但任何类似的自定义食谱同样适用。

如果 spec/unit/recipes 目录不存在,请通过执行以下命令来创建它:

$ mkdir -p spec/unit/recipes

spec/unit 中的 recipes 目录下可以找到 ChefSpec 单元测试,通常:

$ tree spec/
spec/
├── spec_helper.rb
└── unit
 └── recipes
 ├── default_spec.rb
 └── virtualhost_spec.rb

每个配方都会有其对应的 ChefSpec 文件。在这种情况下,我们的简单食谱包含两个配方,因此我们会有两个测试规范。

Spec 辅助工具

对于所有相关的食谱测试,拥有一组通用的要求是很有帮助的。默认情况下,它的文件名为spec_helper.rb,位于spec/unit目录的根目录下。我们建议至少包括以下三项要求:

  • ChefSpec 本身

  • 用于依赖管理的 Berkshelf 插件

  • 立即开始代码覆盖率

这是我们的示例spec_helper.rb文件:

require 'chefspec'
require 'chefspec/berkshelf'
ChefSpec::Coverage.start!

测试成功的 Chef 运行上下文

我们现在将单元测试默认的 apache 食谱配方。我们的第一步是引入在default_spec.rb文件中创建的 helper。这将在我们所有的未来测试中被引入:

require 'spec_helper'

所有单元测试都以描述性块开始,如这里所示:

describe 'cookbook::recipe_name' do 
  [...]
end

在这个代码块中,我们想要在模拟的 CentOS 7.2 环境中模拟 Chef 运行,使用默认属性。这就是上下文,我们期望这个 Chef 运行不会引发任何错误:

describe 'apache::default' do
  context 'Default attributes on CentOS 7.2' do
    let(:chef_run) do
      runner = ChefSpec::ServerRunner.new(platform: 'centos', version: '7.2.1511')
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end
  end
end

要找到我们可能需要的确切 CentOS 版本(过去或未来),我们可以访问 CentOS 镜像站点,mirror.centos.org/centos/,或者阅读github.com/customink/fauxhai/tree/master/lib/fauxhai/platforms上的所有模拟平台的完整列表。

使用chef exec rspec执行我们的第一个单元测试(它使用 Chef DK 中捆绑的rspec):

$ chef exec rspec --color
.....

Finished in 0.82521 seconds (files took 1.87 seconds to load)
5 examples, 0 failures

ChefSpec Coverage report generated...

 Total Resources:   2
 Touched Resources: 0
 Touch Coverage:    0.0%

Untouched Resources:

 yum_package[httpd]                 apache/recipes/default.rb:7
 service[httpd]                     apache/recipes/default.rb:11

我们可以看到模拟的 Chef 运行执行时间,以及覆盖率报告(目前为 0%,因为我们还没有测试任何内容)。ChefSpec 甚至会告诉我们哪些内容尚未进行单元测试!

一个不错的选项是文档 RSpec 格式化器,这样我们就能看到正在测试的内容描述。在本节的最后,我们将得到类似这样的输出,使用该格式化器:

$ chef exec rspec --format documentation --color

apache::default
 Default attributes on CentOS 7.2
 converges successfully
 installs httpd
 enables and starts httpd service

apache::virtualhost
 Default attributes on CentOS 7.2
 converges successfully
 creates a virtualhost directory
 creates and index.html file
 creates a virtualhost configuration file

Finished in 1.14 seconds (files took 2.56 seconds to load)
7 examples, 0 failures

ChefSpec Coverage report generated...

 Total Resources:   5
 Touched Resources: 5
 Touch Coverage:    100.0%

You are awesome and so is your test coverage! Have a fantastic day!

测试包安装

我们的默认配方首先安装httpd包。以下是在我们之前创建的上下文中,使用 ChefSpec 测试它的方法:

 it 'installs httpd' do
 expect(chef_run).to install_package('httpd')
 end

再次执行rspec,查看触摸覆盖率达到 50%,因为默认配方中的两个资源之一现在已被测试。

测试服务状态

默认配方启用并启动了httpd服务。以下是在之前创建的上下文中,使用 ChefSpec 测试这两个操作是否被代码处理的方式:

 it 'enables and starts httpd service' do
 expect(chef_run).to enable_service('httpd')
 expect(chef_run).to start_service('httpd')
 end

由于我们测试了两个声明的资源,现在默认配方的测试覆盖率达到了 100%。

测试来自同一食谱的另一个配方

由于我们在 apache 食谱中有两个配方,接下来我们将为第二个配方virtualhost_spec.rb创建测试。像第一个测试一样,以描述、上下文和初步的有效 Chef 运行测试开始:

require 'spec_helper'

describe 'apache::virtualhost' do
  context 'Default attributes on CentOS 7.2' do
    let(:chef_run) do
      runner = ChefSpec::ServerRunner.new(platform: 'centos', version: '7.2.1511')
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end
  end
end

执行 RSpec,查看覆盖率从 100%降至 40%。现在有三个新的资源未经过测试,来自apache::virtualhost配方:

$ chef exec rspec --color
[...]
ChefSpec Coverage report generated...

 Total Resources:   5
 Touched Resources: 2
 Touch Coverage:    40.0%

Untouched Resources:

 directory[/var/www/default]        apache/recipes/virtualhost.rb:8
 file[/var/www/default/index.html]   apache/recipes/virtualhost.rb:15
 template[/etc/httpd/conf.d/default.conf]   apache/recipes/virtualhost.rb:22

好消息是,ChefSpec 仍然告诉我们哪些资源尚未经过测试!

测试目录创建

这个特定的apache::virtualhost配方首先创建一个目录。以下是我们如何测试该目录是否存在以及其所有权参数:

    it 'creates a virtualhost directory' do
      expect(chef_run).to create_directory('/var/www/default').with(
        user: 'root',
        group: 'root'
      )
    end

代码覆盖率现在是 60%!

测试文件创建

同一个食谱随后创建了一个索引文件。这是我们如何测试它是否按所需的所有权被创建:

    it 'creates and index.html file' do
      expect(chef_run).to create_file('/var/www/default/index.html').with(
        user: 'root',
        group: 'root'
      )
    end

代码覆盖率现在为 80%!

测试模板创建

该食谱以从模板中创建 Apache VirtualHost 结束。以下是如何测试它是否按默认属性创建:

    it 'creates a virtualhost configuration file' do
      expect(chef_run).to create_template('/etc/httpd/conf.d/default.conf').with(
        user: 'root',
        group: 'root'
      )
    end

总的来说,我们现在已经覆盖了 100% 的资源!

正如输出所说:

You are awesome and so is your test coverage! Have a fantastic day!

为搜索虚拟数据包进行存根

我们之前创建的 mysite cookbook 包含一个搜索虚拟数据包的功能,以便稍后用内容填充文件。问题是,我们正在进行单元测试,没有真实的 Chef 服务器来响应请求。因此,测试失败了:模拟的 Chef 运行没有成功,因为无法执行搜索。幸运的是,ChefSpec 允许我们使用真实内容存根数据包。下面是在 mysite cookbook 中的 spec/unit/recipes/default_spec.rb 文件中是如何操作的:

describe 'mysite::default' do
  context 'Default attributes on CentOS 7.2' do
    let(:chef_run) do
      runner = ChefSpec::ServerRunner.new(platform: 'centos', version: '7.2.1511')
      runner.create_data_bag('webusers', {
 'john' => {
 'id' => 'john',
 'htpasswd' => '$apr1$AUI2Y5pj$0v0PaSlLfc6QxZx1Vx5Se.'
 }
 })
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end
  end
end

现在模拟的 Chef 运行中有一个 webusers 数据包,并且可以使用一些示例数据!

测试食谱的包含

在另一个食谱中包含食谱是非常常见的。通常,当使用通知从文件更改重新启动服务时,相关服务必须包含在文件资源所在的食谱中;否则,代码很可能是偶然工作的,因为所需的依赖 cookbook 可能在其他地方被包含!这是如何测试 cookbook 包含的方式:

    it 'includes the `apache` recipes' do
      expect(chef_run).to include_recipe('apache::default')
      expect(chef_run).to include_recipe('apache::virtualhost')
    end

现在我们确保依赖项始终被包含。

在测试中拦截错误

有时我们需要处理第三方 cookbooks,这些 cookbooks 可能会引发错误。这就是官方 MySQL cookbook 的情况,它依赖于 RHEL/CentOS 平台的 SELinux cookbook。由于某种原因,这个 cookbook 无法与 ChefSpec 一起使用,因此在运行时,它会报错 chefspec not supported!。ChefSpec 停止在此,并表示 Chef 运行出错。由于我们无法控制这种情况,这里有一个解决方法,用于期望 Chef 运行中的特定错误,之后它将多次派上用场:

    it 'converges successfully' do
      # The selinux cookbook raises this error.
      expect { chef_run }.to raise_error(RuntimeError, 'chefspec not supported!')
    end

我们已经看到了 Chef cookbook 中最常见和可重用的单元测试!

还有更多……

使用 Puppet 时,Puppet Labs 提供了一个包含多个有用工具的仓库,我们将在本章中使用——Puppet Labs Spec Helper。让我们安装它:

$ sudo puppet resource package puppetlabs_spec_helper provider=puppet_gem

对于单元测试,rspec-puppet 是 Puppet 中相当于 ChefSpec 的工具,并已作为 puppetlabs_spec_helper 的依赖安装。现在我们将为 Apache 模块中的每个清单添加单元测试。首先,我们需要一个 Rakefile 来创建所需的目标。幸运的是,puppetlabs_spec_helper gem 提供了这些目标。让我们在 Apache 模块的顶级目录中创建一个 Rakefile,内容如下:

require 'puppetlabs_spec_helper/rake_tasks'

所有单元测试应保持在 spec 目录中。在编写任何测试之前,我们还需要一个适用于所有测试的助手脚本。让我们在 spec/spec_helper.rb 中创建它。该文件应包含以下行:

require 'puppetlabs_spec_helper/module_spec_helper'

现在我们准备好编写单元测试了。我们的模块中有四个清单,我们将为每个清单创建一个单元测试。目标如下:

  • 对于apache/manifests/init.pp清单:单元测试需要验证清单是否正在编译,apache2包是否已安装,apache2服务是否正在运行并在启动时激活。

  • 对于apache/manifests/vhost.pp清单:单元测试应确保虚拟主机已在/etc/apache2/sites-available中创建,并在/etc/apache2/sites-enabled中激活。

  • 对于apache/manifests/htpasswd.pp清单:单元测试应确保htpasswd文件正确生成。

  • 对于apache/manifests/htaccess.pp清单:单元测试应确保.htaccess文件正确生成。

让我们尝试第一个!由于清单包含类声明,单元测试应放在spec/classes中。类名为apache,这将是包含测试的文件的基本名称。每个测试文件应以_spec.rb为后缀,因此让我们创建spec/classes/apache_spec.rb并添加以下内容:

require 'spec_helper'

# Description of the "apache" class
describe 'apache' do
  # Assertion list
  it { is_expected.to compile.with_all_deps }
  it { is_expected.to contain_package('apache2').with(
     {
      'ensure' => 'present',
    }
  ) }
  it { is_expected.to contain_service('apache2').with(
     {
      'ensure' => 'running',
      'enable' => 'true',
    }
  ) }
end

单元测试位于描述性块中,包含一系列断言。在这里,我们有三个在描述测试目标时提到的断言。

现在,让我们使用spec rake 目标运行单元测试:

$ rake spec
...

Finished in 2.42 seconds (files took 1.53 seconds to load)
3 examples, 0 failures

就是这样!我们的三个断言已成功测试!

另外三个测试应放在spec/defines下,因为对应的清单声明了define语句。让我们创建:

  • spec/defines/apache_vhost_spec.rb,其内容如下:

    require 'spec_helper'
    
    # Description of the "apache::vhost" 'define' resource
    describe 'apache::vhost', :type => :define do
    
      # As a requirement, we should load the apache class
      let :pre_condition do
        'class {"apache":;}'
      end
    
      # Define a title for the 'define' resource
      let :title do
        'mysite'
      end
    
      # Parameters list 
      let :params do 
        {
          :website => 'www.sample.com' , 
          :docroot => '/var/www/docroot',
        }
      end
    
      # Assertions list
      it { is_expected.to compile }
      it { is_expected.to contain_class('apache') }
      it { is_expected.to contain_file('/etc/apache2/sites-available/www.sample.com.conf')
        .with_content(/DocumentRoot \/var\/www\/docroot/) }
      it { is_expected.to contain_file('/etc/apache2/sites-enabled/www.sample.com.conf').with(
        'ensure' => 'link',
        'target' => '/etc/apache2/sites-available/www.sample.com.conf'
      ) }
    end
    
  • spec/defines/apache_htpasswd_spec.rb,其内容如下:

    require 'spec_helper'
    
    # Description of the "apache::htpasswd" 'define' resource
    describe 'apache::htpasswd', :type => :define do
    
      # As a requirement, we should load the apache class
      let :pre_condition do
        'class {"apache":;}'
      end
    
      # Define a title for the 'define' resource
      let :title do
        'myhtpasswd'
      end
    
      # Parameters list
      let :params do 
        {
          :filepath => '/tmp/htpasswd' , 
          :users => [ { "id" => "user1", "htpasswd" => "hash1" } ]
        }
      end
    
      # Assertion list
      it { is_expected.to compile }
      it { is_expected.to contain_class('apache') }
      it { is_expected.to contain_file('/tmp/htpasswd')
        .with_content(/user1:hash1/) }
    end
    
  • spec/defines/apache_htaccess_spec.rb,其内容如下:

    require 'spec_helper'
    
    # Description of the "apache::htaccess" 'define' resource
    describe 'apache::htaccess', :type => :define do
    
      # As a requirement, we should load the apache class
      let :pre_condition do
        'class {"apache":;}'
      end
    
      # Define a title for the 'define' resource
      let :title do
        'myhtaccess'
      end
    
      # Parameters list
      let :params do 
        {
          :filepath => '/tmp/htpasswd' , 
          :docroot => '/var/www/docroot',
        }
      end
    
      # Assertion list
      it { is_expected.to compile }
      it { is_expected.to contain_class('apache') }
      it { is_expected.to contain_file('/var/www/docroot/.htaccess')
        .with_content(/AuthUserFile \/tmp\/htpasswd/) }
    end
    

现在我们已经完成了所有单元测试,每个测试都验证了我们之前定义的初始目标。总的断言数为13,现在我们可以运行完整的测试套件:

$ rake spec
.............

Finished in 2.88 seconds (files took 1.52 seconds to load)
13 examples, 0 failures

注意

提供的 Rake 目标还包含一个lint目标,可以使用rake lint命令。我们可以直接使用这个目标,而不是像之前那样手动使用 puppet-lint。

另请参见

使用 Test Kitchen 对 Chef 进行测试基础设施,使用 Beaker 对 Puppet 进行测试

Test Kitchen 是 Chef 生态系统中的核心工具,它使基础设施代码的彻底测试成为可能,并且与我们已经使用和了解的许多其他工具非常兼容。它将开发世界中的强大测试文化应用于基础设施即代码环境。Test Kitchen 帮助启动一个隔离的系统环境,应用 Chef cookbooks,然后执行测试。支持的测试框架包括 RSpec,ServerSpec 或 Bats(还有更多),支持的环境包括 AWS,Vagrant,Digital Ocean,Docker 和 OpenStack。Test Kitchen 与 Berkshelf 集成得非常好,因此在测试复杂基础设施时,cookbook 的依赖关系不会成为问题。最棒的是,它已经包含在 Chef DK 中,我们只需使用它。

在这一部分,我们将构建所有必要的内容,以便使用 Vagrant 和 CentOS 7.2 正确测试我们的 Chef cookbooks 代码

注意

本文撰写时 Chef DK 中使用的 Test Kitchen 版本是 1.13.2。

准备就绪

要逐步执行此步骤,您需要以下内容:

  • 工作站上安装了可用的 Chef DK

  • 工作站上安装了可用的 Vagrant

  • 来自第六章的 Chef 代码,使用 Chef 和 Puppet 管理服务器的基础知识,或任何自定义 Chef 代码

如何操作……

Test Kitchen 由位于 cookbook 根目录的单个.kitchen.yml文件进行配置。它包含了大量信息:

  • 如何测试系统(默认使用 Vagrant)

  • 如何配置系统(chef-solo,chef-zero 或其他模式)

  • 测试哪些平台(Ubuntu 16.04,CentOS 7.2 或其他发行版)

  • 测试套件(应用什么,在哪里找到信息,在哪种上下文中,类似的信息)

配置 Test Kitchen

无论我们是否已经有了.kitchen.yml文件,都让我们打开它并填写以下详细信息:

  • 我们希望使用Vagrant运行测试,以便尽可能模拟生产环境中的虚拟机

  • 我们希望使用Chef Zero进行配置(通过在本地模拟 Chef 服务器)

  • 我们只希望在CentOS 7.2上进行测试(我们的代码目前不支持在其他平台上运行)

  • 我们希望只有一个测试套件,包含mysite::default配方的运行列表,以及Data Bags的路径

这是我们的.kitchen.yml文件在mysite cookbook 中的样子:

---
driver:
  name: vagrant

provisioner:
  name: chef_zero

platforms:
  - name: centos-7.2

suites:
  - name: default
    data_bags_path: "../../data_bags"
    run_list:
      - recipe[mysite::default]
    attributes:

使用 Test Kitchen 进行测试

要简单地启动具有指定配置的 Test Kitchen,请执行以下命令:

$ kitchen test
-----> Testing <default-centos-72>
-----> Creating <default-centos-72>...
[...]
 Finished creating <default-centos-72> (1m1.51s).
-----> Converging <default-centos-72>...
[...]
-----> Installing Chef Omnibus (install only if missing)
[...]
 resolving cookbooks for run list: ["mysite::default"]
 Synchronizing Cookbooks:
 - apache (0.5.0)
 - php (0.2.0)
 - selinux (0.9.0)
 - yum-mysql-community (1.0.0)
 - mysite (0.3.1)
 - mysql (8.0.4)
 - yum (4.0.0)
[...]
 Chef Client finished, 41/56 resources updated in 02 minutes 47 seconds
 Finished converging <default-centos-72> (3m18.96s).
-----> Setting up <default-centos-72>...
 Finished setting up <default-centos-72> (0m0.00s).
-----> Verifying <default-centos-72>...
 Preparing files for transfer
 Transferring files to <default-centos-72>
 Finished verifying <default-centos-72> (0m0.00s).
-----> Destroying <default-centos-72>...
 ==> default: Stopping the VMware VM...
 ==> default: Deleting the VM...
 Vagrant instance <default-centos-72> destroyed.
 Finished destroying <default-centos-72> (0m28.38s).
 Finished testing <default-centos-72> (4m48.86s).
-----> Kitchen is finished. (4m50.01s)

这里发生的事情如下:

  • Test Kitchen 读取了.kitchen.yml文件

  • Test Kitchen 使用指定的镜像创建了 Vagrant 虚拟机

  • Test Kitchen 安装了 Chef,同步了 cookbook,解决了 Berkshelf 中的依赖关系,并应用了run_list内容

  • Test Kitchen 启动了测试(目前我们没有测试)

  • Test Kitchen 销毁了虚拟机,因为一切都进展顺利

它是如何工作的……

当我们执行简单的kitchen test命令时,实际上是在执行五个步骤:

  1. kitchen create:这将创建虚拟测试环境(在我们的情况下,通过 Vagrant 和一个 hypervisor),但不进行配置。

  2. kitchen converge:这会使用我们创建的 .kitchen.yml 中的套件信息配置实例。因为我们正在使用带有 Chef 的 Test Kitchen,它首先安装 Chef,然后为我们解决 cookbook 的依赖关系。然后,它以请求的 Chef 模式(在我们的情况下是 chef-zero)应用 run_list

  3. kitchen setup:这将安装我们可能需要的任何额外插件。

  4. kitchen verify:这首先安装运行测试所需的所有内容 —— 在我们的情况下,这将是 ServerSpec。

  5. kitchen destroy:如果所有测试通过,此步骤将销毁测试环境。

我们强烈建议您按顺序使用每个命令进行调试。

供参考,因为这将在下一节讨论,所有测试都位于 test/integration/<suite_name>/<plugin_name> 文件夹中。换句话说,test/integration/default/serverspec/virtualhost_spec.rb 文件将匹配名为 virtualhost 的 Chef cookbook 配方,从 default Kitchen 测试套件执行,并使用 serverspec 插件进行测试。

还有更多…

Puppet 的对应工具是 Beaker。Beaker 的开发非常活跃,当前版本(6.x)需要至少 Ruby 2.2.5。为了使用 Puppet Collections 提供的嵌入式 Ruby,让我们保持在 5.x 分支上:

$ sudo puppet resource package beaker-rspec 
provider=puppet_gem ensure=5.6.0

注意

安装 Beaker 需要 C/C++ 编译器,因此在尝试安装 beaker-rspec 之前,请安装 gcc/g++ 或 clang。还需要 Zlib 库(二进制和头文件)。

我们还需要另一个包含辅助工具的 gem:beaker-puppet_install_helper。这个 gem 主要用于在测试期间在虚拟机中安装 Puppet:

$ sudo puppet resource package beaker-puppet_install_helper provider=puppet_gem

我们首先需要定义一个支持的平台列表,用于运行测试验收。每个平台必须在 spec/acceptance/nodesets 中的 YAML 文件中定义。由于我们的代码仅在 Ubuntu 上工作,请在 spec/acceptance/nodesets/default.yml 中定义一个平台:

HOSTS:
 ubuntu-1604-x64:
 roles:
 - agent
 - default
 platform: ubuntu-16.04-amd64
 hypervisor: vagrant
 box: bento/ubuntu-16.04
CONFIG:
 type: foss

正如您所看到的,我们将使用 Vagrant 作为 hypervisor,并使用 Ubuntu Xenial box。

注意

type: foss 表示将使用 Puppet 的开源版本。

现在我们可以运行 Beaker:

$ rake beaker
/opt/puppetlabs/puppet/bin/ruby -I/opt/puppetlabs/puppet/lib/ruby/gems/2.1.0/gems/rspec-support-3.6.0.beta1/lib:/opt/puppetlabs/puppet/lib/ruby/gems/2.1.0/gems/rspec-core-3.6.0.beta1/lib /opt/puppetlabs/puppet/lib/ruby/gems/2.1.0/gems/rspec-core-3.6.0.beta1/exe/rspec --pattern spec/acceptance --color
No examples found.
Finished in 0.00081 seconds (files took 0.14125 seconds to load)
0 examples, 0 failures

尚未定义任何验收测试,但我们将在接下来的页面中看到如何编写验收测试。

另请参阅

使用 ServerSpec 进行集成测试

集成测试在单元测试之后进行:现在我们正在真实的黑盒系统上测试实际功能。我们可能正在使用许多做着大量事情的菜谱,每个单元在早期阶段已经进行了测试,但它们在实际运行中是如何协同工作的呢?所有组件组装在一起,意图可能一致,但现实可能大相径庭。重写可能会重叠,遗漏的食谱可能会改变行为,服务可能无法启动,接着就会发生变化,回归问题可能会引入,或者更新后的系统或更新可能会导致故障;在真实系统中,事物出错的原因有无数种。这就是我们需要集成测试的原因;测试我们所有菜谱组合应用到真实测试系统后的结果,现在开始。

在 Chef 的情况下,我们有一个很棒的工具,叫做 Test Kitchen,帮助我们处理这个问题,我们之前已经安装并配置好了它来运行和执行测试。现在,让我们开始编写这些测试吧!

我们将编写集成测试,针对第六章中的mysite菜谱,使用 Chef 和 Puppet 管理服务器的基础,用于演示目的,但这些测试完全通用,可以在任何地方重用。我们将测试服务、文件、目录、yum 仓库、软件包、端口和注入内容。通过这种方式,我们可以确信我们编写的代码实际上在(模拟的)现实世界中做到了预期的功能!

注意

我们强烈建议您将这些集成测试添加到自动化 CI 系统中。这样,在代码变更后,测试可以自动启动,随着时间的推移,复杂度会随着更多测试用例的加入而飙升,这样您就不必再考虑它了:所有内容都会被测试,如果您的变更破坏了某些您未注意到的内容,您会在几秒钟内知道。没有人愿意在每次更改时,手动验证在三个版本的四种操作系统上是否没有出错。

准备工作

要完成这份食谱,您需要以下内容:

  • 工作站上安装好的 Chef DK

  • 工作站上安装好的 Vagrant

  • 来自第六章的 Chef 代码,或者任何自定义的 Chef 代码

如何操作……

根据我们测试的菜谱的创建方式,可以创建一个带有一些示例内容的test文件夹。我们不需要它,所以请确保清除test文件夹下的所有内容,从头开始。我们将使用第六章中的mysite菜谱,作为构建我们的 ServerSpec 测试的基础菜谱,但显然这些测试可以在任何地方使用:

$ cd cookbooks/mysite
$ rm -rf test/*

Test Kitchen 与测试套件一起工作,因此它期望在integration文件夹中有一个与套件名称相同的文件夹层次结构。一个default测试套件的最终文件夹层次结构将是mysite/test/integration/default/serverspec

$ mkdir -p test/integration/default/serverspec

创建 ServerSpec 辅助脚本

ServerSpec 至少需要两行配置,每个测试中都必须重复这些配置。为了避免重复,我们可以在test/integration/default/serverspec/spec_helper.rb中创建一个辅助脚本:

require 'serverspec'
# Required by serverspec
set :backend, :exec

现在我们所有的测试只需在文件顶部包含以下内容:

require 'spec_helper'

测试包的安装

我们的食谱做了很多事情,其中最重要的之一就是包的安装。这些内容之前已经进行了单元测试,但现在我们进入了集成阶段。这些包真的安装了吗?让我们通过在apache_spec.rb中编写测试来查明httpd包是否安装:

require 'spec_helper'

describe package('httpd') do
  it { should be_installed }
end

现在我们可以启动 Test Kitchen,看看这个特定的包是否真的安装了!

注意

在编写集成测试时,我们强烈建议使用 Test Kitchen 来创建/汇聚/设置/验证过程,而不是使用简单的kitchen test命令来一次性完成所有操作——手动方式要快得多!

类似地,测试php包在php_spec.rb文件中的内容将完全相同:

require 'spec_helper'

describe package('php') do
  it { should be_installed }
end

describe package('php-cli') do
  it { should be_installed }
end

describe package('php-mysql') do
  it { should be_installed }
end

测试服务状态

ServerSpec 允许我们测试实际的进程状态。在安装 Apache HTTPD 服务器的配方中,我们要求它启用并运行。让我们通过在apache_spec.rb文件中添加以下内容来验证它是否真是如此:

describe service('httpd') do
  it { should be_enabled }
  it { should be_running }
end

对于我们的 MySQL 安装,官方食谱中的文档指出,默认服务名称是mysql-default(而不是通常的mysqld)。在mysql_spec.rb文件中,添加以下内容:

describe service('mysql-default') do
  it { should be_enabled }
  it { should be_running }
end

测试监听端口

ServerSpec 是一个很棒的工具,可以测试监听端口。在我们的案例中,我们期望 Apache 监听80端口(HTTP),并且我们将 MySQL 配置为监听3306端口。将以下内容添加到apache_spec.rb文件中:

describe port('80') do
  it { should be_listening }
end

类似地,在mysql_spec.rb文件中添加以下内容来测试 MySQL:

describe port('3306') do
  it { should be_listening }
end

测试文件的存在性和内容

我们之前已经对在我们的食谱中创建这些文件的意图进行了单元测试,例如具有自定义名称的 VirtualHost,这会影响文件名和内容(这正是第六章的mysite食谱所做的,覆盖了自定义 apache 食谱的默认值)。它真的有效吗?让我们通过使用vhost_spec.rb测试虚拟主机配置来找出答案:

describe file('/etc/httpd/conf.d/mysite.conf') do
  it { should exist }
  it { should be_mode 644 }
  its(:content) { should match /ServerName mysite/ }
  it { should be_owned_by 'root' }
  it { should be_grouped_into 'root' }
end

这实际上证明了默认属性确实被mysite值覆盖,并且虚拟主机配置文件的内容也与该值匹配。这个食谱确实有效。

目录可以像这样在相同的vhost_spec.rb文件中进行测试:

describe file('/var/www/mysite') do
  it { should be_directory }
end

另一个有趣的测试是检查htpasswd文件的内容;在第六章,使用 Chef 和 Puppet 管理服务器的基础知识中,我们编写了一个食谱,向 Chef 服务器请求数据包中的授权用户。我们通过模拟数据包来进行单元测试,然后使用 Test Kitchen 配置它以模拟这些数据包的可用性。这个特定于 Chef 服务器的代码是否真的有效,并将john用户添加到htpasswd文件中,同时限制对该文件的访问?让我们通过在htaccess_spec.rb文件中添加以下内容来找出答案:

describe file('/etc/httpd/htpasswd') do
  it { should exist }
  it { should be_mode 660 }
  its(:content) { should match /john/ }
  it { should be_owned_by 'root' }
  it { should be_grouped_into 'root' }
end

测试仓库是否存在

我们的mysite食谱示例来自第六章,使用 Chef 和 Puppet 管理服务器的基础知识,它使用官方的 Chef 食谱来部署 MySQL,其中包括添加一个 yum 仓库。由于它现在是系统的重要组成部分,我们最好测试它的存在和状态!要测试 yum 仓库,可以将以下内容添加到mysql_spec.rb文件中:

describe yumrepo('mysql57-community') do
    it { should be_exist   }
    it { should be_enabled }
end

系统的许多其他部分也可以使用 ServerSpec 进行测试,特别是在网络方面(路由表、网关和接口)、Unix 用户和组、实际命令、cron 作业等。

还有更多内容…

使用 Puppet 和 Beaker,让我们尝试为我们的 Apache 模块编写验收测试。验收测试需要放置在spec/acceptance目录中。

我们需要定义一个将由所有验收测试共享的辅助文件。让我们创建一个spec/spec_helper_acceptance.rb文件,内容如下:

require 'beaker-rspec'
require 'beaker/puppet_install_helper'

# Install puppet
run_puppet_install_helper

RSpec.configure do |c|
  # Project root
  proj_root = File.expand_path(File.join(File.dirname(__FILE__), '..'))

  # Output should contain test descriptions
  c.formatter = :documentation

  # Configure nodes
  c.before :suite do
    # Install module 
    puppet_module_install(:source => proj_root, :module_name => 'apache')
  end
end

这个辅助文件将用于在测试机器上安装 Puppet,并将我们的apache模块填充到模块目录中。

作为主要apache类的第一个基本验收测试,让我们创建spec/acceptances/classes/apache_spec.rb,内容如下:

require 'spec_helper_acceptance'

describe 'Apache' do
  describe 'Puppet code' do
    it 'should compile and work with no error' do
      pp = <<-EOS
        class { 'apache': }
      EOS

      apply_manifest(pp, :catch_failures => true)
      apply_manifest(pp, :catch_changes => true)
    end
  end
end

该测试的目标如下:

  • 使用我们的类安装 Apache

  • 验证 Puppet 是否正确应用。

  • 验证第二次运行 Puppet 时不会改变任何内容:我们想证明代码是幂等的。

让我们进行测试吧!

$ rake beaker
...
...
Beaker::Hypervisor, found some vagrant boxes to create
Bringing machine 'ubuntu-1604-x64' up with 'virtualbox' provider...
...
...
Apache
 Puppet code
localhost $ scp /var/folders/k9/7sp85p796qx7c22btk7_tgym0000gn/T/beaker20161101-75828-1of1g5j ubuntu-1604-x64:/tmp/apply_manifest.pp.cZK277 {:ignore => }
localhost $ scp /var/folders/k9/7sp85p796qx7c22btk7_tgym0000gn/T/beaker20161101-75828-1l28bth ubuntu-1604-x64:/tmp/apply_manifest.pp.q2Z81Z {:ignore => }
 should compile and work with no error
Destroying vagrant boxes
==> ubuntu-1604-x64: Forcing shutdown of VM...
==> ubuntu-1604-x64: Destroying VM and associated drives...

Finished in 19.68 seconds (files took 1 minute 20.11 seconds to load)
1 example, 0 failures

在这个例子中,Beaker 创建了机器,安装了 Puppet,上传了我们的代码,应用 Puppet 两次以验证我们的测试,并销毁了机器。

若想获得更多关于 Puppet 代理安装和执行的日志,可以在nodeset文件中添加一行log_level: verbose

HOSTS:
  ubuntu-1604-x64:
    roles:
      - agent
      - default
    platform: ubuntu-16.04-amd64
    hypervisor: vagrant
    box: bento/ubuntu-16.04
CONFIG:
  type: foss
 log_level: verbose

现在,让我们扩展我们的测试,使用 Apache 模块中包含的所有代码。我们需要更新文件顶部的清单,以便执行以下操作:

  • 安装 Apache

  • 定义一个虚拟主机

  • 创建虚拟主机的根目录

  • 创建一个包含测试用户的htpasswd文件

  • 在根目录中创建一个.htaccess文件,使用之前的htpasswd文件

关于测试,我们希望:

  • 验证 Puppet 是否应用

  • 验证代码是幂等的

  • 验证 Apache 是否正在运行并在启动时激活

  • 验证 Apache 是否在监听

  • 验证虚拟主机是否已部署并且激活,且 DocumentRoot 配置正确

  • 验证 htpasswd 文件是否已部署并且内容正确

  • 验证 .htaccess 文件是否已部署并且内容正确

更新后的验收测试代码如下:

require 'spec_helper_acceptance'

describe 'Apache' do
  describe 'Puppet code' do
    it 'should compile and work with no error' do
      pp = <<-EOS
        class { 'apache': }
 apache::vhost{'mysite':
 website    => 'www.sample.com',
 docroot    => '/var/www/docroot',
 }
 apache::htpasswd{'htpasswd':
 filepath => '/etc/apache2/htpasswd',
 users    => [ { "id" => "user1", "htpasswd" => "hash1" } ],
 }
 file { '/var/www/docroot':
 ensure => directory,
 owner  => 'www-data',
 group  => 'www-data',
 mode   => '0755',
 }
 apache::htaccess{'myhtaccess':
 filepath => '/etc/apache2/htpasswd',
 docroot  => '/var/www/docroot',
 }
      EOS

      apply_manifest(pp, :catch_failures => true)
      apply_manifest(pp, :catch_changes => true)
    end
  end

 # Apache running and enabled at boot ?
 describe service('apache2') do
 it { is_expected.to be_enabled }
 it { is_expected.to be_running }
 end

 # Apache listening ?
 describe port(80) do
 it { is_expected.to be_listening }
 end

 # Vhost deployed ?
 describe file ('/etc/apache2/sites-available/www.sample.com.conf') do
 its(:content) { should match /DocumentRoot \/var\/www\/docroot/ }
 end

 describe file ('/etc/apache2/sites-enabled/www.sample.com.conf') do
 it { is_expected.to be_symlink }
 end

 # htpasswd file deployed ?
 describe file ('/etc/apache2/htpasswd') do
 its(:content) { should match /user1:hash1/ }
 end

 # htaccess file deployed ?
 describe file ('/var/www/docroot/.htaccess') do
 its(:content) { should match /AuthUserFile \/etc\/apache2\/htpasswd/ }
 end

end

现在,让我们再次尝试运行 Beaker:

$ rake beaker
…
…
Beaker::Hypervisor, found some vagrant boxes to create
Bringing machine 'ubuntu-1604-x64' up with 'virtualbox' provider...
…
…
Apache
 Puppet code
localhost $ scp /var/folders/k9/7sp85p796qx7c22btk7_tgym0000gn/T/beaker20161103-41882-1twwbr2 ubuntu-1604-x64:/tmp/apply_manifest.pp.nWPdZJ {:ignore => }
localhost $ scp /var/folders/k9/7sp85p796qx7c22btk7_tgym0000gn/T/beaker20161103-41882-73vqlb ubuntu-1604-x64:/tmp/apply_manifest.pp.0Jht7j {:ignore => }
 should compile and work with no error
 Service "apache2"
 should be enabled
 should be running
 Port "80"
 should be listening
 File "/etc/apache2/sites-available/www.sample.com.conf"
 content
 should match /DocumentRoot \/var\/www\/docroot/
 File "/etc/apache2/sites-enabled/www.sample.com.conf"
 should be symlink
 File "/etc/apache2/htpasswd"
 content
 should match /user1:hash1/
 File "/var/www/docroot/.htaccess"
 content
 should match /AuthUserFile \/etc\/apache2\/htpasswd/
Destroying vagrant boxes
==> ubuntu-1604-x64: Forcing shutdown of VM...
==> ubuntu-1604-x64: Destroying VM and associated drives...

Finished in 20.22 seconds (files took 1 minute 24.54 seconds to load)
8 examples, 0 failures

我们现在有了一个完整的 Apache 模块验收测试套件!

另见

第八章:使用 Chef 和 Puppet 维护系统

本章我们将介绍以下几种配方:

  • 使用计划的收敛性保持一致的系统

  • 创建环境

  • 使用 Chef 加密数据包和 Puppet 中的 Hiera-eyaml

  • 使用 Chef Vault 加密

  • 使用 Ohai 访问和操作系统信息

  • 自动化应用部署(以 WordPress 为例)

  • 使用 TDD 工作流

  • 为最坏情况做好准备——训练重建工作系统

引言

我们之前已经看到如何用代码自动化系统,以及如何正确测试这些代码。现在我们已经准备好迎接正式应用;有一整套特性、约束和目标需要正确设置。我们将希望将开发、测试和生产环境隔离开。我们需要确保基础设施代码在没有干预的情况下保持一致。安全性和机密性将开始成为一个问题,也许那些密码和密钥根本不应该以明文存储。几个月后,我们的自动化基础设施将扩展到大量的管理节点,且必须确保系统按照配置文件一致地运行——我们将需要收集和处理系统信息。最终,我们将直接从 Chef 部署 Web 应用。为了保持代码质量,即便我们的代码库越来越复杂,我们将采用测试驱动开发TDD)方法来优化工作流。最后,我们将确保随时准备重新部署任何基础设施部分(想象一下灾难发生时)。

所有的配方都是基于 Chef 的。然而,在可能的情况下,我们将尝试展示如何使用 Chef 的直接替代品——Puppet 来实现类似的功能。

使用计划的收敛性保持一致的系统

一旦初步部署并配置好系统,几乎无法想象仍然通过登录每台主机并启动chef-client命令来手动更新系统。使用 Chef 维护的系统可以在预定时间通过chef-client守护进程或 cron 作业进行收敛。我们将详细讲解这两种选项。

准备工作

要逐步执行此配方,你需要:

  • 在工作站上安装一个可用的 Chef DK

  • 在工作站上安装一个可用的 Vagrant

  • Chef 代码(可选)来自第六章,使用 Chef 和 Puppet 管理服务器的基础知识,第七章,使用 Chef 和 Puppet 编写更好的基础设施代码并进行测试,或任何自定义的 Chef 代码

如何操作…

我们建议你创建一个与其他 cookbook 不同的 cookbook,专门用于配置底层主机。我们称这个 cookbook 为common

$ cd chef-repo/cookbooks
$ chef generate cookbook common
$ cd common

要配置 Chef 客户端,有一个官方食谱名为chef-client。让我们在Berksfile中声明对它的cookbook依赖:

cookbook 'chef-client', '~> 7.0.0'

common/metadata.rb文件中,添加依赖项:

depends 'chef-client'

将 Chef 客户端作为守护进程使用

文档告诉我们,包含默认食谱将自动检测主机平台,并相应配置chef-client以作为守护进程运行。以下是启用chef-client的步骤:

  1. 将以下内容添加到recipes/default.rb中:

    include_recipe 'chef-client'
    
  2. 使用 Berkshelf 安装依赖项:

    $ berks install
    
    
  3. 现在上传common食谱及其所有依赖项:

    $ berks upload
    
    
  4. common食谱添加到主机的run-list中:

    $ knife node run_list add vagrant common
    
    
  5. 在目标主机上,最后一次启动 Chef 客户端以使其作为服务部署:

    $ chef-client 
    Recipe: chef-client::systemd_service
    [...]
     * service[chef-client] action enable
     - enable service service[chef-client]
     * service[chef-client] action start
     - start service service[chef-client]
     * service[chef-client] action restart
     - restart service service[chef-client]
    
    
  6. 日志看起来相当乐观,但我们还是再检查一下守护进程是否真的在主机上运行:

    $ systemctl status chef-client
    Ÿ
     chef-client.service - Chef Client daemon
     Loaded: loaded (/etc/systemd/system/chef-client.service; enabled; vendor preset: disabled)
     Active: active (running) since Mon 2016-11-07 01:35:05 UTC; 57s ago
     Main PID: 12943 (chef-client)
     CGroup: /system.slice/chef-client.service
     └─12943 /opt/chef/embedded/bin/ruby /usr/bin/chef-client -c /etc/chef/client.rb -i 1800 -s 300
    
    

chef-client服务已经启用并在运行!

调整收敛间隔时间

足够有趣的是,我们看到间隔是每 1800 秒(30 分钟)引入的。如果我们想要不同的收敛间隔,比如每 900 秒(15 分钟)呢?让我们来修改default.rb食谱:

node.override['chef_client']['interval'] = '900'
include_recipe 'chef-client'

metadata.rb中提升版本,上传新版本,等待新的chef-client执行,或者自己启动它以节省一些时间。systemd单元已更新:

$ systemctl status chef-client
Ÿ
 chef-client.service - Chef Client daemon
[...]
 └─13316 /opt/chef/embedded/bin/ruby /usr/bin/chef-client -c /etc/chef/client.rb -i 900 -s 300

现在我们的系统已经配置为每 15 分钟收敛一次,变动控制在 300 秒以内。

注意

我们强烈建议在每次新的主机部署过程中都包含这个common食谱,这样它们都能被自动配置为按照预定的间隔进行收敛。

将 Chef 客户端作为 cron 作业运行

在某些情况下,我们可能不希望将 Chef 客户端作为守护进程运行(例如内存或安全需求)。幸运的是,我们可以简单地退回到基于 cron 的简单方法。让我们修改默认的recipe.rb食谱以匹配这一点:

node.override['chef_client']['init_style'] = 'none'
include_recipe 'chef-client::cron'

使用 Berkshelf 上传这个食谱版本,并在目标主机上执行chef-client。查看 root 的crontab文件:

$ sudo crontab -l
# Chef Name: chef-client
0 0,4,8,12,16,20 * * * /bin/sleep 69;  /usr/bin/chef-client > /dev/null 2>&1

默认情况下,它每四小时执行一次chef-client,并在此情况下延迟69秒,以避免每个节点同时向 Chef 服务器发送请求。

调整 Chef 的 cron 作业

如果每四小时收敛一次还不够,你希望像我们在default.rb食谱中做的那样每 15 分钟收敛一次,以下是你需要做的:

node.override['chef_client']['init_style'] = 'none'
node.override['chef_client']['cron']['minute'] = '*/15'
node.override['chef_client']['cron']['hour'] = '*'

include_recipe 'chef-client::cron'

上传食谱并运行chef-client(或等待下一次计划的运行)。间隔现在已设置为每 15 分钟运行一次:

$ sudo crontab -l
# Chef Name: chef-client
*/15 * * * * /bin/sleep 69;  /usr/bin/chef-client > /dev/null 2>&1

还有更多…

在 Puppet 中,代理也可以作为服务或 cron 作业运行。

以下命令用于启用服务模式:

# puppet resource service puppet ensure=running enable=true

在此模式下,Puppet 代理将默认每 30 分钟应用一次配置。这个延迟可以在/etc/puppetlabs/puppet.conf中更改。以下是将延迟减少到五分钟的示例:

[agent]
  runinterval = 5m

要将 Puppet 代理作为 cron 作业运行,我们需要声明一个 Puppet cron 资源,如下所示:

# puppet resource cron puppet-agent ensure=present user=root minute=0 command='/opt/puppet/bin/puppet agent --onetime --no-daemonize --splay --splaylimit 60' 

生成的crontab文件是:

$ sudo crontab -l
0 * * * * /opt/puppet/bin/puppet agent --onetime --no-daemonize --splay --splaylimit 60

在本示例中,Puppet 代理将每小时运行一次。splay选项用于在运行之前引入一个随机延迟,该延迟不能超过 60 分钟(即splaylimit选项的值)。这对于有很多节点连接到同一个 Puppet 服务器的情况特别有用,可以将 Puppet 代理的请求分散到不同的时间点。

当然,如果您的基础设施中有许多节点,您应该创建一个包含这些 Puppet 资源的模块,并为每个节点包含该模块。基于我们之前的 Vagrant LAMP 环境设置,让我们创建一个本地模块,并创建一个文件module/baseconfig/manifests/init.pp,其内容为:

# @param agentmode Agent type: service or cron. If anything else, agent will be disabled. Default value: service
class baseconfig (
  $agentmode='service'
) {
  case $agentmode {
    'service': {
      $ensureservice='running';
      $enableservice=true;
      $ensurecron='absent'
    }
    'cron': {
      $ensureservice='stopped';
      $enableservice=false;
      $ensurecron='present'
    }
    default: {
      $ensureservice='stopped';
      $enableservice=false;
      $ensurecron='absent'
    }
  }
  service {'puppet':
    ensure => $ensureservice,
    enable => $enableservice,
  }
  cron {'puppet-agent':
    ensure  => $ensurecron,
    user    => root,
    minute  => 0,
    command => '/opt/puppet/bin/puppet agent --onetime --no-daemonize --splay --splaylimit 60',
  }
}

现在我们可以从主清单中定义所需的模式:

node 'web.pomes.pro' {
...
  class { 'baseconfig':
    agentmode => 'cron';
  }
...
}

如果将来在servicecron之间发生任何更改,我们的baseconfig模块将删除之前模式的配置。

另见

创建环境

一个经典的组织至少有两个运行基础设施的环境:开发环境和生产环境。通常会看到许多不同的环境,如暂存环境、测试环境、Alpha 或 Beta 环境。组织可以根据自身需求来建模基础设施,复杂性也可能迅速增长。好消息是,Chef 在将这一模型映射到基础设施时提供了很大帮助。有一组信息会在两个不同的环境中有所不同,例如菜谱版本或属性,而 Chef 尽可能简化了管理这些环境的过程。默认情况下,未设置环境的节点将运行在_default环境中。

在本节中,我们将介绍如何创建不同的环境,如何将节点(包括现有节点和新节点)设置到专用环境中,如何设置菜谱约束,最后如何在每个环境中覆盖属性。

准备工作

要逐步执行此配方,您需要:

  • 工作站上已安装的 Chef DK

  • 工作站上已安装的 Vagrant

  • Chef 代码(可选)来自第六章, 使用 Chef 和 Puppet 管理服务器的基础,第七章, 用 Chef 和 Puppet 编写和测试更好的基础设施代码,或任何自定义 Chef 代码

如何操作……

Chef 环境存储在名为environments的文件夹中,该文件夹位于chef-repo的根目录。如果该文件夹不存在,请创建它:

$ mkdir environments

创建生产环境

要创建生产环境,请按照以下步骤操作:

  1. 让我们从创建一个名为production.rbproduction环境开始:

    name 'production'
    description 'The production environment'
    
  2. 这是最简单的环境,它什么也不做。将其上传到 Chef 服务器:

    $ knife environment from file environments/production.rb
    Updated Environment production
    
    
  3. 列出可用的远程环境:

    $ knife environment list
    _default
    production
    
    

我们看到有两个可用环境:production_default

将环境设置为节点

要将已存在的节点设置为新的 production 环境,请执行以下命令:

$ knife node environment set my_node_name production
my_node_name:
 chef_environment: production

使用环境引导节点

如果我们正在使用 knife bootstrap 命令引导一个节点,我们可以从一开始就使用所需的环境(使用名为 vagrant 的用户,如前面的示例所示):

$ knife bootstrap a.b.c.d -N vagrant -x vagrant --sudo --environment production --run-list 'recipe[mysite]'

为环境修正配方版本

假设我们的生产系统运行的是版本为 0.3.1 的稳定 mysite 配方,但我们希望在开发基础设施中尝试同一配方的 0.4.0 版本新特性。由于每个配方版本可以共存,因此每个环境可以调用其自己的版本。production.rb 文件将包含以下内容,用于 production 环境:

cookbook_versions  'mysite' => '= 0.3.1'

development.rb 文件将包含以下内容,用于开发环境:

cookbook_versions  'mysite' => '= 0.4.0'

一个 Chef 环境文件可能包含多个配方约束,如下所示:

cookbook_versions: {
    'mysite': '= 0.4.0',
    'apache': '= 0.6.0'
}

为环境覆盖属性

每个环境可以覆盖任何值,在 Chef 中,这是覆盖的最高级别。没有其他内容可以覆盖为环境设置的值。所以,如果我们仅想将 sitename 属性的值覆盖为 production.rb,它将像这样:

override_attributes 'sitename' => 'mysite_production'

从配方中访问环境

节点的环境可以通过 node.chef_environment 属性从任何配方中访问。

所以,如果我们的目标是创建一个显示节点正在运行的环境的文件,我们需要创建一个像这样的模板:

Running in <%= @node.chef_environment %> mode.

还有更多内容...

使用 Puppet 时,环境位于 Puppet 服务器上的不同目录中。你可能在 第六章,管理 Chef 和 Puppet 服务器的基础 中注意到这一点;我们在 /etc/puppetlabs/code/environments/production 目录中创建了代码。

这是因为默认的 Puppet 环境是 production。其他环境,例如 test,应该在 /etc/puppetlabs/code/environments/ 下创建。

在 Puppet 服务器上手动创建环境

我们从 第六章,管理 Chef 和 Puppet 服务器的基础,开始,尝试创建一个新的环境,命名为 test。在 Puppet 服务器上,我们只需要执行如下操作:

$ sudo -s
# cd /etc/puppetlabs/code/environments/
# cp -a production test

节点环境选择

在节点端,使用的环境可以通过 --environment 控制:

# puppet agent --test --environment test

若要将此环境设置为默认环境,而不使用 --environment,我们可以在 /etc/puppetlabs/puppet/puppet.conf 中配置如下:

[agent]
environment = test

从清单中获取环境

对于 Chef,我们可以从任何清单中获取正在运行的环境名称。通过使用由 Puppet 服务器设置的$environment变量来实现。

为了说明这一点,让我们修改 index.php 文件(manifests/site.pp),分别在productiontest中进行修改:

file {"${docroot}/index.php":
    ensure  => present,
    owner   => 'www-data',
    group   => 'www-data',
    mode    => '0644',
    content => "<?php echo \"Running from ${environment}\" ?>"
}

我们现在可以在testproduction之间切换并查看更改:

# puppet agent --test
Info: Using configured environment 'production' 
…
@@ -1 +1 @@
-<?php echo "Running from test" ?>
+<?php echo "Running from production" ?>
…
# puppet agent --test --environment test
Info: Using configured environment 'test'
@@ -1 +1 @@
-<?php echo "Running from production" ?>
+<?php echo "Running from test" ?>

动态方式 – r10k

我们直接在 Puppet 主机中编辑了环境和代码,这是不推荐的。幸运的是,r10k(我们在第六章中已经使用过,用于安装模块)可以用来从 Git 仓库创建环境。每个 Git 仓库中的分支将被检出到一个独立的目录,并作为一个环境提供。这一特性是动态的:每次向 Git 仓库添加新分支,r10k 都会部署它。

让我们从工作站尝试一下。到目前为止,我们的 Vagrant 设置中共享文件夹的工作是将相对目录puppetcode映射到/etc/puppetlabs/code/environments/production路径上,位于puppet.pomes.pro箱中。我们即将使用多个环境,因此需要将映射更改为/etc/puppetlabs/code/

我们需要一个包含两个分支(productiontest)的 Git 仓库,里面包含所有先前的代码。一个示例可以在 github.com/ppomes/r10k_sample.git 找到。

r10k 工具需要一个全局配置文件,必须在与 Vagrantfile 同一级别创建,文件内容如下:

:sources:
  :my-repos:
    remote: 'https://github.com/ppomes/r10k_sample.git'
    basedir: 'puppetcode/environments'

现在让我们使用 r10k:

$ r10k -c ./r10k.yaml deploy environment -p
$ ls -l puppetcode/environments/
total 0
drwxr-xr-x  8 ppomes  staff   272B 26 Nov 16:40 production/
drwxr-xr-x  8 ppomes  staff   272B 26 Nov 16:40 test/

Git 仓库中的两个分支都已部署,我们现在可以启动 Vagrant,操作我们的盒子和分支。

注意

r10k 工具还会处理每个分支中的Puppetfile文件,正如我们在第六章中所看到的,使用 Chef 和 Puppet 管理服务器的基础知识,并部署外部模块(如果有的话)。

另请参见

使用 Chef 加密数据包和 Hiera-eyaml 与 Puppet

数据包中的一些信息可以安全地以明文存储在 Chef 服务器中,但在某些情况下,敏感信息加密后可能更为安全。公司可能不希望生产环境的 API 密钥、私钥或类似的敏感内容以明文形式存储在 Chef 服务器或第三方服务(如 GitHub)中。我们将看到如何在命令行中以及在 Chef 配方中对数据进行加密和解密。

准备工作

要执行此配方,你将需要:

  • 工作站上安装好的 Chef DK

  • 工作站上安装好的 Vagrant

  • 来自第六章的 Chef 代码(可选),使用 Chef 和 Puppet 管理服务器的基础知识,第七章,使用 Chef 和 Puppet 测试和编写更好的基础架构代码,或者任何自定义 Chef 代码

如何操作……

我们的目标是创建一个包含us-east-1区域 AWS 凭证的配置文件,且不允许将凭证以明文存储在 Chef 服务器上。我们希望使用数据包,因为它可以加密:

  1. 创建一个名为aws的数据包文件夹来存储us-east-1区域的凭证:

    $ mkdir data_bags/aws
    
    
  2. 在 Chef 服务器上创建数据包,顺便说一下:

    $ knife data bag create aws
    Created data_bag[aws]
    
    
  3. aws数据包文件夹内,创建一个示例文件us-east-1.json,其中包含凭证:

    {
      "id": "us-east-1",
      "aws_access_key": "AKIAJWTIBGE3NFDB4HOB",
      "aws_secret_key": "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"
    }
    

这是正常数据包的标准过程。如果我们现在按原样上传,它将不会被加密。

使用共享密钥加密数据包

使用加密数据包的解决方案是从我们的工作站发送加密数据。加密通过共享密钥完成,密钥可以是文件或字符串。我们使用字符串s3cr3t作为加密密钥(弱密钥)。为了简单地发送加密版本的数据包,我们可以使用knife命令的加密功能:

$ knife data bag from file --encrypt --secret s3cr3t aws us-east-1.json
Updated data_bag_item[aws::us-east-1]

如果我们请求数据但没有提供解密密钥,我们将从 Chef 服务器获取加密数据:

$ knife data bag show aws us-east-1
WARNING: Encrypted data bag detected, but no secret provided for decoding. Displaying encrypted data.
aws_access_key:
 cipher:         aes-256-cbc
 encrypted_data: RwbfsWgKk16sSCkMD38tXKGHmT1AHFGHRm/7fyzppye7wSS0kk19Zml0VuhQ
 XxxI

 iv:             iRRgrKfz6Ou2qdpYLkUA+w==

 version:        1
aws_secret_key:
 cipher:         aes-256-cbc
 encrypted_data: uSppKMYrRbEYn/njDYo3CIGC5tY+pptN1Z7LiARtNIU/zsllBNdSVENC1XwX
 QksifE6g00sdcHTGlHlVU0WJ0Q==

 iv:             ppjeAJcegZ9Yyn9rXgHRBQ==

 version:        1
id:             us-east-1

看起来我们得到了我们想要的:数据已经加密存储在 Chef 服务器上!

由于将未加密的数据包存储在版本控制系统(如 Git)中可能不是一个安全的做法,我们可以请求一个 JSON 格式的加密版本,如下所示,并将输出重定向到 JSON 文件以便存储:

$ knife data bag show aws us-east-1 -Fj
{
 "id": "us-east-1",
 "aws_access_key": {
 "encrypted_data": "RwbfsWgKk16sSCkMD38tXKGHmT1AHFGHRm/7fyzppye7wSS0kk19Zml0VuhQ\nXxxI\n",
 "iv": "iRRgrKfz6Ou2qdpYLkUA+w==\n",
 "version": 1,
 "cipher": "aes-256-cbc"
 },
 "aws_secret_key": {
 "encrypted_data": "uSppKMYrRbEYn/njDYo3CIGC5tY+pptN1Z7LiARtNIU/zsllBNdSVENC1XwX\nQksifE6g00sdcHTGlHlVU0WJ0Q==\n",
 "iv": "ppjeAJcegZ9Yyn9rXgHRBQ==\n",
 "version": 1,
 "cipher": "aes-256-cbc"
 }
}

这可能是你想存储到 Git 中的内容!

在 CLI 中访问加密数据包

要从 knife CLI 访问未加密的数据,过程与加密数据一样简单——将共享密钥作为参数传递:

$ knife data bag show aws us-east-1 --secret s3cr3t
Encrypted data bag detected, decrypting with provided secret.
aws_access_key: AKIAJWTIBGE3NFDB4HOB
aws_secret_key: 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
id:             us-east-1

现在我们可以访问我们的数据,但它是未加密的。

从食谱中使用加密数据包

现在数据已安全存储在 Chef 服务器上,我们如何从 Chef 食谱中访问它?假设我们的目标是创建一个名为/etc/aws/credentials的文件,里面包含 Chef 服务器上加密版本的未加密值。最终的文件应如下所示:

[region_name]
aws_access_key_id = the_access_key
aws_secret_access_key = the_secret_key
  1. 为此,在mysite食谱中创建一个名为aws的新食谱:

    $ chef generate recipe aws
    
    

    不要忘记相应地增加食谱版本和环境限制。

  2. 首先使用directory资源创建/etc/aws文件夹:

    directory "/etc/aws" do
      owner 'root'
      group 'root'
      mode '0755'
      action :create
    end
    
  3. 这是我们目标文件/etc/aws/credentialstemplates/aws.erb ERB 模板文件:

    [<%= @aws_region %>]
    aws_access_key_id = <%= @aws_access_key %>
    aws_secret_access_key = <%= @aws_secret_key %>
    

    我们看到模板期望aws_regionaws_access_keyaws_secret_key变量。让我们编写代码将这些值注入到aws.rb食谱中。首先,我们通过内联共享密钥s3cr3t访问加密的数据包项us-east-1,该数据包项位于aws数据包中:

    aws = Chef::EncryptedDataBagItem.load("aws", "us-east-1", 's3cr3t')
    

    注意

    如果需要,我们可以将所有这些信息设置为属性。如果选择文件方法来共享密钥,最后一个参数将是解密数据的密钥文件路径。

  4. 现在让我们创建模板,将解密后的凭据写入/etc/aws/credentials文件:

    template "/etc/aws/credentials" do
      source 'aws.erb'
      owner 'root'
      group 'root'
      mode '0600'
      variables(
        aws_region: aws['id'],
        aws_access_key: aws['aws_access_key'],
        aws_secret_key: aws['aws_secret_key']
      )
    end
    

我们完成了!现在,Chef 服务器已经安全地存储了加密数据。为了提高安全性,最好不要硬编码共享密钥——使用单独发送的密钥文件(但这会在部署系统中增加复杂性)。

还有更多…

在使用 Puppet 时,最好将凭据和站点信息存储在 Hiera 中,正如我们在第六章,使用 Chef 和 Puppet 管理服务器的基础知识中看到的那样。使用hiera-eyaml,可以加密敏感数据。使用我们之前的 LAMP 设置和 Vagrant,让我们尝试加密 MySQL 的 root 密码。

准备 Puppet 服务器。

我们需要为 Hiera 安装一个新的后端。我们还没有深入讨论 Hiera,现在是时候这样做了。Hiera 用于将数据存储在清单之外,并基于层级结构查找数据。安装 Puppet 服务器时会提供默认配置,位于/etc/puppetlabs/puppet/hiera.yaml

---
:backends:
  - yaml
:hierarchy:
  - "nodes/%{::trusted.certname}"
  - common

:yaml:
:datadir:

在这里,定义了一个yaml后端,允许我们在环境的hieradata目录中使用yaml文件。然后,定义了一个层级结构。Puppet 将首先尝试查找与客户端证书名称(即 FQDN 节点)匹配的yaml文件,并将其放置在nodes子目录下。如果没有找到数据,Puppet 将尝试查找common.yaml文件。

使用hiera-eyaml时,我们需要声明一个新的后端来查找加密文件中的数据。这个后端是eyaml,默认情况下,我们将查找扩展名为.eyaml的文件。这个后端依赖于一对密钥来读取数据,因此我们需要生成这些密钥。

幸运的是,存在一个名为puppet/hiera的 Puppet 模块来处理这一切。因此,我们只需将其与依赖项添加到Puppetfile中(不要忘记运行r10k puppetfile install):

mod 'puppetlabs/inifile'
mod 'puppet/hiera'
mod 'puppetlabs/puppetserver_gem' 

使用这个模块,现在可以非常轻松地使用以下命令准备 Puppet 服务器:

node 'puppet.pomes.pro' {

  # Create a service resource for the puppetserver
  # This is needed by the hiera module, in order
  # to restart the server once hiera-eyaml is installed
  service {'puppetserver':
    ensure => running,
  }
  # Configure hiera
  class { 'hiera':
    hierarchy => [
      'nodes/%{::trusted.certname}',
    ],
    eyaml          => true,
    manage_package => true,
    provider       => 'puppetserver_gem',
    master_service => 'puppetserver',
  }
}

这段代码将会:

  • 为 Puppet 服务器声明一个服务资源。这是puppet/hiera模块所需的(请参见参数master_service)。

  • 在 Puppet 服务器中安装eyaml后端。

  • 更新 Hiera 配置以便使用这个后端。

  • 生成私钥和公钥。

  • 重启 Puppet 服务器。

私钥和公钥将分别放置在 /etc/puppetlabs/puppet/keys/private_key.pkcs7.pem/etc/puppetlabs/puppet/keys/public_key.pkcs7.pem 中。

准备工作站

为了准备工作站,按照以下步骤操作:

  1. 要创建和编辑加密数据,我们需要eyaml。让我们通过以下命令来安装它:

    $ sudo puppet resource package hiera-eyaml provider=puppet_gem
    
    
  2. 让我们从 Puppet 服务器复制密钥,并将它们存储在 $HOME 下的 keys 文件夹中:

    $ ls ~/keys/
    private_key.pkcs7.pem  public_key.pkcs7.pem
    
    
  3. 出于安全原因,限制对私钥的访问是个好主意:

    $ chmod 500 keys
    $ chmod 400 keys/private_key.pkcs7.pem
    
    
  4. 我们还需要一个 eyaml 配置文件,位于 ~/.eyaml/config.yaml,内容如下(不要忘记调整你的 $HOME 目录路径):

    ---
    pkcs7_public_key: "/Users/me/keys/public_key.pkcs7.pem"
    pkcs7_private_key: "/Users/me/keys/private_key.pkcs7.pem"
    

我们现在准备加密敏感数据。

安全地设置 MySQL 根密码

从命令行,eyaml 可以加密值。以下是一个会话示例:

$ eyaml encrypt -s 'super_secure_password'
[hiera-eyaml-core] Loaded config from /Users/me/.eyaml/config.yaml
string: ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEALjJ2a9uZ04lk2V5xKqEd0n3BtA4OLe1B6rA2iVruJRKxWJdevuGvJ55DDedRwBMZmqbvSMO1cgMUyPbfEy54i3SXw4x3LEuxc1R31ILoOspBgzU4OLuepCotuhBASA/pI/xu40y66AZAcCQ4CtD9SZJYjiWNtUA91rcARy/xYQGK39QievxT2eq5De89qIn2w/5fIRIkJBRyNqnwyYCWKcKSRwaiLbimpwmarOP+dxGHEFRrD/FiM4NfoV1WNNVr1UkPEFuNrWBzwBpvyZUnMbGHN676Rg5vq9sS6aWI6zPxTrJyLtssZm1f4GsfhmE+anFmuxrcWtEH6C82wKMOoTBMBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBC3MhSP09yUw8XTj0XdlG1VgCCDCGhqIFdUmORYKlq0Pn5CE/cDZKTO+bhHxdBw5amAGQ==]

OR

block: >
 ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEw
 DQYJKoZIhvcNAQEBBQAEggEALjJ2a9uZ04lk2V5xKqEd0n3BtA4OLe1B6rA2
 iVruJRKxWJdevuGvJ55DDedRwBMZmqbvSMO1cgMUyPbfEy54i3SXw4x3LEux
 c1R31ILoOspBgzU4OLuepCotuhBASA/pI/xu40y66AZAcCQ4CtD9SZJYjiWN
 tUA91rcARy/xYQGK39QievxT2eq5De89qIn2w/5fIRIkJBRyNqnwyYCWKcKS
 RwaiLbimpwmarOP+dxGHEFRrD/FiM4NfoV1WNNVr1UkPEFuNrWBzwBpvyZUn
 MbGHN676Rg5vq9sS6aWI6zPxTrJyLtssZm1f4GsfhmE+anFmuxrcWtEH6C82
 wKMOoTBMBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBC3MhSP09yUw8XTj0Xd
 lG1VgCCDCGhqIFdUmORYKlq0Pn5CE/cDZKTO+bhHxdBw5amAGQ==]

由于 eyaml 后端正在寻找扩展名为 .eyaml 的文件,我们只需创建一个 hieradata/nodes/web.pomes.pro.eyaml 文件,内容如下:

---
root_password: >
 ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEw
 DQYJKoZIhvcNAQEBBQAEggEALjJ2a9uZ04lk2V5xKqEd0n3BtA4OLe1B6rA2
 iVruJRKxWJdevuGvJ55DDedRwBMZmqbvSMO1cgMUyPbfEy54i3SXw4x3LEux
 c1R31ILoOspBgzU4OLuepCotuhBASA/pI/xu40y66AZAcCQ4CtD9SZJYjiWN
 tUA91rcARy/xYQGK39QievxT2eq5De89qIn2w/5fIRIkJBRyNqnwyYCWKcKS
 RwaiLbimpwmarOP+dxGHEFRrD/FiM4NfoV1WNNVr1UkPEFuNrWBzwBpvyZUn
 MbGHN676Rg5vq9sS6aWI6zPxTrJyLtssZm1f4GsfhmE+anFmuxrcWtEH6C82
 wKMOoTBMBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBC3MhSP09yUw8XTj0Xd
 lG1VgCCDCGhqIFdUmORYKlq0Pn5CE/cDZKTO+bhHxdBw5amAGQ==]

然而,eyaml 具有一个非常方便的功能——edit 模式——允许我们基于存储在 $HOME 目录中的密钥,创建和编辑加密的明文值:

$ eyaml edit hieradata/nodes/web.pomes.pro.eyaml

这个命令将启动一个编辑器,我们只需输入以下内容:

---
root_password: >
 DEC::PKCS7[super_secure_password]!

在保存时,eyaml 将写入包含加密内容的文件用于 root_password。如果需要,我们可以再次编辑文件,所有加密值将被自动解密。

注意

使用 eyaml edit 编辑时,所有新的值应包含在 DEC::PKCS7[value]! 块中。对于现有值,eyaml 将为 DEC(<num>)::PKCS7[value]! 块添加一个名为 num 的索引。此索引必须保持不变。

作为最后一步,我们需要修改主清单文件,进行 Hiera 查找,以便获取密码:

node 'web.pomes.pro' {
...
    $pass=hiera('root_password');
...
    class { 'mysql::server':
      root_password => $pass;
    }
...
}

现在,根密码已经在 Hiera 中加密,只有持有密钥的人才能恢复它。

另见

使用 Chef Vault 加密

另一种加密数据的方法是通过 Chef Vault 提供的,它不要求你将密钥包含在代码中。这个概念既优雅又简单:共享密钥加密针对每个现有的 Chef 节点进行,并通过它们已经存在的客户端密钥完成。这样,只有被授权访问数据的节点才能解密数据——每个节点使用自己的私钥——确保没有明文共享的密钥像经典的加密数据包方案那样发送。

准备就绪

要按照这个步骤操作,你需要:

  • 工作站上的 Chef DK 安装已就绪

  • 工作站上的 Vagrant 安装已就绪

  • 来自第六章, 使用 Chef 和 Puppet 管理服务器的基础, 第七章, 使用 Chef 和 Puppet 测试与编写更好的基础设施代码,或者任何自定义的 Chef 代码

如何操作……

我们将在之前已有的mysite食谱基础上进行扩展;不过,任何其他情况也可以类似处理。我们将创建一个新的eu-west-1条目,类似于data_bags/aws/eu-west-1.jsonus-east-1的条目:

{
  "id": "eu-west-1",
  "aws_access_key": "an_access_key",
  "aws_secret_key": "a_secret_key"
}

如我们所知,数据将为每个运行节点的公钥进行加密。这意味着我们必须根据搜索来过滤主机。我建议你使用search(*:*)来搜索每个节点;然而,你可以根据自己的需要,限制搜索条件,以提高安全性或适应实际情况,比如使用标签或角色,例如search(tags:aws)search(role:mysite)

$ knife vault create aws eu-west-1 --json data_bags/aws/eu-west-1.json --search "*:*" --mode "client"

注意

执行时别忘了--mode "client"选项,尤其是当使用 Chef 服务器时,就像我们一样!

从一个食谱中访问加密的 Vault

knife vault的配套工具是chef-vault食谱。我们将用它来轻松地在我们的食谱中访问加密的数据。如果你使用 Berkshelf 管理依赖项,请不要忘记在需要的地方添加食谱(无论是metadata.rb还是Berksfile)。在aws.rb文件中,包含chef-vault食谱,并将aws设置为chef_vault_item帮助器搜索的结果:

include_recipe 'chef-vault'
aws = chef_vault_item('aws', 'eu-west-1')

如果发起请求的节点没有权限用其私钥解密数据,则会出现错误。如果节点能够解密数据,就像我们之前使用传统数据包那样,数据就会可供使用:

template "/etc/aws/credentials" do
  source 'aws.erb'
  owner 'root'
  group 'root'
  mode '0600'
  variables(
    aws_region: aws['id'],
    aws_access_key: aws['aws_access_key'],
    aws_secret_key: aws['aws_secret_key']
  )
end

最终,/etc/aws/credentials文件会填充有效的未加密数据:

$ sudo cat /etc/aws/credentials
[eu-west-1]
aws_access_key_id = an_access_key
aws_secret_access_key = a_secret_key

使用 Chef Vault 时,从未有任何共享密钥以明文形式传输,只有过滤后的和现有的节点才能解密专门为它们加密的数据。这个工具能做的远不止这些!

另见

使用 Ohai 访问和操作系统信息

Chef 通过 Ohai 可以获取大量来自系统的信息。这个程序在每次 Chef 运行时执行,并将收集的所有信息存储在 Chef 数据库中,使其能够直接在食谱中使用。默认情况下收集的信息量非常庞大。

它涵盖了从网络详细信息——比如链路速度、MTU 或地址——到你在像top这样的工具上看到的所有内存使用详情,再到文件系统或虚拟化系统的所有可想象的数据,甚至包括每个已安装的包和登录用户的列表。

除此之外,Ohai 是一个模块化系统,拥有许多社区插件,可以将 Dell DRAC 信息与与 KVM、LXC 或 XenServer 相关的支持信息集成。

它甚至可以用来检索与Windows 管理工具WMI)相关的特定数据。显然,我们可以编写自己的插件,但这已经超出了本书的范围。

准备工作

要逐步完成这个食谱,你需要:

  • 工作站上安装的 Chef DK

  • 工作站上安装的 Vagrant

  • 来自第六章, 通过 Chef 和 Puppet 管理服务器的基础, 第七章, 使用 Chef 和 Puppet 测试和编写更好的基础设施代码,或任何自定义的 Chef 代码

如何操作…

在一个全新且精简的 CentOS 7.2 虚拟机安装中,ohai输出包含 5,292 行信息,内容丰富。要逐步查看,请参考以下内容:

$ ohai | more
{
 "cpu": {
 "0": {
 "vendor_id": "GenuineIntel",
 "family": "6",
 "model": "69",
 "model_name": "Intel(R) Core(TM) i7-4578U CPU @ 3.00GHz",
 "stepping": "1",
 "mhz": "2999.991",
 "cache_size": "4096 KB",
 "physical_id": "0",
 "core_id": "0",
 "cores": "1",

或者,另一种解决方案是将其内容重定向到一个文件中,以便使用专用工具更方便地处理:

$ ohai > ohai.json

所有这些信息在 Chef 界面中也可以图形化显示,当你选择节点并进入属性选项卡时:

如何操作…

从 Chef 食谱中访问 Ohai 信息

现在让我们从食谱中访问这些信息。我们希望有一个index.html页面来展示其中的一些信息,因此让我们编辑已经存在的apache食谱中的页面;不过,你也可以从头开始。我们希望这个页面动态地展示类似这样的内容:

This centos 7.2.1511 linux system version 3.10.0-327.el7.x86_64 listening on 192.168.146.129 is up since 25 minutes 55 seconds

我们需要的所有信息都存储在 ohai 的某个地方:platformplatform_versionosos_versionipaddressuptime都是有效的值。让我们使用它们。

apache/templates/index.html.erb中,添加以下内容:

This <%= node['platform'] %> <%= node['platform_version'] %> <%= node['os'] %> system version <%= node['os_version'] %> listening on <%= node['ipaddress'] %> is up since <%= node['uptime'] %>

为了构建更有趣的东西,因为平台名称可用,我们可以让我们的apache食谱在不同的 Linux 发行版之间更具可移植性。当运行在 Ubuntu 上时,安装apache2包;否则,安装httpd包。(这需要更精确,以处理所有实际情况。)在apache::default食谱中,做如下更改,以便在运行 Ubuntu 时将httpd变量设置为apache2,在其他地方设置为默认的httpd

if node['platform'] == 'ubuntu'
  httpd = 'apache2'
else
  httpd = 'httpd'
end

package httpd do
  action :install
end

service httpd do
  action [:enable, :start]
end

这就是我们如何开始在 Chef 基础设施中利用强大的ohai命令。

还有更多…

Puppet 的对应工具是facter,它与 Puppet 代理一起安装。像ohai一样,facter是一个命令行工具:

$ facter | more
aio_agent_version => 1.8.0
augeas => {
 version => "1.4.0"
}
disks => {
 sda => {
 model => "VBOX HARDDISK",
 size => "40.00 GiB",
 size_bytes => 42949672960,
 vendor => "ATA"
 }
}
dmi => {
 bios => {
 release_date => "12/01/2006",
 vendor => "innotek GmbH",
 version => "VirtualBox"
 },
 board => {
 manufacturer => "Oracle Corporation",
 product => "VirtualBox"
 },
...

至于 Chef,facter信息可以从 Puppet 清单中访问。在 Puppet 中,这种信息被称为事实

从 Puppet 4.x 开始,可以通过$facts哈希从清单中访问事实。让我们尝试为apache模块创建更具可移植性的代码行:

if $facts['os']['family'] == 'debian' {
  $packagename='apache2'
} else {
  $packagename='httpd'
}

package{'apache2':
  ensure => present,
  name   => $packagename,
}  

service{'apache2':
  ensure => running,
  enable => true,
  name   => $packagename,
}

注意

你可能会看到一些访问事实的代码片段,使用变量,如 $osfamily 而不是 $facts['os']['family']。这种方法适用于旧版本的 Puppet,但这里没有明显显示正在使用一个事实。

另见

自动化应用程序部署(一个 WordPress 示例)

Chef 也可以用来从代码仓库部署应用程序。它结合了最完整、功能最丰富、最复杂的 Chef 资源之一——deploy 资源——以及各种强大而流行的食谱,如 database 食谱。我们将展示如何直接从 GitHub 仓库部署一个简单的 WordPress 应用程序,创建专用的数据库和用户,以及所有必要的依赖项。这在先前的基础上进行了扩展,但这里展示的资源和食谱可以在任何地方重用。

准备工作

要逐步执行此食谱,您将需要:

  • 工作站上有一个可用的 Chef DK 安装

  • 工作站上有一个可用的 Vagrant 安装

  • 来自 第六章,使用 Chef 和 Puppet 管理服务器的基础知识,第七章,测试和编写更好的基础设施代码,或任何自定义的 Chef 代码

如何操作…

由于我们将为 MySite 部署一个应用程序(可能是 MySite 公司的工程博客),让我们将此食谱命名为 mysite::deploy。从 chef-repo 中创建该食谱:

$ chef generate recipe cookbooks/mysite deploy

我们接下来的步骤将包括 Apache 和 MySQL 依赖项,配置 MySQL 以便安装 WordPress,并最终从 GitHub 部署 WordPress 代码。

包括依赖项

一个 WordPress 安装至少需要一个 HTTP 服务器和一个数据库。首先,通过包括我们已经拥有的服务的已知依赖项来启动:一个 Apache 虚拟主机和 MySQL。在 deploy.rb 中包含它们:

include_recipe 'apache::virtualhost'
include_recipe 'mysite::mysql'

创建应用程序的数据库

在我们部署任何内容之前,我们需要在已经运行的 MySQL 服务器上创建一个数据库,并为 WordPress 创建一个专用用户。这里有一个专门用于此目的的精彩食谱:database 食谱。我们将频繁重用这个食谱。它提供了许多帮助函数,适用于大多数用例和各种类型的数据库。根据文档,我们需要部署一个名为 mysql2_chef_gem 的 gem,幸运的是,它也带有一个专门的食谱。最后,由于我们使用的是 MySQL,让我们确保依赖于其官方食谱。我们将所有这些信息包括在我们的 mysite 食谱的 metadata.rb 中:

depends 'database'
depends 'mysql2_chef_gem', '~> 1.1'
depends 'mysql', '~> 8.1'

要使用烹饪手册中的新 mysql2_chef_gem 资源构建 mysql2 gem,我们需要安装名为 mysql-community-devel 的 MySQL 开发包。我们需要将以下内容添加到我们的 deploy.rb 配方中:

package 'mysql-community-devel'

mysql2_chef_gem 'default' do
  action :install
end

数据库烹饪手册创建了包括 mysql_databasemysql_database_user 在内的两个对我们非常有用的资源。顾名思义,它们分别帮助创建 MySQL 数据库和 MySQL 用户。现在我们来创建 MySQL 连接信息变量,以便在这两个资源中重复使用:

mysql_connection_info = {
  host: '127.0.0.1',
  username: 'root',
  password: 'super_secure_password'
}

注意

在正式的 production 环境中,我们应该使用加密的数据包来处理这个问题,正如本章所展示的那样。这里我们试图保持代码简单。

现在我们可以使用 mysql_database 资源创建名为 wordpress 的数据库:

mysql_database 'wordpress' do
  connection  mysql_connection_info
  action      :create
end

此外,创建一个 wordpress_user MySQL 用户,密码为 changeme。这将创建该用户并授予其所有权限:

mysql_database_user 'wordpress_user' do
  connection    mysql_connection_info
  password      'changeme'
  database_name 'wordpress'
  host          '%'
  privileges    [:all]
  action        [:create, :grant]
end

到此为止,我们应该已经具备与数据库相关的所有内容。

从 git 或 GitHub 部署应用程序

现在进入应用程序部署!我们知道我们想从 git 部署。让我们确保 git 已安装:

package 'git'

deploy_revision 资源是所有资源中最复杂的。它有很多选项,甚至一个完整的章节也不足以讲解它。我们在这里保持简单,并建议你参考完整的在线文档以了解更复杂的用法。我们保持简单,请参考非常完整的在线文档以获取更复杂的用法——因为这个资源非常强大,正确使用时能产生惊人的效果。我们知道以下几点:

  • 我们的代码可通过 github.com/WordPress/WordPress 获取

  • 我们想尝试最新的修订版本(HEAD)并保留最后五个修订版本以便回滚

  • 我们的 HTTP Web 服务器以 apache 用户身份运行

  • 虚拟主机文件夹继承自之前设置的属性(/var/www/#{node['sitename']}

  • 在 WordPress 中无需执行数据库迁移

deploy_revision 资源是模仿 Capistrano 的,因此它源自 Ruby on Rails 的世界。但这些概念仍然适用于大多数编程语言,并且在生产环境中创建共享文件夹和符号链接以配置长期存在的文件和配置是一个很好的实践。它包括证书、数据库配置文件、本地资产等。然而,为了保持当前部署的简单性,我们现在不使用这些内容,尽管你可能会在需要时开始考虑它们。我们将包含符号链接的配置,并将其初始化为空,这样在需要时代码已经准备好。下面是所有这些如何联系起来:

deploy_revision 'wordpress' do
  repo 'https://github.com/WordPress/WordPress'
  revision 'HEAD'
  user 'apache'
  deploy_to "/var/www/#{node['sitename']}"
  keep_releases 5
  symlinks({})
  symlink_before_migrate({})
  migrate false
  action :deploy
end

一旦代码应用,/var/www/mysite(或你可能已覆盖的任何名称)结构将稍有变化:

$ ls /var/www/mysite/
current  index.html  releases  shared

这里有 currentreleasesshared 文件夹。shared 文件夹包含所有在发布版本之间保留的内容,包括当前代码的缓存副本。releases 文件夹包含所有已存储的发布版本。current 文件夹本身是一个指向特定发布版本的符号链接,该版本是 GitHub 上的 git 提交 SHA(72606bed348e61b6f98318cf920684765aa08b37)。每个随后的发布版本将通过其 SHA 标识,指示其唯一标识,并在部署过程结束时符号链接到 current。保留的发布版本数量由 keep_releases 整数设置:

$ ls -ld /var/www/mysite/current
lrwxrwxrwx. 1 apache root 65 Nov 17 02:18 /var/www/mysite/current -> /var/www/mysite/releases/72606bed348e61b6f98318cf920684765aa08b37

一旦这个代码应用到我们的节点,如果我们访问 http://<node_ip>/current/,就会看到 WordPress 安装页面:

从 git 或 GitHub 部署应用程序

为了检查与数据库的连接是否正常,输入我们 Chef 代码中的所有信息:

从 git 或 GitHub 部署应用程序

干得漂亮!WordPress 安装程序说,好样的,sparky!你已经完成了安装的这一部分。WordPress 现在可以与数据库通信。如果你准备好了,现在是... 运行安装程序的时候了!

现在,我们基本上可以随时随地、反复无常地从零开始部署任何 WordPress 安装。

注意

让我们再坚持一遍

一旦你熟悉了这个,参考 deploy 资源文档,发现这个资源能提供的一切功能。它效果非凡。

还有更多…

使用 Puppet 时,没有 deploy 资源。然而,Puppet Labs 提供了一个有用的模块——vcsrepo。通过这个模块,我们将能够从 git 部署一个 WordPress 网站。

让我们在 第六章中重用我们的 Vagrant LAMP 示例,使用 Chef 和 Puppet 管理服务器的基础。我们只需将 vcsrepo 模块添加到 Puppetfile 中(不要忘记运行 r10k puppetfile install):

mod 'puppetlabs/vcsrepo', '1.4.0'

现在,我们准备修改箱子的主清单,即 web.pomes.pro,以包括 WordPress 部署。首先,安装 git 包:

  package {'git':
    ensure => installed,
  }

然后,为 WordPress 创建一个数据库:

  mysql::db {'wordpress':
    user     => 'wordpress_user',
    password => 'changeme',
    host     => '%',
    grant    => 'ALL',
  }

另外,更新我们的虚拟定义以更改 DocumentRoot

  apache::vhost { 'web.pomes.pro':
    website => 'web.pomes.pro',
    docroot => '/var/www/wordpress',
  }

最后,从 git 安装 WordPress,并赋予 Apache 权限:

  vcsrepo {'/var/www/wordpress':
    ensure   => latest,
    provider => git,
    source   => 'https://github.com/WordPress/WordPress',
    revision => 'master',
  }

  file {'/var/www/wordpress':
    ensure    => directory,
    owner     => 'www-data',
    group => 'www-data',
	recurse => true,
}	

另请参见

使用 TDD 工作流

TDD(测试驱动开发)是开发团队中一种流行的技术,其包含以下步骤:首先编写测试,这些测试会失败,因为实际上没有编写代码,然后编写代码使得这些测试通过。通过这种方式,我们确保编写的代码已经过测试,并且真正覆盖了测试的范围;如果有某天出现回归问题,它会立即被发现。这里,我们将展示一个完整的工作流程,从开发到生产,我们将在 CentOS 7 和 Ubuntu 16.04 上使用 TDD 技术部署 Docker。通过使用 Git 分支、Chef 工具、Test Kitchen、linting 和 ServerSpec,我们将按照 TDD 原则的每个步骤进行一个小项目的开发。我们这样做是为了在团队中实现最高的代码质量,从最初的开发阶段一直到最终的生产环境。

准备工作

要执行这个食谱,你需要:

  • 在工作站上安装一个可用的 Chef DK。

  • 在工作站上安装一个可用的 Vagrant。

  • Chef 代码(可选)来自 第六章,使用 Chef 和 Puppet 管理服务器的基础知识,第七章,使用 Chef 和 Puppet 编写更好的基础设施代码,或任何自定义的 Chef 代码。

如何执行……

我们的目标是基于 Docker 启动一个新平台。为此,请按照以下步骤操作:

  1. 从创建 platform cookbook 开始:

    $ cd chef-repo
    $ chef generate cookbook cookbooks/platform
    
    
  2. 现在创建一个空的 platform::docker 食谱:

    $ chef generate recipe cookbooks/platform docker
    
    
  3. 如果还未初始化 git 仓库,请执行初始化操作:

    $ git init
    
    
  4. 如果当前有任何工作内容,请添加并提交到仓库:

    $ git add .
    $ git commit -m "initial chef repo state" 
    
    
  5. 为我们即将处理的 docker 支持创建一个功能分支:

    $ git checkout -b docker_support
    
    

基础设施 TDD —— 先编写测试

让我们先编写测试,以便它们肯定会失败,这样我们就知道我们是从正确的地方开始构建的。

在平台 cookbook 中创建 ServerSpec 集成文件夹:

$ mkdir -p test/integration/default/serverspec

platform cookbook 文件的根目录创建 .kitchen.yml 文件,内容如下。我们将使用 Vagrant 并通过 chef_zero 提供程序模拟一个 Chef 服务器。我们希望我们的平台能够在 Ubuntu 16.04 和 CentOS 7.2 上运行,并希望将 cookbook 的默认食谱作为其入口点:

---
driver:
  name: vagrant

provisioner:
  name: chef_zero
  always_update_cookbooks: true

platforms:
  - name: centos-7.2

suites:
  - name: default
    run_list:
      - recipe[platform::default]
    attributes:

如本书前面所述,在 serverspec 文件夹中创建一个名为 spec_helper.rb 的助手脚本:

require 'serverspec'
# Required by serverspec
set :backend, :exec

让我们开始测试,看看根据我们的需求,想要做什么:

  • 我们希望安装 docker-engine 包。

  • 我们希望 docker 服务能够启用并启动。

  • 我们希望拉取一个特定的 docker 镜像(即 sjourdan/terraform:0.7.10)。

让我们在 serverspec 文件夹中的 docker_spec.rb 中编写这些测试:

require 'spec_helper'

describe package('docker-engine') do
  it { should be_installed }
end

describe service('docker') do
  it { should be_enabled }
  it { should be_running }
end

describe command('docker images') do
  its(:exit_status) { should eq 0 }
  its(:stdout) { should match(%r{^sjourdan/terraform\s.*0.7.10}) }
end

这已经足够满足我们的需求!让我们通过启动 kitchen 来启动我们的测试环境:

$ kitchen create
$ kitchen converge
$ kitchen verify
[...]
       Package "docker-engine"
         should be installed (FAILED - 1)

       Service "docker"
         should be enabled (FAILED - 2)
         should be running (FAILED - 3)

       Command "docker images"
         exit_status
           should eq 0 (FAILED - 4)
         stdout
           should match /^sjourdan\/terraform\s.*0.7.10/ (FAILED - 5)
[...]

我们成功地失败了!Docker 既未安装,也未启用,也未启动,且没有 docker 镜像。

让我们开始工作。

使用 Chef 部署 Docker。

有一本非常棒的食谱,文档写得极其详尽,做了我们需要的所有事情(github.com/chef-cookbooks/docker)。我们将它添加到 metadata.rb 中,以便让它成为我们的依赖:

depends 'docker', '~> 2.0'

如果你计划在 Berksfile 中使用它,也可以将它添加到 Berkshelf 中:

cookbook 'docker', '~> 2.0'

由于我们将在 platform::docker 食谱中编写 Docker 代码,首先让我们将其包含在 default.rb 食谱中:

include_recipe 'platform::docker'

docker 食谱为我们提供了一个新的资源,名为 docker_installation,它正是做这件事:安装 docker。有许多安装选项可以让你玩弄。让我们保持简单,从 Docker 仓库安装当前稳定版本的 Docker(而不是从我们的 Linux 发行版安装)。将以下内容添加到 docker.rb 食谱中:

docker_installation 'default' do
  repo 'main'
  action :create
end

再次执行 kitchen 以应用我们的代码,并查看测试是通过还是失败:

$ kitchen converge
$ kitchen verify
[...]
 Package "docker-engine"
 should be installed
[...]
 Finished in 0.18797 seconds (files took 0.43908 seconds to load)
 5 examples, 4 failures

很好!几分钟前失败的测试现在通过了。这证明我们的行动解决了问题,我们走在正确的道路上。然而,其他测试仍然失败。

让我们创建 Docker 服务并使用 docker_service 资源启动它,这是食谱为我们提供的:

docker_service 'default' do
  action [:create, :start]
end

再次执行 kitchen 以应用我们的代码,并查看测试结果:

$ kitchen converge
$ kitchen verify
[...]
 Package "docker-engine"
 should be installed

 Service "docker"
 should be enabled
 should be running
[...]
 Finished in 0.12301 seconds (files took 0.28237 seconds to load)
 5 examples, 1 failure

很好!服务现在已通过测试并启用,并且正在运行。让我们添加一个小要求,即从一开始就拉取一个镜像,我们选择了 Docker 镜像 sjourdan/terraform,版本为 0.7.10:

docker_image 'sjourdan/terraform' do
  tag '0.7.10'
  action :pull
end

再次执行 kitchen 以应用我们新的代码,并检查测试是否通过:

$ kitchen converge
$ kitchen verify
[...]
 Finished in 0.23526 seconds (files took 0.44015 seconds to load)
 5 examples, 0 failures

我们的代码似乎正如我们预期的那样运行!现在让我们销毁测试环境:

$ kitchen destroy

代码检查

别忘了使用 cookstyle 来检查我们的代码是否干净,运行在 platform 食谱内部:

$ cookstyle
Inspecting 6 files
......

6 files inspected, no offenses detected

没有错误!我们的代码很干净。我们继续进行下一步。

支持其他平台

让我们检查一下这段代码是否也能在 Ubuntu 16.04 上运行。能在当前支持长期维护的两个平台上都运行岂不是太棒了吗?只需将平台添加到食谱的 kitchen.yml 文件中:

  - name: ubuntu-16.04

再次启动 kitchen 并检查它是否也能在 Ubuntu 16.04 上工作:

$ kitchen test
[...]

 Package "docker-engine"
 should be installed

 Service "docker"
 should be enabled
 should be running

 Command "docker images"
 exit_status
 should eq 0
 stdout
 should match /^sjourdan\/terraform\s.*0.7.10/

 Finished in 0.27516 seconds (files took 0.43079 seconds to load)
 5 examples, 0 failures

 Finished verifying <default-ubuntu-1604> (5m9.12s).

我们现在确认我们的代码也支持 Ubuntu 16.04 了!

团队使用 Chef 和 git 协作

现在我们的 platform 食谱在 docker_support git 分支上运行得相当好,让我们提交这项工作。首先检查哪些内容尚未被跟踪:

$ git status
On branch docker_support
Untracked files:
 (use "git add <file>..." to include in what will be committed)

 cookbooks/platform/ 

提交这项工作:

$ git add cookbooks/platform
$ git commit -m "added docker support to the platform"

我们的 git 树干净吗?可以将它提交给我们的团队了吗?使用以下代码检查:

$ git status
On branch docker_support
nothing to commit, working tree clean

然后,让我们将其推送到我们的 git 仓库(可能是 GitHub,也可能是其他任何平台):

$ git push

现在我们的一位同事可以进行代码审查,并最终将 docker_support 合并到 master 分支:

$ git merge docker_support master 

我们的新食谱现在已经准备好投入实际使用,可以部署到预发布环境了。

注意

在更复杂的设置中,强烈建议你在持续集成系统中运行这些集成测试,如 Jenkins。这些系统可以与 GitHub 或 GitLab 等服务很好地集成,并在推送或拉取请求后自动启动测试。这是附加的价值,确保在发布前的过程质量。

部署到暂存环境

现在,让我们将这个新平台的 cookbook 部署到我们的暂存环境。首先,确保我们有所有必需的 cookbook 依赖项:

$ cd cookbooks/platform
$ berks

然后,上传所有必需的 cookbook:

$ berks upload

使用已存在的环境,如staging,并将我们新的platform cookbook 版本约束添加到environments/staging.rb或你正在使用的任何类似环境中:

name 'staging'
description 'The staging environment'
cookbook_versions  'platform' => '= 0.1.0'

使用knife命令更新该环境:

$ knife environment from file environments/staging.rb
Updated Environment staging

将此代码提交到git

$ git add .
$ git commit -m "added platform::docker to staging"

将平台 cookbook 添加到目标节点的 run_list 中:

$ knife node run_list add my_node_name 'recipe[platform]'

等待下次 Chef 运行或自行运行,Docker 将在任何节点上可用,包括此配方。

部署到生产环境

在此阶段,部署到生产环境与部署到暂存环境完全相同;没有区别。environments/production.rb文件现在应如下所示:

name 'production'
description 'The production environment'
cookbook_versions  'platform' => '= 0.1.0'

别忘了将其上传到 Chef 服务器:

$ knife environment from file environments/production.rb
Updated Environment production

将更改提交到git

$ git add .
$ git commit -m "updated production env with platform::docker"

等待下次 Chef 运行或自行执行,从现在开始,我们将拥有一个简单的四步工作流程:

  1. 本地存储 TDD 基础设施代码

  2. 同行评审和合并

  3. 部署到暂存环境

  4. 部署到生产环境

现在,每当我们想要测试或暂存通过了步骤 1 和 2 的新版本 cookbook 时,我们只需增加 cookbook 版本号的约束,在不影响生产环境的情况下验证暂存环境中的结果,最后尽可能将其部署到生产环境。

还有更多内容…

对于 Puppet,相同的逻辑适用。在第七章,使用 Chef 和 Puppet 测试和编写更好的基础设施代码中,我们介绍了 Beaker 作为验收测试工具。在 TDD 工作流中,我们可以首先在任何模块的specs/acceptance子目录中编写验收测试,然后再编写代码本身。

使用多个nodesets,我们还可以确保测试可以在多个平台上进行验证。在第七章,使用 Chef 和 Puppet 测试和编写更好的基础设施代码中,我们仅在spec/acceptance/nodesets/default.yml中使用了一个平台(Ubuntu)。但是,我们可以根据需要创建任意多个。下面是一个应该在spec/acceptance/nodesets/centos-7-x64.yml中定义的 CentOS 节点的示例:

HOSTS:
  centos-7-x64:
    roles:
      - agent
      - default
    platform: redhat-7-x86_64
    hypervisor: vagrant
    box: puppetlabs/centos-7.2-64-nocm
CONFIG:
  type: foss

使用环境变量BEAKER_set,然后可以指定需要在哪个平台上运行测试:

$ BEAKER_set=centos-7-x64 rake beaker

使用 git 和 r10k 时,团队的工作流是相同的。我们首先在test分支上进行开发。当所有测试都成功通过时,我们将它们合并到production分支,并使用 r10k 部署代码。

另见

为最坏的情况做准备——训练重建工作系统

最终通过 Chef 管理整个基础设施是件不容易的事——一块一块地搭建,经过数周,经过不断的修改——确保 Chef 运行始终顺利且正常工作。然而,能够从零开始重新引导一个正常工作的系统是另一回事。假设当前的设置之所以能正常工作,其实是因为某个脚本或二进制文件仍然留在去年某个地方,正是它做了使系统正常运行的事情?如果应用服务器今晚发生了损坏呢?如果发生这种情况,我们能从头开始重建它吗?如果明天我们的 IaaS 云服务提供商崩溃了,在什么时间框架内我们能在其他地方重建系统(假设备份正常工作;嗯,那是另一回事)?

现在我们的系统尽可能地实现了自动化,希望能达到百分之百。了解在灾难发生时我们是否能够完全重新引导这些系统非常重要;如果可以,花多长时间。你可能会感到惊讶,当你收集一些数据后会发现,许多系统可以在几分钟内恢复。与之相比,找出过时的文档,应用未经测试的手动流程,最后在紧急情况下采取一切手段让系统运行起来,可能需要花费的时间要更长。如果我们知道所有的系统配置文件都在持续重新引导且成功地运行,那么我们会度过更轻松的夜晚和周末;实际上,为什么不每天晚上使用 CI 系统,这样每天早晨我们就能知道前一天的更改是否影响了任何事情。作为一个团队,我们始终知道,如果需要,我们随时准备重新部署系统。

做好准备

要按此步骤操作,您需要:

  • 工作中的 Chef DK 安装

  • 工作中的 Vagrant 安装

  • 第六章中的 Chef 代码(可选),使用 Chef 和 Puppet 管理服务器的基础知识, 第七章中的 Chef 代码,使用 Chef 和 Puppet 测试与编写更好的基础设施代码,或任何自定义的 Chef 代码

如何操作……

没有单一的方式来实现我们的目标。我们已经介绍了 Test Kitchen,它可能是一个不错的解决方案,特别是如果我们编写了大量的测试。将其集成到公司的持续集成CI)系统中,这将完成任务。

一个更简单和更快速的解决方案也可以是仅使用正确的 Chef-provisioning 配置文件启动 Vagrant 盒子,以满足每种用例:dockerwebserver,数据库服务器或完整部署。

注意

有关 Vagrant 工具的更多信息,请参考本书的 Vagrant 章节!

我们的生产服务器通过一些 Chef 代码进行配置,并且目前这项工作做得很好。我们能够轻松地重新引导类似的 CentOS 7.2 服务器到一个类似的安装点,而没有任何 Chef 或系统错误吗?让我们通过在基础设施存储库的根目录包含Vagrantfile,使用以前用于部署 Docker 的项目代码来找出答案(但是对于任何类型的 Chef 存储库,这个想法是一样的)。我们最少可以做的是引导一个新鲜的 CentOS 7.2:

Vagrant.configure("2") do |config|
  config.vm.box = "bento/centos-7.2"
end

我们希望自动在我们的临时节点上安装 Chef,因此让我们使用vagrant-omnibus插件(记住,安装它很容易:vagrant plugin install vagrant-omnibus)。以下是执行此操作的代码:

  config.omnibus.chef_version = :latest

让我们配置 Vagrant provisioning 系统以使用 Chef Zero 来模拟 Chef 服务器。我们也可以直接使用真实的 Chef 服务器;如果我们在防火墙后面有一个,那将非常方便。我们必须指定所有内容的位置(cookbooks,environments,roles 等),还要添加一个空的nodes文件夹的微妙之处,这在我们的情况下将被留空。我们的虚拟机将在production环境中运行,并应用docker角色:

  config.vm.provision "chef_zero" do |chef|
    chef.cookbooks_path = "cookbooks"
    chef.environments_path = "environments"
    chef.roles_path = "roles"
    chef.nodes_path = "nodes"
    chef.environment = "production"
    chef.add_role "docker"
  end

我们快完成了!我们需要告诉 Vagrant Berkshelf 插件在哪里查找Berksfile以及是否启用它(安装 Berkshelf 插件很容易:vagrant plugin install vagrant-berkshelf)。以下是执行此操作的代码:

  config.berkshelf.berksfile_path = "cookbooks/platform/Berksfile"
  config.berkshelf.enabled = true

此时启动 Vagrant 只会从头部署所有内容:

$ vagrant up 
[...]
# Chef Client finished, 17/45 resources updated in 03 minutes 30 seconds

如果运行成功,表示已应用来自 Docker 角色的代码,我们是安全的。让我们销毁 VM:

$ vagrant destroy -f

在我们的 CI 系统中包含此 Vagrant 命令将确保此特定角色在此特定环境和此特定系统中无缺陷地运行,并且可能仅需三分半钟即可从无到有的工作状态恢复。

多机恢复

让我们转向更复杂的设置。Vagrant 支持多机设置,让我们为每个定义配置文件。在本章的前一个示例中,我们使用数据包和模板配置了 WordPress 安装,包括配置了 Apache web 服务器,所有数据都是加密的。我们将实现相同的想法,但Vagrantfile将包括多个机器配置文件:一个只启动带有webserver角色的虚拟机,另一个仅部署数据库部分,第三个启动所有内容,包括 Web 应用程序。所以我们要确保最终产品的所有部分都可以从头开始重新部署(这是主要目标)。

所有 VM 定义都将存储在主 Vagrant 配置内:

Vagrant.configure('2') do |config|
  config.vm.define 'whatever_vm', autostart: false do |node|
    [...]
  end
end

注意

我们建议禁用 VM 的自动启动,以免因错误而启动数十个 VM。

为了确保我们的代码能够从头开始仅部署webserver角色,我们将需要进行以下操作——为所有内容设置路径,包括作业的特定Berksfile

  config.vm.define 'webserver', autostart: false do |ws|
    ws.vm.box = 'bento/centos-7.2'

    ws.vm.provision :chef_zero do |chef|
      chef.cookbooks_path = 'cookbooks'
      chef.environments_path = 'environments'
      chef.roles_path = 'roles'
      chef.nodes_path = 'nodes'
      chef.environment = 'production'
      chef.add_role 'webserver'
    end

    ws.berkshelf.berksfile_path = 'cookbooks/apache/Berksfile'
    ws.berkshelf.enabled = true
  end

为了仅启动此框,以确保能够从头开始部署webserver角色,请使用以下命令:

$ vagrant up webserver

为了确保我们的代码能够从头开始仅启动此平台的数据库部分,只需在类似的环境中执行mysite::mysql配方:

  config.vm.define 'db', autostart: false do |db|
    db.vm.box = 'bento/centos-7.2'

    db.vm.provision :chef_zero do |chef|
      chef.cookbooks_path = 'cookbooks'
      chef.environments_path = 'environments'
      chef.roles_path = 'roles'
      chef.nodes_path = 'nodes'
      chef.environment = 'production'
      chef.add_recipe 'mysite::mysql'
    end

    db.berkshelf.berksfile_path = 'cookbooks/mysite/Berksfile'
    db.berkshelf.enabled = true
  end

为了仅启动此框,以确保能够从头开始部署数据库配方,请使用以下命令:

$ vagrant up db
[...]
Chef Client finished, 29/43 resources updated in 01 minutes 28 seconds

为了确保我们的代码能够从头开始启动整个平台,我们只需执行整个mysite::default配方再加一步。其中一个包含的配方使用了加密数据包。它在 Chef 服务器上以加密形式存储,但在本地,我们的./data_bags/目录当前仅包含未加密的 JSON 版本。我们必须确保另一个文件夹存储加密版本(例如,您可能已经有一个文件夹将它们存储在 GitHub 上)。否则,从 Chef 服务器导入加密版本到一个新目录中,例如,使用-Fj

$ mkdir data_bags_encrypted 
$ knife data bag show aws us-east-1 -Fj > data_bags_encrypted/us-east-1.json 

现在我们可以像其他 VM 一样定义完整的 VM,只需修改数据包路径以适应加密版本:

  config.vm.define 'mysite', autostart: false do |mysite|
    mysite.vm.box = 'bento/centos-7.2'

    mysite.vm.provision :chef_zero do |chef|
      chef.cookbooks_path = 'cookbooks'
      chef.environments_path = 'environments'
      chef.data_bags_path = 'data_bags_encrypted'
      chef.roles_path = 'roles'
      chef.nodes_path = 'nodes'
      chef.environment = 'production'
      chef.add_recipe 'mysite::default'
    end

    mysite.berkshelf.berksfile_path = 'cookbooks/mysite/Berksfile'
    mysite.berkshelf.enabled = true
  end

为了确保我们的代码能够从头开始部署整个配方,请使用以下命令:

$ vagrant up mysite

将这些命令(及其销毁对应命令)放入 CI 或任何您喜欢的系统中,定期执行,如每天或每周,用于基础设施的每个自动化部分。通过这样做,您将始终确保在灾难来临时可以重新部署系统。

还有更多……

使用 Puppet,我们使用的所有示例都基于 Vagrant,并且可以轻松地从头重建节点。但在现实中,您可能不会在您的工作站上部署和维护运行 Vagrant 的生产系统。

然而,这些示例表明,通过简单的vagrant up命令可以模拟完整的基础设施,因此,可以轻松地将其放入任何 CI 系统中,以确保能够轻松重建生产系统。

第九章:与 Docker 一起工作

在本章中,我们将涵盖以下内容:

  • Docker 使用概览

  • 选择合适的 Docker 基础镜像

  • 优化 Docker 镜像大小

  • 使用标签版本化 Docker 镜像

  • 在 Docker 中部署 Ruby-on-Rails Web 应用

  • 使用 Docker 构建和使用 Golang 应用

  • 使用 Docker 进行网络配置

  • 创建更动态的容器

  • 自动配置动态容器

  • 使用非特权用户提高安全性

  • 使用 Docker Compose 进行编排

  • 对 Dockerfile 进行代码检查

  • 使用 S3 存储部署私有 Docker 注册表

介绍

在本章中,我们将探索在开发环境中使用 Docker 的最佳实践:从 Docker 镜像优化到版本控制、安全性和网络配置,选择合适的基础 Docker 镜像的技巧,以及如何使其动态和自配置;如何利用 Docker 进行 Go 程序的交叉编译或部署 Ruby-on-Rails Web 应用。依然以开发者为中心,旨在实现尽可能高的代码质量,我们将花一些时间进行代码检查,最后部署我们自己的 Docker 注册表来存储内部镜像——既在本地存储,也在 AWS S3 上实现无限存储空间。

Docker 使用概览

本节是 Docker 初学者的入门介绍,也可以作为其他人复习的资料。我们将学习如何快速使用 Docker 完成一些任务,例如执行 Ubuntu 容器或网络 Web 服务器、与容器共享数据、构建镜像以及访问不同于默认注册表的注册表。

准备工作

为了执行本食谱,您需要一个工作正常的 Docker 安装。

如何操作…

我们将快速操作 Docker,确保基本使用上手。

在 Ubuntu 16.04 容器中运行 Bash

要在 Ubuntu 容器中执行 /bin/bash,请使用标签 16.04(ubuntu:16.04)。我们的环境将是交互式的(使用 -i),并且希望分配一个伪终端(使用 -t)。我们希望在之后销毁该容器(使用 --rm):

$ docker run -it --rm ubuntu:16.04 /bin/bash
root@d372dba0ab90:/# hostname
d372dba0ab90

我们已经运行了第一个容器!现在可以随意操作它。退出容器将销毁它,并且由于我们指定了 --rm 选项,它的内容将永久丢失。

在容器中运行 Nginx

Nginx 已正式打包为 Docker 容器。我们希望通过 -p 选项,从容器的 80 端口访问宿主机的 80 端口,且使用最新的 Nginx 版本:

$ docker run --rm -p 80:80 nginx

发出一些 HTTP 请求,例如 curl

$ curl -IL http://localhost
HTTP/1.1 200 OK
Server: nginx/1.11.5
[…]

Docker 的标准输出日志显示日志如下:

172.17.0.1 - - [21/Nov/2016:21:21:15 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.43.0" "-"

也许由于某些原因,我们需要启动特定版本的 Nginx,例如:

$ docker run --rm -p 80:80 nginx:1.10

HTTP 头部将反映我们现在运行的是当前稳定版本:

$ curl -IL http://localhost
HTTP/1.1 200 OK
Server: nginx/1.10.2

与容器共享数据

我们希望显示我们自己的内容,而不是默认的 Nginx 页面。让我们在 www 目录中创建一个 index.html 文件,包含一些自定义内容,例如:

<html>
  <h1>Hello from Docker!</h1>
</html>

默认情况下,Nginx 在 /usr/share/nginx/html 提供内容;让我们使用 -v 选项将我们自己的目录与容器共享:

$ docker run --rm -p 80:80 -v ${PWD}/www:/usr/share/nginx/html nginx:1.10

让我们来看一下新内容的服务:

$ curl -L http://localhost
<html>
 <h1>Hello from Docker!</h1>
</html>

构建带有工具的容器

让我们创建自己的 Ubuntu 16.04 镜像,并在其中包含一些工具,如curldignetcat,这样无论我们使用什么机器,都能随时使用这些工具。为了构建我们的容器,我们需要一个名为Dockerfile的文件,它像脚本一样按行执行,来构建最终的容器。我们知道我们想从 Ubuntu 16.04 开始,然后更新 APT 基础,最后安装我们需要的工具。让我们使用FROMRUN指令来完成这个任务:

FROM ubuntu:16.04
RUN apt-get -yq update
RUN apt-get install -yq dnsutils curl netcat

现在使用docker build命令进行构建,传入容器名称,并使用-t选项:

$ docker build -t utils .
Step 1 : FROM ubuntu:16.04
 ---> 2fa927b5cdd3
Step 2 : RUN apt-get update -yq
 ---> Running in 0d8f8e01bde8
[...]
Step 3 : RUN apt-get install ruby -yq
 ---> Running in 425bfb1e8ee1
[...]
Removing intermediate container 425bfb1e8ee1
Successfully built c86310e48731

我们可以看到Dockerfile的每一行都是构建过程中的一步,每一步都是一个容器(因此每次都会有不同的 ID)。

现在让我们执行容器,使用dig进行 DNS 请求:

$ docker run -it --rm utils dig +short google.com
172.217.5.14

或者,我们可以使用curl,如下所示:

$ docker run -it --rm utils curl -I google.com
HTTP/1.1 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: http://www.google.ca/?gfe_rd=cr&ei=UgA1VMLPRUvF9gfJ_riACg
Content-Length: 258
Date: Wed, 23 Nov 2016 02:34:58 GMT

使用私有注册表

当没有指定其他内容时,Docker 会在本地查找该容器,然后再查找 Docker Hub(hub.docker.com)。不过,我们可以运行自己的注册表或使用其他注册表,例如quay.io/。其工作方式如下:我们不仅指定容器名称或用户名/容器名称组合,还要在它们前面加上注册表的 DNS 名称,例如quay.io/。在这里,我们将启动在 CoreOS 账户中托管的 HTTP/2 Caddy Web 服务器,该服务器位于 Quay.io 注册表:

$ docker run -it --rm -p 80:2015 quay.io/coreos/caddy
Activating privacy features... done.
http://0.0.0.0:2015

这是一个关于如何使用 Docker 的简短介绍。

另请参见

选择正确的 Docker 基础镜像

根据我们的最终目标,使用我们最喜欢的 Linux 发行版的镜像可能是也可能不是最佳方案。从一个完整的 CentOS 容器镜像开始可能会浪费资源,而 Alpine Linux 镜像可能没有我们所需的最完整的 libc。在其他情况下,使用我们最喜欢的编程语言的镜像可能是个好主意,也可能不是。让我们深入了解并学习在何时选择什么来源。

准备工作

要执行此操作,您需要一个有效的 Docker 安装。

如何做……

大多数常见的发行版都可以以容器的形式使用。

从 Ubuntu 镜像开始

Ubuntu 提供了官方镜像,并且每个镜像都带有版本号和名称标签:ubuntu:16.04等同于ubuntu:xenial。截至目前,支持的 Ubuntu 版本有 12.04(precise)、14.04(trusty)、16.04(xenial)和 16.10(yakkety)。

要在 Dockerfile 中使用 Ubuntu 镜像,执行以下命令:

FROM ubuntu:16.04
ENTRYPOINT ["/bin/bash"]

从 CentOS 镜像开始

CentOS 团队发布了官方容器镜像,所有镜像都带有版本标签。强烈建议使用持续更新的滚动版本,因为这些版本仅通过主要版本号标记,如 centos:7。在撰写本文时,受支持的 CentOS 版本为 CentOS 7、6 和 5。如果由于某些合规原因需要使用特定的 CentOS 7 版本,可以使用如 centos:7.3.1611centos:7.2.1511centos:7.1.1503centos:7.0.1406 等特定标签。

要从最新的 CentOS 7 开始,在 Dockerfile 中执行以下命令:

FROM centos:7
ENTRYPOINT ["/bin/bash"]

从 Red Hat Enterprise Linux(RHEL)镜像开始

Red Hat 也发布了 RHEL 的容器镜像。撰写时,这些镜像托管在 Red Hat 的 Docker 注册服务器上(access.redhat.com/containers/)。这些镜像没有使用发行版版本标记,而是直接用镜像的名称标记:rhel7 代表 RHEL 7,rhel6 代表 RHEL 6。类似地,子版本也直接体现在镜像名称中:RHEL 7.3 的镜像名为 rhel7.3

要从最新的 RHEL 7 开始,在 Dockerfile 中执行以下命令:

FROM registry.access.redhat.com/rhel7
ENTRYPOINT ["/bin/bash"]

从 Fedora 镜像开始

Fedora 官方为 Docker 构建,每个版本都会简单地标记为其版本号。Fedora 25 的标签是 fedora:25,撰写时版本号最早可以追溯到 fedora:20

要从最新的 Fedora 版本开始,在 Dockerfile 中使用以下命令:

FROM fedora:latest
ENTRYPOINT ["/bin/bash"]

从 Alpine Linux 镜像开始

Alpine Linux 是容器世界中非常流行且安全的轻量级 Linux 发行版。它的体积比其他主流发行版小数十倍:不到 5 MB。它变得如此流行,以至于 Docker(公司)现在将其作为所有官方镜像的基础——而 Alpine 的创始人现在也在 Docker 工作。Alpine 的版本可以在镜像标签中找到:Alpine 3.1 为 alpine:3.1,类似地,Alpine 3.4 为 alpine:3.4

要从 Alpine Linux 的 3.4 版本开始,在 Dockerfile 中使用以下命令:

FROM alpine:3.4
ENTRYPOINT ["/bin/sh"]

从 Debian 镜像开始

Debian 发行版也有多个不同的标签:我们可以找到常见的 debian:stabledebian:unstabledebian:sid,以及一些其他标签,如 debian:oldstable。发行版的名称会像相应的版本一样标记,因此镜像 debian:8debian:jessie 相同。Debian 为每个发行版发布精简版镜像:debian:jessie-slim 比主版本小 30%(撰写时为 80 MB,相较于 126 MB)。

要从 Debian 8(Jessie)版本开始,在 Dockerfile 中使用以下命令:

FROM debian:jessie
ENTRYPOINT ["/bin/bash"]

Linux 发行版容器镜像大小表

下面是当前各个引用镜像的大小表:

Linux 发行版镜像 大小
Alpine 3.4 4.799 MB
Debian 8 (slim) 80 MB
Debian 8 123 MB
Ubuntu 16.04 126.6 MB
RHEL 7.3 192.5 MB
CentOS 7.3 191.8 MB
Fedora 25 199.9 MB

有了这些信息,我们现在可以决定选择其中任何一个。

尽管如此,许多流行的编程语言(Go、Node、Java、Python、Ruby、PHP 等)也在发布自己的容器镜像。它们通常基于前面表格中的操作系统容器镜像。如果我们的产品确定要使用相应的语言,使用这些镜像将会很有趣,因为它们通常提供定制的版本和功能。

从 Node JS 镜像开始

Node Docker 镜像的官方仓库包含多个标签版本和多个基础镜像:node:7 基于 Debian Jessie,而 node:7-alpine 基于 Alpine 3.4。node:7-slim 将基于精简版的 Debian Jessie,如果我们想在 Debian Wheezy 上运行 Node 7,还有 node:7-wheezy。另外,Node 6、4 及更低版本也有提供。

要从最新的 Node 7 镜像版本开始,可以在 Dockerfile 中使用以下内容:

FROM node:7
ENTRYPOINT ["/bin/bash"]

需要说明的是,node:7 镜像大约为 650 MB,而 node:4-slim 镜像大约为 205 MB。

从 Golang 镜像开始

Go 作为 Docker 镜像被广泛分发。它的版本通过版本号进行标签(例如 golang:1.7),还有基于 Alpine 的替代版本(golang:1.7-alpine)或甚至适用于 Windows Server 的版本(golang:1.7-windowsservercoregolang:1.7-nanoserver)。

要从 Go 镜像开始,可以在 Dockerfile 中使用以下内容:

FROM golang:1.7
ENTRYPOINT ["/bin/bash"]

主 Go 1.7 镜像为 672 MB。

从 Ruby 镜像开始

Ruby 也作为官方 Docker 镜像发布:所有最新的版本都以 ruby:2.3 的标签形式存在。此外,还有来自 Alpine Linux 和 Debian Jessie 精简版镜像的替代构建版本。

注意

曾经有一个独立的 Ruby-on-Rails Docker 镜像,但现在已被弃用,转而使用主 Ruby Docker 镜像。

要从 Ruby 2.3 镜像开始,可以使用以下内容启动 Dockerfile:

FROM ruby:2.3
ENTRYPOINT ["/bin/bash"]

主 Ruby 2.3 镜像为 725 MB。

从 Python 镜像开始

Python 官方发布并支持多个版本的 Docker 镜像。我们可以找到版本 2.7、3.3、3.4、3.5 和当前的 beta 版本,这些版本基于 Debian Jessie 或 Wheezy、Alpine 和 Windows Server。

要使用 Python 3.5 镜像启动我们的项目,可以在 Dockerfile 中添加以下内容:

FROM python:3.5
ENTRYPOINT ["/bin/bash"]

python:3.5 镜像大约为 683 MB。

从 Java 镜像开始

Java 用户也可以在 Docker 上获取官方发布版本。OpenJDK 和 JRE 都可以使用,适用于 6、7、8 和 9 版本,基于 Debian Jessie 或 Alpine。

要使用 OpenJDK 9 镜像,可以在 Dockerfile 中使用以下内容:

FROM openjdk:9
ENTRYPOINT ["/bin/bash"]

openjdk:9 镜像为 548 MB——是可用的最小的编程语言镜像之一。

从 PHP 镜像开始

PHP Docker 镜像非常流行,并且有很多不同的版本。它是轻松测试新旧 PHP 版本的一种非常简便的方式。PHP 5.6 和 7.0(以及所有的 beta 版本)都有提供,而且每个版本也有不同的变种,例如基于 Alpine 的php:7-alpine,基于 Debian Jessie 的 Apache 版本php:7-apache,或者基于 Debian Jessie 的 FPM 版本php:7-fpm,但如果我们仍然喜欢 Alpine 版本的 FPM,也可以使用php:7-fpm-alpine

要开始使用经典的 PHP 7 Docker 镜像,在 Dockerfile 中使用以下内容:

FROM php:7
ENTRYPOINT ["/bin/bash"]

主要的php:7镜像为 363 MB——这是目前最小的编程语言镜像。

另见

优化 Docker 镜像大小

Docker 镜像是按照 Dockerfile 中的指令逐步生成的。尽管完全正确,但在谈到镜像大小时,很多镜像在优化上存在不足。让我们通过在 Ubuntu 16.04 上构建 Apache Docker 容器来看看能做些什么。

准备工作

要执行这个操作,你需要一个正常工作的 Docker 安装。

如何实现……

以下是更新 Ubuntu 镜像、安装apache2包,并删除/var/lib/apt缓存文件夹的Dockerfile。它完全正确,如果你构建它,镜像大小大约是 260 MB:

FROM ubuntu:16.04
RUN apt-get update -y
RUN apt-get install -y apache2
RUN rm -rf /var/lib/apt
ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]

现在,每一层都是在上一层的基础上添加的。所以,在apt-get update这层中所写的内容会一直保留,即使我们在最后的RUN命令中删除它。

让我们用一行代码重写这个Dockerfile,以节省一些空间:

FROM ubuntu:16.04
RUN apt-get update -y && \
    apt-get install -y apache2 && \
    rm -rf /var/lib/apt/
ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]

这个镜像与原镜像完全相同,但大小仅为 220 MB。节省了 15%的空间!

debian:stable-slim镜像替代ubuntu:16.04镜像可以得到相同的结果,但大小为 135 MB(大小减少了 48%!):

FROM debian:stable-slim
RUN apt-get update -y && \
    apt-get install -y apache2 && \
    rm -rf /var/lib/apt/
ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]

它是如何工作的……

每一层都在其前一层之上添加。通过将下载到删除的所有相关命令合并在一起,我们在这个特定的层上保持了一个干净的状态。另一个很好的例子是,当 Dockerfile 下载一个压缩包时;分别下载、解压和删除这个压缩包会使用大量的额外层空间。如果把这些操作写成一行代码,它们会一次性完成。所以,代替从压缩包及其解压后的内容中累积的空间,所占空间仅来自解压后的内容。通常,这样做会带来非常可观的空间节省!

使用标签对 Docker 镜像进行版本控制

一个非常常见的需求是快速识别一个 Docker 镜像正在运行的软件版本,并选择是否固定版本,或者确保始终运行一个稳定版本。这是 Docker 标签的完美应用场景。我们将构建一个 Terraform 容器,使用稳定和不稳定标签,这样多个版本可以并存——一个用于生产,另一个用于测试。

注意

Docker 标签与 Docker 标签不同。标签是纯粹的信息性,而标签则可以直接请求,以便从操作角度区分镜像。

准备工作

要按照此配方操作,您需要一个正常工作的 Docker 安装。

如何操作…

这是一个简单的 Dockerfile,用于创建 Terraform 容器(Terraform 在本书前面已经介绍过):

FROM alpine:latest
ENV TERRAFORM_VERSION=0.7.12
VOLUME ["/data"]
WORKDIR /data
RUN apk --update --no-cache add ca-certificates openssl && \
  wget -O terraform.zip "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \
  unzip terraform.zip -d /bin && \
  rm -rf terraform.zip /var/cache/apk/*
ENTRYPOINT ["/bin/terraform"]
CMD [ "--help" ]

这是当前的稳定和最新版本,也是 0.7.12。我们希望我们的用户能够请求以下版本之一:

  • terraform:latest(对于那些始终需要最新版本的用户)

  • terraform:stable(对于那些始终需要稳定版本的用户,而不是测试版)

  • terraform:0.7.12(对于那些始终需要特定版本的用户,例如由于兼容性问题)

通过直接构建所有这些不同的标签,轻松实现这一目标:

$ docker build -t terraform:latest -t terraform:stable -t terraform:0.7.12 .

现在,当请求哪些镜像可用时,我们可以看到它们都有相同的镜像 ID,但具有不同的标签。这正是我们想要的,因为它是相同的镜像,分享所有这些标签:

$ docker images terraform
REPOSITORY          TAG                 IMAGE ID            CREATED              SIZE
terraform           0.7.12              9d53a0811d63        About a minute ago   83.61 MB
terraform           latest              9d53a0811d63        About a minute ago   83.61 MB
terraform           stable              9d53a0811d63        About a minute ago   83.61 MB

几天后,我们发布了软件的新版本作为 Docker 容器供我们的团队测试。这一次是一个不稳定的 0.8.0-rc1 版本。我们希望我们的用户能够请求以下镜像之一:

  • terraform:latest(它仍然是可用的最新版本,即使是不稳定的)

  • terraform:unstable(这是一个发布候选版本,而不是稳定版本)

  • terraform:0.8.0-rc1(这是这个特定版本)

Dockerfile中更改TERRAFORM_VERSION变量,并使用以下标签构建镜像:

$ docker build -t terraform:latest -t terraform:unstable -t terraform:0.8.0-rc1 .

现在,如果我们查看可用的 Terraform 镜像,我们可以确认它是相同的镜像 ID,latestunstable0.8.0-rc1标签共享该镜像 ID,而我们的用户如果更倾向于稳定版本,则不会受到我们更改的影响:

$ docker images terraform
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
terraform           0.8.0-rc1           44609fa7c016        18 seconds ago      86.77 MB
terraform           latest              44609fa7c016        18 seconds ago      86.77 MB
terraform           unstable            44609fa7c016        18 seconds ago      86.77 MB
terraform           0.7.12              9d53a0811d63        9 minutes ago       83.61 MB
terraform           stable              9d53a0811d63        9 minutes ago       83.61 MB

注意

这引出了一个非常重要的问题:由于默认情况下未指定任何标签时使用的是最新标签,是否也应该将其用于不稳定的版本?这需要根据您的需求和环境来回答。

在 Docker 中部署 Ruby-on-Rails Web 应用程序

Docker 的好处在于,作为开发人员,我们可以在某个环境(如开发或预发布)上将所有工作正常的内容打包到该容器中,并确信它将在另一个环境(如生产)中类似运行。部署压力较小,回滚也更容易。然而,要实现这种安心感,我们需要的不仅仅是一个 Ruby-on-Rails 应用程序,例如,我们需要打包一个包含所有内容的 Dockerfile,以便任何人都可以运行它。以下是操作方法。

准备工作

要按照此配方操作,您需要以下内容:

  • 一个正常工作的 Docker 安装

  • 一个 Rails 应用程序

如何操作…

这是我们的标准要求:

  • 这个 Rails 应用程序需要 Ruby 2.3

  • 所有依赖项都由 Bundler 管理,需要在容器中安装

  • 还需要 Node 5

  • 我们希望在镜像中预编译资产(将它们放到其他地方超出了此范围)。

我们将按以下步骤进行操作。为了满足我们的主要需求,我们将从 ruby:2.3 镜像开始:

FROM ruby:2.3

启用官方 Node 5 仓库的一种方法是下载并执行一个安装脚本。我们来做一下:

RUN curl -sL https://deb.nodesource.com/setup_5.x | bash -

现在我们需要安装 Node 5 (apt-get install nodejs) 并删除所有缓存文件:

RUN apt-get install -qy nodejs && \
  rm -rf /var/lib/apt/* && \
  rm -rf /var/lib/cache/* && \
  rm -rf /var/lib/log/* && \
  rm -rf /tmp/*

Ruby 镜像文档建议将 /usr/src/app 用作我们代码的目标文件夹。我们确保它已创建并切换到该目录,直到完成其余过程:

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

为了安装所有声明的依赖项,我们需要将 GemfileGemfile.lock 发送到目标文件夹 /usr/src/app。我们将其作为一个独立的步骤,以便以后可以根据需要定制此步骤。然后我们执行 Bundler(如果有的话,跳过测试和开发部分)。如果您是 Ruby 开发人员,请根据需要进行定制!

COPY Gemfile /usr/src/app/
COPY Gemfile.lock /usr/src/app/
RUN bundle install --without test development --jobs 20 --retry 5

现在是时候将应用程序代码本身复制到目标文件夹 /usr/src/app(在本例中,它是当前文件夹):

COPY . /usr/src/app

下一步是预编译资产,设置 RAILS_ENV 为生产环境,但您可以根据需要调整,包括编译命令:

RUN RAILS_ENV=production rake assets:precompile

最后,通过 Bundler 在所有接口上运行 Rails 服务器(默认情况下,它监听 TCP/3000):

CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

我们现在可以构建这个 Dockerfile,并使我们的完整、独立且完全可用的 Ruby-on-Rails 应用程序准备好运行在 Docker 上。

注意

在 CI 中插入构建过程并执行测试是一个好习惯,运行这个新镜像时一定要进行测试!

使用 Docker 构建和使用 Golang 应用程序

Golang 是一种伟大的编程语言,能够为不同平台创建静态链接的二进制文件,如 Linux(ELF 二进制文件)或 Mac OS(Mach-O 二进制文件)。这些二进制文件通常非常小,且该语言在微服务领域日益流行,因为它的可移植性和部署速度:在几十台服务器上部署一个自给自足的 10 MB Docker 镜像比部署一个满是库的 1.5 GB 镜像要方便且快速得多。Golang 和容器是两种非常匹配的技术,使用 Go 程序来运输或管理基础设施轻松便捷。

准备工作

要逐步进行此配方,您需要以下内容:

  • 一个有效的 Docker 安装

  • 一个 Golang 应用程序源代码

如何操作……

假设我们的应用程序代码存放在 src/hello 中。我们希望至少编译程序,无论是为 Linux 平台还是为 Mac 操作系统。

使用 golang Docker 镜像进行 Go 程序的交叉编译

我们可以通过共享代码文件夹并将工作目录设置为该文件夹来编译我们的程序:

$ docker run --rm -v "${PWD}/src/hello":/usr/src/hello -w /usr/src/hello golang:1.7 go build -v

这样,即使在 Mac OS 系统上,我们也可以生成一个正确的 ELF 二进制文件:

$ file src/hello/hello
src/hello/hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

也就是说,如果我们明确想要一个 Mac 二进制文件,我们可以传递标准的 Go 环境变量 GOOSGOARCH,这样即使是 Linux 机器也能构建 Mac 二进制文件:

$ docker run --rm -v "${PWD}/src/hello":/usr/src/hello -w 
/usr/src/hello -e GOOS=darwin -e GOARCH=amd64 golang:1.7 go build -v

确认我们有一个 Mach-O 可执行文件,而不是 ELF 二进制文件:

$ file src/hello/hello
src/hello/hello: Mach-O 64-bit executable x86_64

使用 golang Docker 镜像构建并部署 Go 程序

如果我们想直接从 Dockerfile 构建程序并生成 Docker 镜像,那么可以按如下方式进行:

FROM golang:1.7
COPY src/hello /go/src/hello
RUN go install hello
ENTRYPOINT ["/go/bin/hello"]

只需构建该镜像并执行即可:

$ docker build -t hello .
$ docker run -it --rm hello

使用 scratch Docker 镜像

现在,对于经常只有几 MB 的小型 Golang 应用程序,使用 675 MB 以上的镜像是有些浪费空间的,而且在服务器上部署也需要时间。这里就用到了 scratch 镜像:它实际上不存在。我们从零开始,复制二进制文件并执行它。我们的构建过程(Makefile、构建过程和 CI)使用 golang 镜像来构建应用程序,但不会将编译后的应用程序一同打包,从而节省了通常 95-99% 的空间,具体取决于二进制文件的大小:

FROM scratch
COPY src/hello/hello /hello
ENTRYPOINT ["/hello"]

这将生成一个最小的镜像。想象一下,只有几兆字节。

使用 Alpine Linux 作为 Go 程序的替代方案

使用 scratch 镜像的主要问题是无法轻松地从容器内部进行调试,以及无法依赖外部库或依赖项(如 SSL 和证书)。Alpine Linux 是一个非常小的镜像(约 5 MB),如果我们希望访问 shell(/bin/sh 可用)和包管理器来调试应用程序,它将非常有帮助。我们可以这样做:

FROM alpine:latest
RUN apk --update --no-cache add ca-certificates openssl && \
    rm -rf /var/cache/apk/*
COPY src/hello/hello /bin/hello
ENTRYPOINT ["/bin/hello"]

这样的镜像通常比应用程序二进制文件大几个兆字节,但在调试时非常有帮助。

使用 Docker 进行网络连接

Docker 提供了一些非常不错的网络选项,从选择暴露哪些端口到同时运行隔离或桥接的网络。它非常有用,可以快速、轻松地模拟生产环境、创建更好的架构,并增加容器在网络前端的曝光度。我们将看到不同的端口暴露方式、如何创建新网络、如何在其中执行 Docker 容器,甚至让每个容器拥有多个网络。

准备工作

要按照此教程操作,您需要以下内容:

  • 一个可用的 Docker 安装

  • 一个示例 HTTP 服务器二进制文件(示例代码包括)

如何操作……

要使容器网络端口对其他人可用,首先需要将其 暴露。考虑任何监听端口的服务,除非正确暴露在 3 中,否则无法访问:

FROM debian:jessie-slim
COPY src/hello/hello /hello
EXPOSE 8000
ENTRYPOINT ["/hello"]

该服务正在 8000 端口监听,默认情况下,运行在主机上的任何其他 Docker 容器都可以访问它,且都在相同的网络上:

# curl -I http://172.17.0.2:8000/
HTTP/1.1 200 OK

然而,这个服务对主机系统不可用:

$ curl http://localhost:8000
curl: (7) Failed to connect to localhost port 8000: Connection refused

为了让其对主机系统可用,容器必须通过显式的端口重定向运行。可以使用选项 -P 随机映射暴露的端口(例如,端口 8000 可以映射到本地机器的 32768 端口),或者使用另一个选项 -p 8000:8000 来固定端口映射:

$ docker run -ti --rm -P --name hello hello 

在另一个终端中,查找端口重定向:

$ docker port hello
8000/tcp -> 0.0.0.0:32771

同时,尝试连接到该端口:

$ curl -I http://localhost:32771/
HTTP/1.1 200 OK

这些是与 Docker 容器进行网络连接的基础知识。

Docker 网络

容器也可以存在于专用网络中,以增加安全性和隔离性。要创建一个新的 Docker 网络,只需给它命名即可:

$ docker network create hello_network
d01a3784dec1ade72b813d87c1e6fff14dc1b55fdf6067d6ed8dbe42a3af96c2

使用docker network inspect命令获取有关此网络的一些信息:

$ docker network inspect hello_network -f '{{json .IPAM.Config }}'
[{"Subnet":"172.18.0.0/16","Gateway":"172.18.0.1/16"}]

这是一个新的子网:172.18.0.0/16(在此情况下)。

要在这个特定的 Docker 网络中执行容器,请像这样使用--network <docker_network_name>选项:

$ docker run -it --rm --name hello --network hello_network hello

确认该容器位于hello_network网络的172.18.0.0/16网络空间中:

$ docker inspect --format '{{json .NetworkSettings.Networks.hello_network.IPAddress }}' hello
"172.18.0.2"

这个容器将被保护,避免任何未在正确网络上运行的容器访问。这里有一个例子,来自一个在默认网络上运行的容器:

# curl -I --connect-timeout 5 http://172.18.0.2:8000/
curl: (28) Connection timed out after 5003 milliseconds 

然而,从同一网络中的容器进行连接是允许的,并且按预期工作:

# curl -I http://hello:8000/
HTTP/1.1 200 OK

为一个容器连接多个网络

在多个网络上拥有一些特定容器可能会很有用;代理、内部服务和其他类似服务可能面临不同的网络配置。一个 Docker 容器可以连接多个 Docker 网络。以这个简单的 HTTP 服务为例,它监听 8000 端口并在默认的桥接网络上启动:

$ docker run -ti --rm --name hello hello

现在,这个服务可以供任何其他容器在默认网络上使用:

# curl -I http://172.17.0.2:8000/
HTTP/1.1 200 OK

然而,我们希望它也可以在hello_network Docker 网络上使用。让我们将它们连接到主机:

$ docker network connect hello_network hello

这个容器现在在hello_network子网中有了一个新的网络接口:

$ docker exec -it hello ip addr
[...]
116: eth0@if117: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
 inet 172.17.0.2/16 scope global eth0
[...]
118: eth1@if119: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
 link/ether 02:42:ac:12:00:02 brd ff:ff:ff:ff:ff:ff
 inet 172.18.0.2/16 scope global eth1
[...]

这意味着它也可以响应来自该网络中容器的请求!

$ curl http://hello:8000
Hello world

在完成后,我们最终会移除与原始网络的链接:

$ docker network disconnect bridge hello

创建更动态的容器

我们可以创建比仅提前固定其用法并执行它们更好的容器。也许某些命令部分需要保留(比如我们始终希望执行 OpenVPN 二进制文件及其选项),也许所有的命令都需要被覆盖(这就是工具箱容器模型,例如默认情况下是/bin/bash命令,但可以执行其他传入的命令),或者两者结合,以实现一个更动态的容器。

准备工作

要执行此步骤,您需要一个正常工作的 Docker 安装。

如何操作……

要让容器执行固定命令,请使用ENTRYPOINT指令。如果命令后面有需要强制执行的参数,请使用数组:

FROM debian:stable-slim
RUN apt-get update -y && \
    apt-get install -y apache2 && \
    rm -rf /var/lib/apt/
EXPOSE 80
ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]

要在运行时覆盖整个命令,请使用--entrypoint选项:

$ docker run -it --rm --entrypoint /bin/sh httpd
# hostname
585dff032d21

要使用可以简单覆盖的命令,请使用CMD指令,而不是ENTRYPOINT

FROM debian:stable-slim
RUN apt-get update -y && \
    apt-get install -y apache2 && \
    rm -rf /var/lib/apt/
EXPOSE 80
CMD ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]

要覆盖命令,只需在运行时提供另一个命令作为参数:

$ docker run -it --rm httpd /bin/sh
# hostname
cb1c6a7083ad

我们可以结合这两条指令,创建一个更动态的容器。在这种情况下,我们希望获取一个始终执行/usr/sbin/apache2ctl的容器,并默认在前台启动守护进程,除非在容器启动时通过任何参数进行覆盖:

FROM debian:stable-slim
RUN apt-get update -y && \
    apt-get install -y apache2 && \
    rm -rf /var/lib/apt/
EXPOSE 80
CMD ["-D", "FOREGROUND"]
ENTRYPOINT ["/usr/sbin/apache2ctl"]

如果这个容器按原样执行,什么也不会改变;apache2ctl会使用-D FOREGROUND选项执行。

然而,给它传递参数后,它变成了一个更有用的容器,因为它会动态地将这些参数添加到apache2ctl命令中,替换掉CMD指令中指定的原始命令:

$ docker run -it --rm httpd -v
Server version: Apache/2.4.10 (Debian)
Server built:   Sep 15 2016 20:44:43

我们可以交互式地传递/usr/sbin/apache2ctl参数,而无需覆盖入口点,例如,提出备选的 Apache 配置文件或选项。

自动配置动态容器

我们不能总是执行二进制文件来获取我们想要的内容。动态配置是一种非常常见的情况;系统路径可以是动态的,用户和密码可以是自动生成的,网络端口可以是上下文相关的,第三方凭证在开发和生产环境中可能不同,工作节点会加入其主节点,集群成员会找到其他节点,其他类似的变化元素都需要在运行时进行适应。这里的关键是将环境变量与作为入口点执行的脚本结合起来,无论如何都会执行,并根据环境变量进行行为调整,可以选择性地与 Dockerfile 中的命令结合使用。

准备就绪

要按照这个配方进行操作,您需要有一个正常工作的 Docker 安装。

如何做……

我们的目标是创建一个临时、动态的 SSH 服务器,容器中的凭证我们无法事先知道。所以,为了按预期工作,我们需要像这样执行该容器:

$ docker run -e USER=john -e PASSWORD=s3cur3 sshd 

以这个简单的Dockerfile为例,它在 Alpine Docker 镜像上创建了运行 Dropbear SSH 服务器所需的环境:

FROM alpine:latest
RUN apk add --update openssh-sftp-server openssh-client dropbear &&\
    rm -rf /var/cache/apk/*
RUN mkdir /etc/dropbear && touch /var/log/lastlog
COPY entrypoint.sh /
ENTRYPOINT ["/entrypoint.sh"]
CMD ["dropbear", "-RFEmwg", "-p", "22"]

构建完成后,该容器将通过执行entrypoint.sh脚本启动,然后是dropbear二进制文件。以下是一个示例entrypoint.sh,它仅对USERPASSWORD环境变量进行简单检查,在容器上创建所需的用户,设置一些权限,最后执行原始 Dockerfile 中的CMD指令:

#!/bin/sh

# Checks for USER variable
if [ -z "$USER" ]; then
  echo >&2 'Please set an USER variable (ie.: -e USER=john).'
  exit 1
fi

# Checks for PASSWORD variable
if [ -z "$PASSWORD" ]; then
  echo >&2 'Please set a PASSWORD variable (ie.: -e PASSWORD=hackme).'
  exit 1
fi

echo "Creating user ${USER}"
adduser -D ${USER} && echo "${USER}:${PASSWORD}" | chpasswd
echo "Fixing permissions for user ${USER}"
chown -R ${USER}:${USER} /home/${USER}
exec "$@"

如果在没有任何参数的情况下执行此容器,它会报错,这得益于entrypoint.sh脚本中的检查:

$ docker run --rm ssh
Please set an USER variable (ie.: -e USER=john).

要正确使用这个动态配置的容器,请根据需要使用环境变量:

$ docker run --rm -h ssh-container -e USER=john -e PASSWORD=s3cur3 -p 22:22 ssh
Creating user john
Password for 'john' changed
Fixing permissions for user john
[1] Nov 29 23:02:02 Not backgrounding

现在,尝试从另一个终端或容器连接到此容器,并提供正确的凭证:

$ ssh john@localhost
[...]
john@localhost's password:
ssh-container:~$ hostname
ssh-container

我们已经登录到 SSH 容器了!

这样的动态系统可以用来为需要的人提供临时、受控且安全的 SSH 访问权限,例如共享卷存储访问或类似用途。关闭容器会撤销所有操作,我们就完成了。

通过无特权用户提高安全性

默认情况下,容器会以 root 用户身份执行所有操作。虽然容器是在隔离环境中运行的,但仍然,公开面向外部的守护进程是以 root 身份在系统上运行的,如果发生安全漏洞,攻击者可能会获得对该容器的访问权限,甚至是 root shell 访问权限,从而至少获得容器的 Docker 覆盖网络访问权限。我们是否愿意看到这个问题与一个 0-day 本地内核安全漏洞结合,进而让攻击者获得对 Docker 主机的访问权限?大概不会。那么,也许我们应该遵循一些古老的最佳实践,从一开始就以非 root 用户身份执行我们的守护进程。

准备工作

要完成这个配方,您需要以下内容:

  • 一个工作中的 Docker 安装

  • 一个示例 HTTP 服务器二进制文件(包括示例代码)

如何实现……

让我们以一个简单的 HTTP 服务器为例,它在容器的 8000 端口上提供响应。通过容器执行时,它的样子将是这样的,如本书前面所示:

FROM debian:jessie-slim
COPY src/hello/hello /usr/bin/hello
RUN chmod +x /usr/bin/hello
EXPOSE 8000
ENTRYPOINT ["/usr/bin/hello"]

这样做是有效的,但从安全角度来看情况并不理想;我们的守护进程实际上是以 root 用户身份运行的,尽管它是在一个非特权端口上运行:

$ ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.6  0.2  36316  4180 ?        Ssl+ 23:30   0:00 /usr/bin/hello

从安全角度来看,这是不理想的。容器是真正的系统,因此它们也可以有用户。结合 Dockerfile 中的 USER 指令,我们将能够以非特权用户身份执行命令!这是一个优化过的 Dockerfile,增加了一个普通的用户和组为 hello 用户,然后作为这个新的非特权用户执行 /usr/bin/hello HTTP 服务器:

FROM debian:jessie-slim
COPY src/hello/hello /usr/bin/hello
RUN chmod +x /usr/bin/hello
RUN groupadd -r hello && useradd -r -g hello hello
USER hello
EXPOSE 8000
ENTRYPOINT ["/usr/bin/hello"]

一旦构建并运行,守护进程仍然能够正常运行,但作为一个非特权用户:

$ ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
hello        1  0.0  0.2  36316  4768 ?        Ssl+ 23:33   0:00 /usr/bin/hello

我们现在正在构建更强大的容器!

使用 Docker Compose 进行编排

手动启动多个容器可能会很麻烦,尤其是在基础设施日益复杂的情况下。依赖关系、共享变量和公共网络可以通过名为 Docker Compose 的编排工具轻松处理。在一个简单的 YAML 文件中,我们可以描述运行应用程序所需的服务(代理、应用程序、数据库等)。在本节中,我们将展示如何创建一个简单的 LAMP docker-compose 文件,然后我们将展示如何从中进行迭代,构建一些适用于暂存和生产的特定更改。

准备工作

要完成这个配方,您需要以下内容:

  • 一个工作中的 Docker 安装

  • 一个工作中的 Docker Compose 安装

如何实现……

要使用 Docker Compose 编排多个容器,我们从一个简单的 WordPress 示例开始。WordPress 团队构建了一个容器,该容器通过类似本章前面所见的环境变量自动配置到一定程度。如果我们仅仅应用随 WordPress Docker 容器一起发布的文档,我们将得到以下 docker-compose.yml 文件,位于某个新目录的根目录中(如果需要,可以是 Git 仓库):

version: '2'

services:
  wordpress:
    image: wordpress
    ports:
      - 8080:80
    environment:
      WORDPRESS_DB_PASSWORD: example
  mysql:
    image: mariadb
    environment:
      MYSQL_ROOT_PASSWORD: example

这有一个很大的优点,即开箱即用;最新的 WordPress 和 MariaDB 镜像会被下载,本地 HTTP 端口 80 会被重定向到主机的端口 8080,MySQL 保持隔离。WordPress 容器在这种情况下只需要一个环境变量——MySQL 的 root 密码,它应与 MySQL 的环境变量匹配。我们会看到,实际上还有更多的环境变量可以配置。

执行 Docker Compose 将自动创建一个 Docker 网络并启动容器:

$ docker-compose up
[...]
mysql_1      | 2016-12-01 20:51:14 139820361766848 [Note] mysqld (mysqld 10.1.19-MariaDB-1~jessie) starting as process 1 ...
[...]
mysql_1      | 2016-12-01 20:51:15 139820361766848 [Note] mysqld: ready for connections.
[...]
wordpress_1  | [Thu Dec 01 20:51:17.865932 2016] [mpm_prefork:notice] [pid 1] AH00163: Apache/2.4.10 (Debian) PHP/5.6.28 configured -- resuming normal operations
wordpress_1  | [Thu Dec 01 20:51:17.865980 2016] [core:notice] [pid 1] AH00094: Command line: 'apache2 -D FOREGROUND'

让我们验证是否能通过本地重定向端口 8080 连接到 WordPress HTTP 服务器:

$ curl -IL http://localhost:8080
HTTP/1.1 302 Found
[...]

HTTP/1.1 200 OK
[...]

使用 ps 命令可以查看更多信息:

$ docker-compose ps
 Name                      Command               State          Ports
-----------------------------------------------------------------------------------
1basics_mysql_1       docker-entrypoint.sh mysqld      Up      3306/tcp
1basics_wordpress_1   docker-entrypoint.sh apach ...   Up      0.0.0.0:8080->80/tcp

让我们通过使用 docker-compose exec 命令来确保 MySQL root 密码使用的确实是 docker-compose.yml 文件中提供的密码,这个命令非常类似于 docker run 命令(它接受 docker-compose.yml 文件中的名称):

$ docker-compose exec mysql /usr/bin/mysql -uroot -pexample
[...]

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| wordpress          |
+--------------------+
4 rows in set (0.00 sec)

当我们完成初始的 Docker Compose 环境后,让我们销毁它;容器和网络将被移除:

$ docker-compose down

扩展 Docker Compose

现在我们已经掌握了基本知识,接下来我们稍微扩展一下使用。我们不满意默认密码,想使用更好的密码,以便模拟一个临时环境。为此,我们将利用 Docker Compose 的覆盖特性,创建一个 docker-compose.staging.yml 文件,简单地覆盖相关的值:

version: '2'
services:
  wordpress:
    image: wordpress:4.6
    environment:
      WORDPRESS_DB_PASSWORD: s3cur3
  mysql:
    environment:
      MYSQL_ROOT_PASSWORD: s3cur3

当使用多个配置文件执行 docker-compose 时,WORDPRESS_DB_PASSWORDMYSQL_ROOT_PASSWORD 这两个环境变量会被覆盖:

$ docker-compose -f docker-compose.yml -f docker-compose.staging.yml up

验证新密码是否在 MySQL 中正常工作:

$ docker exec -it 1basics_mysql_1 mysql -uroot -ps3cur3
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 4

我们非常容易地通过简单的 YAML 文件来覆盖值!

假设我们现在想要在配置中加入反向代理,使用一个稍早版本的 Docker 镜像并更换 MySQL 密码,以模拟我们在生产环境中的特定情况。我们可以使用 jwilder/nginx-proxy 提供的优秀动态 Nginx 镜像来完成这项工作,并在 docker-compose.production.yml 文件中添加一个新的 proxy 服务,共享端口 80 以及本地 Docker 套接字作为只读(以动态访问运行中的容器):

  proxy:
    image: jwilder/nginx-proxy
    ports:
      - "80:80"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro

这个 nginx-proxy 容器需要一个名为 VIRTUAL_HOST 的变量,以便在有多个虚拟主机的情况下知道该回答什么。我们将其设置为 localhost(或者根据你的本地主机名进行调整),并添加更好的密码以及 WordPress 镜像版本:

  wordpress:
    image: wordpress:4.5
    environment:
      WORDPRESS_DB_PASSWORD: sup3rs3cur3
      VIRTUAL_HOST: localhost

让 MySQL 部分的密码也匹配,我们就完成了生产环境的模拟:

$ docker-compose -f docker-compose.yml -f docker-compose.production.yml up

确认 nginx-proxy 在 HTTP/80 上正常响应,并转发来自 WordPress 容器的正确 HTTP 响应:

$ curl -IL http://localhost/
HTTP/1.1 302 Found
Server: nginx/1.11.3
[...]
HTTP/1.1 200 OK
Server: nginx/1.11.3

我们已经看到,仅用几行 YAML 配置就能轻松编排容器,如何处理不同的情况和环境,并且它也能成功扩展。不过,这只是 Docker Compose 可以实现的功能的一个小介绍——它是一个非常强大的工具!

另见

Lint 检查 Dockerfile

就像其他任何语言一样,Dockerfile 也可以并且应该进行 lint 检查,以确保最佳实践和代码质量。Docker 也不例外,好的实践总是在不断发展,更新,并且在不同社区之间可能略有不同。在本节中,我们将从之前找到的一个基本 Dockerfile 开始,最后得到一个完全经过双重检查的 lint 检查文件。

准备工作

为了按此方法进行操作,您需要以下内容:

  • 一个正常工作的 Docker 安装

  • 一个 AWS 账户

如何操作……

有许多不同的 linters 可以用于 lint 检查 Dockerfile:Hadolint(hadolint.lukasmartinelli.ch/)可能是使用最广泛的 linters,而 Project Atomic 的 dockerfile_lint 项目则可能是最完整的一个(github.com/projectatomic/dockerfile_lint)。

这是本书之前提到的工作中的 Dockerfile:

FROM debian:stable-slim
RUN apt-get update -y \
    && apt-get install -y apache2 \
    && rm -rf /var/lib/apt
ENTRYPOINT ["/usr/sbin/apache2ctl"]
CMD ["-D", "FOREGROUND"]

Hadolint

让我们开始使用 Hadolint,因为它易于安装(提供预构建的二进制文件和 Docker 镜像)和使用。所有规则都可以在 Hadolint 的 Wiki 中找到解释(github.com/lukasmartinelli/hadolint/wiki),而且使用方法非常简单:

$ hadolint Dockerfile

另外,可以使用 Docker 容器化版本;它可能在 CI 脚本中非常有用。请注意镜像的大小;在撰写时,该镜像为 1.7 GB,而 Hadolint 二进制文件小于 20 MB:

$ docker run --rm -i lukasmartinelli/hadolint < Dockerfile

从本章开始对 Dockerfile 进行 lint 检查,我们会注意到不同的警告。也许有些是误报,或者有些规则还没有更新到最新的废弃通知,例如以下内容:

$ hadolint Dockerfile
Dockerfile DL4000 Specify a maintainer of the Dockerfile

事实上,这个 Dockerfile 遵循了 Docker 1.13 的推荐做法,其中包括不再包含 maintainer 指令。然而,Hadolint 还没有更新以适应这一废弃的变化,因此执行以下命令以忽略一个或多个 ID,仍然可以保持正常:

$ hadolint --ignore DL4000 --ignore <another_ID> Dockerfile

Dockerfile_lint

由 Project Atomic 团队(www.projectatomic.io/)主导的这个项目也提出了不同的检查和关于如何编写 Dockerfile 的强烈意见。这些建议通常都是很好的建议。

执行此命令以从官方 Docker 镜像启动 dockerfile_lint

$ docker run -it --rm -v $PWD:/root/ projectatomic/dockerfile-lint dockerfile_lint

会出现一些建议(错误、警告和信息),每个建议都带有相关的参考 URL 供您参考。

当有疑问时,通常跟随建议并相应地修复代码是一个不错的选择。

在这个双重 lint 检查过程结束时,我们的 Dockerfile 改动很大,如下所示:

FROM debian:stable-slim
LABEL name="apache"
LABEL maintainer="John Doe <john@doe.com>"
LABEL version=1.0
RUN apt-get update -y \
    && apt-get install -y --no-install-recommends apache2=2.4.10-10+deb8u7 \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*
EXPOSE 80
ENTRYPOINT ["/usr/sbin/apache2ctl"]
CMD ["-D", "FOREGROUND"]

我们添加了标签以识别镜像、版本和维护者,并且我们固定了 apache2 包的正确版本。这样就不会因为未经测试的更新出现意外(更新包需要重新构建镜像),我们更精确地清理了 apt 缓存,并明确地从容器中暴露了一个端口。

总体来说,linters 提出的这些更改帮助我们构建了一个更好、更强大的容器。它们在 CI 中的作用至关重要;在你的 Jenkins、Circle 或 Travis CI 作业中加入 linters!

部署一个带 S3 存储的私有 Docker 注册表

Docker 注册表是一个中央镜像分发服务。当我们拉取推送镜像时,它来自 Docker 注册表。它可以是商业托管的(例如 CoreOS Quay quay.io/ 和 Docker 自己的 hub.docker.com/),也可以是自托管的(出于隐私、速度、带宽问题或公司政策的考虑)。Docker 公司让我们轻松部署它;它有详细的文档和打包好的版本。在众多可部署的功能中,我们首先简单地部署一个准备好负载均衡的单一注册表,然后将其后端存储切换为 AWS S3,这样磁盘空间再也不是问题。

准备就绪

要按照这个配方进行操作,你将需要以下内容:

  • 一个正常工作的 Docker 安装

  • 一个具有完全 S3 访问权限的 AWS 账户

如何操作…

我们将使用 Docker Compose 来完成这个配方。我们的目标是托管自己的私有 Docker 注册表,最初使用本地存储,然后使用 S3 桶来提供无限的空间。该注册表将通过 http://localhost:5000 访问,但你也可以使用任何其他可解析的名称或具有本地可用名称的专用服务器。

首先,我们需要 Docker 注册表 v2 镜像:registry:2。根据文档,我们知道注册表服务器会暴露端口 5000,所以我们需要将其转发到主机以便本地使用。如果我们在负载均衡器后面运行多个注册表,分享一个通用密钥是安全的,我们将其设置为 s3cr3t

这是我们初始的 docker-compose.yml 文件:

version: '2'

services:
  registry:
    image: registry:2
    ports:
      - 5000:5000
    environment:
      REGISTRY_HTTP_SECRET: s3cr3t

通过这个简单的设置,我们已经能够运行我们自己的本地 Docker 注册服务器:

$ docker-compose up

要将镜像上传到我们的私有注册表,过程就是简单地用本地注册表 URL 标记镜像并推送。执行以下命令将 ubuntu:16.04 镜像标记为 localhost:5000/ubuntu

$ docker tag ubuntu:16.04 localhost:5000/ubuntu

然后,要将镜像推送到本地注册表,执行以下操作:

$ docker push localhost:5000/ubuntu

这个 Docker 镜像现在已存储在本地,可以在不访问公共网络、Docker Hub 或类似服务的情况下重复使用。

使用 S3 后端

高使用率的本地 Docker 注册表面临的一个问题是磁盘空间管理——它是有限的。好消息是,Docker 注册表可以轻松处理 S3 后端(如果我们有内部的 OpenStack,也可以使用 Swift)。需要说明的是,Google Cloud 和 Azure 存储也得到支持。要启用 S3 后端,只需在 docker-compose.yml 文件中设置少数几个变量:要联系的 AWS 区域、密钥和桶名称。

      REGISTRY_STORAGE: s3
      REGISTRY_STORAGE_S3_REGION: us-east-1
      REGISTRY_STORAGE_S3_BUCKET: registry-iacbook
      REGISTRY_STORAGE_S3_ACCESSKEY: AKIAXXXXXXXXX
      REGISTRY_STORAGE_S3_SECRETKEY: 1234abcde#

如果你尝试过之前的示例,请先销毁(docker-compose down)它,然后启动这个更新后的示例:

$ docker-compose up

现在重新在本地标记一个镜像:

$ docker tag ubuntu:16.04 localhost:5000/ubuntu

然后,将镜像推送到本地注册表:

$ docker push localhost:5000/ubuntu

根据你的上传速度,注册表同步我们推送的层到 AWS S3 后端所需的时间长短会有所不同。

现在我们拥有了自己的本地注册表,具备了无限存储空间!

另见

第十章:维护 Docker 容器

本章将介绍以下内容:

  • 使用 BATS 测试 Docker 容器

  • 使用 Docker 和 ServerSpec 进行测试驱动开发(TDD)

  • 从 Git 创建自动化 Docker 构建的工作流程

  • 连接持续集成(CI)系统的工作流程

  • 使用 Quay.io 和 Docker Cloud 扫描漏洞

  • 将 Docker 日志发送到 AWS CloudWatch Logs

  • 监控和获取 Docker 信息

  • 使用 sysdig 调试容器

介绍

在本章中,我们将探索一些高级且非常有趣的领域,这些领域可能是今天大多数开发人员已经习惯的。基础设施代码仍然是代码,因此它应该与软件代码没有什么不同;同样的原则应该适用。这意味着 Docker 代码应该是可测试的,构建过程是自动的,CI 系统应连接到我们的 Git 服务器,以便它们可以持续执行这些测试。此外,安全检查应该成为强制发布流程的一部分,日志应易于访问,即使应用程序在多台机器上进行了扩展。还要注意,容器不应是黑箱,我们应该拥有高性能的调试工具来帮助我们完成工作。好消息是,这些话题将在本章中覆盖,因为所有这些都可以轻松实现。

使用 BATS 测试 Docker 容器

BATSBash 自动化测试系统)让你可以用非常自然的语言进行快速简便的测试,无需大量依赖项。根据需求,BATS 也可以随着复杂性增加。在这一部分,我们将使用 Docker 和 Docker Compose 来处理构建,并使用 Makefile 将构建过程和 BATS 测试过程之间的依赖关系绑定在一起;这样更容易将该过程稍后集成到 CI 系统中。

准备工作

为了进行此操作,你将需要:

  • 一个正常工作的 Docker 安装

  • BATS 安装(它适用于所有主要的 Linux 发行版和 Mac OS)

注意

本章使用的是 BATS 版本 0.4.0。

如何操作……

让我们从这个简单的 Dockerfile 开始,它将安装 Apache 并在清理缓存后运行它:

FROM debian:stable-slim
LABEL name="apache"
LABEL maintainer="John Doe <john@doe.com>"
LABEL version=1.0
RUN apt-get update -y \
    && apt-get install -y --no-install-recommends apache2=2.4.10-10+deb8u7 \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*
EXPOSE 80
ENTRYPOINT ["/usr/sbin/apache2ctl"]
CMD ["-D", "FOREGROUND"]

为了方便起见,让我们创建一个 docker-compose.yml 文件,这样就可以轻松构建和运行镜像:

version: '2'

services:
  http:
    build: .
    image: demo-httpd
    ports:
      - "80:80"

这样,运行 docker-compose up 时,如果镜像不存在,它也会进行构建。或者,如果只想构建镜像,可以使用以下命令:

$ docker-compose build

创建 BATS 测试

现在我们将测试该镜像应该执行的两项主要操作:

  • 安装 Apache 2.4.10

  • 清理 APT 缓存

首先,在我们仓库的根目录创建一个 test 文件夹,用于存放 BATS 测试:

$ mkdir test

我们的第一个测试是验证安装的 Apache 版本是否为 2.4.10,这是要求的版本。我们怎么手动验证呢?我们可能会执行以下命令并检查输出:

$ apache2ctl -v
Server version: Apache/2.4.10 (Debian)

这在 Docker 中与我们的镜像对应的命令如下(-vapache2ctl ENTRYPOINT 指令的命令(CMD)):

$ docker run --rm demo-httpd:latest -v
Server version: Apache/2.4.10 (Debian)

基本上,现在我们只需运行 grep 来查找正确的版本:

$ docker run --rm demo-httpd:latest -v | grep 2.4.10
Server version: Apache/2.4.10 (Debian)

如果 grep 成功,它会返回 0

$ echo $?
0

一个简单的 BATS 测试命令返回码如下:

@test "test title" {
  run <some command>
  [ $status -eq 0 ]
}

我们现在拥有编写第一个 BATS 测试所需的一切,文件路径为 test/httpd.bats

@test "Apache version is correct" {
  run docker run --rm demo-httpd:latest -v \| grep 2.4.10
  [ $status -eq 0 ]
}

要执行我们的测试,让我们将包含测试的文件夹作为参数传给 BATS:

$ bats test

Ÿ
 Apache version is correct

1 test, 0 failures 

好的!我们现在已经确认正确的 Apache 版本已经安装。

让我们确保在构建镜像后清理 APT 缓存,以免浪费宝贵的空间。删除 APT 列表意味着 /var/lib/apt/lists 文件夹将变为空,因此,如果你在此之后统计该文件夹中的文件数量,应该返回 0

$ ls -1 /var/lib/apt/lists | wc -l

然而,我们不能像处理 Apache 版本那样直接将命令发送到容器中;其入口点是 apache2ctl,需要在 docker run 命令行中通过 sh 进行重写。以下是 apt.bats 测试文件,执行的是 shell 命令而不是 apache2ctl,期望成功执行并返回输出 0

@test "apt lists are empty" {
  run docker run --rm --entrypoint="/bin/sh" demo-httpd:latest -c "ls -1 /var/lib/apt/lists | wc -l"
  [ $status -eq 0 ]
  [ "$output" = "0" ]
}

执行 BATS 测试:

$ bats test
 apt lists are empty
 Apache version is correct

2 tests, 0 failures

使用 Makefile 将所有内容连接起来

现在这个过程在 CI 中可能有点繁琐,需要在测试之前做一些额外的步骤(比如镜像需要先构建并可用,然后才能进行测试)。让我们创建一个 Makefile 来处理这些前置工作:

test: bats

bats: build
  bats test

build:
  docker-compose build

现在,当你执行 make test 命令时,它将启动 bats 测试套件,而该套件依赖于通过 docker-compose 构建镜像——这是一个更简单的命令,可以更好地集成到你选择的 CI 系统中:

$ make test
docker-compose build
Building http
Step 1 : FROM debian:stable-slim
 ---> d2103c196fde
[...]
Successfully built 1c4f46316f19
bats test

. apt lists are empty
. Apache version is correct
 2 tests, 0 failures

另请参见

使用 Docker 和 ServerSpec 进行测试驱动开发(TDD)

Docker 容器可能使用更简化的语言,但最终,通用概念依然是相同的,仍然适用。测试有助于保证质量,而先编写测试可以确保我们编写能够通过测试的代码,而不是在编写完代码后再编写测试,这样可能会错过一些错误。为了帮助我们实现这一点,我们将使用基于 RSpec 的 ServerSpec 来启动 TDD 工作流,同时编写和测试 Docker 容器。以这种方式工作通常能确保非常高的工作质量和非常可持续的容器。

准备就绪

为了按照这个步骤操作,你将需要:

  • 一个正常工作的 Docker 安装

  • 一个可用的 Ruby 环境(包括 Bundler)

如何实现…

我们的目标是按照 TDD 原则创建一个 NGINX 容器。在开始编码之前,让我们先设置好我们的环境。

使用 Bundler 创建一个 ServerSpec 环境

ServerSpec 作为一个 gem(Ruby 包)提供,并且由于我们将使用 Docker APIs,我们还需要 docker-api gem。为了便于部署,让我们创建一个包含依赖的 Gemfile,并将其放在 test 组中:

source 'https://rubygems.org'

group :test do
  gem 'serverspec'
  gem 'docker-api'
end

使用 Bundler 安装这些依赖:

$ bundle install
Using docker-api 1.33.0
Using serverspec 2.37.2
[...]
Bundle complete! 2 Gemfile dependencies, 18 gems now installed.

现在我们将能够在本地环境中使用 Bundler 执行 rspec

$ bundle exec rspec

初始化测试

让我们从创建第一个 Docker Rspec 测试开始,暂时只是初始化所需的库,并在其他任何操作之前构建 Docker 镜像。它在 spec/Dockerfile_spec.rb 中看起来是这样的:

require "serverspec"
require "docker"

describe "Docker NGINX image" do
  before(:all) do
    @image = Docker::Image.build_from_dir('.')

    set :os, family: :debian
    set :backend, :docker
    set :docker_image, @image.id
  end
end

TDD – 使用 Debian Jessie 基础的 Docker 镜像

我们现在希望为我们的项目使用 Debian 稳定版,目前是 Debian 8。要查看当前 Debian 系统的版本,只需查看 /etc/debian_version 文件(在基于 Red-Hat 的系统中,它位于 /etc/redhat_release):

$ cat /etc/debian_version
8.6

很好!让我们在 ServerSpec 中创建一个定义,通过这个命令检查 Debian 版本:

describe "Docker NGINX image" do
[...]
  def debian_version
    command("cat /etc/debian_version").stdout
  end
end

现在,debian_version 内容可以轻松查询,例如,使用以下检查:

  it "installs Debian Jessie" do
    expect(debian_version).to include("8.")
  end

如果该系统正在运行 Debian 8,那么测试将通过。如果 Dockerfile 为空,则测试将失败:

$ bundle exec rspec --color --format documentation
Docker image
 installs Debian Jessie (FAILED - 1)

Failures:

 1) Docker image installs Debian Jessie
 Failure/Error: @image = Docker::Image.build_from_dir('.')
 Docker::Error::ServerError:
 No image was generated. Is your Dockerfile empty?

很好!我们的测试失败了。让我们在 Dockerfile 中写下 FROM 指令,使其通过;这是因为当前的 Debian 稳定版是版本 8:

FROM debian:stable-slim

保存文件并再次启动测试:

$ bundle exec rspec --color --format documentation
Docker NGINX image
 installs Debian Jessie

Finished in 0.72234 seconds (files took 0.29061 seconds to load)
1 example, 0 failures

做得好!我们的测试通过了,意味着这确实是 Debian 8。

TDD – 安装 NGINX 包

我们的下一个目标是安装 nginx 包。让我们在 Dockerfile_spec.rb 中编写 Rspec 测试,检查这个问题。

describe "Docker NGINX image" do
[...]
  describe package('nginx') do
    it { should be_installed }
  end
end

启动测试以确保它失败:

$ bundle exec rspec --color --format documentation
Docker NGINX image
 installs Debian Jessie
 Package "nginx"
 should be installed (FAILED - 1)

现在是时候在 Dockerfile 中添加安装 NGINX 的指令了:

RUN apt-get update -y \
    && apt-get install -y --no-install-recommends nginx=1.6.2-5+deb8u4 \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

重新启动测试(这需要一些时间,因为它需要构建镜像):

$ bundle exec rspec --color --format documentation
Docker NGINX image
 installs Debian Jessie
 Package "nginx"
 should be installed

Finished in 51.89 seconds (files took 0.3032 seconds to load)
2 examples, 0 failures

我们现在可以确定 nginx 包已经安装。

TDD – 运行 NGINX

现在我们已经构建了带有 NGINX 的镜像,接下来执行它。使用 ServerSpec,我们可以通过之前构建的镜像的 id 属性启动容器。在 Dockerfile_spec.rb 文件中,创建并启动容器:

describe "Docker NGINX image" do
[...]
  describe 'Running the NGINX container' do
    before(:all) do
      @container = Docker::Container.create(
        'Image'      => @image.id
        )
      @container.start
    end
  end
end

使用标准的 ServerSpec 检查,验证是否有 NGINX 进程在运行:

    describe process("nginx") do
      it { should be_running }
    end 

我们不能仅停留在这里,还需要清理容器。我们需要在测试完成后停止它并删除它:

    after(:all) do
      @container.kill
      @container.delete(:force => true)
    end

现在我们可以运行测试,它将执行容器并在检查 nginx 进程时失败(因为我们没有写任何启动 nginx 的代码):

$ bundle exec rspec --color --format documentation
Docker NGINX image
 installs Debian Jessie
 Package "nginx"
 should be installed
 Running the NGINX container
 Process "nginx"
 should be running (FAILED - 1)

现在让我们在容器中执行 /usr/bin/nginx,并将其放在前台运行,特别是在 Dockerfile 中:

EXPOSE 80
ENTRYPOINT ["/usr/sbin/nginx"]
CMD ["-g", "daemon off;"]

重新运行测试,检查 nginx 进程是否如预期般运行:

$ bundle exec rspec --color --format documentation
Docker NGINX image
 installs Debian Jessie
 Package "nginx"
 should be installed
 Running the NGINX container
 Process "nginx"
 should be running

Finished in 1.94 seconds (files took 0.30853 seconds to load)
3 examples, 0 failures

为了在 CI 系统中集成这些测试时简化操作,让我们创建一个简单的 Makefile

test: rspec
rspec:
  bundle exec rspec --color --format documentation

现在,简单的 make test 命令将启动 ServerSpec 测试。

做得好!我们已经按照 TDD 原则构建了第一个简单的 Docker 容器。现在我们可以使用这种技术构建更复杂、更安全的容器。

另请参阅

从 Git 创建自动化 Docker 构建的工作流程

构建本地容器是一个不错的选择,但它的广泛分发怎么办呢?我们可以使用 Docker Hub 服务来存储和分发我们的容器(或者其替代品 Quay.io);然而,手动上传每一个容器及其版本很快就会成为问题。假设你需要紧急重建几十个容器,因为另一个 OpenSSL 安全漏洞的存在;没人愿意一个一个地上传它们,特别是在工作时网络连接不佳。而且,当我们使用分支和标签进行 Docker 代码工作时,看到相同的行为自动反映在远程 Docker 仓库中会非常棒。这包括 Docker Hub(或 Quay.io)的两个功能:在代码发生变化时自动构建 Docker 镜像并将它们服务给全世界。在本节中,我们将完全按照此流程操作:从我们的代码到 GitHub,再到 Docker Hub,创建一个自动构建和分发流水线。

准备就绪

要执行此教程,你需要:

  • 一个免费的 GitHub 账户

  • 一个免费的 Docker Hub 账户

  • 一个 Docker 项目

如何操作…

我们的目标是获得一个完全工作的 Docker 构建流水线。为了实现这一目标,我们将使用两个免费的流行服务:GitHub 和 Docker Hub。让我们从上一节中帮助我们构建 NGINX 容器的代码开始;我们也可以使用任何包含至少一个可构建的 Dockerfile 的 GitHub 仓库。代码需要实际托管在 GitHub 上,而不仅仅是在本地使用 Git 版本控制。仓库应该如下所示:

如何操作…

这个仓库已经准备好与其他构建服务进行通信。

在 Docker Hub 上创建自动构建

Docker Hub 是由创建 Docker 的公司提供的商业服务之一。它既是一个公共 Docker 镜像仓库服务(根据你的订阅,容器可以是私有的或公共的),也是一个 Docker 镜像构建服务,当代码发生变化时,它可以自动创建新的镜像。访问hub.docker.com,登录或者如果你没有账户,创建一个新账户。

创建菜单中点击创建自动构建

在 Docker Hub 上创建自动构建

选择托管基础设施代码的提供者;在我们的例子中,是GitHub

在 Docker Hub 上创建自动构建

同步完成后,选择 GitHub 仓库:

在 Docker Hub 上创建自动构建

最后,决定镜像的名称(它不必是 GitHub 仓库的名称)和命名空间。命名空间可以是你的用户名,也可以是你的组织名称(如果有的话)。写一个简短的描述,并选择镜像的可见性:私人内容应保持私密,公共内容可以公开。我们在发布内容时要小心:

在 Docker Hub 上创建自动构建

进入我们 Docker Hub 项目的构建设置以触发初始构建:

在 Docker Hub 创建自动构建

点击触发按钮会创建一个构建。这是通过将我们的仓库的分支类型设为 master 来完成的,并使用 latest 标签标记该构建。如果出于某种原因,我们的项目的 Dockerfile 不在根目录,我们可以在这里指定它。这个构建还允许我们管理不同的 Dockerfile,用于不同的目的,比如分别构建开发和生产容器等选项。

一旦构建完成(应该会在几分钟内完成),进入标签标签页会显示可用的标签(latest是我们现在唯一拥有的)和镜像的大小:

在 Docker Hub 创建自动构建

Dockerfile标签显示了构建镜像所使用的 Dockerfile 内容,而构建详情标签将列出所有构建及其详情,包括构建输出。这对于调试出现问题时非常有用。

将 GitHub 配置为 Docker Hub 自动构建管道

现在让我们修改 Dockerfile,例如,为镜像的名称和版本添加标签:

LABEL name="demo-nginx"
LABEL version=1.0

提交并推送此更改到 GitHub:

$ git add Dockerfile
$ git commit -m "added some missing labels"
[master f20017b] added some missing labels
 1 file changed, 2 insertions(+)
$ git push

Docker Hub 上发生了什么?它会在检测到 GitHub 上的变化后自动开始构建新镜像:

将 GitHub 配置为 Docker Hub 自动构建管道

几秒钟后,我们最新的构建可以供大家使用:

$ docker pull sjourdan/nginx-docker-demo

使用 Git 标签构建 Docker 镜像

由于我们对这个版本满意,我们希望它作为 1.0 标签发布在 Docker Hub 上。为了做到这一点,我们需要完成两个操作:

  • 配置 Docker Hub 根据 Git 标签而非仅仅根据分支来构建和标记

  • 在 Git 上标记并推送我们的发布版本

为了让 Docker Hub 构建与我们在 Git 上设置的相同标签的镜像,让我们在构建设置标签中添加一个新类型,叫做标签。这样,Docker Hub 将会遵循我们在 Git 上设置的标签,它还会构建你将来可能创建的任何其他标签:

使用 Git 标签构建 Docker 镜像

让我们在 Git 上将代码标记为 1.0,以便稍后引用:

$ git tag 1.0
$ git push --tags
Total 0 (delta 0), reused 0 (delta 0)
To https://github.com/sjourdan/nginx-docker-demo.git
 * [new tag]         1.0 -> 1.0 

这刚刚在 Docker Hub 上触发了一个新的构建,使用了我们要求的匹配标签1.0

使用 Git 标签构建 Docker 镜像

现在每个人都可以引用这个稳定版本,并且使用它时无需担心来自主分支的破坏性变更;这个分支将始终使用最新标签进行构建:

$ docker pull sjourdan/nginx-docker-demo:1.0

更好的是,从现在开始,我们未来需要同时拥有此容器和稳定性的 Docker 项目,只需在 Dockerfile 中加入以下一行即可:

FROM sjourdan/nginx-docker-demo:1.0

现在我们有了一个不错的初始工作流,用于构建主分支和已标记的稳定版容器。

连接持续集成(CI)系统的工作流

作为编写代码和测试代码的人,完全没有理由不在 CI 中执行这些测试。就像每个程序都有语言要求一样,我们的程序也需要能够构建 Docker 容器并执行一些 Ruby 代码。能够在任何代码提交时自动执行一整堆测试,是质量提升的一个重要步骤。没有人能测试每一个可能性、回归和几个月或几年以前的特殊情况。这在软件代码中是如此,在基础设施代码中也是一样。让我们找到一种优雅的自动化方法,系统性地在 CI 中执行我们的基础设施代码测试,这将是与更大蓝图连接的又一个点。

准备就绪

为了执行这个教程,你需要:

  • 一个正常工作的 Docker 安装

  • 一个免费的 Travis CI 账户

How to do it…

我们希望每次提交 Git 更改时,能够自动执行 RSpec 集成测试。这正是 CI 系统的完美工作,如 Jenkins、Circle CI 或 Travis CI。我们唯一的要求是 CI 平台能够构建并执行 Docker 容器并运行 RSpec 测试。Travis 对 Docker 的支持很好,开箱即用。而 Jenkins 也能在正确配置后,在防火墙后正常工作,就像大多数其他 CI 系统一样。下面是如何配置 CI 平台,在新提交时自动执行测试:

  1. 创建一个免费的 Travis CI 账户或使用你自己的账户 (travis-ci.org/)。

  2. 点击 + 按钮以添加一个新的 GitHub 仓库:How to do it…

  3. 启用 Travis 对仓库的监控:How to do it…

  4. 现在,在仓库根目录添加一个名为 .travis.yml 的配置文件。这个文件可以包含很多信息来做很多事情,但现在它应该简单地告诉 Travis,我们需要在一个最近的 Linux 发行版中运行 Docker 的 Ruby 环境。同时,它应该执行 Makefile 中的 make test。在我们的案例中,这个命令将执行 RSpec 测试:

    sudo: required
    language: ruby
    dist: trusty
    services:
     - docker
    script: make test
    
    
  5. 提交并推送这个文件,它将触发我们在 Travis 上的第一次测试:

    $ git add .travis.yml
    $ git commit -m "added travis.yml"
    $ git push
    
    
  6. 返回到 Travis CI,我们可以看到测试开始了:How to do it…

  7. 几秒钟后,测试成功通过,确保构建与我们的预期一致。Travis 甚至可以轻松访问命令的输出:How to do it…

我们刚刚为将自动化测试集成到工作流中启动了新步骤。随着每个项目或团队的发展,这变得越来越重要,向生产环境中发布未经测试的容器变得更加危险。

注意

强烈建议你包括任何可以在这个 CI 系统中执行的其他测试,比如本书早些时候提到的 Docker linters 检查。质量只能越来越高:检查越多,效果越好。为更快的反馈循环构建更快速的测试将成为下一个主题。

和每个 CI 系统一样,测试完成后的最后一步是打包、发布和部署容器。尽管这一步令人兴奋,但遗憾的是,已经超出了本书的范围。

使用 Quay.io 和 Docker Cloud 扫描漏洞

使用容器时的一个主要问题是它们的弃用和维护成本。容器通常是在某一天构建的,因其正常工作而被推送到生产环境,并且被遗忘直到下一次重建(这可能不会很快发生)。库依然是库,安全修复每天都会推送到发行版的包仓库。系统管理员习惯了修补系统;然而,现在更新运行中的容器完全是反模式。容器需要重新构建,正如开发者习惯于通过更新库来重新构建应用程序以消除有问题的代码。例外的是,我们足够幸运,有工具监控我们每个 Docker 镜像的每一层,并告诉我们如何以及何时它们会变得脆弱,让我们能够简单地重新构建并重新部署它们。

准备工作

要完成这个教程,你需要:

  • 一个正常工作的 Docker 安装

  • 一个免费的 Quay.io 账户和/或 Docker Hub 的付费账户

如何操作……

使用免费的 Quay.io 账户(由 CoreOS 团队提供),在通过docker login登录后,将镜像推送到他们的 Docker Registry 服务。下面是使用本章早些时候的镜像操作的步骤:

$ docker tag sjourdan/nginx-docker-demo:1.0 quay.io/sjourdan/nginx-docker-demo:1.0
$ docker push quay.io/sjourdan/nginx-docker-demo:1.0
The push refers to a repository [quay.io/sjourdan/nginx-docker-demo]
82819c620e5d: Pushed
d07a4f6d2067: Pushed

注意

Quay.io 有一个非常好的安全功能:由于 Docker 将密码以明文存储在本地工作站上,因此你可以在 Quay.io 账户的设置标签页中生成一个加密密码,这个密码不仅可以用于 Docker,也可以用于 Kubernetes、rkt 或 Mesos。这种加密密码是登录服务的一个更好的选择。

稍等片刻,在我们镜像的Repository Tags标签页中,我们将看到一个安全扫描的总结:

如何操作……

在这个示例中,我们有一些问题需要进一步调查:

如何操作……

显示了许多漏洞,但不要害怕。实际上,在我们的案例中,没有漏洞是可以修复的(点击仅显示可修复查看可以采取的措施)。原因有很多,比如当前没有可用的修复程序,漏洞与我们运行的平台无关,等等。

这是一个非常脆弱的容器的截图,Quay.io 扫描仪提供了关于可用修复的有用建议:

如何操作……

Quay.io 安全扫描仪还会通过电子邮件发送提醒,提供在我们账户下托管的所有容器的漏洞总结。这样我们就不必太担心错过重要的安全问题。

使用 Docker 安全扫描

Docker Hub 上有一个类似的功能,使用的是付费账户,尽管在撰写本文时仍处于预览阶段。默认情况下,Docker 安全扫描未激活,因此我们需要进入账户界面的账单标签页,并勾选该选项以启用它:

使用 Docker 安全扫描

从现在起,每当创建或推送一个新的 Docker 镜像时,系统将迅速扫描并逐个标签报告问题。要访问报告摘要,只需点击 标签 标签:

使用 Docker 安全扫描

要查看详细信息(以及相应的漏洞),点击标签编号:

使用 Docker 安全扫描

这一层有明显的问题!但不要盲目跟从,要仔细检查所说的漏洞。这个例子中的所有关键问题只涉及 Apple 平台,而我们正在运行 Linux 容器。

它是如何工作的…

在幕后,Quay 安全扫描器是基于 Clair 的。Clair 是一个由 CoreOS 开发的开源静态分析漏洞扫描器,我们可以自己运行或基于它构建工具。它目前处理 Debian、Ubuntu、Alpine、Oracle 和 Red Hat 的安全数据源,并提供简单的 API 接口。我们的自定义工具可以发送我们感兴趣的每个 Docker 镜像层,并获取相应的漏洞或修复信息。

参见

将 Docker 日志发送到 AWS CloudWatch 日志

当我们在生产环境中运行数十或数百个容器时,尤其是在集群化的容器平台上,阅读、搜索和处理日志变得越来越困难和繁琐——就像以前容器与服务在数十或数百台物理或虚拟服务器上运行时的情况一样。问题在于,传统解决方案并不直接适用于处理 Docker 日志。幸运的是,AWS 提供了一个简单易用的日志聚合服务——AWS CloudWatch。Docker 为此提供了一个专用的日志驱动程序。我们将立刻把 Tomcat 日志发送到它!

准备就绪

要执行这个操作步骤,你将需要:

  • 一个正常工作的 Docker 安装

  • 一个 AWS 账户

如何操作…

要使用 AWS CloudWatch 日志,我们需要至少一个 日志组。可以使用本书中关于 Terraform 代码的章节来创建一个 CloudWatch 日志组和一个专用的 IAM 用户,或者手动创建二者。

注意

一如既往,使用 AWS 时,强烈建议为我们将使用的每对 AWS 密钥使用专用的 IAM 用户。在我们的案例中,我们可以将名为 CloudWatchLogsFullAccess 的预构建 IAM 策略与一个新的专用用户关联,以便快速并安全地启动。

Docker 守护进程需要在内存中运行 AWS 凭证——这些信息不会传递给容器,因为它由 Docker 守护进程的日志驱动程序处理。为了让 Docker 守护进程访问我们创建的密钥,让我们为 Docker 服务在 /etc/systemd/system/docker.service.d/aws.conf 中创建一个额外的 systemd 配置文件:

[Service]
Environment="AWS_ACCESS_KEY_ID=AKIAJ..."
Environment="AWS_SECRET_ACCESS_KEY=SW+jdHKd.."

别忘了重新加载 systemd 守护进程并重启 Docker 以应用更改:

$ sudo systemctl daemon-reload
$ sudo systemctl restart docker

我们现在可以通过 Docker 守护进程与 AWS API 进行交互。

使用 Docker run

这是一个简单的方式来执行 Tomcat 9 容器,使用 awslogs 驱动程序。利用位于 us-east-1 数据中心的名为 docker_logs 的 CloudWatch 日志组,并自动创建一个名为 www 的新日志流:

$ sudo docker run -d -p 80:8080 --log-driver="awslogs" --log-opt awslogs-region="us-east-1" --log-opt awslogs-group="docker_logs" --log-opt awslogs-stream="www" tomcat:9

在 AWS 控制台中导航,新的日志流将在 Search Log Group 下显示:

使用 Docker run

点击日志流名称将使我们能够访问来自 Tomcat 容器的所有输出日志:

使用 Docker run

我们现在可以访问无限的日志存储和搜索功能,而且我们付出的努力非常有限!

使用 docker-compose

也可以使用 Docker Compose 配置日志驱动程序。以下是如何在 docker-compose.yml 中创建一个名为 tomcat 的日志流,位于同一个日志组下:

version: '2'

services:
  tomcat:
    image: tomcat:9
    logging:
      driver: 'awslogs'
      options:
        awslogs-region: 'us-east-1'
        awslogs-group: 'docker_logs'
        awslogs-stream: 'tomcat'

像往常一样启动 Compose:

$ sudo docker-compose up
Creating network "ubuntu_default" with the default driver
[...]
tomcat_1  | WARNING: no logs are available with the 'awslogs' log driver

tomcat CloudWatch 日志流现在自动创建,日志流入其中。

使用 systemd

启动容器的另一种有用方法是通过使用 systemd。以下是如何使用 systemd 单元名称(在此示例中为 tomcat.service)创建动态命名的日志流。这在使用多个相同容器实例的平台上非常有用,可以让它们分别发送日志。以下是一个正在运行 Docker 并将日志发送到动态分配的日志流名称的工作 Tomcat systemd 服务,在 /etc/systemd/system/tomcat.service 中:

[Unit]
Description=Tomcat Container Service
After=docker.service

[Service]
TimeoutStartSec=0
Restart=always
ExecStartPre=/usr/bin/docker pull tomcat:9
ExecStartPre=-/usr/bin/docker kill %n
ExecStartPre=-/usr/bin/docker rm %n
ExecStart=/usr/bin/docker run --rm -p 80:8080 --log-driver=awslogs --log-opt awslogs-region=us-east-1 --log-opt awslogs-group=docker_logs --log-opt awslogs-stream=%n --name %n tomcat:9
ExecStop=/usr/bin/docker stop %n

[Install]
WantedBy=multi-user.target

重新加载 systemd 并启动 tomcat 单元:

$ sudo systemctl daemon-reload
$ sudo systemctl start tomcat

现在创建了第三个日志流,带有服务名称,并且 systemd 单元的日志正在流入其中。

使用 systemd

享受一种集中且强大的存储和访问日志的方式,直到最终处理它们!

还有更多...

Docker 守护进程不仅可以将日志流传送到 AWS,还可以传送到更常见的 syslog。这提供了许多选项(例如,可以与传统的 rsyslog 设置和兼容传统格式的在线服务一起使用)。类似地,它不仅将日志发送到 journald,还支持 Graylog 或 Logstash GELF 日志格式。还支持 Fluentd 统一日志层,同时在平台方面,支持 Splunk 和 Google Cloud 以及 AWS CloudWatch 日志。

监控并获取 Docker 中的信息

当出现奇怪的问题或系统性能严重下降时,通常需要快速获取一些有用的信息。系统发生了什么问题?是否有某个容器占用了所有内存?也许某个小容器崩溃了并占用了所有的 CPU。所有这些信息都不难获取,但对于构建高质量的容器来说,它们非常宝贵。我们将看到两个非常适合这项工作的工具:第一个工具是 Docker 本身自带的工具,第二个工具是谷歌推出的一个完全不同的工具——cAdvisor,它是一个提供丰富且易于获取信息的 Web 用户界面。

准备工作

要按照这个步骤执行,你需要:

  • 一个正常工作的 Docker 安装

如何操作...

有几种方法可以从 Docker 中获取信息。我们将通过 Docker 主程序探索第一种方法。

使用 docker stats

要获取正在运行的容器的实时度量信息(CPU、内存和网络),我们可以使用简单的 docker stats 命令:

$ docker stats
CONTAINER           CPU %               MEM USAGE / LIMIT     MEM %               NET I/O               BLOCK I/O             PIDS
c2904d5b5c89        0.01%               892.9 MB / 8.326 GB   10.72%              258.2 GB / 10.27 GB   374 MB / 0 B          16
0641790f1b30        3.36%               894.4 MB / 8.326 GB   10.74%              258.2 GB / 11.12 GB   419.1 MB / 0 B        16
bc8d85e05be8        112.65%             891.4 MB / 8.326 GB   10.71%              179.6 GB / 536.5 GB   326.6 MB / 0 B        10
a7be664792b3        0.02%               45.37 MB / 8.326 GB   0.54%               17.85 GB / 17.72 GB   18.78 MB / 110.6 kB   18
ab2d4e922949        2.37%               70.34 MB / 8.326 GB   0.84%               83.15 MB / 550 MB     459.7 MB / 143.4 kB   17
08e685124dfd        0.01%               192 MB / 8.326 GB     2.31%               8.76 MB / 42.11 MB    1.499 MB / 14.05 MB   3
5893c5d6f43f        0.74%               546.1 MB / 8.326 GB   6.56%               46.74 MB / 40.22 MB   160.7 MB / 317.9 MB   74
7f21e405bdee        5.23%               8.184 MB / 8.326 GB   0.10%               30.14 GB / 30.28 GB   8.192 kB / 0 B        7

然而,这种方法并不是非常有帮助,因为它使用的是容器的 ID 而非名称,当运行许多容器时,这种方法可能会变得不可读,因此几乎没有用处。所以我们可以使用一个技巧:请求所有正在运行的容器的统计数据(docker stats)(docker ps)并通过 Go 模板格式化提取容器名称 (--format)

$ docker stats $(docker ps --format '{{.Names}}')
CONTAINER                   CPU %               MEM USAGE / LIMIT     MEM %               NET I/O               BLOCK I/O             PIDS
sm_streammachine-slave_2    18.34%              889.4 MB / 8.326 GB   10.68%              258.2 GB / 10.27 GB   374 MB / 0 B          16
sm_streammachine-slave_1    28.39%              900.1 MB / 8.326 GB   10.81%              258.2 GB / 11.12 GB   419.1 MB / 0 B        16
sm_streammachine-master_1   1.89%               890.4 MB / 8.326 GB   10.69%              179.6 GB / 536.5 GB   326.6 MB / 0 B        10
sm_proxy_1                  0.02%               45.37 MB / 8.326 GB   0.54%               17.85 GB / 17.72 GB   18.78 MB / 110.6 kB   18
sm_cadvisor_1               1.62%               70.34 MB / 8.326 GB   0.84%               83.16 MB / 550 MB     459.7 MB / 143.4 kB   17
sm_analytics_1              0.01%               192 MB / 8.326 GB     2.31%               8.76 MB / 42.11 MB    1.499 MB / 14.05 MB   3
sm_elasticsearch_1          0.72%               546.1 MB / 8.326 GB   6.56%               46.74 MB / 40.22 MB   160.7 MB / 317.9 MB   74
sm_streamer_1               8.17%               8.184 MB / 8.326 GB   0.10%               30.15 GB / 30.29 GB   8.192 kB / 0 B        7

使用谷歌的 cAdvisor 工具

谷歌创建了一个很棒的 Web 工具,用于查看运行容器的机器的状态:cAdvisor。它收集、组织并显示有关资源使用的度量数据,按容器逐个列出,适用于给定的主机。尽管它不是交互式的,但由于安装和使用非常简单,它仍然足够强大。要安装和使用它,只需运行 cAdvisor 的 Docker 镜像,并使其能够访问所需的系统信息,像这样:

$ sudo docker run \
 --volume=/:/rootfs:ro \
 --volume=/var/run:/var/run:rw \
 --volume=/sys:/sys:ro \
 --volume=/var/lib/docker/:/var/lib/docker:ro \
 --publish=8080:8080 \
 --detach=true \
 --name=cadvisor \
 google/cadvisor:latest

或者,如果使用 docker-compose

  cadvisor:
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:rw
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
    ports:
      - "8080:8080"
    image: google/cadvisor:latest
    restart: always

使用 Web 浏览器访问主机的 8080 端口(或你选择的任何端口)将显示一个 Web 界面,在该界面上我们可以浏览并查看主机上容器使用情况的图形信息:

使用谷歌的 cAdvisor 工具

或者,我们可能有一些更通用的指标,用于实时指示资源使用情况:

使用谷歌的 cAdvisor 工具

还可以通过具有容器感知上下文的主机进程表获取类似 top 的有用数据。所有这些数据都可以浏览,它们帮助你更深入地了解特定容器及其内容和使用情况:

使用谷歌的 cAdvisor 工具

cAdvisor 还可以插入许多后端存储系统,如 Prometheus、ElasticSearch、InfluxDB、Redis、statsD 等。

注意

如果你打算让 cAdvisor 永久运行,最好通过简单的 HTTP 身份验证来限制访问。这是 cAdvisor 默认支持的功能,使用 --http_auth_file /cadvisor.htpasswd --http_auth_realm my_message

参见

使用 sysdig 调试容器

Sysdig 是一个非常棒的工具,可以用于许多目的,包括监控、日志记录、进程调试、网络分析以及深入探索系统。它还提供了出色的 Linux 容器支持。它是可编程的,并且可以使用已录制的真实流量数据包捕获进行离线分析。它是一个不可思议的工具,每一个与容器打交道的人都应该至少了解它的基本功能,作为习惯使用代码的基础设施开发人员,我们知道调试工具有多么重要。Sysdig 也不例外,现在我们将探索一些与容器相关的精彩功能。

准备开始

要按此步骤操作,您需要:

  • 一个可用的 Docker 安装

  • Sysdig 已安装并在主机上运行

如何实现...

在大多数平台上安装 sysdig 很容易,包括 CoreOS (www.sysdig.org/install/)。不过,如果您赶时间,下面是一个一行命令,可以完成在 Linux 主机上安装 Sysdig 的工作。当然,我们可能会选择更好的方式通过编程部署它,比如使用 Ansible 或 Chef,是否通过 Docker 容器部署也无所谓:

$ curl -s https://s3.amazonaws.com/download.draios.com/stable/install-sysdig | sudo bash

下面是如何获得系统上所有正在运行的容器的类似 htop 的视图:

$ sudo csysdig --view=containers

如何实现...

进入 F2/Views 菜单可以帮助您进入多个不同的选项,查看正在运行的内容,从进程到 syslog,再到打开的文件,甚至包括 Kubernetes、Marathon 或 Mesos 的集成。想查看哪个容器消耗了所有的 IO?您来对地方了:

如何实现...

这是一个 Tomcat 容器的示例,展示了所有本地和远程连接、IP、端口、协议、带宽、IO 以及相应的命令——对于查找可疑行为非常有用:

如何实现...

另一个有用的工具是 F5/Echo,它可以抓取这个容器上正在传输的内容:(解)密内容、日志、输出等。这对于捕捉容器出现异常的情况也非常有用:

如何实现...

Sysdig 中的另一个非常强大的工具是 F6/Dig。它基本上提供了一个完整的容器 strace;可以想象它强大的调试功能:

如何实现...

F8/Actions 功能是一个完整的 Docker 命令集成工具,可以直接在 sysdig 中使用。选择一个容器后,我们可以进入容器,查看日志,查看其镜像历史,杀死容器等:

如何实现...

这些命令也可以从主界面直接访问:想要获得这个选定容器的 shell 吗?只需输入 b

这些只是我们使用 Docker 容器与 Sysdig 结合后能够完成的众多强大功能中的一小部分。

另请参阅

posted @ 2025-07-08 12:24  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报