使用 Terraform 部署 nginx

2015 年,Gogo 在 Vultr 上购买了第一个 VPS,在其上搭建了这个 blog、配置了域名、申请了 https 证书。我在这台 VPS 上玩了很多东西,有 Wordpress 时代的 LAMP、邮箱服务的 Postfix、网盘 NextCloud、以及早被遗忘的各种服务。这些服务大多都已被遗弃,但许多痕迹却长久地留在了机器上。现在,是时候清理下服务器了!

目标

部署的服务比较多,准备先搞定两个最常用的:

  • Nginx,部署了 blog 在内的多个静态网页
  • acme.sh,用于申请 https 证书

部署方案一开始准备在 Vultr 上创建一台新机子,用 Docker 逐步把服务迁移过来。但如此就相当于是把老机子丢掉了,但 go 还是想留个纪念 (虽然不知道有什么意义 😂)。于是选择退而求其次,给老机子重新装下系统吧……

准备

把 Nginx 上可能要用到的数据下载到本地。并且用 Vultr 的 Snapshots 功能备份了一个快照,如果后续发现有数据丢了,可以从快照恢复一台机子把数据取回来。

接下来需要重建系统了。这台机子上的系统还停留在上古的 Ubuntu 16.04 上,Vultr 早已不支持重新安装这个版本,借机安装个 Ubuntu 22.04。

之后调了一通配置,手动配置了 docker 和 acme.sh,终于要部署 Nginx 了。

Terraform 介绍

Terraform 是一个 IaC (infrastructure as code) 工具,可以通过配置文件声明的方式,高效地创建 计算实例、存储、网络 或是 DNS、SaaS 等基础设施。

想尝试一下 Terraform 是因为它有两个优势:

  1. 可以用配置文件声明的方式创建资源,这很 coooool!
  2. Terraform 会缓存当前资源的状态,修改资源后,会对比新的配置与当前状态,仅修改变更状态。比调试时手动修改状态方便得多。

Terraform 有 CLI 和 Cloud 两个产品。Terraform CLI 是开源的 CLI 工具,需要自行管理生成的状态文件。Terraform Cloud 提供了版本控制系统来管理状态文件,并提供了协作、访问控制等功能。

使用 Terraform 部署 Nginx

按照原计划,我应该使用 Terraform 去创建一个 Vultr 实例。既然已经手动把前几步做了,接下来就用 Terraform 部署个 Nginx 的 Docker 容器吧。

Terraform 使用称为 Terraform Language 的 DSL 作为配置文件,在本地新建 main.tf 并写入如下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 2.13.0"
}
}
}

provider "docker" {
host = "ssh://[email protected]:22222"
ssh_opts = ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
}

resource "docker_image" "nginx" {
name = "nginx:latest"
}

resource "docker_container" "nginx" {
image = docker_image.nginx.image_id
name = "nginx"
ports {
internal = 80
external = 80
}
}

Terraform 使用插件模块的形式提供各种资源。在 terraform { } 块中我们声明资源的提供者,这里使用 docker provider 。在 provider "docker" { } 块中配置提供者,我配置了通过 ssh 使用 VPS 上的 docker service。随后是资源的配置,想要启动一个 nginx 容器,需要声明 docker_imagedocker_container 两个资源对象。这个配置写起来非常地直观,资源的定义和 docker 的使用方式完全一致。

main.tf 的目录下执行:

1
terraform init

这条命令会初始化工作目录,包括下载 provider 插件、模块。

随后执行:

1
terraform plan

这条命令首先让 Terraform 读取远程的资源,确保本地状态是最新的;其次将状态与当前配置文件对比,打印变更之处;最后显示一组建议的变更动作。部分输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  # docker_container.nginx will be created
+ resource "docker_container" "nginx" {
...
+ ports {
+ external = 80
+ internal = 80
+ ip = "0.0.0.0"
+ protocol = "tcp"
}
}

# docker_image.nginx will be created
+ resource "docker_image" "nginx" {
+ id = (known after apply)
+ image_id = (known after apply)
+ keep_locally = false
+ latest = (known after apply)
+ name = "nginx:latest"
+ output = (known after apply)
+ repo_digest = (known after apply)
}

Plan: 2 to add, 0 to change, 0 to destroy.

这个 diff 显示两个资源会被创建,接下来应用这次变更:

1
terraform apply

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Plan: 2 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

docker_image.nginx: Creating...
docker_image.nginx: Still creating... [10s elapsed]
docker_image.nginx: Creation complete after 12s [id=sha256:5d58c024174dd06df1c4d41d8d44b485e3080422374971005270588204ca3b82nginx:latest]
docker_container.nginx: Creating...
docker_container.nginx: Creation complete after 6s [id=fbb01a7f30ef89788c6efae9bc17656a93650bb4c9cd7344fe6429675925ece5]

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

访问一下 http://gogo.moe ,确实能看到 nginx 默认首页了,好耶🚀!

修改 Nginx 配置

通过 Terraform 修改 nginx 配置有很多种方法,最直观的显然是通过 docker volume 映射一个新的配置替换默认配置。我同样先从简单的开始改,这一步的目标就先给 nginx 默认页面启动 https 吧。

编辑配置文件 default.conf,这个文件拷贝自 nginx 的默认配置文件,添加 443 端口的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name localhost;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}

ssl_certificate /etc/nginx/cert/cert.pem;
ssl_certificate_key /etc/nginx/cert/key.pem;
}

这里的配置直接复制 80 端口的配置,只添加了最后两行 ssl 证书的设置。

接下来编辑 main.tf,首先我需要上传本地的 default.conf ,参考了 stackoverflow 上的一个配置。使用 Terraform 上传文件实际上不是一种很好的方式,后文会提到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
locals {
remote_conf_path = "/home/gogo/app/nginx/conf.d"
local_conf_path = "./conf.d"
}

resource "null_resource" "nginx_assets" {
connection {
type = "ssh"
user = "gogo"
host = "gogo.moe"
port = 22222
private_key = file("/home/gogo/.ssh/id_rsa")
}

provisioner "remote-exec" {
inline = [
"rm -rf ${local.remote_conf_path} && mkdir -p ${local.remote_conf_path}"
]
}

provisioner "file" {
source = "${local.local_conf_path}/default.conf"
destination = "${local.remote_conf_path}/default.conf"
}
}

我们定义了一个空资源,这个资源仅仅是为了上传文件。在 connection { } 块中配置 ssh 连接方式,在 provisioner 块中我们可以定义一些配置动作,这里新建了一个文件夹,并将 default.conf 文件上传至远程主机的指定。另外,我们使用了 locals { } 定义一些局部变量以简化书写。

Nginx 容器的配置也需要变更,我增加了 443 端口的映射,配置文件和证书两个 volume 的挂载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
resource "docker_container" "nginx" {
image = docker_image.nginx.image_id
name = "nginx"

ports {
internal = 80
external = 80
}
ports {
internal = 443
external = 443
}

volumes {
host_path = "/home/gogo/app/cert"
container_path = "/etc/nginx/cert"
read_only = true
}

volumes {
host_path = "/home/gogo/nginx/conf.d/defalut.conf"
container_path = "/etc/nginx/conf.d/defalut.conf"
read_only = true
}

depends_on = [null_resource.nginx_assets]
}

证书是之前手动用 acme.sh 已经配置在服务器上的,(没有用 terraform 配 acme.sh 是因为它过于复杂了🤣)。不过 acme.sh 每隔一段时间会自动更新一遍证书,而 nginx 需要 reload 才能加载证书,因此使用 acme.sh 自动 reload 下 docker 里的 nginx:

1
2
3
4
5
# remote
acme.sh --install-cert -d gogo.moe \
--key-file /home/gogo/app/cert/key.pem \
--fullchain-file /home/gogo/app/cert/cert.pem \
--reloadcmd "docker exec -it nginx nginx -s reload"

好了,万事具备,重新 terraform plan ,部分输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
+ ports {
+ external = 443
+ internal = 443
+ ip = "0.0.0.0"
+ protocol = "tcp"
}
+ volumes {
+ container_path = "/etc/nginx/cert"
+ host_path = "/home/gogo/app/cert"
+ read_only = true
}
+ volumes {
+ container_path = "/etc/nginx/conf.d/default.conf"
+ host_path = "/home/gogo/app/nginx/conf.d/default.conf"
+ read_only = true
}
...

符合预期,terraform apply

访问 https://gogo.moe ,能跑通了。

这个例子不太 Terraform

虽然这个例子能 run,但是仍然有两个地方存在不足,让我感觉这个 Terraform 不够 Terraform

  1. 生硬的上传配置文件
  2. bash 脚本就能搞定 docker,有点小题大做

使用 Terraform 上传配置文件确实有点硬伤。麻烦之处在于,上文中使用的 provisioner 上传文件方法,并不能检测到资源文件变更,修改资源文件后进行 plan 只会提示:

1
No changes. Your infrastructure matches the configuration.

这一个问题可以通过 nginx provider 来解决,这个 provider 提供了一种 nginx_server_block 的资源,这很 terraform。

第二个问题,使用 terraform 拉起 docker 确实不见得比写个 bash 脚本来的简单。在开头也提到过,使用 terraform 创建一个 vultr 实例,这才是更符合 terraform 的定位。


我是 Gogo,好久不见!

这是工作后的第一篇 blog,预计在上周日发布的。工作后可支配时间少了很多,一直拖到现在才完成 😂。

Terraform 挺好玩的,不愿意花钱创建一些实例的话,可以像本文一样用 docker 试水哦!

下次再见!