Python-DevOps-指南-四-

95 阅读1小时+

Python DevOps 指南(四)

原文:annas-archive.org/md5/68b28228356df0415ddc83eb0aaea548

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:监控和日志记录

当诺亚在旧金山的初创公司工作时,他利用午餐时间锻炼。他会打篮球,跑到科伊特塔上,或者练巴西柔术。诺亚工作过的大多数初创公司都提供午餐。

他发现一个非常不寻常的模式,在午饭后回来。从来没有剩下任何不健康的东西可吃。剩下的东西通常是完整的沙拉,水果,蔬菜或健康的瘦肉。一群群的初创公司工作人员在他锻炼时吃光了所有不健康的选择,不留一丝诱惑吃坏的食物。不随波逐流确实有一番道理。

同样,当开发机器学习模型,移动应用程序和 Web 应用程序时,忽视操作是一条容易的路。忽视操作是如此典型,就像在提供午餐时吃薯片,苏打水和冰淇淋一样。虽然成为正常人并不一定是首选。在本章中,描述了软件开发的“沙拉和瘦肉”方法。

构建可靠系统的关键概念

经过一段时间的公司建设,看看在软件工程部分起作用和不起作用的东西是有趣的。其中一个最好的反模式是“相信我”。任何理智的 DevOps 专业人员都不会相信人类。他们是有缺陷的,会犯情感上的错误,并且可以随心所欲地摧毁整个公司。特别是如果他们是公司的创始人。

不是基于完全胡言乱语的层次结构,构建可靠系统的更好方法是逐步构建它们。此外,在创建平台时,应该经常预期失败。唯一会影响这个真理的事情是如果有一个有权势的人参与建立架构。在那种情况下,这个真理将呈指数增长。

你可能听说过 Netflix 的混沌猴,但为什么要费心呢?相反,让你公司的创始人,首席技术官或工程副总裁做随意编码并对你的架构和代码库提出质疑。人类混沌猴将在 Netflix 周围打转。更好的是,让他们在生产中断期间编译 jar 文件,并逐个将它们放在节点上,通过 SSH,同时喊着,“这会奏效的!”通过这种方式,混沌和自我之间的谐波均值被实现。

对于理智的 DevOps 专业人员来说,行动项目是什么?自动化大于层次结构。解决初创公司混乱的唯一方法是自动化,怀疑,谦卑和不可变的 DevOps 原则。

不可变的 DevOps 原则

很难想象有比这个不可改变的原则更好的地方来开始构建可靠的系统。如果首席技术官正在从笔记本电脑构建 Java 的.jar文件来解决生产问题,你应该辞职。没有什么能够拯救你的公司。我们应该知道——我们曾经在那里!

无论一个人有多聪明/强大/有魅力/有创意/有钱,如果他们在软件平台危机中手动应用关键更改,你已经死了。只是你还不知道。摆脱这种可怕状态的替代方案是自动化。

长期来看,人类不能参与软件部署。这是软件行业存在的头号反模式。它本质上是暴徒对你的平台造成严重破坏的后门。相反,部署软件、测试软件和构建软件需要 100%自动化。

在公司中,你可以产生最显著的初始影响是建立持续集成和持续交付。其他所有事情都相形见绌。

集中日志记录

在自动化之后,日志记录在重要性上紧随其后。在大规模分布式系统中,日志记录不是可选的。必须特别关注应用程序级别和环境级别的日志记录。

例如,异常应始终发送到集中日志记录系统。另一方面,在开发软件时,通常创建调试日志而不是打印语句是一个好主意。为什么这样做?花费了很多时间开发调试源代码的启发式算法。为什么不捕获它,以便在生产中再次出现问题时可以打开它?

这里的诀窍在于日志记录级别。通过创建仅出现在非生产环境中的调试日志级别,可以将调试逻辑保留在源代码中。同样,不要让过于冗长的日志在生产中出现并引起混乱,可以随时开启或关闭它们。

在大规模分布式系统中,日志记录的一个例子是Ceph的使用:守护程序可以拥有高达 20 个调试级别!所有这些级别都在代码中处理,允许系统精细调节日志记录量。Ceph 通过能够限制每个守护程序的日志记录量进一步推动了这一策略。系统有几个守护程序,可以增加一个或所有守护程序的日志记录量。

案例研究:生产数据库损坏硬盘

日志记录的另一个关键策略是解决可扩展性问题。一旦应用程序足够庞大,可能不再可行将所有日志存储在文件中。阿尔弗雷多曾经被指派解决一个广泛网络应用的主数据库问题,这个应用托管大约一百家报纸、广播电台和电视台的站点。这些站点产生了大量流量并生成了大量日志。产生了如此多的日志输出,以至于 PostgreSQL 的日志记录被设置到最低,他无法调试问题,因为需要提高日志级别。如果提高日志级别,应用程序将因所产生的强烈 I/O 而停止工作。每天早上五点左右,数据库负载就会急剧上升。情况越来越糟。

数据库管理员反对提高日志级别以查看最昂贵的查询(PostgreSQL 可以在日志中包含查询信息)整整一天,因此我们妥协了:每天早晨五点左右十五分钟。一旦能够获取这些日志,阿尔弗雷多立即开始评估最慢的查询及其运行频率。有一个明显的优胜者:一个耗时如此之长的SELECT *查询,以至于十五分钟的时间窗口无法捕获其运行时间。应用程序并未对任何表进行全表查询;那么问题出在哪里呢?

经过多次劝说,我们终于获得了对数据库服务器的访问权限。如果每天清晨五点左右出现负载激增,是否可能存在某种定期脚本?我们调查了crontab(用于记录定时运行任务的程序),发现了一个可疑的脚本:backup.sh。脚本内容包含几条 SQL 语句,其中包括多个SELECT *。数据库管理员使用此脚本备份主数据库,随着数据库规模的增长,负载也随之增加,直至无法忍受。解决方案?停止使用此脚本,开始备份四个次要(副本)数据库中的一个。

这解决了备份问题,但并没有解决无法访问日志的问题。提前考虑分发日志输出是正确的做法。像rsyslog这样的工具就是为解决这类问题而设计的,如果从一开始就添加,可以避免在生产中遇到问题时束手无策。

是否自建还是购买?

令人难以置信的是供应商锁定如何备受关注。然而,供应商锁定是因人而异的。在旧金山市中心,你几乎可以随处一掷石头就能碰到有人大声疾呼供应商锁定的罪恶。然而,深入挖掘之后,你会想知道他们的替代方案是什么。

在经济学中,有一个叫做比较优势的原则。简而言之,它意味着将精力集中在自己最擅长的事务上,并将其他任务外包给其他人是经济上有利的。尤其是在云计算领域,不断的改进使最终用户从中受益,而且通常比以前更少复杂。

除非你的公司运营规模达到技术巨头之一,否则几乎不可能实施、维护和改进私有云,并能在同时节省成本和提升业务。例如,2017 年,亚马逊发布了跨多个可用区具有自动故障切换功能的多主数据库部署能力。作为曾尝试过这一点的人,我可以说这几乎是不可能的,而在这种情况下添加自动故障切换功能非常困难。在考虑外包时要考虑的一个关键问题是:“这是否是业务的核心竞争力?”一个运营自己邮件服务器但核心竞争力是销售汽车零件的公司,正在玩火并且可能已经在亏钱。

容错性

容错性是一个迷人的主题,但它可能非常令人困惑。容错性的含义是什么,如何实现?学习更多关于容错性的信息的好地方是阅读尽可能多的来自 AWS 的白皮书

在设计容错系统时,最好从尝试回答以下问题开始:当此服务宕机时,我可以实施什么来消除(或减少)手动交互?没有人喜欢收到关键系统宕机的通知,尤其是这意味着需要多个步骤来恢复,更不用说还需要与其他服务进行沟通,确保一切恢复正常。需要注意的是,这个问题并不是将宕机视为不太可能发生的事件,而是明确承认该服务会宕机,并且需要进行一些工作来使其恢复运行。

不久之前,计划对复杂的构建系统进行全面重新设计。该构建系统完成了多项任务,其中大部分与软件打包和发布相关:必须检查依赖关系,使用make和其他工具构建二进制文件,生成 RPM 和 Debian 软件包,并创建和托管不同 Linux 发行版(如 CentOS、Debian 和 Ubuntu)的软件库。这个构建系统的主要要求是速度快。

尽管速度是主要目标之一,但在设计涉及多个步骤和不同组件的系统时,解决已知的痛点并努力防止新问题的出现非常有用。大型系统中总会存在未知因素,但使用适当的日志记录(以及日志聚合)、监控和恢复策略至关重要。

现在回到构建系统,其中一个问题是创建仓库的机器有些复杂:一个 HTTP API 接收特定项目特定版本的软件包,并自动生成仓库。 这个过程涉及数据库、RabbitMQ 服务用于异步任务处理,以及大量的存储空间来保存仓库,由 Nginx 提供服务。 最后,一些状态报告将发送到中央仪表板,以便开发人员可以查看其分支在构建过程中的位置。 设计所有东西都围绕这个服务可能出现故障的可能性是至关重要的。

在白板上添加了一张大字条,上面写着:“错误:仓库服务因磁盘已满而停止。” 任务并不是要防止磁盘满,而是要创建一个能在磁盘满的情况下继续工作,并在问题解决后几乎不费力就能重新将其纳入系统的系统。 “磁盘已满”错误是一个虚构的错误,可能是任何事情,比如 RabbitMQ 没有运行或者 DNS 问题,但它完美地说明了当前的任务。

直到某个关键部分失效时,理解监控、日志记录和良好设计模式的重要性才变得困难,并且几乎无法确定为什么以及如何。 你需要知道为什么它会崩溃,以便可以采取预防措施(警报、监控和自我恢复)来避免将来出现类似问题。

为了使这个系统能够继续工作,我们将负载分成了五台机器,它们都是相同的,做着相同的工作:创建和托管仓库。 生成二进制文件的节点将查询一个健康的仓库机器的 API,然后该机器会发送一个 HTTP 请求来查询其列表中下一个构建服务器的 /health/ 终端。 如果服务器报告健康,则二进制文件会发送到那里;否则,API 将选择列表中的下一个服务器。 如果一个节点连续三次未通过健康检查,它将被移出轮换。 系统管理员只需在修复后重新启动仓库服务即可将其重新纳入轮换。(仓库服务有一个自我健康评估,如果成功将通知 API 准备好进行工作。)

虽然实现并非完全可靠(仍需努力使服务器恢复运行,并且通知并不总是准确),但在需要恢复服务时对服务维护产生了巨大影响,并保持所有内容在降级状态下继续运行。 这就是容错性的全部意义所在!

监控

监控是这样一种事情,你几乎什么都不做也可以声称已经建立了一个监控系统(作为实习生,Alfredo 曾经使用一个curl定时任务来检查一个生产网站),但是它可能变得非常复杂,以至于生产环境看起来像是灵活的。当监控和报告做得正确时,通常可以帮助回答生产生命周期中最困难的问题。拥有它是至关重要的,但要做到这一点却很困难。这就是为什么有许多公司专门从事监控、警报和指标可视化的原因。

在其核心,大多数服务遵循两种范式:拉取和推送。本章将涵盖 Prometheus(拉取)和 Graphite 与 StatsD(推送)。了解在何时选择其中一种更为合适以及其中的注意事项,在为环境添加监控时是非常实用的。更重要的是,了解两者并具备根据特定场景选择最佳服务的能力,这一点非常实际。

可靠的时间序列软件必须能够承受极高的事务信息输入速率,能够存储这些信息,与时间相关联,支持查询,并提供一个可以根据过滤器和查询自定义的图形界面。本质上,它几乎必须像一个高性能数据库,但专门用于时间、数据操作和可视化。

Graphite

Graphite 是一个用于数字时间数据的数据存储:它保存与捕获时间相关的数值信息,并根据可自定义的规则保存这些信息。它提供了一个非常强大的 API,可以查询有关其数据的信息,包括时间范围,并且还可以应用函数对数据进行转换或计算。

Graphite的一个重要方面是它不会收集数据;相反,它集中精力于其 API 和处理大量数据的能力。这迫使用户考虑与 Graphite 部署的收集软件。有很多选择可供将指标发送到 Graphite;在本章中,我们将介绍其中一种选择,StatsD。

Graphite 的另一个有趣之处在于,虽然它自带了一个可以按需呈现图形的 Web 应用程序,但通常会部署一个不同的服务,该服务可以直接使用 Graphite 作为图形的后端。一个很好的例子就是优秀的Grafana 项目,它提供了一个功能齐全的 Web 应用程序用于呈现指标。

StatsD

Graphite 允许您通过 TCP 或 UDP 将指标推送到它,但是使用类似 StatsD 的工具特别方便,因为 Python 中有仪表化选项,允许通过 UDP 聚合指标然后将其发送到 Graphite。这种设置对于不应阻塞发送数据的 Python 应用程序很合理(TCP 连接会阻塞,直到收到响应;UDP 则不会)。如果捕获的非常耗时的 Python 循环用于指标,那么将其通信时间加入到捕获指标的服务中是没有意义的。

简而言之,将指标发送到 StatsD 服务感觉就像没有成本一样(应该是这样!)。利用可用的 Python 仪器,测量一切非常直接。一旦 StatsD 服务有足够的指标要发送到 Graphite,它就会开始发送指标的过程。所有这些都是完全异步进行的,有助于应用程序的继续运行。指标、监控和日志记录绝不能以任何方式影响生产应用程序!

使用 StatsD 时,推送到它的数据在给定间隔(默认为 10 秒)内聚合并刷新到可配置的后端(如 Graphite)。在几个生产环境中部署了 Graphite 和 StatsD 的组合后,发现在每个应用程序服务器上使用一个 StatsD 实例比为所有应用程序使用单个实例更容易。这种部署方式允许更简单的配置和更紧密的安全性:所有应用服务器上的配置将指向 localhost 的 StatsD 服务,不需要打开外部端口。最后,StatsD 将通过出站 UDP 连接将指标发送到 Graphite。这也通过将可扩展性进一步推向 Graphite 的管道下游来分担负载。

注意

StatsD 是一个 Node.js 守护程序,因此安装它意味着引入 Node.js 依赖。它绝对 不是 一个 Python 项目!

Prometheus

在许多方面,Prometheus 与 Graphite 非常相似(强大的查询和可视化)。主要区别在于它从源头 拉取 信息,并且通过 HTTP 进行。这需要服务公开 HTTP 端点以允许 Prometheus 收集指标数据。与 Graphite 的另一个显著区别是,它内置了警报功能,可以配置规则来触发警报或使用 Alertmanager:一个负责处理警报、静音、聚合并将其中继到不同系统(如电子邮件、聊天和值班平台)的组件。

一些项目(比如 Ceph)已经具有可配置选项,以便让 Prometheus 在特定间隔内抓取信息。当这种类型的集成被提供时,这是很好的;否则,它需要在某个地方运行一个可以公开服务的 HTTP 实例来暴露度量数据。例如,在 PostgreSQL 数据库 的情况下,Prometheus 导出器是运行一个公开数据的 HTTP 服务的容器。在许多情况下,这可能是fine的,但是如果已经有集成可以使用诸如collectd之类的东西收集数据,那么运行 HTTP 服务可能效果不是那么好。

Prometheus 是短期数据或频繁变化的时间数据的绝佳选择,而 Graphite 更适合长期历史信息。两者都提供非常先进的查询语言,但 Prometheus 更为强大。

对于 Python 来说,prometheus_client是一个很好的实用工具,可以开始将指标发送到 Prometheus;如果应用程序已经是基于 Web 的,该客户端还具有许多不同 Python Web 服务器的集成,例如 Twisted、WSGI、Flask,甚至 Gunicorn。除此之外,它还可以导出所有数据以直接在定义的端点公开(而不是在单独的 HTTP 实例上执行)。如果你想让你的 Web 应用程序在 /metrics/ 暴露出来,那么添加一个调用 prometheus_client.generate_latest() 的处理程序将以 Prometheus 解析器理解的格式返回内容。

创建一个小的 Flask 应用程序(保存到web.py中),以了解generate_latest()的使用是多么简单,并确保安装了prometheus_client包:

from flask import Response, Flask
import prometheus_client

app = Flask('prometheus-app')

@app.route('/metrics/')
def metrics():
    return Response(
        prometheus_client.generate_latest(),
        mimetype='text/plain; version=0.0.4; charset=utf-8'
    )

使用开发服务器运行应用程序:

$ FLASK_APP=web.py flask run
 * Serving Flask app "web.py"
 * Environment: production
   WARNING: This is a development server.
   Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [07/Jul/2019 10:16:20] "GET /metrics HTTP/1.1" 308 -
127.0.0.1 - - [07/Jul/2019 10:16:20] "GET /metrics/ HTTP/1.1" 200 -

在应用程序运行时,打开一个 Web 浏览器,输入网址[*http://localhost:5000/metrics*](http://localhost:5000/metrics)。它开始生成 Prometheus 可以收集的输出,即使没有什么真正重要的东西:

...
# HELP process_cpu_seconds_total Total user and system CPU time in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 0.27
# HELP process_open_fds Number of open file descriptors.
# TYPE process_open_fds gauge
process_open_fds 6.0
# HELP process_max_fds Maximum number of open file descriptors.
# TYPE process_max_fds gauge
process_max_fds 1024.0

大多数生产级 Web 服务器(如 Nginx 和 Apache)可以生成有关响应时间和延迟的详尽指标。例如,如果向 Flask 应用程序添加这种类型的度量数据,那么中间件,其中所有请求都可以记录,将是一个很好的选择。应用程序通常会在请求中执行其他有趣的操作,所以让我们添加另外两个端点——一个带有计数器,另一个带有计时器。这两个新端点将生成度量数据,该数据将由 prometheus_client 库处理,并在通过 HTTP 请求 /metrics/ 端点时报告。

向我们的小应用程序添加一个计数器涉及一些小的更改。创建一个新的索引端点:

@app.route('/')
def index():
    return '<h1>Development Prometheus-backed Flask App</h1>'

现在定义 Counter 对象。添加计数器的名称(requests)、一个简短的描述(Application Request Count)和至少一个有用的标签(比如 endpoint)。这个标签将帮助识别这个计数器来自哪里:

from prometheus_client import Counter

REQUESTS = Counter(
    'requests', 'Application Request Count',
    ['endpoint']
)

@app.route('/')
def index():
    REQUESTS.labels(endpoint='/').inc()
    return '<h1>Development Prometheus-backed Flask App</h1>'

定义了 REQUESTS 计数器后,将其包含在 index() 函数中,重新启动应用程序并进行几次请求。然后如果请求 /metrics/,输出应显示我们创建的一些新活动:

...
# HELP requests_total Application Request Count
# TYPE requests_total counter
requests_total{endpoint="/"} 3.0
# TYPE requests_created gauge
requests_created{endpoint="/"} 1.562512871203272e+09

现在添加一个 Histogram 对象来捕获一个端点的详细信息,有时回复时间较长。代码通过随机休眠一段时间来模拟这一情况。与 index 函数一样,还需要一个新的端点,其中使用了 Histogram 对象:

from prometheus_client import Histogram

TIMER = Histogram(
    'slow', 'Slow Requests',
    ['endpoint']
)

模拟的昂贵操作将使用一个函数来跟踪开始时间和结束时间,然后将这些信息传递给直方图对象:

import time
import random

@app.route('/database/')
def database():
    with TIMER.labels('/database').time():
        # simulated database response time
        sleep(random.uniform(1, 3))
    return '<h1>Completed expensive database operation</h1>'

需要两个新模块:timerandom。它们将帮助计算传递给直方图的时间,并模拟在数据库中执行的昂贵操作。再次运行应用程序并请求 /database/ 端点将在 /metrics/ 被轮询时开始生成内容。现在应该能看到几个测量我们模拟时间的项目:

# HELP slow Slow Requests
# TYPE slow histogram
slow_bucket{endpoint="/database",le="0.005"} 0.0
slow_bucket{endpoint="/database",le="0.01"} 0.0
slow_bucket{endpoint="/database",le="0.025"} 0.0
slow_bucket{endpoint="/database",le="0.05"} 0.0
slow_bucket{endpoint="/database",le="0.075"} 0.0
slow_bucket{endpoint="/database",le="0.1"} 0.0
slow_bucket{endpoint="/database",le="0.25"} 0.0
slow_bucket{endpoint="/database",le="0.5"} 0.0
slow_bucket{endpoint="/database",le="0.75"} 0.0
slow_bucket{endpoint="/database",le="1.0"} 0.0
slow_bucket{endpoint="/database",le="2.5"} 2.0
slow_bucket{endpoint="/database",le="5.0"} 2.0
slow_bucket{endpoint="/database",le="7.5"} 2.0
slow_bucket{endpoint="/database",le="10.0"} 2.0
slow_bucket{endpoint="/database",le="+Inf"} 2.0
slow_count{endpoint="/database"} 2.0
slow_sum{endpoint="/database"} 2.0021886825561523

Histogram 对象非常灵活,可以作为上下文管理器、装饰器或直接接收值。拥有这种灵活性非常强大,有助于在大多数环境中轻松生成可用的仪器设备。

仪器设备

在我们熟悉的某家公司,有一个由多家不同报纸使用的大型应用程序——一个巨大的单体网络应用程序,没有运行时监控。运维团队在监视系统资源如内存和 CPU 使用率方面做得很好,但没有任何方式来检查每秒向第三方视频供应商发出多少 API 调用,以及这些调用有多昂贵。可以说,通过日志记录可以实现这种类型的测量,这并不是错误,但再次强调,这是一个有着荒谬数量日志记录的大型单体应用程序。

这里的问题是如何引入具有简单可视化和查询的健壮指标,而不需要开发人员进行三天的实施培训,并使其像在代码中添加日志语句一样简单。运行时的任何技术仪器设备必须尽可能接近前述声明。任何偏离这一前提的解决方案都将难以成功。如果查询和可视化困难,那么很少有人会关心或注意。如果实施(和维护!)困难,那么它可能会被放弃。如果开发人员在运行时添加这些内容复杂,那么无论基础设施和服务是否准备好接收指标,都不会发货(或至少不会是有意义的)。

python-statsd是一个出色的(而且很小)库,用于将指标推送到 StatsD(稍后可以中继到 Graphite),可以帮助您轻松地进行指标仪表化。在应用程序中有一个专用模块包装此库非常有用,因为您需要添加定制化内容,如果到处重复将会很繁琐。

提示

StatsD 的 Python 客户端在 PyPI 上有几个可用的包。为了这些示例的目的,请使用python-statsd包。在虚拟环境中安装它,命令为pip install python-statsd。如果未使用正确的客户端可能会导致导入错误!

最简单的用例之一是计数器,python-statsd的示例显示类似于以下内容:

>>> import statsd
>>>
>>> counter = statsd.Counter('app')
>>> counter += 1

本示例假定本地正在运行 StatsD。因此无需创建连接;在这里默认设置非常有效。但是Counter类的调用传递了一个在生产环境中不起作用的名称(*app*)。如“命名约定”所述,具有帮助识别统计信息的环境和位置的良好方案至关重要,但如果到处都这样做将会非常重复。在某些 Graphite 环境中,作为认证手段,所有发送的指标必须在命名空间前加上一个secret。这增加了另一个需要抽象化的层次,以便在仪表化指标时不需要它。

命名空间的某些部分(如密钥)必须是可配置的,而其他部分可以以编程方式分配。假设有一种方法可以选择性地使用函数get_prefix()作为命名空间前缀,那么Counter将被包装以在单独的模块中提供平滑交互。为使示例生效,请创建新模块,命名为metrics.py,并添加以下内容:

import statsd
import get_prefix

def Counter(name):
    return statsd.Counter("%s.%s" % (get_prefix(), name))

遵循“命名约定”中使用的同一示例,用于调用 Amazon S3 API 的小型 Python 应用程序的某个路径,例如web/api/aws.pyCounter可以这样实例化:

from metrics import Counter

counter = Counter(__name__)

counter += 1

通过使用__name__Counter对象将以模块的完整 Python 命名空间(在接收端显示为web.api.aws.Counter)创建。这很好地实现了功能,但如果我们需要在不同位置的循环中有多个计数器,这种方式就不够灵活了。我们必须修改包装器,以允许使用后缀:

import statsd
import get_prefix

def Counter(name, suffix=None):
    if suffix:
        name_parts = name.split('.')
        name_parts.append(suffix)
        name =  '.'.join(name_parts)
    return statsd.Counter("%s.%s" % (get_prefix(), name))

如果aws.py文件包含两个需要计数器的地方,例如用于 S3 读取和写入功能,则可以轻松添加后缀:

from metrics import Counter
import boto

def s3_write(bucket, filename):
    counter = Counter(__name__, 's3.write')
    conn = boto.connect_s3()
    bucket = conn.get_bucket(bucket)
    key = boto.s3.key.Key(bucket, filename)
    with open(filename) as f:
        key.send_file(f)
    counter += 1

def s3_read(bucket, filename):
    counter = Counter(__name__, 's3.read')
    conn = boto.connect_s3()
    bucket = conn.get_bucket(bucket)
    k = Key(bucket)
    k.key = filename
    counter += 1
    return k

这两个辅助工具现在从同一个包装中具有独特的计数器,如果在生产环境中配置,则度量数据将显示在类似于 secret.app1.web.api.aws.s3.write.Counter 的命名空间中。在尝试识别每个操作的指标时,这种粒度是有帮助的。即使有些情况下不需要粒度,拥有而不使用总比需要而没有要好。大多数度量仪表板允许自定义分组指标。

在函数名称(或类方法)后添加后缀对于几乎不代表它们是什么或它们所做的功能的函数名称来说是有用的,因此通过使用有意义的内容来改进命名是增加灵活性的另一个好处:

    def helper_for_expensive_operations_on_large_files():
        counter = Counter(__name__, suffix='large_file_operations')
        while slow_operation:
            ...
            counter +=1
注意

计数器和其他指标类型,比如计量表,添加起来可能很容易,可以诱人地将它们包含在循环中,但对于每秒运行数千次的性能关键代码块来说,添加这些类型的仪表可能会产生影响。考虑限制发送的指标或稍后发送它们是不错的选择。

本节展示了如何为本地的 StatsD 服务添加仪表指标。此实例最终将其指标数据中继到像 Graphite 这样的配置后端,但这些简单的示例并不意味着仅适用于 StatsD。相反,它们表明添加帮助程序和实用工具来包装常见用法是必不可少的,当存在简单的仪表时,开发人员将希望在各处添加它们。拥有过多的度量数据的问题,比完全没有度量数据要好。

命名惯例

在大多数监控和度量服务中,如 Graphite、Grafana、Prometheus,甚至 StatsD 中,都有命名空间的概念。命名空间非常重要,值得仔细考虑一个约定,以便轻松识别系统组件,同时足够灵活以适应增长甚至变化。这些命名空间类似于 Python 的使用方式:每个名称之间用点分隔,每个分隔的部分表示从左到右层次结构中的一步。从左边开始的第一项是父级,每个后续部分都是子级。

例如,假设我们有一个灵活的 Python 应用程序,通过 AWS 进行一些 API 调用,在网站上提供图像服务。我们要关注的 Python 模块路径如下:web/api/aws.py。此路径的自然命名空间选择可能是:web.api.aws,但如果我们有多个生产应用服务器怎么办?一旦指标使用了一个命名空间,要改成其他方案就很困难(几乎不可能!)。我们改进命名空间以帮助识别生产服务器:{server_name}.web.api.aws

好多了! 但是你能看到另一个问题吗? 在发送指标时,会发送一个尾随名称。 在计数器示例中,名称将类似于:{server_name}.web.api.aws.counter。 这是一个问题,因为我们的小应用程序对 AWS 进行了几次调用,比如 S3,而且我们将来可能还想与其他 AWS 服务交互。 修复子命名比修复父命名更容易,因此在这种情况下,它只需要开发人员尽可能精确地匹配所测量的指标。 例如,如果我们在 aws.py 文件中有一个 S3 模块,那么将其包含进去以区分它与其他部分是有意义的。 该指标的子部分将类似于 aws.s3,计数器指标最终将看起来像 aws.s3.counter

有这么多命名空间变量可能会感到繁琐,但是大多数已建立的度量服务都允许轻松组合,例如“显示上周所有来自东海岸生产服务器的 S3 调用的平均计数”。 很强大,对吧?

这里还有另一个潜在问题。 我们在生产和分级环境中该怎么办? 如果我在某个虚拟机中进行开发和测试怎么办? 如果每个人都将他们的开发机器命名为 srv1,那么{server_name}部分可能不会太有用。 如果部署到不同的地区,甚至如果计划超越单个地区或国家进行扩展,则额外添加到命名空间也是有意义的。 有许多方法可以扩展命名空间以更好地适应环境,但类似这样的适当前缀是合适的:{region}.{prod|staging|dev}.{server_name}

日志记录

配置 Python 日志记录可能会让人望而生畏。 日志记录模块性能很高,有几种不同的输出方式可以接收其输出。 一旦掌握了其初始配置,向其中添加内容就不那么复杂了。 由于不喜欢正确配置日志记录模块而曾经重写了另一种日志记录方式,我们曾经犯过错误。 这是一个错误,因为它几乎从未考虑过标准库模块擅长的所有事情:多线程环境、Unicode 和支持除 STDOUT 之外的多个目的地,仅举几例。

Python 的日志记录模块非常庞大,可以用来适应许多不同的用途(就像本章中所述的几乎所有软件一样),甚至一个完整的章节都不足以涵盖所有内容。 本节将为最简单的用例提供简短的示例,然后逐渐向更复杂的用法发展。 一旦几个场景被充分理解,将日志记录扩展到其他配置就不难了。

即使它很复杂,可能需要一些时间才能完全理解,但它是 DevOps 的 重要支柱 之一。 没有它,你就不能成为成功的 DevOps 人员。

为什么这么难?

Python 应用程序,如命令行工具和一次性工具,通常采用自顶向下的设计,非常程序化。当你开始用像 Python(或者可能是 Bash)这样的东西学习开发时,习惯于这种流程是合理的。即使转向更面向对象的编程并使用更多的类和模块,仍然存在声明所需内容、实例化对象以使用它们等这种感觉。模块和对象通常不会在导入时预先配置,并且在实例化之前全局配置某些导入的模块并不常见。

有这种感觉*“不知何故已经配置了,如果我甚至还没有调用它,这怎么可能。”* 日志记录就像这样;一旦在运行时配置,模块会在任何地方导入和使用之前持久化这种配置,并且在创建记录器之前。这一切都非常方便,但是当几乎没有其他东西以这种方式在 Python 标准库中运作时,要习惯这种方式是困难的!

基本配置

摆脱日志配置困扰的最简单方法就是简单地使用basicconfig。这是一个直接的方法来让日志记录工作,具有许多默认值,大约三行的工作量:

>>> import logging
>>> logging.basicConfig()
>>> logger = logging.getLogger()
>>> logger.critical("this can't be that easy")
CRITICAL:root:this can't be that easy

几乎不需要理解任何关于日志记录的东西,消息就会出现,模块似乎配置正确。这也很好,它可以支持更多的定制选项,并且适合于不需要高度定制化日志接口的小型应用程序。日志消息的格式和设置详细程度都可以轻松实现:

>>> import logging
>>> FORMAT = '%(asctime)s %(name)s %(levelname)s %(message)s'
>>> logging.basicConfig(format=FORMAT, level=logging.INFO)
>>> logger = logging.getLogger()
>>> logger.debug('this will probably not show up')
>>> logger.warning('warning is above info, should appear')
2019-07-08 08:31:08,493 root WARNING warning is above info, should appear

此示例配置为将最低级别设置为INFO,这就是为什么调试消息没有发出任何内容。格式化信息传递到basicConfig调用中以设置时间、日志名称(本节后面会详述)、级别名称和最后消息。对于大多数应用程序来说,这已经足够了,并且了解到日志记录的简单入门已经可以做这么多事情。

这种类型的配置问题在于它不足以应对更复杂的场景。这种配置有很多默认值,可能不被接受,并且更改起来很麻烦。如果应用程序有可能需要更复杂的内容,建议完全配置日志记录并努力理解如何做到这一点。

更深层次的配置

日志模块有几种不同的loggers;这些记录器可以独立配置,也可以从parent记录器继承配置。最顶层的记录器是root记录器,所有其他记录器都是子记录器(root是父记录器)。当配置root记录器时,实际上是为全局设置了配置。在不同应用程序或单个应用程序的不同部分需要不同类型的日志记录接口和设置时,这种组织日志记录的方式是有意义的。

如果一个 web 应用程序想要将 WSGI 服务器错误发送到电子邮件,但将其他所有内容记录到文件中,如果配置了单个根级别记录器,则这将是不可能的。这与“命名约定”类似,名称之间用点分隔,每个点表示一个新的子级别。这意味着可以配置app.wsgi以通过电子邮件发送错误日志,而app.requests可以单独设置为基于文件的记录。

提示

处理这个命名空间的一个好方法是使用与 Python 相同的命名空间,而不是使用自定义的命名空间。通过在模块中使用*name*来创建记录器来实现这一点。对项目和日志记录使用相同的命名空间可以防止混淆。

日志记录的配置应尽可能早地设置。如果应用程序是一个命令行工具,那么正确的位置是在主入口点,可能甚至在解析参数之前。对于 web 应用程序,日志记录配置通常是通过框架的辅助工具进行的。今天最流行的 web 框架都提供了日志记录配置的接口:Django、Flask、Pecan 和 Pyramid 都提供了早期日志记录配置的接口。利用它!

此示例显示了如何配置一个命令行工具;你可以看到与basicConfig有一些相似之处:

import logging
import os

BASE_FORMAT = "[%(name)s][%(levelname)-6s] %(message)s"
FILE_FORMAT = "[%(asctime)s]" + BASE_FORMAT

root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)

try:
    file_logger = logging.FileHandler('application.log')
except (OSError, IOError):
    file_logger = logging.FileHandler('/tmp/application.log')

file_logger.setLevel(logging.INFO)
console_logger.setFormatter(logging.Formatter(BASE_FORMAT))
root_logger.addHandler(file_logger)

这里发生了很多事情。通过调用getLogger()而不带任何参数来请求root记录器,并将级别设置为DEBUG。这是一个很好的默认设置,因为其他子记录器可以修改级别。接下来,配置文件记录器。在这种情况下,它尝试创建文件记录器,如果无法写入文件,则会回退到临时位置。然后将其设置为INFO级别,并更改其消息格式以包含时间戳(对于基于文件的日志文件很有用)。

请注意,在最后,文件记录器被添加到root_logger中。这感觉有些违反直觉,但在这种情况下,根配置被设置为处理所有内容。向根记录器添加stream handler将使应用程序同时将日志发送到文件和标准错误输出:

console_logger = logging.StreamHandler()
console_logger.setFormatter(BASE_FORMAT)
console_logger.setLevel(logging.WARNING)
root_logger.addHandler(console_logger)

在这种情况下,使用BASE_FORMAT,因为它要去到终端,而时间戳可能会引起过多的噪音。正如你所看到的,它需要相当多的配置和设置,一旦我们开始处理不同的记录器,情况就会变得非常复杂。为了最小化这一点,最好使用一个带有设置所有这些选项的辅助程序的单独模块。作为这种类型配置的替代方案,logging模块提供了一种基于字典的配置,其中设置以键值接口设置。下面的示例显示了相同示例的配置会是什么样子。

要看它如何运作,请在文件末尾添加几个日志调用,直接用 Python 执行,并将其保存在名为log_test.py的文件中:

# root logger
logger = logging.getLogger()
logger.warning('this is an info message from the root logger')

app_logger = logging.getLogger('my-app')
app_logger.warning('an info message from my-app')

根记录器是父记录器,并引入了一个名为my-app的新记录器。直接执行文件将在终端以及名为application.log的文件中输出:

$ python log_test.py
[root][WARNING] this is an info message from the root logger
[my-app][WARNING] an info message from my-app
$ cat application.log
[2019-09-08 12:28:25,190][root][WARNING] this is an info message from the root
logger
[2019-09-08 12:28:25,190][my-app][WARNING] an info message from my-app

输出重复了,因为我们都配置了,但这并不意味着它们必须这样。文件日志记录器的格式已更改,允许在控制台中获得更清晰的视图:

from logging.config import dictConfig

dictConfig({
    'version': 1,
    'formatters': {
        'BASE_FORMAT': {
            'format': '[%(name)s][%(levelname)-6s] %(message)s',
        },
        'FILE_FORMAT': {
            'format': '[%(asctime)s] [%(name)s][%(levelname)-6s] %(message)s',
        },
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'level': 'INFO',
            'formatter': 'BASE_FORMAT'
        },
        'file': {
            'class': 'logging.FileHandler',
            'level': 'DEBUG',
            'formatter': 'FILE_FORMAT'
        }

    },
    'root': {
        'level': 'INFO',
        'handlers': ['console', 'file']
    }
})

使用dictConfig有助于更好地可视化事物的走向以及它们如何相互联系,相对于早期更手动的示例。对于需要多个记录器的复杂设置,dictConfig方法更好。大多数 Web 框架都专门使用基于字典的配置。

有时候,日志格式被忽视了。它通常被视为为人阅读日志提供视觉吸引力的一种表面装饰。虽然这在某种程度上是正确的,但加上一些方括号来指定日志级别(例如,[CRITICAL])也是很好的,但在其他环境细节(例如,生产、暂存或开发)需要分离时也可以起到一定作用。对于开发人员来说,日志来自开发版本可能会立即清晰,但如果它们被转发或集中收集,识别它们则非常重要。动态应用此操作是通过环境变量和在dictConfig中使用logging.Filter完成的:

import os
from logging.config import dictConfig

import logging

class EnvironFilter(logging.Filter):
    def filter(self, record):
        record.app_environment = os.environ.get('APP_ENVIRON', 'DEVEL')
        return True

dictConfig({
    'version': 1,
    'filters' : {
        'environ_filter' : {
          '()': EnvironFilter
        }
    },
    'formatters': {
        'BASE_FORMAT': {
            'format':
                '[%(app_environment)s][%(name)s][%(levelname)-6s] %(message)s',
        }
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'level': 'INFO',
            'formatter': 'BASE_FORMAT',
            'filters': ['environ_filter'],
        }
    },
    'root': {
        'level': 'INFO',
        'handlers': ['console']
    }
})

在这个示例中发生了很多事情。可能会容易忽略一些已更新的内容。首先,添加了一个名为EnvironFilter的新类,它将logging.Filter作为基类,并定义了一个名为filter的方法,该方法接受一个record参数。这是基类要求定义此方法的方式。record参数被扩展以包括默认为*DEVEL*APP_ENVIRON环境变量。

然后,在dictConfig中,添加了一个新的键(filters),将这个过滤器命名为environ_filter,指向EnvironFilter类。最后,在handlers键中,我们添加了filters键,它接受一个列表,在这种情况下,它只会添加一个单独的过滤器:environ_filter

定义和命名过滤器感觉很笨重,但这是因为我们的例子太简单了。在更复杂的环境中,它允许您扩展和配置,而无需填充字典中的样板文件,使得更新或进一步扩展变得更加容易。

在命令行中进行快速测试表明新过滤器如何显示环境。在这个例子中,使用了基本的Pecan应用程序:

$ pecan serve config.py
Starting server in PID 25585
serving on 0.0.0.0:8080, view at http://127.0.0.1:8080
2019-08-12 07:57:28,157 [DEVEL][INFO    ] [pecan.commands.serve] GET / 200

DEVEL的默认环境有效,将其更改为生产环境只需一个环境变量:

$ APP_ENVIRON='PRODUCTION' pecan serve config.py
Starting server in PID 2832
serving on 0.0.0.0:8080, view at http://127.0.0.1:8080
2019-08-12 08:15:46,552 [PRODUCTION][INFO    ] [pecan.commands.serve] GET / 200

常见模式

日志模块提供了一些非常好用但并非立即显而易见的模式。其中一个模式是使用logging.exception助手。常见的工作流程如下:

try:
    return expensive_operation()
except TypeError as error:
    logging.error("Running expensive_operation caused error: %s" % str(error))

这种工作流程在几个方面存在问题:它主要是吞噬异常,并仅报告其字符串表示。如果异常不明显或发生在不明显的位置,则报告TypeError是无用的。当字符串替换失败时,可能会得到一个 ValueError,但如果代码模糊了回溯,则该错误毫无帮助:

[ERROR] Running expensive_operation caused an error:
    TypeError: not all arguments converted during string formatting

发生在哪里?我们知道在调用expensive_operation()时会发生,但是在哪里?在哪个函数、类或文件中?这种记录方式不仅无益,而且令人恼火!日志模块可以帮助我们记录完整的异常回溯:

try:
    return expensive_operation()
except TypeError:
    logging.exception("Running expensive_operation caused error")

使用logging.exception助手会神奇地将完整的回溯推送到日志输出。实现不需要再担心像以前那样捕获error,甚至尝试从异常中检索有用信息。日志模块会处理一切。

另一种有用的模式是利用日志模块的内置能力进行字符串插值。以这段代码为例:

>>> logging.error(
"An error was produced when calling: expensive_operation, \
with arguments: %s, %s" % (arguments))

该语句需要两个字符串替换,并且假设arguments将有两个项目。如果arguments没有两个参数,上述语句将破坏生产代码。您绝对不希望因为日志记录而破坏生产代码。该模块有一个助手来捕获这种情况,将其报告为问题,并允许程序继续运行:

>>> logging.error("An error was produced when calling: expensive_operation, \
with arguments: %s, %s", arguments)

这是安全的,并且是向语句传递项目的推荐方式。

ELK 堆栈

就像 Linux、Apache、MySQL 和 PHP 被称为LAMP一样,您经常会听到ELK堆栈:Elasticsearch、Logstash 和 Kibana。该堆栈允许您从日志中提取信息,捕获有用的元数据,并将其发送到文档存储(Elasticsearch),然后使用强大的仪表板(Kibana)显示信息。了解每个部分对于有效地消费日志至关重要。该堆栈的每个组件同样重要,尽管您可能会发现每个组件都有类似的应用,但本节重点介绍它们在示例应用程序中的实际角色。

大多数生产系统已经运行了一段时间,你很少有机会从头开始重新设计基础架构。即使你有幸能够从头开始设计基础架构,也可能会忽视日志结构的重要性。适当的日志结构和捕获有用信息一样重要,但当结构不完整时,Logstash 可以提供帮助。在安装 Nginx 时,默认的日志输出类似于这样:

192.168.111.1 - - [03/Aug/2019:07:28:41 +0000] "GET / HTTP/1.1" 200 3700 "-" \
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0"

日志语句的某些部分很简单,比如 HTTP 方法(一个GET)和时间戳。如果你可以控制信息,丢弃无意义的内容或包含所需的数据,只要你清楚所有这些组件的含义即可。HTTP 服务器的配置在*/etc/nginx/nginx.conf*中包含了这些细节:

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
...

当你第一次看输出时,可能会认为破折号是表示缺失信息的,但这并不完全正确。在日志输出示例中,两个破折号跟在 IP 后面;一个只是装饰用的,第二个用于缺失信息。配置告诉我们,IP 后面跟着一个破折号,然后是$remote_user,在涉及认证时很有用,以便捕获已认证的用户。如果这是一个未启用认证的 HTTP 服务器,可以在有权限和访问权限的情况下从nginx.conf文件中删除$remote_user,或者可以使用从日志中提取元数据的规则来忽略它。让我们在下一节看看 Logstash 如何利用其大量的输入插件来帮助。

提示

Elasticsearch、Logstash 和 Kibana 通常在 Linux 发行版中可用。根据发行版的特性,需要导入正确的签名密钥,并配置包管理器以从正确的存储库获取。请参阅官方文档上的安装部分。确保也安装了 Filebeat 包。这是一个轻量级(但功能强大)的日志转发工具。稍后将用它将日志发送到 Logstash。

Logstash

决定采用 ELK 堆栈后的第一步是对一些 Logstash 规则进行修改,以从给定源中提取信息,过滤它,然后将其发送到一个服务(比如这种情况下的 Elasticsearch)。安装 Logstash 后,路径*/etc/logstash/将可用,并且有一个有用的conf.d*目录,我们可以在其中为不同的服务添加多个配置。我们的用例是捕获 Nginx 信息,对其进行过滤,然后将其发送到本地已安装并运行的 Elasticsearch 服务。

要消费日志,需要安装 filebeat 实用程序。这可以从安装 Elasticsearch、Kibana 和 Logstash 的相同软件仓库中获取。在配置 Logstash 之前,我们需要确保 Filebeat 已为 Nginx 日志文件和 Logstash 的位置进行了配置。

安装 Filebeat 后,在 /etc/filebeat/filebeat.yml 文件中定义(或取消注释)以下行,添加 Nginx 的日志路径以及 localhost (5044) 的默认 Logstash 端口:

filebeat.inputs:

- type: log
  enabled: true

  paths:
    - /var/log/nginx/*.log

output.logstash:
  hosts: ["localhost:5044"]

这使得 Filebeat 可以查看 /var/log/nginx/ 中的每个路径,并将其转发到 Logstash 的 localhost 实例。如果需要为另一个 Nginx 应用程序添加单独的日志文件,则在此处添加。配置文件中可能存在其他默认值,这些值应该保持不变。现在启动服务:

$ systemctl start filebeat

现在在 Logstash 配置目录(在 /etc/logstash/conf.d/ 中)创建一个新文件,命名为 nginx.conf。首先要添加的部分是处理输入:

input {
  beats {
    port => "5044"
  }
}

input 部分表明信息来源将通过 Filebeat 服务,使用 5044 端口。由于所有文件路径配置都在 Filebeat 配置中完成,这里不需要其他配置。

接下来,我们需要提取信息并映射到键(或字段)。需要设置一些解析规则以理解我们处理的非结构化数据。对于这种类型的解析,请使用 grok 插件;将以下配置追加到同一文件中:

filter {
  grok {
    match => { "message" => "%{COMBINEDAPACHELOG}"}
  }
}

filter 部分现在定义了使用 grok 插件来接收传入行并应用强大的 COMBINEDAPACHELOG,这是一组正则表达式,可以准确地查找并映射来自 Nginx 的 Web 服务器日志的所有组件。

最后,输出部分需要设置新结构化数据的目标位置:

output {
  elasticsearch {
    hosts => ["localhost:9200"]
  }
}

这意味着所有结构化数据都会发送到 Elasticsearch 的本地实例。正如你所看到的,对于 Logstash(和 Filebeat 服务),配置非常简单。可以添加多个插件和配置选项来进一步微调日志收集和解析。这种“一应俱全”的方法非常适合初学者,无需探索扩展或插件。如果你感兴趣,可以浏览 Logstash 源代码,并搜索包含 COMBINEDAPACHELOGgrok-patterns 文件,其中包含一组相当不错的正则表达式。

Elasticsearch 和 Kibana

安装完 elasticsearch 包后,你几乎不需要做其他操作就可以在本地搭建一个可以接收来自 Logstash 的结构化数据的运行环境。确保服务已启动并正常运行:

$ systemctl start elasticsearch

类似地,安装 kibana 包并启动服务:

$ systemctl start kibana

第一次启动 Kibana 后,在浏览日志输出时,它立即开始寻找运行在主机上的 Elasticsearch 实例。这是其自身 Elasticsearch 插件的默认行为,无需额外配置。行为是透明的,消息告诉你它已经成功初始化插件并连接到 Elasticsearch:

{"type":"log","@timestamp":"2019-08-09T12:34:43Z",
"tags":["status","plugin:elasticsearch@7.3.0","info"],"pid":7885,
"state":"yellow",
"message":"Status changed from uninitialized to yellow",
"prevState":"uninitialized","prevMsg":"uninitialized"}

{"type":"log","@timestamp":"2019-08-09T12:34:45Z",
"tags":["status","plugin:elasticsearch@7.3.0","info"],"pid":7885,
"state":"green","message":"Status changed from yellow to green - Ready",
"prevState":"yellow","prevMsg":"Waiting for Elasticsearch"}

在将配置更改为不正确的端口后,日志非常清楚地表明自动行为并未完全起作用:

{"type":"log","@timestamp":"2019-08-09T12:59:27Z",
"tags":["error","elasticsearch","data"],"pid":8022,
"message":"Request error, retrying
  GET http://localhost:9199/_xpack => connect ECONNREFUSED 127.0.0.1:9199"}

{"type":"log","@timestamp":"2019-08-09T12:59:27Z",
"tags":["warning","elasticsearch","data"],"pid":8022,
"message":"Unable to revive connection: http://localhost:9199/"}

一旦 Kibana 正常运行,以及 Elasticsearch(在正确的端口上!)、Filebeat 和 Logstash,你将看到一个功能齐全的仪表板,并有很多选项可供开始使用,就像 图 7-1 中展示的那样。

pydo 0701

图 7-1. Kibana 的起始仪表板页面

访问本地的 Nginx 实例以在日志中生成一些活动并启动数据处理。在这个例子中,使用了 Apache 基准测试工具 (ab),但你也可以用你的浏览器或直接使用 curl 测试:

$ ab -c 8 -n 50 http://localhost/
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient).....done

要打开 Kibana 的默认 URL 和端口(运行在 [*http://localhost:5601*](http://localhost:5601)),无需做任何特定的配置。默认视图提供了大量添加选项。在 discover 部分,你将看到所有请求的结构化信息。这是 Logstash 处理的示例 JSON 片段,可以在 Kibana 中使用(Kibana 从 Elasticsearch 获取数据):

...
    "input": {
      "type": "log"
    },
    "auth": "-",
    "ident": "-",
    "request": "/",
    "response": "200",
    "@timestamp": "2019-08-08T21:03:46.513Z",
    "verb": "GET",
    "@version": "1",
    "referrer": "\"-\"",
    "httpversion": "1.1",
    "message": "::1 - - [08/Aug/2019:21:03:45 +0000] \"GET / HTTP/1.1\" 200",
    "clientip": "::1",
    "geoip": {},
    "ecs": {
      "version": "1.0.1"
    },
    "host": {
      "os": {
        "codename": "Core",
        "name": "CentOS Linux",
        "version": "7 (Core)",
        "platform": "centos",
        "kernel": "3.10.0-957.1.3.el7.x86_64",
        "family": "redhat"
      },
      "id": "0a75ccb95b4644df88f159c41fdc7cfa",
      "hostname": "node2",
      "name": "node2",
      "architecture": "x86_64",
      "containerized": false
    },
    "bytes": "3700"
  },
  "fields": {
    "@timestamp": [
      "2019-08-08T21:03:46.513Z"
    ]
  }
...

重要的键,如 verbtimestamprequestresponse,已被 Logstash 解析并捕获。在这个初始设置中有很多工作要做,以将其转换为更有用和实用的内容。捕获的元数据可以帮助呈现流量(包括地理位置),而且 Kibana 甚至可以为数据设置阈值警报,用于在特定指标超出或低于设定值时进行警报。

在仪表板中,可以拆解这些结构化数据并用于创建有意义的图表和展示,如 图 7-2 所示。

即使 Kibana 是一个仪表板,并且 ELK 堆栈并非构建于 Python 上,这些服务集成得非常好,展示了出色的平台设计和架构。

图 7-2. Kibana 中的结构化数据

正如我们所见,ELK 堆栈可以让您开始捕获和解析日志,几乎不需要配置和努力。这些示例可能很简单,但已经演示了其组件的巨大能力。我们往往会面对一些基础设施,其中有一个 cron 记录正在 tail 日志并 grep 某些模式以发送电子邮件或向 Nagios 提交警报。使用功能强大的软件组件,并理解它们即使在最简单的形式下也能为您做多少,对于更好的基础设施至关重要,而在这种情况下,能够更好地看到基础设施正在做什么也非常重要。

练习

  • 什么是容错性,它如何帮助基础设施系统?

  • 针对产生大量日志的系统可以采取哪些措施?

  • 解释为什么在将指标推送到其他系统时可能更喜欢 UDP。为什么 TCP 可能会有问题?

  • 描述 拉取推送 系统之间的区别。在什么情况下哪种更好?

  • 制定一个命名规范,用于存储适用于生产环境、Web 和数据库服务器以及不同应用程序名称的指标。

案例研究问题

  • 创建一个 Flask 应用程序,完全实现不同级别(info、debug、warning 和 error)的日志记录,并在产生异常时通过 StatsD 服务将指标(如计数器)发送到远程 Graphite 实例。

第八章:DevOps 的 Pytest

持续集成、持续交付、部署以及一般的管道工作流,只要稍加思考,就会充满验证。这种验证可以在每一步和达到重要目标时发生。

例如,在生成部署的一长串步骤中,如果调用 curl 命令获取一个非常重要的文件,如果失败了,你认为构建应该继续吗?可能不应该!curl 有一个标志可以用来产生非零的退出状态(--fail),如果发生 HTTP 错误。这个简单的标志用法是一种验证:确保请求成功,否则失败构建步骤。关键词确保某事成功,这正是本章的核心:可以帮助您构建更好基础设施的验证和测试策略。

考虑到 Python 混合其中时,思考验证变得更加令人满意,利用像 pytest 这样的测试框架来处理系统的验证。

本章回顾了使用出色的 pytest 框架进行 Python 测试的一些基础知识,然后深入探讨了框架的一些高级特性,最后详细介绍了 TestInfra 项目,这是 pytest 的一个插件,可进行系统验证。

使用 pytest 进行测试超能力

我们对 pytest 框架赞不绝口。由 Holger Krekel 创建,现在由一些人维护,他们出色地生产出一个高质量的软件,通常是我们日常工作的一部分。作为一个功能齐全的框架,很难将范围缩小到足以提供有用的介绍,而不重复项目的完整文档。

小贴士

pytest 项目在其文档中有大量信息、示例和特性细节值得查看。随着项目持续提供新版本和改进测试的不同方法,总是有新东西可以学习。

当 Alfredo 首次接触这个框架时,他在尝试编写测试时遇到了困难,并发现要遵循 Python 的内置测试方式与 unittest 相比有些麻烦(本章稍后将详细讨论这些差异)。他花了几分钟时间就迷上了 pytest 的神奇报告功能。它不强迫他改变他编写测试的方式,而且可以立即使用,无需修改!这种灵活性贯穿整个项目,即使今天可能无法做到的事情,也可以通过插件或配置文件扩展其功能。

通过了解如何编写更简单的测试用例,并利用命令行工具、报告引擎、插件可扩展性和框架实用程序,您将希望编写更多无疑更好的测试。

开始使用 pytest

在其最简单的形式中,pytest是一个命令行工具,用于发现 Python 测试并执行它们。它不强迫用户理解其内部机制,这使得入门变得容易。本节演示了一些最基本的功能,从编写测试到布置文件(以便它们被自动发现),最后看看它与 Python 内置测试框架unittest的主要区别。

提示

大多数集成开发环境(IDE),如 PyCharm 和 Visual Studio Code,都内置了对运行pytest的支持。如果使用像 Vim 这样的文本编辑器,则可以通过pytest.vim插件进行支持。从编辑器中使用pytest可以节省时间,使调试失败变得更容易,但要注意,并非每个选项或插件都受支持。

使用 pytest 进行测试

确保您已安装并可以在命令行中使用pytest

$ python3 -m venv testing
$ source testing/bin/activate

创建一个名为test_basic.py的文件;它应该看起来像这样:

def test_simple():
    assert True

def test_fails():
    assert False

如果pytest在没有任何参数的情况下运行,它应该显示一个通过和一个失败:

$ (testing) pytest
============================= test session starts =============================
platform linux -- Python 3.6.8, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /home/alfredo/python/testing
collected 2 items

test_basic.py .F                                                        [100%]

================================== FAILURES ===================================
_________________________________ test_fails __________________________________

    def test_fails():
>       assert False
E       assert False

test_basic.py:6: AssertionError
===================== 1 failed, 1 passed in 0.02 seconds ======================

输出从一开始就非常有益;它显示了收集了多少个测试、通过了多少个测试以及失败了哪一个测试(包括其行号)。

提示

pytest的默认输出非常方便,但可能过于冗长。您可以通过配置控制输出量,使用-q标志来减少输出量。

不需要创建一个包含测试的类;函数被发现并正确运行。测试套件可以同时包含两者的混合,而框架在这种环境下也能正常工作。

布局和约定

在 Python 中进行测试时,pytest隐含遵循一些约定。这些约定大多数是关于命名和结构的。例如,尝试将test_basic.py文件重命名为basic.py,然后运行pytest看看会发生什么:

$ (testing) pytest -q

no tests ran in 0.00 seconds

由于将测试文件前缀为test_的约定,没有运行任何测试。如果将文件重命名回test_basic.py,它应该能够被自动发现并运行测试。

注意

布局和约定对于自动发现测试非常有帮助。可以配置框架以使用其他命名约定或直接测试具有唯一名称的文件。但是,遵循基本预期有助于避免测试不运行时的混淆。

这些都是将工具用于发现测试的约定:

  • 测试目录需要命名为tests

  • 测试文件需要以test作为前缀;例如,test_basic.py,或者以test.py作为后缀。

  • 测试函数需要以test_作为前缀;例如,def test_simple():

  • 测试类需要以Test作为前缀;例如,class TestSimple

  • 测试方法遵循与函数相同的约定,以test_作为前缀;例如,def test_method(self):

因为在自动发现和执行测试时需要前缀test_,所以可以引入带有不同名称的帮助函数和其他非测试代码,以便自动排除它们。

与 unittest 的差异

Python 已经提供了一套用于测试的实用工具和辅助程序,它们是unittest模块的一部分。了解pytest与其不同之处以及为什么强烈推荐使用它是很有用的。

unittest模块强制使用类和类继承。对于了解面向对象编程和类继承的经验丰富的开发人员,这不应该是一个问题,但对于初学者来说,这是一个障碍。写基本测试不应该要求使用类和继承!

强制用户从unittest.TestCase继承的部分是,您必须理解(并记住)用于验证结果的大多数断言方法。使用pytest时,有一个可以完成所有工作的单一断言助手:assert

这些是在使用unittest编写测试时可以使用的几个断言方法。其中一些很容易理解,而另一些则非常令人困惑:

  • self.assertEqual(a, b)

  • self.assertNotEqual(a, b)

  • self.assertTrue(x)

  • self.assertFalse(x)

  • self.assertIs(a, b)

  • self.assertIsNot(a, b)

  • self.assertIsNone(x)

  • self.assertIsNotNone(x)

  • self.assertIn(a, b)

  • self.assertNotIn(a, b)

  • self.assertIsInstance(a, b)

  • self.assertNotIsInstance(a, b)

  • self.assertRaises(exc, fun, *args, **kwds)

  • self.assertRaisesRegex(exc, r, fun, *args, **kwds)

  • self.assertWarns(warn, fun, *args, **kwds)

  • self.assertWarnsRegex(warn, r, fun, *args, **kwds)

  • self.assertLogs(logger, level)

  • self.assertMultiLineEqual(a, b)

  • self.assertSequenceEqual(a, b)

  • self.assertListEqual(a, b)

  • self.assertTupleEqual(a, b)

  • self.assertSetEqual(a, b)

  • self.assertDictEqual(a, b)

  • self.assertAlmostEqual(a, b)

  • self.assertNotAlmostEqual(a, b)

  • self.assertGreater(a, b)

  • self.assertGreaterEqual(a, b)

  • self.assertLess(a, b)

  • self.assertLessEqual(a, b)

  • self.assertRegex(s, r)

  • self.assertNotRegex(s, r)

  • self.assertCountEqual(a, b)

pytest允许您专门使用assert,并且不强制您使用上述任何一种方法。此外,它确实允许您使用unittest编写测试,并且甚至执行这些测试。我们强烈建议不要这样做,并建议您专注于只使用普通的 assert 语句。

不仅使用普通的 assert 更容易,而且pytest在失败时还提供了丰富的比较引擎(下一节将详细介绍)。

pytest 功能

除了使编写测试和执行测试更容易外,该框架还提供了许多可扩展的选项,例如钩子。钩子允许您在运行时的不同点与框架内部进行交互。例如,如果要修改测试的收集,可以添加一个收集引擎的钩子。另一个有用的示例是,如果要在测试失败时实现更好的报告。

在开发 HTTP API 时,我们发现有时测试中使用 HTTP 请求针对应用程序的失败并不有益:断言失败会被报告,因为预期的响应(HTTP 200)是 HTTP 500 错误。我们想要了解更多关于请求的信息:到哪个 URL 端点?如果是 POST 请求,是否有数据?它是什么样子的?这些信息已经包含在 HTTP 响应对象中,因此我们编写了一个钩子来查看这个对象,并将所有这些项目包含在失败报告中。

钩子是pytest的高级功能,您可能根本不需要,但了解框架可以灵活适应不同需求是有用的。接下来的章节涵盖如何扩展框架,为什么使用assert如此宝贵,如何参数化测试以减少重复,如何使用fixtures制作帮助工具,以及如何使用内置工具。

conftest.py

大多数软件都允许您通过插件扩展功能(例如,Web 浏览器称其为扩展);同样地,pytest有一个丰富的 API 用于开发插件。这里没有涵盖完整的 API,但其更简单的方法是:conftest.py文件。在这个文件中,工具可以像插件一样扩展。无需完全理解如何创建单独的插件、打包它并安装它。如果存在conftest.py文件,框架将加载它并消费其中的任何特定指令。这一切都是自动进行的!

通常,您会发现conftest.py文件用于保存钩子、fixtures 和这些 fixtures 的 helpers。如果声明为参数,这些fixtures可以在测试中使用(该过程稍后在 fixture 部分描述)。

当多个测试模块将使用它时,将 fixtures 和 helpers 添加到此文件是有意义的。如果只有一个单独的测试文件,或者只有一个文件将使用 fixture 或 hook,那么无需创建或使用conftest.py文件。Fixtures 和 helpers 可以在与测试相同的文件中定义并表现相同的行为。

加载conftest.py文件的唯一条件是存在于tests目录中并正确匹配名称。此外,尽管此名称是可配置的,但我们建议不要更改它,并鼓励您遵循默认命名约定以避免潜在问题。

令人惊叹的assert

当我们不得不描述pytest工具的强大之处时,我们首先描述assert语句的重要用途。幕后,框架检查对象并提供丰富的比较引擎以更好地描述错误。通常会遇到抵制,因为 Python 中的裸assert很糟糕地描述错误。以比较两个长字符串为例:

>>> assert "using assert for errors" == "using asert for errors"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

差异在哪里?如果不花时间仔细查看这两行长字符串,很难说清楚。这会导致人们建议不要这样做。一个小测试展示了pytest在报告失败时如何增强:

$ (testing) pytest test_long_lines.py
============================= test session starts =============================
platform linux -- Python 3.6.8, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
collected 1 item

test_long_lines.py F                                                    [100%]

================================== FAILURES ===================================
_______________________________ test_long_lines _______________________________

    def test_long_lines():
>      assert "using assert for errors" == "using asert for errors"
E      AssertionError: assert '...rt for errors' == '...rt for errors'
E        - using assert for errors
E        ?        -
E        + using asert for errors

test_long_lines.py:2: AssertionError
========================== 1 failed in 0.04 seconds ===========================

你能说出错误在哪里吗?这极大地简化了。它不仅告诉你失败了,还指出失败发生的确切位置。例如,一个简单的断言与一个长字符串,但是这个框架可以处理其他数据结构,如列表和字典,毫无问题。你有没有在测试中比较过非常长的列表?很难轻松地分辨出哪些项目不同。这里是一个有长列表的小片段:

    assert ['a', 'very', 'long', 'list', 'of', 'items'] == [
            'a', 'very', 'long', 'list', 'items']
E   AssertionError: assert [...'of', 'items'] == [...ist', 'items']
E     At index 4 diff: 'of' != 'items'
E     Left contains more items, first extra item: 'items'
E     Use -v to get the full diff

在通知用户测试失败后,它精确指向索引号(第四个或第五个项目),最后说一个列表有一个额外的项目。没有这种深入的反思,调试失败将需要很长时间。报告中的额外奖励是,默认情况下,在进行比较时省略非常长的项目,因此输出中只显示相关部分。毕竟,你想知道的不仅是列表(或任何其他数据结构)不同之处,而且确切地在哪里不同。

参数化

参数化是一个需要一些时间来理解的特性,因为它在unittest模块中不存在,是 pytest 框架独有的特性。一旦你发现自己编写非常相似的测试,只是输入稍有不同,但测试的是同一个东西时,它就会变得很清晰。举个例子,这个类正在测试一个函数,如果一个字符串暗示一个真实的值,则返回Truestring_to_bool是测试的函数:

from my_module import string_to_bool

class TestStringToBool(object):

    def test_it_detects_lowercase_yes(self):
        assert string_to_bool('yes')

    def test_it_detects_odd_case_yes(self):
        assert string_to_bool('YeS')

    def test_it_detects_uppercase_yes(self):
        assert string_to_bool('YES')

    def test_it_detects_positive_str_integers(self):
        assert string_to_bool('1')

    def test_it_detects_true(self):
        assert string_to_bool('true')

    def test_it_detects_true_with_trailing_spaces(self):
        assert string_to_bool('true ')

    def test_it_detects_true_with_leading_spaces(self):
        assert string_to_bool(' true')

看看所有这些测试如何从相似的输入评估相同的结果?这就是参数化发挥作用的地方,因为它可以将所有这些值分组并传递给测试;它可以有效地将它们减少到单个测试中:

import pytest
from my_module import string_to_bool

true_values = ['yes', '1', 'Yes', 'TRUE', 'TruE', 'True', 'true']

class TestStrToBool(object):

    @pytest.mark.parametrize('value', true_values)
    def test_it_detects_truish_strings(self, value)
        assert string_to_bool(value)

这里发生了几件事情。首先导入了pytest(框架)以使用pytest.mark.parametrize模块,然后将true_values定义为应评估相同的所有值的(列表)变量,并最后将所有测试方法替换为单一方法。测试方法使用parametrize装饰器,定义了两个参数。第一个是字符串*value*,第二个是先前定义的列表的名称。这可能看起来有点奇怪,但它告诉框架*value*是在测试方法中使用的参数名称。这就是value参数的来源!

如果在运行时增加了详细输出,输出将显示确切传入的值。它几乎看起来像单个测试被复制到每次迭代中传入的值中:

test_long_lines.py::TestLongLines::test_detects_truish_strings[yes] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[1] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[Yes] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[TRUE] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[TruE] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[True] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[true] PASSED

输出包括 单个测试 中每次迭代中使用的值,用方括号括起来。它将非常冗长的测试类简化为单个测试方法,多亏了 parametrize。下次您发现自己编写非常相似的测试并且使用不同的输入来断言相同的结果时,您将知道可以通过 parametrize 装饰器简化它。

Fixture

我们把 pytest Fixture 想象成可以注入到测试中的小助手。无论您是编写单个测试函数还是一堆测试方法,Fixture 都可以以相同的方式使用。如果它们不会被其他测试文件共享,那么可以在同一个测试文件中定义它们;否则它们可以放在 conftest.py 文件中。Fixture 就像帮助函数一样,可以是任何您需要的测试用例,从预创建的简单数据结构到为 Web 应用程序设置数据库等更复杂的功能。

这些助手还可以有定义的 scope。它们可以有特定的代码,为每个测试方法、类和模块进行清理,或者甚至允许为整个测试会话设置它们一次。通过在测试方法(或测试函数)中定义它们,您实际上是在运行时获取 Fixture 的注入。如果这听起来有点混乱,通过下几节中的示例将变得清晰起来。

入门

用来定义和使用的 Fixture 如此简单,以至于它们经常被滥用。我们知道我们创建了一些本来可以简化为简单帮助方法的 Fixture!正如我们已经提到的,Fixture 有许多不同的用例——从简单的数据结构到更复杂的用例,例如为单个测试设置整个数据库。

最近,Alfredo 不得不测试一个解析特定文件内容的小应用程序,该文件称为 keyring file。它具有类似 INI 文件的结构,某些值必须是唯一的,并且遵循特定的格式。在每次测试中重新创建文件结构可能非常繁琐,因此创建了一个 Fixture 来帮助。这就是 keyring file 的外观:

[mon.]
    key = AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A==
    caps mon = "allow *"

Fixture 是一个返回 keyring file 内容的函数。让我们创建一个名为 test_keyring.py 的新文件,其中包含 Fixture 的内容,以及验证默认键的小测试函数:

import pytest
import random

@pytest.fixture
def mon_keyring():
    def make_keyring(default=False):
        if default:
            key = "AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A=="
        else:
            key = "%032x==" % random.getrandbits(128)

        return """
 [mon.]
 key = %s
 caps mon = "allow *"
 """ % key
    return make_keyring

def test_default_key(mon_keyring):
    contents = mon_keyring(default=True)
    assert "AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A==" in contents

Fixture 使用一个执行繁重工作的嵌套函数,允许使用一个 default 键值,并在调用者希望有随机键时返回嵌套函数。在测试中,它通过声明为测试函数的参数的一部分接收 Fixture(在本例中为 mon_keyring),并使用 default=True 调用 Fixture,以便使用默认键,然后验证它是否按预期生成。

注意

在真实场景中,生成的内容将被传递给解析器,确保解析后的行为符合预期,并且没有错误发生。

使用此固件的生产代码最终发展到执行其他类型的测试,并且在某些时候,测试希望验证解析器能够处理不同条件下的文件。该固件返回一个字符串,因此需要扩展它。现有测试已经使用了 mon_keyring 固件,因此为了在不更改当前固件的情况下扩展功能,创建了一个新的固件,该固件使用了框架的一个特性。固件可以 请求 其他固件!您将所需的固件定义为参数(就像测试函数或测试方法一样),因此在执行时框架会注入它。

这是创建(并返回)文件的新固件的方式:

@pytest.fixture
def keyring_file(mon_keyring, tmpdir):
    def generate_file(default=False):
        keyring = tmpdir.join('keyring')
        keyring.write_text(mon_keyring(default=default))
        return keyring.strpath
    return generate_file

按行解释,pytest.fixture装饰器告诉框架这个函数是一个固件,然后定义了固件,请求 两个固件 作为参数:mon_keyringtmpdir。第一个是前面在 test_keyring.py 文件中创建的,第二个是框架提供的内置固件(关于内置固件的更多内容将在下一节讨论)。tmpdir 固件允许您使用一个临时目录,在测试完成后将其删除,然后创建 keyring 文件,并写入由 mon_keyring 固件生成的文本,传递 default 参数。最后,它返回新创建文件的绝对路径,以便测试可以使用它。

这是测试函数如何使用它的方式:

def test_keyring_file_contents(keyring_file):
    keyring_path = keyring_file(default=True)
    with open(keyring_path) as fp:
        contents = fp.read()
    assert "AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A==" in contents

您现在应该对固件是什么,您可以在哪里定义它们以及如何在测试中使用它们有了一个很好的理解。下一部分将介绍一些最有用的内置固件,这些固件是框架的一部分。

内置固件

前一节简要介绍了 pytest 提供的众多内置固件之一:tmpdir 固件。框架提供了更多固件。要验证可用固件的完整列表,请运行以下命令:

$ (testing) pytest  -q --fixtures

我们经常使用的两个固件是 monkeypatchcapsys,当运行上述命令时,它们都在生成的列表中。这是您将在终端看到的简要描述:

capsys
    enables capturing of writes to sys.stdout/sys.stderr and makes
    captured output available via ``capsys.readouterr()`` method calls
    which return a ``(out, err)`` tuple.
monkeypatch
    The returned ``monkeypatch`` funcarg provides these
    helper methods to modify objects, dictionaries or os.environ::

    monkeypatch.setattr(obj, name, value, raising=True)
    monkeypatch.delattr(obj, name, raising=True)
    monkeypatch.setitem(mapping, name, value)
    monkeypatch.delitem(obj, name, raising=True)
    monkeypatch.setenv(name, value, prepend=False)
    monkeypatch.delenv(name, value, raising=True)
    monkeypatch.syspath_prepend(path)
    monkeypatch.chdir(path)

    All modifications will be undone after the requesting
    test function has finished. The ``raising``
    parameter determines if a KeyError or AttributeError
    will be raised if the set/deletion operation has no target.

capsys 捕获测试中产生的任何 stdoutstderr。您是否尝试过验证某些命令输出或日志记录在单元测试中的输出?这很难做到,并且需要一个单独的插件或库来 patch Python 的内部,然后检查其内容。

这是验证分别在 stderrstdout 上产生的输出的两个测试函数:

import sys

def stderr_logging():
    sys.stderr.write('stderr output being produced')

def stdout_logging():
    sys.stdout.write('stdout output being produced')

def test_verify_stderr(capsys):
    stderr_logging()
    out, err = capsys.readouterr()
    assert out == ''
    assert err == 'stderr output being produced'

def test_verify_stdout(capsys):
    stdout_logging()
    out, err = capsys.readouterr()
    assert out == 'stdout output being produced'
    assert err == ''

capsys 固件处理所有补丁,设置和助手,以检索测试中生成的 stderrstdout。每次测试都会重置内容,这确保变量填充了正确的输出。

monkeypatch可能是我们最常使用的装置。在测试时,有些情况下测试的代码不在我们的控制之下,修补就需要发生来覆盖模块或函数以具有特定的行为。Python 中有相当多的修补模拟库(模拟是帮助设置修补对象行为的助手),但monkeypatch足够好,你可能不需要安装额外的库来帮忙。

以下函数运行系统命令以捕获设备的详细信息,然后解析输出,并返回一个属性(由blkid报告的ID_PART_ENTRY_TYPE):

import subprocess

def get_part_entry_type(device):
    """
 Parses the ``ID_PART_ENTRY_TYPE`` from the "low level" (bypasses the cache)
 output that uses the ``udev`` type of output.
 """
    stdout = subprocess.check_output(['blkid', '-p', '-o', 'udev', device])
    for line in stdout.split('\n'):
        if 'ID_PART_ENTRY_TYPE=' in line:
            return line.split('=')[-1].strip()
    return ''

要进行测试,设置所需的行为在subprocess模块的check_output属性上。这是使用monkeypatch装置的测试函数的外观:

def test_parses_id_entry_type(monkeypatch):
    monkeypatch.setattr(
        'subprocess.check_output',
        lambda cmd: '\nID_PART_ENTRY_TYPE=aaaaa')
    assert get_part_entry_type('/dev/sda') == 'aaaa'

setattr调用设置修补过的可调用对象(在本例中为check_output)。补丁它的是一个返回感兴趣行的 lambda 函数。由于subprocess.check_output函数不在我们的直接控制之下,并且get_part_entry_type函数不允许任何其他方式来注入值,修补是唯一的方法。

我们倾向于使用其他技术,如在尝试修补之前注入值(称为依赖注入),但有时没有其他方法。提供一个可以修补和处理所有测试清理工作的库,这是pytest是一种愉悦的工作方式的更多原因之一。

基础设施测试

本节解释了如何使用Testinfra 项目进行基础设施测试和验证。这是一个依赖于装置的pytest插件,允许您编写 Python 测试,就像测试代码一样。

前几节详细讨论了pytest的使用和示例,本章以系统级验证的概念开始。我们解释基础设施测试的方式是通过问一个问题:*如何确定部署成功?*大多数情况下,这意味着一些手动检查,如加载网站或查看进程,这是不够的;这是错误的,并且如果系统很重要的话可能会变得乏味。

虽然最初可以将pytest视为编写和运行 Python 单元测试的工具,但将其重新用于基础设施测试可能是有利的。几年前,阿尔弗雷多被委托制作一个安装程序,通过 HTTP API 公开其功能。该安装程序旨在创建一个 Ceph 集群,涉及多台机器。在启动 API 的质量保证阶段,常常会收到集群未按预期工作的报告,因此他会获取凭据以登录这些机器并进行检查。一旦必须调试包含多台机器的分布式系统时,就会产生乘数效应:多个配置文件、不同的硬盘、网络设置,任何和所有的东西都可能不同,即使它们看起来很相似。

每当阿尔弗雷多需要调试这些系统时,他都会有一个日益增长的检查清单。服务器上的配置是否相同?权限是否符合预期?特定用户是否存在?最终他会忘记某些事情,并花时间试图弄清楚自己漏掉了什么。这是一个不可持续的过程。如果我能写一些简单的测试用例来针对集群? 阿尔弗雷多编写了几个简单的测试来验证清单上的项目,并执行它们以检查构成集群的机器。在他意识到之前,他已经拥有了一套很好的测试,只需几秒钟即可运行,可以识别各种问题。

这对于改进交付流程是一个令人难以置信的启示。他甚至可以在开发安装程序时执行这些(功能)测试,并发现不正确的地方。如果 QA 团队发现任何问题,他可以针对其设置运行相同的测试。有时测试会捕捉到环境问题:一个硬盘脏了并导致部署失败;来自不同集群的配置文件遗留下来并引发问题。自动化、精细化测试以及频繁运行它们使工作变得更好,并减轻了 QA 团队需要处理的工作量。

TestInfra 项目具有各种夹具,可高效测试系统,并包含一整套用于连接服务器的后端,无论其部署类型如何:Ansible、Docker、SSH 和 Kubernetes 是一些支持的连接方式。通过支持多种不同的连接后端,可以执行相同的一组测试,而不受基础设施更改的影响。

接下来的章节将介绍不同的后端,并展示一个生产项目的示例。

什么是系统验证?

系统验证可以在不同级别(使用监控和警报系统)和应用程序生命周期的不同阶段进行,例如在预部署阶段、运行时或部署期间。最近由 Alfredo 投入生产的应用程序需要在重新启动时优雅地处理客户端连接,即使有任何中断也不受影响。为了维持流量,应用程序进行了负载均衡:在系统负载较重时,新的连接会被发送到负载较轻的其他服务器。

当部署新版本时,应用程序必须重新启动。重新启动意味着客户端在最佳情况下会遇到奇怪的行为,最坏的情况下会导致非常糟糕的体验。为了避免这种情况,重新启动过程等待所有客户端连接终止,系统拒绝新的连接,允许其完成来自现有客户端的工作,其余系统继续工作。当没有活动连接时,部署继续并停止服务以获取更新的代码。

沿途的每一步都进行了验证:在部署之前通知负载均衡器停止发送新的客户端,并且后来验证没有新的客户端处于活动状态。如果该工作流程转换为测试,标题可能类似于:确保当前没有客户端在运行。一旦新代码就位,另一个验证步骤检查负载均衡器是否已确认服务器再次准备好生成工作。这里的另一个测试可能是:负载均衡器已将服务器标记为活动。最后,确保服务器正在接收新的客户端连接——又一个要编写的测试!

在这些步骤中,验证已经到位,可以编写测试来验证这种类型的工作流程。

系统验证也可以与监控服务器的整体健康状况(或集群环境中的多个服务器)相关联,或者作为开发应用程序和功能测试的持续集成的一部分。验证的基础知识适用于这些情况以及可能从状态验证中受益的任何其他情况。它不应仅用于测试,尽管这是一个很好的开始!

Testinfra 简介

针对基础设施编写单元测试是一个强大的概念,使用 Testinfra 一年多以来,我们可以说它提高了我们必须交付的生产应用程序的质量。以下部分详细介绍了如何连接到不同节点并执行验证测试,并探讨了可用的固定装置类型。

要创建新的虚拟环境,请安装pytest

$ python3 -m venv validation
$ source testing/bin/activate
(validation) $ pip install pytest

安装testinfra,确保使用版本2.1.0

(validation) $ pip install "testinfra==2.1.0"
注意

pytest固定装置提供了 Testinfra 项目提供的所有测试功能。要利用本节,您需要了解它们是如何工作的。

连接到远程节点

因为存在不同的后端连接类型,当未直接指定连接时,Testinfra 默认到某些类型。最好明确指定连接类型并在命令行中定义它。

这些是 Testinfra 支持的所有连接类型:

  • 本地

  • Paramiko(Python 中的 SSH 实现)

  • Docker

  • SSH

  • Salt

  • Ansible

  • Kubernetes(通过 kubectl)

  • WinRM

  • LXC

在帮助菜单中会出现一个 testinfra 部分,提供一些关于提供的标志的上下文。这是来自 pytest 与其与 Testinfra 集成的一个不错的特性。这两个项目的帮助来自相同的命令:

(validation) $ pytest --help
...

testinfra:
  --connection=CONNECTION
                        Remote connection backend (paramiko, ssh, safe-ssh,
                        salt, docker, ansible)
  --hosts=HOSTS         Hosts list (comma separated)
  --ssh-config=SSH_CONFIG
                        SSH config file
  --ssh-identity-file=SSH_IDENTITY_FILE
                        SSH identify file
  --sudo                Use sudo
  --sudo-user=SUDO_USER
                        sudo user
  --ansible-inventory=ANSIBLE_INVENTORY
                        Ansible inventory file
  --nagios              Nagios plugin

有两台服务器正在运行。为了演示连接选项,让我们检查它们是否在运行 CentOS 7,查看 /etc/os-release 文件的内容。这是测试函数的外观(保存为 test_remote.py):

def test_release_file(host):
    release_file = host.file("/etc/os-release")
    assert release_file.contains('CentOS')
    assert release_file.contains('VERSION="7 (Core)"')

它是一个单一的测试函数,接受 host fixture,并针对所有指定的节点运行。

--hosts 标志接受一个主机列表,并使用连接方案(例如 SSH 将使用 *ssh://hostname*),还允许使用通配符进行一些其他变体。如果一次测试多个远程服务器,则在命令行中传递它们变得很麻烦。以下是使用 SSH 测试两台服务器的方式:

(validation) $ pytest -v --hosts='ssh://node1,ssh://node2' test_remote.py
============================= test session starts =============================
platform linux -- Python 3.6.8, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
cachedir: .pytest_cache
rootdir: /home/alfredo/python/python-devops/samples/chapter16
plugins: testinfra-3.0.0, xdist-1.28.0, forked-1.0.2
collected 2 items

test_remote.py::test_release_file[ssh://node1] PASSED                   [ 50%]
test_remote.py::test_release_file[ssh://node2] PASSED                   [100%]

========================== 2 passed in 3.82 seconds ===========================

使用增加冗余信息(使用 -v 标志)显示 Testinfra 在调用中指定的两台远程服务器中执行一个测试函数。

注意

在设置主机时,具有无密码连接是重要的。不应该有任何密码提示,如果使用 SSH,则应该使用基于密钥的配置。

当自动化这些类型的测试(例如作为 CI 系统中的作业的一部分)时,您可以从生成主机、确定它们如何连接以及任何其他特殊指令中受益。Testinfra 可以使用 SSH 配置文件确定要连接的主机。在上一次测试运行中,使用了 Vagrant,它创建了具有特殊密钥和连接设置的这些服务器。Vagrant 可以为其创建的服务器生成临时 SSH 配置文件:

(validation) $ vagrant ssh-config

Host node1
  HostName 127.0.0.1
  User vagrant
  Port 2200
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile /home/alfredo/.vagrant.d/insecure_private_key
  IdentitiesOnly yes
  LogLevel FATAL

Host node2
  HostName 127.0.0.1
  User vagrant
  Port 2222
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile /home/alfredo/.vagrant.d/insecure_private_key
  IdentitiesOnly yes
  LogLevel FATAL

将输出内容导出到文件,然后传递给 Testinfra 可以提供更大的灵活性,特别是在使用多个主机时:

(validation) $ vagrant ssh-config > ssh-config
(validation) $ pytest --hosts=default --ssh-config=ssh-config test_remote.py

使用 --hosts=default 避免在命令行中直接指定它们,并且引擎从 SSH 配置中获取。即使没有 Vagrant,SSH 配置提示仍然对连接到具有特定指令的多个主机有用。

Ansible 是另一种选择,如果节点是本地、SSH 或 Docker 容器。测试设置可以从使用主机清单(类似于 SSH 配置)中受益,它可以将主机分组到不同的部分。主机组也可以指定,以便您可以单独针对主机进行测试,而不是针对所有主机执行。

对于在先前示例中使用的 node1node2,清单文件的定义如下(保存为 hosts):

[all]
node1
node2

如果对所有这些执行,命令将更改为:

$ pytest --connection=ansible --ansible-inventory=hosts test_remote.py

如果在清单中定义了需要排除的其他主机,则还可以指定一个组。假设两个节点都是 Web 服务器,并且属于 nginx 组,则此命令将仅在该组上运行测试:

$ pytest --hosts='ansible://nginx' --connection=ansible \
  --ansible-inventory=hosts test_remote.py
提示

许多系统命令需要超级用户权限。为了允许特权升级,Testinfra 允许指定 --sudo--sudo-user--sudo 标志使引擎在执行命令时使用 sudo,而 --sudo-user 命令允许以不同用户的更高权限运行。这个 fixture 也可以直接使用。

功能和特殊的 fixtures。

到目前为止,在示例中,仅使用 host fixture 来检查文件及其内容。然而,这是具有误导性的。host fixture 是一个 全包含 的 fixture;它包含了 Testinfra 提供的所有其他强大 fixtures。这意味着示例已经使用了 host.file,其中包含了大量额外的功能。也可以直接使用该 fixture:

In [1]: import testinfra

In [2]: host = testinfra.get_host('local://')

In [3]: node_file = host.file('/tmp')

In [4]: node_file.is_directory
Out[4]: True

In [5]: node_file.user
Out[5]: 'root'

全功能的 host fixture 利用了 Testinfra 的广泛 API,它为连接到的每个主机加载了所有内容。其想法是编写单个测试,针对从同一 host fixture 访问的不同节点执行。

这些是一些可用的 几十个 属性。以下是其中一些最常用的:

host.ansible

在运行时提供对任何 Ansible 属性的完全访问,例如主机、清单和变量。

host.addr

网络工具,如检查 IPV4 和 IPV6,主机是否可达,主机是否可解析。

host.docker

代理到 Docker API,允许与容器交互,并检查它们是否在运行。

host.interface

用于检查给定接口地址的辅助工具。

host.iptables

用于验证防火墙规则(如 host.iptables 所见)的辅助工具。

host.mount_point

检查挂载点、文件系统类型在路径中的存在以及挂载选项。

host.package

非常有用 以查询包是否已安装及其版本。

host.process

检查运行中的进程。

host.sudo

允许您使用 host.sudo 执行命令,或作为不同用户执行。

host.system_info

各种系统元数据,如发行版版本、发布和代号。

host.check_output

运行系统命令,检查其输出(如果成功运行),可以与 host.sudo 结合使用。

host.run

运行命令,允许检查返回代码,host.stderrhost.stdout

host.run_expect

验证返回代码是否符合预期。

示例

无摩擦地开始开发系统验证测试的方法是在创建实际部署时执行。与测试驱动开发(TDD)有些类似,任何进展都需要一个新的测试。在本节中,需要安装并配置 Web 服务器在端口 80 上运行以提供静态着陆页面。在取得进展的同时,将添加测试。编写测试的一部分是理解失败,因此将引入一些问题来帮助我们确定要修复的内容。

干净的 Ubuntu 服务器上,首先安装 Nginx 包:

$ apt install nginx

在取得进展后创建一个名为test_webserver.py的新测试文件。Nginx 安装后,让我们再创建一个测试:

def test_nginx_is_installed(host):
    assert host.package('nginx').is_installed

使用-q标志减少pytest输出的冗长以集中处理失败。远程服务器称为node4,使用 SSH 连接到它。这是运行第一个测试的命令:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
.
1 passed in 1.44 seconds

进展!Web 服务器需要运行,因此添加了一个新的测试来验证其行为:

def test_nginx_is_running(host):
    assert host.service('nginx').is_running

再次运行应该再次成功:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
.F
================================== FAILURES ===================================
_____________________ test_nginx_is_running[ssh://node4] ______________________

host = <testinfra.host.Host object at 0x7f629bf1d668>

    def test_nginx_is_running(host):
>       assert host.service('nginx').is_running
E       AssertionError: assert False
E        +  where False = <service nginx>.is_running
E        +    where <service nginx> = <class 'SystemdService'>('nginx')

test_webserver.py:7: AssertionError
1 failed, 1 passed in 2.45 seconds

一些 Linux 发行版不允许在安装时启动包服务。此外,测试捕获到systemd(默认单元服务)报告 Nginx 服务未运行。手动启动 Nginx 并运行测试应该再次使一切顺利通过:

(validate) $ systemctl start nginx
(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
..
2 passed in 2.38 seconds

如本节开头所述,Web 服务器应在端口 80 上提供静态着陆页面。添加另一个测试(在test_webserver.py中)以验证端口是下一步:

def test_nginx_listens_on_port_80(host):
    assert host.socket("tcp://0.0.0.0:80").is_listening

此测试更为复杂,需要关注一些细节。它选择检查服务器中任何 IP 上端口80的 TCP 连接。虽然对于此测试来说这没问题,但如果服务器有多个接口并配置为绑定到特定地址,则必须添加新的测试。添加另一个检查端口80是否在给定地址上监听的测试可能看起来有些多余,但如果考虑到报告,它有助于解释发生了什么:

  1. 测试nginx是否在端口80上监听:PASS

  2. 测试nginx是否在地址192.168.0.2和端口80上监听:FAIL

以上告诉我们 Nginx 绑定到端口80只是没有绑定到正确的接口。额外的测试是提供细粒度的好方法(以牺牲额外的冗长)。

再次运行新添加的测试:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
..F
================================== FAILURES ===================================
_________________ test_nginx_listens_on_port_80[ssh://node4] __________________

host = <testinfra.host.Host object at 0x7fbaa64f26a0>

    def test_nginx_listens_on_port_80(host):
>       assert host.socket("tcp://0.0.0.0:80").is_listening
E       AssertionError: assert False
E        +  where False = <socket tcp://0.0.0.0:80>.is_listening
E        +    where <socket tcp://0.0.0.0:80> = <class 'LinuxSocketSS'>

test_webserver.py:11: AssertionError
1 failed, 2 passed in 2.98 seconds

没有任何地址在端口80上有监听。查看 Nginx 的配置发现,它设置为使用默认站点中的指令在端口8080上进行监听:

(validate) $ grep "listen 8080" /etc/nginx/sites-available/default
    listen 8080 default_server;

将其改回到端口80并重新启动nginx服务,测试再次通过:

(validate) $ grep "listen 80" /etc/nginx/sites-available/default
    listen 80 default_server;
(validate) $ systemctl restart nginx
(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
...
3 passed in 2.92 seconds

由于没有内置的夹具来处理向地址的 HTTP 请求,最后一个测试使用wget实用程序检索正在运行的网站的内容,并对输出进行断言以确保静态站点渲染:

def test_get_content_from_site(host):
    output = host.check_output('wget -qO- 0.0.0.0:80')
    assert 'Welcome to nginx' in output

再次运行test_webserver.py以验证所有我们的假设是正确的:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
....
4 passed in 3.29 seconds

理解 Python 中测试概念,并将其重新用于系统验证,具有非常强大的功能。在开发应用程序时自动化测试运行,甚至在现有基础设施上编写和运行测试,都是简化日常操作的极佳方式,因为这些操作往往容易出错。pytest 和 Testinfra 是可以帮助你入门的优秀项目,并且在需要扩展时使用起来非常方便。测试是技能的升级

使用 pytest 测试 Jupyter Notebooks

在数据科学和机器学习中,如果忘记应用软件工程最佳实践,很容易在公司引入大问题。解决这个问题的一种方法是使用 pytest 的nbval插件,它允许你测试你的笔记本。看看这个Makefile

setup:
    python3 -m venv ~/.myrepo

install:
    pip install -r requirements.txt

test:
    python -m pytest -vv --cov=myrepolib tests/*.py
    python -m pytest --nbval notebook.ipynb

lint:
    pylint --disable=R,C myrepolib cli web

all: install lint test

关键项目是--nbval标志,它还允许建立服务器测试仓库中的笔记本。

练习

  • 至少列出三个约定,以便pytest可以发现测试。

  • conftest.py文件的作用是什么?

  • 解释测试参数化。

  • 什么是 fixture,如何在测试中使用它?它方便吗?为什么?

  • 解释如何使用monkeypatch fixture。

案例研究问题

  • 创建一个测试模块,使用testinfra连接到远程服务器。测试 Nginx 是否安装,在systemd下运行,并且服务器是否绑定到端口 80。当所有测试通过时,尝试通过配置 Nginx 监听不同的端口来使它们失败。

第九章:云计算

云计算是一个术语,它像其他流行的现代术语一样会造成混淆,比如大数据、人工智能和敏捷。当一个术语变得足够流行时,最终会对很多人有很多含义。这里是一个精确的定义。云是按需提供计算服务的交付,您只需支付您使用的量,就像任何其他公用事业一样:天然气、电力或水。

云计算的顶级好处包括成本、速度、全球规模、生产力、性能、可靠性和安全性。让我们逐个解析这些。

成本

没有前期成本,资源可以精确计量以满足需求。

速度

云提供自助服务,因此专业用户可以利用资源快速构建解决方案。

全球规模

所有主要云提供商都具有全球规模,这意味着可以在世界各地提供服务,以满足地理区域的需求。

生产力

许多任务,如架设服务器、配置网络硬件和物理保护数据中心,已经不复存在。公司可以专注于构建核心知识产权,而不是重复造轮子。

性能

与您拥有的硬件不同,云硬件不断升级,这意味着最快和最新的硬件始终按需可用。所有硬件还连接在一起,通过低延迟和高带宽的基础设施,创建了一个理想的高性能环境。

可靠性

云的核心架构在每一步都提供冗余。每个区域都有多个数据中心,每个数据中心都有多个。云原生架构可以围绕这些能力设计,从而实现高可用性架构。此外,许多核心云服务本身也具有高可用性,比如亚马逊 S3,其可靠性为九个“9”,即 99.999999999%。

安全性

您的安全性取决于最薄弱的环节。通过集中到中央化的安全性,可以实现更高级别的安全性。诸如物理访问数据中心或静止加密等问题,在第一天就成为行业标准。

云计算基础

从某些方面来看,很难在不考虑云的情况下思考 DevOps。亚马逊将以下内容描述为 DevOps 的最佳实践:持续集成、持续交付、微服务、基础设施即代码、监控与日志、以及沟通与协作。在这些最佳实践中,可以说所有这些都依赖于云的存在。即使是较难定义的“沟通与协作”实践,也是通过现代化的 SaaS 沟通工具套件实现的:Jira、Trello、Slack、GitHub 等。所有这些 SaaS 沟通工具都运行在云上。

现代云时代有什么独特之处?至少有三个定义性特征:理论上的无限计算资源,按需访问计算资源以及没有前期资本承诺。这些特征内涵了 DevOps 技能的帕累托分布。

在实践中,云在支持云的真正效率方面使用时变得极具成本效益。另一方面,对于使用云计算的不成熟组织来说,可能会非常昂贵,因为他们没有利用云计算的核心功能。可以说,在云计算的早期阶段,80%的总利润来自于未成熟的用户,他们让实例空闲,选择了错误的实例(过大),没有为自动扩展进行架构设计,或者使用了非云原生的软件架构,例如将所有内容都塞进关系数据库中。同样,其余 20%的总利润来自于具有卓越 DevOps 技能的极为节俭的组织。

在云存在之前,有一个永远不会消失的固定成本。这个成本无论是在金钱上还是在开发人员时间上都是固定的。一个数据中心必须由一个团队来维护,这是一份全职工作,而且非常昂贵。随着云的成熟发展,现在只有最优秀的人才才会在数据中心工作,他们为像谷歌、微软和亚马逊这样极为成熟的组织工作。从统计上讲,小公司无法长期拥有那些水平的数据中心工程师硬件技能。

经济学的一个基本法则是比较优势原则。与其看云计算的成本,然后认为自己可以通过自己动手省钱,不如看看不做某些事情的机会成本。大多数组织已经得出结论:

  1. 他们无法在数据中心专业知识方面与谷歌、亚马逊和微软竞争。

  2. 支付云服务费用使公司能够专注于其他领域,利用他们独特的技能。

Netflix 决定专注于提供流媒体服务和创作原创内容,而不是运营自己的数据中心。如果你看一下 Netflix 从 2008 年到 2019 年的 11 年股票价格(图 9-1),很难反驳这一策略。

Netflix 11 年股票价格

图 9-1. Netflix 11 年股票价格

Netflix 的独特之处在于其在云端运营卓越性上的承诺。目前或曾经在 Netflix 工作的员工在各大会议上发表了许多演讲,在 GitHub 上开发和发布了工具,并在 DevOps 和云计算主题上撰写了文章和书籍。这进一步支持了这样一个观点:仅仅意识到云是正确的选择是不够的,这个决定必须以卓越的运营实践为支撑。否则,一个组织可能会像那些注册了一年会员却只去了三周健身房的人一样,那些不去健身房的会员在经济上补贴了那些经常去的会员。

云计算的类型

云计算有几种主要类型:公有云、私有云、混合云和多云。大多数情况下,当我们谈论云时,指的是公有云。但这并不是唯一的云类型。私有云由组织独占使用,可以是物理上位于该组织的数据中心,也可以由另一家公司为该组织托管。一些私有云提供商包括 HPE、VMware、戴尔和甲骨文。一个流行的开源私有云选项是 OpenStack。实际上的一个很好的例子是,Rackspace,一个在托管空间中更为专业的替代品,是 OpenStack 私有云即服务的最大提供商之一。

更加灵活的选择是混合云。混合云结合了私有云和公有云。这种架构的一个例子是在需要可伸缩性和额外容量的情况下使用公有云,而在日常运营中使用私有云。另一个例子可能涉及专用硬件架构,比如在私有云中进行深度学习的 GPU 农场,而连接的公有云则作为核心基础设施。即使是主要的云供应商也进入了这个领域。一个很好的例子是谷歌的 Anthos 平台。这个平台通过在本地数据中心与 GCP 之间建立链接来完成难以置信的工作,允许以无缝的方式运行 Kubernetes 集群。

最后,多云是一种选择,部分由现代 DevOps 技术(如 Docker 容器)和基础设施即代码(IaC)解决方案(如 Terraform)所启用。多云策略涉及同时使用多个云。一个很好的例子是在多个云上同时运行容器中的作业。为什么这样做?首先,你可以决定在 AWS Spot 实例价格适宜以赚取利润时运行作业,但在 AWS 价格过高时切换到 GCP。像 Terraform 这样的工具允许你将云概念抽象为熟悉的配置语言,而容器允许代码和执行环境在能运行容器的任何目标上运行。

云服务的类型

云服务有五种主要类型:基础设施即服务(IaaS),金属即服务(MaaS),平台即服务(PaaS),无服务器,以及软件即服务(SaaS)。这些云服务在不同的抽象层上工作,并各有利弊。让我们来详细了解每一种服务。

基础设施即服务

Infrastructure as a Service(基础设施即服务,IaaS)是一个低级别的类别,包括按分钟租用虚拟机、访问对象存储、提供软件定义网络(SDN)和软件定义存储(SDS),以及竞标可用虚拟机的能力。这种服务水平与 AWS 密切相关,特别是在早期(2006 年)亚马逊推出 S3 云存储、SQS(简单队列服务)和 EC2(虚拟机)时。

这项服务对于在 DevOps 方面拥有强大专业知识的组织来说具有巨大的成本效益和可靠性,只需少数人就能完成。缺点是 IaaS 有陡峭的学习曲线,当管理效率低下时,它在成本和人力上可能会非常昂贵。在 2009 年至 2019 年期间的旧金山湾区,这种情况在许多公司的 AWS 上实时发生。

一个令人铭记的故事是,当诺亚管理一个提供监控和搜索工具的 SaaS 公司的工程部门时发生的。在他上任的第一个月,云端发生了两个关乎任务的严重问题。第一个问题发生在第一周,是 SaaS 计费系统错误配置了存储系统。公司正在删除付费客户的数据!问题的要点是他们没有成功在云端运作所需的 DevOps 基础设施:没有构建服务器,没有测试,没有真正的隔离开发环境,没有代码审查,以及有限的自动部署软件能力。诺亚采取的解决措施是这些 DevOps 实践,就在一场象征性的大火正在燃烧的时候。

注意

一名开发人员曾用烤面包机烤培根,导致办公室起火。诺亚闻到了烟味,于是走进了厨房,发现火焰正顺着墙壁和天花板蔓延。他对情况的讽刺意味感到震惊,所以他坐在那里几秒钟,沉浸在其中。幸运的是,一个反应迅速的同事(产品经理)拿起了灭火器,扑灭了火势。

我们的云架构随后发生了第二个更为严重的问题。公司所有开发人员都必须值班,以确保 24/7 覆盖(除了 CTO/创始人经常编写直接或间接导致故障的代码......稍后再说)。一天晚上,当诺亚值班时,他在凌晨 2 点被 CEO/创始人的手机电话吵醒。他告诉诺亚他们被黑客攻击了,整个 SaaS 系统不复存在。平台上没有任何网页服务器、搜索端点或任何其他虚拟机在运行。诺亚问为什么他没有收到警报,CEO 说监控系统也被删除了。诺亚决定凌晨 2 点驱车去公司解决问题。

随着更多信息浮出水面,问题变得显而易见。CEO 和创始人最初设置了 AWS 账户,并且所有有关服务中断的电子邮件都发送到他的邮箱。几个月来,亚马逊一直向他发送关于我们所在地区北弗吉尼亚的虚拟机需要退役并且即将删除的电子邮件。最终那一天到来了,在深夜,整个公司的服务器都停止存在了。

当诺亚开车去上班时,他发现了这个问题,于是专注于从 GitHub 源代码重新构建一个完整的 SaaS 公司。从这一点开始,诺亚开始理解 AWS 的强大和复杂性。他从凌晨 2 点到下午 8 点之间,将 SaaS 系统恢复正常,能够接收数据、处理支付并提供仪表板服务。又花了 48 小时完全恢复所有备份数据。

导致恢复时间如此之长的原因之一是部署过程集中在先前员工创建但从未提交到版本控制的 Puppet 的分支版本上。幸运的是,诺亚在凌晨 6 点左右找到了存活下来的孤立机器上的那个版本的 Puppet 的副本。如果这台机器不存在,可能会导致公司的灭亡。在没有基础设施即代码(IAC)支撑的情况下,完全重建这种复杂公司可能需要一周时间。

一个非常令人压力巨大的经历,但最终有一个相对幸运的结局,给他带来了很多教训。诺亚意识到这是云计算的一个折衷之处;虽然非常强大,但学习曲线对于旧金山湾区的风投支持的初创公司来说压力巨大。现在回到那位 CTO/创始人,他不在值班,但是在没有使用构建服务器或持续集成系统的情况下将代码推送到生产环境。这个人并不是故事的反派。诺亚本人在职业生涯的某个阶段如果成为一家公司的 CTO/创始人,可能也会犯同样的错误。

真正的问题是权力动态。等级制度并不等同于正确性。很容易沉迷于自己的权力,并认为因为你掌管着,你所做的一切总是有道理的。当诺亚经营一家公司时,他也犯了类似的错误。关键要点是过程必须正确,而不是个人。如果不自动化,那就是有问题的。如果没有经过某种类型的自动化质量控制测试,那也是有问题的。如果部署不可重复,那也是有问题的。

最后要分享的关于这家公司的故事涉及监控。在这两次初始危机之后,症状得到了缓解,但根本疾病仍然是恶性的。该公司存在一个无效的工程过程。另一个故事突显了根本问题。有一个自制的监控系统(再次由创始人最初创建),平均每 3-4 小时生成一次警报,每天 24 小时。

由于除了首席技术官外,所有工程人员都在值班,大部分工程人员总是睡眠不足,因为他们每晚都会接到系统不工作的警报。对警报的“修复”是重新启动服务。诺亚自愿连续一个月值班,以便工程有时间解决问题。这段持续的痛苦和缺觉期导致他意识到几件事情。首先,监控系统不比随机更好。他可能用这个 Python 脚本完全替换整个系统:

from  random import choices

hours = list(range(1,25))
status = ["Alert", "No Alert"]
for hour in hours:
    print(f"Hour: {hour} -- {choices(status)}"
✗ python random_alert.py
Hour: 1 -- ['No Alert']
Hour: 2 -- ['No Alert']
Hour: 3 -- ['Alert']
Hour: 4 -- ['No Alert']
Hour: 5 -- ['Alert']
Hour: 6 -- ['Alert']
Hour: 7 -- ['Alert']
Hour: 8 -- ['No Alert']
Hour: 9 -- ['Alert']
Hour: 10 -- ['Alert']
Hour: 11 -- ['No Alert']
Hour: 12 -- ['Alert']
Hour: 13 -- ['No Alert']
Hour: 14 -- ['No Alert']
Hour: 15 -- ['No Alert']
Hour: 16 -- ['Alert']
Hour: 17 -- ['Alert']
Hour: 18 -- ['Alert']
Hour: 19 -- ['Alert']
Hour: 20 -- ['No Alert']
Hour: 21 -- ['Alert']
Hour: 22 -- ['Alert']
Hour: 23 -- ['No Alert']
Hour: 24 -- ['Alert']

一旦他意识到这一点,他深入挖掘数据,并按天创建了过去一年每个单独警报的历史图片(注意这些警报旨在可行动并“唤醒你”)。从图 9-2 中可以看出,这些警报不仅毫无意义,而且在事后看来频率还荒谬增长。他们在“货物崇拜”工程最佳实践,并象征性地在一个由稻草建造的泥土跑道上挥舞棕榈树枝。

pydo 0902

图 9-2. SaaS 公司每日警报

查看数据后,了解到工程师们花费了多年的生命响应页面和夜间被唤醒,却毫无意义。这种痛苦和牺牲一无所获,强化了生活不公的悲哀真相。这种情况的不公非常令人沮丧,并且需要大量说服才能让人们同意关闭警报。人类行为中有一种固有的偏见,继续做你一直做过的事情。此外,由于痛苦如此严重和持久,往往倾向于赋予更深层次的意义。最终,这是一个虚假的神。

对于这家特定公司使用 AWS 云 IaaS 的回顾实际上是 DevOps 的卖点:

  1. 你必须有交付流水线和反馈循环:构建、测试、发布、监控,然后计划。

  2. 开发与运维不是独立的。如果首席技术官在编写代码,他们也应该负责值班(多年被惊醒的痛苦和苦难将成为正确的反馈循环)。

  3. 地位在等级制度中并不比流程更重要。团队成员之间应该有一种强调所有权和责任的合作关系,不论职称、薪水或者经验水平。

  4. 速度是 DevOps 的基本要求。因此,微服务和持续交付是必需的,因为它们使团队能够快速拥有自己的服务并发布软件。

  5. 快速交付是 DevOps 的基本要求,但它还需要持续集成、持续交付以及有效和可操作的监控和日志记录。

  6. 它提供了在规模上管理基础设施和开发过程的能力。自动化和一致性是硬性要求。使用基础设施即代码(IaC)以可重复和自动化的方式管理开发、测试和生产环境是解决方案。

MaaS(Metal as a Service)

MaaS(Metal as a Service)允许你像虚拟机一样处理物理服务器。在管理虚拟机集群时同样便捷的使用体验也适用于物理硬件。MaaS 是由 Canonical 提供的一项服务,Canonical 的所有者马克·舒特尔沃斯特称其为“云语义”进入裸金属世界。MaaS 还可以指的是使用将硬件视为虚拟化硬件的供应商概念。这方面的一个很好的例子是 SoftLayer,这是一家被 IBM 收购的裸金属提供商。

在正面的优势中,对硬件拥有完全控制确实对特定应用具有一定吸引力。这方面的一个很好的例子可以是使用基于 GPU 的数据库。实际上,常规公共云也可以提供类似的服务,因此进行全面的成本效益分析有助于在何时使用 MaaS 时进行合理的辩解。

平台即服务(Platform as a Service)

PaaS(Platform as a Service)是一个完整的开发和部署环境,具备创建云服务所需的所有资源。其例子包括 Heroku 和 Google App Engine。PaaS 与 IaaS 不同之处在于它拥有开发工具、数据库管理工具以及高级服务,提供“点对点”集成。可以捆绑的服务类型的例子包括认证服务、数据库服务或 Web 应用服务。

对 PaaS 的一个合理批评是,长期来看它可能比 IaaS 更昂贵,正如之前讨论的;然而这取决于环境。如果组织无法执行 DevOps 行为,那么成本就成了无关紧要的点。在这种情况下,最好支付更昂贵的服务,提供更多这些能力。对于需要学习管理 IaaS 部署高级功能的组织来说,机会成本可能对初创企业的短期生命周期来说太高。对于一个组织来说,把这些能力外包给 PaaS 提供商可能更明智。

无服务器计算

无服务器是云计算的新类别之一,仍然在积极发展中。无服务器的真正承诺在于能够花更多时间构建应用程序和服务,而不需要或几乎不需要考虑它们的运行方式。每个主要的云平台都有无服务器解决方案。

服务器无关解决方案的构建模块是计算节点或函数即服务(FaaS)。AWS 拥有 Lambda,GCP 拥有 Cloud Functions,Microsoft 拥有 Azure Functions。传统上,这些云函数的底层执行已经被抽象为一个运行时,即 Python 2.7、Python 3.6 或 Python 3.7. 所有这些供应商都支持 Python 运行时,并且在某些情况下,它们还支持通过定制的 Docker 容器来定制底层运行时。这里是一个简单的 AWS Lambda 函数示例,用于获取维基百科的第一页。

有几点需要指出关于这个 Lambda 函数。逻辑本身在 lambda_handler 中,并且它接受两个参数。第一个参数 event 来自于触发它的任何内容。Lambda 可以是从 Amazon Cloud Watch 事件定时器到使用从 AWS Lambda 控制台制定的负载运行。第二个参数 context 具有方法和属性,提供有关调用、函数和执行环境的信息。

import json
import wikipedia

print('Loading function')

def lambda_handler(event, context):
    """Wikipedia Summarizer"""

    entity = event["entity"]
    res = wikipedia.summary(entity, sentences=1)
    print(f"Response from wikipedia API: {res}")
    response = {
    "statusCode": "200",
    "headers": { "Content-type": "application/json" },
    "body": json.dumps({"message": res})
    }
    return response

要使用 Lambda 函数,需要发送一个 JSON 负载:

{"entity":"google"}

Lambda 的输出也是一个 JSON 负载:

Response
{
    "statusCode": "200",
    "headers": {
        "Content-type": "application/json"
    },
    "body": "{\"message\": \"Google LLC is an American multinational technology"}
}

FaaS 最强大的一点之一是能够编写响应事件而不是持续运行的代码:例如 Ruby on Rails 应用程序。FaaS 是云原生能力,真正利用了云的弹性特性。此外,编写 Lambda 函数的开发环境已经有了很大进步。

AWS 的 Cloud9 是一个基于浏览器的开发环境,与 AWS 深度集成(图 9-3)。

pydo 0903

图 9-3. 使用 AWS Cloud9

Cloud9 现在是我编写 AWS Lambda 函数和运行需要 AWS API 密钥的代码的首选环境。Cloud9 内置了用于编写 AWS Lambda 函数的工具,使得在本地构建和测试它们,以及部署到 AWS 中变得简单直观。

图 9-4 显示了如何传递 JSON 负载并在 Cloud9 中本地测试 lambda。这种测试方式是这一不断发展平台的显著优势。

pydo 0904

图 9-4. 在 Cloud9 中运行 Lambda 函数

同样,Google Cloud 在你使用 GCP 云 Shell 环境时开始启动你(参见 图 9-5)。云 Shell 还允许您快速启动开发,访问关键命令行工具和完整的开发环境。

pydo 0905

图 9-5. GCP 云 Shell

GCP 云 Shell 编辑器(参见 图 9-6)是一个功能齐全的 IDE,具有语法高亮显示、文件浏览器和许多传统 IDE 中通常找到的其他工具。

pydo 0906

图 9-6. GCP 云 Shell 编辑器

关键要点是,在云端,最好在可能的情况下使用本地开发工具。这样做可以减少安全漏洞,限制由于从笔记本电脑传输数据到云端而导致的减速,并因其与本地环境的深度集成而提高生产力。

软件即服务

SaaS 和云从一开始就被结合在一起。随着云端的功能不断增加,SaaS 产品继续在云端创新的基础上分发创新。SaaS 产品有许多优势,特别是在 DevOps 领域。例如,如果你刚开始时可以租用一个监控解决方案,为什么要自己建造呢?此外,许多核心的 DevOps 原则,如持续集成和持续交付,也可以通过云供应商提供的 SaaS 应用(如 AWS CodePipeline)或第三方 SaaS 解决方案(如 CircleCI)实现。

在许多情况下,能够混合使用 IaaS、PaaS 和 SaaS 允许现代公司以比 10 年前更可靠和高效的方式开发产品。由于云端以及构建在云端之上的 SaaS 公司的快速演变,每年构建软件变得更加容易。

基础设施即代码

IaC 在 第十章 中有更详细的介绍;请参考该章节以获取 IaC 的更详细说明。然而,就云和 DevOps 而言,IaC 是实施真实世界云计算的基本要素。在云上实施 DevOps 实践必须具备 IaC 的能力。

持续交付

持续交付是一个较新的术语,可能会在持续集成和持续部署之间产生混淆。关键区别在于软件被交付到某个环境,例如一个演示环境,可以进行自动化和手动测试。虽然不要求立即部署,但它处于可部署状态。有关构建系统的更详细解释可以在 第十五章 中找到,但同样值得指出的是,这是正确使用云的基本要求之一。

虚拟化和容器

在云中最基本的组成部分莫过于虚拟化了。当 AWS 在 2006 年正式推出时,Amazon 弹性计算云(EC2)是发布的核心服务之一。有几个关键的虚拟化领域需要讨论。

硬件虚拟化

AWS 发布的第一个虚拟化抽象是硬件虚拟化。硬件虚拟化有两种形式:半虚拟化(PV)或硬件虚拟机(HVM)。最佳性能来自于 HVM。性能上的关键差异在于,HVM 能够利用硬件扩展,与主机硬件紧密结合,实质上使虚拟机成为主机硬件的一部分,而不仅仅是一个不知情于主机操作的客人。

硬件虚拟化提供了在一个主机上运行多个操作系统的能力,以及将 CPU、I/O(包括网络和磁盘)和内存分区到客户操作系统的能力。这种方法有许多优点,是现代云计算的基础,但对于 Python 本身也存在一些独特的挑战。一个问题是,通常的粒度对于 Python 来说太大,无法充分利用环境。由于 Python 和线程的限制(它们不能在多核上工作),一个有两个核心的虚拟机可能会浪费一个核心。使用硬件虚拟化和 Python 语言,由于缺乏真正的多线程,可能会造成资源的巨大浪费。对于 Python 应用程序的虚拟机配置往往会导致一个或多个核心处于空闲状态,浪费金钱和能源。幸运的是,云计算提供了新的解决方案,帮助消除 Python 语言中的这些缺陷。特别是,容器和无服务器消除了这个问题,因为它们将云视为一个操作系统,而不是线程,有的是 lambda 或容器。而不是在队列上监听线程,lambda 响应来自云队列(例如 SQS)的事件。

软件定义网络

软件定义网络(SDNs)是云计算的重要组成部分。SDNs 的杀手级特性在于能够动态和程序化地改变网络行为。在此能力出现之前,这通常由网络专家负责管理,类似于使用 F5 负载均衡器。诺亚曾在一家大型电信公司工作过,那里每天都有一个称为“变更管理”的会议,由一个名叫 Bob 的人负责控制每一个被发布的软件。

要成为 Bob,需要有独特的个性。Bob 和公司内的人们经常发生争吵。这是经典的 IT 运维与开发之间的斗争,Bob 乐于说不。云和 DevOps 完全消除了这个角色、硬件和每周的吵架。持续交付流程是使用精确配置、软件和所需数据持续地构建和部署软件,以用于生产环境。Bob 的角色深深地融入了矩阵中的 0 和 1 中,被一些 Terraform 代码所取代。

软件定义存储

软件定义存储(SDS)是一种允许按需配置存储的抽象概念。此存储可以配置有细粒度的磁盘 I/O 和网络 I/O。一个很好的例子是亚马逊的 EBS 卷,您可以在其中配置已配置的磁盘 I/O。通常,云 SDS 会随着卷大小自动增加磁盘 I/O。一个如何在实践中工作的绝佳例子是亚马逊弹性文件系统(EFS)。EFS 随着存储大小的增长增加磁盘 I/O(这是自动发生的),并且设计用于支持同时来自数千个 EC2 实例的请求。它还与亚马逊 EC2 实例深度集成,允许挂起的写入进行缓冲并异步发生。

诺亚在这种情况下使用 EFS 有丰富的经验。在 AWS 批处理可用之前,他设计并编写了一个系统,该系统利用了数千个挂载了 EFS 卷的 spot 实例,它们执行从 Amazon SQS 收集的分布式计算机视觉作业。使用一个始终在线的分布式文件系统对于分布式计算来说是一个巨大的优势,并且它简化了从部署到集群计算的一切。

容器

容器已经存在了几十年,它们指的是操作系统级虚拟化。内核允许存在隔离的用户空间实例。在 2000 年代初期,有许多托管公司使用 Apache 网站的虚拟托管作为操作系统级虚拟化的形式。大型机和经典的 Unix 操作系统,如 AIX、HP-UX 和 Solaris,多年来也拥有先进的容器形式。作为开发人员,诺亚在 2007 年推出的 Solaris LDOM 技术中使用了 Solaris LDOM 技术,并对他如何能够安装允许对 CPU、内存和 I/O 进行细粒度控制的完整操作系统而感到敬畏,所有这些都可以通过远程登录到具有带外管理卡的机器来完成。

容器的现代版本正在快速发展,借鉴了主机时代的优秀特性,并结合了像源代码控制这样的新思想。特别是,容器的一个重大革新是将其视为从版本控制中签出的项目。Docker 容器现在是容器的标准格式,所有主要的云供应商都支持 Dockerfile 容器和 Kubernetes 容器管理软件。有关容器的更多信息,请参阅第十二章,但与云相关的基本内容列于此处:

容器注册表

所有的云服务提供商都有一个容器注册表,用于存储您的容器。

Kubernetes 管理服务

所有的云服务提供商都有 Kubernetes 服务,并且这现在是管理基于容器的部署的标准。

Dockerfile 格式

这是构建容器的标准方法,它是一个简单的文件格式。在构建过程中使用像hadolint这样的代码审查工具是一个最佳实践,以确保简单的错误不会通过。

使用容器进行持续集成

所有的云服务提供商都有基于云的构建系统,允许与容器集成。谷歌有Cloud Build,亚马逊有AWS CodePipeline,Azure 有Azure Pipelines。它们都可以构建容器并将其注册到容器注册表中,同时也可以使用容器构建项目。

深度集成容器到所有云服务中

当你进入云平台上的托管服务时,可以放心它们都有一个共同点——容器!亚马逊的 SageMaker,一个托管的机器学习平台,使用容器。谷歌云 Shell 云开发环境使用容器来允许您定制开发环境。

分布式计算中的挑战和机遇

计算机科学中最具挑战性的领域之一是分布式计算。在云计算的现代时代,有几个根本性的转变彻底改变了一切。最显著的转变之一是多核机器的兴起和摩尔定律的终结。请参见图 9-7。

pydo 0907

图 9-7. 摩尔定律的终结(来源:John Hennessy 和 David Patterson,《计算机体系结构:定量方法》第 6 版,2018 年)

摩尔定律揭示了云时代表现出来的两个基本问题。第一个问题是,CPU 被设计为多用途处理器。它们并不专门用于运行并行工作负载。如果将其与增加 CPU 速度的终极物理极限相结合,CPU 在云时代变得不那么关键。在 2015 年,摩尔定律实际上结束了,每年的增益率为 3%。

第二个问题是,为了抵消单处理器速度的限制而制造多核机器导致了软件语言的连锁反应。许多语言以前在多核利用方面存在重大问题,因为它们是在多处理器甚至是互联网之前设计的时代。Python 在这里是一个很好的例子。更具挑战性的是,图 9-8 显示,通过为主要非并行问题增加更多核心并不是一种“免费午餐”。

pydo 0908

图 9-8. 阿姆达尔定律

云和不同的架构的机会,例如应用特定集成电路(ASIC)。这些包括图形处理单元(GPU)、现场可编程门阵列(FPGA)和张量处理单元(TPU)。这些专用芯片越来越多地用于机器学习工作负载,并为使用多种硬件组合解决分布式计算中的复杂问题铺平了道路。

云时代的 Python 并发性、性能和进程管理

想象一下,在旧金山的一个危险街区深夜走在黑暗的街道上。在这种情况下,你是巴西柔术的黑带。你独自一人,注意到有个陌生人似乎在跟踪你。当他们走近时,你的心开始加速,你想到了你多年的武术训练。你会不会不得不在街上和陌生人打斗?你每周在健身房与对手进行活跃的实战。你觉得自己准备好了,如果需要的话可以保护自己。你也知道巴西柔术是一种高效的武术,适用于现实世界的情况。

另一方面,与人打斗仍然是要避免的事情。这是危险的。可能会涉及武器。你可能会赢得这场斗争,但会严重伤害你的对手。你也可能会输掉这场战斗,并且自己也会受重伤。即使是巴西柔术的专家也知道,在街头打斗并不是一个理想的场景,尽管很有可能会赢得比赛。

Python 中的并发性非常类似。有一些方便的模式,如多进程和 asyncio。最好是节制地使用并发性。通常,与通过编程语言自己创建的并发性相比,使用平台的并发性选项(无服务器、批处理处理、竞价实例)更好。

进程管理

Python 中的进程管理是该语言的一个突出能力。当 Python 作为连接其他平台、语言和进程的胶水时,它表现最佳。此外,进程管理的实际实现在多年来已经发生了显著变化,并且继续改进。

用子进程管理进程

使用标准库启动进程最简单和最有效的方法是使用run()函数。只要你安装了 Python 3.7 或更高版本,就从这里开始简化你的代码。一个简单的示例只需要一行代码:

out = subprocess.run(["ls", "-l"], capture_output=True)

这行代码几乎可以满足你所需。它在 Python 子进程中调用 shell 命令并捕获输出。返回值是一个CompletedProcess类型的对象。这个对象包含了启动进程时使用的argsreturncodestdoutstderrcheck_returncode

这个一行代码替换了过于冗长和复杂的调用 shell 命令的方法。对于经常写 Python 代码并夹杂着 shell 命令的开发人员来说,这非常棒。以下是一些可能有用的其他提示。

避免使用 shell=True

最佳实践是将命令作为列表中的项调用:

subprocess.run["ls", "-la"]

最好避免使用字符串:

#AVOID THIS
subprocess.run("ls -la", shell=True)

这样做的原因很简单。如果你接受任意字符串并执行它,很容易意外引入一个安全漏洞。假设你编写了一个允许用户列出目录的简单程序。用户可以植入任何想要的命令并利用你的程序。意外制造后门非常可怕,希望这说明了使用shell=True是多么糟糕的主意!

#This is input by a malicious user and causes permanent data loss
user_input = 'some_dir && rm -rf /some/important/directory'
my_command = "ls -l " + user_input
subprocess.run(my_command, shell=True)

相反,你可以通过不允许使用字符串完全避免这个问题:

#This is input by a malicious user and does nothing
user_input = 'some_dir && rm -rf /some/important/directory'
subprocess.run(["ls", "-l", user_input])

设置适当的超时时间并在适当时处理它们

如果你正在编写一个可能运行一段时间的进程的代码,你应该设置一个明智的默认超时时间。一个测试这个的简单方法是使用 Unix 的sleep命令。下面是一个在 IPython shell 中在超时触发之前完成的sleep命令的示例。它返回一个CompletedProcess对象:

In [1]: subprocess.run(["sleep", "3"], timeout=4)
Out[1]: CompletedProcess(args=['sleep', '3'], returncode=0)

这里是第二个版本,它会抛出一个异常。在大多数情况下,处理这个异常会很明智:

----> 1 subprocess.run(["sleep", "3"], timeout=1)

/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/subprocess.py
 in run(input, capture_output, timeout, check, *popenargs, **kwargs)
    477             stdout, stderr = process.communicate()
    478             raise TimeoutExpired(process.args, timeout, output=stdout,
--> 479                                  stderr=stderr)
    480       except:  # Including KeyboardInterrupt, communicate handled that.
    481             process.kill()

TimeoutExpired: Command '['sleep', '3']' timed out after 1 seconds

一个合理的做法是捕获这个异常TimeoutExpired,然后记录异常并实现一些清理代码:

import logging
import subprocess

try:
    subprocess.run(["sleep", "3"], timeout=4)
except subprocess.TimeoutExpired:
    logging.exception("Sleep command timed out")

在构建专业级别的系统时,记录异常至关重要。如果这段代码稍后部署在许多机器上,没有一个可搜索的集中式日志系统,追踪错误可能会变得不可能。对于 DevOps 专业人员来说,遵循这个模式并传播它的用处是至关重要的。

用 Python 线程的问题

你可能在成长过程中有过父母告诉你不要和某个朋友交往的经历。如果是这样,很可能是因为你的父母试图帮助你避免犯错。Python 线程就像你成长过程中那个糟糕的朋友一样。如果你继续和它们联系,事情不会有好结果。

在其他语言中,线程是一个合理的折衷方案。在像 C#这样的语言中,你可以执行与队列连接的线程池,并期望每个生成的线程都可以利用设备上的所有核心。这种已经被证明有效的使用线程与队列的模式减少了在代码中手动设置和移除锁的缺点。

Python 不是这样工作的。如果你生成线程,它不会利用你机器上的所有核心,并且它通常会表现出非确定性的方式,从一个核心跳到另一个核心,甚至“减慢你的代码”。为什么在有替代方案的情况下要使用这样的东西呢?

如果你对学习更多关于 DevOps 感兴趣,那么你很可能专注于实用性。你只想学习和应用实际和有意义的知识。实用性是避免在 Python 中使用线程的另一个理由。理论上,在某些情况下可以使用线程并获得性能提升,如果问题是 I/O 绑定的话。然而,再次问一下,为什么要使用一个不可靠的工具,当有可靠的工具存在时?在 Python 中使用线程就像开车需要推一下然后通过弹跳离合器来启动汽车,因为电池不靠谱。当你没有地方可以推动它或者无法把车停在斜坡上时会发生什么?采用这种策略纯粹是疯狂的!

本章中没有使用线程的示例。为什么展示一些不正确的东西?与其使用线程,不如专注于本章中概述的其他替代方案。

使用多进程解决问题

多进程库是使用 Python 标准库在机器上利用所有核心的唯一统一方式。查看图 9-9 时,操作系统级别有几个选择:多进程和容器。

pydo 0909

图 9-9. 运行并行 Python 代码

使用容器作为替代方案是一个重要的区别。如果使用多进程库的目的是在没有进程间通信的情况下多次调用进程,可以有很强的理由使用容器、虚拟机或云原生构造,如函数即服务。一个受欢迎且有效的云原生选项是 AWS Lambda。

同样,与自行分叉进程相比,容器具有许多优势。容器有许多优点。容器定义为代码。容器可以精确地调整到所需的级别:即内存、CPU 或磁盘 I/O。它们是直接竞争对手,通常是自行分叉进程的更好替代品。在实践中,它们也可以更容易地融入 DevOps 思维方式。

从 DevOps 的角度来看,如果你认同这样一个观点,即除非没有其他选择,否则应该避免在 Python 中自己实现并发,那么即使是使用 multiprocessing 模块的场景也是有限的。也许在开发和实验阶段,multiprocessing 最好只作为一种工具,因为在容器和云层面都存在更好的选择。

另一种说法是问你信任哪个进程分叉:你在 Python 中编写的多进程代码,Google 编写的 Kubernetes 开发人员,还是亚马逊编写的 AWS Lambda 开发人员?经验告诉我,当我站在巨人的肩膀上时,我做出了最好的决定。在哲学考虑之后,这里是一些有效使用多进程的方法。

使用 Pool()分叉进程

测试多进程分叉能力并针对其运行函数的一个直接方法是使用 sklearn 机器学习库计算 KMeans 聚类。KMeans 计算密集且时间复杂度为 O(n**2),这意味着随着数据量增加,其增长速度会指数级减慢。这个例子非常适合在宏观或微观级别上并行化处理。在下面的例子中,make_blobs方法创建了一个包含 10 万条记录和 10 个特征的数据集。每个 KMeans 算法的计时以及总计时如下:

from sklearn.datasets.samples_generator import make_blobs
from sklearn.cluster import KMeans
import time

def do_kmeans():
    """KMeans clustering on generated data"""

    X,_ = make_blobs(n_samples=100000, centers=3, n_features=10,
                random_state=0)
    kmeans = KMeans(n_clusters=3)
    t0 = time.time()
    kmeans.fit(X)
    print(f"KMeans cluster fit in {time.time()-t0}")

def main():
    """Run Everything"""

    count = 10
    t0 = time.time()
    for _ in range(count):
        do_kmeans()
    print(f"Performed {count} KMeans in total time: {time.time()-t0}")

if __name__ == "__main__":
    main()

KMeans 算法的运行时显示,其是一个昂贵的操作,运行 10 次迭代需要 3.5 秒:

(.python-devops) ➜  python kmeans_sequential.py
KMeans cluster fit in 0.29854321479797363
KMeans cluster fit in 0.2869119644165039
KMeans cluster fit in 0.2811620235443115
KMeans cluster fit in 0.28687286376953125
KMeans cluster fit in 0.2845759391784668
KMeans cluster fit in 0.2866239547729492
KMeans cluster fit in 0.2843656539916992
KMeans cluster fit in 0.2885470390319824
KMeans cluster fit in 0.2878849506378174
KMeans cluster fit in 0.28443288803100586
Performed 10 KMeans in total time: 3.510640859603882

在下面的例子中,使用multiprocessing.Pool.map方法将 10 个 KMeans 集群操作分配给一个包含 10 个进程的池。这个例子通过将参数100000映射到函数do_kmeans来实现:

from multiprocessing import Pool
from sklearn.datasets.samples_generator import make_blobs
from sklearn.cluster import KMeans
import time

def do_kmeans(n_samples):
    """KMeans clustering on generated data"""

    X,_ = make_blobs(n_samples, centers=3, n_features=10,
                random_state=0)
    kmeans = KMeans(n_clusters=3)
    t0 = time.time()
    kmeans.fit(X)
    print(f"KMeans cluster fit in {time.time()-t0}")

def main():
    """Run Everything"""

    count = 10
    t0 = time.time()
    with Pool(count) as p:
        p.map(do_kmeans, [100000,100000,100000,100000,100000,
                    100000,100000,100000,100000,100000])

    print(f"Performed {count} KMeans in total time: {time.time()-t0}")

if __name__ == "__main__":
    main()

每个 KMeans 操作的运行时间较慢,但总体加速度翻倍。这是并发框架的常见问题;并行工作分配有开销。并行代码的运行并不是“免费午餐”。每个任务的启动时间约为 1 秒:

(.python-devops) ➜ python kmeans_multiprocessing.py
KMeans cluster fit in 1.3836050033569336
KMeans cluster fit in 1.3868029117584229
KMeans cluster fit in 1.3955950736999512
KMeans cluster fit in 1.3925609588623047
KMeans cluster fit in 1.3877739906311035
KMeans cluster fit in 1.4068050384521484
KMeans cluster fit in 1.41087007522583
KMeans cluster fit in 1.3935530185699463
KMeans cluster fit in 1.4161033630371094
KMeans cluster fit in 1.4132652282714844
Performed 10 KMeans in total time: 1.6691410541534424

这个例子展示了为什么对代码进行性能分析和谨慎立即跳转到并发是至关重要的。如果问题规模较小,那么并行化方法的开销可能会使代码变慢,并且调试起来更加复杂。

从 DevOps 的角度来看,最直接和最可维护的方法始终应该是首选。实际上,这可能意味着这种多进程并行化的风格是一个合理的方法,但在尝试宏观准备水平的并行化方法之前不要轻易采用。一些替代的宏观方法可能包括使用容器,使用 FaaS(如 AWS Lambda 或其他无服务器技术),或者使用一个高性能服务器,Python 运行工人对其进行工作(如 RabbitMQ 或 Redis)。

作为服务的函数和无服务器

现代 AI 时代已经创建了压力,促使新范式的出现。CPU 时钟速度的增加已经停滞不前,这实际上结束了摩尔定律。与此同时,数据爆炸、云计算的兴起以及应用特定集成电路(ASIC)的可用性填补了这一空白。现在,函数作为工作单元已经成为一个重要的概念。

Serverless 和 FaaS 可以在某种程度上互换使用,它们描述了在云平台上作为工作单元运行函数的能力。

使用 Numba 进行高性能 Python

Numba 是一个非常酷的库,用于进行分布式问题解决的实验。使用它就像是用高性能的市场售后部件改装你的汽车一样。它还利用了使用 ASIC 解决特定问题的趋势。

使用 Numba 即时编译器

让我们来看看 官方文档示例 中关于 Numba 即时编译器(JIT)的例子,稍作调整,然后分析发生了什么。

这个示例是一个被 JIT 装饰的 Python 函数。参数 nopython=True 强制代码通过 JIT 并使用 LLVM 编译器进行优化。如果不选择这个选项,意味着如果某些内容无法转换为 LLVM,则会保持常规的 Python 代码:

import numpy as np
from numba import jit

@jit(nopython=True)
def go_fast(a):
    """Expects Numpy Array"""

    count = 0
    for i in range(a.shape[0]):
        count += np.tanh(a[i, i])
    return count + trace

接下来,创建一个 numpy 数组,并使用 IPython 的魔术函数来计时它:

x = np.arange(100).reshape(10, 10)
%timeit go_fast(x)

输出显示,运行该代码耗时 855 纳秒:

The slowest run took 33.43 times longer than the fastest. This example could mean
that an intermediate result is cached. 1000000 loops, best of 3: 855 ns per loop

可以使用此技巧运行常规版本以避免装饰器:

%timeit go_fast.py_func(x)

输出显示,没有 JIT,常规 Python 代码运行速度慢了 20 倍:

The slowest run took 4.15 times longer than the fastest. This result could mean
that an intermediate run is cached. 10000 loops, best of 3: 20.5 µs per loop

使用 Numba JIT,for 循环是可以加速的优化对象。它还优化了 numpy 函数和 numpy 数据结构。这里的主要观点是,也许值得查看已运行多年的现有代码,看看 Python 基础架构的关键部分是否可以受益于使用 Numba JIT 进行编译。

使用高性能服务器

自我实现是人类发展中的一个重要概念。自我实现的最简单定义是个体达到他们真实潜力的状态。为了做到这一点,他们必须接受自己的人性,包括其中的所有缺陷。有一种理论认为,不到 1%的人已完全实现了自我。

同样的概念也可以应用于 Python,这种语言。全面接受语言的优势和劣势允许开发人员充分利用它。Python 不是一种高性能语言。Python 不是一种像其他语言(如 Go、Java、C、C++、C# 或 Erlang)那样优化用于编写服务器的语言。相反,Python 是一种在高性能语言或平台上应用高级逻辑的语言。

Python 之所以广受欢迎,是因为它符合人类思维的自然过程。通过足够的语言使用经验,你可以像使用母语一样思考 Python。逻辑可以用许多方式表达:语言、符号表示、代码、图片、声音和艺术。计算机科学构造,如内存管理、类型声明、并发原语和面向对象设计可以从纯逻辑中抽象出来。它们对于表达一个想法是可选的。

类似 Python 这样的语言的强大之处在于它允许用户在逻辑层面工作,而不是计算机科学层面。什么是要点?为任务选择正确的工具,而通常这是云或另一种语言的服务。

结论

DevOps 和数据科学都有一个共同点,那就是它们既是职位名称,又是能力。DevOps 方法的一些好处是速度、自动化、可靠性、规模和安全性通过实用主义实现。在考虑可用框架和解决方案之前使用宏观级别的解决方案来提高并发和进程管理的效率是一种危险的 DevOps 反模式。

Python 在云时代的要点是什么?

  • 学会为手头的任务掌握正确的并发技术。

  • 学会使用高性能计算库 Numba 为你的代码提速,使用真实线程、JIT 和 GPU。

  • 学会使用 FaaS 优雅地解决独特问题。

  • 将云视为操作系统,并让它承担并发的繁重工作。

  • 拥抱云原生构造,如持续交付、Docker 格式容器和无服务器。

练习

  • 什么是 IaaS?

  • 什么是 PaaS?

  • 弹性是什么意思?

  • 可用性是什么意思?

  • 什么是块存储?

  • 云计算服务的不同类型是什么?

  • 什么是无服务器?

  • IaaS 和 PaaS 之间有哪些关键区别?

  • 什么是 CAP 定理?

  • 什么是阿姆达尔定律?

案例研究问题

  • 一家公司犹豫不决地转向云计算,因为它听说可能会更昂贵。有哪些方法可以减轻采用云计算的成本风险?

  • 什么是云原生架构的例子?绘制一个云原生系统的架构图,并列出关键特性。

  • spot 或抢先实例有什么作用?它们如何节省金钱?它们适用于什么问题?它们不适用于什么问题?