绝望者的-DevOps-指南-全-
绝望者的 DevOps 指南(全)
原文:
zh.annas-archive.org/md5/def9dc47ee22dcb4b5b31853b043f92402039d04dcc8bd2ee9515b1e53342eae译者:飞龙
前言

每天,DevOps 工程师都沉浸在基于云的趋势和技术中。与此同时,工程领域的其他人也被期望熟悉 DevOps,并跟上它的发展步伐。原因很简单:DevOps 是软件开发不可或缺的一部分。然而,你可能没有时间既做本职工作,又跟上 DevOps 不断变化的局面——幸运的是,你不需要这样做。只需了解 DevOps 的基础概念、术语和策略,你就能走得更远。
另一方面,当需要交付代码时,你不能只是把头埋在沙子里,希望别人来处理。编写配置文件、强制实施可观测性、设置持续集成/持续交付(CI/CD)管道已经成为软件开发中的常态。因此,你需要精通代码和基础设施。
如果你是软件工程师、开发人员或系统管理员,本书将教你 DevOps、可靠性和现代应用堆栈的概念、命令和技术,为你打下坚实的基础。但请注意,这只是 DevOps 的介绍,而非权威指南。我选择将知识的火 hose 轻轻开着,专注于以下几个基础概念:
-
基础设施即代码
-
配置管理
-
安全
-
容器化与编排
-
交付
-
监控与告警
-
故障排除
还有许多其他优秀的书籍可以深入探讨 DevOps 的概念和文化。我鼓励你去阅读它们,了解更多。但如果你只想从基础开始,《DevOps for the Desperate》这本书就能满足你的需求。
DevOps 的现状如何?
在过去几年里,DevOps 中出现了不同的趋势。重点放在微服务、容器编排(Kubernetes)、自动化代码交付(CI/CD)和可观测性(详细的日志记录、追踪、监控和告警)上。这些话题对 DevOps 社区来说并不陌生,但由于大家都已经“吃下红药丸”,进入了云计算和容器化的世界,这些话题正在获得更多关注。
自动化和测试“代码到客户”的体验仍然是 DevOps 中最重要的部分之一,并且随着后期采用者的追赶,这一趋势将持续下去。随着工程生态系统的成熟,越来越多的 DevOps 工作正在向技术栈的更高层次发展。换句话说,DevOps 工程师越来越依赖工具和流程,以便软件工程师能够自助地交付代码。因此,与功能团队共享 DevOps 实践和技术对交付标准化和可预测的软件至关重要。
这里有几个新兴趋势值得简要提及。第一个是安全性。DevSecOps 正在成为构建过程中的一个必要部分,而不是发布后的附加思考。另一个趋势是利用机器学习进行数据驱动的决策,例如警报。机器学习的洞察力在启发式方面非常有用,并将在未来发挥更大的作用。
谁应该阅读本书?
本书旨在帮助软件工程师在现代应用栈中感到得心应手并茁壮成长。因此,它提供了关于 DevOps 任务的适量入门信息。这并不是说它对已成型的 DevOps 工程师毫无帮助。恰恰相反,它提供了许多关于容器化、监控和故障排除的有用信息。如果您是 DevOps 工程师或小型企业的软件工程师,您甚至可以使用本书帮助您创建整个应用栈,从本地开发到生产。
所以,如果您是一个软件开发人员,想了解有关 DevOps 的知识,本书适合您。如果您有兴趣成为一名通才,本书适合您。如果我付钱给您读这本书——嗯,那么这本书绝对适合您。
本书的组织结构
本书分为三个部分,如下所示:
第一部分:基础设施即代码、配置管理、安全性和管理
第一部分介绍了基础设施即代码(IaC)和配置管理(CM)的概念,这对于构建具有可重复、版本化和可预测状态的系统至关重要。我们还将探索基于主机和基于用户的安全性。
-
第一章:设置虚拟机 本章讨论了 IaC 和 CM 的概念。接着介绍了两种技术,Vagrant 和 Ansible,您将使用它们来创建和配置 Ubuntu 虚拟机。
-
第二章:使用 Ansible 管理密码、用户和组 本章探讨了如何使用 CM 进行用户和组创建,以限制文件和目录的访问权限。还解释了如何使用 CM 强制执行复杂密码。
-
第三章:使用 Ansible 配置 SSH 本章展示了如何设置公钥和双因素身份验证,从而使未经授权的用户更难访问您的主机和敏感数据。
-
第四章:使用 sudo 控制用户命令 本章向您展示了如何创建一个安全策略,委派特定用户和组的命令访问权限。控制用户和组在主机上的命令访问权限可以帮助您避免不必要的暴露给攻击者。至少,它可以防止您拥有配置不当的操作系统。
-
第五章:自动化和测试基于主机的防火墙 本章描述了如何创建和测试一个最小的防火墙,它将阻止所有不需要的访问,同时允许批准的流量。通过限制端口暴露,您可以减少主机和应用程序可能遭遇的外部漏洞。
第二部分:容器化和部署现代应用
第二部分介绍了容器化、编排和交付的概念。它还探讨了一些构成现代栈的组件。
-
第六章:使用 Docker 容器化应用 本章介绍了容器和容器化,并展示了如何创建一个示例容器化应用。理解容器及如何将其用于本地开发和生产环境是你能与任何现代应用栈协作的关键。
-
第七章:使用 Kubernetes 进行编排 本章介绍了容器编排,并探讨了如何使用 Kubernetes 和 minikube 等技术在本地集群上部署应用。它还展示了如何设置本地开发环境的示例。
-
第八章:部署代码 本章讨论了持续集成和持续部署(CI/CD)的概念。它还探讨了一些核心技术,如 Skaffold,帮助你在本地 Kubernetes 集群上创建管道。构建有效的 CI/CD 管道后,你将能够很好地理解如何构建、测试和部署软件。
第三部分:可观测性与故障排除
最后,第三部分介绍了监控、告警和故障排除的概念。它讨论了应用和主机的度量收集与可视化,还讨论了一些常见的主机和应用问题,以及你可以使用的工具来诊断它们。
-
第九章:可观测性 本章介绍了监控和告警堆栈的概念,并探讨了构成该堆栈的技术(Prometheus、Alertmanager 和 Grafana)。你将学习如何检测系统状态,并在出现问题时进行告警。
-
第十章:故障排除主机 最后一章讨论了主机上常见的问题和错误,以及你可以使用的一些工具来排查它们。能够分析主机上的问题将帮助你在危机时刻找到解决方案,并帮助你理解自己代码和应用中的性能问题。
你需要的工具
为了探索本书中的 DevOps 概念,你将安装一些工具以及适用于 x86 硬件的免费虚拟化技术 VirtualBox,这样你就可以在本地主机上运行其他操作系统。不幸的是,某些操作系统和 CPU(如 Windows 和 Apple Silicon)上,某些所需工具无法原生运行。使用 Linux 或基于 Intel 的 Mac 作为主机是最直接的选择。以下是针对每种操作系统的概述:
Linux
- 如果你使用的是 Linux 主机,所有示例和示范应用都可以直接使用。由于你将安装 VirtualBox,你需要运行一个桌面版的 Linux,而不是无头服务器。
基于 Intel 的 Mac
- 如果你使用的是基于 Intel 的 Mac,像在 Linux 上一样,所有的示例和应用程序都无需任何修改即可运行。使用 Brew 包管理器 (
brew.sh) 安装软件。
Windows
-
如果你使用的是 Windows 主机,在本书中安装所有工具和应用程序可能会遇到一些挑战。例如,你将使用 Ansible 来探索配置管理,但在 Windows 上没有简单的方法安装 Ansible。作为解决方法,你可以使用 Ubuntu 虚拟机作为起点。我建议使用 Hyper-V 创建虚拟机,因为它是 Windows 的原生功能。你需要 Windows 10 或 11 专业版才能使用 Hyper-V。有关如何在 Hyper-V 上创建 Ubuntu 虚拟机的说明,请参阅 Ubuntu Wiki (
wiki.ubuntu.com/Hyper-V)。你还需要启用嵌套虚拟化,因为你将在 Hyper-V 的 Ubuntu 虚拟机内安装 VirtualBox。要启用此功能,请在管理员 PowerShell 终端中输入以下命令:
`Set-VMProcessor -VMName` `VMName` `-ExposeVirtualizationExtensions $true`当 Ubuntu 虚拟机停止时,你需要运行此命令,否则它会失败。将
VMName替换为你刚刚创建的 Ubuntu 虚拟机的名称。在你的虚拟机启动并运行后,你需要使用
www.virtualbox.org/wiki/Linux_Downloads上列出的 Ubuntu 版本安装 VirtualBox。完成安装后,你就可以在新创建的虚拟机内运行本书的示例。对于旧版本的 Windows,你可以使用 VirtualBox(是的,VirtualBox 在 VirtualBox 内)或 VMware (
www.vmware.com/products/workstation-player.html) 来创建 Ubuntu 虚拟机。这些选项的具体说明超出了本书的范围。
苹果硅
- 如果你使用的是苹果硅计算机作为主机,VirtualBox 不是一个可行的选项。苹果硅的 CPU 基于 ARM 架构,而 VirtualBox 仅支持 x86。相反,你需要使用像 Parallels (
parallels.com)、VMware Fusion (vmware.com) 或 Qemu (www.qemu.org) 这样的虚拟化技术来创建一个基于 ARM 的虚拟机。前两种选择是付费软件,可能提供更好的用户体验。Qemu 是免费的开源软件,且需要一些额外的配置步骤。访问配套的 GitHub 仓库 (github.com/bradleyd/devops_for_the_desperate/tree/main/apple-silicon/) 获取有关如何在你的苹果硅 Mac 上设置合适实验环境的详细说明。
为了获得最佳体验,主机应至少有 8GB 内存和 20GB 可用磁盘空间;如果你内存或磁盘空间较少,体验可能会有所不同。本书还假设你对 Linux 和命令行有基本的了解。你应该熟悉 Bash 并能够自如地编辑文件。
下载和安装 VirtualBox
从 www.virtualbox.org/wiki/Downloads/ 下载安装程序。选择最新版本并下载适合你操作系统的版本。如前所述,Windows 用户如果使用 Hyper-V,将安装适用于 Ubuntu Linux 的 VirtualBox。对于 Intel 架构的 Mac,请点击 OS 主机链接并下载安装程序。对于 Linux,你猜对了——点击 Linux 发行版链接以找到适合你发行版的下载。VirtualBox 网站提供了针对不同操作系统的详细安装说明,地址为 www.virtualbox.org/manual/。
从你安装 VirtualBox 的位置启动它,以验证是否正常运行。如果一切正常,你应该会看到一个启动屏幕(见图 1)。

图 1:macOS 上的 VirtualBox 启动屏幕(根据你的主机操作系统,它的外观会有所不同)
如果你决定使用操作系统的包管理器来安装 VirtualBox,请确保你安装的是最新版本,因为旧版本可能与本书中的示例有所不同。
配套仓库
由于这是一本面向绝望者的书,我擅自创建了 IaC 文件、Kubernetes 清单、示例应用程序以及其他一些有助于你跟随书中内容的资源。我已经将所有示例和源代码放入 Git 仓库,地址是 github.com/bradleyd/devops_for_the_desperate.git。为了跟随章节和示例,你需要克隆本书的仓库。你的操作系统默认应该已安装 Git,但如果没有,可以访问 git-scm.com/downloads,获取如何为你的操作系统下载和安装 Git 的信息。
在终端中输入以下命令以克隆配套仓库:
$ **git clone https://github.com/bradleyd/devops_for_the_desperate.git**
随意将该仓库克隆到你喜欢的任何位置。如果你需要更多帮助,我在 README 文件中也添加了一些信息。我们将在本书的过程中多次访问这个仓库。
编辑器
在本书中,你需要编辑和查看文件以完成任务。例如,在一些 Ansible 文件中,我已经将部分内容注释掉,你需要取消注释,或者你需要填写一些缺失的信息。
我建议使用你熟悉的任何编辑器。你无需任何特殊插件或依赖来跟随本书的内容。然而,如果你仔细寻找,我相信你一定能找到语法插件来帮助编辑不同类型的文件,比如 Ansible 和 Vagrant 清单文件。我使用 Vim 作为编辑器,但你可以随意替换成你喜欢的编辑器。
现在,所有背景知识都已介绍完毕,你已经准备好开始了!在第一章,我们将深入探讨如何设置本地虚拟机。
第一部分
基础设施即代码、配置管理、安全性与管理
第一章:设置虚拟机

配置(即设置)虚拟机(VM)是为特定目的配置虚拟机的过程。这个目的可以是运行应用程序、在不同平台上测试软件,或应用更新。
设置虚拟机需要两个步骤:创建和配置。在这个示例中,您将使用 Vagrant 和 Ansible 来构建和配置虚拟机。Vagrant 自动化了虚拟机的创建过程,而 Ansible 在虚拟机运行后对其进行配置。您将在本地的 VirtualBox 上设置并测试虚拟机。这个过程类似于在云中创建和配置服务器。您现在设置的虚拟机将是本书第一部分所有示例的基础。
为什么要使用代码来构建基础设施?
使用代码来构建和配置基础设施使您能够始终如一、快速且高效地管理和部署应用程序。这使得您的基础设施和服务可以扩展。它还可以降低运营成本,减少灾难恢复时间,并最小化配置错误的机会。
将基础设施视为代码的另一个好处是易于部署。应用程序在交付流水线中以相同的方式构建和测试。例如,像 Docker 镜像这样的工件会被一致地创建和部署,使用相同版本的库和程序。将基础设施视为代码使您能够构建可重用的组件,使用测试框架,并应用标准的软件工程最佳实践。
然而,有时将基础设施视为代码可能会显得过于复杂。例如,如果您只需要建立一个虚拟机或运行一个简单的 Bash 脚本,可能不值得花费时间和精力来创建所有基础设施和 CM 代码,以完成一个您可以在五分钟内完成的任务。做决策时请根据具体情况作出最佳判断。
使用 Vagrant 入门
Vagrant 是一个框架,使得创建和管理虚拟机变得简单。它支持多种操作系统(OS),可以运行在多个平台上。Vagrant 使用一个名为 Vagrantfile 的配置文件来以代码描述虚拟环境。您将使用这个文件来创建您的本地基础设施。
安装
要安装 Vagrant,请访问 Vagrant 的官方网站 www.vagrantup.com/downloads.html。选择适合您的主机操作系统和架构的版本。完成安装后,下载二进制文件并按照您的操作系统特定的说明进行安装。例如,由于我使用的是 Mac,所以我会选择 macOS 64 位的链接下载最新版本。
当你的虚拟机启动时,你还需要确保它安装了 VirtualBox 的来宾增强功能。(在跟随本书引言的过程中,你应该已经安装了 VirtualBox。)来宾增强功能提供更好的驱动支持、端口转发和仅主机网络功能。它们帮助你的虚拟机运行得更快,并提供更多可用选项。安装 Vagrant 后,在终端输入以下命令来安装 Vagrant 的来宾增强插件:
$ **vagrant plugin install vagrant-vbguest**
Installing the 'vagrant-vbguest' plugin. This can take a few minutes...
Fetching vagrant-vbguest-0.30.0.gem
Installed the plugin 'vagrant-vbguest (0.30.0)'!
上面的输出显示了成功安装 Vagrant 的 vbguest 插件。你的插件版本很可能会有所不同,因为新版本会定期发布。每次升级 Vagrant 和 VirtualBox 时,最好更新这个插件。
Vagrantfile 的结构
Vagrantfile 描述了如何构建和配置虚拟机(VM)。最佳实践是每个项目使用一个 Vagrantfile,这样你可以将配置文件添加到项目的版本控制中并与团队共享。配置文件的语法是 Ruby 编程语言,但你只需要理解一些基本原理即可开始使用。
本书提供的 Vagrantfile 包含文档和合理的选项,可以为你节省时间。这个文件太大,无法在这里包含,因此我只会讨论我从 Vagrant 默认设置中更改的部分。你将从文件的顶部开始,一直到文件的底部,所以可以随时打开文件并跟着做。该文件位于你从本书引言中克隆的仓库中的 vagrant/ 目录下。在本章稍后的部分,你将使用这个文件来创建你的虚拟机。
操作系统
Vagrant 默认支持许多操作系统基础镜像,称为 boxes。你可以在 app.vagrantup.com/boxes/search/ 查找 Vagrant 支持的 boxes 列表。一旦找到你想要的,使用 vm.box 选项将其设置在 Vagrantfile 的顶部,如下所示:
config.**vm.box** = **"ubuntu/focal64"**
在这个例子中,我将 vm.box 标识符设置为 ubuntu/focal64。
网络配置
你可以为不同的网络场景配置虚拟机的网络选项,如 静态 IP 或 动态主机配置协议(DHCP)。为此,请在文件中部修改 vm.network 选项:
config.**vm.network** **"private_network"**,type:**"dhcp"**
对于这个例子,你希望虚拟机通过 DHCP 从私有网络获取 IP 地址。这样,你就可以轻松地从本地主机访问虚拟机上的资源,如 Web 服务器。
提供者
提供者是一个插件,它知道如何创建和管理虚拟机。Vagrant 支持多个提供者来管理不同类型的机器。每个提供者都有类似 CPU、磁盘和内存的常见选项。Vagrant 将使用提供者的应用编程接口(API)或命令行选项来创建虚拟机。你可以在 www.vagrantup.com/docs/providers/ 找到支持的提供者列表。提供者设置在文件的底部,看起来像这样:
config.vm.provider "virtualbox" do |vb|
vb.memory = "1024"
vb.name = "dftd"
`--snip--`
end
基本的 Vagrant 命令
现在你知道了 Vagrantfile 的布局,让我们来看看一些基本的 Vagrant 命令。你最常用的四个命令是vagrant up、vagrant destroy、vagrant status和vagrant ssh:
-
vagrant up使用 Vagrantfile 作为指南创建虚拟机 -
vagrant destroy销毁正在运行的虚拟机 -
vagrant status检查虚拟机的运行状态 -
vagrant ssh通过安全外壳(Secure Shell)访问虚拟机
这些命令都有额外的选项。要查看它们,输入命令后添加--help标志以获取更多信息。要了解更多关于 Vagrant 功能的信息,请访问www.vagrantup.com/docs/上的文档。
一旦通过运行vagrant up创建了虚拟机,你将得到一个核心的 Linux 系统,包含所有操作系统的默认设置。接下来,让我们来看一下如何通过配置管理应用你自己的设置。
入门指南:Ansible
Ansible 是一个配置管理(CM)工具,可以协调虚拟机等基础设施的配置。Ansible 使用声明式配置风格,这意味着它允许你描述基础设施的期望状态。这与命令式配置风格不同,后者要求你提供关于期望状态的所有细节。由于采用声明式风格,Ansible 是一个非常适合不太懂系统管理的开发人员的工具。Ansible 还是开源软件,并且免费使用。
Ansible 是用 Python 编写的,但你不需要理解 Python 就能使用它。你需要理解的唯一依赖项是Yet Another Markup Language (YAML),它是一种数据序列化语言,Ansible 用它来描述复杂的数据结构和任务。通过查看一些基本示例,它很容易上手,稍后我在讲解 Ansible 的 playbook 和任务时会提供一些示例。这里有两个重要的要点需要注意:YAML 使用缩进来组织元素,像 Python 一样,并且它是区分大小写的。你可以在docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html上阅读更多关于 YAML 的内容。
Ansible 通过安全外壳(SSH)应用配置更改,SSH 是一种与远程主机通信的安全协议。SSH 最常见的用途是获得远程主机上的命令行访问权限,但用户也可以使用它转发网络流量并安全地复制文件。通过使用 SSH,Ansible 可以通过网络对单个主机或一组主机进行配置。
安装
现在,你应该安装 Ansible,以便 Vagrant 可以用它进行配置。访问 Ansible 的文档 docs.ansible.com/ansible/latest/installation_guide/intro_installation.html。查找适合你操作系统的文档,并按照步骤安装 Ansible。例如,我使用的是 macOS,安装 Ansible 的推荐方式是使用 pip,这是一个用于安装应用程序和依赖项的 Python 包管理器。我是在安装 Ansible 的 macOS 链接下找到这些信息的,最终将我引导到通过安装 pip 安装 Ansible 的链接。由于 Ansible 是用 Python 编写的,使用 pip 是安装最新版本的有效方法。
Ansible 关键概念
现在你已经安装了 Ansible,你需要了解这些术语和概念,以便快速让它运行:
-
Playbook A playbook 是一系列有序的任务或角色,您可以使用它来配置主机。
-
控制节点 A 控制节点 是任何安装了 Ansible 的 Unix 机器。你将从控制节点运行你的 playbook 或命令,并且可以有任意数量的控制节点。
-
库存 A 库存 是一个文件,包含 Ansible 可以通信的主机或主机组的列表。
-
模块 A 模块 封装了如何在不同操作系统中执行某些操作的细节,比如如何安装软件包。Ansible 自带了许多模块。
-
任务 A 任务 是在管理主机上执行的命令或操作(例如安装软件或添加用户)。
-
角色 A 角色 是一组任务和变量,组织在一个标准化的目录结构中,定义了服务器的特定用途,并且可以与其他用户共享以达成共同目标。一个典型的角色可能会配置主机为数据库服务器。这个角色将包括安装数据库应用程序、配置用户权限和应用种子数据所需的所有文件和说明。
Ansible Playbook
要配置虚拟机,你将使用我提供的 Ansible playbook。这个名为 site.yml 的文件位于你从介绍中克隆的 ansible/ 目录下。把 playbook 当作一本关于如何组装主机的说明书。现在,看看 playbook 文件本身。导航到 ansible/ 目录,并在你的编辑器中打开 site.yml 文件。
你可以将 playbook 文件拆分为不同的部分。第一部分充当头部,这里是设置全局变量的好地方,可以在整个 playbook 中使用。在头部,你将设置 name(play 名称)、hosts、remote_user 和特权升级方法等内容:
---
- name: Provision VM
hosts: all
become: yes
become_method: sudo
remote_user: ubuntu
`--snip--`
这些设置大多是样板代码,但我们先集中讨论一些要点。务必为每个剧本(play)指定一个name,这样如果出现问题,能更容易定位和调试。上述示例中的剧本(play)的name被设置为Provision VM。你可以在单个剧本中包含多个剧本,但对于此示例,只需要一个。接下来,hosts选项设置为all,以匹配任何由 Vagrant 构建的虚拟机,因为 Vagrant 会动态生成 Ansible 清单文件。一些主机上的操作可能需要提升的权限,因此 Ansible 允许你为特定用户提升权限或激活权限提升。由于你使用的是 Ubuntu,默认的具有提升权限的用户是ubuntu。你还可以设置不同的授权方法,在此示例中,你将使用sudo。
下一部分是列出主机的所有任务。在这里,实际的工作将被执行。如果你把剧本当作一本说明书,那么任务就像说明书中的每个独立步骤。tasks部分的格式如下:
`--snip--`
tasks:
**#- import_tasks: chapter2/pam_pwquality.yml**
**#- import_tasks: chapter2/user_and_group.yml**
`--snip--`
内置的 Ansible import_tasks函数从两个独立的文件中加载任务:pam_pwquality.yml 和 user_and_group.yml。import_tasks函数可以更好地组织任务,避免产生一个庞大杂乱的剧本。这些文件每个可以包含一个或多个独立的任务。我将在未来的章节中讨论任务和剧本的其他部分。现在,请注意,这些任务已经用井号(#)符号注释掉,直到取消注释它们之前,它们不会改变任何东西。
基本的 Ansible 命令
Ansible 应用程序带有多个命令,但你通常只会使用这两个命令:ansible 和 ansible-playbook。
你主要使用ansible命令来运行临时或一次性命令,这些命令通常是从命令行执行的。例如,要指示一组 web 服务器重启 Nginx,你可以输入以下命令:
$ **ansible** **webservers-m service -a "name=nginx state=restarted" --become**
这条指令让 Ansible 在一个名为webservers的主机组上重启 Nginx。请注意,webservers组的映射将在清单文件中找到。Ansible 的service模块与操作系统交互以执行重启操作。service模块需要一些额外的参数,这些参数通过-a标志传递。在此情况下,既指定了service的名称(nginx),也指定了它应该重启。你需要root权限才能重启service,因此你将使用--become标志来请求提升权限。
ansible-playbook命令用于运行剧本。事实上,这是 Vagrant 在配置阶段使用的命令。为了让ansible-playbook命令对名为dockerhosts的主机组执行aws-cloudwatch.yml剧本,你需要在终端中输入以下命令:
$ **ansible-playbook -l dockerhosts aws-cloudwatch.yml**
dockerhosts需要在库存文件中列出,才能使命令成功运行。请注意,如果你没有使用-l标志提供主机子集,Ansible 会默认认为你希望在库存文件中的所有主机上运行这个 playbook。
创建一个 Ubuntu 虚拟机
到目前为止,我们一直在讨论概念和配置文件。现在,让我们将这些知识付诸实践,站起来并配置一些基础设施。要创建 Ubuntu 虚拟机,请确保你在与 Vagrantfile 相同的目录中。这是因为 Vagrant 在创建虚拟机时需要引用配置文件。你将使用vagrant up命令来创建虚拟机,但在运行命令之前,你应该知道它会产生大量的输出并且可能需要几分钟时间。因此,我在这里只关注相关部分。在终端中输入以下命令:
$ **vagrant up**
输出的第一部分是 Vagrant 下载基础镜像:
`--snip--`
Bringing machine 'default' up with 'virtualbox' provider...
==> default: **Importing base box 'ubuntu/focal64'**...
`--snip--`
在这里,Vagrant 正在下载ubuntu镜像,正如预期的那样。镜像的下载可能需要几分钟时间,具体取决于你的网络连接。
接下来,Vagrant 将配置一个公钥/私钥对,以提供 SSH 访问虚拟机的权限。(我们将在第三章详细讨论密钥对。)
`--snip--`
default: Vagrant insecure key detected. Vagrant will automatically replace
default: this with a newly generated keypair for better security.
default:
default: Inserting generated public key within guest...
default: Removing insecure key from the guest if it's present...
default: Key inserted! Disconnecting and reconnecting using new SSH key...
`--snip--`
Vagrant 会将私钥保存在本地主机(.vagrant/)中,然后将公钥添加到虚拟机的~/.ssh/authorized_keys文件中。如果没有这些密钥,你将无法通过 SSH 连接到虚拟机。
默认情况下,Vagrant 和 VirtualBox 会在虚拟机内挂载一个共享目录。这个共享目录将允许你从虚拟机内访问主机的目录:
`--snip--`
==> default: Mounting shared folders...
default: /vagrant => /Users/bradleyd/devops_for_the_desperate/vagrant
`--snip--`
你可以看到,我的本地主机目录Users/bradleyd/devops_for_the_desperate/已挂载在虚拟机内部的vagrant/目录下。你的目录会有所不同。你可以使用这个共享目录在主机和虚拟机之间传输文件,比如源代码。如果你不需要共享目录,Vagrant 提供了一个配置选项来关闭它。详情请参阅 Vagrant 的文档。
最后,以下是 Ansible provisioner的输出:
`--snip--`
==> default: Running provisioner: ansible...
default: Running 1ansible-playbook...
PLAY [Provision VM] *******************************
2 TASK [Gathering Facts] ****************************
3 ok: [default]
PLAY RECAP *******************************
`--snip--`
default : ok=1 4changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
这表明 Ansible provisioner正在使用ansible-playbook 1 命令运行。Ansible 会记录每个TASK 2 及其是否在主机 3 上有所更改。在这种情况下,所有tasks都被注释掉了,因此虚拟机上没有任何changed 4。这是判断成功与否时需要查看的第一个输出。
让我们进行一个简单的检查,看看虚拟机是否实际在运行。在终端中输入以下命令,查看虚拟机的当前状态:
$ **vagrant status**
Current machine states:
default running (virtualbox)
`--snip--`
在这里,你可以看到虚拟机的状态是running。这意味着你已经创建了虚拟机,并且它应该可以通过 SSH 访问。
如果你的输出与预期不同,请确保在继续之前,vagrant up 命令没有错误。如果需要更多信息,请向 up 命令添加 debug 标志,以增加 Vagrant 输出的详细程度:vagrant up --debug。此时你需要确保已成功完成配置,否则在接下来的章节中会很难跟上。
总结
在本章中,你安装了 Vagrant 和 Ansible 来创建和配置虚拟机(VM)。你学习了如何通过 Vagrantfile 配置 Vagrant,并掌握了使用 Ansible playbooks 和任务来配置虚拟机的基本知识。现在,你已经理解了这些基本概念,应该能够创建并配置任何类型的基础设施,而不仅仅是虚拟机。
在下一章中,你将使用提供的两个 Ansible 任务来创建一个用户和组。在配置主机时,你需要具备用户和组管理的基础知识。
第二章:使用 Ansible 管理密码、用户和组

现在你已经构建好了虚拟机(VM),让我们开始进行一些管理任务,比如用户管理。DevOps 实践中的自动化是构建和管理资源的关键。要管理任何 Linux 主机,你需要对密码、用户和组的工作原理有基本的理解。用户和密码是身份管理的基础,而组则使你能够管理一组用户并控制对文件、目录和命令的访问。通过在用户和组之间划分责任,可能会决定是否允许未经授权的访问。
在本章中,你将继续学习如何使用 Ansible,并且还将配置你刚刚创建的虚拟机,以改善你的基本安全策略。你将使用本书提供的 Ansible 任务来强制执行复杂密码、管理用户和组,以及控制对共享目录和文件的访问。一旦你掌握了这些安全基础,你就能将它们作为每个 playbook 的基础。
强制执行复杂密码
让用户决定什么是强密码可能会导致灾难,因此你需要在每个用户可以访问的主机上强制执行复杂密码。由于自动化是我们的指导原则之一,你将使用代码来强制执行所有用户的强密码。为此,你可以使用一个 Ansible 任务来安装一个 可插拔认证模块 (PAM) 插件,这是大多数 Linux 发行版使用的用户认证框架。提供复杂密码的插件叫做 pam_pwquality。该模块根据你设置的标准验证密码。
安装 libpam-pwquality
pwquality PAM 模块可以在 Ubuntu 软件仓库中找到,名称为 libpam-pwquality。你将使用本书提供的 Ansible 任务来安装和配置此软件包。记住,目标是尽可能地自动化所有内容,任务提供了执行管理工作的机制。这些任务位于你从简介中克隆的仓库中。导航到 ansible/chapter2/ 目录,并在你喜欢的编辑器中打开 pam_pwquality.yml 文件。该文件包含两个任务:安装 libpam-pwquality 和 配置 pam_pwquality。
让我们专注于第一个任务,使用 Ansible 的 package 模块在虚拟机上安装 libpam-pwquality。文件顶部的安装任务应该像这样:
---
- name: **Install libpam-pwquality**
package:
name: "libpam-pwquality"
state: present
`--snip--`
每个 Ansible 任务应以 name 声明开始,用于定义其目标。在本例中,name 为 Install libpam-pwquality。接下来,Ansible 的 package 模块执行软件安装。package 模块要求你设置两个参数:name 和 state。在本例中,软件包名称(可在 Ubuntu 仓库中找到)应为 libpam-pwquality,而 state 应设置为 present。要删除软件包,将 state 设置为 absent。这是声明性指令的一个很好的示例,因为你告诉 Ansible 确保安装了这个软件包。你无需关心它是如何被安装的,只要确保它被安装即可。如果你安装了该软件包(present),然后从 Ansible 中删除该任务,下次提供时,软件包仍将被安装。如果你希望主机保持所需状态,必须显式将软件包设置为 absent。
如第一章所述,Ansible 模块(如上所示)在操作系统上执行常见操作,例如启用防火墙、管理用户或(在本例中)安装软件。Ansible 使得你的操作能够是幂等的,这意味着你可以反复执行某个特定操作,并且结果将与上次执行时相同。正因如此,你应该尽可能自动化所有工作!这样你不仅能节省时间,还能避免因手动操作疲劳导致的错误。试想一下,如果你每天需要配置 1,000 台机器,没了自动化,几乎是不可能完成的!
配置 pam_pwquality 以强制执行更严格的密码策略
在默认的 Ubuntu 系统中,密码复杂度并不像它应该的那样强大。它要求密码的最小长度为六个字符,并且仅执行一些基本的复杂度检查。要加强复杂度,你需要配置 pam_pwquality 来设置更严格的密码策略。
一个名为 /etc/pam.d/common-password 的文件负责配置 pam_pwquality 模块。这个文件是 Ansible 任务用来对密码进行验证并做出必要更改的地方。你所需要做的就是修改该文件中的一行。使用 Ansible 编辑文件中的某一行的常见方法是使用 lineinfile 模块,该模块可以更改文件中的一行或检查某一行是否存在。
在仍然打开的 pam_pwquality 任务文件中,让我们回顾一下从顶部开始的第二个任务。它应如下所示:
`--snip--`
- name: **Configure pam_pwquality**
lineinfile:
path: "/etc/pam.d/common-password"
regexp: "pam_pwquality.so"
line: "password required pam_pwquality.so minlen=12 lcredit=-1 ucredit=-1
dcredit=-1 ocredit=-1 retry=3 enforce_for_root”
state: present
`--snip--`
再次,任务以名称 Configure pam_pwquality 开始,描述其目的。然后,它告诉 Ansible 使用 lineinfile 模块来编辑 PAM 密码文件。lineinfile 模块要求提供文件的 path,以便对其进行更改。在此案例中,它是 PAM 密码文件 /etc/pam.d/common-password。使用正则表达式(regexp)来找到要更改的文件行。正则表达式定位到包含 pam_pwquality.so 的行,并用新的一行替换它。替换的 line 参数包含 pwquality 配置更改,这些更改强制执行更复杂的密码要求。上面提供的选项强制执行以下要求:
-
密码最小长度为 12 个字符
-
一个小写字母
-
一个大写字母
-
一个数字字符
-
一个非字母数字字符
-
三次重试
-
禁用 root 覆盖
添加这些要求将加强 Ubuntu 默认的密码策略。任何新密码都需要满足或超过这些要求,从而使攻击者暴力破解用户密码变得更加困难。
关闭 pam_pwquality.yml 文件,这样你就可以继续使用 Ansible 模块来创建用户。
Linux 用户类型
说到 Linux,用户可以分为三种类型:普通用户、系统用户和 root 用户。你可以将普通用户看作是人类账户,接下来你将创建一个这样的账户。每个普通用户通常都会关联一个密码、一个组和一个用户名。将系统用户看作是非人类账户,比如 Nginx 运行的用户。事实上,系统用户与普通用户几乎相同,但它位于不同的用户 ID(UID)范围内,出于隔离的考虑。root 用户(或超级用户)账户对操作系统有无限制的访问权限。你可以通过 UID 来辨别 root 用户,它的 UID 始终是零。与所有的配置一样,当涉及到创建和配置用户时,你将使用 Ansible 模块来进行重任操作。
Ansible 用户模块入门
Ansible 配有 user 模块,使得管理用户变得非常简单。它处理账户的所有繁琐细节,比如 shell、密钥、组和主目录。你将使用 user 模块创建一个名为 bender 的新用户。如果你愿意,可以取一个别的名字,但由于本书中的示例将继续使用 bender 这个用户名,记得在以后的章节中也将名字更改为 bender。
打开位于 ansible/chapter2/ 目录下的 user_and_group.yml 文件。该文件包含以下五个任务:
-
确保 developers 组存在。
-
创建用户 bender。
-
将 bender 分配给 developers 组。
-
创建一个名为 engineering 的目录。
-
在工程目录中创建一个文件。
这些任务将创建一个组和一个用户,将用户分配到组中,并创建一个共享目录和文件。
尽管这有些违反直觉,我们先从列表中的第二个任务开始,即创建用户bender。(我们将在下一页的“Linux 群组”部分讨论第一个任务。)它应该如下所示:
`--snip--`
- name: **Create the user 'bender'**
user:
name: bender
shell: /bin/bash
password: $6$...(truncated)
`--snip--`
这个任务,像其他任务一样,以描述其功能的name开头。在这个例子中,name是Create the user 'bender'(创建用户'bender')。你将使用 Ansible 的user模块来创建用户。user模块有许多选项,但只有name参数是必需的。在本例中,name被设置为bender。在配置时设置用户密码是有用的,因此可以将可选的password参数设置为已知的密码哈希值(稍后会详细介绍)。以$6开头的password值是 Linux 支持的加密哈希。我已经提供了bender的密码哈希示例,展示如何自动化此步骤。在下一部分,我将详细介绍我生成密码哈希的过程。
生成复杂密码
你可以使用多种不同的方法生成密码,以匹配你在pam_pwquality中设置的复杂度要求。如前所述,我提供了一个密码哈希值,符合这一阈值,以节省时间。我使用了两个命令行应用程序,pwgen和mkpasswd,来创建复杂密码。pwgen命令可以生成安全密码,而mkpasswd命令可以使用不同的哈希算法生成密码。pwgen应用程序由pwgen包提供,mkpasswd应用程序由名为whois的包提供。这些工具结合在一起,可以生成 Ansible 和 Linux 所期望的哈希值。
Linux 将密码哈希值存储在名为shadow的文件中。在 Ubuntu 系统中,默认的密码哈希算法是 SHA-512。要为 Ansible 的用户模块创建自己的 SHA-512 哈希,请在 Ubuntu 主机上使用以下命令:
$ **sudo apt update**
$ **sudo apt install pwgen whois**
$ **pass=`pwgen --secure --capitalize --numerals --symbols 12 1`**
$**echo $pass | mkpasswd --stdin --method=sha-512; echo $pass**
由于这些软件包默认没有安装,你需要先使用 APT 包管理器安装它们。pwgen命令生成符合pwquality要求的复杂密码,并将其保存在一个名为pass的变量中。接下来,将pass变量的内容通过管道传输到mkpasswd中,使用sha-512算法进行哈希处理。最终输出应包含两行。第一行是 SHA-512 哈希值,第二行是新密码。你可以将哈希字符串拿来,并在用户创建任务中设置password值以更改密码。尽管如此,尽情尝试吧!
Linux 群组
Linux 群组允许你在主机上管理多个用户。创建群组也是限制访问主机资源的一种高效方式。对群组进行管理比对成百上千的用户逐个管理要容易得多。在下一个示例中,我提供了一个 Ansible 任务,创建一个名为developers的群组,你将使用它来限制对某个目录和文件的访问。
开始使用 Ansible 群组模块
与user模块类似,Ansible 还有一个group模块,可以管理创建和删除组。与其他 Ansible 模块相比,group模块非常简洁;它只能创建或删除组。
在您的编辑器中打开user_and_group.yml文件,以查看组创建任务。文件中的第一个任务应该是这样的:
- name: **Ensure group 'developers' exists**
group:
name: developers
state: present
`--snip--`
任务的name字段表明您希望确保组存在。使用group模块创建组。此模块要求您设置name参数,在此处设置为developers。state参数设置为present,因此如果组不存在,则会创建该组。
文件中的第一个任务是创建组,这并非偶然。在执行任何其他任务之前,您需要创建开发者组。任务按顺序运行,因此您需要确保组首先存在。如果在创建组之前尝试引用该组,则会收到错误消息,指出开发者组不存在,且配置将失败。理解 Ansible 任务操作顺序对执行更复杂的操作至关重要。
继续查看其他任务时,请保持user_and_group.yml文件打开状态。
分配用户到组
要使用 Ansible 将用户添加到组中,您将再次利用user模块。在user_and_group.yml文件中,找到将bender分配给开发者组的任务(文件中从顶部算起第三个任务)。它应该看起来像这样:
`--snip--`
- name: **Assign 'bender' to the 'developers' group**
user:
name: bender
**groups: developers**
append: yes
`--snip--`
任务的name字段描述了其意图。user模块将bender追加到开发者组。groups选项可以接受逗号分隔的多个组。通过使用append选项,您保留了bender之前的所有组,并仅添加了开发者组。如果省略append选项,则bender将从除其主要组和groups参数中列出的组之外的所有组中移除。
创建受保护的资源
确定了bender的组关联后,让我们来看看user_and_group.yml文件中的最后两个任务,这些任务涉及在虚拟机上创建一个目录(/opt/engineering/)和一个文件(/opt/engineering/private.txt)。稍后您将使用该目录和文件来测试bender的用户访问权限。
仍然在user_and_group.yml文件中,找到这两个任务。首先是目录创建任务(文件中从顶部算起第四个任务),它应该看起来像这样:
- name: **Create a directory named 'engineering'**
file:
path: /opt/engineering
state: directory
mode: 0750
group: developers
首先,像之前一样,将 name 设置为匹配任务的意图。使用 file 模块来管理目录及其属性。path 参数指定了你希望创建目录的位置。在这个例子中,它被设置为 /opt/engineering/。因为你希望创建一个目录,所以将 state 参数设置为你想创建的资源类型,这里是 directory。你还可以使用其他类型,稍后你创建文件时会看到另一个。mode(权限)设置为 0750。这个数字允许所有者(root)对该目录进行读取、写入和执行操作,而组成员仅允许读取和执行。执行权限是进入目录并列出其内容所必需的。Linux 使用八进制数字(此例中为 0750)来定义文件和组的权限。有关权限模式的更多信息,请参见 chmod 的手册页。最后,将目录的 group 所有权设置为 developers 组。这意味着只有 developers 组中的用户才能读取或列出该目录的内容。
user_and_group.yml 文件中的最后一个任务会在你刚创建的 /opt/engineering/ 目录内创建一个空文件。位于文件底部的任务应该像这样:
- name: **Create a file in the engineering directory**
file:
path: "/opt/engineering/private.txt"
state: touch
mode: 0770
group: developers
将任务的 name 设置为你想在主机上执行的操作。再次使用 file 模块来创建一个文件并设置一些属性。path 是必填项,指定了文件在虚拟机中的位置。这个例子演示了在 /opt/engineering/ 目录中创建一个名为 private.txt 的文件。state 参数设置为 touch,意味着如果文件不存在,就创建一个空文件。如果你需要创建一个非空文件,可以使用 copy 或 template 这两个 Ansible 模块。更多细节请参见文档。mode(权限)设置为允许组中的任何用户读取、写入和执行(0770)。最后,将文件的 group 所有权设置为 developers 组。
理解这一点非常重要:你可以使用多种方法来保护 Linux 主机上的资源。组限制只是生产环境中更大授权体系的一小部分。我将在后续章节讨论不同的访问控制。但现在,你只需要知道,借助 Ansible 的任务和模块,你可以在整个环境中执行许多常见的系统配置任务,比如保护文件和目录。
更新虚拟机
到目前为止,我们一直在描述 Ansible 模块,并回顾将为虚拟机提供配置的任务。下一步实际上是使用它们。要配置虚拟机,你需要取消注释位于 ansible/ 目录下 playbook 中的任务。site.yml 文件是你在 Vagrantfile 的配置器部分引用的 playbook 文件。
打开编辑器中的 site.yml playbook 文件,找到第二章的任务,它们看起来像这样:
`--snip--`
tasks:
**#-** **import_tasks****: chapter2/****pam_pwquality.yml**
**#-** **import_tasks****: chapter2/****user_and_group.yml**
`--snip--`
它们被注释掉了。移除两行开头的哈希符号(#)以取消注释,这样 Ansible 才能执行这些任务。
现在,剧本应该如下所示:
---
- name: Provision VM
hosts: all
become: yes
become_method: sudo
remote_user: ubuntu
tasks:
- import_tasks: chapter2/pam_pwquality.yml
- import_tasks: chapter2/user_and_group.yml
`--snip--`
第二章中的两个任务,pam_pwquality和user_and_group,现在都已取消注释,因此它们将在下次配置虚拟机时执行。暂时保存并关闭剧本文件。
你在第一章创建了虚拟机。然而,如果虚拟机未运行,请输入vagrant up命令重新启动它。虚拟机运行后,只需在vagrant/目录中运行vagrant provision命令来执行配置程序:
$ **vagrant provision**
`--snip--`
PLAY RECAP *********************************************************************
Default : ok=8 changed=7 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
最后一行显示 Ansible 剧本已经运行并完成了8个操作。可以把操作看作是执行的任务和其他操作。八个操作中有七个改变了虚拟机的某些状态。这一行显示配置已完成且没有错误或失败的操作。
如果你的配置出现故障,停止并尝试排查问题。再次运行provision命令并加上--debug标志,如第一章所示,以获取更多信息。你需要成功的配置才能继续进行本书中的示例。
测试用户和组权限
为了测试你刚刚配置的用户和组权限,你将发出ssh命令让vagrant访问虚拟机。确保你在vagrant/目录中,这样你才能访问 Vagrantfile。进入该目录后,在终端中输入以下命令以登录到虚拟机:
$ **vagrant ssh**
vagrant@dftd:~$
你应该以vagrant用户身份登录,这是 Vagrant 默认创建的用户。
接下来,为了验证用户bender是否已创建,你将使用getent命令查询passwd数据库中的用户信息。此命令允许你查询像/etc/passwd、/etc/shadow和/etc/group这样的文件中的条目。要检查bender是否存在,请输入以下命令:
$ **getent passwd bender**
bender:x:1002:1003::/home/bender:/bin/bash
你的结果应类似于上面的输出。如果用户没有被创建,命令将没有任何结果。
现在,你应该检查developers组是否存在,以及bender是否是该组成员。查询group数据库以获取此信息:
$ **getent group developers**
developers:x:1002:bender
结果应类似于上面的输出,显示一个developers组,并将用户bender分配给它。如果该组不存在,命令将没有任何输出结果。
最后的检查是测试只有developers组的成员才能访问/opt/engineering/目录和private.txt文件。为此,尝试先以vagrant用户身份访问该目录和文件,然后再以bender用户身份访问。
当以vagrant用户身份登录时,输入以下命令列出/opt/engineering/目录及其内容:
$ **ls -al /opt/engineering/**
ls: cannot open directory '/opt/engineering/': Permission denied
输出显示,当尝试以vagrant用户身份列出/opt/engineering中的文件时,访问被denied。这是因为vagrant用户不是developers组的成员,因此无法读取该目录。
现在,要测试 vagrant 的文件权限,使用 cat 命令查看 /opt/engineering/private.txt 文件:
$ **cat /opt/engineering/private.txt**
cat: /opt/engineering/private.txt: Permission denied
由于 vagrant 用户没有读取文件的权限,因此发生了相同的错误。
下一步是验证 bender 是否能够访问相同的目录和文件。为此,必须以 bender 用户登录。从 vagrant 切换到 bender 用户,使用 sudo su 命令。(我将在第四章中讲解 sudo 命令。)
在终端中,输入以下命令来切换用户:
vagrant@dftd:~$ **sudo su - bender**
bender@dftd:~$
一旦成功切换用户,再次尝试命令列出目录:
$ **ls -al /opt/engineering/**
total 8
drwx`r`-`x`--- 2 root `developers` 4096 Jul 3 03:59 .
drwxr-xr-x 3 root root 4096 Jul 3 03:59 ..
-rwx`rwx`--- 1 root `developers` 0 Jul 3 04:02 private.txt
如您所见,您已经成功以 bender 访问了该目录及其内容,并且 private.txt 文件是可查看的。
接下来,输入以下命令检查 bender 是否可以读取 /opt/engineering/private.txt 文件的内容:
$**cat****/opt/engineering/private.txt**
您再次使用 cat 命令查看文件内容。由于文件为空,未有输出。更重要的是,bender 在尝试访问文件时没有出现错误。
总结
在本章中,您使用以下 Ansible 模块配置了虚拟机:package、lineinfile、user、group 和 file。这些模块配置了主机以强制执行复杂密码、管理用户和组,以及确保文件和目录的访问安全。这些是 DevOps 工程师在典型环境中常做的任务。您不仅扩展了 Ansible 知识,还学会了如何在虚拟机上自动化基本的安全卫生工作。在下一章中,您将继续完成任务,并提高 SSH 安全性,限制对虚拟机的访问。
第三章:使用 Ansible 配置 SSH

SSH 是一种协议和工具,提供从你自己的机器到远程主机的命令行访问。如果你正在管理一台或多台远程主机,最常见的访问方式是通过 SSH。大多数服务器可能是无头的,因此最简单的访问方式就是通过终端。由于 SSH 开放了主机访问权限,错误配置或默认安装可能导致未经授权的访问。与很多 Linux 服务一样,默认的安全设置对于大多数情况是足够的,但你还是应该知道如何提高安全性并进行自动化。作为一名工程师,你应该理解如何在主机或多个主机上锁定 SSH 的步骤。
在本章中,你将学习如何使用 Ansible 来确保你虚拟机的 SSH 访问安全。你将通过禁用 SSH 密码访问,要求通过 SSH 使用公钥认证,并为你的用户 bender 启用两因素认证 (2FA)。你将使用一些熟悉的 Ansible 模块的组合,并将接触到一些新的模块。到本章结束时,你将更好地理解如何强制执行严格的 SSH 访问控制,以及实现这一目标所需的自动化步骤。
理解并激活公钥认证
大多数 Linux 发行版默认使用密码进行 SSH 身份验证。虽然这种方式在许多设置中是可以接受的,但你应该通过增加另一种选项来加强安全性:公钥认证。这种方法使用一对密钥,包括公钥文件和私钥文件来确认你的身份。公钥认证被认为是 SSH 身份验证的最佳实践,因为潜在的攻击者若想劫持用户身份,需要同时拥有用户的私钥副本和解锁该私钥的密码短语。
当你用密钥创建 SSH 会话时,远程主机会用你的公钥加密一个 挑战 并将其发送回你。因为你持有私钥,所以你可以解码消息,并将回应发送回远程服务器。如果服务器能够验证回应,它会知道你持有私钥,从而确认你的身份。要了解更多关于密钥交换和 SSH 的内容,请访问 www.ssh.com/academy/ssh/。
生成公钥对
要生成密钥对,你需要使用ssh-keygen命令行工具。这个工具通常作为ssh包的一部分默认安装在 Unix 主机上,用于生成和管理 SSH 的身份验证密钥对。你很可能已经在本地主机上拥有一个公钥对,但为了本书的目的,我们将创建一个新的密钥对,这样就不会干扰到现有的密钥对。你还会为私钥设置一个密码短语。密码短语类似于密码,但通常更长(像是由不相关的词组成,而不是一串复杂的字符)。你添加它是为了防止万一私钥落入错误的人手中,坏人需要知道你的密码短语才能解锁它并冒充你的身份。
在本地主机的终端中,输入以下命令生成一个新的密钥对:
$ **ssh-keygen -t rsa -f ~/.ssh/dftd -C dftd**
Generating public/private rsa key pair.
Enter passphrase (empty for no passphrase): `<passphrase>`
Enter same passphrase again: `<passphrase>`
Your identification has been saved in /Users/bradleyd/.ssh/dftd.
Your public key has been saved in /Users/bradleyd/.ssh/dftd.pub.
你首先指示ssh-keygen创建一个名为dftd(DevOps for the Desperate)的rsa密钥对。如果你没有指定名称,它会默认使用id_rsa,这可能会覆盖你现有的本地密钥。-C标志会在密钥末尾添加一个可读的注释,以帮助识别该密钥的用途。这里,它的注释也设置为dftd。在执行过程中,命令会提示你通过添加密码短语来保护你的密钥。输入一个强密码短语来保护密钥。同时,记得始终保持密码短语的安全,因为如果你丢失了它,密钥将永远被锁定,你将无法再使用它进行身份验证。
在确认密码短语后,私钥和公钥文件将被创建在你本地的~./ssh/目录下。
使用 Ansible 将公钥传送到虚拟机
每个用户在虚拟机上的主文件夹中都有一个名为authorized_keys的文件。该文件包含一份公钥列表,SSH 服务器可以用来验证该用户的身份。你将使用这个文件在通过 SSH 访问虚拟机时验证bender的身份。为此,你需要将你在上一节中创建的本地公钥(在我的案例中是/Users/bradleyd/.ssh/dftd.pub)复制到虚拟机上的/home/bender/.ssh/authorized_keys文件中。
要复制文件的内容,你将使用提供的 Ansible 任务。这个任务以及本章所有相关任务位于克隆的仓库中的ansible/chapter3/目录下。
打开你喜欢的编辑器中的authorized_keys.yml文件,查看 Ansible 任务。你首先会注意到,这个文件只有一个任务。它应该看起来像这样:
- name: Set authorized key file from local user
authorized_key:
user: bender
state: present
key: "{{ lookup('file', lookup('env','HOME') + '/.ssh/dftd.pub') }}"
首先,设置任务的name来标识其意图。使用 Ansible 的authorized_key模块将你的公钥从本地主机复制到虚拟机上的bender用户。authorized_key模块非常简单,要求你只设置user和key两个参数。在这个例子中,它将你之前创建的本地公钥复制到bender的/home/bender/.ssh/authorized_keys文件中。将state设置为present,因为你想添加密钥而不是删除它。
要获取本地公钥的内容,你需要使用 Ansible 的评估扩展操作符({{ }})和一个内置的 Ansible 函数 lookup。 lookup 函数根据作为其第一个参数指定的插件,从外部资源中检索信息。在这个示例中,lookup 使用 file 插件读取 ~/.ssh/dftd.pub 公钥文件的内容。这个公钥文件的完整路径是通过 lookup `env` 插件和用 *+* 符号表示的字符串连接构建的。如果你使用的是 Mac,最终结果应该类似于:*/Users/bradleyd/.ssh/dftd.pub*。如果你使用的是 Linux,结果应该类似于:*/home/bradleyd/.ssh/dftd.pub*。文件路径将根据你的操作系统和用户名有所不同。
## Adding Two-Factor Authentication Security is built in layers. The more layers you have, the harder it is for an intruder to gain access. The next layer of security to add is *two-factor authentication (2FA**)*, which validates a user’s identity by using credentials and something that the user has, like a phone or device. The main goal of 2FA is to make it harder for someone to spoof your identity if your password or key is compromised. Two-factor authentication relies on your providing two out of these three things: *something you know*, *something you have*, and *something you are*. Here are some examples of each: 1. Something you know: password or pin 2. Something you have: phone or hardware authentication device, such as a YubiKey 3. Something you are: fingerprint or voice For this example, you’ll use a *time-based one-time password (TOTP)* to satisfy the “something you have” portion, along with your public key for access. You’ll use the `Google Authenticator` package to configure your VM to use TOTP tokens for logging in. These TOTP tokens are usually generated from an application like `oathtool` ([`www.nongnu.org/oath-toolkit/`](https://www.nongnu.org/oath-toolkit/)) and are valid for only a short period of time. I have taken the liberty of creating 10 TOTP tokens that Ansible will use for you, but I will also show you how to use `oathtool` (more on this later). To enforce 2FA on your VM, you’ll use some provided Ansible tasks to install another PAM module, configure the SSH server, and enable 2FA. To review the provided tasks, first open the *two_factor.yml* file in your editor. (All the Ansible files for this chapter are located in the *ansible/chapter3/* directory.) This file has seven tasks, and each task has a specific job to enable 2FA. The tasks are named as follows: 1. Install the `libpam-google-authenticator` package. 2. Copy over preconfigured `GoogleAuthenticator` config. 3. Disable password authentication for SSH. 4. Configure PAM to use `GoogleAuthenticator` for SSH logins. 5. Set `ChallengeResponseAuthentication` to `Yes`. 6. Set Authentication Methods for *bender*, *vagrant*, and *ubuntu*. 7. Insert an additional line here that reads: Restart SSH Server. We’ll look at each of these tasks in the following sections. ### Installing Google Authenticator `Google Authenticator` is a PAM module that allows you to enforce 2FA over SSH. This module is located in the Ubuntu software repository under the name `libpam-google-authenticator`. The package contains all the necessary files to enable `Google Authenticator`. With the *two_factor.yml* file still open, find the first task at the top. It should look like this: ``` - name: Install the libpam-google-authenticator package apt: name: "libpam-google-authenticator" update_cache: yes state: present ``` The `name` on the first line identifies the task’s intent (installing a package). You’ll use Ansible’s `apt` module to install the OS package. The `apt` module also requires the following `name` parameter to be set, and in this example, it is set to the package name `libpam-google-authenticator`. Finally, as before, set the `state` to `present` since you want to install the package and not remove it. Most Ansible modules have the `state` set to `present` as a default, but you are most likely not the only person using these tasks. Letting the other engineers know your intent leaves little room for doubt or error, so even though you could omit this step, it’s always better to be explicit. ### Configuring Google Authenticator To configure `Google Authenticator` for a user, you typically would run the `google-authenticator` command that was installed from the `libpam-google-authenticator` package. This application creates a configuration file named *.google_authenticator* in the user’s *home/* directory by default. The configuration file consists of a Base32 key (secret); configuration options, such as token reuse and time to live; and 10 emergency recovery tokens. To keep the focus on provisioning, I’ve created the *google_authenticator* configuration file for you in the *chapter3/* directory. Since the goal is to automate, you’ll use an Ansible task to copy this configuration file over to the VM. If you’re tempted to think, “It would be easier just to run the command by hand,” remember that in most cases you’ll be managing many hosts. Doing that by hand would be tedious and make you error prone. With the *two_factor.yml* file still open, locate the task on line 7 of the file that looks like this: ``` - name: Copy over preconfigured GoogleAuthenticator config copy: src: ../ansible/chapter3/google_authenticator dest: /home/bender/.google_authenticator owner: bender group: bender mode: 0600 ``` As always, the `name` of the task describes its intent (copy a file). The Ansible `copy` module copies the configuration file from your local host to the VM. Use the `copy` module when you need to copy a file from a source to a destination. (The source can be either local or remote.) The `copy` module requires you to set the `src` and `dest` parameters. In this case, the `src` field is set to the local *google_authenticator* file in the cloned repository ([`github.com/bradleyd/devops_for_the_desperate/`](https://github.com/bradleyd/devops_for_the_desperate/)). Notice the two dots (`..`) in the beginning of the source (*src*) file. These dots indicate that the file is located one directory up from the current *vagrant/* directory, where the `ansible` command is run. Without these dots, the `ansible-playbook` command would not be able to find the *ansible/* directory where the file is located. The `dest` parameter is set to the file named */home/bender/.google_authenticator* on the VM. The file permission, or `mode`, is set to read and write (`0600`), so only the owner of the file, *bender*, can read and write to it. To learn more about `Google Authenticator`, visit [`github.com/google/google-authenticator/wiki/`](https://github.com/google/google-authenticator/wiki/)*.* ### Configuring PAM for Google Authenticator As mentioned in Chapter 2, PAM controls a lot of authorization and authentication methods in Linux. To be able to use `Google Authenticator` over SSH, you need to modify the SSH PAM configuration file, which is very similar to what you did in Chapter 2. To add `Google Authenticator` to PAM, you’ll need to make changes to the module file located at */etc/pam.d/sshd*. This file controls how PAM interacts with the SSH server (more on that later). You’ll use two provided Ansible tasks that disable password prompts over SSH and tell PAM where it can find the `Google Authenticator` file (*pam_google_authenticator.so*). Remember, you want to force users to use public key authentication in lieu of passwords. This change will also make it harder for attackers to brute-force SSH with a password since you will not allow it. With the *two_factor.yml* file still open, locate the first of the two tasks that configure PAM (on line 15). It should look like this: ``` - name: Disable password authentication for SSH lineinfile: dest: "/etc/pam.d/sshd" regex: "@include common-auth" line: "#@include common-auth" ``` This task disables `password` prompts for SSH via the PAM module. To edit the PAM *sshd* file, this task uses the familiar Ansible `lineinfile` module, which locates the `common-auth` line with a regular expression (`regex`) and comments it out with a *#* sign. In this case, the regular expression searches for the full `common-auth` line. By commenting out that line, SSH `password` prompts for users are disabled when logging in over SSH. The second task that will configure PAM, located on line 21, should look like this: ``` - name: Configure PAM to use GoogleAuthenticator for SSH logins lineinfile: dest: "/etc/pam.d/sshd" line: "auth required pam_google_authenticator.so nullok" ``` This task tells PAM about the `Google Authenticator` module. It uses the Ansible `lineinfile` module again to edit the PAM *sshd* file. This time, you just want to add the `auth` line to the bottom of the PAM file, which lets PAM know it should use `Google Authenticator` as an authentication mechanism. The `nullok` option at the end of the line tells PAM that this authentication method is optional, which allows you to avoid locking out users until they have successfully configured 2FA. In a production environment, you should remove the `nullok` option once all users have enabled 2FA. ### Configuring the SSH Server The SSH server manages all the SSH connections from the clients and enforces specific rules governing those connections. The SSH server will require some changes to expect a 2FA response, since that’s not a default configuration. First, you’ll want to use Ansible to enable a keyboard response prompt when authenticating over SSH. The option to set is called `ChallengeResponseAuthentication`, and it’s needed so users can enter the two-factor verification code when logging in. The second change Ansible will make is to set the SSH users’ `AuthenticationMethods`, which enable the SSH server to enforce specific ways for users to authenticate themselves. For this example, you’ll set the `AuthenticationMethods` for *bender* to be `publickey` and `keyboard-interactive`. This will force *bender* to need a public key and a TOTP token to log in. You’ll also set the *vagrant* and *ubuntu* users’ `AuthenticationMethods` only to `publickey` to log in, so you’ll still have users that can access the VM if anything goes wrong with 2FA. With the *two_factor.yml* file still open, let’s review the two tasks that modify the VM’s SSH server. The first of these tasks, on line 26, should look like this: ``` - name: Set ChallengeResponseAuthentication to Yes lineinfile: dest: "/etc/ssh/sshd_config" regexp: "^ChallengeResponseAuthentication (yes|no)" line: "ChallengeResponseAuthentication yes" state: present ``` The task sets the `ChallengeResponseAuthentication` to `yes`. It uses the `lineinfile` module again to change a line in the VM’s SSH server config file. It locates the line using a regular expression that searches for the `ChallengeResponseAuthentication` option at the beginning of a line that is set to `yes` or `no`. Once it finds the line, it sets the line to `ChallengeResponseAuthentication` `yes` to enable keyboard interactivity for 2FA. The last task in the file that configures the SSH server should look like this: ``` - name: Set Authentication Methods for bender, vagrant, and ubuntu blockinfile: path: "/etc/ssh/sshd_config" block: | Match User "ubuntu,vagrant" AuthenticationMethods publickey Match User "bender,!vagrant,!ubuntu" AuthenticationMethods publickey,keyboard-interactive state: present notify: "Restart SSH Server" ``` This task sets the authentication methods for users using the `blockinfile` module. Similar to `lineinfile`, `blockinfile` can manipulate a block of text. This is useful when you need to change multiple lines at once and preserve indentation inside a file. The `blockinfile` module requires that the `path` parameter be set. In this case, the `path` of the file to edit is */etc/ssh/sshd_config*. The pipe character (`|`) is YAML notation for introducing a multiline string: the block of text, where the task uses an SSH server configuration option called `Match` that allows you to apply certain criteria to specific users. In this example, you want to allow the *ubuntu* and *vagrant* users to use `publickey` authentication only when logging in over SSH. Then you want to set the authentication methods for *bender* to be `publickey` and `keyboard-interactive`, to enforce 2FA. Finally, this example sets a `notify` action to `"Restart SSH Server"` on this task. (I’ll discuss the `notify` option next.) ### Restarting the SSH Server with a Handler Editing the configuration file is not enough; the SSH server requires a restart for all the changes to take effect. To make that happen, you’ll use the `notify` Ansible option that triggers a `handler` to perform a single task. A `handler` is just like any other task, but it’s executed only once and has a globally unique name across the whole playbook. The last Ansible task in *two_factor.yml* activates a `handler` that restarts the SSH server for you. Open the *handlers/restart_ssh.yml* file found in the *ansible/* directory. It should look like this: ``` - name: Restart SSH Server service: name: sshd state: restarted ``` This `handler`’s `name` is set to `Restart SSH Server`. This `name` matches the `notify` value from the previous task (`Set Authentication Methods for bender`, `vagrant, and ubuntu`). This is not an accident. The values must match exactly to be triggered. The `service` module restarts the SSH server. This module requires the `name` parameter, which is `sshd` in this case, to be set. Finally, this task sets the `state` to `restarted`. If, for some reason, the SSH server does not restart, the task will fail. You’re now finished with the Ansible tasks, so it’s safe to close all the open files. ## Provisioning the VM To provision the VM with all the tasks described thus far, you’ll need to uncomment them in the playbook. You’ll follow essentially the same process that you followed in Chapter 2, but this time around, you’ll need to uncomment two tasks and a `handler`. Open the *site.yml* file in your editor and locate the task for authorized keys, which should look like this: ``` **#-** **import_tasks****: chapter3/****authorized_keys.yml** ``` Remove the `#` symbol to uncomment it. Next, find the task for 2FA: ``` **#-** **import_tasks****: chapter3/****two_factor.yml** ``` Remove the `#` symbol to uncomment that line as well. Next, find the `handler` section that’s located below all the tasks. The `handler` to restart the SSH server should look like this: ``` **#-** **import_tasks****: handlers/****restart_ssh.yml** ``` Remove the `#` symbol at the beginning of the line to uncomment it. The playbook should now look like this: ``` - name: Provision VM hosts: all become: yes become_method: sudo remote_user: ubuntu tasks: - import_tasks: chapter2/pam_pwquality.yml - import_tasks: chapter2/user_and_group.yml **- import_tasks: chapter3/authorized_keys.yml** **- import_tasks: chapter3/two_factor.yml** `--snip--` handlers: **- import_tasks: handlers/restart_ssh.yml** ``` Here, the changes to the playbook for Chapter 3 are added on to the changes from Chapter 2. As mentioned previously, the playbook is a collection of tasks that will perform specific actions on a host or group of hosts to enforce a specified state. Now, you’ll automate the configuration of the VM using Vagrant. Navigate to the *vagrant/* directory, and once there, enter the following command: ``` $ **vagrant** **provision** `--snip--` PLAY RECAP ********************************************************************* default : ok=16 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ``` Notice that the total task count has increased to `16` since the last provision. You have also changed a total of `9` things on the VM. Here’s a summary of the things that changed: * Seven new tasks from Chapter 3 * One task that updates the empty file from the previous chapter * One `handler` Once again, make sure no actions failed before you continue. The values from the provision output will vary, depending on how many times you run the `provision` command in this chapter. This is because Ansible is working hard to make sure your environment is consistent, and it doesn’t do extra work that is not needed. As mentioned earlier, Ansible is idempotent, meaning it can be executed several times and each execution completes with the same end state you would expect from the initial execution. ## Testing SSH Access With the VM successfully provisioned, you should test *bender*’s access over SSH. To test public key and 2FA over SSH, you’ll need the private key you created earlier and one of the emergency tokens from the *google_authenticator* file in the repository. The private key should be located in your local SSH directory. On my Mac, it’s in */Users/bradleyd/.ssh/dftd*. The emergency tokens are the 10 eight-digit numbers located at the bottom of the *ansible/chapter3/google_authenticator*file. Choose the first one. To `ssh` in to the VM as *bender*, open a terminal on your local host and enter the following command: ``` $ **ssh** 1**-i ~/.ssh/dftd** **-p 2222** 2**bender@localhost** Enter passphrase for key /Users/bradleyd/.ssh/dftd: `<passphrase>` Verification code: `<76338876>` `--snip--` bender@dftd:~$ ``` In the `ssh` command, you set the identity file to your private key 1 for authentication and set the remote SSH port to `2222`. The default SSH port is 22, but Vagrant listens on a different SSH port to avoid conflicts on your local host. You also set the login user to *bender* and the SSH host to `localhost` 2. The output indicates you should have been prompted twice during this login session: once to enter the passphrase to unlock your private key, and a second time to enter a 2FA verification code. After satisfying both prompts, you should be successfully logged in to the VM as *bender*. If, for some reason, you weren’t prompted for a TOTP token or for the private key passphrase, stop and check for errors. You can log in to the VM as the *vagrant* user and inspect the logs. A good place to start looking for errors is in either */var/log/auth.log* or */var/log/syslog*on the VM. Common errors include the SSH server not restarting cleanly and one of the configuration files having a syntax issue. Each of the 10 tokens provided is for one-time use. Every time you successfully use one, it’s removed from the */home/bender/.google_authenticator* file. If, for some reason, you burn through all the tokens, run the `vagrant provision` command again to replace the file and replenish the tokens. Another option is to use a TOTP application like `oathtool` and generate a time-based one-time token by using the Base32 secret at the top of the */home/bender/.google_authenticator* file. You can install `oathtool` with Ubuntu’s package manager by using the `apt install oathtool` command. Every time you need a token, you can use the following command: ``` $ **oathtool --totp --base32 "QLIUWM4UVD7E5SI6PPVZ2EGRFU"** 097903 ``` Here, you pass `oathtool` your Base32 secret in the double quotes and set the flags `--totp` and `--base32` to generate the token. In this result, the token `097903` is generated and can be used when prompted for a verification code. Feel free to use this method or the provided tokens when logging in. ## Summary In this chapter, you secured the VM by disabling password logins, requiring public key authentication, and enforcing 2FA for *bender*. Automating these simple steps improves your host’s security, whether it’s local or on someone else’s computer in the cloud. As with the previous chapters, these automation tasks are a part of a foundational base that you can employ with all your hosts. In the next chapter, you’ll use more Ansible tasks to control user access by enabling security policies.
第四章:使用 sudo 控制用户命令

到目前为止,你已经通过公钥和双因素认证确保了对虚拟机的访问。你还通过使用组权限控制了对特定文件和目录的访问。接下来的基础步骤是允许用户在虚拟机上运行提升权限的命令。用户通常需要访问一些可能需要管理员权限的命令,比如重启服务或安装缺失的软件包。作为管理员,你希望严格控制谁可以运行哪些命令。在 Linux 操作系统上,sudo(超级用户执行)命令允许用户以root或其他用户的身份执行特定命令,同时保持事件的审计痕迹。
在本章中,你将使用 Ansible 安装一个简单的 Python Flask web 应用程序。你还将使用 Ansible 创建一个sudoers安全策略,这个策略由一个文件配置,决定了用户在调用sudo命令时拥有的权限。这个策略将允许developers组的成员使用sudo命令启动、停止、重启以及编辑示例 web 应用程序。虽然这是一个假设的例子,但它遵循了软件工程师应熟悉的典型发布工作流程。在本章结束时,你将对如何自动化应用程序部署并通过sudoers策略进行控制有一个清晰的理解。
什么是 sudo?
如果你是sudo的新手,它是大多数 Unix 操作系统中的一个命令行工具,允许用户或用户组以其他用户的身份执行命令。例如,一个软件工程师可能需要重启由root用户拥有的 Nginx web 服务器,或者系统管理员可能需要提升权限来安装一些软件包。如果你在 Linux 上待得够久,你可能已经使用过sudo来执行需要提升权限的命令。通常,你不会允许任何人拥有这种权限,因为这会涉及到各种安全问题。无论你的使用场景如何,用户都需要一种安全且可追溯的方式来访问特权命令,以完成他们的工作。
sudo的最佳特性之一是它能够留下审计痕迹。如果有人使用sudo执行命令,你可以查看日志,看看是谁执行了什么命令。如果没有sudo,如果你盲目允许人们切换到其他用户去执行命令,就没有任何责任追踪。
你还可以通过插件增强sudo。实际上,sudo自带一个名为sudoers的默认安全策略插件,它决定了用户在调用sudo命令时拥有的权限。你将为用户bender实现这一策略。
规划 sudoers 安全策略
当你规划一个sudoers策略时,少即是多。你希望一个用户或一组用户在主机上拥有恰到好处的权限。如果有一个用户能够在管理公司网站时运行许多特权命令,那么如果这个用户被攻击者利用,你将面临严重的问题。这是因为任何攻击者都会继承被攻破用户的相同访问权限。
话虽如此,认为你可以完全锁定主机并仍然能够完成工作是天真的。试想一个软件交付工作流,其中应用程序在每次部署后需要重新启动。如果没有适当的用户权限,你将无法为该应用程序实现持续交付自动化。
在本章中,你将设置的示例安全策略是,developers组中的每个人都能够访问示例 Web 应用程序。他们还将能够停止、启动和编辑主应用程序文件。
安装问候 Web 应用程序
我提供的示例 Python Web 应用程序巧妙地(也是懒惰地)命名为Greeting。这个简单的 Web 应用程序在你访问虚拟机上的http://localhost:5000时,会热情地回应“Greetings!”我提供这个应用程序是为了让你集中精力学习自动化和配置;在这里我不会讲解它的代码。
你将使用 Ansible 任务安装运行 Web 应用程序所需的库和文件。你还将安装一个systemd单元文件,它是管理 Linux 主机上进程和服务的标准服务管理器,便于启动和停止 Web 应用程序。
安装 Web 应用程序的 Ansible 任务(以及本章的所有其他任务)位于ansible/chapter4/目录中。你应该导航到该目录并在你喜欢的编辑器中打开名为web_application.yml的任务文件。
这个文件包含四个独立的任务,名称如下:
-
安装
python3-flask、gunicorn3和nginx -
复制 Flask 示例应用程序
-
复制Systemd单元文件以启动问候应用程序
-
启动并启用问候应用程序
我将逐个讲解这些任务,首先从安装 Web 应用程序依赖项的任务开始:python3-flask、gunicorn3和nginx。这是文件顶部的第一个任务,应该如下所示:
- name: Install python3-flask, gunicorn3, and nginx
apt:
name:
- python3-flask
- gunicorn3
- nginx
update_cache: yes
任务name描述了它的意图,即安装一些软件包。apt模块再次被用来从 Ubuntu 存储库中在虚拟机上安装python3-flask、gunicorn3和nginx包。然而这次,apt模块使用了一些语法糖:YAML 列表。这一特性允许你在一个任务中安装多个软件包(或卸载它们),而不需要为每个要安装的软件包创建单独的任务。
从顶部开始的第二个任务将示例问候应用程序复制到虚拟机上。你需要两个文件来让问候 Web 应用程序正常运行,任务应该如下所示:
- name: Copy Flask Sample Application
copy:
src: "../ansible/chapter4/{{ item }}"
dest: "/opt/engineering/{{ item }}"
group: developers
mode: '0750'
loop:
- greeting.py
- wsgi.py
copy 模块将两个文件从提供的仓库复制到虚拟机(VM)。src 和 dest 行是模板化的(使用双大括号),并由 loop 模块的值替换。在这里,loop 模块通过名称引用了两个文件:greeting.py 和 wsgi.py。greeting.py 文件是实际的 Python Flask 代码,而 wsgi.py 文件包含了 HTTP 服务器的应用程序对象。在此任务的运行时,{{ item }} 占位符会被 loop 中的这两个文件名之一替换。例如,src 行在 loop 第一次遍历时会变成 "../ansible/chapter4/greeting.py"。mode 行将两个文件的权限设置为任何 developers 组的成员都可以读取和执行。
接下来,让我们看看复制 systemd 单元文件到虚拟机的任务。此任务位于从顶部数起的第三个位置,应如下所示:
- name: Copy Systemd Unit file for Greeting
copy:
src: "../ansible/chapter4/greeting.service"
dest: "/etc/systemd/system/greeting.service"
该任务首先使用描述性的 name,如往常一样。然后,熟悉的 Ansible copy 模块将一个文件从本地主机复制到虚拟机。在这种情况下,它将 greeting.service 文件复制到虚拟机上 systemd 可以找到的位置:/etc/systemd/system。
让我们回顾一下 system service 文件。这类文件可以有很多选项和设置,但在本例中,我提供了一个简单的文件来控制 Greeting Web 应用程序的生命周期。
在编辑器中打开 ansible/chapter4/greeting.service 文件。它应如下所示:
[Unit]
Description=The Highly Complicated Greeting Application
After=network.target
[Service]
Group=developers
**WorkingDirectory=/opt/engineering**
**ExecStart=/usr/bin/gunicorn3 --bind 0.0.0.0:5000 --access-logfile - --error-logfile - wsgi:app**
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
[Install]
WantedBy=multi-user.target
WorkingDirectory 和 ExecStart 行是此文件中最重要的部分。第一行将工作目录设置为 /opt/engineering,因为这是应用程序代码所在的目录。在 ExecStart 行中,gunicorn3 应用程序调用 wsgi.py 文件来启动 Web 应用程序。你还会告诉 gunicorn3 将 STDOUT(--access-logfile -)和 STDERR(--error-logfile -)日志记录到 systemd 日志中,默认情况下,这些日志会转发到 /var/log/syslog 文件。现在关闭 greeting.service 文件。
web_application.yml 文件中的最后一个任务确保 Greeting Web 应用程序已启动,并且每次执行配置时都会重新加载 systemd 守护进程。它应如下所示:
- name: Start and enable Greeting Application
systemd:
name: greeting.service
daemon_reload: yes
state: started
enabled: yes
在这里,systemd Ansible 模块启动 Greeting Web 应用程序。该模块要求你设置 name 和 state,在此情况下分别为 greeting.service 和 started。enabled 参数告诉 systemd 在启动时自动启动该服务。使用 daemon_reload 参数还强制 systemd 重新加载所有服务文件,并在执行其他操作之前发现 greeting.service 文件。这相当于运行 systemctl daemon-reload。daemon_reload 参数在主机的首次配置时非常有用,以确保 systemd 知道该服务。务必使用 daemon_reload 参数,以确保 systemd 始终知道服务文件的任何更改。
sudoers 文件的结构
sudoers 文件是配置安全策略(针对用户和组),用于调用 sudo 命令的地方。此类安全文件由名为 Defaults、User Specifications 和 Aliases 的部分组成。sudoers 文件是从上到下读取的,规则按此顺序应用,因此最后匹配的规则总是会生效。
Defaults 语法允许你在运行时覆盖一些 sudoers 选项,例如设置用户在运行 sudo 时可以访问的环境变量。User Specifications 部分决定了用户可以运行哪些命令,以及可以在哪些主机上运行这些命令。例如,你可以授予 bender 用户在所有 Web 服务器主机上运行 apt install 命令的权限。Aliases 语法引用文件中的其他对象,这对于在有很多重复内容时保持配置清晰简洁非常有用。
你可以混合使用的四个别名如下:
-
Host_Alias指定一个主机或一组主机 -
Runas_Alias指定一个命令可以以哪些用户或组的身份运行 -
Cmnd_Alias指定一个或多个命令 -
User_Alias指定一个用户或一组用户
在本例中,你只会在 sudoers 文件中使用 Cmnd_Alias 和 Host_Alias。
创建 sudoers 文件
要创建 sudoers 文件,你将使用 Ansible 的 template 模块和一个模板文件。Ansible 的 template 模块对于创建需要用变量修改的文件非常有用。template 模块使用 Python 模板的 Jinja2 模板引擎来创建文件。你将把模板文件保存在一个名为 ansible/templates/ 的独立目录中(稍后会详细说明)。
在 ansible/chapter4/ 目录下,使用你喜欢的编辑器打开名为 sudoers.yml 的任务文件。你首先应该注意到的是,在文件顶部有一个新的 Ansible 模块,叫做 set_fact。这个模块允许你设置主机变量,这些变量可以在任务或整个剧本中使用。在这里,你将使用它来设置一个变量,并在模板文件中使用:
- set_fact:
greeting_application_file: "/opt/engineering/greeting.py"
这会创建一个名为 greeting_application_file 的变量,并将其值设置为 /opt/engineering/greeting.py(之前的任务会安装这个 Web 应用)。如前所述,developers 组中的任何人都可以在 /opt/engineering/ 目录下读取和执行文件。
接下来,找到位于 set_fact 模块下面的任务。这个任务为 developers 组创建 sudoers 文件,应该如下所示:
- name: Create sudoers file for the developers group
template:
src: "../ansible/templates/developers.j2"
dest: "/etc/sudoers.d/developers"
validate: 'visudo -cf %s'
owner: root
group: root
mode: 0440
Ansible 的template模块构建了你的sudoers文件。它需要一个源文件(src)和一个目标文件(dest)。源文件是你本地的 Jinja2 模板(developers.j2),目标文件将在 VM 上创建developers sudoers文件。template模块还包含一个validate步骤,用于验证模板是否正确。在这种情况下,visudo命令以安全的方式编辑并验证你的sudoers文件。给visudo命令加上-cf标志可以确保sudoers文件合规并且没有语法错误。%s是dest参数中文件的占位符。如果validate命令因任何原因失败,Ansible 任务也会失败。最后,将文件的所有者、组和权限设置为root、root和0440(分别)。这是sudoers期望的正确权限。
sudoers 模板
Ansible 的template模块任务引用了位于ansible/templates/目录中的源 Jinja2 模板文件。它包含了为developers组创建sudoers策略的基本构建块。
导航到ansible/templates/目录,并在编辑器中打开developers.j2文件。文件的.j2后缀告诉 Ansible 这是一个 Jinja2 模板。文件的内容应如下所示:
# Command alias
Cmnd_Alias START_GREETING = /bin/systemctl start greeting , \
/bin/systemctl start greeting.service
Cmnd_Alias STOP_GREETING = /bin/systemctl stop greeting , \
/bin/systemctl stop greeting.service
Cmnd_Alias RESTART_GREETING = /bin/systemctl restart greeting , \
/bin/systemctl restart greeting.service
# Host Alias
Host_Alias LOCAL_VM = {{ hostvars[inventory_hostname]['ansible_default_ipv4']['address'] }}
# User specification
%developers LOCAL_VM = (root) NOPASSWD: START_GREETING, STOP_GREETING, \
RESTART_GREETING, \
sudoedit {{ greeting_application_file }}
该文件以三个Cmnd_Alias声明开始,这些声明用于停止、启动和重启 Greeting Web 应用程序。(在systemd中,服务可以被称为greeting或greeting.service,所以这两种情况都会被处理。)接下来,设置一个名为LOCAL_VM的Host_Alias,它指向 VM 的私有 IP 地址。内建的 Ansible 变量hostvars在配置运行时动态获取 VM 的 IP 地址。如果你同时配置多个主机,这将非常有用。最后,这会为developers组创建一个用户规范。(%表示这是一个组,而不是一个用户。)用户规范规则声明,在LOCAL_VM上的任何developers组成员,都可以作为root用户,无需密码启动、停止、重启或编辑 Greeting Web 应用程序。请注意,发出sudoedit命令仅允许编辑 Web 应用程序。(稍后我会更详细地讨论sudoedit。){{ greeting_application_file }}变量将在运行时设置,指向通过set_fact设置的 Greeting Web 应用程序文件。
到此为止,可以安全地关闭所有打开的文件。接下来,你将配置 VM 并测试bender的sudo权限。
配置 VM
要运行本章的所有任务,你需要像在前几章中一样取消注释它们。在编辑器中打开ansible/site.yml文件,并找到安装 Web 应用程序的任务。它应该像这样:
**#-** **import_tasks****: chapter4/****web_application.yml**
删除#符号以取消注释。
接下来,找到创建developers sudoer策略的任务:
**#- import_tasks: chapter4/sudoers.yml**
通过删除#符号来取消注释该行。
现在,剧本应该看起来像这样:
---
- name: Provision VM
hosts: all
become: yes
become_method: sudo
remote_user: ubuntu
tasks:
- import_tasks: chapter2/pam_pwquality.yml
- import_tasks: chapter2/user_and_group.yml
- import_tasks: chapter3/authorized_keys.yml
- import_tasks: chapter3/two_factor.yml
**-** **import_tasks****: chapter4/****web_application.yml**
**-** **import_tasks****: chapter4/****sudoers.yml**
`--snip--`
handlers:
- import_tasks: handlers/restart_ssh.yml
第四章的 playbook 更改已经添加到第三章的更改中
现在,你将使用 Vagrant 运行 Ansible 任务。返回到 vagrant/ 目录,其中包含你的 Vagrant 文件,并输入以下命令来配置虚拟机:
$ **vagrant** **provision**
`--snip--`
PLAY RECAP *********************************************************************
default : ok=21 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
配置输出中的值会有所不同,具体取决于你运行 provision 命令的次数,因为 Ansible 会确保你的环境一致,如果不需要,它不会做多余的工作。这里的总任务数量已增加到 21。你还在虚拟机上更改了以下六个内容:
-
第四章的五个新任务
-
一个任务更新了第二章中的空文件的时间戳
再次确认在继续之前没有操作失败。
测试权限
在虚拟机成功配置后,你现在可以通过测试 bender 的命令访问来检查你的安全策略。首先,你需要重新以 bender 用户登录虚拟机。sudoers 策略应该允许 developers 组中的任何人(在这个案例中是 bender)启动、停止、重启或编辑网络应用程序。
要以 bender 身份登录,请获取另一个 2FA 令牌。这次,找到 ansible/chapter3/google_authenticator 文件顶部的第二个 2FA 令牌;它应该是 68385555。拿到它后,在终端输入以下命令以 bender 用户身份登录:
$ **ssh** **-i ~/.ssh/dftd** **-p 2222 bender@localhost**
Enter passphrase for key '/Users/bradleyd/.ssh/dftd: `<passphrase>`
Verification code: `<68385555>`
`--snip--`
bender@dftd:~$
这里,你使用的是第三章中的 SSH 参数来登录虚拟机。当系统提示输入 2FA 令牌时,使用刚刚获取的第二个令牌。这个登录过程现在应该很熟悉了,如果不熟悉,请回顾第三章以获取更多信息。
访问网络应用程序
你需要确保网络应用程序正在运行并响应请求。你将使用 curl 命令来测试,它将数据传输到服务器(在此情况下是 HTTP 服务器)。Greeting 应用程序服务器在所有接口上监听 5000 端口的请求。所以,在终端输入以下命令,向 Greeting 服务器的 5000 端口发送 HTTP GET 请求:
bender@dftd:~$ **curl http://localhost:5000**
<h1 style='color:green'>Greetings!</h1>
输出显示,Greeting 网络应用程序在虚拟机的 localhost 上成功响应请求。
编辑 greeting.py 来测试 sudoers 策略
接下来,你将通过 sudoedit 对 Greeting 应用程序进行小幅修改,以测试 bender 的权限。你在本章早些时候设置的 sudoers 策略允许 developers 组的成员使用 sudoedit 命令编辑 /opt/engineering/greeting.py 文件,sudoedit 让用户可以用任何编辑器编辑文件,并且在编辑前会复制一份文件,以防出错。如果没有 sudoedit,你可能需要为每个用户想使用的编辑器创建多个命令别名。
在真实的生产系统中,你可能不会直接在主机上编辑文件。相反,你会编辑源控版本,并允许你的自动化更新它,确保使用最新版本。然而,我之所以描述这种方法,是为了展示如何测试你的 sudoers 策略。
在仍然以 bender 身份登录的情况下,输入以下命令以编辑 greeting.py 文件:
bender@dftd:~$ **sudoedit /opt/engineering/greeting.py**
该命令应该将你带入 Nano 文本编辑器(Ubuntu 的默认编辑器)。进入编辑器后,在 hello() 函数内找到类似下面的代码行:
return "<h1 style='color:green'>Greetings!</h1>"
将 <h1>Greetings!</h1> 文本修改为 <h1>Greetings and Salutations!</h1>,使该行如下所示:
return "<h1 style='color:green'>**Greetings and Salutations!**</h1>"
保存文件并退出 Nano 文本编辑器。
使用 systemctl 停止和启动
为了使问候语字符串更改生效,你需要使用 sudo 和 systemctl 命令(后者是一个命令行应用程序,允许你控制由 systemd 管理的服务)停止并启动 Web 应用程序服务器。你在 sudoers 策略中的 Cmnd_Alias 声明允许任何属于 developers 组的用户执行 /bin/systemctl stop greeting 或 /bin/systemctl start greeting 命令。
要使用 systemctl 停止正在运行的 Greeting 应用程序,请输入以下命令:
bender@dftd:-$ **sudo systemctl stop greeting**
命令应没有输出,并且不应该提示输入密码。
接下来,重新运行 curl 命令,以确保 Web 应用程序已停止:
bender@dftd:~$ **curl http://localhost:5000**
curl: (7) Failed to connect to localhost port 5000: Connection refused
在这里,curl 返回了一个 Connection refused 错误,因为服务器不再运行。
通过输入以下命令重新启动已停止的 Greeting 服务器:
bender@dftd:-$ **sudo systemctl start greeting**
如果命令成功执行,将不会有任何输出。
重新运行 curl 命令,检查 Web 应用程序是否正在运行并且代码已更新:
bender@dftd:~$ **curl http://localhost:5000**
<h1 style='color:green'>Greetings and Salutations!</h1>
Greeting 服务器成功响应了新的改进版问候语。如果由于某种原因,你的 Greeting 应用程序没有像这样响应,请回溯你的步骤。从检查虚拟机上的 /var/log/syslog 文件或 /var/log/auth.log 文件中的错误开始。
审计日志
如前所述,sudo 的一个重要特点是它会留下审计日志。这些日志中的事件通常用于监控框架,或在事件响应过程中进行取证。不管怎样,你应该确保审计数据存放在一个可访问的区域,以便你进行查看。
如果你按照本章中的测试进行操作,你会执行了三次 sudo 命令。那些事件被记录在 /var/log/auth.log 文件中,因此让我们查看一些与这些 sudo 命令相关的日志行。我已经挑选出了一些与本示例相关的日志行,以免你被日志解析的艺术所困扰。然而,你可以随时深入探索日志文件。
在 auth.log 文件中,你将看到的第一行日志是关于 bender 使用 sudoedit 的:
Jul 23 23:17:43 ubuntu-focal sudo: bender : TTY=pts/0 ; PWD=/home/bender ; USER=root ; COMMAND=sudoedit /opt/engineering/greeting.py
这行日志提供了相当多的信息,但我们将重点关注 date/time、USER 和 COMMAND 列。你可以看到 bender 在 7 月 23 日 的 23:17:43 调用了 sudo,执行了 sudoedit /opt/engineering/greeting.py 命令。这是在你修改 greeting.py 文件以更改问候文本时发生的。
这行日志显示了你使用 bender 停止 Greeting 服务器的操作:
Jul 23 23:18:19 ubuntu-focal sudo: bender : TTY=pts/0 ; PWD=/home/bender ; USER=root ; COMMAND=/usr/bin/systemctl stop greeting
在7 月 23 日的23:18:19,bender以root用户身份使用sudo执行了/bin/systemctl stop greeting命令。
最后,这里是日志行,显示了bender启动了 Greeting 应用程序:
Jul 23 23:18:39 ubuntu-focal sudo: bender : TTY=pts/0 ; PWD=/home/bender ; USER=root ; COMMAND=/usr/bin/systemctl start greeting
在7 月 23 日的23:18:39,bender以root用户身份使用sudo执行了/bin/systemctl start greeting命令。
到目前为止,我已经展示了成功且预期的日志条目。以下一行展示了bender执行失败的命令:
Jul 23 23:25:14 ubuntu-focal sudo: bender : command not allowed ; TTY=pts/0 ; PWD=/home/bender ; USER=root ; COMMAND=/usr/bin/tail /var/log/auth.log
在7 月 23 日的23:25:14,bender尝试运行/usr/bin/tail /var/log/auth.log命令,但被拒绝。这些可能是你希望在警报系统中追踪的日志行,因为这可能是一个恶意行为者试图在主机上进行导航。
摘要
本章探讨了允许用户以提升的权限运行命令的重要性。使用 Ansible、sudo命令和sudoers文件,你可以限制命令访问并记录审计日志以确保安全。你还使用了不同的 Ansible 模块,如template、systemd和set_fact,这些模块使你能够自动化安装 Web 应用程序并控制其生命周期。
在下一章中,你将总结这一节关于配置和安全性的内容。你还将使用一些提供的 Ansible 任务来保护网络并为虚拟机实现防火墙。
第五章:自动化和测试基于主机的防火墙

对于一台生产服务器,特别是暴露在互联网上的服务器,不过滤其网络流量是很危险的。作为软件或 DevOps 工程师,我们会为像 SSH 或 Web 服务器这样的服务开放端口,这是一种必要的、被接受的风险。然而,这并不意味着我们可以忽略所有其他目的地为我们主机的流量。为了最小化风险,我们需要过滤所有其他流量,并在允许进出的流量上做出务实的决策。因此,我们使用防火墙来监控网络或主机上的进出数据包。防火墙有两种类型。网络防火墙 通常是一个设备,所有流量都通过它从一个网络流向另一个网络,而 基于主机的 防火墙 控制进出单一主机的数据包。
在本章中,你将专注于基于主机的防火墙。你将学习如何使用 Ansible、一些提供的任务和一个叫做 Uncomplicated Firewall (UFW) 的软件应用来自动化配置基于主机的防火墙。这个防火墙将阻止所有传入流量,除了 SSH 连接和你在第四章中安装的 Greeting Web 应用。到本章结束时,你将理解如何自动化基本的基于主机的防火墙,并能够审计来自防火墙的日志事件。
规划防火墙规则
防火墙规则需要非常明确地指定允许哪些流量,拒绝哪些流量。如果你不小心屏蔽了一个端口(或更糟,留下了一个暴露的端口),结果将会非常不理想。
你可以将防火墙的流量分为三个默认部分,称为链。可以将链看作是一个门,数据包必须通过这个门。每个门当正确路由的数据包到达时,都会引导到特定的地方。以下是 UFW 中你可以访问的三个默认链的简要描述:
-
输入链 过滤目标主机的数据包
-
输出链 过滤源自主机的数据包
-
转发链 过滤正在通过主机路由的数据包
你将创建的防火墙规则只会应用于输入链,因为你关注的是传入 VM 的流量。转发链和输出链超出了本书的范围,因为你正在构建一个简单的基于主机的防火墙。如果你需要屏蔽出去的端口并转发网络流量,请访问ubuntu.com/server/docs/security-firewall/获取更多详情。
你将实现的防火墙规则将允许两种已知端口的传入流量,同时拒绝所有其他流量。你需要为 shell 访问(SSH)和 Ansible 配置开放端口 22;此外,还需要为 Web 应用开放端口 5000。你还将对端口 5000 添加速率限制,以保护 Web 服务器和主机免受过度滥用。最后,你将启用防火墙日志,以便审计通过防火墙的网络流量。
自动化 UFW 规则
简单防火墙 (UFW) 是一个软件应用程序,它为 iptables 框架提供了一个简化的封装,iptables 是基于内核的 Unix 操作系统包过滤的根本所在。具体来说,iptables、Netfilter、连接跟踪和网络地址转换 (NAT) 共同构成了包过滤框架。UFW 隐藏了使用 iptables 时的复杂性。结合 Ansible,它使得基于主机的防火墙设置变得简单、易于操作并且可重复。因此,您将使用 Ansible 任务来通过 UFW 创建规则。
配置防火墙的 Ansible 任务位于 ansible/chapter5/ 目录下。只有在配置虚拟机后,这些规则才会生效,因此在配置之前让我们先回顾一下这些规则。请导航到 ansible/chapter5/ 目录并用您喜欢的编辑器打开名为 firewall.yml 的任务文件。该文件包含以下五个任务:
-
将
Logging级别设置为低。 -
允许通过
22端口的 SSH 连接。 -
允许所有访问
5000端口。 -
限制对
5000端口的过度滥用。 -
丢弃所有其他流量。
文件顶部的第一个任务应该如下所示:
- name: Turn Logging level to low
ufw:
logging: 'low'
这个任务启用 logging 功能来记录 UFW 日志,并将日志级别设置为 low。Ansible 的 ufw 模块用于在虚拟机上创建防火墙规则和策略。您可以将 logging 参数设置为 off、low、medium、high 或 full。low 日志级别将记录任何未匹配默认策略的被阻止数据包,以及您添加的任何其他防火墙规则。medium 级别会做 low 级别做的所有事情,并额外记录所有未匹配默认策略的允许数据包和所有新连接。high 日志级别会做 medium 级别做的所有事情,但它还会记录所有数据包,并对日志消息进行一定的速率限制。如果您的磁盘空间充足,并且想知道关于主机上的每个数据包的所有信息,可以将日志级别设置为 high。任何高于 medium 的设置都会生成大量日志数据,并可能在繁忙的主机上快速填满磁盘,因此请小心使用这些日志设置。
接下来,让我们看一下文件顶部的第二个任务,它为 SSH 连接打开 22 端口。它应该如下所示:
- name: Allow SSH over port 22
ufw:
rule: allow
port: '22'
proto: tcp
在这里,Ansible 的 ufw 模块创建了一个 rule,它允许来自任何源 IP 地址的连接,使用 TCP 协议连接到虚拟机的 22 端口。根据您的使用场景,您可以将 rule 参数设置为 deny、limit 或 reject。例如,如果您想停止某个端口的连接,但不介意向远程主机发送拒绝回复,您应该选择 reject。拒绝回复将告诉远程系统您已经启用但不接受该端口上的流量。另一方面,如果您想直接丢弃传入的数据包而不向远程主机发送任何回复,则选择 deny 规则。这会使得扫描您的主机的人更难知道主机是否在运行。(稍后我会详细讨论 limit 规则。)
下一个任务是允许通过端口5000进行远程连接到 Greeting Web 应用程序的规则。它应该像这样:
- name: Allow all access to port 5000
ufw:
rule: allow
port: '5000'
proto: tcp
这个rule与之前的任务行为相同,只不过它允许通过 TCP 连接端口5000,而不是端口22。
文件中的第四个任务限制了在特定时间范围内对端口5000(Greeting 服务器)的连接数量。这对于你想要自动阻止某人滥用服务非常有用,无论该用户是合法的还是可疑的。它应该像这样:
- name: Rate limit excessive abuse on port 5000
ufw:
rule: limit
port: '5000'
proto: tcp
UFW 的默认速率限制功能规定,如果某个源在 30 秒内尝试进行超过六次连接,则会拒绝该连接。如果你托管像 API 或 Web 服务器这样的公共服务,这个功能非常有用。你可以利用速率限制临时阻止用户频繁访问你的服务。另一个适用的场景是限制 SSH 上的暴力破解尝试,尤其是在堡垒主机上,堡垒主机是系统管理员用来远程访问私有网络的强化主机。然而,使用这个默认的限制设置时要小心,因为它可能对生产环境过于严格。允许远程系统在 30 秒内连接超过六次,可能对你来说是正常的流量。你将在本章稍后测试速率限制规则。
如果你想调整默认的速率限制设置,使用lineinfile模块(参见第三章)创建一个新任务,以定位并更新/etc/ufw/user.rules中的那一行,应该像这样:
-A ufw-user-input -p tcp --dport 5000 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 -j ufw-user-limit
根据你的环境需要,修改hitcount和seconds选项。
本文件中的最后一个任务会丢弃所有到目前为止未匹配任何其他规则的流量。记住,Ansible 是按顺序执行任务的。丢弃规则应该像这样:
- name: Drop all other traffic
ufw:
state: enabled
policy: deny
direction: incoming
请注意,这里没有rule参数。这个任务将 VM 上ufw服务的state设置为启用。它还将默认的incoming策略设置为deny,这强制你将所有需要暴露的服务列入白名单。如果有人不小心配置错误并打开了主机的端口,这也能保护你。
如前所述,Ansible 按顺序从上到下读取任务,而 UFW 规则也是按相同顺序读取的。如果drop规则是文件中的第一个任务,它将把策略设置为丢弃所有流量,并启用防火墙。那时drop规则会匹配所有传入的包并丢弃它们,停止对任何其他可能匹配的规则的搜索。这样,你不仅会失去对虚拟机的访问,还会丢失 Ansible 通过 SSH 建立的连接。这意味着配置过程会失败,可能会导致机器处于不正常的状态,因此在添加或删除规则时务必记住顺序。
配置虚拟机
要运行本章的所有任务,你需要在剧本中取消注释它们。这与之前章节的过程相同,应该已经熟悉了。打开ansible/site.yml文件,在编辑器中找到安装firewall的任务。它应该看起来像这样:
**#-** **import_tasks****: chapter5/****firewall.yml**
移除#符号以取消注释。此时,剧本应该如下所示:
---
- name: Provision VM
hosts: all
become: yes
become_method: sudo
remote_user: ubuntu
tasks:
- import_tasks: chapter2/pam_pwquality.yml
- import_tasks: chapter2/user_and_group.yml
- import_tasks: chapter3/authorized_keys.yml
- import_tasks: chapter3/two_factor.yml
- import_tasks: chapter4/web_application.yml
- import_tasks: chapter4/sudoers.yml
**- import_tasks: chapter5/firewall.****yml**
`--snip--`
handlers:
**- import_tasks: handlers/restart_ssh.yml**
第五章对剧本的更改是在第四章的基础上添加的。
现在,到了使用 Vagrant 运行 Ansible 任务的时候了。返回到包含Vagrantfile的vagrant/目录,并输入以下命令来配置虚拟机:
$ **vagrant provision**
`--snip--`
PLAY RECAP *********************************************************************
default : ok=26 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
总任务数量已增加到26,虚拟机上有6项更改:本章的五个新任务和一个更新第二章空文件时间戳的任务。继续之前,再次确认没有任务失败。
测试防火墙
接下来,你需要测试基于主机的防火墙是否已启用,允许两个白名单端口,阻止所有其他端口,并对 Greeting 应用程序进行速率限制。
首先,你需要能够从本地主机访问虚拟机,因此先从虚拟机获取一个 IP 地址。在Vagrantfile中,你告诉 Vagrant 创建另一个接口,并让 VirtualBox 通过 DHCP 从一个范围内分配地址。
如果你不再登录虚拟机,请重新以bender身份登录并获取另一个 2FA 令牌(如果需要)。这次,从ansible/chapter3/google_authenticator文件顶部获取第三个 2FA 令牌,它应该是52973407。获得后,在终端中输入以下命令以bender身份登录:
$ **ssh** **-i ~/.ssh/dftd** **-p 2222 bender@localhost**
Enter passphrase for key '/Users/bradleyd/.ssh/dftd: `<passphrase>`
Verification code: `<52973407>`
`--snip--`
bender@dftd:~$
然后,使用ip命令从你指示 Vagrant 和 VirtualBox 创建的接口中获取 IP 地址。此命令主要用于列出和操作 Linux 主机上的网络路由和设备。在虚拟机终端中,输入以下命令:
bender@dftd:~$ **ip -4 -br addr**
lo UNKNOWN 127.0.0.1/8
enp0s3 UP 10.0.2.15/24
enp0s8 UP 172.28.128.3/24
上面的输出显示ip命令已成功完成。-4标志将输出限制为仅显示 IPv4 地址。-br标志仅打印基本的接口信息,如 IP 地址和名称,addr 命令告诉`ip`显示网络接口的地址信息。
The output lists three devices in tabular format. The first device, named `lo`, is a loopback network interface that is created on Linux hosts (commonly referred to as `localhost`). The loopback device is not routable (accessible) from outside the VM. The second device, `enp0s3`, has an IP address of `10.0.2.15`. This is the default interface and the IP you get from Vagrant and VirtualBox when you first create the VM. This device is also not routable from outside the VM. The last interface, `enp0s8`, has an IP address of `172.28.128.3`, which was dynamically assigned by this line in the *Vagrantfile*: ``` config.vm.network "private_network", type: "dhcp" ``` This IP address is how you’ll access the VM from your local machine. Because these IP addresses are assigned using DHCP, yours may not match exactly. The interface name may be different as well; just use whatever IP address is listed for the interface that is not a `loopback` device or the device in the `10.0.2.0/24` subnet. Keep this terminal and connection open to the VM, as you’ll use it again in the next section. ### Scanning Ports with Nmap To test that the firewall is filtering traffic, you’ll use the `nmap`(network mapper) command line tool for scanning hosts and networks. Be sure to install the appropriate Nmap version for your specific OS. Visit [`nmap.org/book/install.html`](https://nmap.org/book/install.html) for instructions on installing Nmap for different OSs. Once it’s installed, you’ll want to do a couple of scans. The first scan, which is a fast check, tests that the firewall is enabled and allowing traffic on your two ports. The other scan is a check for the services and versions running behind those open ports. To run the first scan, enter the following command in your terminal, using the IP address of the VM you copied earlier (if you are on a Mac or Linux host, you’ll need to use `sudo` since Nmap requires elevated permissions): ``` $ **sudo nmap -F** `<172.28.128.3>` Password: Starting Nmap 7.80 ( https://nmap.org ) at 2022-08-11 10:14 MDT Nmap scan report for 172.28.128.3 Host is up (0.00066s latency). Not shown: 98 filtered ports PORT STATE SERVICE 22/tcp open ssh 5000/tcp open upnp MAC Address: 08:00:27:FB:C3:AF (Oracle VirtualBox virtual NIC) Nmap done: 1 IP address (1 host up) scanned in 1.88 seconds ``` The `-F` flag tells `nmap` to do a fast scan, which looks for only the 100 most common ports, such as `80` (web), `22` (SSH), and `53` (DNS). As expected, the output shows `nmap` detects that ports `22` and `5000` are open. It shows the other 98 ports are *filtered*, which means `nmap` could not detect what state the ports were in because of the firewall. This tells you that the host-based firewall is enabled and filtering traffic. The next scan you’ll do is one that bad actors do on the internet every day. They scan for hosts that are connected to the internet, looking for services and versions while hoping they can match a vulnerability to it. Once they have an exploit in hand, they can use it to try to gain access to that host. Enter the following command from your local host’s terminal to detect your service versions: ``` $ **sudo nmap -sV** `<172.28.128.3>` Starting Nmap 7.80 ( https://nmap.org ) at 2022-08-11 21:06 MDT Nmap scan report for 172.28.128.3 Host is up (0.00029s latency). Not shown: 998 filtered ports PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.1 (Ubuntu Linux; protocol 2.0) 5000/tcp open http Gunicorn 20.0.4 MAC Address: 08:00:27:F7:33:1F (Oracle VirtualBox virtual NIC) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel ``` ```` ``` Service detection performed. Please report any incorrect results at https://nmap.org/submit/. Nmap done: 1 IP address (1 host up) scanned in 13.13 seconds ``` The `-sV` flag tells `nmap` to attempt to extract service and version information from running services. Once again, `nmap` finds the two open ports, `22` and `5000`. Also, a service name and version are listed next to each port. For port `22`, the service name is `OpenSSH`, and the version is `8.2p1` for `Ubuntu Linux`. For port `5000`, the service name is `Gunicorn`, and the version is `20.0.4.` If you were a bad actor armed with this information, you could search the many vulnerability databases, looking for exploits for these services and versions. Next, you’ll want to check the logs for evidence that the firewall blocked connection attempts on non-whitelisted ports. ### Firewall Logging All events that the firewall processes can be logged. You enabled logging and set the level to `low` for UFW in the Ansible task earlier in this chapter. The log for those events is located in the */var/log/ufw.log*file. This logfile requires *root* permissions to read it, so you’ll need a user with elevated permissions. As an example, I have pulled out a log entry to demonstrate a block event from the *ufw.log* file. Here is what UFW logged when Nmap tried to scan port `80`: ``` Aug 11 16:56:17 ubuntu-focal kernel: [51534.320364] 1[UFW BLOCK] 2IN=enp0s8 OUT= MAC=08:00:27:fb:c3:af:0a:00:27:00:00:00:08:00 3SRC=172.28.128.1 4DST=172.28.128.3 LEN=44 TOS=0x00 PREC=0x00 TTL=48 ID=7129 PROTO=TCP SPT=33405 5DPT=80 WINDOW=1024 RES=0x00 SYN URGP=0 ``` This log line contains a lot of information, but you’ll focus on only a few components here. The event type name 1 is a block type, so it’s named `[UFW BLOCK]`. The `IN` key-value pair 2 shows the network interface for which this packet was destined. In this case, it’s the VM interface from the earlier section. The source IP address (`SRC`) 3 is where the packet originated. In this example, it’s the source IP address from the local host where you ran the `nmap` command. This IP address was created from VirtualBox when you added the other interface in Vagrant. The destination IP address, `DST` 4, is the IP address for which the packet was destined. It should be the IP address of the second non-loopback interface on the VM. The destination port, `DPT` 5, is the port where the packet was being sent. In this log line, it’s port `80`. Since you don’t have a rule permitting any traffic on port `80`, it was blocked. This means your firewall is blocking unwanted connection attempts. Remember, Nmap’s fast scan will try 100 different ports, so there will be multiple log lines that look like this one. However, they will have different destination ports (`DPT`). ### Rate Limiting To test that the firewall will rate-limit excessive connection attempts (six in 30 seconds) to your Greeting web server, you’ll leverage the `curl` command again. From your local host, enter the following to access the Greeting web server six times: ``` $ **for i in `seq 1 6` ; do curl -w "\n" http://172.28.128.3:5000 ; done** <h1 style='color:green'>Greetings!</h1> <h1 style='color:green'>Greetings!</h1> <h1 style='color:green'>Greetings!</h1> <h1 style='color:green'>Greetings!</h1> <h1 style='color:green'>Greetings!</h1> curl: (7) Failed to connect to 172.28.128.22 port 5000: Connection refused ``` Here, a simple `for` loop in Bash iterates and executes the `curl` command six times in succession. The `curl` command uses the `-w` `"\n"` flag to write out a new line after each loop, which makes the web server’s response output more readable. As you can see, the last line shows a `Connection refused` notification after the fifth successful connection to the Greeting web server. This is because the rate limit on the firewall for port `5000` was triggered by being hit six times in less than 30 seconds. Let’s explore the log line for this event. (Once again, I’ve grabbed the relevant log line for you.) ``` Aug 11 17:38:48 ubuntu-focal kernel: [54085.391114] 1 [UFW LIMIT BLOCK] IN=enp0s8 OUT= MAC=08:00:27:fb:c3:af:0a:00:27:00:00:00:08:00 2SRC=172.28.128.1 3DST=172.28.128.3 LEN=64 TOS=0x00 PREC=0x00 TTL=64 ID=0 DF PROTO=TCP SPT=58634 4DPT=5000 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 ``` The UFW event type is named `[UFW LIMIT BLOCK]` 1. This packet is coming (`SRC`) from the local host IP address 2 where you ran the `curl` command. The destination (`DST`) 3 IP address is the one for the VM. The destination port (`DPT`) 4 is `5000`, which is the Greeting web server. This temporary limit will block your local host IP address (`172.28.128.1`)2 from accessing port `5000` for about 30 seconds after the limit is reached. After that, you should be able to access it again. ## Summary In this chapter, you’ve learned how to implement a simple but effective host-based firewall for the VM. You can easily apply this firewall to any host you have, whether it is local or from a cloud provider. Creating firewall rules with Ansible that permit specific traffic to a VM while blocking other traffic is a typical setup a DevOps or software engineer would use. You also learned how to limit the number of connections a host can make in a given time frame. All of these techniques provide a smaller attack surface to help deter network attacks. You can do a lot more to enhance your host-based firewall, and I encourage you to explore the possibilities on your own by visiting [`help.ubuntu.com/community/UFW/`](https://help.ubuntu.com/community/UFW/). This brings Part I to an end. You now should have a good understanding of how to provision your infrastructure and apply some basic security foundations to your environment. In Part II, we’ll move on to containers, container orchestration, and deploying modern application stacks. We’ll start with installing and understanding Docker. ````
第二部分
容器化与现代应用的部署
第六章:使用 Docker 容器化应用程序

容器是基于容器镜像的运行实例。使用容器为您提供了一种可预测且隔离的方式来创建和运行代码。它允许您将应用程序及其依赖项打包成一个便于分发和运行的可移植工件。微服务架构和持续集成/持续开发流水线广泛使用容器,如果您是软件工程师或 DevOps 工程师,使用容器很可能已经改变了您交付和编写软件的方式。
在本章中,您将学习如何安装 Docker 引擎和docker客户端命令行工具。您还将快速了解 Dockerfile、容器镜像和容器的基础知识。您将结合这些知识以及一些基本的 Docker 命令,将我在本书存储库中提供的样例应用程序telnet-server容器化(github.com/bradleyd/devops_for_the_desperate/)。通过本章的学习,您将对如何使用 Docker 将任何应用程序容器化以及这样做的好处有深入的理解。
Docker 的高层视角
单词Docker已经成为容器运动的代名词。这是因为 Docker 的易用性,微服务架构的兴起,以及解决“在我的机器上可运行”的悖论的需要。容器的概念已经存在了相当长的时间,然而,存在许多容器框架。但自从 Docker 在 2013 年 3 月发布了第一个开源版本以来,该行业已经采纳了 Docker 框架作为事实上的标准。Docker 的第一个稳定版本(1.0)发布于 2014 年,自那时以来,新版本已包含了许多改进。
Docker 框架由一个 Docker 守护程序(服务器)、一个docker命令行客户端以及本书范围之外的其他工具组成。Docker 利用 Linux 内核特性来构建和运行容器。这些组件结合在一起使得 Docker 能够发挥其魔力:操作系统级虚拟化,将操作系统分割成看起来像是独立隔离的服务器,如图 6-1 所示。因此,当您需要在有限的硬件上运行大量应用程序时,容器非常有效。

图 6-1:操作系统级虚拟化
使用 Docker 入门
首先,你需要创建一个 Dockerfile,描述如何从应用程序构建 容器镜像。容器镜像由不同的层组成,这些层包含了你的应用程序、依赖关系以及应用程序运行所需的其他内容。容器镜像可以通过一个叫做 镜像库 的服务进行分发和提供。Docker 提供了最受欢迎的镜像库:hub.docker.com/。在这里,你几乎可以找到任何你需要的镜像,例如 Ubuntu 或 PostgreSQL 数据库。使用简单的 docker pull <image-name> 命令,你可以在几秒钟内下载并使用一个镜像。容器是基于容器镜像运行的应用实例。图 6-2 显示了 Docker 各个部分是如何协同工作的。在本章中,你将主要使用 docker 客户端。

图 6-2:Docker 框架
Dockerfile 指令
Dockerfile 包含了指导 Docker 服务器如何将应用程序转化为容器镜像的指令。每条指令代表一个特定的任务,并在容器镜像内创建一个新的层。以下列表包含了最常见的指令:
-
FROM指定从哪个父镜像或基础镜像构建新的镜像(必须是文件中的第一个命令) -
COPY将当前目录(Dockerfile 所在的目录)中的文件添加到镜像文件系统中的目标位置 -
RUN在镜像内执行命令 -
ADD将新的文件或目录从源位置或 URL 复制到镜像文件系统中的目标位置 -
ENTRYPOINT使容器像可执行文件一样运行(你可以把它当作任何在主机上接受参数的 Linux 命令行应用程序) -
CMD提供容器的默认命令或默认参数(可以与ENTRYPOINT配合使用)
请参考 Dockerfile 文档:docs.docker.com/engine/reference/builder/ 以获取指令和配置详情。
容器镜像和层
你构建的 Dockerfile 会创建一个容器镜像。这个镜像由不同的层组成,这些层包含了你的应用程序、依赖关系以及应用程序运行所需的其他内容。这些层就像是应用程序状态的时间快照,因此将 Dockerfile 与源代码一起保存在版本控制中,可以让你在每次应用程序代码更改时更容易构建新的容器镜像。
各个层就像乐高积木一样紧密结合。每一层,或称为中间镜像,都是在执行 Dockerfile 中的每一条指令时创建的。例如,每次使用 RUN 指令时,都会根据该指令的结果创建一个新的中间层。每个层(镜像)都会分配一个唯一的哈希值,并且所有层默认都会被缓存。这意味着你可以与其他镜像共享层,因此如果某个层没有发生变化,你就无需重新从头构建它。此外,缓存是你的好朋友,因为它可以减少构建镜像所需的时间和空间。
Docker 可以将这些层叠加在一起,因为它使用了联合文件系统(UFS),允许多个文件系统结合起来,创建出看似单一的文件系统。最上面的层是容器层,在你运行容器镜像时会添加这个层。它是唯一一个可以被写入的层。所有后续的层都是只读的,这是有意为之的。如果你在容器层做了任何文件或系统更改,然后删除运行中的容器,这些更改将会消失。底层的只读镜像会保持不变。这就是为什么容器在软件工程师中如此受欢迎:镜像是不可变的工件,可以在任何 Docker 主机上运行,并表现出相同的行为。
容器
Docker 容器是容器镜像的一个运行实例。在计算机编程术语中,可以将容器镜像视为一个类,而容器则是该类的实例。当容器启动时,容器层会被创建。这个可写层是所有更改(例如写入、删除和修改现有文件)发生的地方。
命名空间和控制组
容器还通过一些边界和有限视图将自己与 Linux 主机的其他部分隔离开来,这些被称为命名空间和控制组。它们是内核特性,限制了容器在主机上可以看到和使用的内容。它们还使操作系统级虚拟化成为现实。命名空间限制了容器的全局系统资源。如果没有命名空间,容器可能会自由地访问整个系统。想象一下,如果一个容器能够看到另一个容器中的进程。那个调皮的容器可能会杀死进程、删除用户,或卸载另一个容器中的目录。当你凌晨 2 点接到故障电话时,试着追踪这个问题!
常见的内核命名空间包括以下内容:
-
进程 ID(
PID)隔离进程 ID -
网络(
net)隔离网络接口栈 -
UTS 隔离主机名和域名
-
挂载(
mnt)隔离挂载点 -
IPC 隔离 SysV 样式的进程间通信
-
用户隔离用户和组 ID
然而,单独使用这些命名空间是不够的。你还需要控制容器使用的内存、CPU 和其他物理资源。这里就需要使用 cgroups。Cgroups 管理并衡量容器可以使用的资源。它们允许你为进程设置资源限制和优先级。Docker 通过 cgroups 设置的最常见资源是内存、CPU、磁盘 I/O 和网络。Cgroups 使得你可以防止容器占用主机的所有资源。
需要记住的主要要点是,命名空间限制了你可以看到的内容,而 cgroups 限制了你可以使用的内容。如果没有这些功能,容器将既不安全也不实用。
安装和测试 Docker
为了将一个示例应用程序容器化,你将首先借助minikube安装 Docker,它是一个包含 Docker 引擎并提供 Kubernetes 集群的应用(你将在下一章中使用该集群)。接下来,你将安装docker客户端,以便能够与 Docker 服务器进行通信。然后,你将配置环境,以便能够找到新的 Docker 服务器。最后,你将测试客户端连接性。
使用 Minikube 安装 Docker 引擎
要安装 minikube,请根据你的操作系统参考minikube.sigs.k8s.io/上的说明。如果你不是在 Linux 主机上,minikube 需要虚拟机管理器来安装 Docker。使用 VirtualBox 即可。
默认情况下,minikube 会根据它将要创建的虚拟机进行内存分配的最佳猜测。它还将 CPU 数量设置为 2 个,磁盘空间设置为 20GB。对于本书的目的,这些默认设置应该是合适的。
要使用资源默认值和 VirtualBox 作为虚拟机管理器启动 minikube,请在终端中输入以下命令:
$ **minikube start --driver=virtualbox**
`--snip--`
Done! kubectl is now configured to use "minikube"
Done!消息表示 minikube 启动成功。如果 minikube 启动失败,你应该检查输出中的错误信息。
安装 Docker 客户端并设置 Docker 环境变量
要安装docker客户端,请根据你的操作系统参考docs.docker.com/engine/install/binaries/上的说明。确保只下载并安装客户端二进制文件。你将使用 minikube 设置一些本地环境变量,包括 Docker 主机的 IP 地址和 Docker 主机 TLS 证书的路径,这些都是连接所需的。Bash 的eval命令会在你的终端中加载这些环境变量。
在终端中,输入以下命令来设置你的 Docker 环境变量:
$ **eval $(minikube -p minikube docker-env)**
如果命令成功执行,它应该没有任何输出。Docker 主机的环境变量应该已经在当前终端会话中导出。
当你关闭这个终端窗口时,环境变量将会丢失,每次想要与 Docker 服务器交互时,你都需要重新运行这个命令。为了避免这个不便,可以将该命令添加到你的 Shell 配置文件底部,比如/.bashrc*或*/.zshrc,这样每次打开终端窗口或标签页时,都会执行该命令。这样,你就不会看到Is the docker daemon running?的错误。
测试 Docker 客户端连接性
你应该测试docker客户端是否能与运行在 minikube 虚拟机中的 Docker 服务器通信。在你设置了环境变量的同一个终端中,输入以下命令来检查 Docker 版本:
$ **docker version**
如果连接成功,输出应该显示你的客户端和服务器版本。
容器化示例应用程序
我创建了一个名为telnet-server的示例应用程序,你可以用它来构建一个 Docker 容器。它是一个简单的 telnet 服务器,模仿了 1980 年代人们使用的公告板系统(BBS)。该应用程序使用 Go 编程语言编写,具有操作系统的可移植性和小巧的占用空间。你将使用一个包含 Go 及所有必要依赖项的 Alpine Linux 容器镜像。
要容器化一个应用程序,你需要源代码或二进制文件来运行容器内的程序,以及一个 Dockerfile 来构建容器镜像。示例应用程序的源代码和 Dockerfile 可以在本书的伴随仓库中找到,地址是github.com/bradleyd/devops_for_the_desperate/,位于telnet-server/文件夹下。
分析示例的 telnet-server Dockerfile
这个示例 Dockerfile 是一个多阶段 构建,包括两个独立的阶段:build和final。多阶段构建让你能够在一个 Dockerfile 中管理复杂的构建过程,同时提供了一种保持容器镜像小巧且安全的良好模式。在构建阶段,Dockerfile 指令会编译包含所有依赖的示例应用程序。在最终阶段,Dockerfile 指令会将构建产物(在这个例子中是编译后的示例应用程序)从构建阶段复制过来。最终的容器镜像会更小,因为它不包含构建阶段中的所有依赖或源代码。欲了解更多关于多阶段构建的信息,请访问docs.docker.com/develop/develop-images/multistage-build/。
导航到telnet-server/目录并打开 Dockerfile,文件内容应如下所示:
# Build stage
FROM golang:alpine AS build-env
ADD . /
RUN cd / && go build -o telnet-server
# Final stage
FROM alpine:latest AS final
WORKDIR /app
ENV TELNET_PORT 2323
ENV METRIC_PORT 9000
COPY –from=build-env /telnet-server /app/
ENTRYPOINT [″./telnet-server″]
文件通过FROM指令开始构建阶段,拉取golang:alpine父镜像。这个镜像来自 Docker Hub,它是一个预先构建的 Alpine Linux 镜像,专门用于 Go 语言的开发。该镜像阶段命名为build-env,使用AS关键字。这个名称引用稍后将在最终阶段再次使用。
ADD指令将当前本地telnet-server/目录中的所有 Go 源代码复制到镜像文件系统中的根目录(/)位置。
下一个RUN指令执行 shell 命令,导航到镜像文件系统中的根目录,并使用go build命令构建名为 telnet-server 的 Go 二进制文件。
最终阶段以FROM指令开始,重新拉取一个 Alpine Linux 镜像(alpine:latest)作为最终阶段的父镜像。不过,这次 Alpine Linux 镜像是应用程序运行的最小镜像,不包含任何依赖项。
WORKDIR指令设置应用程序的工作目录,在此示例中为/app。之后的任何CMD、RUN、COPY或ENTRYPOINT指令都将在该工作目录的上下文中执行。
两个ENV指令在容器镜像中设置环境变量,供应用程序使用:它们将 telnet 服务器设置为端口 2323,并将度量服务器端口设置为 9000。(稍后将详细讨论这些端口。)
COPY指令将 telnet-server 的 Golang 二进制文件从 build-env 阶段复制,并将其放置在最终阶段 Alpine 镜像中的工作app/目录中。
最后的ENTRYPOINT指令在容器启动时调用 telnet-server 二进制文件来执行示例应用程序。你将使用ENTRYPOINT而不是CMD,因为该应用程序在后续章节的容器测试中需要传递额外的标志。如果你需要覆盖容器中的默认命令,可以将ENTRYPOINT与CMD指令互换。有关CMD与ENTRYPOINT的更多信息,请参见 Dockerfile 参考文档:docs.docker.com/engine/reference/builder/。
构建容器镜像
接下来,你将使用刚才审阅的 Dockerfile 构建示例 telnet-server 应用程序的容器镜像。导航到telnet-server/目录并输入以下内容,将镜像名称和 Dockerfile 位置传递给 Docker:
$ **docker build -t dftd/telnet-server:v1** .
-t标志设置镜像的名称和(可选)标签,点(.)参数设置 Dockerfile 的当前位置。dftd/telnet-server:v1 URI 由三部分组成:注册表主机名(dftd)、镜像名称和标签。注册表是本地的 minikube,而不是在线的,因此你可以随意设置基本名称。(如果是远程注册表,你会使用类似 registry.example.com 的名称。)镜像名称位于正斜杠(/)和冒号(:)之间,设置为示例应用程序的名称,telnet-server。v1 镜像标签紧随冒号之后。
标签允许你识别每个镜像构建,并指示其中的变化。使用 Git 提交哈希作为标签是一种常见做法,因为每个哈希都是唯一的,可以标记镜像的源代码版本。如果你省略标签,Docker 会使用最新的词作为默认标签。
运行命令后,你应该看到类似这样的输出:
Sending build context to Docker daemon 13MB
Step 1/9 : FROM golang:alpine AS build-env
---> 6f9d081b1170
Step 2/9 : ADD . /
---> 3146d8206747
Step 3/9 : RUN cd / && go build -o telnet-server
---> Running in 3e05a0704b36
go: downloading github.com/prometheus/client_golang v1.6.0
go: downloading github.com/prometheus/common v0.9.1
go: downloading github.com/prometheus/client_model v0.2.0
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/cespare/xxhash/v2 v2.1.1
go: downloading github.com/golang/protobuf v1.4.0
go: downloading github.com/prometheus/procfs v0.0.11
go: downloading github.com/matttproud/golang_protobuf_extensions
v1.0.1 1 # Build stage
go: downloading google.golang.org/protobuf v1.21.0
go: downloading golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f
Removing intermediate container 3e05a0704b36
---> 96631440ea5d
Step 4/9 : FROM alpine:latest AS final
---> c059bfaa849c
Step 5/9 : WORKDIR /app
---> Running in ddc5b73b1712
Removing intermediate container ddc5b73b1712
---> 022bcbba3b94
Step 6/9 : ENV TELNET_PORT 2323
---> Running in 21bd3d15f50c
Removing intermediate container 21bd3d15f50c
---> 30d0284cade4
Step 7/9 : ENV METRIC_PORT 9000
---> Running in 8f1fc01b04d5
Removing intermediate container 8f1fc01b04d5
---> adfd026e1c27
Step 8/9 : COPY --from=build-env /telnet-server /app/
---> fd933cd32a94
Step 9/9 : ENTRYPOINT ["./telnet-server"]
---> Running in 5d8542e950dc
Removing intermediate container 5d8542e950dc
---> f796da88ab94
**Successfully built f796da88ab94**
**Successfully tagged dftd/telnet-server:v1**
每个指令都会被记录,允许你按顺序跟踪构建过程。在构建结束时,应该列出镜像 ID(f796da88ab94),随后会有一条说明,表示镜像成功标记为 dftf/telnet-server:v1。你看到的镜像 ID 会不同。
如果你的 docker build 没有成功,你需要解决输出中的任何错误,因为接下来你将基于这个镜像进行构建。常见错误包括 RUN 执行中的拼写错误和使用 COPY 指令时缺少文件。
验证 Docker 镜像
接下来,验证 minikube 内的 Docker 仓库是否存储了 telnet-server 镜像。(如前所述,仓库是存储并提供容器镜像的服务器。)
在终端中输入以下命令来列出 Docker 的 telnet-server 镜像:
$ **docker image ls dftd/telnet-server:v1**
REPOSITORY TAG IMAGE ID CREATED SIZE
dftf/telnet-server v1 f796da88ab94 1 minute ago 16.8MB
注意,最终的 telnet-server 镜像只有 16.8MB。最终阶段的 Alpine Linux 基础镜像在添加 telnet-server 应用之前大约为 5MB。
运行容器
下一步是从你刚刚构建的镜像创建并运行 telnet-server 容器。通过输入以下命令来完成:
$ **docker run -p 2323:2323 -d --name telnet-server dftd/telnet-server:v1**
9b4b719216a1664feb096ba5a67c54907268db781a28d08596e44d388c9e9632
-p(端口)标志暴露了容器外部的端口 2323。(telnet-server 应用需要开放端口 2323。)冒号(:)左侧是主机端口,右侧是容器端口。如果你有另一个应用程序正在监听相同端口,并且需要为主机更改端口,同时保持容器端口不变,这很有用。-d(分离)标志将在后台启动容器。如果你没有提供 -d 标志,容器将在启动它的终端前台运行。--name 标志将容器名称设置为 telnet-server。默认情况下,如果你没有设置,Docker 会为容器随机分配名称。最后一个参数是构建步骤中的镜像名称,包含路径和标签。
容器现在在后台运行并准备接受流量。这个 docker run 命令成功执行,因为它返回了容器 ID(一长串数字和字母,对于你来说会不同),且没有出现错误。
输入以下命令验证容器是否实际正在运行:
$ **docker container ls -f name=telnet-server**
可选的筛选标志(-f)将输出限制为你指定的容器。如果省略筛选标志,运行该命令将列出主机上所有正在运行的容器。
如果容器正在运行,输出应该如下所示:
CONTAINER ID IMAGE COMMAND ... PORTS NAMES
9b4b719216a1 dftd/... "./telnet-.." ... 0.0.0.0:2323->2323/tcp telnet-server
CONTAINER ID列匹配先前docker run命令接收到的 ID 的前 12 位数字。IMAGE列包含构建容器镜像时给定的镜像 ID。PORTS列显示端口2323在每个接口(0.0.0.0)上公开,并将流量映射到容器内部的端口2323。方向箭头(->)表示流量流向。最后,NAMES列显示之前从run命令设置的 telnet-server 名称。
现在,在终端中输入以下内容停止容器:
$ **docker container stop telnet-server**
telnet-server
容器名称应该被返回,让您知道 Docker 守护程序认为容器已停止。要重新启动容器,将 stop 与 start 交换,并且您应该再次看到容器名称返回。
Docker 不会检查应用程序在启动后是否保持运行状态。只要容器能够启动并且不会立即出错,输入 docker start 或 docker run 将返回容器名称,就像一切正常一样。这可能会产生误导。您应该执行健康检查并监视应用程序,以验证其实际运行情况(我们将在未来的章节中探讨这些主题)。
其他 Docker 客户端命令
让我们看看在处理容器时需要使用的几个常见 Docker 命令。
exec
exec命令允许您在容器内部运行命令或与容器交互,就像您登录到终端会话中一样。例如,如果您正在容器中调试应用程序并希望验证是否正确设置了环境变量,您可以在终端中运行以下命令输出所有环境变量:
$ **docker exec telnet-server env**
TELNET_PORT=2323
HOSTNAME=c8f66b93424a
SHLVL=1
HOME=/root
TERM=xterm
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PWD=/app
METRIC_PORT=9000
env命令在容器内部使用操作系统的默认 shell 执行。执行完毕后,输出将发送回终端。
exec命令还允许您访问正在运行的容器以进行故障排除或运行命令。您需要传递交互标志(-i)和伪 TTY 标志(-t),以及 shell 命令(/bin/sh)来执行此操作。交互标志保持 STDIN 打开,因此您可以在容器层内输入命令。伪 TTY 标志模拟终端,与交互标志结合使用时,模拟在容器内部进行实时终端会话。除了 Linux 外,其他操作系统将使用不同的 shell,最常见的是 /bin/sh 和 /bin/bash。Alpine Linux 默认使用 /bin/sh 作为其默认 shell。
在终端中输入以下内容以获取容器内部的 shell:
$ **docker exec -it telnet-server /bin/sh**
/app # ls
telnet-server
/app #
发出 ls 命令以显示您已构建的容器内部。 (您先前将工作目录设置为 app/ 并将 telnet-server 二进制文件放在其中。)输入 exit 命令并按 Enter 键以离开容器并返回本地终端。
rm
rm命令删除已停止的容器。例如,一旦 telnet-server 容器停止,输入以下内容在终端中删除它:
$ **docker container rm telnet-server**
telnet-server
删除的容器名称应该被返回。您可以使用-f(强制)标志来删除正在运行的容器,但最好先停止它。
inspect
inspect docker命令返回有关某些 Docker 对象的低级信息。默认情况下,输出是 JSON 格式的。根据 Docker 对象的不同,结果可能很冗长。
要检查 telnet-server 容器,请在终端中输入以下内容:
$ **docker inspect telnet-server**
[
{
"Id": "c8f66b93424a3dac33415941e357ae9eb30567a3d64d4b5e87776701ad8274c5",
"Created": "2022-02-16T03:35:44.777190911Z",
"Path": "./telnet-server",
"Args": [],
"State": { 1
"Status": "running",
"Running": true,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 19794,
"ExitCode": 0,
"Error": "",
"StartedAt": "2022-02-16T03:35:45.230788473Z",
"FinishedAt": "0001-01-01T00:00:00Z"
},
`--snip--`
"NetworkSettings": { 2
"Bridge": "",
"HairpinMode": false,
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"Ports": {
"2323/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "2323"
}
]
},
"Gateway": "172.17.0.1",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAddress": "172.17.0.5",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"MacAddress": "02:42:ac:11:00:05",
`--snip--`
State部分 1 包含有关运行中的容器的数据,如Status和StartedAt日期。NetworkSettings部分 2 提供像Ports和IPAddress这样的信息,这些在排除容器问题时非常有用。
history
history命令显示容器镜像的历史,对于查看镜像层的数量和大小很有用。
要查看 telnet-server 镜像的层,请在终端中输入以下内容:
$ **docker history dftd/telnet-server:v1**
IMAGE CREATED CREATED BY SIZE COMMENT
cb5a2baff085 20 hours ago /bin/sh -c #(nop) ENTRYPOINT ["./telnet-ser... 0B
a826cfe49c09 20 hours ago /bin/sh -c #(nop) COPY file:47e9acb5fa56759e... 13MB
a9a45301f95b 5 days ago /bin/sh -c #(nop) ENV METRIC_PORT=9000 0B
001a12a073c2 5 days ago /bin/sh -c #(nop) ENV TELNET_PORT=2323 0B
379892a150e3 6 days ago /bin/sh -c #(nop) WORKDIR /app 0B
f70734b6a266 3 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 3 weeks ago /bin/sh -c #(nop) ADD file:b91adb67b670d3a6f... 5.61MB
输出(已编辑)显示了启动每个层的指令,如COPY和ADD。它还显示了各层的年龄和大小。
stats
stats命令显示容器正在使用的资源的实时更新。它从 cgroups 收集这些信息,行为类似于 Linux 的top命令。如果您有一个管理多个容器的主机,并希望查看哪个容器占用资源,使用stats命令。一旦运行stats命令,它会将您带入一个每隔几秒更新一次的页面。由于这在书中无法展示,我们将传递--no-stream标志以拍摄资源快照并立即退出。
输入以下命令以显示 telnet-server 容器的资源使用情况:
$ **docker stats --no-stream telnet-server**
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
c8f66b93424a telnet-server 0.00% 2.145MiB/5.678GiB 0.04% 0B / 0B 0B / 0B 7
这个 telnet-server 容器几乎没有使用 CPU、磁盘或网络 I/O,只有 2MiB 的内存。您可以轻松地在单个服务器的云环境中运行数百个这样的容器。
访问docs.docker.com/engine/reference/commandline/cli/以探索所有docker命令行客户端的命令和标志。
测试容器
为了确定您容器化的示例应用程序是否实际运行,您将连接到端口2323上的 telnet-server 并运行一些基本命令。然后,您将查看容器日志,以验证应用程序是否正常工作。
然而,在执行这些步骤之前,您需要为您的操作系统安装一个telnet客户端,以便与 telnet-server 通信。如果您使用的是 macOS,只需在终端中输入brew install telnet。如果您使用的是 Ubuntu,请以特权用户身份在终端中输入apt install telnet。
连接到 Telnet-Server
要连接到服务器,向 telnet 传递服务器的主机名或 IP 地址以及要连接的端口。由于 Docker 服务器运行在虚拟机(minikube)内部,因此您需要使用 minikube 暴露给本地主机的 IP 地址。
在终端中输入以下内容以获取 IP 地址:
$ **minikube ip**
192.168.99.103
我的 minikube IP 地址是 192.168.99.103;您的可能不同。
要连接到容器内运行的 telnet-server,使用 IP 地址(192.168.99.103)和端口(2323)传递给telnet命令:
$ **telnet 192.168.99.103 2323**
Trying 192.168.99.103...
Connected to 192.168.99.103.
Escape character is '^]'.
____________ ___________
| _ \ ___|_ _| _ \
| | | | |_ | | | | | |
| | | | _| | | | | | |
| |/ /| | | | | |/ /
|___/ \_| \_/ |___/
>
成功了!DFTD 的 ASCII 文本横幅应该会以它的全貌欢迎你。你现在已经连接到了 telnet-server 应用程序。提示符(>)是你可以输入命令的地方。首先,你只能输入date、help、yell和quit命令。你可以使用这些命令的首字母作为快捷方式,且你输入的任何命令都会被记录。
在仍然连接到 telnet-server 的情况下,输入以下命令以打印当前的日期和时间:
>**d**
Tue May 10 22:55:13 +0000 UTC 2022.
太好了!当前的日期和时间应该会显示出来。根据你的年龄,这可能会唤起你对波特率和高频尖叫声的回忆。
输入以下命令退出 telnet-server 会话:
>**q**
Good Bye!
Connection closed by foreign host.
你应该能看到 telnet-server 会话会很友好地向你告别。现代互联网,我看你能做什么!
你可以在telnet-server/telnet/server.go文件中添加新命令或更改响应。如果这样做,别忘了使用你在本章之前学到的命令来构建、停止并替换镜像和容器。
从容器获取日志
Docker 提供了一种简单的方法来获取正在运行的容器的日志。这对于故障排除和取证非常重要。
要查看 telnet-server 的所有日志(日志输出到 STDOUT),在终端中输入以下命令:
$ **docker logs telnet-server**
telnet-server: 2022/01/04 19:38:22 telnet-server listening on [::]:2323
telnet-server: 2022/01/04 19:38:22 Metrics endpoint listening on :9000
telnet-server: 2022/01/04 19:38:32 [IP=192.168.99.1] New session
telnet-server: 2022/01/04 19:38:43 [IP=192.168.99.1] Requested command: d
telnet-server: 2022/01/04 19:38:44 [IP=192.168.99.1] User quit session
输出的前两行是启动消息,显示服务器正在运行并监听特定的端口。(我们将在第九章讨论监控应用时探讨度量服务器。)第四行日志来自你在 telnet 会话中输入d命令来打印当前日期和时间。第五行日志显示你输入q命令退出测试 telnet 会话的时刻。
总结
如果你是软件工程师或 DevOps 工程师,你需要对当今基础设施中的容器有深入的理解。在本章中,你探索了 Docker 如何通过操作系统级别的虚拟化使容器成为可能。你研究了 Dockerfile 如何工作以创建容器镜像的层,并将这一知识应用于使用多阶段构建构建一个示例容器镜像。最后,你启动了一个由提供的 telnet-server 镜像创建的容器,测试它是否正常工作,并检查了其日志。在下一章中,你将把你在这里构建的 telnet-server 镜像运行在 Kubernetes 集群中。
第七章:使用 Kubernetes 进行编排

容器使得应用程序变得便捷和一致,但它只是现代应用程序堆栈中的一部分。试想需要管理成千上万的容器,分布在不同的主机、网络端口和共享卷上。如果某个容器停止了怎么办?如何应对负载的扩展?如何强制容器在不同主机上运行以提高可用性?容器编排解决了所有这些问题以及更多问题。Kubernetes,或称K8s,是许多公司用来管理容器的开源编排系统。Kubernetes 自带一些有用的模式(如网络、基于角色的访问控制和版本化的 API),但它的目的是作为一个基础框架,在此基础上构建你独特的基础设施和工具。Kubernetes 是容器编排的标准。你可以将它视为你的基础设施中的一个低级组件,就像 Linux 一样。
在本章中,你将学习一些基本的 Kubernetes 资源和与容器编排相关的概念。为了实践编排,你将使用kubectl命令行客户端,在你的 Kubernetes 集群中部署第六章中的 telnet-server 容器镜像。
从 30,000 英尺的高度看 Kubernetes
Kubernetes(在希腊语中意为舵手)是由 Google 的 Borg 和 Omega 系统演变而来。它于 2014 年开源,并自那时起获得了广泛的社区支持和多次增强。
一个 Kubernetes 集群由一个或多个控制平面节点和一个或多个工作节点组成。节点可以是任何类型的计算资源,从云虚拟机到裸金属服务器,再到树莓派。控制平面节点处理像 Kubernetes API 调用、集群状态以及容器调度等任务。核心服务(如 API、etcd 和调度器)运行在控制平面上。工作节点运行由控制平面调度的容器和资源。更多细节见图 7-1。

图 7-1:Kubernetes 集群的基本构建模块
网络和调度是你在编排容器时遇到的最复杂的问题。在容器网络配置时,你必须考虑所有需要的端口和访问权限。容器可以在集群内外相互通信。这发生在微服务的内部通信中,或者在运行面向公众的 Web 服务器时。当调度容器时,你必须考虑当前系统资源和任何特殊的部署策略。你可以针对特定的用例调整工作节点,例如高连接数,然后创建规则以确保需要该功能的应用程序最终会部署到该特定工作节点。这被称为节点亲和性。作为一个容器编排器,你还需要限制用户认证和授权。你可以使用像基于角色的访问控制这样的方式,它允许容器在安全和受控的方式下运行。这些方法仅代表了你需要的复杂“胶水”和“布线”的一小部分。成功部署和管理容器需要一个完整的框架。
Kubernetes 工作负载资源
资源是封装状态和意图的一种对象类型。为了让这个概念更清晰一点,我们可以用汽车做一个类比。如果运行在 Kubernetes 上的工作负载是一辆车,那么资源就描述了车的各个部分。例如,你可以设置你的车有两个座位和四个车门。你不需要了解如何制造座位或车门,你只需要知道 Kubernetes 会维持这两个部件的数量(不多也不少)。Kubernetes 资源在一个叫做manifest的文件中定义。在本章中,我们将交替使用资源和对象这两个术语。
让我们来看看现代应用栈中最常用的 Kubernetes 资源。
Pods
Pods是 Kubernetes 中最小的构建模块,它们构成了你与容器进行各种操作的基础。一个 Pod 由一个或多个共享网络和存储资源的容器组成。每个容器都可以连接到其他容器,所有容器可以通过挂载的卷共享一个目录。你不会直接部署 Pods;相反,它们会被集成到像 ReplicaSet 这样的更高层抽象中。
ReplicaSet
ReplicaSet资源用于维护固定数量的相同 Pods。如果一个 Pod 被终止或删除,ReplicaSet 会创建另一个 Pod 来替代它。如果你需要创建自定义的编排行为,才会使用 ReplicaSet。通常,你应该选择 Deployment 来管理你的应用。
Deployments
Deployment 是一种资源,用于管理 Pods 和 ReplicaSets。它是管理应用程序时最常用的资源。Deployment 的主要任务是维持其清单中配置的状态。例如,你可以定义 Pods 的数量(在这种情况下称为 replicas)以及部署新 Pods 的策略。Deployment 资源控制 Pod 的生命周期——从创建、更新、扩展到删除。如果需要,你还可以回滚到早期版本的 Deployment。当你的应用程序需要长期运行且具备容错能力时,Deployment 应该是你的首选。
StatefulSets
StatefulSet 是用于管理有状态应用程序(如 PostgreSQL、ElasticSearch 和 etcd)的一种资源。类似于 Deployment,它可以管理清单中定义的 Pods 的状态。但它还增加了管理唯一 Pod 名称、Pod 创建和终止顺序等功能。StatefulSet 中的每个 Pod 都有自己的状态和绑定的数据。如果你要将有状态应用程序添加到集群中,选择 StatefulSet 而不是 Deployment。
Services
Services 允许你将运行在 Pod 或一组 Pods 中的应用程序暴露到 Kubernetes 集群内部或互联网上。你可以选择以下基本 Service 类型:
-
ClusterIP是创建 Service 时的默认类型。它会分配一个内部可路由的 IP 地址,并将连接代理到一个或多个 Pods。你只能从 Kubernetes 集群内部访问ClusterIP。 -
Headless不会创建单一的 Service IP 地址,也不会进行负载均衡。 -
NodePort通过节点的 IP 地址和端口来暴露 Service。 -
LoadBalancer将 Service 暴露到外部。它可以通过使用云服务提供商的组件,如 AWS 的 Elastic Load Balancing (ELB),或者使用裸金属解决方案,如 MetalLB,来实现这一点。 -
ExternalName将一个 Service 映射到externalName字段中的内容,并创建一个其值的CNAME记录。
你将最常使用 ClusterIP 和 LoadBalancer。请注意,只有 LoadBalancer 和 NodePort 类型的 Service 才能将 Service 暴露到 Kubernetes 集群外部。
Volumes
Volume 本质上是一个目录或文件,Pod 中的所有容器都可以访问,但有一些限制。Volumes 提供了一种容器之间共享和存储数据的方式。如果 Pod 中的一个容器被杀死,Volume 和其数据将存活;如果整个 Pod 被杀死,Volume 及其内容将被移除。因此,如果你需要的存储与 Pod 的生命周期无关,请为你的应用程序使用 Persistent Volume (PV)。PV 是集群中的一种资源,就像节点一样。Pods 可以使用 PV 资源,但 PV 不会随着 Pod 的终止而结束。如果你的 Kubernetes 集群运行在 AWS 上,你可以使用 Amazon Elastic Block Storage (Amazon EBS) 作为你的 PV,这使得 Pod 故障更容易恢复。
Secrets
秘密是一个方便的资源,用于安全可靠地与 Pods 共享敏感信息(如密码、令牌、SSH 密钥和 API 密钥)。你可以通过环境变量或作为 Pod 内的卷挂载来访问秘密。秘密存储在 Kubernetes 节点的内存文件系统中,直到 Pod 请求它们。当 Pod 不使用时,它们会保存在内存中,而不是磁盘上的文件中。然而,要小心,因为秘密清单要求数据以 Base64 编码的形式存储,这并不是一种加密形式。
使用秘密,敏感信息与应用程序分开存储。这是因为这类信息在持续集成/持续部署过程中比存储在资源中时更容易暴露。你仍然需要通过使用 RBAC 来保护你的秘密清单,限制对 Secrets API 的广泛访问。你还可以将敏感数据加密存储在秘密中,并通过其他过程在 Pod 挂载或需要时解密它。另一个选项是在将其添加到版本控制之前先在本地加密清单。无论选择哪种方法,确保你有一个安全的计划来存储 Secrets 中的敏感信息。
配置映射
配置映射允许你将非敏感的配置文件挂载到容器内。Pod 中的容器可以通过环境变量、命令行参数,或作为卷挂载中的文件访问配置映射。如果你的应用有配置文件,将其放入配置映射清单提供了两个主要的好处。首先,你可以更新或部署新的清单文件,而不需要重新部署整个应用。其次,如果你的应用程序监控配置文件的变化,那么当配置文件更新时,应用程序将能够重新加载配置,而不必重启。
命名空间
命名空间资源允许你将一个 Kubernetes 集群划分为多个较小的虚拟集群。当设置了命名空间时,它提供了资源的逻辑分离,尽管这些资源可以位于相同的节点上。如果在创建资源时没有指定命名空间,它将继承巧妙地命名为default的命名空间。如果你的团队有很多用户,并且项目分布广泛,你可能会将这些团队或应用分割到不同的命名空间中。这样可以轻松地对这些资源应用安全权限或其他约束。
部署示例 telnet-server 应用
为了开始探索 Kubernetes,你将创建一个 Deployment 和两个 telnet-server 应用的服务。我选择了 Deployment 来为你的应用提供容错性。这两个服务将暴露 telnet-server 应用的端口和应用的指标端口。在本节结束时,你将拥有一个 Kubernetes Deployment,其中包含两个运行 telnet-server 应用的 Pod(副本),并且可以从本地主机访问。
与 Kubernetes 的交互
在你部署你的 telnet-server 应用程序之前,你需要确保可以连接到你的 Kubernetes 集群。与集群交互的最直接方式是使用kubectl命令行工具,你可以通过两种方式获得它。第一种方式是从kubernetes.io/docs/tasks/tools/install-kubectl/下载适合你操作系统的独立二进制文件。第二种方式,就是你将在这里使用的方式,即利用 minikube 内置的kubectl支持。每当你第一次调用minikube kubectl命令时(如果还没有安装),minikube 会为你下载kubectl二进制文件。
使用minikube kubectl时,大多数命令需要在minikube kubectl和子命令之间加上双短横线(--)。然而,独立版本的kubectl则不需要命令之间的短横线。如果你已经安装了kubectl,或者希望使用独立版本,只需去掉接下来所有示例中的minikube前缀和双短横线。
让我们从一个简单的命令开始,这样 minikube 就可以下载kubectl二进制文件并测试集群的访问。使用cluster-info子命令来验证集群是否已启动并正常运行:
$ **minikube kubectl cluster-info**
Kubernetes master is running at https://192.168.99.109:8443
`--snip--`
你应该能看到类似的输出,表明你能够连接到 Kubernetes 集群。如果与集群的通信有问题,你可能会看到类似 "The control plane node must be running for this command" 的错误。如果发生这种情况,输入minikube status命令,确保 minikube 仍然正常运行。
查看清单
现在你已经可以访问集群,查看提供的部署(Deployment)和服务(Services)清单。Kubernetes 清单是用于描述应用程序和服务期望状态的文件。它们管理诸如部署(Deployments)、Pod 和 Secrets 等资源。这些文件可以是 JSON 格式或 YAML 格式;我们在本书中使用 YAML 格式,纯粹是出于个人喜好。清单文件应当放在源代码管理下。你通常会发现这些文件与它们描述的应用程序共同存放。
我已提供清单文件来创建 telnet-server 部署和两个服务。这些文件位于仓库 github.com/bradleyd/devops_for_the_desperate/ 中。导航到 telnet-server/ 目录,并列出 kubernetes/ 子目录中的文件。你应该能找到两个文件。第一个文件,deployment.yaml,创建了一个包含两个 telnet-server 容器镜像 Pod 的 Kubernetes 部署。第二个文件,service.yaml,创建了两个独立的服务。第一个服务创建了一个 LoadBalancer,这样你就可以从 Kubernetes 集群外部连接到 telnet-server。另一个服务创建了一个 ClusterIP,它暴露了集群内部的度量端点。不要担心本章中的度量端口——我们将在第九章讨论监控和度量时使用它。
这些清单文件可能相当冗长,所以我们将重点关注每个文件包含的基本结构。为了描述一个复杂对象,你需要多个字段、子字段和数值来定义资源的行为。由于这个原因,从头开始编写清单可能会很困难。在所有这些字段和数值中,有一部分必需字段称为 顶级字段。这些字段在所有清单文件中都是通用的。理解顶级字段使得记住和解析清单文件变得更容易。四个顶级字段如下:
-
apiVersion这是一个 Kubernetes API 版本和组,例如 apps/v1。Kubernetes 使用版本化的 API 和组来提供不同版本的功能和资源支持。 -
kind这是你想要创建的资源类型,例如 Deployment。 -
metadata这是你设置诸如名称、注解和标签之类内容的地方。 -
spec这是你设置资源(类型)所需行为的地方。
这些顶级字段中的每一个都包含多个子字段。子字段包含诸如名称、副本数量、模板和容器镜像等信息。例如,metadata 包含 name 和 labels 子字段。每个 Kubernetes 资源的字段格式可能有所不同。我不会描述每个字段,但我会经常使用 labels 子字段。标签为用户提供了一种方式,可以通过可识别的键值对标记资源。例如,你可以为所有处于 生产环境 中的资源添加标签。
`--snip--`
metadata:
labels:
environment: production
`--snip--`
你可以使用这些 labels 来缩小搜索结果并将类似的应用程序分组在一起,比如前端网站和其后端数据库对应的应用。稍后,当你调用 minikube kubectl 命令时,你会使用 labels。
列出清单文件中所有不同的字段结构会占用大量空间。相反,你可以在两个不同的地方探索文档。Kubernetes 文档在kubernetes.io/docs/concepts/overview/working-with-objects/中描述了所有资源并提供了示例。第二个探索的地方,是我最喜欢的,kubectl的explain子命令。explain子命令描述了与每个资源类型相关的字段。你可以在查找嵌套字段时使用点(.)符号作为字段分隔符。例如,要了解更多关于部署的metadata labels子字段的信息,可以在终端输入以下内容:
$ **minikube kubectl -- explain deployment.metadata.labels**
KIND: Deployment
VERSION: apps/v1
FIELD: labels <map[string]string>
DESCRIPTION:
Map of string keys and values that can be used to organize and categorize
(scope and select) objects. May match selectors of replication
controllers and services. More info:
http://kubernetes.io/docs/user-guide/labels
请注意,示例首先搜索资源类型,然后是其顶级字段,再然后是子字段。
检查 telnet-server 部署
现在你已经了解了清单文件的构建块,让我们将所学应用到 telnet-server 部署的清单中。我将deployment.yaml文件拆分为几个部分,以便更容易解析。文件顶部的第一部分包含了apiVersion、kind和metadata字段:
apiVersion: apps/v1
kind: Deployment
metadata:
name: telnet-server
labels:
app: telnet-server
`--snip--`
类型(kind)是Deployment,使用 Kubernetes API 组apps和 API 版本v1。在metadata字段下,部署的name设置为telnet-server,labels设置为app: telnet-server。你将在稍后查找 telnet-server 部署时使用这个标签。
文件的下一部分包含父级spec字段,它描述了部署的行为和规范。spec字段包含许多子字段和数值:
`--snip--`
spec:
replicas: 2
selector:
matchLabels:
app: telnet-server
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
`--snip--`
首先,spec描述了部署的replicas数量;它被设置为2,以反映你希望运行的 Pods 数量。在selector字段内,matchLabels定位此部署将影响的 Pods。在matchLabels中使用的键值必须与 Pod 的模板标签匹配(稍后会详细讲解)。
strategy字段描述了在滚动更新过程中如何用新 Pods 替换当前正在运行的 Pods。这个示例使用了RollingUpdate,它会一次替换一个 Pod。这是部署的默认策略。另一种策略选项,Recreate,会在创建新 Pods 之前先终止当前正在运行的 Pods。
maxSurge 和 maxUnavailable 键控制创建和终止 Pods 的数量。在这里,它被设置为在滚动更新期间启动一个额外的 Pod,这会暂时使 Pod 数量达到 replicas + 1(在此例中为三)。一旦新的 Pod 启动并运行后,一个旧的 Pod 会被终止。然后,这个过程会重复,直到所有新的 Pods 都在运行,旧的 Pods 被终止。这些设置将确保在 Deployment 期间始终有一个 Pod 来处理流量。有关策略的更多信息,请参见 kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy/。
spec 部分的下一部分是 template 字段。这个字段(以及它的子字段)描述了这个 Deployment 将要创建的 Pods。此部分中的主要子字段是 metadata 和 spec:
`--snip--`
template:
metadata:
labels:
app: telnet-server
spec:
containers:
- image: **dftd/telnet-server:v1**
imagePullPolicy: IfNotPresent
name: telnet-server
resources:
requests:
cpu: 1m
memory: 1Mi
limits:
cpu: 500m
memory: 100Mi
ports:
- containerPort: **2323**
name: telnet
- containerPort: **9000**
name: metrics
在这里,app: telnet-server 键值对被添加到 Deployment 中每个 Pod 上,使用的是 template 下的 labels 子字段以及 metadata。app: telnet-server 标签与你在 spec 的 selector: 字段中之前使用的键值匹配。(你稍后在查找 Pods 时会再次使用这个标签。)
containers 字段设置了 Pod 中第一个容器的容器镜像。在这个例子中,它被设置为你在第六章中构建的 dftd/telnet-server:v1 镜像。这个容器的 name 是 telnet-server,就像在 Deployment 中一样。使用相同的 name 并不是必需的;name 可以是任何你选择的字符串,只要它在 Pod 中的容器之间是唯一的。
containers 下的下一个子字段是 resources,它控制容器的 CPU 和内存。你可以为每个容器单独定义 requests 和 limits。requests 用于 Kubernetes 调度(编排)和整体应用健康。如果一个容器启动时至少需要 2GB 内存和一个 CPU,你肯定不希望 Kubernetes 将该 Pod(容器)调度到内存只有 1GB 或没有可用 CPU 的节点上。requests 是应用程序所需的最小资源。另一方面,limits 控制容器在节点上可使用的最大 CPU 和内存。你不希望一个容器占满节点的所有内存或 CPU,而让其他容器无法获得资源。在这个例子中,CPU 限制被设置为 500m(millicpu),即半个 CPU。这个单位也可以用小数表示,如 0.5。在 Kubernetes 中,一个 CPU 等于一个 CPU 核心。memory 限制被设置为 100Mi,即 104,857,600 字节。在 Kubernetes 中,memory 是以字节为单位表示的,但你也可以使用更常见的单位,如 M、Mi、G 和 Gi。当这些 limits 被设置后,如果 telnet-server 容器使用超过 100Mi 的 memory,Kubernetes 会终止它。然而,如果超出了 CPU 限制(500m),Kubernetes 并不会直接终止容器。它会限制该容器的 CPU 请求时间。有关 Kubernetes 如何量化资源的更多信息,请参见 kubernetes.io/docs/concepts/configuration/manage-resources-containers/。
容器的 ports 字段设置了你希望暴露的端口。在这个例子中,暴露了端口 2323(telnet)和 9000(metrics)。这些端口定义仅供参考,不影响容器是否能接收流量。它们只是让用户和 Kubernetes 知道容器预计会监听哪些端口。
检查 telnet-server 服务
下一个需要检查的清单是 Service 资源。service.yaml 文件创建了两个独立的 Services:一个暴露 telnet-server,另一个暴露应用程序的度量指标。我们这里只查看 telnet Service 和一些具体字段,因为 metric Service 与其几乎相同:
apiVersion: v1
kind: Service
metadata:
labels:
app: telnet-server
name: telnet-server
spec:
ports:
- port: 2323
name: telnet
protocol: TCP
targetPort: 2323
selector:
app: telnet-server
type: LoadBalancer
`--snip--`
在 kind 字段中设置了一个 Service 资源,这与前面展示的 Deployment 清单不同。Service 的 name 可以是任何名称,但必须在 Kubernetes 命名空间内唯一。为了方便使用,我保持了 names 与其他资源一致。我还使用了相同的 app: telnet-server 标签,以使查找更加统一和简单。
ports 字段告诉 Service 应该暴露哪个端口,并如何将其连接到 Pods。它暴露端口 2323(telnet),并将所有流量转发到 Pod 上的端口 2323。
就像部署的selector字段一样,Service也使用selector字段来查找要转发流量的 Pod。此示例使用熟悉的 Pod 标签app: telnet-server作为selector的匹配条件,这意味着所有带有app: telnet-server标签的 Pod 将接收来自该Service的流量。如果有多个 Pod,就像在部署中一样,流量将以轮询方式发送到所有 Pod。由于telnet-server应用程序的目标是将其暴露在集群外部,因此它被设置为LoadBalancer。
创建部署和服务
现在是创建部署和服务的时候了。要将示例应用程序转化为 Kubernetes 部署,你需要使用minikube kubectl命令行工具以及你刚刚查看过的清单文件(github.com/bradleyd/devops_for_the_desperate/)。
要创建和更新资源,你可以将minikube kubectl命令传递两个子命令:create和apply。create子命令是命令式的,这意味着它会根据清单文件重新创建资源。如果资源已存在,它还会抛出错误。apply子命令是声明式的,这意味着如果资源不存在,它将创建资源,如果资源已存在,则更新它。对于这个场景,你将使用apply命令并带上-f标志,指示kubectl对kubernetes/目录中的所有文件执行操作。-f标志也可以接受文件名代替目录。
在telnet-server/目录内,输入以下命令来创建Deployment和两个Services:
$ **minikube** **kubectl -- apply -f kubernetes/**
deployment.apps/telnet-server created
service/telnet-server-metrics created
service/telnet-server created
输出应显示所有三个资源已被创建。如果在此命令中出现错误,请务必调查错误的原因。你可能看到的常见错误通常是由于 YAML 文件中的语法错误或拼写错误导致的。
查看部署和服务
一旦创建了telnet-server部署和服务,你需要了解如何找到它们。Kubernetes 提供了多种查看对象状态的方法。最简单的方法是使用minikube kubectl -- get <``resource``> <``name``>命令。
你可以通过获取部署状态来开始,首先按名称获取部署状态,然后再查看服务。输入以下命令获取telnet-server的Deployment状态:
$ **minikube kubectl -- get deployments.apps telnet-server**
NAME READY UP-TO-DATE AVAILABLE AGE
telnet-server 2/2 2 2 7s
输出应显示telnet-server部署有两个副本(Pod)在运行(2/2 READY),并且它们已经运行了七秒钟(7s AGE)。这应该与部署清单中设置的副本数相匹配。UP-TO-DATE和AVAILABLE列分别显示了为达到期望副本数(2)而更新的 Pod 数量,以及可供用户访问的 Pod 数量(2)。在这种情况下,Kubernetes 认为该部署已经启动并且完全可用。
你还可以运行minikube kubectl get pods命令来查找部署是否准备好接收流量。因为你可能有数百个 Pod,所以你希望通过-l标签过滤器标志来缩小结果范围。输入以下命令仅显示telnet-server Pods:
$ **minikube kubectl -- get pods -l app=telnet-server**
NAME READY STATUS RESTARTS AGE
telnet-server-775769766-2bmd5 1/1 Running 0 4m34s
telnet-server-775769766-k9kx9 1/1 Running 0 4m34s
此命令列出所有带有app: telnet-server标签的 Pod;这是在deployment.yaml文件中的spec.template.metadata.labels字段下设置的相同标签。输出显示两个telnet-server Pod 已准备好接受流量。你可以从READY列看到1/1容器正在运行,而你的部署只有一个容器(telnet-server)。如果你有一个包含多个容器的 Pod,你希望运行容器的数量与总容器数相同。
现在,使用与上面相同的命令,但将pods资源替换为services来显示两个服务:
$ **minikube kubectl -- get services -l app=telnet-server**
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
telnet-server LoadBalancer 10.105.187.105 <pending> 2323:30557/TCP 10m
telnet-server-metrics ClusterIP 10.96.53.191 <none> 9000/TCP 10m
由于你使用相同的标签(app: telnet-server)来组织应用,你可以使用-l标志来查找匹配项。输出显示大约 10 分钟前创建了两个服务。其中一个服务类型是LoadBalancer,另一个是ClusterIP。LoadBalancer用于暴露telnet-server应用。如果你的EXTERNAL-IP状态显示<pending>,不要担心。因为你是在 minikube 上运行,没有包含实际的LoadBalancer组件。
ClusterIP服务允许从集群内部抓取应用程序指标。在此示例中,内部应用程序可以通过使用telnet-server-metrics规范名称或 IP 10.96.53.191来访问指标端点。推荐使用规范名称。
测试部署和服务
现在,telnet-server 的部署和服务已经在运行,你将要测试其连通性和可用性。你希望像第六章中一样,使用 telnet 客户端能够访问 telnet-server 应用。之后,你将通过杀死一个 telnet-server Pod 并观察其恢复,来测试部署的弹性。最后,你将学习如何扩展,即通过命令行根据负载变化增减部署的副本数。
访问 Telnet 服务器
你将使用minikube tunnel命令将你的LoadBalancer服务暴露到 Kubernetes 集群外部。此命令将为你提供一个 IP 地址,你可以再次使用telnet client命令进行连接。tunnel子命令在前台运行,因此它应该在不会关闭的终端中运行。此命令还需要root权限。如果你的本地机器没有root权限,请改用minikube service命令。更多详情请访问minikube.sigs.k8s.io/docs/commands/service/。
在终端中,输入以下命令来创建到telnet-server服务的网络 tunnel:
$ **minikube tunnel**
Password:
Status:
machine: minikube
pid: 42612
route: 10.96.0.0/12 -> 192.168.99.103
minikube: Running
services: [telnet-server]
errors:
minikube: no errors
router: no errors
loadbalancer emulator: no errors
输入密码后,命令会输出一个route,暴露的services和任何出现的errors。确保在尝试连接到telnet-server时保持此命令运行。一旦隧道关闭,所有连接都会断开。由于没有需要报告的errors,此时隧道应该是可操作的。现在不要执行,但当你想关闭隧道时,按 CTRL-C 将其关闭。
现在,隧道已经建立,你需要获取LoadBalancer服务的新外部 IP 地址。作为快捷方式,传递服务名称到get services telnet-server(在本例中)来查看你感兴趣的服务:
$ **minikube kubectl -- get services telnet-server**
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
telnet-server LoadBalancer 10.105.187.105 10.105.187.105 2323:30557/TCP 15m
EXTERNAL-IP列现在应该填充了一个 IP 地址,而不是<pending>。这里,telnet-server应用程序的 IP 地址被设置为10.105.187.105,外部PORT被设置为2323。你的EXTERNAL-IP可能与我的不同,因此请使用此列中的 IP。
在另一个没有运行隧道的终端中,再次使用telnet client命令(telnet 10.105.187.105)并输入新的 IP 地址以访问telnet-server,如图 7-2 所示。
如你所见,telnet-server 响应了 ASCII 艺术 logo。按 Q 退出,因为你只是测试连接性。隧道命令使得使用分配的 IP 地址访问服务成为可能,就像它是一个面向公众的应用程序一样。如果这是在像 AWS 这样的云提供商上,IP 将对互联网上的任何人可访问。可以随时关闭另一个终端中的隧道命令,但你将在未来章节中再次使用它。

图 7-2:测试 telnet 访问 telnet-server
故障排除提示
如果无法像在图 7-2 中那样连接到 telnet-server,请检查 Pods 是否仍在运行,并且它们是否报告1/1容器是READY。如果READY列显示0/1,并且STATUS列有类似ImagePullBackOff或ErrImagePull的错误,那么 Pod 可能无法找到你在第六章中构建的 telnet-server 镜像。确保镜像已构建并且在列出 Docker 镜像时可用。
如果READY和STATUS列是正确的,下一步是确保你的服务已连接到你的 Pods。检查此连接的一种方式是使用kubectl get endpoints命令,它会告诉你服务是否能够找到你在服务spec.selector字段中指定的 Pods,该字段位于service.yaml文件中:
$ **minikube kubectl -- get endpoints -l app=telnet-server**
NAME ENDPOINTS AGE
telnet-server 172.17.0.3:2323,172.17.0.5:2323 20m
telnet-server-metrics 172.17.0.3:9000,172.17.0.5:9000 20m
ENDPOINTS 列显示了内部 Pod 的 IP 地址和端口。由于你有两个 Pod,每个服务都会显示两个用逗号分隔的 IP 地址。如果服务无法找到 Pod,ENDPOINTS 列将显示 <none>。如果你的 ENDPOINTS 列显示 <none>,请检查服务中的 spec.selector 字段是否与 deployment.yaml 文件中的 spec.template.metadata.labels 字段匹配。我在示例中已将其预设为标签 app: telnet-server。服务和资源之间标签不匹配是常见错误;你至少会遇到一次。
删除 Pod
部署的另一个伟大功能是恢复。故障是不可避免的,所以要接受它!部署会让你迅速恢复并恢复到完全正常的状态。记住,部署的主要目的是保持所需数量的 Pod 处于运行状态。为了验证这一点,你将删除其中一个 telnet-server Pod,然后观察部署如何重新创建另一个 Pod 来替代它。首先,你需要获取一个 telnet-server Pod 的名称并删除它。
输入以下命令再次获取 telnet-server Pods:
$ **minikube kubectl -- get pods -l app=telnet-server**
NAME READY STATUS RESTARTS AGE
telnet-server-775769766-2bmd5 1/1 Running 0 25m
telnet-server-775769766-k9kx9 1/1 Running 0 25m
删除哪个 Pod 并不重要,所以你只需选择列表中的第一个,假设是我集群中的 telnet-server-775769766-2bmd5。(你的 Pod 名称会不同,因为它们是自动生成的。)
现在,输入以下命令来 delete 选定的 Pod:
$ **minikube kubectl -- delete pod** `<telnet-server-775769766-2bmd5>`
pod "telnet-server-775769766-2bmd5" deleted
命令可能会挂起几秒钟,但当 Pod 被终止后,它最终会完成。
如果你再次列出 Pods,你会看到仍然有两个 Pod 正在运行,但现在 telnet-server-775769766-2bmd5 被删除,并且已经被一个新的 Pod 替代:
$ **minikube kubectl -- get pods -l app=telnet-server**
NAME READY STATUS RESTARTS AGE
telnet-server-775769766-k9kx9 1/1 Running 0 25m
telnet-server-775769766-rdg5w 1/1 Running 0 1m16s
这个新 Pod 名为 telnet-server-775769766-rdg5w,已经运行了超过一分钟,当前状态为 Running,并已准备好接受连接。
扩展
假设 telnet-server 应用程序非常受 35 岁以上怀旧人群的喜爱,并取得了巨大的成功。两个 telnet-server 实例已经不足以应对增加的流量,因此你需要将副本数扩展到大于两个。你可以通过两种方式做到这一点。第一种方式是编辑 deployment.yaml 清单文件,并使用 minikube apply 命令将更改应用到集群中。第二种方式是使用 minikube kubectl scale 命令。我将通过 minikube kubectl scale 命令演示这个例子,因为你已经在本章早些时候学会了如何应用清单文件。
你将通过增加部署的副本数来扩展部署,将 Pod 的总数增加到三个。(在真实的生产环境中,你会根据一些关键指标来确定副本数,而不是随便猜测。)输入以下命令来扩展 telnet-server 部署:
$ **minikube kubectl -- scale deployment telnet-server --replicas=3**
deployment.apps/telnet-server scaled
scale deployment 命令带有一个 --replicas 标志,用于设置 Pod 副本的数量。输出显示 telnet-server 部署已经 scaled,但让我们来验证一下。
输入以下命令验证你的 Deployment 副本数量是否已更改:
$ **minikube kubectl -- get deployments.apps telnet-server**
NAME READY UP-TO-DATE AVAILABLE AGE
telnet-server 3/3 3 3 17m
在这里,你可以看到 telnet-server 的 Deployment 资源信息。Deployment 中有三个副本(3/3)处于 READY 状态,比之前的两个副本要多。
scale 命令会实时更改集群中的副本数量。这可能会带来风险。如果同事在你通过命令行调整了副本数后,立即推出新版本的 telnet-server 应用程序,副本状态将不匹配。原因是,当他或她运行 minikube kubectl -- apply -f kubernetes/deployment.yaml 命令时,Deployment 的副本数量会恢复为两个,因为这是 deployment.yaml 清单文件中指定的值。
日志
测试的最后一部分是访问 telnet-server 应用程序日志。幸运的是,Kubernetes 通过 kubectl logs 子命令使这一过程变得简单。你需要抓取所有三个 telnet-server Pods 的日志。一个方法是为每个 Pod 执行 logs 命令并查看结果。输入以下命令查看其中一个 Pod 的日志(记住,你的 Pod 名称与我的不同):
$ **minikube kubectl -- logs** `<telnet-server-775769766-rdg5w>`
`--snip--`
如果你没有很多 Pods,或者你知道某个事件发生在哪个 Pod 上,这种方法没问题。如果没有,更好的选择是同时抓取所有 Pods 的日志,并标记每一行日志的来源 Pod 名称。输入以下命令抓取每个 Pod 的所有日志:
$ **minikube kubectl -- logs -l app=telnet-server --all-containers=true --prefix=true**
[pod/telnet-server-775769766-k9kx9/telnet-server] telnet-server: 2022/02/03 21:07:30 telnet-server listening on [::]:2323
[pod/telnet-server-775769766-k9kx9/telnet-server] telnet-server: 2022/02/03 21:07:30 Metrics endpoint listening on :9000
`--snip--`
这个命令使用了相当多的标志;我们来逐一解析每个标志:
-
只抓取具有此标签的 Pods:
-l app=telnet-server -
如果你有多个 Pods 并且想查看所有日志:
--all-containers=true -
每行日志都会标明其来源的 Pod 名称:
--prefix=true
输出应该显示至少六行日志——每个 Pod 启动的两行日志(共 3 个 Pod)以及可能由于之前使用 telnet 命令连接时产生的其他日志。此时,日志内容不重要,关键是确保你可以访问应用程序的日志。
摘要
在本章中,你学习了如何在 Kubernetes 集群中运行 telnet-server 容器镜像。你通过使用 Kubernetes Deployment 资源成功地协调了你的应用程序,并通过 Kubernetes 服务将其暴露到本地主机。最后,你探索了如何使用 minikube kubectl 命令创建、查询和查看资源及日志。在下一章中,你将学习如何通过在 Kubernetes 内部实现一个简单的交付管道来自动化 telnet-server 的部署。
第八章:部署代码

你已经有条不紊地构建了你的基础设施,达到了这个阶段,并且你已经完成了运行应用所需的所有基础组件。你已经在 Kubernetes 集群中构建并部署了 telnet-server 应用的容器镜像。如果你想发布应用的新版本,你只需要重建容器镜像,然后重新部署 Kubernetes 清单。
然而,你的设置中存在一些明显的缺陷。首先,你没有运行任何测试来验证代码或容器镜像是否无缺陷。另外,根据你目前的设置,每次代码或配置发生变化时,你都需要手动构建容器镜像并发布部署。这种手动过程对于测试新技术是可以的,但希望你已经学到了(并且同意)这些步骤可以并且应该自动化。成功的软件工程团队通常会使用自动化发布小的代码变更,这样可以快速发现错误,并减少基础设施的复杂性。正如前面章节提到的那样,这个过程通常被称为持续集成和持续部署(CI/CD),它使得代码从编辑器到利益相关者的传递更加一致和自动化。
在本章中,你将使用免费可用的工具为 telnet-server 应用构建一个简单的 CI/CD 管道。这个管道将监视 telnet-server 源代码的变更,如果有变动,它将启动一系列步骤,将这些变更部署到 Kubernetes 集群中。到本章结束时,你将拥有一个本地开发管道,它能够通过自动化构建、测试并部署你的代码到 Kubernetes 集群。
现代应用堆栈中的 CI/CD
持续集成和持续部署是描述代码构建、测试和交付的两种软件开发方法。CI 步骤包括代码和配置变更的测试与构建,而 CD 步骤则自动化新代码的部署(或交付)。
在持续集成(CI)阶段,软件工程师通过版本控制系统(如 Git)引入新特性或修复 bug。代码会经过一系列构建和测试,最后生成一个像容器镜像这样的产物。这个过程解决了“在我机器上能运行”这个问题,因为一切都是以相同的方式进行测试和构建,确保生成一致的产品。测试步骤通常包括单元测试、集成测试和安全扫描。单元测试和集成测试确保应用无论是独立运行还是与堆栈中的其他组件交互时,都能按预期行为运行。安全扫描通常会检查你应用软件依赖中的已知漏洞,或你所导入的基础容器镜像中是否存在漏洞。测试步骤完成后,新的产物被构建并推送到共享仓库,持续交付(CD)阶段可以访问它。
在持续交付(CD)阶段,产物从仓库中提取出来,并部署到通常是生产环境的基础设施上。持续交付可以使用不同的策略来发布代码。这些策略通常是金丝雀发布、滚动发布(在我们的案例中),或者蓝绿部署。有关每种策略的更多信息,请参见表 8-1。
部署策略的核心理念是最大限度地减少问题代码,防止其影响到大量用户。你将要部署的基础设施很可能是像我们的 Kubernetes 集群这样的容器编排器,但也可以是云服务提供商的虚拟机(VM)。
表 8-1:部署策略
| 金丝雀发布 | 这个策略将新代码发布给少量用户进行访问。如果金丝雀代码没有错误,则可以将新代码进一步推出,供更多用户使用。 |
|---|---|
| 蓝绿部署 | 在这个策略中,生产服务(蓝色)处理流量,而新服务(绿色)进行测试。如果绿色代码按预期运行,绿色服务将替换蓝色服务,所有客户请求将通过绿色服务转发。 |
| 滚动部署 | 这种策略将新代码逐个部署,与当前生产中的代码并行,直到完全发布。 |
部署成功后,监控步骤应当观察新代码,确保没有任何问题遗漏在持续集成(CI)阶段。如果检测到问题,如高延迟或错误计数增加,可以轻松地将应用回滚到一个被认为是安全的先前版本。这是像 Kubernetes 这样的容器编排器的一个伟大特性,它使得代码的前进和回滚变得非常简单。(我们稍后会测试回滚功能。)
设置你的流水线
在创建管道之前,您需要安装一些工具来帮助自动化代码构建、测试和交付。市场上有许多工具可以完成这些任务,但在我们的范围内,我使用了两款开源软件,它们与 Kubernetes 集成得很好。第一个工具叫做 Skaffold,它帮助 Kubernetes 本地应用程序进行持续开发。它将使设置 CI/CD 管道到本地 k8s 集群变得简单。如果尚未安装 Skaffold,请按照skaffold.dev/docs/install/上的操作指南,根据您的操作系统完成安装。
另一个工具是container-structure-test,这是一个命令行应用程序,用于验证容器镜像在构建后的结构。它可以通过验证特定文件是否存在来测试镜像是否构建正确,或者执行命令并验证其输出。您还可以使用它验证容器镜像是否构建了正确的元数据,例如在 Dockerfile 中设置的端口或环境变量。container-structure-test的安装说明可以在github.com/GoogleContainerTools/container-structure-test/找到。
审查 skaffold.yaml 文件
skaffold.yaml文件描述了如何构建、测试和部署您的应用程序。该文件应位于项目的根目录中,并保持在版本控制下。YAML 文件有许多不同的选项可供选择,但您的管道将专注于三个主要部分:build、test和deploy。build部分描述了如何构建容器镜像,test部分描述了执行哪些测试,deploy部分描述了如何将应用程序发布到 Kubernetes 集群。
skaffold.yaml文件位于克隆仓库中的telnet-server/目录下(github.com/bradleyd/devops_for_the_desperate/)。您不需要编辑或打开此文件,但应该对其基本内容和结构有一定的了解。
`--snip--`
kind: Config
build:
local: {}
artifacts:
- image: dftd/telnet-server
test:
- image: dftd/telnet-server
custom:
- command: go test ./... -v
structureTests:
- ./container-tests/command-and-metadata-test.yaml
deploy:
kubectl:
manifests:
- kubernetes/*
build部分使用默认的构建操作,即docker build命令,在本地创建我们的容器镜像。容器image名称设置为dftd/telnet-server。这与您在deployment.yaml文件中使用的镜像名称相匹配。您将会在查看deploy部分时明白这点为什么很重要。Skaffold 工具会使用当前的 Git 提交哈希来预先计算容器镜像标签,这是默认行为。生成的标签会自动附加到容器镜像名称,并且它会方便地设置为环境变量($IMAGE),如果需要可以引用。
test部分允许你对应用程序和容器镜像运行任何测试。在这种情况下,你将使用我为你提供的telnet-server应用程序的单元测试。这些单元测试位于custom字段下,运行go test命令来执行所有的测试文件。此步骤要求安装 Go 编程语言。如果你尚未安装 Go,可以按照go.dev/doc/install/上的说明进行安装。
下一个要运行的测试是structureTests。此测试检查最终的容器镜像是否存在缺陷。稍后我们将简要讲解这些容器测试。
最后,deploy部分使用kubernetes/目录中的 Kubernetes 清单文件来发布telnet-server部署。Skaffold 工具对正在运行的部署执行补丁操作,并用在build步骤中 Skaffold 生成的新容器镜像和标签(即dftd/telnet-server:v1)替换当前镜像。因为这些名称与标签匹配,所以可以轻松在流水线中更新为新的标签。
审查容器测试
一旦telnet-server容器镜像构建完成并且应用程序测试通过,容器测试将针对新构建的镜像运行。容器测试位于一个名为container-tests/的子目录下,该目录位于telnet-server/目录中。此目录包含一个名为command-and-metadata-test.yaml的测试文件。在这个文件中,我提供了一个应用程序测试,以确保二进制文件正确构建,并且还提供了一些容器镜像测试,以验证容器是否按照预期的指令构建。
你现在应该回顾结构测试。打开 YAML 文件到编辑器中,或者继续往下看:
`--snip--`
commandTests:
- name: "telnet-server"
command: "./telnet-server"
args: ["-i"]
expectedOutput: ["telnet port :2323\nMetrics Port: :9000"]
metadataTest:
env:
- key: TELNET_PORT
value: 2323
- key: METRIC_PORT
value: 9000
cmd: ["./telnet-server"]
workdir: "/app
commandTests命令执行telnet-server二进制文件,并向其传递-i(信息)标志,以将应用程序监听的端口输出到 STDOUT。然后,命令输出将与expectedOutput字段中的内容进行匹配。对于成功的测试,输出应匹配telnet port :2323\nMetrics Port: :9000,这样你可以确保在容器build阶段正确编译了二进制文件。此测试确保telnet-server应用程序至少可以基本运行并发挥作用。
metadataTest检查容器镜像是否按照 Dockerfile 中的正确指令构建。元数据测试验证环境变量(env)、命令(cmd)和工作目录(workdir)。这些测试对于捕捉不同提交之间 Dockerfile 变更的差异非常有用。
模拟开发流水线
现在你已经理解了管道配置,让我们开始运行管道。你可以通过执行带有 run 或 dev 子命令的 skaffold 命令来启动管道。run 子命令是一次性执行,构建、测试和部署应用程序后会退出,不会监视任何新的代码更改。dev 命令执行与 run 相同的操作,但它会监视源文件的任何更改。一旦检测到更改,它会启动 skaffold.yaml 文件中描述的 build、test 和 deploy 步骤。对于本例,你将使用 dev 子命令来模拟开发管道。
在成功运行 dev 子命令后,它将等待并阻止任何变化的发生。默认情况下,你需要按 CTRL-C 来退出 skaffold dev 模式。然而,当你使用 CTRL-C 退出时,默认行为是清理自己,删除 Kubernetes 集群中的 telnet-server 部署和服务。由于你将在本章和本书中持续使用 telnet-server 部署,因此可以在 dev 命令后添加 --cleanup=false 标志来跳过这个行为。这样,Pod 会在你退出命令后继续运行。
要启动管道,确保你处于 telnet-server/ 目录中,并且 Kubernetes 集群仍在运行。执行 skaffold 命令时,它可能会输出很多信息。为了更容易跟踪,你将根据上面提到的三个 skaffold 部分(build、test 和 deploy)来分解输出。
在终端中输入以下命令以运行 skaffold:
$ **skaffold dev --cleanup=false**
Listing files to watch...
- dftd/telnet-server
Generating tags...
- dftd/telnet-server -> dftd/telnet-server:4622725
Checking cache...
- dftd/telnet-server: Not found. Building
Found [minikube] context, using local docker daemon.
Building [dftd/telnet-server]...
`--snip--`
Successfully tagged dftd/telnet-server:4622725
该命令执行的第一个操作是将容器标签设置为 4622725,然后构建 Docker 镜像。你的标签可能会有所不同,因为它基于我仓库当前的 Git 提交哈希值。
在成功构建后,skaffold 会触发测试部分,在这里会进行单元测试和容器基础设施测试:
Starting test...
Testing images...
=======================================================
====== Test file: command-and-metadata-test.yaml ======
=======================================================
=== RUN: Command Test: telnet-server
--- PASS
duration: 571.602755ms
stdout: telnet port :2323
Metrics Port: :9000
=== RUN: Metadata Test
--- PASS
duration: 0s
=======================================================
======================= RESULTS =======================
=======================================================
Passes: 2
Failures: 0
Duration: 571.602755ms
Total tests: 2
PASS
Running custom test command: "go test ./... -v"
? telnet-server [no test files]
? telnet-server/metrics [no test files]
=== RUN TestServerRun
Mocked charge notification function
TestServerRun: server_test.go:23: PASS: Run()
--- PASS: TestServerRun (0.00s)
PASS
ok telnet-server/telnet (cached)
Command finished successfully.
容器测试和 telnet-server 单元测试都通过,没有任何错误。
最后,在容器构建完成并且所有测试通过后,skaffold 会尝试将容器部署到 Kubernetes:
`--snip--`
Starting deploy...
- deployment.apps/telnet-server created
- service/telnet-server created
- service/telnet-server-metrics created
Waiting for deployments to stabilize...
- deployment/telnet-server: waiting for rollout to finish: 0 of 2 updated replicas are available...
- pod/telnet-server-6497d64d7f-j8jq5: creating container telnet-server
- pod/telnet-server-6497d64d7f-sx5ll: creating container telnet-server
- deployment/telnet-server: waiting for rollout to finish: 1 of 2 updated replicas are available...
- deployment/telnet-server is ready.
Deployments stabilized in 2.140948622s
**Press Ctrl+C to exit**
Watching for changes...
部署使用了我们的 Kubernetes 清单文件来部署 telnet-server 应用程序。对于此部署,skaffold 使用了刚刚构建和测试的新的容器镜像和标签(dftd/telnet-server:4622725)来替代当前运行的镜像(dftd/telnet-server:v1)。如果 build、test 和 deploy 步骤都成功,则不会出现任何可见错误,最后一行应该显示“Watching for changes”。如果任何步骤出现错误,管道将立即停止,并抛出一个 error,并提供一些故障发生位置的线索。如果发生错误,可以在 skaffold dev 命令后添加 --verbosity debug 标志来增加输出的详细程度。
如果容器镜像和标签已经存在,skaffold 将跳过 build 和 test 部分,直接进入 deploy 步骤。这是一个节省时间的好方法,因为如果你只是重新部署相同的容器镜像,就无需重复所有步骤。如果你的代码库中有未提交的更改,skaffold 会在标签末尾加上 -dirty(如 4622725-dirty)以表示还有未提交的更改。在大多数情况下,当你在本地开发时,这种情况会经常发生,因为你可能会在提交代码之前不断修改和调整。
做出代码更改
现在流水线已经设置好,你需要做出一个代码更改来测试工作流。我们可以尝试一个简单的操作,比如更改连接到 telnet-server 时显示的 DFTD 横幅的颜色。telnet-server 的源代码位于 telnet-server/ 目录下。目前,横幅设置为绿色(我最喜欢的颜色)。一旦你做出代码更改并保存文件,skaffold 应该会识别到更改,并重新触发 build、test 和 deploy 步骤。
在一个与运行 skaffold 的终端不同的终端中,使用你喜欢的编辑器打开 banner.go 文件,该文件位于 telnet/ 子目录下。不要担心代码或文件的内容,你只是要更改颜色。在第 26 行,你会看到类似下面的代码:
return fmt.Sprintf("%s%s%s", **colorGreen**, b, colorReset)
这是设置横幅颜色的那一行代码。
将字符串 colorGreen 替换为 colorYellow,这样这一行现在应该像这样:
return fmt.Sprintf("%s%s%s", **colorYellow**, b, colorReset)
更改后,保存并关闭文件。返回你运行 skaffold dev 命令的终端。你现在应该能看到新的活动,类似于第一次运行 skaffold 时的输出。所有步骤会再次被触发,因为你对 skaffold 监视的源代码进行了更改。最终结果应该是相同的:你将完成部署滚动,并且会有两个新的 Pod 正在运行。如果不是这样,请确保你已经保存了 banner.go 文件,并且 skaffold dev 仍在运行。
测试代码更改
接下来,你需要确保新代码已成功交付到 Kubernetes 集群。通过验证 DFTD 横幅的颜色是否从绿色变为黄色来确认。
在上一章中,你使用了 minikube tunnel 命令来访问 telnet-server 应用。如果你仍然在终端中运行该命令,可以直接跳到下面的 telnet 客户端说明。如果没有,打开另一个终端,再次运行 minikube tunnel 命令。
你需要再次获取 telnet-server 服务的 IP 地址才能访问它。运行以下命令获取 telnet-server 服务的 IP:
$ **minikube kubectl -- get services telnet-server**
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
telnet-server LoadBalancer 10.105.161.160 **10.105.161.160** 2323:30488/TCP 6m40s
你的 EXTERNAL-IP 可能与我的不同,所以请使用该列中的 IP 和端口 2323。
再次使用 telnet 客户端命令访问应用,命令如下:
$ **telnet 10.105.161.160 2323**
DFTD 横幅,如 图 8-1 所示,现在应该是黄色的。

图 8-1:Telnet 会话应该显示黄色横幅
如果它不是黄色的,请回去确保代码中颜色已正确更改,并且文件已保存。此外,你可以使用 minikube kubectl get pods 命令来验证是否有新的 Pods 正在运行。确保 Pods 的时间戳回到你保存 banner.go 文件后的短时间内。你还应该查看运行 skaffold dev 的终端输出,以发现任何明显的错误。
测试回滚
有时你需要回滚已部署的应用程序。这可能是由于多种原因,比如代码有问题,或者产品与工程之间的不匹配。假设你想回到欢迎横幅是绿色的版本,你会有两个选择。一方面,你可以做必要的代码修改,把横幅重新设置为绿色,并再次将应用程序放回 CI/CD 流水线。另一方面,你也可以将 Deployment 回滚到旧版本,那个版本的 DFTD 横幅是绿色的。我们将探讨后者选项。
如果出现问题的应用程序不会立即导致服务中断或对客户产生持续影响,那么你应该为代码做一个热修复,并通过 CI/CD 流水线跟随发布周期。但如果这个 bug(错误)在你部署代码后立刻导致了服务中断呢?你可能没有时间等待彻底的调查以及热修复通过流水线运行。但是 Kubernetes 提供了一种方法,允许你将 Deployment 和其他资源回滚到之前的版本。所以在这种情况下,你只需要回滚一个版本,回到横幅是绿色的时候。
首先,检查部署历史记录。每次你部署新的代码时,Kubernetes 会跟踪 Deployment 并保存那个时刻的资源状态。输入以下命令在终端中获取 telnet-server 的部署历史:
$ **minikube kubectl -- rollout history deployment telnet-server**
deployment.apps/telnet-server
REVISION CHANGE-CAUSE
1 <none>
2 <none>
如果你在没有任何问题的情况下跟随操作,输出应该显示两个被跟踪的 Deployments。目前,REVISION 2 是活动版本。注意,CHANGE-CAUSE 列显示 <none>。这是因为你没有告诉 Kubernetes 记录变更。在运行 kubectl apply 时使用 --record 标志可以让 Kubernetes 记录触发 deploy 的命令。对于本书来说,不必担心使用 --record。根据你从第七章部署清单的次数,或者你运行了多少次 skaffold dev,你的 REVISION 数字可能不同。实际的数字并不重要;你只是需要回到之前的版本。
让我们从命令行强制回滚到 REVISION 1,这应该会重新应用第一次 deploy 时使用的清单,那时横幅是绿色的。kubectl rollout 命令有一个 undo 子命令,用于这种情况:
$ **minikube kubectl -- rollout undo deployment telnet-server --to-revision=1**
deployment.apps/telnet-server rolled back
你可以省略 --to-revision=1 标志,因为默认情况下是回滚到上一个版本。我在这里添加它,以防你需要回滚到不是上一个版本的修订。
几秒钟后,之前的版本应该开始运行并接受新连接。通过运行 minikube kubectl get pods 命令来验证这一点,以确保 Pods 是新的,并且只运行了几秒钟:
$ **minikube kubectl -- get pods**
NAME READY STATUS RESTARTS AGE
telnet-server-7fb57bd65f-qc8rg 1/1 Running 0 28s
telnet-server-7fb57bd65f-wv4t9 1/1 Running 0 29s
这些 Pods 的名称已经改变,并且这些 Pods 仅运行了 29 秒,这是在刚刚回滚后你所期望的情况。
现在,检查横幅的颜色。确保 minikube tunnel 命令仍在运行,然后再次在应用程序中输入 telnet 命令:
$ **telnet 10.105.161.160 2323**
如果一切顺利,你的 DFTD 横幅应该再次变为绿色。
如果你再次运行 rollout history 命令,当前部署的版本将是 3,而当横幅为黄色时的前一个版本将是 2。
你现在知道如何在 Kubernetes 中进行紧急回滚,以从任何即时的服务中断中恢复。这项技术在你的组织关注平均恢复时间 (MTTR**)时特别有用,这基本上意味着从客户的角度看,服务从“宕机”到“恢复正常”所需要的时间。
其他 CI/CD 工具
开发管道是你基础设施中复杂的组成部分。在我努力以简单方式讲解这些内容的过程中,我可能简化了一些方面。然而,我的主要目标是向你展示如何创建一个简单的管道,在本地 Kubernetes 集群中测试和部署代码。你也可以在非本地环境中使用这种模式,比如在 AWS 或 Google 上的设置。这些过程的共同点是可移植性和使用单一文件来描述应用程序的管道。这意味着如果你的管道 YAML 文件在本地运行正常,它也应该能在远程基础设施上运行。
话虽如此,描述一些在 CI/CD 领域流行的工具可能会有所帮助。可用的工具比我能列举的还要多,但流行的工具包括 Jenkins、ArgoCD 和 GitLab CI/CD。其中,Jenkins 可能是最广泛使用的,它可以同时进行 CI 和 CD 操作,适用于虚拟机、容器以及你正在使用的任何其他工件。还有很多广泛可用的社区插件,使 Jenkins 可扩展,但它们也带来了一些安全问题。务必注意更新插件,并留意潜在的安全问题。
Jenkins 可以部署到任何基础设施,并使用任何版本控制的代码仓库。而 Argo CD 则是一个专注于部署阶段的 Kubernetes 部署工具。它可以开箱即用进行金丝雀发布或蓝绿部署,并且提供了一个很棒的命令行工具来管理基础设施。在 CI 阶段完成后,你可以将 Argo CD 集成到流水线中。最后,GitLab CI/CD 提供了一个功能齐全的流水线(类似于 Jenkins),它利用 GitLab 的版本控制产品来管理代码仓库。它是为 DevOps 设计的,几乎包括了在现代基础设施栈中启动并运行所需的一切。
尽管这些工具能够很好地帮助你构建流水线,但将 CI/CD 背后的理念与此领域中使用的工具分开是很重要的。事实是,每个你工作的组织可能会使用或不使用这里描述的工具或流程。重要的是方法论,而不是单个工具本身。无论使用什么工具,CI/CD 的主要目标是以小而可预测的迭代验证并交付代码,从而减少错误或缺陷的发生概率。
总结
本章介绍了持续集成和持续部署方法。你创建的 CI/CD 流水线使用了两个工具来构建、测试和部署代码。这使你能够在 Kubernetes 集群中自动化应用程序的生命周期。你还了解了 Kubernetes 中内置的回滚功能,这使得从错误的代码或配置错误的发布中快速恢复变得更加容易。
这标志着第二部分的结束,第二部分集中于容器化和编排。现在你可以在 Kubernetes 集群中构建并部署一个简单的应用程序。接下来,我们将转换话题,讨论可观测性,重点是指标、监控和告警。我们还将探讨在主机或网络上常见的故障排除场景,以及你可以用来诊断这些问题的工具。
第三部分
可观测性与故障排除
第九章:可观察性

可观察性 是系统的属性,而不是您要做的事情。这是系统被监视、跟踪和分析的能力。任何值得投入生产的应用程序都应该具备可观察性。您在观察系统时的主要目标是分辨它内部在做什么。通过分析像度量、跟踪和日志等系统输出,您可以做到这一点。度量 通常包括随时间变化的数据,提供关键的应用程序健康和/或性能见解。跟踪 跟踪请求在不同服务中的传递过程,以提供全面的视图。日志 提供错误或事件的历史审计轨迹,可用于故障排除。一旦收集了这些数据,您需要监视它,并在出现意外行为时通知相关人员。
并不需要分析每个应用程序或架构的度量、跟踪和日志。例如,在运行分布式微服务时,跟踪对于了解给定服务的个体状态及其与其他服务的交互至关重要。关于观察什么、如何观察以及观察多少的决定,真正取决于您处理的架构复杂性水平。由于您的应用程序和基础架构相对简单,您将通过度量、监控和警报来观察您的 telnet-server 应用程序。
在本章中,您将首先在您在第七章创建的 Kubernetes 集群内安装监控堆栈。然后,您将调查可以作为任何服务或应用程序的起点使用的常见度量模式。最后,您将配置监控堆栈,以在触发警报时发送电子邮件通知。通过本章结束时,您将对如何在 Kubernetes 内安装、监控和发送通知,为任何应用程序具有坚实的理解。
监控概述
监控 是任何涉及记录、分析和根据预定指标发出警报的操作,以了解系统当前状态的行为。为了衡量系统的状态,您需要应用程序发布指标,这些指标可以在任何时候讲述系统正在做什么的故事。通过设置指标周围的阈值,您可以创建应用程序行为的基准。例如,大多数情况下,Web 应用程序预期会响应 HTTP 200。当应用程序的基准不在您期望的范围内时,您需要通知相关人员,以便他们将应用程序恢复到正常状态。系统会发生故障,但是强大的监控和警报可以成为用户满意和轮班结束后您能安心入眠的桥梁。
一个可观察的系统应该尽最大努力回答两个主要问题:“什么?”和“为什么?”“什么?”询问在特定时间段内应用或服务的症状,而“为什么?”则是询问症状背后的原因。你通常可以通过监控症状来回答“什么?”的问题,而可以通过其他手段(如日志和追踪)来回答“为什么?”的问题。将症状与原因关联起来可能是监控和可观察性中最难的部分。这意味着,你的应用的弹性只有在应用输出的数据足够好的情况下才会表现得好。一句常用的短语来描述这个概念是“垃圾进,垃圾出”。如果从应用导出的度量数据没有针对性或与用户如何与服务交互无关,那么检测和诊断问题就会变得更加困难。因此,衡量应用的关键路径(即最常用的部分)比衡量每个可能的用例更为重要。
举个例子,假设你因为早上醒来感到恶心和胃部痉挛而去看医生。医生会问你一些基本问题,并测量你的体温、心率和血压。虽然你的体温稍微升高,但其他指标都在正常范围内。经过所有数据的审核后,医生会做出判断,找出你不舒服的原因。医生很可能能正确诊断你的病情(或至少能找到更多的线索来进一步追踪)。
这种医疗诊断过程与诊断应用问题的过程是相同的。你将测量症状并尝试通过诊断或假设来解释它们。如果你拥有足够相关的数据点,关联症状和原因会变得更容易。在上面的例子中,如果医生询问你最近吃了什么(这是另一个可靠的数据点),他们可能会将你的恶心和痉挛与凌晨 3 点在加油站吃寿司的不明智选择联系起来。
最后,在为您的应用设计度量和监控解决方案时,始终考虑“什么?”和“为什么?”这两个问题。避免设置那些无法为利益相关者提供价值的度量或警报。那些不断被无效警报轰炸的工程师往往会感到疲倦,最终忽视这些警报。
监控示例应用
你将开始监控本书示例中的 telnet-server 所发布的度量数据。telnet-server 应用有一个 HTTP 端点,可以提供关于该应用的度量信息。你感兴趣的度量数据主要集中在用户体验方面,如连接错误和流量。你的 telnet-server 应用的技术栈将包括三个主要的监控应用和一个流量模拟应用。你将使用这些应用来监控、警报和可视化由 telnet-server 部署的度量数据。
监控应用程序包括 Prometheus、Alertmanager 和 Grafana。它们在 Kubernetes 生态系统中广泛使用。Prometheus 是一款度量数据收集应用,使用其强大的内建查询语言查询度量数据。它还可以为这些度量设置警报。如果收集的度量超过设定的阈值,Prometheus 会将警报发送到 Alertmanager,后者接收 Prometheus 的警报并根据一些用户可配置的标准决定将其路由到哪里。路由通常是通知。Grafana 提供了一个易于使用的界面,用于创建和查看 Prometheus 提供的数据的仪表板和图表。流量模拟器 bbs-warrior 模拟了 telnet-server 应用的最终用户可能生成的流量。这可以让您测试监控系统、应用程序度量和警报。图 9-1 显示了示例堆栈的概览。

图 9-1:我们的监控堆栈概览
安装监控堆栈
要安装这些应用程序,您将使用提供的 Kubernetes 清单文件。监控堆栈和流量模拟器的清单文件位于仓库中(github.com/bradleyd/devops_for_the_desperate/),在monitoring目录下。在该目录内有四个子目录:alertmanager、bbs-warrior、grafana和prometheus。这些构成了示例监控堆栈。您将通过应用这些目录中的所有清单文件,将 Prometheus、Alertmanager 和 Grafana 安装到一个新的 Kubernetes 命名空间 monitoring 中。
在终端中,输入以下命令来安装监控堆栈和 bbs-warrior:
$ **minikube kubectl -- apply -R -f monitoring/**
namespace/monitoring created
serviceaccount/alertmanager created
configmap/alertmanager-config created
deployment.apps/alertmanager created
service/alertmanager-service created
cronjob.batch/bbs-warrior created
configmap/grafana-dashboard-pods created
configmap/grafana-dashboard-telnet-server created
configmap/grafana-dashboards created
configmap/grafana-datasources created
deployment.apps/grafana created
service/grafana-service created
clusterrolebinding.rbac.authorization.k8s.io/kube-state-metrics created
clusterrole.rbac.authorization.k8s.io/kube-state-metrics created
deployment.apps/kube-state-metrics created
serviceaccount/kube-state-metrics created
service/kube-state-metrics created
clusterrole.rbac.authorization.k8s.io/prometheus created
clusterrolebinding.rbac.authorization.k8s.io/prometheus created
configmap/prometheus-server-conf created
deployment.apps/prometheus created
service/prometheus-service created
输出显示您的监控堆栈和 bbs-warrior 的所有清单文件都已无错误地运行。-R 标志使 kubectl 命令递归地遍历monitoring目录下的所有应用程序目录及其子目录。如果没有这个标志,kubectl 将跳过任何嵌套的子目录,比如 grafana/dashboards/。Prometheus、Grafana、Alertmanager 和 bbs-warrior 应该很快就会启动并运行。
验证安装
如果在 Kubernetes 集群中成功安装了监控栈,你应该能在浏览器中访问 Grafana、Alertmanager 和 Prometheus 的 Web 界面。在提供的 Kubernetes 清单文件中,我已将 Prometheus、Grafana 和 Alertmanager 的 Kubernetes 服务类型设置为 NodePort。Kubernetes 的 NodePort 服务允许你连接到 Kubernetes 集群外的应用,因此你应该能够通过 minikube IP 地址和动态端口访问每个应用。你还应该能确认 bbs-warrior 流量模拟器已安装并定期运行。
Grafana
在终端中,输入以下命令以打开 Grafana:
$ **minikube -n monitoring service grafana-service**
|------------|-----------------|-------------|-----------------------------|
| NAMESPACE | NAME | TARGET PORT | URL |
|------------|-----------------|-------------|-----------------------------|
| monitoring | grafana-service | 3000 | http://192.168.99.105:31517 |
|------------|-----------------|-------------|-----------------------------|
Opening service monitoring/grafana-service in default browser...
Grafana 位于 monitoring 命名空间中,因此此命令使用 -n(命名空间)标志来告诉 minikube service 命令服务的位置。如果省略 -n 标志,minikube 会报错,因为在默认命名空间中没有名为 grafana-service 的服务。此时,你应该能在浏览器中看到 Grafana 打开,并且加载了 telnet-server 仪表盘作为首页。如果没有看到 telnet-server 仪表盘,请检查你运行 minikube service 命令的终端中是否有错误。(你需要访问 Grafana 才能继续完成本章内容。)稍后我们将讨论 Grafana 仪表盘上的图表;现在,确保 Grafana 已正确安装,并且你能够在浏览器中打开它。
Alertmanager
在终端中,输入与打开 Grafana 相同的命令,但将服务名称替换为 alertmanager-service,像这样:
$ **minikube -n monitoring service alertmanager-service**
`--snip--`
现在,Alertmanager 应用应该已在浏览器中打开。此页面有几个导航链接,如 Alerts、Silences、Status 和 Help。Alerts 页面显示当前的警报及其元数据,如时间戳和警报的严重性。Silences 页面显示所有已被静音的警报。你可以为特定时间段静音或静默警报,如果某个警报持续触发,而你又不希望不断接收到通知,这时非常有用。Status 页面显示有关 Alertmanager 的信息,如版本、就绪状态和当前配置。Alertmanager 通过 configmap.yaml 文件在 alertmanager/ 目录中进行配置。(你稍后将编辑该文件以启用通知。)最后,Help 页面是一个指向 Alertmanager 文档的链接。
Prometheus
在你的终端中,输入你刚才输入的相同命令,但将grafana-service替换为prometheus-service以打开 Prometheus:
$ **minikube -n monitoring service prometheus-service**
`--snip--`
Prometheus 应该会在你的浏览器中打开,并在页面顶部显示几个链接:Alerts、Graph、Status 和 Help。Alerts 页面显示所有已知的警报及其当前状态。Graph 页面是默认页面,允许你对度量数据库执行查询。Status 页面包含有关 Prometheus 健康状况和配置文件的信息。Prometheus,像 Alertmanager 一样,是由 configmap.yaml 文件控制的,位于 prometheus 目录中。这个文件控制 Prometheus 用于抓取度量的端点,并且包含特定度量的警报规则。(稍后我们将探讨警报规则。)Help 页面是 Prometheus 官方文档的链接。目前,你只需要确认 Prometheus 正在运行。保持 Prometheus 打开,因为接下来的部分你将需要它。
bbs-warrior
bbs-warrior 应用是一个 Kubernetes CronJob,每分钟运行一次,并向 telnet-server 应用创建随机数量的连接和错误。它还会向 telnet-server 发送随机数量的 BBS 命令(如 date 和 help),以模拟典型的用户活动。在你安装 bbs-warrior 后约一分钟,它应该开始生成随机流量。这个模拟应该只持续几秒钟。
为了确保 bbs-warrior 在你的 Kubernetes 集群中正确安装并处于活动状态,请在终端中输入以下命令:
$ **minikube kubectl -- get cronjobs.batch -l app=bbs-warrior**
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
bbs-warrior */1 * * * * False 0 25s 60s
-l(标签)标志在搜索 CronJobs 时可以缩小结果范围。输出显示 CronJob 是在一分钟前安装的(在 AGE 列下为60s),并且它最后一次运行是在 25 秒前(在 LAST SCHEDULE 列下)。如果它正在积极运行,ACTIVE 列会显示为 1 而不是 0。
你现在知道 CronJob 已经运行,但你应该确保它成功完成。为此,你将列出在默认命名空间中带有标签 bbs-warrior 的 Pod,并在 STATUS 列中查找 Completed。在你之前使用的终端中,输入以下命令:
$ **minikube kubectl -- get pods -l app=bbs-warrior**
NAME READY STATUS RESTARTS AGE
bbs-warrior-1600646880-chkbw 0/1 Completed 0 60s
输出显示 bbs-warrior CronJob 在大约 60 秒前成功完成。如果 CronJob 的状态不是 Completed,请像在第七章中那样检查 Pod 的日志以查找错误。
度量指标
你已经安装并验证了你的监控栈,现在,你应该专注于监控你的 telnet-server。因为你想将度量指标与用户的满意度对齐,所以你应该使用一个通用的模式来统一所有应用程序。这始终是仪表化服务时的好方法,因为允许应用程序执行其独特的度量版本会使故障排除(因此也影响值班)变得非常困难。
在这个示例中,你将探索一个常见的度量模式,叫做 Golden Signals。它提供了一组度量指标来跟踪,例如错误和流量,并为你和你的同事提供一种共同的语言,用来讨论什么样的状态是健康的。
黄金信号
黄金信号(这一术语最早由谷歌提出)是帮助我们理解微服务健康状况的四个指标。黄金信号包括延迟、流量、错误和饱和度。延迟是服务处理请求所需的时间。流量是应用程序接收到的请求数量。错误是应用程序报告的错误数量(例如,Web 服务器报告 500 错误)。饱和度是服务的负载情况。对于饱和度信号,你可以通过测量 CPU 使用率来判断系统还剩余多少空间,直到应用程序或主机变得缓慢或无响应。你在衡量应用程序时将经常使用这个模式。如果你遇到不知道该监控什么的情况,可以从黄金信号开始。它们将提供关于应用程序健康状况的充分信息。
微服务通常是一个与平台上其他服务松散耦合的应用程序。它的设计目标是仅关注你整体领域中的一两个方面。在本章中,telnet-server 应用程序将作为你衡量健康状况的微服务。
调整监控模式
你的应用程序可能无法完美适应像黄金信号这样的预定义监控模式。请根据你的判断决定监控哪些内容。例如,我决定在为 telnet-server 应用程序进行仪表化时不追踪延迟,尽管模式中列出了它。使用这种应用程序的用户通常不会连接后运行一个命令然后退出。你可以追踪命令的延迟,或者为每个命令工作流程添加追踪。然而,这对于这个示例应用程序来说有些过头,也超出了本书的范围。你的命令仅用于演示目的,因此关注流量、错误和饱和度信号,从用户的角度来看,会提供应用程序健康状况的总体了解。
Telnet-server 仪表板
让我们回顾一下 Grafana 仪表板上的流量、饱和度和错误信号。在你第一次打开 Grafana 的浏览器中,telnet-server 仪表板有三个黄金信号图表,以及两个折叠的图表行,分别是“系统”和“应用程序”(见图 9-2)。你将重点关注黄金信号图表,具体如下:每秒连接数、饱和度和每秒错误数。
第一个图表,连接数(左上角),提供了流量黄金信号。在这种情况下,你测量在两分钟的时间框架内每秒接收到的连接数。每当有连接建立时,telnet-server 应用会增加一个指标计数器,这能清楚地显示有多少人连接到该应用。过多的连接可能会导致性能或可靠性问题。在这个例子中,x 轴在两个 telnet-server Pods 中急剧上升,超过 4.0 每秒连接数。你的图表可能与我的不同,因为 bbs-warrior 会随机生成流量;目标是确保图表数据正在填充。

图 9-2:telnet-server Grafana 仪表板
饱和度图(右上角)表示饱和度黄金信号。在饱和度的衡量中,你需要测量 Kubernetes 限制你的 telnet-server 容器 CPU 的时间。你在第七章中为 telnet-server 容器设置了 500 毫 CPU 的 CPU 资源限制。因此,如果 telnet-server 容器使用的 CPU 超过了最大限制,Kubernetes 会对其进行限制,这可能会导致 telnet-server 对命令或连接的响应变慢。这种限制可能会导致性能下降或服务中断。在 图 9-2 中显示的饱和度图中,两个 Pod 的 x 轴都平稳地显示在 0 微秒。如果发生 CPU 限制,图线会升高。
每秒错误数(左下角)图表对应错误黄金信号。对于这个指标,你需要跟踪在两分钟时间段内收到的每秒连接错误。这些错误会在客户端无法正确连接或连接意外中断时递增。高错误率可能表示你需要解决的代码或基础设施问题。在 图 9-2 中显示的图表中,错误每秒的错误率在两个 pod 上都急剧上升到 0.4。
本仪表板底部折叠的两行包含一些本章未涵盖的杂项图表,但你应该自行探索它们。telnet-server 仪表板的系统行包含两个图表:一个是内存使用情况,另一个是 telnet-server Pods 的 CPU 使用情况。应用行包含四个图表:总连接数、活动连接数、连接错误总数和未知命令总数。
telnet-server 仪表板位于 grafana/dashboards/telnet-server.yaml 文件中。该文件是一个 Kubernetes ConfigMap 资源,包含 Grafana 创建仪表板和图表所需的 JSON 配置。
PromQL:简介
PromQL 是 Prometheus 应用内置的查询语言。你用它来查询和操作指标数据。可以将 PromQL 看作是 SQL 的远亲。它有一些内置函数(如 average 和 sum),可以简化数据查询,还支持条件逻辑(如 > 或 =)。我们在这里不会深入探讨这个查询语言,只会展示如何查询 telnet-server 的黄金信号指标,以填充你的图表和警报。
例如,这是你输入的查询,用于生成每秒错误数的图表:
**rate(telnet_server_connection_errors_total{job="kubernetes-pods"}[2m])**
该指标的名称是telnet_server_connection_errors_total。该指标用于衡量用户可能遇到的连接错误的总数。查询使用了 Prometheus 的 rate() 函数,该函数计算在指定时间间隔内每秒的连接错误平均值。你使用方括号[2m]限制查询获取数据的时间范围为两分钟。结果将显示你在第七章中安装的两个运行中的 telnet-server Pods。花括号({})允许你通过使用标签作为匹配项来细化查询。在这里,你指定只获取带有 job="kubernetes-pods" 标签的 telnet_server_connection_errors 指标的数据。
在 Prometheus 中创建警报规则时,你可以输入与上述相同的查询来触发警报。然而,这一次,你应该将 rate() 函数的结果包装在 sum() 函数中。你这样做是因为你希望了解两个 Pod 的总体错误率。警报规则应该如下所示:
**sum(rate(telnet_server_connection_errors_total{job="kubernetes-pods"}[2m]))** **> 2**
在查询的最后,你添加了大于符号(>)的条件逻辑,并指定了一个数字:2。这基本意味着,如果每秒的错误率大于 2,则此警报查询将评估为真。(在本章稍后部分,我们将讨论当警报规则为真时会发生什么。)
如果你想查看或调整这些指标中的任何一个,可以访问 Prometheus Web 界面中的图形页面。图 9-3 展示了正在运行的 telnet_server_connection_errors_total 查询。

图 9-3:在 Prometheus 的 Web 界面中运行查询
查询返回了两个 Pods 的连接错误数据。要了解更多关于 PromQL 的信息,请访问 prometheus.io/docs/prometheus/latest/querying/basics/,以获取更多示例和信息。
警报
指标和图表仅构成监控解决方案的一半。当你的应用决定走下悬崖时(它肯定会),就需要有人或某些东西来监控它。如果一个 Pod 在部署中死掉,Kubernetes 会用新的 Pod 来替换它。但如果 Pod 一直重启,就需要有人处理这个问题,这就是警报和通知发挥作用的地方。
什么是一个好的告警?除了针对每个应用程序的 Golden Signals 设置告警外,你可能还需要围绕某个关键指标设置告警进行监控。当这种情况发生时,创建告警时要遵循以下几个指南:
-
不要将阈值设置得过低。将告警阈值设置得过低可能导致告警频繁触发,然后如果你的指标波动较大,告警又会被清除。这种行为被称为 告警波动,它是很常见的现象。系统不应该为波动的指标每隔几分钟就发出告警,因为值班工程师在收到通知后会感到很有压力,而当他们赶到时,发现告警已经被清除。
-
避免创建不可操作的告警。不要为无法采取措施解决的服务创建告警。我把这些告警称为 状态告警。没有什么比半夜被叫醒来看守一个不需要采取行动的告警更让值班工程师沮丧的了。
对于本书,我提供了三个告警,分别是 HighErrorRatePerSecond、HighConnectionRatePerSecond 和 HighCPUThrottleRate(稍后会详细介绍)。这些告警位于 Prometheus 配置文件(configmap.yaml)中的 prometheus.rules 部分。Prometheus 使用告警规则来决定某个指标是否处于不希望的状态。一个 告警规则 包含告警名称、PromQL 查询、阈值和标签等信息。对于你的示例,我违反了自己设定告警的建议,故意将阈值设置得非常低,使得 bbs-warrior 很容易触发这些告警。在学习实时指标和告警时,活生生的例子最为有效!
审查 Prometheus 中的 Golden Signal 告警
你可以在 Prometheus 或 Alertmanager 的 Web 界面中查看告警。不同之处在于,Alertmanager 仅显示正在触发的告警,而 Prometheus 会显示所有告警,无论它们是否正在触发。你需要查看所有告警,所以在这个示例中你将使用 Prometheus。然而,当某个告警正在触发时,你也应该访问 Alertmanager 的界面。
在最初打开 Prometheus 的浏览器中,点击左上角导航栏中的 Alerts 链接。你应该能看到三个提供的 telnet-server Golden Signals 告警:HighErrorRatePerSecond、HighConnectionRatePerSecond 和 HighCPUThrottleRate。这些告警是在你之前安装 Prometheus 时创建的。告警页面应该像 图 9-4 一样显示。

图 9-4:Prometheus 对 telnet-server 的告警
每个警报将处于三种状态之一:待处理(黄色)、非活动(绿色)或触发(红色)。在图 9-4 中,HighConnectionRatePerSecond警报处于触发状态。其他两个警报,HighCPUThrottleRate和HighErrorRatePerSecond,由于未被触发,处于非活动状态。由于 bbs-warrior 的随机性,你的警报页面可能与我的不同。如果你的页面没有显示任何触发状态的警报,可以等几分钟生成更多流量,然后刷新浏览器页面。在我为本章进行的所有测试中,我总是至少有一条警报过渡到触发状态。
HighErrorRatePerSecond警报关注每秒接收到的连接错误数量。如果两分钟窗口内连接错误的速率超过 2 次,警报将进入触发状态。在我的本地 Kubernetes 设置中,此警报当前处于非活动状态。
下一条警报,HighConnectionRatePerSecond,用于检测在两分钟时间框架内连接速率是否超过每秒 2 次。目前,这条警报处于触发状态。图 9-4 显示,当前我的连接速率超过每秒 9.1 次,远远超过设定的 2 的阈值。我已经在浏览器中展开了这条警报,以显示警报提供的元数据,采用键值对布局。在所有三个警报的标签部分,我设置了一个名为severity的标签,其值为Critical,这样可以更容易区分非关键警报和需要立即处理的警报。稍后,你将在 Alertmanager 中使用这个标签来路由重要的警报。注释部分包括描述、摘要和指向运行手册的链接,运行手册是为不熟悉的工程师提供服务的“蓝图”,解释了为何、如何以及如何做这项服务。提供这些信息对于发送警报通知至关重要,因为它能让值班人员在故障排除时了解应关注的内容。
最后一条警报,HighCPUThrottleRate,用于检测 CPU 饱和度是否过高。如果 CPU 在两分钟的时间窗口内被 Kubernetes 限流超过 300 微秒,系统将切换到触发状态。此警报当前处于非活动状态,但通常情况下,我建议在追踪 CPU 限流时使用至少五分钟的时间窗口。因为较小的时间窗口可能会让你更容易在短暂的工作负载峰值期间触发警报。
路由和通知
你已经验证了指标和警报可见并处于活动状态,现在,你应该配置 Alertmanager 以发送电子邮件通知。一旦警报处于触发状态,Prometheus 会将其发送到 Alertmanager 进行路由和通知。通知可以通过短信、推送通知或电子邮件发送。Alertmanager 将这些通知方式称为接收器。路由用于匹配警报并将其发送到特定的接收器。一种常见的模式是根据特定标签路由警报。警报标签在 Prometheus 的configmap.yaml文件中设置。稍后在启用通知时,你将使用这种模式。
提供的 Alertmanager 配置位于alertmanager/configmap.yaml文件中。它设置为匹配所有severity标签为Critical的警报,并将它们路由到none 接收器,即一个不会在警报发生时通知任何人的黑洞。这意味着,要查看警报是否被触发,你需要访问 Alertmanager 或 Prometheus 上的网页。这个设置并不理想,因为每隔几分钟刷新一次网页会变得很麻烦,所以你将把任何severity标签设置为Critical的警报路由到email接收器。如果你在跟着操作,这一步完全是可选的,但它向你展示了如何在 Alertmanager 中配置接收器。
启用电子邮件通知
要将警报路由到email接收器,你需要编辑 Alertmanager 的配置。我已经在configmap.yaml文件中为email接收器和route块提供了一个模板。这个电子邮件示例是基于 Gmail 账户的,但你可以根据需要修改为任何电子邮件提供商。更多详情请参见www.prometheus.io/docs/alerting/latest/configuration/#email_config/。
打开 Alertmanager 的configmap.yaml文件,用你喜欢的编辑器打开,它应该是这样的:
`--snip--`
global: null
receivers:
1 #- name: email
# email_configs:
# - send_resolved: true
# to: <`GMAIL_USERNAME@gmail.com`>
# from: <`GMAIL_USERNAME@gmail.com`>
# smarthost: smtp.gmail.com:587
# auth_username: <`GMAIL_USERNAME@gmail.com`>
# auth_identity: <`GMAIL_USERNAME@gmail.com`>
# auth_password: <`GMAIL_PASSWORD`>
2 - name: none
route:
group_by:
- job
group_interval: 5m
group_wait: 10s
3 receiver: none
repeat_interval: 3h
routes:
4 - receiver: none
match:
severity: "Critical"
在这里,你有两个接收器,分别命名为email 1 和none 2。none接收器不会将警报发送到任何地方,但当取消注释时,email接收器将把警报发送到一个 Gmail 账户。取消注释email接收器的相关行,然后用一个你可以用于测试的电子邮件账户替换它。
配置好电子邮件设置后,将routes部分下的receiver 3 更改为email。这将配置 Alertmanager,在警报的severity标签设置为Critical时,将任何警报路由到email接收器。接收器行 4 现在应该如下所示:
- receiver: **email**
你仍然会有默认的或用于捕获所有警报的接收器 3,设置为none,因此任何不符合severity标签规则的警报都会发送到这里。保存此文件,因为你已经完成了对它的修改。
应用 Alertmanager 的配置更改
接下来,你将更新 Kubernetes 集群中的 Alertmanager ConfigMap。由于本地文件包含集群中不存在的更改,请在终端中输入以下内容:
$ **minikube kubectl -- apply -f monitoring/alertmanager/configmap.yaml**
configmap/alertmanager-config configured
下一步是告诉 Kubernetes 重启 Alertmanager 部署,以便它能够获取新的配置更改。在同一个终端中,输入以下命令来重启 Alertmanager:
$ **minikube kubectl -- -n monitoring rollout restart deployment alertmanager**
deployment.apps/alertmanager restarted
Alertmanager Pod 应该会在几秒钟后重启。如果你有任何处于触发状态的警报,你应该开始在收件箱中收到邮件通知。根据 Alertmanager 和你的邮件提供商,通知可能需要一些时间才能出现。
如果你没有收到任何通知邮件,请检查几个常见问题。首先,确保configmap.yaml文件没有任何拼写错误或缩进问题。YAML 文件很容易对齐错误。其次,确保你输入的邮件设置符合邮件提供商的要求。查看 Alertmanager 的日志,以查找这些和其他常见问题。输入以下kubectl命令来查看日志中的错误:
$ **minikube kubectl -- -n monitoring logs -l app=alertmanager**
如果你需要出于某种原因禁用通知,请将路由接收器设置回none,修改configmap.yaml文件,应用清单更改,并重启。
现在你已经为 telnet-server 的黄金信号配置了警报和通知。
总结
指标和警报是监控应用程序的基础。它们提供了服务健康状况和性能的洞察。在本章中,你了解了黄金信号监控模式,以及如何在 Kubernetes 集群中使用 Prometheus、Alertmanager 和 Grafana 安装现代监控栈。最后,你学会了如何配置 Alertmanager 以发送关键警报的邮件通知。
在下一章,我们将转变话题,讨论在主机或网络上常见的故障排除场景,以及你可以使用的诊断工具。
第十章:主机故障排除

工程师们花费大量时间试图弄清楚为什么某些事情不能按预期运行。仪器设备、跟踪和监控在确定主机或应用程序的健康状态方面起着重要作用,但有时仅靠可观察性还不够。有时候,你需要挽起袖子找出为什么某些事情出了问题以及如何修复它。换句话说,你将进行故障排除和调试。故障排除 是分析系统并排除潜在问题原因的过程。而调试 则是发现问题原因并可能实施修复步骤的过程。两者之间的区别微妙,实际上,你可以把调试看作是故障排除的一个子集。本章中的大部分内容都可视为故障排除。
在本章中,你将探讨 Linux 主机上常见的性能问题和可能遇到的问题。你将查看症状、可以用来诊断各种潜在问题的命令,以及故障排除后要采取的下一步措施。通过本章的学习,你将扩展你的命令行工具库和侦察技能,以解决常见问题。
故障排除与调试:入门指南
故障排除和调试是一门艺术,而非一门精确的科学。很少会看到一个大型霓虹灯牌上有一个指向确切问题的箭头。大多数时候,你会找到一串串面包屑,从线索到线索引导你。你可能需要在草丛中爬行,可能会在找到所需内容之前抓狂。但诊断一个破损系统可能会带来极大的满足感,找到困扰你的客户或同事的问题会让你感到惊喜。
但即使是一位艺术家也需要方法,拥有一套标准的步骤和技术,每当你调查问题时都跟随,是开始的好方法。因此,以下是在面对我们称之为主机的这些善变的野兽时要记住的一些技巧:
-
从简单开始。在解决问题时,很容易贸然下结论并假设是最坏的情况。相反,要有方法论,建立在你所获取的知识基础之上。问题通常出在人为错误。
-
建立心理模型。理解系统的角色及其与其他系统的交互方式将有助于你更快地进行故障排除。你会发现自己花费更少的时间担心架构问题,更多时间解决实际问题。
-
给自己时间构建理论。你可能会想抓住找到的第一个线索,但检查一下面包屑能否引领你更远总是值得的。制定一个测试来验证你的理论。
-
确保主机之间使用一致的工具。确保你的主机是使用相同的工具构建的。没有什么比登录到一台主机时发现它与其他主机不同更糟糕的了。工具的一致性是通过自动化构建主机的好处之一。
-
保持记录。保持一个高层次的问题、症状和修复的记录,这样你就不会忘记关于某个问题的重要细节。你的未来的自己会感谢你的。
-
知道什么时候寻求帮助。如果你的业务依赖于解决一个问题,但你在找不到原因时感到困惑,最好发出求救信号。经验丰富的人通常能够提供帮助,某一天,你也会将这些知识传递下去,甚至回报这份帮助。
情景:高负载平均值
Linux 有一个名为负载平均值的度量,它可以反映主机的忙碌程度。负载平均值在计算时会考虑 CPU 和 I/O 等数据。系统的负载会以 1 分钟、5 分钟和 15 分钟的平均值显示。乍一看,任何一个高值可能都会被认为是问题。但排查高负载平均值时可能会比较棘手,因为高负载并不总是意味着主机处于降级状态。一台忙碌的主机可能会有较高的负载,但仍然能够正常响应请求和命令。就像两个人体温相同,但一个人保持清醒,正常运作,另一个人则躺在床上,行动迟缓。每台主机和每种工作负载都是不同的,因此你需要首先确定你主机的正常负载范围。一个简单的经验法则是,如果负载平均值大于 CPU 核心数,那么可能有进程在等待,导致延迟或性能下降。在调查这种情况时,第一步是确定高负载,并尽量找到可能导致负载增加的进程。
uptime
输入 uptime 命令以显示主机的运行时间、已登录用户的数量以及系统负载。它以 1 分钟、5 分钟和 15 分钟的平均值报告负载:
$ **uptime**
09:30:38 up 47 days, 31 min, 2 users, load average: 8.05, 1.01, 0.00
这台四核 CPU 主机已经运行了 47 天 31 分钟,当前有 2 个用户 登录。1 分钟负载平均值是8.05。5 分钟负载平均值是1.01,这意味着在 1 到 5 分钟的运行期间,系统的负载正在增加。你之所以知道这一点,是因为 15 分钟的负载平均值是 0.00(那个时间没有负载)。如果这些数字相反,即 15 分钟的负载值较高,而 1 分钟的负载为零,那么你可以推断出负载的激增并不是持续发生的,而是发生在大约 15 分钟前。由于这个负载似乎在不断增加,并且已经持续攀升超过 5 分钟,同时它大于 CPU 核心数,因此可能值得调查其原因。
top
top命令显示有关系统和在该主机上运行的进程的信息。它提供了诸如 CPU 占用百分比、负载平均值、内存和进程信息等详细内容。执行top命令可以启动一个交互式实时仪表板,显示系统信息,如图 10-1 所示。

图 10-1:在大部分空闲的主机上执行top命令的输出
默认情况下,top会根据CPU百分比对所有进程进行排序。第一行显示的是在给定轮询周期内使用最多CPU百分比的进程。显示会每 3.0 秒刷新(轮询一次),因此你需要观察top几轮,才能决定哪个进程或数据可能是导致高负载的原因。
以下是top报告中的一段,其中一个进程使用了 120%的 CPU:
PID USER ... RES SHR S %CPU %MEM TIME+ COMMAND
3048 root ... 177740 5164 S 120.3 1.8 173:02.78 fail2ban-server
关键列是PID、RES、%CPU、%MEM和COMMAND。(为了可读性,其他列在此省略。)fail2ban-server命令(在COMMAND列中)使用了 120.3%的 CPU,并且消耗了大约177,740KB 的内存,如RES列所示。该进程使用了主机总内存的1.8%(%MEM)。综合来看,调查进程3048,查明其为何消耗如此多的 CPU 资源,是个不错的主意。
后续步骤
在负载平均值较高的情况下,你需要更深入地分析问题进程。也许这个应用程序配置不当、卡住了,或者在等待外部资源(如磁盘或 HTTP 请求)。也有可能是主机的规格不足以应对其使用场景。如果是云实例,也许 CPU 核心数或磁盘 IOPS 不够。另外,也要检查在此期间主机是否流量增加,这可能表示出现了间歇性的流量激增。你还可以使用vmstat、strace和lsof等工具,进一步了解进程与系统的交互。(你将在后续章节中详细了解这些工具。)
场景:高内存使用
临时的流量激增、性能问题或内存泄漏的应用程序,都可能导致内存以较高的速度被消耗。调查高内存使用的第一步是确保主机确实存在内存不足的情况。Linux 倾向于将所有内存用于缓存和缓冲区,因此可能看起来空闲内存较少。但实际上,Linux 内核可以在需要时将这些缓存内存重新分配到其他地方。free、vmstat和ps命令可以帮助识别使用了多少内存,以及是哪个进程可能是罪魁祸首。
free
free 命令通过显示运行时的已用和可用内存,提供了一个快速的系统内存检查。传递 -h 和 -m 标志,指示 free 命令以人类可读的(-h)格式,使用 兆二进制字节(-m)为单位显示所有输出字段。在 人类可读格式 中,数据以类似 兆二进制字节 或 吉比字节 的单位出现,而不是字节。以下示例显示了一个内存不足的主机。输入以下命令以显示内存:
$ **free -hm**
total used free shared buff/cache available
Mem: 981Mi 838Mi 95Mi 3.0Mi 47Mi 43Mi
Swap: 1.0Gi 141Mi 882Mi
系统总内存为 981Mi,其中 838Mi 被 used,95Mi 为 free。buff/cache 列包含从磁盘读取的数据和相关元数据的信息。这些数据被用来加速检索,如果你需要再次访问它。正因为如此,Linux 尝试使用所有的系统内存,而不是让它空闲。若系统内存不足,Linux 主机会将数据交换到磁盘中。正如你能想象的那样,使用磁盘作为内存比使用实际的 RAM 要慢得多。如果 Swap 的 free 列数值过低,系统可能会比平时更慢。在这个例子中,系统仅略微交换到磁盘(141Mi),这可以是正常现象。
used 和 free 列在 Linux 主机上可能会让人误解。Linux 喜欢利用系统的每一份 RAM,因此快速查看时可能看起来主机内存不足。或者,就像这个例子一样,它可能显示比实际可用的内存更多。这里,free 列显示 95Mi,但根据 available 列,实际上只剩下 43Mi。当使用 free 命令显示系统内存时,注意 available 列,它是判断系统和新进程实际可用内存的一个晴雨表。
从这个示例中看出可用内存非常少,可以安全地说,这个主机存在内存短缺。系统剩余大约 43Mi(从 1Gi 中)可能会导致稳定性问题,并且阻止新进程的创建。它还可能迫使 Linux 内核调用内存不足管理器(OOM),并选择一个进程进行终止,这可能会导致不可预期的行为。
vmstat
vmstat 命令提供了关于进程、内存、IO、磁盘和 CPU 活动的有用信息。它可以在一段时间内报告这些数据,这是对 free 命令的升级,使得趋势更加容易识别。你将向 vmstat 命令传递两个参数:delay,指定每次轮询之间的时间延迟,以及 count,指定 vmstat 获取数据的次数,直到停止。对于这个示例,你将以一秒的延迟轮询五次数据。输入以下命令来轮询数据:
$ **vmstat 1 5**
procs ---------memory-------- --swap-- -----io---- -system- ---cpu--------
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 54392 74068 7260 117804 0 10 84 432 81 158 3 1 96 0 0
1 0 54392 73864 7260 117852 0 0 8 0 379 104 44 0 56 0 0
1 2 54392 71768 484 38724 104 0 496 196 469 327 41 1 57 1 0
1 0 54392 71508 484 39768 20 0 1024 0 357 82 44 0 56 0 0
1 0 54392 71508 484 39768 4 0 0 0 370 43 46 0 54 0 0
vmstat报告分为多个类别:procs、memory、swap、io、system和cpu。每个类别包含类似的列。数据的第一行是自上次启动以来每个统计量的平均值。由于你正在寻找内存使用高的情况,你只会关注vmstat输出中的memory和swap部分。
memory部分的swpd列显示使用的总交换空间;在本例中约为 54Mi(54,392Ki)。接下来是free列。根据vmstat,空闲内存在轮询快照中波动在 71,000Ki 到 74,000Ki 之间。这并不意味着你只有 71,000Ki 可用的内存;这是一个估算,因为可释放的缓存和缓冲区。
在swap部分下面有两列:si(交换入)和so(交换出)。si和so列表示你正在将内存页到磁盘和从磁盘交换内存。曾经有一段时间,你正在以约104KiB 每秒的速度从磁盘交换内存。如前所述,少量交换是可以接受的,但是如果空闲内存不足并且还在交换,则通常表示存在内存瓶颈。
在procs下的r和b列可以提供可能存在瓶颈的良好指示。r列是运行中(或等待运行)进程的数量。这里的高数值可能表示存在 CPU 瓶颈。b列是处于不可中断睡眠状态的进程数。如果b列中的数值较高,这可能是一个信号,表明有进程在等待资源,比如磁盘或网络 IO。
ps
如果主机的内存使用量很高,你将要检查所有运行中的进程,找出内存使用情况。ps命令提供了主机上当前进程的快照。你将使用一些标志来缩小结果范围,并仅显示按最大内存排序的前 10 个主机。输入以下命令:
$ **ps -efly --sort=-rss | head**
S UID PID PPID C PRI NI RSS SZ WCHAN STIME TTY TIME CMD
R root 931 930 93 80 0 890652 209077 - 05:56 ? ... memory-hog
S root 469 1 0 -40 - 18212 86454 - Jan16 ? ... /sbin/multipathd
S root 672 1 0 80 0 10420 233460 - Jan16 ? ... /usr/lib/snapd
S root 350 1 0 79 -1 7416 12919 - Jan16 ? ... /lib/systemd
使用-efly和--sort=-rss标志显示所有进程的长格式。RSS(常驻集大小)列显示进程使用的非可交换物理内存量(以千字节为单位),按降序排列。你将这些结果传输到head命令,默认显示前 10 行。CMD列显示每个进程所属的命令。在这个示例中,memory-hog命令根据RSS列使用了约 890MB(890,652KB)的物理内存。考虑到这台主机只有 1Gi 的总内存,该应用程序正在占用所有内存。
下一步
你解决高内存使用问题的步骤将取决于系统和/或用户的风险因素。如果你在处理生产系统,你需要小心行事,查看日志、跟踪记录和度量数据,以确定问题何时何地开始。如果这是生产系统上的新行为,回滚memory-hog到先前版本是一个很好的第一步。(任何时候你能快速恢复生产环境,都是一次胜利。)一旦在生产环境中修复了问题,可以在不同环境中进行性能分析,深入挖掘线索,找出内存使用的原因和位置。
场景:高 iowait
如果一个主机花费过多时间等待磁盘 I/O,我们称其为高 iowait。衡量 iowait 的方法是检查 CPU 空闲的时间百分比,因为系统存在未完成的磁盘 I/O 请求,这些请求阻塞了进程的其他工作。显著的 iowait 通常会导致主机负载增加,可能还会导致报告的 CPU 使用率高于正常水平。换句话说,如果 CPU 在等待磁盘响应,它就没有足够的时间处理系统其他部分的请求。高 iowait 的原因可能是磁盘老化、慢速或故障。另一个原因可能是应用程序正在执行大量的磁盘读写。如果你处于虚拟化环境中,慢速的网络附加存储很可能是瓶颈所在。
所有系统都会有一定的 iowait,现代 CPU 的速度通常快于存储。然而,单独的高 iowait 并不足以表明问题。一些系统即使出现高 iowait 也能正常运行,而另一些系统则会出现明显的瓶颈迹象。目标是找出伴随高 iowait 出现的问题。正常 iowait 和高 iowait 之间没有明确的界限,因此我将高 iowait 的阈值设定为持续超过 30% 的情况。
两个命令行工具,iostat和iotop,将帮助你排查高 iowait 的主机问题。
iostat
iostat命令行工具报告设备的 CPU 和 I/O 状态,因此它是帮助你确定系统是否出现 iowait 的绝佳工具。如果系统默认未安装iostat,可以使用包管理器安装 sysstat 包。
如前所述,拥有一定的 iowait 是正常的。你需要关注的是异常行为,因此你需要在一段时间内轮询系统,以便更好地了解问题,就像你使用vmstat命令时一样。在这个例子中,输入下面的命令,每秒轮询一次,持续 20 次。命令和输出应如下所示:
$ **iostat -xz 1 20**
`--snip--`
avg-cpu: %user %nice %system %iowait %steal %idle
6.25 0.00 27.08 66.67 0.00 0.00
Device r/s rkB/s w/s wkB/s %util ...
vda 0.00 0.00 1179.00 712388.00 100.00 ...
第一个报告 iostat 打印的是主机上次启动时的数据。由于这些数据与当前的故障排除场景无关,因此我在此省略了它,以及 Device 输出中的多个列。-xz 标志只显示使用扩展统计格式的活动设备。w/s 列显示 vda 设备每秒执行大量写请求(1179.00)。CPU 大约 66.67% 的时间都在等待未完成的磁盘请求(%iowait)。最后,作为该磁盘非常繁忙的进一步证据,%util(百分比利用率)列显示为 100%。
你可以得出结论,主机正遭遇持续的高 iowait,而不仅仅是间歇性的。更重要的是,你知道 iowait 发生在名为 vda 的设备上。从这里开始,值得尝试找到可能导致 iowait 增加的进程。你可以使用 iotop 命令来实现,接下来你将探索这个命令。
iotop
iotop 命令以类似 top 的格式显示 I/O 使用情况。它不仅提供主机 I/O 的概述,还允许你深入到进程级别,定位任何可能导致大量磁盘 I/O 的进程。大多数发行版默认不包括 iotop,因此你需要使用包管理器来安装它。
在运行 iotop 时,你可能只希望限制输出显示执行 I/O 的活跃进程,使用不断轮询的批处理模式,以保持输出简洁并揭示可能的 I/O 模式。此命令需要提升的权限,因此你需要使用 sudo 或以特权用户身份运行它。请输入以下命令:
$ **sudo iotop -oPab**
Total DISK READ: 15.04 M/s | Total DISK WRITE: 446.28 M/s
Current DISK READ: 15.04 M/s | Current DISK WRITE: 321.58 M/s
PID PRIO USER DISK READ DISK WRITE SWAPIN IO COMMAND
88576 be/4 bob 512.00 M 616.81 M 0.00 % 83.26% heavy-io
469 rt/4 root 0.00 B 0.00 B 0.00 % 0.00% multipathd -d -s
`--snip--`
-oPab 标志使得 iotop 仅显示执行 I/O 的进程,并以批处理模式显示累积统计信息。在这个示例中,heavy-io 命令的 IO 列显示为 83.26%。PID 列报告了进程 ID,在此情况下为 88576。在报告中没有其他进程使用大量 I/O,因此可以推测 heavy-io 进程是导致高 iowait 的原因之一。
下一步
在检查统计信息并找到导致高 iowait 的进程 ID 后,你可能想要探索一下这个应用程序的用途。如果你有源代码或配置文件,可以通过检查进程访问的磁盘操作或文件来寻找更多线索。另一个导致高 iowait 的原因可能是你的虚拟机托管在云服务提供商上,而你为磁盘预配置的 I/O 操作数量不足。检查磁盘指标以确认并调整数字,以补偿负载。如果一切都失败了,可以使用像 lsof 这样的工具检查哪些文件是打开的,使用 strace 跟踪进程正在进行的系统调用,或者使用 dmesg 查找任何硬件内核错误。(我们将在本章后面讨论 lsof、strace 和 dmesg。)
场景:主机名解析失败
传统上,当一个服务需要连接到另一个服务时,它会使用域名系统(DNS)查找 IP 地址并发送请求。DNS 是主机 IP 地址映射的目录。它允许我们使用像 google.com 或 nostarch.com 这样的名称,而无需知道这些主机的确切 IP 地址。人类比起像 142.250.72.78 或 104.20.208.3 这样的 IP 地址,更容易记住名字。想象一下,如果你不得不通过尝试记住一个商店的经纬度坐标来找到它,而不能使用 GPS,而只是记住它在 123 Main Street,你会迷路…很多次。
*假设在这种情况下,你有一个应用程序尝试连接到本地环境中的 Postgres 数据库。应用程序开始在日志中输出类似这样的错误:
psql: error: could not translate host name "db.smith.lab" to address: Temporary failure in name resolution
看起来应用程序无法解析db.smith.lab的 DNS 记录。名称解析失败可能有多种原因。我们将探索一些工具来帮助排除这个错误。在此之前,你需要真正理解主机如何使用 DNS。
resolv.conf
在任何 Linux 主机上,调查 DNS 问题的第一步是查看/etc/resolv.conf文件,该文件提供了要查询的 DNS 服务器信息以及任何特殊选项(例如超时或安全性)。以下是典型 Ubuntu 主机的resolv.conf文件:
# This file is managed by man:systemd-resolved(8). **Do not edit.**
#
# This is a dynamic resolv.conf file for connecting local clients to the
# internal DNS stub resolver of systemd-resolved. This file lists all
# configured search domains.
#
# Run "resolvectl status" to see details about the uplink DNS servers
# currently in use.
#
# Third party programs must not access this file directly, but only through
# the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a
# different way, replace this symlink by a static file or a different
# symlink.
#
# See man:systemd-resolved.service(8) for details about the supported
# modes of operation for /etc/resolv.conf.
nameserver 127.0.0.53
options edns0 trust-ad
该文件包含了多个描述systemd-resolved的注释,最重要的是,它指出你不应编辑此文件。此文件由systemd提供的systemd-resolved服务控制,并且在主机或服务重新启动时,服务会覆盖该文件。在注释之后,从底部倒数第二行包含了nameserver关键字和查询 DNS 服务器的 IP 地址。在这台 Ubuntu 主机上,nameserver设置为127.0.0.53,意味着任何 DNS 请求都会发送到这个地址。如果本地resolver不知道查询的答案,resolver将把请求转发到上游的DNS 服务器。
通常,当你从 DHCP 服务器获取 IP 地址租约时,上游的 DNS 服务器会被设置。这些上游的 DNS 服务器可以是处理所有请求的内部服务器,也可以是互联网上使用的众多公共服务器之一。例如,Cloudflare 在 1.1.1.1 提供公共 DNS 服务器。全球范围内有许多公共 DNS 服务器可供使用。
文件中的最后一行使用options关键字修改了一些特定的解析器属性。在这个示例中,设置了edns0和trust-ad选项。edns0选项为 DNS 协议启用扩展功能。详情请参阅 RFC 2671(tools.ietf.org/html/rfc2671/)。trust-ad,或称为认证数据(AD)位选项,将在所有外发的 DNS 查询中包含认证数据,并在响应中保留认证数据。这将允许客户端和服务器相互验证交换内容。此选项是一个更大安全扩展集的一部分,旨在增强 DNS 的安全性。更多信息请参见www.dnssec.net/。
resolvectl
在这个示例主机的resolv.conf文件中,DNS 服务器被设置为127.0.0.53,这是一个本地解析器,代理任何它无法识别的 DNS 请求。每个 DNS 服务器通常会有一个上游服务器,转发它无法识别的请求。由于你正在使用systemd-resolver,你可以使用名为resolvectl的工具与本地解析器进行交互。如果这个命令行工具缺失,你可以通过包管理器安装它。
你需要知道本地 DNS 解析器(127.0.0.53)将未知请求发送到哪里。这可能有助于你找出为什么db.smith.lab解析失败。要查看解析器指向的上游 DNS 服务器,可以输入以下命令:
$ **resolvectl dns**
Global:
`--snip--`
Link 2 (enp0s3): 10.0.2.3
结果显示,下游 DNS 服务器被设置为接口enp0s3的10.0.2.3,这是该主机的默认接口和路由。你的设置和接口可能不同。当该主机上的任何应用程序尝试连接到db.smith.lab时,它首先会向127.0.0.53发送 DNS 请求,询问该主机名解析为哪个 IP 地址。本地解析器首先会在本地查找答案。如果映射存在,结果会立即返回。然而,如果答案未知,解析器会将请求转发到上游 DNS 服务器10.0.2.3。现在,如果10.0.2.3的 DNS 服务器知道db.smith.lab的答案,它将返回响应给本地解析器,后者再向用户响应。如果它不知道答案,上游服务器会将请求转发给其上游服务器,直到到达该域名的权威服务器。
现在你已经知道了本地解析器和上游 DNS 服务器的 IP 地址,你可以查询这两个地址以寻找线索。
dig
dig命令行工具查询 DNS 服务器并显示结果。当你在排查 DNS 问题或需要获取主机的 IP 地址时,它非常有用。你只需将主机名传递给dig,响应将提供有关查询和响应服务器的信息。
尝试查询本地解析器以获取db.smith.lab的 IP 地址。输入以下命令:
$ **dig db.smith.lab**
; DiG 9.16.1-Ubuntu db.smith.lab
;; global options: +cmd
;; Got answer:
;; -HEADER- opcode: QUERY, status: 1SERVFAIL, id: 35816
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; **QUESTION SECTION**:
;db.smith.lab. IN 2A
;; Query time: 32 msec
;; **SERVER**: 3127.0.0.53#53(127.0.0.53)
`--snip--`
status 字段 1 告诉我们查询是否成功。成功的查询状态为 NOERROR。在这个例子中,状态设置为 SERVFAIL,表示无法提供答案。这是合理的,因为本地 DNS 不知道如何找到 db.smith.lab。QUESTION SECTION 显示了发送到 DNS 服务器的查询内容。在此案例中,查询是针对 db.smith.lab 的 A 记录 2。(A 记录 是一种 DNS 记录类型,用于将域名映射到 IP 地址。)SERVER 部分告诉我们查询是向哪个 DNS 服务器发送的。在这个例子中,查询是发送给本地解析器 (127.0.0.53) 3,符合预期。
为了测试你的上游服务器,你可以指示 dig 与特定的 DNS 服务器进行通信,而不是使用本地的。这将帮助你验证 DNS 解析是否在本地或上游出现了问题。输入以下命令:
$ **dig @10.0.2.3 db.smith.lab**
...
;; -HEADER- opcode: QUERY, status: SERVFAIL, id: 57409
...
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;db.smith.lab. IN A
...
;; Query time: 32 msec
;; SERVER: 10.0.2.3#53(10.0.2.3)
;; WHEN: Sat Jun 19 18:20:23 UTC 2022
;; MSG SIZE rcvd: 116
@10.0.2.3 参数使得 dig 跳过本地 DNS,直接查询上游主机。然而,结果是相同的,你依然收到了 SERVFAIL 状态。这意味着上游服务器无法为主机名提供答案。你知道查询的是正确的服务器,因为 SERVER 部分现在显示的是 10.0.2.3,而不是 127.0.0.53。
为了安全起见,你应该再进行一次查询,确保本地和上游 DNS 服务器都正常工作。首先,你可以查询一个你确定会返回响应的 DNS 记录。这将帮助你验证 DNS 是否只对 db.smith.lab 失效,还是所有域名都无法解析。输入以下命令查询 google.com 的 A 记录:
$ **dig google.com**
...
;; -HEADER- opcode: QUERY, status: NOERROR, id: 15154
...
;; QUESTION SECTION:
;google.com. IN A
;; ANSWER SECTION:
google.com. 300 IN A 142.250.72.78
;; Query time: 36 msec
;; SERVER: 127.0.0.53#53(127.0.0.53)
...
状态是 NOERROR,并且你在 ANSWER SECTION 收到了 142.250.72.78 的 A 记录。这意味着 DNS 服务器可以解析其他主机名而没有错误,但出于某种原因,它不知道 db.smith.lab 的 A 记录。请注意,当出现错误或没有答案时,ANSWER SECTION 会从结果中省略。
下一步
如果针对某个主机名存在解析问题,而 DNS 正常工作并能够解析其他主机名,那么问题可能出在缺少将主机名映射到 IP 地址的信息的 DNS 解析器。如果你的 DNS 托管在像 Amazon Route53 这样的服务上,请确保记录没有因配置管理软件或人为错误被删除。如果你本地管理 DNS 服务器,可以查看是否存在该 A 记录。如果没有,可能是配置中存在语法错误,导致记录无法提供,或者 DNS 服务器需要重启才能读取新的记录。
场景:磁盘空间不足
你最终会用完磁盘空间。当这种情况发生时,你需要找出是什么占用了所有的空间。罪魁祸首可能是行为不正常的应用程序、没有限制的日志文件,或是 Docker 镜像的堆积。为了找出问题的根源,你首先需要找出哪个驱动器和文件系统空间不足。一旦确定了这些位置,你就可以在磁盘上搜索可能占用了大量空间的文件。
df
df 命令显示主机上所有挂载文件系统的可用磁盘空间。它有多个选项,但 -h 标志(用于人类可读格式)可能是你需要的全部。要查看挂载文件系统的可用空间,请在终端中输入以下命令:
$ **df -h**
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 25G 25G 0 100% /
`--snip--`
在这个例子中,设备 /dev/vda1 正在使用其 25G 磁盘空间的 100%。文件系统挂载在 /,即根目录。如果你的主机有多个挂载的磁盘,它们也会在输出中显示出来。
find
find 命令用于在文件系统中查找目录和文件,你可以通过仅查找符合特定条件或某个目录的文件来过滤结果,缩小搜索范围。你还可以按文件在磁盘上的大小来定位文件。
在你的例子中,既然你知道在运行 df 命令后 root 文件系统的空间已经用完,你应该让 find 在此处进行搜索。你将执行 find 命令,搜索 root 文件系统,查找所有超过 100M 的文件。你将按大小排序,并使用 head 命令显示前 10 个。这可能会花费一些时间,具体取决于磁盘上文件的数量。输入以下命令:
$ **sudo find / -type f -size +100M -exec du -ah {} + | sort -hr | head**
`--snip--`
10G /var/log/php7.2-fpm.log
5G /var/lib/docker/containers/.../...a3b76-json.log
`--snip--`
对于每个大于 100M 的文件,你将执行 (-exec 标志) du -ah 命令,以人类可读的格式获取文件的磁盘大小。结果按文件大小降序排列,首先显示最大文件。然后,展示前 10 个结果。
此输出显示了一个名为 php7.2-fpm.log 的文件,位于 /var/log 下,大小为 10G。同时,一个位于 /var/lib/docker/containers 的 Docker 容器日志占用了 5G 空间。这两个文件总共占用了 15GB 的磁盘空间。通常,像这样的应用程序日志应该进行轮转,而不会变得这么大。两个文件都如此庞大,应该引起你的警觉,感觉这里有些不对劲。
有了更多的线索之后,检查是否有进程正在使用 php7.2-fpm.log 文件,然后再提出假设。
lsof
使用 lsof 命令列出主机上打开的文件。Linux 主机上的文件可以是常规文件、目录或套接字,仅举几例。你可以搜索由特定进程或特定用户拥有的文件。
你将使用 lsof,该命令需要提升权限,来查找写入 /var/log/php7.2-fpm.log 文件的进程。输入以下命令:
$ **sudo lsof /var/log/php7.2-fpm.log**
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
php-fpm7\. 23496 root 2w REG 252,1 1048580000 1529 /var/log/php7.2-fpm.log
`--snip--`
你必须传递你感兴趣的文件的完整路径。在这种情况下,就是日志文件。php-fpm7命令与PID 23496进程拥有该日志文件。文件描述符是2w,这意味着文件描述符是2,并且文件是以写访问(w)模式打开的。文件的TYPE是REG(常规),表示它是一个典型的 ASCII 文本文件。
下一步
当你的空闲磁盘空间不足,并且你已追踪到一个占用空间的文件时,你有几个选项可以解决这个问题。由于这个日志文件当前正在使用,直接截断或删除它对php-fpm7进程来说是不明智的。这样做可能导致进程崩溃或完全停止写入日志。相反,你可以先查看日志输出,看看是否有任何提示性的错误,或者应用程序的日志级别是否可能被卡在debug。此外,这个日志文件可能与 Docker 容器日志文件较大的事实相关,也许这个进程就运行在该容器内。你也可以检查容器日志的内容,看是否有任何明显的错误。关于清理,应该确保主机已设置使用logrotate命令,以定期压缩和旋转日志文件。这可以防止日志文件无限增大,占用过多磁盘空间。logrotate的配置文件位于 Ubuntu 系统的/etc/logrotate.d**/目录下。
情景:连接被拒绝
有时候,服务会拒绝连接,但没有留下明显的原因。例如,假设你有一个内部 API,它报告了较高的错误率,并且其他使用此 API 的服务也抛出了许多错误。应用程序日志中的错误可能类似于以下内容:
Failed to connect to api.smith.lab port 8080: Connection refused
看起来用户在尝试连接到 API 服务器时遇到了Connection refused错误。你知道 Docker 容器是启动并运行的,否则你会收到它宕机的警报。为了排查这个问题,你将使用一些命令,帮助你识别任何与网络或配置相关的问题。
curl
每当你需要检查一个网页服务器是否响应请求,或者只是想获取一些数据或文件时,可以使用curl命令。对于这个示例,你需要验证一个端点是否对所有人都不可用,而不仅仅是其他主机上存在路由问题。如果 API 服务器正常运行,它应该以HTTP 200状态进行响应。为了再次确认 API 服务器是否拒绝连接,你可以通过输入以下命令来使用curl:
$ **curl http://api.smith.lab:8080**
curl: (7) Failed to connect to api.smith.lab port 8080: Connection refused
输出显示你也遇到了Connection refused错误。这通常意味着主机未在你的端口上监听,或者防火墙拒绝了数据包。不论原因是什么,总之某些问题导致了你的 API 请求失败。
ss
ss(套接字统计)命令用于转储主机上的套接字信息。在你的故障排除场景中,你将使用它查看主机上的任何应用程序是否已绑定(或监听)port 8080 上的请求。输入以下命令:
$ **sudo ss -l -n -p | grep 8080**
...0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=1448197,fd=4))
`--snip--`
-l 标志显示主机上的所有监听套接字。-n 标志指示 ss 不解析任何服务名称,如 HTTP 或 SSH,-p 标志显示正在使用该套接字的进程。为了让 ss 确定哪个进程拥有该套接字,需要使用 sudo 或提升权限。我截断了输出行的开头部分以提高可读性,但重要部分显示 docker-proxy 进程正在所有接口上监听端口 8080(0.0.0.0:8080)。接下来,您可以验证指向 api.smith.lab 的请求是否能够到达主机,并确认它的存在。
tcpdump
验证主机上的网络流量的一种方法是使用 tcpdump 命令,该命令有许多选项,可以在一个或所有接口上捕获流量。它甚至可以将网络捕获写入文件,以便以后分析。tcpdump 不仅适用于排除网络故障,还可以用于安全审计。对于你的示例,你将使用它来捕获发送到 api.smith.lab 主机的端口 8080 的网络流量。这样你就可以知道发送到该主机的流量是否已到达目标,并且希望能为你为何收到 Connection refused 错误信息提供一些线索。
在运行 API 应用程序的主机上,在终端中输入以下命令。这将启动在所有接口上捕获任何指向端口 8080 的 TCP 数据包(注意,监听网络接口需要提升权限):
$ **sudo tcpdump -ni any tcp port 8080**
IP 192.168.50.26.50563 > 192.168.50.4.8080: Flags [**S**], seq 3446688967, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 157893401 ecr 0,sackOK,eol], length 0
IP 192.168.50.4.8080 > 192.168.50.26.50563: Flags [**R.**], seq 0, ack 3446688968, win 0, length 0
IP 192.168.50.26.50563 > 192.168.50.4.8080: Flags [**S**], seq 3446688967, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 157893501 ecr 0,sackOK,eol], length 0
IP 192.168.50.4.8080 > 192.168.50.26.50563: Flags [**R.**], seq 0, ack 1, win 0, length 0
-n 标志确保不会尝试解析任何主机或端口名称。-i 标志告诉 tcpdump 在哪个网络接口上监听。在此情况下,指定了 any,表示“在所有接口上监听”。你希望捕获所有发送到端口 8080 的数据包,因为主机上可能有多个网络接口。最后的 tcp port 8080 参数表示你只想捕获那些包含端口 8080 的 TCP 数据包。这些数据包将包括来自客户端和服务器的内容。
让我们聚焦于有助于解决Connection refused错误问题的输出部分。在第一行,IP部分显示源 IP 192.168.50.26 正在尝试连接到 192.168.50.4 的 8080 端口。>(大于号)表示通信的方向,即从一个 IP 到另一个 IP。设置的标志位显示发送的数据包类型。第一个包有一个S(同步)标志。每当客户端想要与另一主机建立连接时,它都会发送同步包。在下一个包中,主机 192.168.50.4 向 192.168.50.26 回复一个重置(R)包。当出现无法恢复的错误时,服务器通常会发送重置包,要求客户端立即终止连接。不为“滚出我家院子!”的重置包所阻碍,客户端再次尝试,发送另一个同步包,这导致服务器 192.168.50.4 又向 192.168.50.26 发送一个重置包。最终,192.168.50.26 的客户端终于明白了,并且连接被关闭。
标志位显示该连接不正常。一个正常的 TCP 连接从客户端发送一个SYN包开始,接着服务器发送一个SYN-ACK包。一旦客户端收到该包,它会返回一个ACK包给服务器,确认最后一个包。这被称为三次握手。详情请参见图 10-2。

图 10-2:TCP 三次握手
你明显看不到从服务器发送的任何其他数据包(除了重置包)。这些重置包会导致连接的客户端报告连接被拒绝。好消息是,你已经验证了连接已经成功到达服务器。坏消息是,你仍然不知道为什么会被拒绝。
下一步
此时,你知道服务正在监听端口 8080。你通过ss命令验证了这一点。根据你使用tcpdump进行的网络抓包,你还知道流量已经成功到达服务器。
下一步要检查的是 Docker 容器和应用程序配置。可能是docker-proxy存在问题,未能将流量转发到运行 API 的容器。另一种可能性是容器启动时内部端口映射错误。你知道外部端口 8080 映射是正确的,因为它正在监听连接。但有可能映射的内部端口配置错误。你可以通过查看 Docker 的系统日志中是否有代理错误,或者运行docker ps <container id> 或 docker inspect <container_id>来检查端口映射。
搜索日志
在几乎所有的故障排除场景中,你很可能需要查看日志。系统和应用程序日志包含了大量的信息,你可以通过命令行查看。现代 Linux 发行版使用systemd,它有一个名为journal的日志收集机制,可以从多个来源(如syslog、auth.log和kern.log)拉取日志事件。这让你可以在一个流中查看和搜索日志。作为一个故障排除“考古学家”,你应该知道日志的位置,以及如何查看和解析它们。
常见日志
大多数 Linux 主机上的系统和应用日志都存储在/var/log目录中。最常见的日志文件有syslog、auth.log、kern.log和dmesg,它们有助于故障排除。根据你的 Linux 发行版,日志文件的名称可能有所不同。
/var/log/syslog
syslog文件包含 Linux 操作系统的一般全局系统消息。以下是一个systemd的日志行示例,表示日志已经完成轮换:
Jun 11 00:00:03 box systemd[1]: Finished Rotate log files.
这一行以时间戳开始,接着是所在主机(box)和报告日志事件的进程(systemd[1])。这一行的最后部分是文本消息。这个结构化的行格式,也叫做syslog,是 Linux 主机日志的默认协议。
/var/log/auth.log
auth.log 文件包含与授权和身份验证事件相关的信息。这使得它成为调查用户登录、暴力破解攻击或跟踪用户sudo命令的好地方。下面是一个auth.log消息的示例:
Jan 15 20:57:35 box sshd[27162]: Invalid user aiden from 192.168.1.133 port 59876
此消息显示了用户aiden通过 SSH 登录失败,来源 IP 地址是192.168.1.133。
/var/log/kern.log
kern.log 文件是查看 Linux 内核消息的好地方,比如硬件问题或与 Linux 内核相关的一般信息。以下日志行展示了 Linux 内存不足管理器(OOM)正在运行的情况:
Jan 16 19:18:47 box kernel: [2397.472979] Out of memory: Killed process 20371 (nginx) total-vm:571408kB, anon-rss:524540kB, file-rss:456kB, shmem-rss:8kB, UID:0 pgtables:1100kB oom_score_adj:1000
进程20371被Out of memory管理器终止,因为系统内存不足。
/var/log/dmesg
dmesg日志包含自上次启动以来主机的启动信息。这些信息可能是 USB 设备被识别,或是可能的 SYN 数据包洪水攻击。以下是来自dmesg的示例日志行,显示了Network driver被加载到内核中:
[1.036655] kernel: e1000: Intel(R) PRO/1000 Network Driver - version 7.3.21-k8-NAPI
dmesg日志有一个专用的命令行应用程序dmesg,可以实时查看内核环形缓冲区。dmesg命令像dmesg日志一样打印信息,但它还可以显示启动后的信息。你也可以用它来排除多种故障,如端口耗尽、硬件故障和内存不足(OOM)。
常见的 journalctl 命令
在使用systemd的主机上,所有这些常见日志都存储在一个名为 journal 的单一二进制流中,该流由journald守护进程协调管理。你可以使用journalctl命令行工具访问 journal。journal 是一个便捷的故障排除工具,因为你可以利用它同时查看和搜索多个日志。journalctl命令模仿了本书中讨论的许多其他日志命令,例如tail、minikube 的minikube kubectl --logs和docker logs。
假设你想查看日志,并且按最新的日志显示。输入sudo命令,并传递-r标志(反向)给journalctl,以按此顺序查看所有日志:
$ **sudo journalctl -r**
-- Logs begin at Sat 2022-02-27 23:10:19 UTC, end at Sun 2022-02-28 18:18:29 UTC. --
Feb 28 18:18:29 box sudo[73978]: pam_unix(sudo:session): session opened for user root by vagrant(uid=0)
Feb 28 18:18:10 box systemd[7265]: Startup finished in 66ms.
`--snip--`
该输出显示了所有服务的日志行,最新的日志优先显示。
接下来,使用--since标志查看某个时间段内的日志。输入以下命令:
$ **sudo journalctl -r --since "2 hours ago"**
-- Logs begin at Sat 2022-02-27 23:10:19 UTC, end at Sun 2022-02-28 18:27:20 UTC. --
Feb 28 18:27:20 box sudo[74471]: pam_unix(sudo:session): session opened for user root by vagrant(uid=0)
Feb 28 18:27:20 box sudo[74471]: vagrant : TTY=pts/2 ; PWD=/home/vagrant ; USER=root ; COMMAND=/usr/bin/journalctl -r --since 2 hours ago
`--snip--`
该输出显示了从2 hours ago(2 小时前)开始直到当前时间的日志(当命令运行时)。使用-r标志时,最新的日志会首先显示。
你可以根据systemd服务名称来过滤日志。例如,要查看所有由 SSH 服务写入的日志,输入以下命令,将-u(unit)标志传递给journalctl:
$ **sudo journalctl -r -u ssh**
`--snip--`
Feb 27 23:17:31 ... sshd[16481]: pam_unix(sshd:session): session opened for user akira by (uid=0)
Feb 27 23:17:31 ... sshd[16481]: Accepted publickey for akira from 10.0.2.2 port 55468 ...
`--snip--`
输出显示了与 SSH 登录session相关的日志行,并按逆序排列。
你还可以显示匹配特定日志级别的日志行,例如信息或错误。通过使用像info、err、debug或crit等关键字选择优先级级别(-p)。以下是与上述相同的命令,但加上了-p err标志,仅显示来自 SSH 守护进程的错误日志:
$ **sudo journalctl -r -u ssh -p err**
`--snip--`
Feb 28 08:39:13 box sshd[4182]: error: maximum authentication attempts exceeded for root from 192.168.25.4 port 34622 ssh2 [preauth]
`--snip--`
输出显示了一个error日志行,指示root用户达到了最大失败登录尝试次数。
将日志缩小到特定时间段,或显示匹配给定日志级别的日志行非常好,但如果你想在 journal 流中找到特定的信息呢?journalctl中的模式匹配标志(-g)可以使用正则表达式匹配任何消息。以下示例搜索 SSH 日志中的session opened消息。输入以下命令:
$ **sudo journalctl -r -u ssh -g "session opened"**
`--snip--`
Jun 10 21:31:40 box sshd[2047134]: pam_unix(sshd:session): session opened for user vagrant by (uid=0)
Jun 09 16:49:10 box sshd[2008012]: pam_unix(sshd:session): session opened for user x7b7 by (uid=0)
`--snip--`
在这里,过滤出了两个不同用户(vagrant和x7b7)的 SSH 会话。
当你想一次查看多个日志时,journalctl工具非常有用,但你也会遇到那些没有被 journal 系统捕获的日志。
解析日志
解析日志是一个关键的故障排除技能。除了journalctl,你还可以使用grep和awk命令来解析和遍历日志。grep命令用于在文本或文件中搜索模式。awk命令是一种脚本语言工具,可以过滤文本,但它还有更高级的功能,比如内置的数学和时间函数。
grep
grep命令可以让你快速搜索某个模式。例如,要使用grep查找文件/var/log/syslog中所有出现的 IP 地址 10.0.2.33,输入以下命令将搜索模式和文件传递给grep:
$ **grep "10.0.2.33" /var/log/syslog**
... box postfix/smtpd[6520]: connect from unknown[10.0.2.33]
... box postfix/smtpd[6520]: disconnect from unknown[10.0.2.33] ehlo=1 auth=0/1 quit=1 commands=2/3
该命令返回了包含10.0.2.33 IP 地址的 postfix 守护进程的两行日志。
要查找尝试执行sudo命令但没有权限的用户,可以通过grep在/var/log/auth.log中进行搜索,输入以下命令:
$ **grep "user NOT in sudoers" /var/log/auth.log**
Jan 31 17:37:40 box sudo: akira : user NOT in sudoers ; TTY=pts/0 ; PWD=/home/akira ; USER=root ; COMMAND=/usr/bin/cat /etc/passwd
搜索模式"user NOT in sudoers``"表示一次未授权的sudo尝试违规。这次搜索返回一个匹配结果,显示用户akira试图读取/etc/passwd文件的内容,但被拒绝了。
更进一步,查看auth.log来了解此用户在同一时间段内的其他操作会很有帮助。要获取grep的更多日志行,可以使用-A标志获取匹配行后的指定行数,或者使用-B标志获取匹配结果前的指定行数。你也可以使用-C标志同时获取匹配结果前后的行。
现在,你应该抓取sudo违规前的五行日志,以查看用户akira的其他操作。这样可以帮助你了解在该时间段内日志中的其他活动。输入以下命令:
$ **grep -B 5 "user NOT in sudoers" /var/log/auth.log**
Jan 31 17:37:35 box sshd[64646]: pam_unix(sshd:session): session opened for user akira by (uid=0) 1
Jan 31 17:37:35 box systemd-logind[632]: New session 169 of user akira.
Jan 31 17:37:35 box systemd: pam_unix(systemd-user:session): session opened for user akira by (uid=0)
Jan 31 17:37:38 box sudo: pam_unix(sudo:auth): Couldn't open /etc/securetty: No such file or directory
Jan 31 17:37:40 box sudo: pam_unix(sudo:auth): Couldn't open /etc/securetty: No such file or directory
Jan 31 17:37:40 box sudo: akira : user NOT in sudoers ; TTY=pts/0 ; PWD=/home/akira ; USER=root ; COMMAND=/usr/bin/cat /etc/passwd 2
前五行显示了用户akira通过 SSH 登录。登录后的五秒钟内(17:37:35到17:37:40),用户akira尝试读取/etc/passwd文件的内容。没有额外的上下文,可能很容易忽略这个操作,但通过查看用户登录后的行为,抓取匹配行周围的其他行可以提供更多的洞察。
awk
awk命令可以像grep一样搜索特定模式,但它也可以过滤掉任何列的信息。在这个例子中,你应该从/var/log/nginx/access.log中的请求中抓取所有源 IP 地址。这个日志包含了所有由 Nginx 代理的网站请求。源 IP 地址通常是日志行中的第一列,除非你修改了 Nginx 的默认日志格式。你将使用awk的print函数,并传递$1参数,这样它只会打印第一列。默认情况下,awk会按照空白字符分列。输入以下命令:
$ **sudo awk '{print $1}' /var/log/nginx/access.log**
127.0.0.1
192.168.1.44
输出仅显示了两个 IP 地址。显然,这不是一个繁忙的 Web 服务器,但输出不像之前的grep例子那样显示整个日志行。你可以解析文本,并使用awk命令显示你选择的列。日志行中的每一列都有一个唯一的列号。例如,要仅查看access.log中的日期时间戳(第四列),可以将$4传递给print函数。如果你想返回多列,可以将多个列号传递给print函数,每个列号之间用逗号分隔,像这样:'{print $1,$4}'。
你将使用awk搜索所有的 HTTP 500 响应代码,这通常位于 Nginx 的access.log文件中的第九列($9)。输入以下命令:
$ **sudo awk '($9 ~ /500/)' /var/log/nginx/access.log**
10.0.2.15 - - [15/Feb/2022:19:41:46 +0000] "GET / HTTP/1.1" 500 396 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36"
括号内的波浪号(~)是一个字段编号,它告诉awk只将搜索模式应用于特定的列。在这种情况下,你想在第九列中搜索任何匹配500\的内容。命令返回了一个结果,表示一个GET请求响应了 HTTP 500。
你可以根据需要修改搜索模式。例如,如果你想搜索日志中任何未经授权的 HTTP 请求,可以将/500/的模式更改为/401/。为了进一步扩展,你可以将搜索模式从/500/更改为/404/,并添加一个要求,即所有 404 响应必须来自 HTTP POST 方法。你可以通过向awk中添加一个if条件语句块来实现这一点。要搜索符合这些标准的所有行,请在终端中输入以下内容:
$ **sudo awk '($9 ~ /404/) {if (/POST/) print}' /var/log/nginx/access.log**
127.0.0.1 - - [31/Jan/2022:18:16:45 +0000] "POST /login HTTP/1.1" 404 162 "-" "curl/7.68.0"
搜索模式与之前的类似。将第$9列的值匹配为数字404。然后通过一个if语句块,语句内容为:“如果第$9列的匹配行包含POST字样,则打印整个日志行。”结果显示了一个 HTTP POST请求访问了/login路径并返回了 HTTP 404。
调试进程
有时候,在主机上调查问题时,你可能不会遇到很多明显的症状。健康状态看起来正常,日志中没有显示任何有趣的内容……但是总感觉有些不对劲。也许某个计划任务没有顺利执行,或者某个应用程序似乎挂起了。深入调查的一种方法是检查主机上正在运行的进程。
strace
strace命令跟踪系统调用和信号,允许你附加到一个进程,并实时获得有价值的信息。你的应用程序通过系统调用请求 Linux 内核执行任务,如打开网络套接字、读写文件或创建子进程。你应该使用strace命令来排查看似有问题的进程,或当你需要了解一个进程在做什么时。请注意,strace命令需要root权限,因为它附加到另一个进程。
有许多系统调用可以使用,以下是一些参考:
-
open()创建或打开文件。 -
read()从文件描述符读取数据。 -
write()写入文件。 -
connect()打开连接。 -
futex()等待或唤醒线程,当条件变为真时(阻塞锁)。
现在,你应该开始追踪一个进程。以下命令附加到正在运行的进程19419,这是第四章中的 Greeting Web 服务器,并打印出当跟踪开始时发生的所有系统调用:
$ **sudo strace -s 128 -p 19419**
strace: Process 19419 attached
`--snip--`
accept4(5, {sa_family=AF_INET, sin_port=htons(64221), sin_addr=inet_addr("172.28.128.1")}, [16], SOCK_CLOEXEC) = 9
`--snip--`
recvfrom(9, "GET / HTTP/1.1\r\nHost: 172.28.128"`...`, 8192, 0, NULL, NULL) = 82
getpeername(9, {sa_family=AF_INET, sin_port=htons(64221), sin_addr=inet_addr("172.28.128.1")}, [16]) = 0
`--snip--`
sendto(9, "HTTP/1.1 200 OK\r\nServer: gunicorn/20.0.4\r\nDate: Mon, 01 Feb 2022 22:03:12 GMT\r\nConnection: close\r\nContent-Type: text/html; chars"..., 160, 0, NULL, 0) = 160
sendto(9, "<h1 style='color:green'>Greetings!</h1>", 39, 0, NULL, 0) = 39
`--snip--`
write(1, "172.28.128.1 - - [01/Feb/2022:21"..., 88) = 88
close(9) = 0
`--snip--`
-s 标志设置 128 字节的消息输出大小。-p 标志告诉 strace 需要附加的 PID(在此情况下是 19419)。我从输出中精心挑选了一些系统调用,以便更容易理解。accept4 系统调用从 IP 地址 172.28.128.1 创建一个新的连接,并返回文件描述符 9。recvfrom 系统调用从文件描述符为 9 的套接字接收一个 HTTP GET 请求。第一个 sendto 系统调用将来自 Web 服务器的 HTTP 头部响应通过套接字发送回去。随后的 sendto 系统调用也通过套接字传输 HTTP GET 响应的主体。write 系统调用将一行看起来像是 syslog 的内容写入文件描述符 1。最后,执行 close 系统调用,关闭先前的套接字文件描述符 9,从而关闭网络连接。你已经捕获了 HTTP 客户端与 HTTP 服务器之间的 GET 请求事务。
现在,假设你正在尝试调查一个问题,但缺乏关于进程的上下文。你已经用尽了其他手段,比如日志浏览和指标监控。一切看起来正常,但你的应用程序仍然没有正确运行。你可以使用 strace 的总结标志(-c)来获取进程使用的系统调用的概览。它会输出正在执行的系统调用的运行计数,每个调用的时间,以及这些调用返回的任何错误。运行命令后,它会在前台暂停收集数据,直到你按下 CTRL-C,才会显示结果。你让它运行的时间越长,收集到的数据就越多。
strace 命令有许多标志和选项可用于跟踪。你可以使用跟踪(-f)标志来跟踪从父进程创建的新进程(分叉)。当你只想跟踪特定系统调用时,可以使用系统调用(-e)标志。如果你想要整体查看系统调用、时间和错误,则可以使用总结(-c)标志。最后,输出(-o)标志非常有用,它可以将追踪结果存储到文件中,方便后续查看和解析。
例如,输入以下命令以获取进程 ID 28485 的摘要:
$ **sudo strace -p 28485 -c**
strace: Process 28485 attached
% time seconds usecs/call calls errors syscall
------- ----------- ----------- --------- --------- ----------------
1 49.47 0.000141 14 10 sendto
13.68 0.000039 2 17 fchmod
10.53 0.000030 6 5 close
7.37 0.000021 3 6 select
7.02 0.000020 4 5 write
2 7.02 0.000020 1 11 6 openat
2.11 0.000006 1 6 getppid
1.75 0.000005 0 10 getpid
0.35 0.000001 0 5 ioctl
0.35 0.000001 0 5 recvfrom
3 0.35 0.000001 0 50 getpeername
0.00 0.000000 0 10 getsockname
0.00 0.000000 0 10 fcntl
------- ----------- ----------- --------- --------- ----------------
100.00 0.000285 150 6 total
% time 列显示每个调用在追踪捕获过程中所占的时间百分比。在这个例子中,进程将大部分追踪时间花费在了 sendto 系统调用上(在追踪停止之前)。calls 列显示系统调用被执行的次数。在这种情况下,getpeername 被执行得最多(50 次)。getpeername 调用返回通过套接字连接的对端的 IP 地址。在追踪期间,进程 28485 在调用 openat 系统调用时记录了 6 次错误。你可以使用此调用通过指定的路径名打开文件。
你应该再次运行 strace,专注于 openat 系统调用的错误。输入以下命令:
$ **sudo strace -p 28485 -e openat**
`--snip--`
openat(AT_FDCWD, "/var/log/telnet-server.log", O_RDONLY) = -1 ENOENT (No such file or directory)
`--snip--`
输出显示进程28485正在尝试打开/var/log/telnet-server.log文件。该调用返回-1,这意味着文件不存在。这与前面总结中的错误输出一致。如你所见,能够深入查看正在运行的进程,并理解它在系统调用层面上做了什么,可能是非常宝贵的。
总结
这里描述的大部分场景反映了你在职业生涯中会遇到的问题。经验和重复将帮助你建立肌肉记忆,使你能迅速解决这些问题。我描述这些场景的目的是向你展示如何运用推理,跟踪线索找到问题的根本原因。
在本章中,你了解了有用的取证工具,如top、lsof、tcpdump、iostat和vmstat,这些工具将帮助你诊断症状。你还学习了如何使用journalctl、grep和awk等工具解析常见的日志文件。所有讨论过的工具和策略将在你下次尝试调查问题时为你提供帮助。
本部分(第三部分)已经结束,内容涉及监控和故障排除。现在,你可以监控并对任何部署到 Kubernetes 的应用程序设置警报。你还获得了故障排除的入门知识,帮助你调查在管理主机和软件时常见的问题。


浙公网安备 33010602011771号