Atlantis で Terraform プルリクエストの自動化

なぜ Atlantis が必要なのか
-------------

Terraform を使おうとすると、ふつうコンフィギュレーションを Git でバージョン管理したくなるだろう。
さらにチーム開発では、 GitHub/GitLab のプルリクエスト/マージリクエスト(以下、PR)でコンフィギュレーション変更のレビューを行いたくなる。
だが、レビューが通って変更がマージされたとして、 `terraform apply` に失敗するかもしれない。
急いで修正しなければならないが、その間、ほかの PR の進行を止める手段はなく、
最悪の場合 `terraform apply` に失敗するコンフィギュレーションが積みあがってしまう。
かといってマージ前に `terraform apply` してしまうと PR が同時進行しているときに、もっとひどい状態（どちらかしか適用されていない状態）になる可能性がある。

チーム開発で Terraform を使おうとすると、このような問題に陥る可能性がある。
規模が大きくなると運用ではカバーしきれなくなってくるだろうが、
[Atlantis](https://www.runatlantis.io/) ならうまい具合に解決できる。

Atlantis のワークフローと動作概要
-------------

Atlantis を使ったときに PR の動きをもって、先述の問題をどう解決しているのかを説明する。

1. レビューイが PR を作成する。
   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/0592582a-b64a-b76d-d2f6-80aad89c0970.png)
   - PR が作成されると Webhook で Atlantis を呼び出してくれる。
1. Atlantis が自動で `terraform plan` を実行し、結果をコメントしてくれる。
   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/6524b425-956b-c0d7-ec56-3376a722fac4.png)
   - 同じユーザなのでわかりにくいが、このコメントはAtlantisによるもの。
   - 変更のあったコンフィギュレーションのみ `terraform plan` が実行される。
   - このとき Atlantis はロックされ、他の PR を作成しても動かなくなっている。
     ロックは PR がマージされるか、クローズされるまで続く。
1. レビュアが確認して問題なければ(レビュアかレビューイのどちらかが) `atlantis apply` とコメントする。
   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/9780f795-af75-3851-ac65-19f6d47c4c85.png)
   - PR にコメントされると Webhook で Atlantis を呼び出してくれる。
1. Atlantis が自動で `terraform apply` を実行し、結果をコメントしてくれる。設定によっては、正常に完了すれば自動で PR のマージまでしてくれる。
   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/725d8d06-5e95-8439-3ea1-7150a174ec7b.png)
   - やはり同じユーザなのでわかりにくいが `atlantis apply` 以下のコメントやマージはすべて Atlantis によるもの。

Atlantis は、ロック機能によって、複数の PR を同時進行できなくしている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/6b1dc1a3-696a-7407-25ed-4bd0ee7fca7d.png)

なお、ロックされていても PR を開いて進めていてよく、
ロックしていた PR がマージされロック解除された後にプッシュしたり `atlantis plan` とコメントするとロックを取り直せる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/61f8199e-ba89-7a9f-a637-662bebbdcce7.png)

時系列を整理して書くと、次のように、PR#1 作成後に PR#2 を作成するとエラーとなるが、
PR#1 マージ後に PR#2 で `atlantis plan` を実行すればロックを取り直せる：

```
PR#1
 |
plan (ロック取得)
 |
 |        PR#2
 |         | Atlantis のロック機能でエラー
 |         |
apply      |
merge      |
           |
          plan (ロック取得)
          apply
          merge
```

なお、ロックは変更のあったコンフィギュレーションごとに取られるので、
関連しないリソースは、下記のようにディレクトリを分けておくとよいだろう。

```
+admin/
| +-- main.tf
| +-- ...
|
+test/
| +-- main.tf
| +-- ...
|
+prod/
  +-- main.tf
  +-- ...

```

たとえば、`test` ディレクトリでロックされているときでも、 `prod` ディレクトリへの変更は可能である。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/f1c653ab-c0af-1395-46d1-40a61763d194.png)

このようなワークフローで Atlantis はチームでの Terraform 運用上の問題を解決できる。

次節では、この Atlantis の構築方法について述べる。

Atlantis の構築
--------------

基本的に[公式のドキュメント](https://www.runatlantis.io/docs/installation-guide.html)を参照すればよい。
ここでは特に Git リポジトリに GitHub を用い、EC2 インスタンス上に Docker コンテナとして動かす Atlantis について説明する。
Atlantis のバージョンは 0.7.1 を使用している。

注意すべき Atlantis のアーキテクチャ上の特徴としては、 Webhook を受けて駆動し PR にコメントしたり PR をマージするので、
GitHub や GitLab と相互にアクセスできなければならない、というものがある。
すなわち GitHub や GitLab.com を使用する場合はインターネット上に公開しなければならない。
共通鍵として Webhook シークレットを利用したり、
駆動するリポジトリをホワイトリストで指定したりすることで、不用意に動かされないようになっているが、
プライベートリポジトリを使うことを検討したほうがよいかもしれない。

まず Atlantis で動かすのに必要な権限を用意する。
AWS のリソースを操作する場合は、EC2 にインスタンスロールを与える。
Terraform で書くと、以下のようになる。

```go
data "aws_iam_policy_document" "atlantis_assume_role_policy" {
  // Atlantis の AssumeRole ポリシー。
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "atlantis" {
  // Atlantis の AssumeRole。
  name               = "atlantis_role"
  assume_role_policy = "${data.aws_iam_policy_document.atlantis_assume_role_policy.json}"
}

data "aws_iam_policy_document" "atlantis" {
  // Atlantis のインスタンス用のロールのポリシー。
  // 管理権限を与えているが、強すぎるかもしれないので検討の余地がある。
  statement {
    effect    = "Allow"
    actions   = ["*"]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "atlantis" {
  // Atlantis のインスタンス用のロール。
  name   = "atlantis_policy"
  role   = "${aws_iam_role.atlantis.id}"
  policy = "${data.aws_iam_policy_document.atlantis.json}"
}

resource "aws_iam_instance_profile" "atlantis" {
  // Atlantis のインスタンスプロファイル。
  name = "atlantis_profile"
  role = "${aws_iam_role.atlantis.name}"
}

resource "aws_key_pair" "atlantis" {
  // Atlantis インスタンスのキーペア。
  key_name   = "atlantis-key"
  public_key = "ssh-rsa ..."
}

resource "aws_instance" "atlantis" {
  // Atlantis の EC2 インスタンス。
  ami                  = "ami-0eb48a19a8d81e20b" // Ubuntu 18.04
  instance_type        = "t2.micro"
  key_name             = "atlantis-key"
  iam_instance_profile = "${aws_iam_instance_profile.atlantis.name}"
}
```

GCP の操作権限を与える場合は、秘密鍵ファイルをダウンロードし、EC2 インスタンス上にアップロードする。
`/atlantis/gcp-credentials/private_key.json` にアップロードしたとする。

EC2 インスタンスで Docker をインストールする。
参考：https://docs.docker.com/install/linux/docker-ce/ubuntu/

```
sudo apt-get update
sudo apt-get install -y \
    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 apt-key fingerprint 0EBFCD88
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 -y docker-ce docker-ce-cli containerd.io
```

https://www.runatlantis.io/docs/server-configuration.html#environment-variables
にある通り、必要な起動オプションを環境変数で指定して起動する。

```
sudo docker run --name atlantis \
  -d \
  -v /atlantis/gcp-credentials:/atlantis/gcp-credentials \
  --restart=always \
  -p 4141:4141 \
  -e ATLANTIS_AUTOMERGE=true \
  -e ATLANTIS_GH_TOKEN=<github_token> \
  -e ATLANTIS_GH_USER=<github_username> \
  -e ATLANTIS_GH_WEBHOOK_SECRET=<webhook_secret> \
  -e ATLANTIS_REPO_WHITELIST=github.com/<github_username>/* \
  -e GOOGLE_CLOUD_KEYFILE_JSON=/atlantis/gcp-credentials/private_key.json \
  runatlantis/atlantis:v0.7.1
```

- `github_username` は Atlantis が使用する GitHub のユーザ名であり、 `github_tolen` はそのユーザの [GitHub のパーソナルアクセストークン](https://github.com/settings/tokens)を、`repo` スコープで作成したものを指定する。
  普段使いのユーザにすると混乱を招きかねないので、マシンユーザを使うことが推奨されている。
- `webhook_secret` は24文字より長く、適当にランダムな文字列を指定する。
  公式のドキュメントではランダム文字列生成に
  https://www.random.org/passwords/?num=2&len=20&format=html&rnd=new
  を参照している。20文字が2列出るので、連結して40文字として使うことが想定されていると思われる。
- GCP の操作権限が不要な場合は `GOOGLE_CLOUD_KEYFILE_JSON` は不要である。
- `ATLANTIS_AUTOMERGE=true` とすると `apply` に成功したとき自動でマージしてくれる。

コンテナ起動後、 EC2 インスタンスの4141ポートにHTTPで接続しに行くと、atlantisの画面が見られるようになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/6ac274cf-a77b-ff7e-853b-e8c425c3f794.png)

最後に GitHub 上のコンフィギュレーションリポジトリに Webhook の設定を行えば、Atlantis が使えるようになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/0cd34cf9-c4ff-fb0b-eac8-972c8d7c1db4.png)

- Secret は Docker コンテナ起動時に指定した `webhook_secret` を入力する。

コンフィギュレーションリポジトリの中身は、次の点に注意：

- コンフィギュレーションリポジトリのディレクトリ構成は指定があるので、もし既存のリポジトリがあっても、そのまま Atlantis に移行はできないはずである。
  [公式ドキュメント](https://www.runatlantis.io/docs/requirements.html#repository-structure)に記載されている。
- [公式ドキュメント](https://www.runatlantis.io/docs/requirements.html#terraform-state)によるとリモートステートは必須のようなので、指定しておく。

動作確認
------------------------

1. GitHub にコンフィギュレーションリポジトリを作る。
   - READMEで初期化しておくとよい。
   - プライベートにしておいたほうがよい。
1. `test` ディレクトリを切って、`main.tf` ファイルを追加する PR を作成する。
   - 本当はリモートステートを指定しておいたほうがよいが、省略する。

```go
provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_instance" "test" {
  ami           = "ami-0f9ae750e8274075b"
  instance_type = "t2.micro"
}
```

   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/0592582a-b64a-b76d-d2f6-80aad89c0970.png)

1. Atlantis が自動で `terraform plan` を実行し、結果をコメントしてくれるはず。
   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/6524b425-956b-c0d7-ec56-3376a722fac4.png)

1. `atlantis apply` とコメントする。(実際にインスタンスが作成されるかもしれないので注意)
   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/9780f795-af75-3851-ac65-19f6d47c4c85.png)

1. Atlantis が自動で `terraform apply` を実行し、結果をコメントしてくれる。
   - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51414/725d8d06-5e95-8439-3ea1-7150a174ec7b.png)

1. 作成されたインスタンスを削除する。

まとめ
------------------------

今回はチームで GitHub 上の Terraform コンフィギュレーションを運用する際に
生じる問題点を解決できる Atlantis を EC2 インスタンスとして立て、 Atlantis ワークフローを試しに動かしてみた。

Atlantis は比較的活発に開発されており(数週間に1回のリリース？)、[メイン開発者の方も2018年10月に HashiCorp に入社したそう](https://medium.com/runatlantis/joining-hashicorp-200ee9572dc5)なので、今後の発展が期待できる。
ただ実際に導入しているところはまだあまり多くなさそうで、とくに日本語の記事は皆無の状況。
実際に使ってみると出てくる課題もありそうだが、使っているのは Terraform であり進退窮まることもないので、個人的には積極的に試していきたい。

補遺：Atlantis は GitOps なのか
-------------

[GitOps という概念が Weaveworks によって提唱されている](https://www.weave.works/technologies/gitops/)が、
2018年7月には[アンチパターン CIOps](https://www.weave.works/blog/kubernetes-anti-patterns-let-s-do-gitops-not-ciops) と煽ってきたりもしていて、
気軽に GitOps と言えない感じになってきている(気がする)。
ただただ GitOps という言葉だけ聞くと、コンフィギュレーションをGitに管理していればよさそう、とか、
コンフィギュレーションの変更をプルリクエスト送って管理していればよさそう、とか思うだろう。
すると、Atlantis は GitOps なのでは、と言えそうに思う。

ここで上記記事に記載された GitOps の原理を見てみる。

>1. The entire system described declaratively.
>2. The canonical desired system state versioned in Git.
>3. Approved changes can be automatically applied to the system.  
>4. Software agents to ensure correctness and alert on divergence.

1番の declaratively = 宣言的にというのは、Terraform を使っている以上(多分)満たされる。
2番は Git を使っていることを言っているので、これも満たされる。
3番も `atlantis apply` で自動的に適用できるので、満たされる。
4番は、Git = 実環境であることを保証し、差異があったらアラートすると言っているが、これはどうだろうか。

Terraform 単体では `apply` に失敗するなどして、 Git = 実環境とならない可能性があったため、
Atlantis による PR ワークフローとロックが必要となった。
これにより、 **PR をマージした時点では** Git = 実環境となるようになったが、
**実環境が変化したときに追随できない**という問題が残っている。
Atlantis はこの「差異があったらアラートする」機能が不足している。
おそらく、次の PR を作ったときに `plan` に妙な差分が出て気づくが、これでは混乱をきたしかねない。

[Weaveworks のブログ](https://www.weave.works/blog/gitops-part-3-observability)では、
Terraform について Git と実環境の差異をチェックする機能を Terradiff として簡単に紹介されている。

>Terradiff - Terraform has a “plan” mode, which shows us if our configuration matches reality – we periodically execute this and export the exit code to Prometheus, from where we monitor and alert on it.

つまり、Atlantis で GitOps をやっていくには、Git と実環境に差異があったらアラートするため、
定期的に Terraform の `plan` でコンフィギュレーションが実環境に一致するかを示し、Prometheus に終了ステータスをエクスポートする Terradiff 機能を別途用意する必要がありそうである。