Python DevOps 指南(五)
原文:
annas-archive.org/md5/68b28228356df0415ddc83eb0aaea548译者:飞龙
第十章:基础设施即代码
在我们拥有花哨的 DevOps 标题和工作描述之前,我们是卑微的系统管理员,或者简称为 sysadmins。那些是黑暗的,云计算之前的日子,当时我们不得不将我们的车的行李箱装满裸金属服务器,然后开车到一个机房设施,将服务器安装到机架上,连接它们,连接一个有轮子的监视器/键盘/鼠标,然后逐个设置它们。格里格仍然不敢想象他在机房度过的小时,灯光刺眼,空调冷得刺骨。我们必须成为 Bash 脚本的巫师,然后我们毕业到 Perl,我们中更幸运的人到了 Python。俗话说,2004 年左右的互联网是用胶带和泡泡糖粘在一起的。
在 2006 年到 2007 年期间的某个时候,我们发现了亚马逊 EC2 实例的神奇世界。我们能够通过一个简单的点-and-click 接口或通过命令行工具来创建服务器。不再需要开车去机房,不再需要堆叠和连接裸金属服务器。我们可以疯狂地一次性启动 10 个 EC2 实例。甚至 20!甚至 100!天空是极限。然而,我们很快发现,手动连接到每个 EC2 实例,然后在每个实例上单独设置我们的应用程序不会扩展。创建实例本身相当容易。困难的是安装我们应用程序所需的软件包,添加正确的用户,确保文件权限正确,最后安装和配置我们的应用程序。为了解决这个问题,第一代基础架构自动化软件诞生了,“配置管理”工具代表着。Puppet 是第一个知名的配置管理工具,于 2005 年发布,早于 Amazon EC2 的发布。在 Puppet 推出后不久推出的其他类似工具包括 2008 年的 Chef,2011 年的 SaltStack,以及 2012 年的 Ansible。
到了 2009 年,世界准备迎来一个新术语的到来:DevOps。到今天为止,DevOps 有着竞争激烈的定义。有趣的是,它诞生在基础设施软件自动化的动荡早期。虽然 DevOps 中有重要的人和文化方面,但在这一章中有一件事是突出的:即自动化基础架构和应用程序的配置、部署和部署能力。
到了 2011 年,要跟踪亚马逊网络服务(AWS)套件中所有的服务变得越来越困难。云比起原始计算能力(Amazon EC2)和对象存储(Amazon S3)要复杂得多。应用程序开始依赖于相互交互的多个服务,并且需要工具来帮助自动化这些服务的配置。亚马逊没有等待太久就填补了这个需求,2011 年它开始提供这样的工具:AWS CloudFormation。这是我们真正能够通过代码描述基础设施的一个重要时刻之一。CloudFormation 为基础设施即代码(IaC)工具的新一代打开了大门,这些工具操作的是云基础设施层,低于第一代配置管理工具所提供的层次。
到了 2014 年,AWS 推出了数十项服务。那一年,另一个在 IaC 领域中重要的工具诞生了:HashiCorp 的 Terraform。时至今日,CloudFormation 和 Terraform 仍然是最常用的 IaC 工具。
在 IaC 和 DevOps 领域的另一个重要进展发生在 2013 年末到 2014 年初之间:Docker 的发布,它成为容器技术的代名词。尽管容器技术已经存在多年,但 Docker 为此带来的巨大好处在于,它将 Linux 容器和 cgroups 等技术包装成易于使用的 API 和命令行界面(CLI)工具集,大大降低了希望将其应用程序打包成容器并在任何 Docker 运行的地方部署和运行的人们的准入门槛。容器技术和容器编排平台在第十一章和第十二章中有详细讨论。
Docker 的使用率和影响力急剧上升,损害了第一代配置管理工具(Puppet、Chef、Ansible、SaltStack)的流行度。这些工具背后的公司目前正陷入困境,并都在试图通过重塑自身以适应云环境来保持活力和时效性。在 Docker 出现之前,您会使用诸如 CloudFormation 或 Terraform 等 IaC 工具来配置应用程序的基础设施,然后使用配置管理工具(如 Puppet、Chef、Ansible 或 SaltStack)部署应用程序本身(代码和配置)。Docker 突然间使得这些配置管理工具变得过时,因为它提供了一种方式,您可以将应用程序(代码+配置)打包到一个 Docker 容器中,然后在由 IaC 工具配置的基础设施内运行。
基础设施自动化工具分类
快进到 2020 年,作为一名 DevOps 从业者,在面对众多基础设施自动化工具时很容易感到迷失。
区分 IaC 工具的一种方式是看它们运行的层级。CloudFormation 和 Terraform 等工具运行在云基础设施层。它们允许你提供云资源,如计算、存储和网络,以及各种服务,如数据库、消息队列、数据分析等。配置管理工具如 Puppet、Chef、Ansible 和 SaltStack 通常在应用程序层操作,确保为你的应用程序安装所有所需的包,并确保应用程序本身配置正确(尽管这些工具中许多也有可以提供云资源的模块)。Docker 也在应用程序层操作。
另一种比较 IaC 工具的方法是将它们分为声明式和命令式两类。你可以用声明式方式告诉自动化工具要做什么,描述你想要实现的系统状态。Puppet、CloudFormation 和 Terraform 采用声明式方式操作。或者,你可以使用程序化或命令式方式使用自动化工具,指定工具需要执行的确切步骤来实现所需的系统状态。Chef 和 Ansible 采用命令式方式操作。SaltStack 可以同时采用声明式和命令式方式操作。
让我们把系统的期望状态看作建筑物(比如体育场)的建造蓝图。你可以使用 Chef 和 Ansible 这样的程序化工具,逐节、逐行地在每个部分内部建造体育场。你需要跟踪体育场的状态和建造进度。使用 Puppet、CloudFormation 和 Terraform 这样的声明式工具,你首先组装体育场的蓝图。然后工具确保建造达到蓝图中描述的状态。
鉴于本章的标题,我们将把剩下的讨论集中在 IaC 工具上,可以进一步按多个维度进行分类。
一个维度是指定系统期望状态的方式。在 CloudFormation 中,你可以使用 JSON 或 YAML 语法,而在 Terraform 中,你可以使用 HashiCorp Configuration Language (HCL) 语法。相比之下,Pulumi 和 AWS Cloud Development Kit (CDK) 允许你使用真正的编程语言,包括 Python,来指定系统的期望状态。
另一个维度是每个工具支持的云提供商。由于 CloudFormation 是亚马逊的服务,因此它专注于 AWS(尽管在使用自定义资源功能时可以定义非 AWS 资源)。AWS CDK 也是如此。相比之下,Terraform 支持许多云提供商,Pulumi 也是如此。
由于这是一本关于 Python 的书,我们想提到一个名为 troposphere 的工具,它允许你使用 Python 代码指定 CloudFormation 堆栈模板,然后将其导出为 JSON 或 YAML。Troposphere 只负责生成堆栈模板,这意味着你需要使用 CloudFormation 进行堆栈的配置。另一个同样使用 Python 并值得一提的工具是 stacker,它在底层使用 troposphere,但它还可以配置生成的 CloudFormation 堆栈模板。
本章的其余部分展示了两个自动化工具 Terraform 和 Pulumi 的实际操作,它们分别应用于一个常见的场景,即在 Amazon S3 中部署静态网站,该网站由 Amazon CloudFront CDN 托管,并通过 AWS 证书管理器(ACM)服务提供 SSL 证书保护。
注意
在以下示例中使用的某些命令会生成大量输出。除非这些输出对理解命令至关重要,否则我们将省略大部分输出行以节省资源,并帮助你更好地专注于文本内容。
手动配置
我们首先通过 AWS 的 Web 控制台手动完成了一系列操作。没有什么比亲自体验手动操作的痛苦更能让你更好地享受自动化繁琐工作的成果了!
我们首先按照 AWS S3 托管网站 的文档进行操作。
我们已经在 Namecheap 购买了一个域名:devops4all.dev。我们为该域名在 Amazon Route 53 中创建了托管区域,并将该域名在 Namecheap 中的名称服务器指向处理托管域名的 AWS DNS 服务器。
我们创建了两个 S3 存储桶,一个用于站点的根 URL(devops4all.dev),另一个用于 www URL(www.devops4all.dev)。我们的想法是将对 www 的请求重定向到根 URL。我们还按照指南配置了这些存储桶,使其支持静态网站托管,并设置了适当的权限。我们上传了一个 index.html 文件和一张 JPG 图片到根 S3 存储桶。
接下来的步骤是为处理根域名(devops4all.dev)及其任何子域名(*.devops4all.dev)的 SSL 证书进行配置。我们使用了添加到 Route 53 托管区域的 DNS 记录进行验证。
注意
ACM 证书需要在 us-east-1 AWS 区域进行配置,以便在 CloudFront 中使用。
然后我们创建了一个 AWS CloudFront CDN 分发,指向根 S3 存储桶,并使用了前面配置的 ACM 证书。我们指定 HTTP 请求应重定向到 HTTPS。分发部署完成后(大约需要 15 分钟),我们添加了 Route 53 记录,将根域名和 www 域名作为类型为别名的 A 记录,指向 CloudFront 分发终端节点的 DNS 名称。
在完成本练习时,我们能够访问 *devops4all.dev*,自动重定向到 *devops4all.dev*,并看到我们上传的图片显示在站点首页上。我们还尝试访问 *www.devops4all.dev*,并被重定向到 *devops4all.dev*。
我们手动创建所有提到的 AWS 资源大约花了 30 分钟。此外,我们还花了 15 分钟等待 CloudFront 分发的传播,总共是 45 分钟。请注意,我们之前已经做过这些,所以我们几乎完全知道该怎么做,只需要最少量地参考 AWS 指南。
注意
值得一提的是,如今很容易配置免费的 SSL 证书。早已不再需要等待 SSL 证书提供商审批您的请求,并提交证明您的公司存在的时间,只需使用 AWS ACM 或 Let’s Encrypt,2020 年再也没有不应该在站点的所有页面上启用 SSL 的借口。
使用 Terraform 进行自动化基础设施配置
我们决定使用 Terraform 作为首选的 IaC 工具来自动化这些任务,尽管 Terraform 与 Python 没有直接关系。它有几个优点,如成熟性、强大的生态系统和多云供应商。
编写 Terraform 代码的推荐方式是使用模块,这些模块是 Terraform 配置代码的可重用组件。HashiCorp 托管的 Terraform 模块的 注册表 是一个通用的地方,您可以搜索用于配置所需资源的现成模块。在本示例中,我们将编写自己的模块。
此处使用的 Terraform 版本是 0.12.1,在撰写本文时是最新版本。在 Mac 上通过 brew 安装它:
$ brew install terraform
配置一个 S3 存储桶
创建一个 modules 目录,在其下创建一个 s3 目录,其中包含三个文件:main.tf、variables.tf 和 outputs.tf。s3 目录中的 main.tf 文件告诉 Terraform 创建一个具有特定策略的 S3 存储桶。它使用一个名为 domain_name 的变量,在 variables.tf 中声明,其值由调用此模块的用户传递给它。它输出 S3 存储桶的 DNS 端点,其他模块将使用它作为输入变量。
这里是 modules/s3 中的三个文件:
$ cat modules/s3/main.tf
resource "aws_s3_bucket" "www" {
bucket = "www.${var.domain_name}"
acl = "public-read"
policy = <<POLICY
{
"Version":"2012-10-17",
"Statement":[
{
"Sid":"AddPerm",
"Effect":"Allow",
"Principal": "*",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::www.${var.domain_name}/*"]
}
]
}
POLICY
website {
index_document = "index.html"
}
}
$ cat modules/s3/variables.tf
variable "domain_name" {}
$ cat modules/s3/outputs.tf
output "s3_www_website_endpoint" {
value = "${aws_s3_bucket.www.website_endpoint}"
}
注意
上述 aws_s3_bucket 资源的 policy 属性是允许公共访问该存储桶的 S3 存储桶策略的一个示例。如果您在 IaC 环境中使用 S3 存储桶,请熟悉 官方 AWS 存储桶和用户策略文档。
将所有模块绑定在一起的主要 Terraform 脚本位于当前目录中的名为 main.tf 的文件中:
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
它引用了一个定义在名为 variables.tf 的单独文件中的变量:
$ cat variables.tf
variable "aws_region" {
default = "us-east-1"
}
variable "domain_name" {
default = "devops4all.dev"
}
这是当前目录树的情况:
|____main.tf
|____variables.tf
|____modules
| |____s3
| | |____outputs.tf
| | |____main.tf
| | |____variables.tf
运行 Terraform 的第一步是调用 terraform init 命令,它将读取主文件引用的任何模块的内容。
接下来的步骤是运行 terraform plan 命令,它创建了前面讨论中提到的蓝图。
要创建计划中指定的资源,请运行 terraform apply 命令:
$ terraform apply
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# module.s3.aws_s3_bucket.www will be created
+ resource "aws_s3_bucket" "www" {
+ acceleration_status = (known after apply)
+ acl = "public-read"
+ arn = (known after apply)
+ bucket = "www.devops4all.dev"
+ bucket_domain_name = (known after apply)
+ bucket_regional_domain_name = (known after apply)
+ force_destroy = false
+ hosted_zone_id= (known after apply)
+ id= (known after apply)
+ policy = jsonencode(
{
+ Statement = [
+ {
+ Action = [
+ "s3:GetObject",
]
+ Effect = "Allow"
+ Principal = "*"
+ Resource = [
+ "arn:aws:s3:::www.devops4all.dev/*",
]
+ Sid = "AddPerm"
},
]
+ Version= "2012-10-17"
}
)
+ region = (known after apply)
+ request_payer = (known after apply)
+ website_domain= (known after apply)
+ website_endpoint = (known after apply)
+ versioning {
+ enabled = (known after apply)
+ mfa_delete = (known after apply)
}
+ website {
+ index_document = "index.html"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
module.s3.aws_s3_bucket.www: Creating...
module.s3.aws_s3_bucket.www: Creation complete after 7s [www.devops4all.dev]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
此时,请检查是否使用 AWS Web 控制台 UI 创建了 S3 存储桶。
使用 AWS ACM 配置 SSL 证书
下一个模块是为使用 AWS 证书管理器服务进行 SSL 证书配置而创建的。创建一个名为 modules/acm 的目录,其中包含三个文件:main.tf、variables.tf 和 outputs.tf。acm 目录中的 main.tf 文件告诉 Terraform 使用 DNS 作为验证方法创建 ACM SSL 证书。它使用一个名为 domain_name 的变量,该变量在 variables.tf 中声明,并由调用此模块的调用者传递其值。它输出证书的 ARN 标识符,该标识符将被其他模块用作输入变量。
$ cat modules/acm/main.tf
resource "aws_acm_certificate" "certificate" {
domain_name = "*.${var.domain_name}"
validation_method = "DNS"
subject_alternative_names = ["*.${var.domain_name}"]
}
$ cat modules/acm/variables.tf
variable "domain_name" {
}
$ cat modules/acm/outputs.tf
output "certificate_arn" {
value = "${aws_acm_certificate.certificate.arn}"
}
在主 Terraform 文件中添加对新的 acm 模块的引用:
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
module "acm" {
source = "./modules/acm"
domain_name = "${var.domain_name}"
}
接下来的三个步骤与 S3 存储桶创建序列中的步骤相同:terraform init、terraform plan 和 terraform apply。
使用 AWS 控制台添加必要的 Route 53 记录以进行验证过程。证书通常会在几分钟内验证和发布。
配置 Amazon CloudFront 分发
下一个模块是为创建 Amazon CloudFront 分发而创建的。创建一个名为 modules/cloudfront 的目录,其中包含三个文件:main.tf、variables.tf 和 outputs.tf。cloudfront 目录中的 main.tf 文件告诉 Terraform 创建 CloudFront 分发资源。它使用在 variables.tf 中声明的多个变量,这些变量的值由调用此模块的调用者传递。它输出 CloudFront 端点的 DNS 域名和 CloudFront 分发的托管 Route 53 区域 ID,这些将作为其他模块的输入变量使用:
$ cat modules/cloudfront/main.tf
resource "aws_cloudfront_distribution" "www_distribution" {
origin {
custom_origin_config {
// These are all the defaults.
http_port= "80"
https_port = "443"
origin_protocol_policy = "http-only"
origin_ssl_protocols= ["TLSv1", "TLSv1.1", "TLSv1.2"]
}
domain_name = "${var.s3_www_website_endpoint}"
origin_id= "www.${var.domain_name}"
}
enabled = true
default_root_object = "index.html"
default_cache_behavior {
viewer_protocol_policy = "redirect-to-https"
compress = true
allowed_methods= ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "www.${var.domain_name}"
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
}
aliases = ["www.${var.domain_name}"]
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = "${var.acm_certificate_arn}"
ssl_support_method = "sni-only"
}
}
$ cat modules/cloudfront/variables.tf
variable "domain_name" {}
variable "acm_certificate_arn" {}
variable "s3_www_website_endpoint" {}
$ cat modules/cloudfront/outputs.tf
output "domain_name" {
value = "${aws_cloudfront_distribution.www_distribution.domain_name}"
}
output "hosted_zone_id" {
value = "${aws_cloudfront_distribution.www_distribution.hosted_zone_id}"
}
在主 Terraform 文件中添加对 cloudfront 模块的引用。将 s3_www_website_endpoint 和 acm_certificate_arn 作为输入变量传递给 cloudfront 模块。它们的值从其他模块 s3 和 acm 的输出中获取。
注意
ARN 代表 Amazon 资源名称。它是一个字符串,用于唯一标识给定的 AWS 资源。当您使用在 AWS 内运行的 IaC 工具时,会看到许多生成的 ARN 值作为变量传递和传递。
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
module "acm" {
source = "./modules/acm"
domain_name = "${var.domain_name}"
}
module "cloudfront" {
source = "./modules/cloudfront"
domain_name = "${var.domain_name}"
s3_www_website_endpoint = "${module.s3.s3_www_website_endpoint}"
acm_certificate_arn = "${module.acm.certificate_arn}"
}
接下来的三个步骤是使用 Terraform 进行资源配置的常规步骤:terraform init、terraform plan 和 terraform apply。
在这种情况下,terraform apply 步骤耗时约 23 分钟。创建 Amazon CloudFront 分发是 AWS 中最耗时的操作之一,因为该分发在幕后由 Amazon 在全球范围内部署。
配置 Route 53 DNS 记录
下一个模块是为站点 www.devops4all.dev 的主域创建 Route 53 DNS 记录。创建一个名为 modules/route53 的目录,并包含两个文件:main.tf 和 variables.tf。route53 目录中的 main.tf 文件告诉 Terraform 创建一个类型为 A 的 Route 53 DNS 记录,作为 CloudFront 终端节点的 DNS 名称的别名。它使用在 variables.tf 中声明的几个变量,并通过调用此模块的调用者传递给它的值:
$ cat modules/route53/main.tf
resource "aws_route53_record" "www" {
zone_id = "${var.zone_id}"
name = "www.${var.domain_name}"
type = "A"
alias {
name = "${var.cloudfront_domain_name}"
zone_id = "${var.cloudfront_zone_id}"
evaluate_target_health = false
}
}
$ cat modules/route53/variables.tf
variable "domain_name" {}
variable "zone_id" {}
variable "cloudfront_domain_name" {}
variable "cloudfront_zone_id" {}
在 main.tf Terraform 文件中添加对 route53 模块的引用。将 zone_id、cloudfront_domain_name 和 cloudfront_zone_id 作为输入变量传递给 route53 模块。zone_id 的值在当前目录的 variables.tf 中声明,而其他值则从 cloudfront 模块的输出中检索:
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
module "acm" {
source = "./modules/acm"
domain_name = "${var.domain_name}"
}
module "cloudfront" {
source = "./modules/cloudfront"
domain_name = "${var.domain_name}"
s3_www_website_endpoint = "${module.s3.s3_www_website_endpoint}"
acm_certificate_arn = "${module.acm.certificate_arn}"
}
module "route53" {
source = "./modules/route53"
domain_name = "${var.domain_name}"
zone_id = "${var.zone_id}"
cloudfront_domain_name = "${module.cloudfront.domain_name}"
cloudfront_zone_id = "${module.cloudfront.hosted_zone_id}"
}
$ cat variables.tf
variable "aws_region" {
default = "us-east-1"
}
variable "domain_name" {
default = "devops4all.dev"
}
variable "zone_id" {
default = "ZWX18ZIVHAA5O"
}
接下来的三个步骤,现在对您来说应该非常熟悉了,是使用 Terraform 配置资源的:terraform init、terraform plan 和 terraform apply。
将静态文件复制到 S3
为了测试从头到尾创建静态网站的配置,创建一个名为 index.html 的简单文件,其中包含一个 JPEG 图像,并将这两个文件复制到之前使用 Terraform 配置的 S3 存储桶。确保 AWS_PROFILE 环境变量已设置为 ~/.aws/credentials 文件中已存在的正确值:
$ echo $AWS_PROFILE
gheorghiu-net
$ aws s3 cp static_files/index.html s3://www.devops4all.dev/index.html
upload: static_files/index.html to s3://www.devops4all.dev/index.html
$ aws s3 cp static_files/devops4all.jpg s3://www.devops4all.dev/devops4all.jpg
upload: static_files/devops4all.jpg to s3://www.devops4all.dev/devops4all.jpg
访问 https://www.devops4all.dev/ 并验证您是否可以看到已上传的 JPG 图像。
使用 Terraform 删除所有已配置的 AWS 资源
每当您配置云资源时,都需要注意与其相关的费用。很容易忘记它们,您可能会在月底收到意外的 AWS 账单。确保删除上面配置的所有资源。通过运行 terraform destroy 命令来删除这些资源。还要注意,在运行 terraform destroy 之前需要删除 S3 存储桶的内容,因为 Terraform 不会删除非空桶的内容。
注意
在运行 terraform destroy 命令之前,请确保您不会删除仍可能在生产环境中使用的资源!
使用 Pulumi 自动化基础设施配置
当涉及到 IaC 工具时,Pulumi 是新秀之一。关键词是 new,这意味着它在某些方面仍然有些粗糙,特别是在 Python 支持方面。
Pulumi 允许您通过告诉它使用真正的编程语言来提供所需的基础设施状态,来指定您的基础设施的期望状态。TypeScript 是 Pulumi 支持的第一种语言,但现在也支持 Go 和 Python。
理解在 Python 中使用 Pulumi 编写基础设施自动化代码与使用 AWS 自动化库(如 Boto)之间的区别至关重要。
使用 Pulumi,您的 Python 代码描述了要部署的资源。实际上,您正在创建本章开头讨论的蓝图或状态。这使得 Pulumi 类似于 Terraform,但其主要区别在于 Pulumi 允许您充分利用像 Python 这样的编程语言的全部功能,例如编写函数、循环、使用变量等。您不受 Terraform 的 HCL 等标记语言的限制。Pulumi 结合了声明式方法的强大之处(描述所需的最终状态)与真正编程语言的强大之处。
使用诸如 Boto 之类的 AWS 自动化库,您可以通过编写的代码描述和提供单个 AWS 资源。没有整体蓝图或状态。您需要自行跟踪已提供的资源,并协调其创建和移除。这是自动化工具的命令式或过程化方法。您仍然可以利用编写 Python 代码的优势。
要开始使用 Pulumi,请在他们的网站 pulumi.io 上创建一个免费帐户。然后,您可以在本地计算机上安装pulumi命令行工具。在 Macintosh 上,请使用 Homebrew 安装pulumi。
本地运行的第一个命令是pulumi login:
$ pulumi login
Logged into pulumi.com as griggheo (https://app.pulumi.com/griggheo)
创建 AWS 的新 Pulumi Python 项目
创建一个名为proj1的目录,在该目录中运行pulumi new,并选择aws-python模板。在项目创建的过程中,pulumi会要求您为堆栈命名。称其为staging:
$ mkdir proj1
$ cd proj1
$ pulumi new
Please choose a template: aws-python A minimal AWS Python Pulumi program
This command will walk you through creating a new Pulumi project.
Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.
project name: (proj1)
project description: (A minimal AWS Python Pulumi program)
Created project 'proj1'
stack name: (dev) staging
Created stack 'staging'
aws:region: The AWS region to deploy into: (us-east-1)
Saved config
Your new project is ready to go!
To perform an initial deployment, run the following commands:
1\. virtualenv -p python3 venv
2\. source venv/bin/activate
3\. pip3 install -r requirements.txt
Then, run 'pulumi up'
重要的是要理解 Pulumi 项目与 Pulumi 堆栈之间的区别。项目是您为指定系统所需状态编写的代码,即您希望 Pulumi 提供的资源。堆栈是项目的特定部署。例如,堆栈可以对应于开发、暂存或生产等环境。在接下来的示例中,我们将创建两个 Pulumi 堆栈,一个称为staging,对应于暂存环境,以及稍后的另一个称为prod,对应于生产环境。
这里是由pulumi new命令自动生成的文件,作为aws-python模板的一部分:
$ ls -la
total 40
drwxr-xr-x 7 ggheo staff 224 Jun 13 21:43 .
drwxr-xr-x 11 ggheo staff 352 Jun 13 21:42 ..
-rw------- 1 ggheo staff 12 Jun 13 21:43 .gitignore
-rw-r--r-- 1 ggheo staff 32 Jun 13 21:43 Pulumi.staging.yaml
-rw------- 1 ggheo staff 77 Jun 13 21:43 Pulumi.yaml
-rw------- 1 ggheo staff 184 Jun 13 21:43 __main__.py
-rw------- 1 ggheo staff 34 Jun 13 21:43 requirements.txt
按照pulumi new的输出指示安装virtualenv,然后创建新的virtualenv环境并安装requirements.txt中指定的库:
$ pip3 install virtualenv
$ virtualenv -p python3 venv
$ source venv/bin/activate
(venv) pip3 install -r requirements.txt
注意
在使用pulumi up之前,确保您正在使用预期目标的 AWS 帐户来配置任何 AWS 资源。指定所需 AWS 帐户的一种方法是在当前 shell 中设置AWS_PROFILE环境变量。在我们的情况下,本地~/.aws/credentials文件中已设置了名为gheorghiu-net的 AWS 配置文件。
(venv) export AWS_PROFILE=gheorghiu-net
由 Pulumi 作为aws-python模板的一部分生成的main.py文件如下:
$ cat __main__.py
import pulumi
from pulumi_aws import s3
# Create an AWS resource (S3 Bucket)
bucket = s3.Bucket('my-bucket')
# Export the name of the bucket
pulumi.export('bucket_name', bucket.id)
本地克隆Pulumi 示例 GitHub 存储库,然后将__main__.py 和pulumi-examples/aws-py-s3-folder中的 www 目录复制到当前目录中。
这里是当前目录中的新__main__.py 文件:
$ cat __main__.py
import json
import mimetypes
import os
from pulumi import export, FileAsset
from pulumi_aws import s3
web_bucket = s3.Bucket('s3-website-bucket', website={
"index_document": "index.html"
})
content_dir = "www"
for file in os.listdir(content_dir):
filepath = os.path.join(content_dir, file)
mime_type, _ = mimetypes.guess_type(filepath)
obj = s3.BucketObject(file,
bucket=web_bucket.id,
source=FileAsset(filepath),
content_type=mime_type)
def public_read_policy_for_bucket(bucket_name):
return json.dumps({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject"
],
"Resource": [
f"arn:aws:s3:::{bucket_name}/*",
]
}]
})
bucket_name = web_bucket.id
bucket_policy = s3.BucketPolicy("bucket-policy",
bucket=bucket_name,
policy=bucket_name.apply(public_read_policy_for_bucket))
# Export the name of the bucket
export('bucket_name', web_bucket.id)
export('website_url', web_bucket.website_endpoint)
请注意 Python 变量content_dir和bucket_name的使用,for循环的使用,以及正常 Python 函数public_read_policy_for_bucket的使用。能够在 IaC 程序中使用正常的 Python 结构感到耳目一新!
现在是运行pulumi up以提供在__main__.py 中指定的资源的时候了。该命令将展示将要创建的所有资源。将当前选择移动到yes将启动配置过程:
(venv) pulumi up
Previewing update (staging):
Type Name Plan
+ pulumi:pulumi:Stack proj1-staging create
+ ├─ aws:s3:Bucket s3-website-bucket create
+ ├─ aws:s3:BucketObject favicon.png create
+ ├─ aws:s3:BucketPolicy bucket-policy create
+ ├─ aws:s3:BucketObject python.png create
+ └─ aws:s3:BucketObject index.html create
Resources:
+ 6 to create
Do you want to perform this update? yes
Updating (staging):
Type Name Status
+ pulumi:pulumi:Stack proj1-staging created
+ ├─ aws:s3:Bucket s3-website-bucket created
+ ├─ aws:s3:BucketObject index.html created
+ ├─ aws:s3:BucketObject python.png created
+ ├─ aws:s3:BucketObject favicon.png created
+ └─ aws:s3:BucketPolicy bucket-policy created
Outputs:
bucket_name: "s3-website-bucket-8e08f8f"
website_url: "s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com"
Resources:
+ 6 created
Duration: 14s
检查现有的 Pulumi 堆栈:
(venv) pulumi stack ls
NAME LAST UPDATE RESOURCE COUNT URL
staging* 2 minutes ago 7 https://app.pulumi.com/griggheo/proj1/staging
(venv) pulumi stack
Current stack is staging:
Owner: griggheo
Last updated: 3 minutes ago (2019-06-13 22:05:38.088773 -0700 PDT)
Pulumi version: v0.17.16
Current stack resources (7):
TYPE NAME
pulumi:pulumi:Stack proj1-staging
pulumi:providers:aws default
aws:s3/bucket:Bucket s3-website-bucket
aws:s3/bucketPolicy:BucketPolicy bucket-policy
aws:s3/bucketObject:BucketObject index.html
aws:s3/bucketObject:BucketObject favicon.png
aws:s3/bucketObject:BucketObject python.png
检查当前堆栈的输出:
(venv) pulumi stack output
Current stack outputs (2):
OUTPUT VALUE
bucket_name s3-website-bucket-8e08f8f
website_url s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com
访问website_url输出中指定的 URL(http://s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com),确保您可以看到静态站点。
在接下来的章节中,将通过指定更多的 AWS 资源来增强 Pulumi 项目。目标是与使用 Terraform 配置的资源保持一致:一个 ACM SSL 证书,一个 CloudFront 分发和一个用于站点 URL 的 Route 53 DNS 记录。
为 Staging 堆栈创建配置值
当前堆栈为staging。将现有的 www 目录重命名为 www-staging,然后使用pulumi config set命令为当前staging堆栈指定两个配置值:domain_name和local_webdir。
提示
有关 Pulumi 如何管理配置值和密钥的详细信息,请参阅Pulumi 参考文档。
(venv) mv www www-staging
(venv) pulumi config set local_webdir www-staging
(venv) pulumi config set domain_name staging.devops4all.dev
要检查当前堆栈的现有配置值,请运行:
(venv) pulumi config
KEY VALUE
aws:region us-east-1
domain_name staging.devops4all.dev
local_webdir www-staging
配置值设置完毕后,将它们用于 Pulumi 代码中:
import pulumi
config = pulumi.Config('proj1') # proj1 is project name defined in Pulumi.yaml
content_dir = config.require('local_webdir')
domain_name = config.require('domain_name')
现在配置值已经就位;接下来我们将使用 AWS 证书管理器服务来提供 SSL 证书。
配置 ACM SSL 证书
大约在这一点上,当涉及到其 Python SDK 时,Pulumi 开始显露出其不足之处。仅仅阅读 Pulumi Python SDK 参考中的acm模块并不足以理解您在 Pulumi 程序中需要做的事情。
幸运的是,有许多 Pulumi TypeScript 示例可供您参考。一个展示我们用例的示例是aws-ts-static-website。
这是创建新 ACM 证书的 TypeScript 代码(来自index.ts):
const certificate = new aws.acm.Certificate("certificate", {
domainName: config.targetDomain,
validationMethod: "DNS",
}, { provider: eastRegion });
这是我们编写的相应 Python 代码:
from pulumi_aws import acm
cert = acm.Certificate('certificate', domain_name=domain_name,
validation_method='DNS')
提示
从 TypeScript 转换 Pulumi 代码到 Python 的一个经验法则是,在 TypeScript 中使用驼峰命名法的参数在 Python 中会变成蛇形命名法。正如您在前面的示例中看到的,domainName变成了domain_name,而validationMethod变成了validation_method。
我们的下一步是为 ACM SSL 证书配置 Route 53 区域,并在该区域中为 DNS 验证记录。
配置 Route 53 区域和 DNS 记录
如果您遵循 Pulumi SDK reference for route53,使用 Pulumi 配置新的 Route 53 区域非常容易。
from pulumi_aws import route53
domain_name = config.require('domain_name')
# Split a domain name into its subdomain and parent domain names.
# e.g. "www.example.com" => "www", "example.com".
def get_domain_and_subdomain(domain):
names = domain.split(".")
if len(names) < 3:
return('', domain)
subdomain = names[0]
parent_domain = ".".join(names[1:])
return (subdomain, parent_domain)
(subdomain, parent_domain) = get_domain_and_subdomain(domain_name)
zone = route53.Zone("route53_zone", name=parent_domain)
前面的代码片段显示了如何使用常规 Python 函数将读取的配置值拆分为 domain_name 变量的两部分。如果 domain_name 是 staging.devops4all.dev,则函数将其拆分为 subdomain (staging) 和 parent_domain (devops4all.dev)。
parent_domain 变量然后作为 zone 对象的构造函数的参数使用,告诉 Pulumi 配置 route53.Zone 资源。
注意
创建 Route 53 区域后,我们必须将 Namecheap 的域名服务器指向新区域的 DNS 记录中指定的域名服务器,以便该区域可以公开访问。
到目前为止,一切都很顺利。接下来的步骤是同时创建 ACM 证书和用于验证证书的 DNS 记录。
我们首先尝试通过将 camelCase 参数名转换为 snake_case 的经验法则来移植示例 TypeScript 代码。
TypeScript:
const certificateValidationDomain = new aws.route53.Record(
`${config.targetDomain}-validation`, {
name: certificate.domainValidationOptions[0].resourceRecordName,
zoneId: hostedZoneId,
type: certificate.domainValidationOptions[0].resourceRecordType,
records: [certificate.domainValidationOptions[0].resourceRecordValue],
ttl: tenMinutes,
});
第一次尝试通过将 camelCase 转换为 snake_case 来将示例移植到 Python:
cert = acm.Certificate('certificate',
domain_name=domain_name, validation_method='DNS')
domain_validation_options = cert.domain_validation_options[0]
cert_validation_record = route53.Record(
'cert-validation-record',
name=domain_validation_options.resource_record_name,
zone_id=zone.id,
type=domain_validation_options.resource_record_type,
records=[domain_validation_options.resource_record_value],
ttl=600)
运气不佳。pulumi up 显示如下错误:
AttributeError: 'dict' object has no attribute 'resource_record_name'
在这一点上,我们陷入困境,因为 Python SDK 文档没有包含这么详细的信息。我们不知道在 domain_validation_options 对象中需要指定哪些属性。
我们只能通过在 Pulumi 的导出列表中添加 domain_validation_options 对象来解决此问题,这些对象由 Pulumi 在 pulumi up 操作结束时打印出来。
export('domain_validation_options', domain_validation_options)
pulumi up 的输出如下:
+ domain_validation_options: {
+ domain_name : "staging.devops4all.dev"
+ resourceRecordName : "_c5f82e0f032d0f4f6c7de17fc2c.staging.devops4all.dev."
+ resourceRecordType : "CNAME"
+ resourceRecordValue: "_08e3d475bf3aeda0c98.ltfvzjuylp.acm-validations.aws."
}
终于找到了!原来 domain_validation_options 对象的属性仍然是驼峰命名法。
这是第二次成功移植到 Python 的尝试:
cert_validation_record = route53.Record(
'cert-validation-record',
name=domain_validation_options['resourceRecordName'],
zone_id=zone.id,
type=domain_validation_options['resourceRecordType'],
records=[domain_validation_options['resourceRecordValue']],
ttl=600)
接下来,指定要配置的新类型资源:证书验证完成资源。这使得 pulumi up 操作等待 ACM 通过检查先前创建的 Route 53 验证记录来验证证书。
cert_validation_completion = acm.CertificateValidation(
'cert-validation-completion',
certificate_arn=cert.arn,
validation_record_fqdns=[cert_validation_dns_record.fqdn])
cert_arn = cert_validation_completion.certificate_arn
到此为止,您已经拥有了通过完全自动化的方式配置 ACM SSL 证书并通过 DNS 进行验证的方法。
接下来的步骤是在托管站点静态文件的 S3 存储桶前面配置 CloudFront 分发。
配置 CloudFront 分发
使用 Pulumi cloudfront 模块 的 SDK 参考来确定传递给 cloudfront.Distribution 的构造函数参数。检查 TypeScript 代码以了解这些参数的正确值。
这里是最终结果:
log_bucket = s3.Bucket('cdn-log-bucket', acl='private')
cloudfront_distro = cloudfront.Distribution ( 'cloudfront-distro',
enabled=True,
aliases=[ domain_name ],
origins=[
{
'originId': web_bucket.arn,
'domainName': web_bucket.website_endpoint,
'customOriginConfig': {
'originProtocolPolicy': "http-only",
'httpPort': 80,
'httpsPort': 443,
'originSslProtocols': ["TLSv1.2"],
},
},
],
default_root_object="index.html",
default_cache_behavior={
'targetOriginId': web_bucket.arn,
'viewerProtocolPolicy': "redirect-to-https",
'allowedMethods': ["GET", "HEAD", "OPTIONS"],
'cachedMethods': ["GET", "HEAD", "OPTIONS"],
'forwardedValues': {
'cookies': { 'forward': "none" },
'queryString': False,
},
'minTtl': 0,
'defaultTtl': 600,
'maxTtl': 600,
},
price_class="PriceClass_100",
custom_error_responses=[
{ 'errorCode': 404, 'responseCode': 404,
'responsePagePath': "/404.html" },
],
restrictions={
'geoRestriction': {
'restrictionType': "none",
},
},
viewer_certificate={
'acmCertificateArn': cert_arn,
'sslSupportMethod': "sni-only",
},
logging_config={
'bucket': log_bucket.bucket_domain_name,
'includeCookies': False,
'prefix': domain_name,
})
运行 pulumi up 以配置 CloudFront 分发。
为站点 URL 配置 Route 53 DNS 记录
在端到端为 staging 栈配置资源的最后一步是将 A 类型的 DNS 记录作为 CloudFront 终端节点的域的别名指定。
site_dns_record = route53.Record(
'site-dns-record',
name=subdomain,
zone_id=zone.id,
type="A",
aliases=[
{
'name': cloudfront_distro.domain_name,
'zoneId': cloudfront_distro.hosted_zone_id,
'evaluateTargetHealth': True
}
])
如常运行 pulumi up。
访问 https://staging.devops4all.dev,查看上传到 S3 的文件。转到 AWS 控制台中的日志桶,并确保 CloudFront 日志在那里。
让我们看看如何将相同的 Pulumi 项目部署到一个新的环境,由新的 Pulumi 栈表示。
创建和部署新栈
我们决定修改 Pulumi 程序,使其不再创建新的 Route 53 区域,而是使用现有区域的区域 ID 作为配置值。
要创建 prod 栈,请使用命令 pulumi stack init 并将其名称指定为 prod:
(venv) pulumi stack init
Please enter your desired stack name: prod
Created stack 'prod'
列出栈现在显示了两个栈,staging 和 prod,prod 栈旁边带有星号,表示 prod 是当前栈:
(venv) pulumi stack ls
NAME LAST UPDATE RESOURCE COUNT URL
prod* n/a n/a https://app.pulumi.com/griggheo/proj1/prod
staging 14 minutes ago 14 https://app.pulumi.com/griggheo/proj1/staging
现在是为 prod 栈设置正确配置值的时候了。使用一个新的 dns_zone_id 配置值,设置为在 Pulumi 配置 staging 栈时已创建的区域的 ID:
(venv) pulumi config set aws:region us-east-1
(venv) pulumi config set local_webdir www-prod
(venv) pulumi config set domain_name www.devops4all.dev
(venv) pulumi config set dns_zone_id Z2FTL2X8M0EBTW
更改代码以从配置中读取 zone_id 并且不创建 Route 53 区域对象。
运行 pulumi up 配置 AWS 资源:
(venv) pulumi up
Previewing update (prod):
Type Name Plan
pulumi:pulumi:Stack proj1-prod
+ ├─ aws:cloudfront:Distribution cloudfront-distro create
+ └─ aws:route53:Record site-dns-record create
Resources:
+ 2 to create
10 unchanged
Do you want to perform this update? yes
Updating (prod):
Type Name Status
pulumi:pulumi:Stack proj1-prod
+ ├─ aws:cloudfront:Distribution cloudfront-distro created
+ └─ aws:route53:Record site-dns-record created
Outputs:
+ cloudfront_domain: "d3uhgbdw67nmlc.cloudfront.net"
+ log_bucket_id : "cdn-log-bucket-53d8ea3"
+ web_bucket_id : "s3-website-bucket-cde"
+ website_url : "s3-website-bucket-cde.s3-website-us-east-1.amazonaws.com"
Resources:
+ 2 created
10 unchanged
Duration: 18m54s
成功!prod 栈已完全部署。
然而,此时 www-prod 目录中包含站点静态文件的内容与 www-staging 目录中的内容相同。
修改 www-prod/index.html,将“Hello, S3!”改为“Hello, S3 production!”,然后再次运行 pulumi up 以检测更改并将修改后的文件上传到 S3:
(venv) pulumi up
Previewing update (prod):
Type Name Plan Info
pulumi:pulumi:Stack proj1-prod
~ └─ aws:s3:BucketObject index.html update [diff: ~source]
Resources:
~ 1 to update
11 unchanged
Do you want to perform this update? yes
Updating (prod):
Type Name Status Info
pulumi:pulumi:Stack proj1-prod
~ └─ aws:s3:BucketObject index.html updated [diff: ~source]
Outputs:
cloudfront_domain: "d3uhgbdw67nmlc.cloudfront.net"
log_bucket_id : "cdn-log-bucket-53d8ea3"
web_bucket_id : "s3-website-bucket-cde"
website_url : "s3-website-bucket-cde.s3-website-us-east-1.amazonaws.com"
Resources:
~ 1 updated
11 unchanged
Duration: 4s
使 CloudFront 分发的缓存失效以查看更改。
访问 https://www.devops4all.dev,查看消息:Hello, S3 production!
关于跟踪系统状态的 IaC 工具有一个注意事项:有时工具看到的状态与实际状态不同。在这种情况下,同步两种状态非常重要;否则,它们会越来越分离,你会陷入不敢再进行更改的境地,因为害怕会破坏生产环境。这就是为什么“Code”一词在“基础设施即代码”中占主导地位的原因。一旦决定使用 IaC 工具,最佳实践建议所有资源都通过代码进行配置,不再手动创建任何资源。保持这种纪律是很难的,但长远来看会带来回报。
练习
-
使用 AWS Cloud Development Kit 配置相同的一组 AWS 资源。
-
使用 Terraform 或 Pulumi 从其他云服务提供商(如 Google Cloud Platform 或 Microsoft Azure)配置云资源。
第十一章:容器技术:Docker 和 Docker Compose
虚拟化技术自 IBM 大型机时代就已存在。大多数人没有机会使用过大型机,但我们相信本书的一些读者还记得他们曾经在惠普或戴尔等制造商那里设置或使用裸金属服务器的日子。这些制造商今天仍然存在,你仍然可以在像互联网泡沫时代那样的机房中使用裸金属服务器。
然而,当大多数人想到虚拟化时,他们不会自动想到大型机。相反,他们很可能想象的是在虚拟化管理程序(如 VMware ESX 或 Citrix/Xen)上运行的虚拟机(VM),并且运行了 Fedora 或 Ubuntu 等客户操作系统(OS)。虚拟机相对于普通裸金属服务器的一大优势是,通过使用虚拟机,你可以通过在几个虚拟机之间分割它们来优化服务器的资源(CPU、内存、磁盘)。你还可以在一个共享的裸金属服务器上运行几个操作系统,每个操作系统在自己的虚拟机中运行,而不是为每个目标操作系统购买专用服务器。像亚马逊 EC2 这样的云计算服务如果没有虚拟化管理程序和虚拟机是不可能的。这种类型的虚拟化可以称为内核级,因为每个虚拟机运行其自己的操作系统内核。
在对资源追求更大回报的不懈努力中,人们意识到虚拟机在资源利用上仍然是浪费的。下一个逻辑步骤是将单个应用程序隔离到自己的虚拟环境中。通过在同一个操作系统内核中运行容器来实现这一目标。在这种情况下,它们在文件系统级别被隔离。Linux 容器(LXC)和 Sun Solaris zones 是这种技术的早期示例。它们的缺点是使用起来很困难,并且与它们运行的操作系统紧密耦合。在容器使用方面的重大突破是当 Docker 开始提供一种简便的方法来管理和运行文件系统级容器。
什么是 Docker 容器?
一个 Docker 容器封装了一个应用程序以及它运行所需的其他软件包和库。人们有时会将 Docker 容器和 Docker 镜像的术语互换使用,但它们之间是有区别的。封装应用程序的文件系统级对象称为 Docker 镜像。当你运行该镜像时,它就成为了一个 Docker 容器。
你可以运行许多 Docker 容器,它们都使用相同的操作系统内核。唯一的要求是你必须在要运行容器的主机上安装一个称为 Docker 引擎或 Docker 守护程序的服务器端组件。通过这种方式,主机资源可以在容器之间以更精细的方式分割和利用,让你的投资得到更大的回报。
Docker 容器提供了比常规 Linux 进程更多的隔离和资源控制,但提供的功能不及完整的虚拟机。为了实现这些隔离和资源控制的特性,Docker 引擎利用了 Linux 内核功能,如命名空间、控制组(或 cgroups)和联合文件系统(UnionFS)。
Docker 容器的主要优势是可移植性。一旦创建了 Docker 镜像,您可以在任何安装有 Docker 服务器端守护程序的主机操作系统上作为 Docker 容器运行它。如今,所有主要操作系统都运行 Docker 守护程序:Linux、Windows 和 macOS。
所有这些可能听起来太理论化,所以现在是一些具体示例的时候了。
创建、构建、运行和删除 Docker 镜像和容器
由于这是一本关于 Python 和 DevOps 的书籍,我们将以经典的 Flask “Hello World” 作为在 Docker 容器中运行的应用程序的第一个示例。本节中显示的示例使用 Docker for Mac 包。后续章节将展示如何在 Linux 上安装 Docker。
这是 Flask 应用程序的主文件:
$ cat app.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
return 'Hello, World! (from a Docker container)'
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')
我们还需要一个需求文件,其中指定了要与 pip 安装的 Flask 包的版本:
$ cat requirements.txt
Flask==1.0.2
在 macOS 笔记本电脑上直接使用 Python 运行 app.py 文件,而不先安装要求,会导致错误:
$ python app.py
Traceback (most recent call last):
File "app.py", line 1, in <module>
from flask import Flask
ImportError: No module named flask
要解决这个问题的一个明显方法是在本地机器上使用 pip 安装需求。这将使一切都与您本地运行的操作系统具体相关。如果应用程序需要部署到运行不同操作系统的服务器上,该怎么办?众所周知的“在我的机器上能运行”的问题可能会出现,即在 macOS 笔记本电脑上一切运行良好,但由于操作系统特定版本的 Python 库,一切在运行其他操作系统(如 Ubuntu 或 Red Hat Linux)的暂存或生产服务器上却突然崩溃。
Docker 为这个难题提供了一个优雅的解决方案。我们仍然可以在本地进行开发,使用我们喜爱的编辑器和工具链,但是我们将应用程序的依赖项打包到一个可移植的 Docker 容器中。
这是描述将要构建的 Docker 镜像的 Dockerfile:
$ cat Dockerfile
FROM python:3.7.3-alpine
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY requirements.txt .
RUN pip install -r requirements.txt
ENTRYPOINT [ "python" ]
CMD [ "app.py" ]
关于这个 Dockerfile 的一些说明:
-
使用基于 Alpine 发行版的 Python 3.7.3 预构建 Docker 镜像,这样可以生成更轻量的 Docker 镜像;这个 Docker 镜像已经包含了诸如
python和pip等可执行文件。 -
使用
pip安装所需的包。 -
指定一个 ENTRYPOINT 和一个 CMD。两者的区别在于,当 Docker 容器运行从这个 Dockerfile 构建的镜像时,它运行的程序是 ENTRYPOINT,后面跟着在 CMD 中指定的任何参数;在本例中,它将运行
python app.py。
注意
如果您没有在您的 Dockerfile 中指定 ENTRYPOINT,则将使用以下默认值:/bin/sh -c。
要为这个应用程序创建 Docker 镜像,运行 docker build:
$ docker build -t hello-world-docker .
要验证 Docker 镜像是否已保存在本地,请运行docker images,然后输入镜像名称:
$ docker images hello-world-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world-docker latest dbd84c229002 2 minutes ago 97.7MB
要将 Docker 镜像作为 Docker 容器运行,请使用docker run命令:
$ docker run --rm -d -v `pwd`:/app -p 5000:5000 hello-world-docker
c879295baa26d9dff1473460bab810cbf6071c53183890232971d1b473910602
关于docker run命令参数的几点说明:
-
--rm参数告诉 Docker 服务器在停止运行后删除此容器。这对于防止旧容器堵塞本地文件系统非常有用。 -
-d参数告诉 Docker 服务器在后台运行此容器。 -
-v参数指定当前目录(pwd)映射到 Docker 容器内的*/app*目录。这对于我们想要实现的本地开发工作流至关重要,因为它使我们能够在本地编辑应用程序文件,并通过运行在容器内部的 Flask 开发服务器进行自动重新加载。 -
-p 5000:5000参数将本地的第一个端口(5000)映射到容器内部的第二个端口(5000)。
要列出运行中的容器,请运行docker ps并注意容器 ID,因为它将在其他docker命令中使用:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED
c879295baa26 hello-world-docker:latest "python app.py" 4 seconds ago
STATUS PORTS NAMES
Up 2 seconds 0.0.0.0:5000->5000/tcp flamboyant_germain
要检查特定容器的日志,请运行docker logs并指定容器名称或 ID:
$ docker logs c879295baa26
* Serving Flask app "app" (lazy loading)
* Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 647-161-014
使用curl命中端点 URL 以验证应用程序是否正常工作。由于使用-p命令行标志将运行在 Docker 容器内部的应用程序的端口 5000 映射到本地机器的端口 5000,因此可以使用本地 IP 地址 127.0.0.1 作为应用程序的端点地址。
$ curl http://127.0.0.1:5000
Hello, World! (from a Docker container)%
现在用你喜欢的编辑器修改app.py中的代码。将问候文本更改为Hello, World! (from a Docker container with modified code)。保存app.py并注意 Docker 容器日志中类似以下行:
* Detected change in '/app/app.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 647-161-014
这表明运行在容器内部的 Flask 开发服务器已检测到app.py中的更改,并重新加载了应用程序。
使用curl命中应用程序端点将显示修改后的问候语:
$ curl http://127.0.0.1:5000
Hello, World! (from a Docker container with modified code)%
要停止运行中的容器,请运行docker stop或docker kill,并指定容器 ID 作为参数:
$ docker stop c879295baa26
c879295baa26
要从本地磁盘中删除 Docker 镜像,请运行docker rmi:
$ docker rmi hello-world-docker
Untagged: hello-world-docker:latest
Deleted:sha256:dbd84c229002950550334224b4b42aba948ce450320a4d8388fa253348126402
Deleted:sha256:6a8f3db7658520a1654cc6abee8eafb463a72ddc3aa25f35ac0c5b1eccdf75cd
Deleted:sha256:aee7c3304ef6ff620956850e0b6e6b1a5a5828b58334c1b82b1a1c21afa8651f
Deleted:sha256:dca8a433d31fa06ab72af63ae23952ff27b702186de8cbea51cdea579f9221e8
Deleted:sha256:cb9d58c66b63059f39d2e70f05916fe466e5c99af919b425aa602091c943d424
Deleted:sha256:f0534bdca48bfded3c772c67489f139d1cab72d44a19c5972ed2cd09151564c1
此输出显示组成 Docker 镜像的不同文件系统层。当删除镜像时,这些层也将被删除。有关 Docker 如何使用文件系统层构建其镜像的详细信息,请参阅Docker 存储驱动程序文档。
发布 Docker 镜像到 Docker 注册表
一旦在本地构建了 Docker 镜像,就可以将其发布到所谓的 Docker 注册表中。有几个公共注册表可供选择,本示例将使用 Docker Hub。这些注册表的目的是允许个人和组织共享可在不同机器和操作系统上重复使用的预构建 Docker 镜像。
首先,在Docker Hub上创建一个免费帐户,然后创建一个仓库,可以是公共的或私有的。我们在griggheo的 Docker Hub 帐户下创建了一个名为flask-hello-world的私有仓库。
然后,在命令行上运行docker login并指定您帐户的电子邮件和密码。此时,您可以通过docker客户端与 Docker Hub 进行交互。
注意
在向 Docker Hub 发布您本地构建的 Docker 镜像之前,我们要指出的最佳实践是使用唯一标签对镜像进行标记。如果不明确打标签,默认情况下镜像将标记为latest。发布不带标签的新镜像版本将把latest标签移至最新镜像版本。在使用 Docker 镜像时,如果不指定所需的确切标签,将获取latest版本的镜像,其中可能包含可能破坏依赖关系的修改和更新。始终应用最小惊讶原则:在推送镜像到注册表时和在 Dockerfile 中引用镜像时都应使用标签。话虽如此,您也可以将所需版本的镜像标记为latest,以便对最新和最伟大的人感兴趣的人使用而不需要指定标签。
在上一节中构建 Docker 镜像时,它会自动标记为latest,并且仓库被设置为镜像的名称,表示该镜像是本地的:
$ docker images hello-world-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world-docker latest dbd84c229002 2 minutes ago 97.7MB
要为 Docker 镜像打标签,请运行docker tag:
$ docker tag hello-world-docker hello-world-docker:v1
现在你可以看到hello-world-docker镜像的两个标签:
$ docker images hello-world-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world-docker latest dbd84c229002 2 minutes ago 97.7MB
hello-world-docker v1 89bd38cb198f 42 seconds ago 97.7MB
在将hello-world-docker镜像发布到 Docker Hub 之前,您还需要使用包含您的用户名或组织名称的 Docker Hub 仓库名称对其进行标记。在我们的情况下,这个仓库是griggheo/hello-world-docker:
$ docker tag hello-world-docker:latest griggheo/hello-world-docker:latest
$ docker tag hello-world-docker:v1 griggheo/hello-world-docker:v1
使用docker push将两个镜像标签发布到 Docker Hub:
$ docker push griggheo/hello-world-docker:latest
$ docker push griggheo/hello-world-docker:v1
如果您跟随进行,现在应该能够看到您的 Docker 镜像已发布到您在帐户下创建的 Docker Hub 仓库,并带有两个标签。
在不同主机上使用相同镜像运行 Docker 容器
现在 Docker 镜像已发布到 Docker Hub,我们可以展示 Docker 的可移植性,通过在不同主机上基于已发布镜像运行容器来展示。这里考虑的场景是与一位没有 macOS 但喜欢在运行 Fedora 的笔记本上开发的同事合作。该场景包括检出应用程序代码并进行修改。
在 AWS 上启动基于 Linux 2 AMI 的 EC2 实例,该 AMI 基于 RedHat/CentOS/Fedora,并安装 Docker 引擎。将 EC2 Linux AMI 上的默认用户ec2-user添加到docker组,以便可以运行docker客户端命令。
$ sudo yum update -y
$ sudo amazon-linux-extras install docker
$ sudo service docker start
$ sudo usermod -a -G docker ec2-user
确保在远程 EC2 实例上检出应用程序代码。在这种情况下,代码仅包括app.py文件。
接下来,运行基于发布到 Docker Hub 的镜像的 Docker 容器。唯一的区别是,作为docker run命令参数使用的镜像是griggheo/hello-world-docker:v1,而不仅仅是hello-world-docker。
运行docker login,然后:
$ docker run --rm -d -v `pwd`:/app -p 5000:5000 griggheo/hello-world-docker:v1
Unable to find image 'griggheo/hello-world-docker:v1' locally
v1: Pulling from griggheo/hello-world-docker
921b31ab772b: Already exists
1a0c422ed526: Already exists
ec0818a7bbe4: Already exists
b53197ee35ff: Already exists
8b25717b4dbf: Already exists
d997915c3f9c: Pull complete
f1fd8d3cc5a4: Pull complete
10b64b1c3b21: Pull complete
Digest: sha256:af8b74f27a0506a0c4a30255f7ff563c9bf858735baa610fda2a2f638ccfe36d
Status: Downloaded newer image for griggheo/hello-world-docker:v1
9d67dc321ffb49e5e73a455bd80c55c5f09febc4f2d57112303d2b27c4c6da6a
请注意,EC2 实例上的 Docker 引擎会意识到本地没有 Docker 镜像,因此会从 Docker Hub 下载镜像,然后基于新下载的镜像运行容器。
在此时,通过向与 EC2 实例关联的安全组添加规则来授予对端口 5000 的访问权限。访问 http://54.187.189.51:5000¹(其中 54.187.189.51 是 EC2 实例的外部 IP)并查看问候语Hello, World! (from a Docker container with modified code)。
在远程 EC2 实例上修改应用程序代码时,运行在 Docker 容器内部的 Flask 服务器将自动重新加载修改后的代码。将问候语更改为Hello, World! (from a Docker container on an EC2 Linux 2 AMI instance),并注意 Flask 服务器通过检查 Docker 容器的日志重新加载了应用程序:
[ec2-user@ip-10-0-0-111 hello-world-docker]$ docker ps
CONTAINER ID IMAGE COMMAND CREATED
9d67dc321ffb griggheo/hello-world-docker:v1 "python app.py" 3 minutes ago
STATUS PORTS NAMES
Up 3 minutes 0.0.0.0:5000->5000/tcp heuristic_roentgen
[ec2-user@ip-10-0-0-111 hello-world-docker]$ docker logs 9d67dc321ffb
* Serving Flask app "app" (lazy loading)
* Debug mode: on
* Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 306-476-204
72.203.107.13 - - [19/Aug/2019 04:43:34] "GET / HTTP/1.1" 200 -
72.203.107.13 - - [19/Aug/2019 04:43:35] "GET /favicon.ico HTTP/1.1" 404 -
* Detected change in '/app/app.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 306-476-204
点击 http://54.187.189.51:5000²现在显示新的问候语*Hello, World! (from a Docker container on an EC2 Linux 2 AMI instance)*。
值得注意的是,为了使我们的应用程序运行,我们没有必要安装任何与 Python 或 Flask 相关的东西。通过简单地在容器内运行我们的应用程序,我们能够利用 Docker 的可移植性。Docker 选择“容器”作为技术的名字并不是没有原因的,其中一个灵感来源于运输容器如何革命了全球运输行业。
提示
阅读“Production-ready Docker images”一书,由 Itamar Turner-Trauring 编写,涵盖了关于 Python 应用程序 Docker 容器打包的大量文章。
使用 Docker Compose 运行多个 Docker 容器
在本节中,我们将使用“Flask By Example”教程,该教程描述了如何构建一个 Flask 应用程序,根据给定 URL 的文本计算单词频率对。
首先克隆Flask By Example GitHub 存储库:
$ git clone https://github.com/realpython/flask-by-example.git
我们将使用compose来运行代表示例应用程序不同部分的多个 Docker 容器。使用 Compose,您可以使用 YAML 文件定义和配置组成应用程序的服务,然后使用docker-compose命令行实用程序来创建、启动和停止这些将作为 Docker 容器运行的服务。
示例应用程序的第一个依赖项是 PostgreSQL,在教程第二部分中有描述。
这是如何在docker-compose.yaml文件中运行 PostgreSQL Docker 容器的方法:
$ cat docker-compose.yaml
version: "3"
services:
db:
image: "postgres:11"
container_name: "postgres"
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata:
关于这个文件的几个注意事项:
-
定义一个名为
db的服务,基于在 Docker Hub 上发布的postgres:11镜像。 -
将本地端口 5432 映射到容器端口 5432。
-
为 PostgreSQL 存储其数据的目录(/var/lib/postgresql/data)指定一个 Docker 卷。这样做是为了确保 PostgreSQL 中存储的数据在容器重新启动后仍然存在。
docker-compose工具不是 Docker 引擎的一部分,因此需要单独安装。请参阅官方文档以获取在各种操作系统上安装的说明。
要启动docker-compose.yaml中定义的db服务,请运行docker-compose up -d db命令,该命令将在后台启动db服务的 Docker 容器(使用了-d标志)。
$ docker-compose up -d db
Creating postgres ... done
使用docker-compose logs db命令检查db服务的日志:
$ docker-compose logs db
Creating volume "flask-by-example_dbdata" with default driver
Pulling db (postgres:11)...
11: Pulling from library/postgres
Creating postgres ... done
Attaching to postgres
postgres | PostgreSQL init process complete; ready for start up.
postgres |
postgres | 2019-07-11 21:50:20.987 UTC [1]
LOG: listening on IPv4 address "0.0.0.0", port 5432
postgres | 2019-07-11 21:50:20.987 UTC [1]
LOG: listening on IPv6 address "::", port 5432
postgres | 2019-07-11 21:50:20.993 UTC [1]
LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
postgres | 2019-07-11 21:50:21.009 UTC [51]
LOG: database system was shut down at 2019-07-11 21:50:20 UTC
postgres | 2019-07-11 21:50:21.014 UTC [1]
LOG: database system is ready to accept connections
运行docker ps命令可以显示运行 PostgreSQL 数据库的容器:
$ docker ps
dCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
83b54ab10099 postgres:11 "docker-entrypoint.s…" 3 minutes ago Up 3 minutes
0.0.0.0:5432->5432/tcp postgres
运行docker volume ls命令显示已为 PostgreSQL /var/lib/postgresql/data目录挂载的dbdata Docker 卷:
$ docker volume ls | grep dbdata
local flask-by-example_dbdata
要连接到运行在与db服务相关联的 Docker 容器中的 PostgreSQL 数据库,运行命令docker-compose exec db并传递psql -U postgres命令行:
$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
postgres=#
参照“Flask by Example, Part 2”,创建一个名为wordcount的数据库:
$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
postgres=# create database wordcount;
CREATE DATABASE
postgres=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+--------+----------+----------+----------+--------------------
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | |postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | |postgres=CTc/postgres
wordcount| postgres | UTF8| en_US.utf8 | en_US.utf8 |
(4 rows)
postgres=# \q
连接到wordcount数据库并创建一个名为wordcount_dbadmin的角色,该角色将被 Flask 应用程序使用:
$ docker-compose exec db psql -U postgres wordcount
wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYPASS';
ALTER ROLE
postgres=# \q
下一步是为 Flask 应用程序创建一个 Dockerfile,安装所有的先决条件。
对requirements.txt文件进行以下修改:
-
将
psycopg2包的版本从2.6.1修改为2.7以支持 PostgreSQL 11。 -
将
redis包的版本从2.10.5修改为3.2.1以提供更好的 Python 3.7 支持。 -
将
rq包的版本从0.5.6修改为1.0以提供更好的 Python 3.7 支持。
以下是 Dockerfile 的内容:
$ cat Dockerfile
FROM python:3.7.3-alpine
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY requirements.txt .
RUN \
apk add --no-cache postgresql-libs && \
apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev && \
python3 -m pip install -r requirements.txt --no-cache-dir && \
apk --purge del .build-deps
COPY . .
ENTRYPOINT [ "python" ]
CMD ["app.py"]
注意
这个 Dockerfile 与第一个hello-world-docker示例中使用的版本有一个重要的区别。这里将当前目录的内容(包括应用程序文件)复制到 Docker 镜像中。这样做是为了展示与之前开发工作流不同的场景。在这种情况下,我们更关注以最便携的方式运行应用程序,例如在暂存或生产环境中,我们不希望像在开发场景中通过挂载卷来修改应用程序文件。在开发目的上通常可以使用docker-compose与本地挂载卷,但本节重点是讨论 Docker 容器在不同环境(如开发、暂存和生产)中的可移植性。
运行docker build -t flask-by-example:v1 .来构建一个本地 Docker 镜像。由于该命令的输出内容相当长,因此这里不显示。
“Flask By Example”教程的下一步是运行 Flask 迁移。
在docker-compose.yaml文件中,定义一个名为migrations的新服务,并指定其image、command、environment变量以及它依赖于db服务正在运行的事实:
$ cat docker-compose.yaml
version: "3"
services:
migrations:
image: "flask-by-example:v1"
command: "manage.py db upgrade"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
depends_on:
- db
db:
image: "postgres:11"
container_name: "postgres"
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata:
DATABASE_URL变量使用db作为 PostgreSQL 数据库主机的名称。这是因为在docker-compose.yaml文件中将db定义为服务名称,并且docker-compose知道如何通过创建一个覆盖网络使所有在docker-compose.yaml文件中定义的服务能够通过其名称互相交互。有关更多详细信息,请参阅docker-compose 网络参考。
DATABASE_URL变量定义引用了另一个名为DBPASS的变量,而不是直接硬编码wordcount_dbadmin用户的密码。docker-compose.yaml文件通常提交到源代码控制,最佳实践是不要将诸如数据库凭据之类的机密信息提交到 GitHub。相反,使用诸如sops之类的加密工具管理密钥文件。
这里是使用sops和 PGP 加密创建加密文件的示例。
首先,在 macOS 上通过brew install gpg安装gpg,然后使用空密码生成新的 PGP 密钥:
$ gpg --generate-key
pub rsa2048 2019-07-12 [SC] [expires: 2021-07-11]
E14104A0890994B9AC9C9F6782C1FF5E679EFF32
uid pydevops <my.email@gmail.com>
sub rsa2048 2019-07-12 [E] [expires: 2021-07-11]
接下来,从其发布页面下载sops。
要创建一个名为environment.secrets的新加密文件,例如,运行带有-pgp标志的sops并提供上述生成的密钥的指纹:
$ sops --pgp BBDE7E57E00B98B3F4FBEAF21A1EEF4263996BD0 environment.secrets
这将打开默认编辑器,并允许输入纯文本密钥。在此示例中,environment.secrets文件的内容是:
export DBPASS=MYPASS
保存environment.secrets文件后,请检查文件以确保其已加密,这样可以安全地添加到源代码控制中:
$ cat environment.secrets
{
"data": "ENC[AES256_GCM,data:qlQ5zc7e8KgGmu5goC9WmE7PP8gueBoSsmM=,
iv:xG8BHcRfdfLpH9nUlTijBsYrh4TuSdvDqp5F+2Hqw4I=,
tag:0OIVAm9O/UYGljGCzZerTQ==,type:str]",
"sops": {
"kms": null,
"gcp_kms": null,
"lastmodified": "2019-07-12T05:03:45Z",
"mac": "ENC[AES256_GCM,data:wo+zPVbPbAJt9Nl23nYuWs55f68/DZJWj3pc0
l8T2d/SbuRF6YCuOXHSHIKs1ZBpSlsjmIrPyYTqI+M4Wf7it7fnNS8b7FnclwmxJjptBWgL
T/A1GzIKT1Vrgw9QgJ+prq+Qcrk5dPzhsOTxOoOhGRPsyN8KjkS4sGuXM=,iv:0VvSMgjF6
ypcK+1J54fonRoI7c5whmcu3iNV8xLH02k=,
tag:YaI7DXvvllvpJ3Talzl8lg==,
type:str]",
"pgp": [
{
"created_at": "2019-07-12T05:02:24Z",
"enc": "-----BEGIN PGP MESSAGE-----\n\nhQEMA+3cyc
g5b/Hu0OvU5ONr/F0htZM2MZQSXpxoCiO\nWGB5Czc8FTSlRSwu8/cOx0Ch1FwH+IdLwwL+jd
oXVe55myuu/3OKUy7H1w/W2R\nPI99Biw1m5u3ir3+9tLXmRpLWkz7+nX7FThl9QnOS25
NRUSSxS7hNaZMcYjpXW+w\nM3XeaGStgbJ9OgIp4A8YGigZQVZZFl3fAG3bm2c+TNJcAbl
zDpc40fxlR+7LroJI\njuidzyOEe49k0pq3tzqCnph5wPr3HZ1JeQmsIquf//9D509S5xH
Sa9lkz3Y7V4KC\nefzBiS8pivm55T0s+zPBPB/GWUVlqGaxRhv1TAU=\n=WA4+
\n-----END PGP MESSAGE-----\n",
"fp": "E14104A0890994B9AC9C9F6782C1FF5E679EFF32"
}
],
"unencrypted_suffix": "_unencrypted",
"version": "3.0.5"
}
}%
要解密文件,请运行:
$ sops -d environment.secrets
export DBPASS=MYPASS
注意
在 Macintosh 上使用sops与gpg交互存在问题。在能够使用sops解密文件之前,您需要运行以下命令:
$ GPG_TTY=$(tty)
$ export GPG_TTY
这里的目标是运行先前在docker-compose.yaml文件中定义的migrations服务。为了将sops密钥管理方法集成到docker-compose中,使用sops -d解密environments.secrets文件,将其内容源化到当前 shell 中,然后使用一个命令调用docker-compose up -d migrations,该命令不会将密钥暴露给 shell 历史记录:
$ source <(sops -d environment.secrets); docker-compose up -d migrations
postgres is up-to-date
Recreating flask-by-example_migrations_1 ... done
通过检查数据库并验证是否创建了两个表alembic_version和results来验证迁移是否成功运行:
$ docker-compose exec db psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
wordcount=# \dt
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+-------------------
public | alembic_version | table | wordcount_dbadmin
public | results | table | wordcount_dbadmin
(2 rows)
wordcount=# \q
第四部分 在“Flask 实例”教程中是部署一个基于 Python RQ 的 Python 工作进程,该进程与 Redis 实例通信。
首先,需要运行 Redis。将其作为名为redis的服务添加到docker_compose.yaml文件中,并确保其内部端口 6379 映射到本地操作系统的端口 6379:
redis:
image: "redis:alpine"
ports:
- "6379:6379"
通过将其作为参数指定给 docker-compose up -d 单独启动 redis 服务:
$ docker-compose up -d redis
Starting flask-by-example_redis_1 ... done
运行 docker ps 查看基于 redis:alpine 镜像运行的新 Docker 容器:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a1555cc372d6 redis:alpine "docker-entrypoint.s…" 3 seconds ago Up 1 second
0.0.0.0:6379->6379/tcp flask-by-example_redis_1
83b54ab10099 postgres:11 "docker-entrypoint.s…" 22 hours ago Up 16 hours
0.0.0.0:5432->5432/tcp postgres
使用 docker-compose logs 命令检查 redis 服务的日志:
$ docker-compose logs redis
Attaching to flask-by-example_redis_1
1:C 12 Jul 2019 20:17:12.966 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 12 Jul 2019 20:17:12.966 # Redis version=5.0.5, bits=64, commit=00000000,
modified=0, pid=1, just started
1:C 12 Jul 2019 20:17:12.966 # Warning: no config file specified, using the
default config. In order to specify a config file use
redis-server /path/to/redis.conf
1:M 12 Jul 2019 20:17:12.967 * Running mode=standalone, port=6379.
1:M 12 Jul 2019 20:17:12.967 # WARNING: The TCP backlog setting of 511 cannot
be enforced because /proc/sys/net/core/somaxconn
is set to the lower value of 128.
1:M 12 Jul 2019 20:17:12.967 # Server initialized
1:M 12 Jul 2019 20:17:12.967 * Ready to accept connections
下一步是在 docker-compose.yaml 中为 Python RQ 工作进程创建一个名为 worker 的服务:
worker:
image: "flask-by-example:v1"
command: "worker.py"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
运行工作服务,就像redis服务一样,使用docker-compose up -d:
$ docker-compose up -d worker
flask-by-example_redis_1 is up-to-date
Starting flask-by-example_worker_1 ... done
运行 docker ps 将显示工作容器:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
72327ab33073 flask-by-example "python worker.py" 8 minutes ago
Up 14 seconds flask-by-example_worker_1
b11b03a5bcc3 redis:alpine "docker-entrypoint.s…" 15 minutes ago
Up About a minute 0.0.0.0:6379->6379/tc flask-by-example_redis_1
83b54ab10099 postgres:11 "docker-entrypoint.s…" 23 hours ago
Up 17 hours 0.0.0.0:5432->5432/tcp postgres
使用docker-compose logs查看工作容器日志:
$ docker-compose logs worker
Attaching to flask-by-example_worker_1
20:46:34 RQ worker 'rq:worker:a66ca38275a14cac86c9b353e946a72e' started,
version 1.0
20:46:34 *** Listening on default...
20:46:34 Cleaning registries for queue: default
现在在自己的容器中启动主 Flask 应用程序。在 docker-compose.yaml 中创建一个名为 app 的新服务:
app:
image: "flask-by-example:v1"
command: "manage.py runserver --host=0.0.0.0"
ports:
- "5000:5000"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
将应用程序容器中的端口 5000(Flask 应用程序的默认端口)映射到本地机器的端口 5000。在应用程序容器中传递命令 manage.py runserver --host=0.0.0.0,以确保 Flask 应用程序在容器内正确地暴露端口 5000。
使用 docker compose up -d 启动 app 服务,同时在包含 DBPASS 的加密文件上运行 sops -d,然后在调用 docker-compose 之前源化解密文件:
source <(sops -d environment.secrets); docker-compose up -d app
postgres is up-to-date
Recreating flask-by-example_app_1 ... done
注意到新的 Docker 容器正在运行应用程序,列表由 docker ps 返回:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d99168a152f1 flask-by-example "python app.py" 3 seconds ago
Up 2 seconds 0.0.0.0:5000->5000/tcp flask-by-example_app_1
72327ab33073 flask-by-example "python worker.py" 16 minutes ago
Up 7 minutes flask-by-example_worker_1
b11b03a5bcc3 redis:alpine "docker-entrypoint.s…" 23 minutes ago
Up 9 minutes 0.0.0.0:6379->6379/tcp flask-by-example_redis_1
83b54ab10099 postgres:11 "docker-entrypoint.s…" 23 hours ago
Up 17 hours 0.0.0.0:5432->5432/tcp postgres
使用 docker-compose logs 检查应用程序容器的日志:
$ docker-compose logs app
Attaching to flask-by-example_app_1
app_1 | * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
运行 docker-compose logs 而不带其他参数允许我们检查 docker-compose.yaml 文件中定义的所有服务的日志:
$ docker-compose logs
Attaching to flask-by-example_app_1,
flask-by-example_worker_1,
flask-by-example_migrations_1,
flask-by-example_redis_1,
postgres
1:C 12 Jul 2019 20:17:12.966 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 12 Jul 2019 20:17:12.966 # Redis version=5.0.5, bits=64, commit=00000000,
modified=0, pid=1, just started
1:C 12 Jul 2019 20:17:12.966 # Warning: no config file specified, using the
default config. In order to specify a config file use
redis-server /path/to/redis.conf
1:M 12 Jul 2019 20:17:12.967 * Running mode=standalone, port=6379.
1:M 12 Jul 2019 20:17:12.967 # WARNING: The TCP backlog setting of 511 cannot
be enforced because /proc/sys/net/core/somaxconn
is set to the lower value of 128.
1:M 12 Jul 2019 20:17:12.967 # Server initialized
1:M 12 Jul 2019 20:17:12.967 * Ready to accept connections
app_1 | * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
postgres | 2019-07-12 22:15:19.193 UTC [1]
LOG: listening on IPv4 address "0.0.0.0", port 5432
postgres | 2019-07-12 22:15:19.194 UTC [1]
LOG: listening on IPv6 address "::", port 5432
postgres | 2019-07-12 22:15:19.199 UTC [1]
LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
postgres | 2019-07-12 22:15:19.214 UTC [22]
LOG: database system was shut down at 2019-07-12 22:15:09 UTC
postgres | 2019-07-12 22:15:19.225 UTC [1]
LOG: database system is ready to accept connections
migrations_1 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
migrations_1 | INFO [alembic.runtime.migration] Will assume transactional DDL.
worker_1 | 22:15:20
RQ worker 'rq:worker:2edb6a54f30a4aae8a8ca2f4a9850303' started, version 1.0
worker_1 | 22:15:20 *** Listening on default...
worker_1 | 22:15:20 Cleaning registries for queue: default
最后一步是测试应用程序。访问 http://127.0.0.1:5000 并在 URL 字段中输入 python.org。此时,应用程序向工作进程发送一个作业,要求其对 python.org 的主页执行函数 count_and_save_words。应用程序定期轮询作业以获取结果,完成后在主页上显示单词频率。
为了使 docker-compose.yaml 文件更具可移植性,将 flask-by-example Docker 镜像推送到 Docker Hub,并在 app 和 worker 服务的容器部分引用 Docker Hub 镜像。
使用 Docker Hub 用户名前缀为现有的本地 Docker 镜像 flask-by-example:v1 打标签,然后将新标记的镜像推送到 Docker Hub:
$ docker tag flask-by-example:v1 griggheo/flask-by-example:v1
$ docker push griggheo/flask-by-example:v1
修改 docker-compose.yaml 以引用新的 Docker Hub 镜像。以下是 docker-compose.yaml 的最终版本:
$ cat docker-compose.yaml
version: "3"
services:
app:
image: "griggheo/flask-by-example:v1"
command: "manage.py runserver --host=0.0.0.0"
ports:
- "5000:5000"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
worker:
image: "griggheo/flask-by-example:v1"
command: "worker.py"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
migrations:
image: "griggheo/flask-by-example:v1"
command: "manage.py db upgrade"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
depends_on:
- db
db:
image: "postgres:11"
container_name: "postgres"
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
redis:
image: "redis:alpine"
ports:
- "6379:6379"
volumes:
dbdata:
要重新启动本地 Docker 容器,请运行 docker-compose down,然后跟着 docker-compose up -d:
$ docker-compose down
Stopping flask-by-example_worker_1 ... done
Stopping flask-by-example_app_1 ... done
Stopping flask-by-example_redis_1 ... done
Stopping postgres ... done
Removing flask-by-example_worker_1 ... done
Removing flask-by-example_app_1 ... done
Removing flask-by-example_migrations_1 ... done
Removing flask-by-example_redis_1 ... done
Removing postgres ... done
Removing network flask-by-example_default
$ source <(sops -d environment.secrets); docker-compose up -d
Creating network "flask-by-example_default" with the default driver
Creating flask-by-example_redis_1 ... done
Creating postgres ... done
Creating flask-by-example_migrations_1 ... done
Creating flask-by-example_worker_1 ... done
Creating flask-by-example_app_1 ... done
注意使用 docker-compose 轻松启动和关闭一组 Docker 容器。
提示
即使您只想运行单个 Docker 容器,将其包含在 docker-compose.yaml 文件中并使用 docker-compose up -d 命令启动它仍然是个好主意。当您想要添加第二个容器时,这将使您的生活更加轻松,并且还将作为基础设施即代码的一个小例子,docker-compose.yaml 文件反映了您的应用程序的本地 Docker 设置状态。
将 docker-compose 服务迁移到新主机和操作系统
现在我们将展示如何将前一节的 docker-compose 设置迁移到运行 Ubuntu 18.04 的服务器。
启动运行 Ubuntu 18.04 的 Amazon EC2 实例并安装 docker-engine 和 docker-compose:
$ sudo apt-get update
$ sudo apt-get remove docker docker-engine docker.io containerd runc
$ sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg-agent \
software-properties-common
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository \
"deb [arch=amd64] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) \
stable"
$ sudo apt-get update
$ sudo apt-get install docker-ce docker-ce-cli containerd.io
$ sudo usermod -a -G docker ubuntu
# download docker-compose
$ sudo curl -L \
"https://github.com/docker/compose/releases/download/1.24.1/docker-compose-\
$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
将 docker-compose.yaml 文件复制到远程 EC2 实例并首先启动 db 服务,以便可以创建应用程序使用的数据库:
$ docker-compose up -d db
Starting postgres ...
Starting postgres ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
49fe88efdb45 postgres:11 "docker-entrypoint.s…" 29 seconds ago
Up 3 seconds 0.0.0.0:5432->5432/tcp postgres
使用 docker exec 在正在运行的 Docker 容器中运行 psql -U postgres 命令访问 PostgreSQL 数据库。在 PostgreSQL 提示符下,创建 wordcount 数据库和 wordcount_dbadmin 角色:
$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
postgres=# create database wordcount;
CREATE DATABASE
postgres=# \q
$ docker exec -it 49fe88efdb45 psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYPASS';
ALTER ROLE
wordcount=# \q
在启动在 docker-compose.yaml 中定义的服务的容器之前,有两件事是必需的:
-
运行
docker login以能够拉取之前推送到 Docker Hub 的 Docker 镜像:$ docker login -
在当前 Shell 中设置
DBPASS环境变量的正确值。在本地 macOS 设置中描述的sops方法可用,但是在本示例中,直接在 Shell 中设置:$ export DOCKER_PASS=MYPASS
现在通过运行 docker-compose up -d 启动应用程序所需的所有服务:
$ docker-compose up -d
Pulling worker (griggheo/flask-by-example:v1)...
v1: Pulling from griggheo/flask-by-example
921b31ab772b: Already exists
1a0c422ed526: Already exists
ec0818a7bbe4: Already exists
b53197ee35ff: Already exists
8b25717b4dbf: Already exists
9be5e85cacbb: Pull complete
bd62f980b08d: Pull complete
9a89f908ad0a: Pull complete
d787e00a01aa: Pull complete
Digest: sha256:4fc554da6157b394b4a012943b649ec66c999b2acccb839562e89e34b7180e3e
Status: Downloaded newer image for griggheo/flask-by-example:v1
Creating fbe_redis_1 ... done
Creating postgres ... done
Creating fbe_migrations_1 ... done
Creating fbe_app_1 ... done
Creating fbe_worker_1 ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f65fe9631d44 griggheo/flask-by-example:v1 "python3 manage.py r…" 5 seconds ago
Up 2 seconds 0.0.0.0:5000->5000/tcp fbe_app_1
71fc0b24bce3 griggheo/flask-by-example:v1 "python3 worker.py" 5 seconds ago
Up 2 seconds fbe_worker_1
a66d75a20a2d redis:alpine "docker-entrypoint.s…" 7 seconds ago
Up 5 seconds 0.0.0.0:6379->6379/tcp fbe_redis_1
56ff97067637 postgres:11 "docker-entrypoint.s…" 7 seconds ago
Up 5 seconds 0.0.0.0:5432->5432/tcp postgres
此时,在允许 AWS 安全组中与我们的 Ubuntu EC2 实例关联的端口 5000 的访问后,您可以在该实例的外部 IP 上的 5000 端口访问并使用该应用程序。
再次强调一下 Docker 简化应用部署的重要性。Docker 容器和镜像的可移植性意味着您可以在任何安装了 Docker 引擎的操作系统上运行您的应用程序。在这里展示的示例中,在 Ubuntu 服务器上不需要安装任何先决条件:不需要 Flask,不需要 PostgreSQL,也不需要 Redis。也不需要将应用程序代码从本地开发机器复制到 Ubuntu 服务器上。在 Ubuntu 服务器上唯一需要的文件是 docker-compose.yaml。然后,只需一条命令就可以启动应用程序的整套服务:
$ docker-compose up -d
提示
警惕从公共 Docker 仓库下载和使用 Docker 镜像,因为其中许多镜像存在严重的安全漏洞,其中最严重的可以允许攻击者突破 Docker 容器的隔离性并接管主机操作系统。一个良好的实践是从一个受信任的、预构建的镜像开始,或者从头开始构建你自己的镜像。随时关注最新的安全补丁和软件更新,并在这些补丁或更新可用时重新构建你的镜像。另一个良好的实践是使用众多可用的 Docker 扫描工具之一(其中包括 Clair、Anchore 和 Falco)扫描所有的 Docker 镜像。这样的扫描可以作为持续集成/持续部署流水线的一部分进行,通常在构建 Docker 镜像时执行。
尽管 docker-compose 可以轻松地运行多个容器化服务作为同一应用的一部分,但它只适用于单台机器,这在生产环境中的实用性受到限制。如果你不担心停机时间并愿意在单台机器上运行所有内容,那么只能认为使用 docker-compose 部署的应用程序是“生产就绪”的(尽管如此,格里格看到一些托管提供者使用 docker-compose 在生产环境中运行 Docker 化应用程序)。对于真正的“生产就绪”场景,你需要一个像 Kubernetes 这样的容器编排引擎,这将在下一章讨论。
练习
-
熟悉 Dockerfile 参考。
-
创建一个 AWS KMS 密钥,并在
sops中使用它,而不是本地的PGP密钥。这允许你将 AWS IAM 权限应用到密钥上,并将对密钥的访问限制为仅需要的开发人员。 -
编写一个 shell 脚本,使用
docker exec或docker-compose exec来运行 PostgreSQL 命令,创建数据库和角色。 -
尝试其他容器技术,例如 Podman。
¹ 这是一个示例 URL 地址——你的 IP 地址将会不同。
² 你的 IP 地址将会不同。