CLOVER🍀

That was when it all began.

GitLabをTerraformのStateの保存先として使う

これは、なにをしたくて書いたもの?

GitLab 13.0で、TerraformのStateのバックエンドとして利用できるようになったというのを見かけまして。

GitLab 13.0 released with Gitaly Clusters, Epic Hierarchy on Roadmaps, and Auto Deploy to ECS / GitLab HTTP Terraform state backend

ポイントは、このあたりですね。

  • Multiple named state files per project
  • Locking
  • Object storage
  • Encryption at rest

こちらを試してみようかな、と。

Terraform State BackendとしてのTerraform

TerraformのRemote Stateに関しては、以前にこちらで書きました。

TerraformでRemote Stateを参照して、Data Sourceとして扱う - CLOVER🍀

通常はローカルファイルシステムに保存されるTerraformのStateを、別のリモートのストアに保存できるようにすることですね。

State: Remote Storage - Terraform by HashiCorp

Stateをリモート管理することで、Stateをチーム内で共有したり、ロックを使用した排他制御を行うことでTerraformを同時に
実行して事故になることを避ける、といったことができるようになります。

どこまでできるかは、実際に利用するバックエンド次第ですが。

GitLabのドキュメントにおける、Terraformに関するページはこちらです。

Infrastructure as code with Terraform and GitLab | GitLab

S3などを用意しなくても、Stateをリモート管理できると謳われており、転送中や保存中のStateの暗号化、ロックをサポート
することがポイントとして書かれています。

  • Supporting encryption of the state file both in transit and at rest.
  • Locking and unlocking state.
  • Remote Terraform plan and apply execution.

あとは、CIで使う時の方法が書かれていたり。

CIに組み込む際には、合わせてGitLabで定義されている環境変数を見ることになりそうですね。

Predefined environment variables reference | GitLab

TerraformのStateを管理する機能は、デフォルトで有効になっていて、その設定などはこちらに書かれています。

Terraform state administration (alpha) | GitLab

「/etc/gitlab/gitlab.rb」で設定するようです。

また、GitLabをTerraform State Backendとして使う場合は、HTTPとなり、RESTでのアクセスとなります。

Backend Type: http - Terraform by HashiCorp

と、説明はこのくらいにして、実際に使っていってみましょう。

TerraformのStateをGitLabで管理し、ProviderはMySQLを使って遊んでみることにします。

環境

今回の環境まわりです。

$ terraform version
Terraform v0.12.26
+ provider.mysql v1.9.0

GitLabは192.168.0.3のサーバーで動作しているものとします。バージョンは、こちら。

$ sudo gitlab-rake gitlab:env:info

System information
System:     Ubuntu 18.04
Current User:   git
Using RVM:  no
Ruby Version:   2.6.6p146
Gem Version:    2.7.10
Bundler Version:1.17.3
Rake Version:   12.3.3
Redis Version:  5.0.9
Git Version:    2.26.2
Sidekiq Version:5.2.7
Go Version: unknown

GitLab information
Version:    13.0.6
Revision:   5aa982e01ea
Directory:  /opt/gitlab/embedded/service/gitlab-rails
DB Adapter: PostgreSQL
DB Version: 11.7
URL:        http://192.168.0.3
HTTP Clone URL: http://192.168.0.3/some-group/some-project.git
SSH Clone URL:  git@192.168.0.3:some-group/some-project.git
Using LDAP: no
Using Omniauth: yes
Omniauth Providers: 

GitLab Shell
Version:    13.2.0
Repository storage paths:
- default:  /var/opt/gitlab/git-data/repositories
GitLab Shell path:      /opt/gitlab/embedded/service/gitlab-shell
Git:        /opt/gitlab/embedded/bin/git

また、MySQLは8.0で、172.17.0.2で動作しているものとします。

シンプルに使ってみる

まずは、こちらを見ながらシンプルに使っていってみましょう。

Infrastructure as code with Terraform and GitLab / Get started using local development

ドキュメントを見ると、Terraformの構成ファイルへ書くのはこれくらいで

terraform {
  backend "http" {
  }
}

残りは「terraform init」時に指定、という雰囲気で書かれていますが、ちょっと面倒だなぁと思ったので…。

$ terraform init \
    -backend-config="address=https://gitlab.com/api/v4/projects/<YOUR-PROJECT-ID>/terraform/state/<YOUR-PROJECT-NAME>" \
    -backend-config="lock_address=https://gitlab.com/api/v4/projects/<YOUR-PROJECT-ID>/terraform/state/<YOUR-PROJECT-NAME>/lock" \
    -backend-config="unlock_address=https://gitlab.com/api/v4/projects/<YOUR-PROJECT-ID>/terraform/state/<YOUR-PROJECT-NAME>/lock" \
    -backend-config="username=<YOUR-USERNAME>" \
    -backend-config="password=<YOUR-ACCESS-TOKEN>" \
    -backend-config="lock_method=POST" \
    -backend-config="unlock_method=DELETE" \
    -backend-config="retry_wait_min=5"

また、可変の値としてGitLabのプロジェクトのID、プロジェクト名、ユーザー名およびアクセストークンが必要だということに
なっています。gitlab.comを使わない場合(今回もですが)は、アクセス先も変わります。

プロジェクトIDとプロジェクト名は、今回はそれぞれ2、sample-projectとします。

今回は、このくらいは定義の方に書くことにしました。

terraform {
  required_version = "0.12.26"

  backend "http" {
    address = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project"
    lock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/lock"
    lock_method = "POST"
    unlock_method = "DELETE"
    retry_wait_max = 5
  }
}

provider "mysql" {
  endpoint = "172.17.0.3:3306"
  username = "root"
  password = "password"

  version = "1.9.0"
}

アクセス先はHTTPにしているので、転送中は思いきり平文ですね。

ユーザー名とパスワードだけは、terraform init時に指定することにします。

$ terraform init \
  -backend-config="username=[your-username]" \
  -backend-config="password=[your-access-token]"

チーム内で使うにしても、他の項目はメンバー単位に変わらないでしょう…。

パスワードは、GitLabのアクセストークンです。持っていなければ、「User Settings」→「Access Tokens」から発行できます。
アクセストークンのスコープとしては、「api」があれば大丈夫な気がします。

で、全体でこんな感じで定義してみました。MySQLデータベースを作成する、リソース定義です。
main.tf

terraform {
  required_version = "0.12.26"

  backend "http" {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_max = 5
  }
}

provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"

  version = "1.9.0"
}

resource "mysql_database" "database" {
  name = "my_database"

  default_character_set = "utf8mb4"
  default_collation     = "utf8mb4_ja_0900_as_cs_ks"
}

applyで、問題なくデータベースの作成ができます。

$ terraform apply

この時、アクセストークンがあればStateの保存内容をcurlなどで見ることができます。

$ curl -H 'Authorization: Bearer [your-access-token]' http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project
{
  "version": 4,
  "terraform_version": "0.12.26",
  "serial": 7,
  "lineage": "",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "mysql_database",
      "name": "database",
      "provider": "provider.mysql",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "default_character_set": "utf8mb4",
            "default_collation": "utf8mb4_ja_0900_as_cs_ks",
            "id": "my_database",
            "name": "my_database"
          },
          "private": "bnVsbA=="
        }
      ]
    }
  ]
}

また、「terraform init」の時点で、ローカルにはバックエンドの情報を保存したこんなファイルができます。
.terraform/terraform.tfstate

{
    "version": 3,
    "serial": 1,
    "lineage": "fe17a8cb-a6c4-be4c-2b3c-2883f103a836",
    "backend": {
        "type": "http",
        "config": {
            "address": "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project",
            "lock_address": "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/lock",
            "lock_method": "POST",
            "password": "[your-access-token]",
            "retry_max": null,
            "retry_wait_max": 5,
            "retry_wait_min": null,
            "skip_cert_verification": null,
            "unlock_address": "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/lock",
            "unlock_method": "DELETE",
            "update_method": null,
            "username": "[your-username]"
        },
        "hash": 1929719276
    },
    "modules": [
        {
            "path": [
                "root"
            ],
            "outputs": {},
            "resources": {},
            "depends_on": []
        }
    ]
}

Remote State+Data Sourceとして使う

続いて、GitLabをTerraformのRemote Stateとして使いつつも、Data Sourceとして参照にも使用してみましょう。

データベースの作成と、ユーザーの作成をそれぞれ別のリソース定義で行い、Data Sourceの参照で紐付けます。

まずは、データベース側のリソース定義。
main.tf

terraform {
  required_version = "0.12.26"

  backend "http" {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/database"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_max = 5
  }
}

provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"

  version = "1.9.0"
}

resource "mysql_database" "database" {
  name = "my_database"

  default_character_set = "utf8mb4"
  default_collation     = "utf8mb4_ja_0900_as_cs_ks"
}

output "database_name" {
  value = mysql_database.database.name
}

ドキュメントではプロジェクト名がBackendのアクセス先になっていましたが、このあたりの値は任意で良さそうなので、
変更しました。「Multiple named state files per project」というのが、リリースブログでもポイントとして書かれていましたしね。
今回は「database」としています。

terraform {
  required_version = "0.12.26"

  backend "http" {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/database"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_max = 5
  }
}

このリソース定義の結果を次で使うので、Outputも定義。データベース名だけですが。

output "database_name" {
  value = mysql_database.database.name
}

で、apply。

$ terraform apply

続いて、ユーザーを作成するリソース定義を行いましょう。データベースを作成した時のリソース定義とは、別のディレクトリで
行ってください。

全体はこんな感じです。
main.tf

terraform {
  required_version = "0.12.26"

  backend "http" {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/users"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/users/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/users/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_max = 5
  }
}

provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"

  version = "1.9.0"
}

variable "gitlab_username" {}
variable "gitlab_password" {}

data "terraform_remote_state" "database" {
  backend = "http"

  config = {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/database"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    username       = var.gitlab_username
    password       = var.gitlab_password
    retry_wait_max = 5
  }
}

resource "mysql_user" "admin_user" {
  user               = "adminuser"
  plaintext_password = "password"
  host               = "%"
}

resource "mysql_grant" "admin_user" {
  user       = "adminuser"
  host       = "%"
  database   = data.terraform_remote_state.database.outputs.database_name
  privileges = ["ALL"]

  depends_on = [mysql_user.admin_user]
}

resource "mysql_user" "application_user" {
  user               = "appuser"
  plaintext_password = "password"
  host               = "%"
}

resource "mysql_grant" "application_user" {
  user       = "appuser"
  host       = "%"
  database   = data.terraform_remote_state.database.outputs.database_name
  privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]

  depends_on = [mysql_user.application_user]
}

Backendのパスは、先ほどのものとは変更し、用途に合わせて「users」としました。

terraform {
  required_version = "0.12.26"

  backend "http" {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/users"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/users/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/users/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_max = 5
  }
}

また、先ほどのデータベース定義の結果を参照したいので、Data Sourceの定義を行います。

こんな感じで。参照先は、「database」のパスになります。

variable "gitlab_username" {}
variable "gitlab_password" {}

data "terraform_remote_state" "database" {
  backend = "http"

  config = {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/database"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/database/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    username       = var.gitlab_username
    password       = var.gitlab_password
    retry_wait_max = 5
  }
}

ここではユーザー名とパスワードを指定せざるをえないので、変数に切り出しました。

値は、環境変数で与えた方がいいのかな?と思います。

$ export TF_VAR_gitlab_username=[your-username]
$ export TF_VAR_gitlab_password=[your-access-token]

そして、Remote StateをData Sourceとして参照して、リソース定義を行います。

resource "mysql_grant" "application_user" {
  user       = "appuser"
  host       = "%"
  database   = data.terraform_remote_state.database.outputs.database_name
  privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]

  depends_on = [mysql_user.application_user]
}

apply。

$ terraform apply

こんな感じで、確認完了です。

GitLab上でのStateの保存先について

GitLab側に保存されるTerraformのStateですが、保存先のパスなどはこちらのドキュメントに記載のある設定箇所で調整します。

Terraform state administration (alpha) | GitLab

設定ファイルは、「/etc/gitlab/gitlab.rb」です。

デフォルトの保存先は、「/var/opt/gitlab/gitlab-rails/shared/terraform_state」ディレクトリなので、こちらを確認してみます。

$ sudo ls -l /var/opt/gitlab/gitlab-rails/shared/terraform_state
total 8
drwxr-xr-x 2 git git 4096 Jun 13 11:01 2
drwxr-xr-x 4 git git 4096 Jun 13 09:22 tmp

プロジェクトID(今回は2)のディレクトリがありますね。

中身を見ると、3つStateと思しきファイルがあります。3つあるのは、今回3回実行したからですね。

$ sudo ls -l /var/opt/gitlab/gitlab-rails/shared/terraform_state/2
total 16
-rw-r--r-- 1 git git  150 Jun 13 10:53 16daa9081b1106c5c970411eaaec7e25.tfstate
-rw-r--r-- 1 git git  695 Jun 13 11:01 840999dc0e463bed8d360b4fdaac939f.tfstate
-rw-r--r-- 1 git git 4451 Jun 13 11:01 f0763fc6cc0691fcd13731b408ea0488.tfstate

ということを考えると、「/api/v4/projects/<YOUR-PROJECT-ID>/terraform/state/<YOUR-PROJECT-NAME>」といった指定のパスとの
マッピングがちょっと気になるところです。

中身を見ると、読めないようになっています。暗号化されているようですね。

$ sudo cat /var/opt/gitlab/gitlab-rails/shared/terraform_state/2/16daa9081b1106c5c970411eaaec7e25.tfstate
\ �Ջ��nUˤ1��Z�z3�����Cܵ�&c��&�IJ�v��M�)������u�2��<Nk$�t$��:�r2�?�&�]X�ou\��1���苮����=���UX�#���u�`�b�+�$���Ԟ�����D(��[��Ux��

というわけで、ちょっと気になることとして、アクセス先にサブディレクトリのようなものを追加したらどうなるのかな?と
思ったのですが

terraform {
  required_version = "0.12.26"

  backend "http" {
    address        = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/foo"
    lock_address   = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/foo/lock"
    unlock_address = "http://192.168.0.3/api/v4/projects/2/terraform/state/sample-project/foo/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_max = 5
  }
}

こうすると、404になるようです。

$ terraform apply

Error: Error locking state: Error acquiring the state lock: Unexpected HTTP response code 404

Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.

というわけで、「/api/v4/projects/<YOUR-PROJECT-ID>/terraform/state/<YOUR-PROJECT-NAME>」のパスルールで
コントロールしましょう、と。