CLOVER🍀

That was when it all began.

TerraformのStateをConsulで管理する

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

TerraformのStateについてちゃんとドキュメントを読んでいなかったので、1度確認しておこうということと、Stateをローカルファイル以外で
管理する方法もあるらしいので、そちらも見てみようかなということで。

今回は、TerraformのStateの管理先として、Consulを使ってみたいと思います。

State?

Stateに関するドキュメントは、こちら。

State - Terraform by HashiCorp

Stateとは、管理対象のインフラストラクチャーとその構成に関する状態を保存したものです。

このStateを使用して、インフラストラクチャーの変更の際に実際の環境との差分を検出してplanを立て、変更を実施した時には
またStateを更新します。つまり、現在のインフラストラクチャーの状態とStateの整合性をとっていく必要があります。

デフォルトでは「terraform.state」というローカルファイルに保存されますが、リモートで保存してチームでシェアすることもできます。
ローカルファイルのままだと、「terraform.state」をなんらかの方法で共有しないと、Terraformを同じStateで実行できなくなって
しまいます。

Stateを保持するBackendがサポートしている場合(Backendは後述)、TerraformのStateに対してロックを行うことができます。

State: Locking - Terraform by HashiCorp

ロックを使用することで、複数の人が同じTerraform管理対象に操作を行い、Stateを壊してしまうことを防げるようです。

Remote State。リモートのストアにStateを保存することができる機能です。ロックのところで出てきたBackendが、リモートでの
Stateの保存先として挙げられます。

State: Remote Storage - Terraform by HashiCorp

以降、今回は気にしない機能について。

既存のインフラストラクチャーをStateとして取り込むことができる、import。現時点で、いろいろ制限はあるようですが。

State: Import Existing Resources - Terraform by HashiCorp

Import - Terraform by HashiCorp

Backendに保存されるStateに対して、名前をつけて切り替えることができるWorkspace。tfvarsなどと組み合わせて、Terraform適用先の
環境が複数ある時に、その状態を環境別に管理することが主なユースケースのようです。

State: Workspaces - Terraform by HashiCorp

リソース定義を行う場合、時として「パスワード」などといった機密データが含まれることがありますが、これはStateに
プレーンに保存されてしまいます。Remote Stateを使っていて、かつBackendがサポートしている場合はState自体を暗号化して
保存することができます。

State: Sensitive Data - Terraform by HashiCorp

Backend?

Stateの話で出てきた、Backendを少し掘り下げます。

Backends - Terraform by HashiCorp

TerraformのBackendは、TerraformがStateをどのように保存し、「terraform apply」などをどのように実行するかを抽象化します。

デフォルトでは「local」Backendが使用されますが、他のBackendを利用することもできます。

Backendの利点は、以下のようです。

  • Stateをリモートで管理することにより、チームでの作業を可能にする
  • 機密データを暗号化して保存することができる
  • リモート実行のサポート

Backendの設定方法は、こちら。Backendを後から切り替えたり、やめたりすることもできるようです。

Backends: Configuration - Terraform by HashiCorp

また、Stateのpullやpush(推奨されていない)もできるようです。

Backends: State Storage and Locking - Terraform by HashiCorp

Backendの種類には、StandardとEnhancedがあり、デフォルトの「local」BackendはEnhancedに属します。

Backend: Supported Backend Types - Terraform by HashiCorp

もうひとつのEnhancedなBackendは、Terraform Cloudです。

Standardの方は、現時点で以下のようなBackendがあります。

  • Artifactory
  • Azure Blob Storage(azurerm)
  • Consul
  • Tencent Cloud Object Storage(COS)
  • etcd(v2/v3)
  • Google Cloud Storage(GCS)
  • REST Client(HTTP)
  • Manta
  • Alibaba Cloud Object Storage Service(OSS
  • PostgreSQL
  • Amazon S3
  • OpenStack Swift

Consul Backend

今回はBackendのうち、Consulを使ってみようと思います。

Backend Type: consul - Terraform by HashiCorp

Consulは、HashiCorp社が開発している、Service Mesh、Key Value Storeです。

Consul by HashiCorp

Terraform BackendにConsulを使用した場合は、StageをKey Value Storeに保存し、またロックのサポートもあります。

暗号化のサポートはありませんが、ロックも試せるようですし、まずはこちらでいいかな、と。

お題

MySQL ProviderでTerraformを使いつつ、TerraformのStateをConsulで管理してみたいと思います。

Provider: MySQL - Terraform by HashiCorp

MySQL Providerは以前も使ったことがあるので、この焼き直し的な感じですね。

Ubuntu Linux 18.04 LTSにTerraformをインストールする - CLOVER🍀

環境

今回の環境は、こちらです。

Terraform。

$ terraform version
Terraform v0.12.24

Consul。

$ ./consul version
Consul v1.7.2
Protocol 2 spoken by default, understands 2 to 3 (agent will automatically use protocol >2 when speaking to compatible agents)

$ ./consul agent -server -ui -client 0.0.0.0 -bootstrap -data-dir /var/lib/consul/data

MySQL

$ mysql -uroot -p
Enter password: 
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 9
Server version: 8.0.19 MySQL Community Server - GPL


mysql> CREATE USER kazuhira@'%' IDENTIFIED BY 'password';
Query OK, 0 rows affected (0.10 sec)

mysql> GRANT ALL PRIVILEGES ON *.* TO kazuhira@'%';
Query OK, 0 rows affected (0.10 sec)

Terraform、Consul、MySQLを動かすサーバーはそれぞれ別とし、Consul、MySQLIPアドレスは以下とします。

  • Consul … 172.17.0.2
  • MySQL … 172.17.0.3

Consul BackendとMySQL Providerを使って、Terraformのリソース定義を書く

では、Terraformを使ったリソース定義を書いていきます。

Terraformで構築する、MySQLデータベースに関する設定。
main.tf

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

  version = "1.9.0"
}

resource "mysql_database" "my_database" {
  name = "my_database"
}

要するに、接続先とcreate databaseの定義ですね。

Terraformのバージョンも書いておきましょう。
version.tf

terraform {
  required_version = "0.12.24"
}

そして、今回のポイントであるBackendの設定。Consulへの接続先を定義します。
backend.tf

terraform {
  backend "consul" {
    address = "172.17.0.2:8500"
    scheme  = "http"
    path    = "mysql/state"
  }
}

各設定項目の意味は、こちら。

Consul / Configuration variables

まずは、接続先(address)、プロトコルscheme)、パス(path)を指定。

接続先とプロトコルの意味はそのままですが、パスはStateを保存するKey Value Store上のパスを設定します。pathで指定した先に、
Stateが保存されるということですね。

コードフォーマットの確認と、バリデーションくらいはしておきましょう。

$ terraform fmt -recursive -check
$ terraform validate

「terraform init」して、Providerをダウンロード。

$ terraform init

プランを確認して、実行。

$ terraform plan
$ terraform apply

MySQL側を確認すると、データベースが作成されました。

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| my_database        |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

この時、ConsulのWeb UI(http://172.17.0.2:8500/)にアクセスして、どんなデータが保存されたのか見てみましょう。

f:id:Kazuhira:20200419154047p:plain

ConsulにStateが保存されたことを確認できました。

なお、「.terraform/terraform.tfstate」はこんな感じになっています。
.terraform/terraform.tfstate

{
    "version": 3,
    "serial": 1,
    "lineage": "e24453f9-20b9-da9c-e524-f41fb4f817c7",
    "backend": {
        "type": "consul",
        "config": {
            "access_token": null,
            "address": "172.17.0.2:8500",
            "ca_file": null,
            "cert_file": null,
            "datacenter": null,
            "gzip": null,
            "http_auth": null,
            "key_file": null,
            "lock": null,
            "path": "mysql/state",
            "scheme": "http"
        },
        "hash": 230759039
    },
    "modules": [
        {
            "path": [
                "root"
            ],
            "outputs": {},
            "resources": {},
            "depends_on": []
        }
    ]
}

また、カレントディレクトリには「terraform.tfstate」ファイルは作成されません。

別のディレクトリからStateを共有してみる

このStateを他の人と共有するというお題で、ちょっと簡単に確認してみましょう。

本来はGitなどでコードを管理すべきでしょうが、今回はローカルで別のディレクトリにtfファイルをコピーして確認します。

他の場所に、「team2」というディレクトリを作成して、もともとtfファイルを置いていたディレクトリ(こちらは「team1」とします)から
tfファイルをコピーします。

$ mkdir team2
$ cd team2
$ cp /path/to/team1/*.tf ./.

「terraform init」します(しないと、次の操作で怒られます)。

$ terraform init

プランを確認してみましょう。

$ terraform plan

Backendの定義もコピーしてきたので、「terraform init」の時にStateを見れるようになっているようです。反映する差分がない、と言っていますね。

Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

mysql_database.my_database: Refreshing state... [id=my_database]

------------------------------------------------------------------------

No changes. Infrastructure is up-to-date.

そのとおりなので、OKです。

applyしても、当然反映するものはありません。

$ terraform apply
mysql_database.my_database: Refreshing state... [id=my_database]

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

これで、Stateが共有できたことが確認できました、と。

ロックを確認してみる

次に、ロックを使っていることを確認してみます。

Backendの設定に、「lock」を加えます。
※あとから気づいたのですが、これがデフォルトでした…

backend.tf 
terraform {
  backend "consul" {
    address = "172.17.0.2:8500"
    scheme  = "http"
    path    = "mysql/state"
    lock = true
  }
}

Consulの場合は、ロックを使うように設定されている(デフォルトまたはlock = true)、「path」で指定した値に「.lock」を
追加したパス($path/.lock)でロックを作るみたいです。

Consul / Configuration variables

Backendの設定を変更したら、「terraform init」を求められるので実行。

$ terraform init

リソース定義を変更して、Character SetとCollationを設定してみます。
main.tf

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

  version = "1.9.0"
}

resource "mysql_database" "my_database" {
  name = "my_database"
  default_character_set = "utf8mb4"
  default_collation = "utf8mb4_ja_0900_as_cs_ks"
}

プランを確認して

$ terraform plan

〜省略〜

Terraform will perform the following actions:

  # mysql_database.my_database will be updated in-place
  ~ resource "mysql_database" "my_database" {
      ~ default_character_set = "utf8" -> "utf8mb4"
      ~ default_collation     = "utf8_general_ci" -> "utf8mb4_ja_0900_as_cs_ks"
        id                    = "my_database"
        name                  = "my_database"
    }

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

apply。これ自体は、問題なく完了します。

$ terraform apply

この時、Consul Web UI上でロックを見ようと思ったのですが…一瞬しかいなくて見えなかったので…Consulのログレベルをdebugにして
確認することにしました。

$ ./consul agent -server -ui -client 0.0.0.0 -bootstrap -data-dir /var/lib/consul/data -log-level debug

すると、ロックを取得していそうなログが確認できました。

    2020-04-19T08:48:57.251Z [DEBUG] agent.http: Request finished: method=PUT url=/v1/txn from=172.17.0.1:53556 latency=28.543288ms
    2020-04-19T08:48:57.272Z [DEBUG] agent.http: Request finished: method=DELETE url=/v1/kv/mysql/state/.lockinfo from=172.17.0.1:53556 latency=20.375662ms
    2020-04-19T08:48:57.291Z [DEBUG] agent.http: Request finished: method=PUT url=/v1/kv/mysql/state/.lock?flags=3304740253564472344&release=d0055d4b-734a-68ea-30c7-1baf9f5fce80 from=172.17.0.1:53556 latency=18.661023ms
    2020-04-19T08:48:57.291Z [DEBUG] agent.http: Request finished: method=GET url=/v1/kv/mysql/state/.lock?consistent=&index=73 from=172.17.0.1:53558 latency=3.535098713s
    2020-04-19T08:48:57.292Z [DEBUG] agent.http: Request finished: method=GET url=/v1/kv/mysql/state/.lock from=172.17.0.1:53558 latency=114.087µs
    2020-04-19T08:48:57.305Z [DEBUG] agent.http: Request finished: method=DELETE url=/v1/kv/mysql/state/.lock?cas=77 from=172.17.0.1:53558 latency=13.017596m

ロックを外して、もう1度ログを見てみましょう。 backend.tf

terraform {
  backend "consul" {
    address = "172.17.0.2:8500"
    scheme  = "http"
    path    = "mysql/state"
    lock = false
  }
}

リソースも変更。

resource "mysql_database" "my_database" {
  name = "my_database"
#  default_character_set = "utf8mb4"
#  default_collation = "utf8mb4_ja_0900_as_cs_ks"
}

initして

$ terraform init

プラン確認とapply。

$ terraform plan
$ terraform apply

ログが、すごくシンプルになりました。

    2020-04-19T08:49:31.074Z [DEBUG] agent.http: Request finished: method=GET url=/v1/kv/mysql/state from=172.17.0.1:53752 latency=267.412µs
    2020-04-19T08:49:35.225Z [DEBUG] agent.http: Request finished: method=PUT url=/v1/txn from=172.17.0.1:53752 latency=63.871263ms

ロックを使わなくなったので、ロックを扱っているようなログが消えました、と。

StateをConsul管理からローカル管理に変更する

次に、StateをConsulでの管理からローカル管理に変更してみます。

とりあえず、リソース定義は戻しておきましょう。

resource "mysql_database" "my_database" {
  name = "my_database"
  default_character_set = "utf8mb4"
  default_collation = "utf8mb4_ja_0900_as_cs_ks"
}

apply。

$ terraform apply

では、Backendの設定をコメントアウトしてみます。
backend.tf

#terraform {
#  backend "consul" {
#    address = "172.17.0.2:8500"
#    scheme  = "http"
#    path    = "mysql/state"
#    lock = true
#  }
#}

「terraform init」すると、「Consul Backendを使わなくなったね?新しい"local" Backendには既存のStateがないようです。
これまでのStateを新しいBackendにコピーする?それとも空のStateから始める?」と聞かれます。

$ terraform init

Initializing the backend...
Terraform has detected you're unconfiguring your previously set "consul" backend.
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "consul" backend to the
  newly configured "local" backend. No existing state was found in the newly
  configured "local" backend. Do you want to copy this state to the new "local"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes



Successfully unset the backend "consul". Terraform will now operate locally.

今回は、既存(Consul)のStateをlocal Backendにコピーするように選択。

こうすると、local Backendになった後に「terraform plan」を行っても、特に差分は検出されません。

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

mysql_database.my_database: Refreshing state... [id=my_database]

------------------------------------------------------------------------

No changes. Infrastructure is up-to-date.

また、Stateの管理先がlocal Backendになったので、カレントディレクトリに「terraform.tfstate」ファイルが作成されます。
terraform.tfstate

{
  "version": 4,
  "terraform_version": "0.12.24",
  "serial": 0,
  "lineage": "4c387586-465f-b08f-6dbb-2cb4f4ce07e4",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "mysql_database",
      "name": "my_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=="
        }
      ]
    }
  ]
}

これで、ConsulからlocalへのBackendの切り替えができました。

今度は、localからConsulへ変更してみましょう。

1度、Consulのデータを全部消してから、Consul Backendの定義を復活させます。 backend.tf

terraform {
  backend "consul" {
    address = "172.17.0.2:8500"
    scheme  = "http"
    path    = "mysql/state"
    lock = true
  }
}

init。

$ terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "consul" backend. No existing state was found in the newly
  configured "consul" backend. Do you want to copy this state to the new "consul"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes


Successfully configured the backend "consul"! Terraform will automatically
use this backend unless the backend configuration changes.

今度は、localからConsulに変更するけど、Stateをコピーする?と聞かれます。

「yes」と答えた後は、Consulからlocalに移した時と同じなので割愛。

これで、Stateをローカル以外で管理するということについて、初歩的な内容は確認できたのではないでしょうか。