どうも

独り Terraform 研究所, 所長兼研究員のかっぱです.

Backends

前回に引き続き, Backend の処理について少しだけ深掘りをしてみたいと思います.

どうも Backends ドキュメント 状態の管理 メリット 設定諸々 Backend Types 二種類の Backend Type Enhanced Backends Standard Backends State Locking ドキュメント State Locking とは 例えば... Docker Providor + Backend Type S3 せっかくなので 簡単な main....

inokara.hateblo.jp

今回は, 簡単な HTTP Backend を実装して Terraform の挙動を確認していきたいと思います.

HTTP Backend の実装

仕様

  • RESTful な API サーバーであること
  • 状態を GET メソッドで取得, POST メソッドで登録と更新, DELETE メソッドで削除できれば良い
  • locking をサポートする場合には, LOCK メソッド, UNLOCK メソッド (WebDav で利用されているメソッド) が要求される
    • ステータスコード 423 で Locked
    • ステータスコード 409 で Conflict
    • ステータスコード 200 でロックの成功
  • locking については, オプションで LOCKUNLOCK メソッド以外のメソッドについても利用可能

上記を満たすサーバーを Sinatra を使って書いていきたいと思います. 尚, 実装にあたり, 以下の HTTP バックエンドを参考にさせて頂いております.

An http backend which store and retrieve tfstates files in a secure way by encrypt/decrypt them through credhub - orange-cloudfoundry/terraform-secure-backend

github.com

有難うございます. この HTTP バックエンドは Go で実装されていて, HTTPS もサポートしています.

最初の実装

Web アプリケーションのコード

とりあえず, 以下のようなシンプルに.

require 'sinatra'

get '/state' do
  status 200
end

delete '/state' do
  status 200
end

post '/state' do
  request.body.read.to_s
end

尚, 以下のような環境でこの Web アプリケーション (以後, 俺の HTTP バックエンド) を動かします.

$ ruby --version
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-darwin17]

$ bundle exec gem list | grep sinatra
sinatra (2.0.3)
sinatra-contrib (2.0.3)

俺の HTTP バックエンドは以下のように起動しておきます.

$ bundle exec ruby app.rb &
[1] 51133
$ [2018-09-09 10:05:49] INFO  WEBrick 1.4.2
[2018-09-09 10:05:49] INFO  ruby 2.5.1 (2018-03-29) [x86_64-darwin17]
== Sinatra (v2.0.3) has taken the stage on 4567 for development with backup from WEBrick
[2018-09-09 10:05:49] INFO  WEBrick::HTTPServer#start: pid=51133 port=4567

Terraform の設定

以下のように Backend に http を利用するように定義します. address俺の HTTP バックエンドのエンドポイントを定義します.

terraform {
  backend "http" {
    address = "http://localhost:4567/state"
  }
}

resource "docker_container" "hoge" {
  image    = "${docker_image.centos.latest}"
  name     = "hoge-${terraform.workspace}"
  hostname = "hoge-${terraform.workspace}"
  command  = ["/bin/sh", "-c", "while true ; do sleep 1; hostname -s ; done"]
}

resource "docker_image" "centos" {
  name = "centos:6"
}

定義した後, init コマンドを実行しておきます.

$ terraform init --reconfigure

Initializing the backend...

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

Terraform の操作

plan

Lock, Unlock が未実装である為, -lock=false オプションをつけて実行すると, 特に問題なく plan の実行は終了します.

$ terraform plan -lock=false
...
Plan: 2 to add, 0 to change, 0 to destroy.

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

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

実行すると, 以下のように俺の HTTP バックエンドのログが出力されます.

::1 - - [09/Sep/2018:10:08:10 +0900] "GET /state HTTP/1.1" 200 - 0.0011
::1 - - [09/Sep/2018:10:08:10 JST] "GET /state HTTP/1.1" 200 0
- -> /state

apply

引き続き, apply を実行してリソースを作成します.

$ terraform apply -lock=false

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create
...
docker_container.hoge: Creation complete after 1s (ID: 97ced319ed6ee961d461c7760be50d7731a78ba60059d244577f1471ded3ce50)

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

# 作成されたリソースを確認
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
97ced319ed6e        b5e5ffb5cdea        "/bin/sh -c 'while t…"   2 minutes ago       Up 2 minutes
                                 hoge-default

実行すると, 以下のように俺の HTTP バックエンド のログが出力されます.

::1 - - [09/Sep/2018:10:13:18 +0900] "GET /state HTTP/1.1" 200 - 0.0007
::1 - - [09/Sep/2018:10:13:18 JST] "GET /state HTTP/1.1" 200 0
- -> /state
::1 - - [09/Sep/2018:10:13:27 +0900] "POST /state HTTP/1.1" 200 2651 0.0134
::1 - - [09/Sep/2018:10:13:27 JST] "POST /state HTTP/1.1" 200 2651
- -> /state

apply 時には, 一度, 状態を取得してからリソースを作成し, リソースの作成が完了した段階で状態を登録 (POST) するようです.

ここまでではめっちゃ簡単に HTTP Backend が動いたので拍子抜けしてしまいました.

destroy

作成したリソースを削除したいと思います.

$ terraform destroy -lock=false
Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

# アレ...
Destroy complete! Resources: 0 destroyed.

# 削除されたはずのリソースを確認
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
97ced319ed6e        b5e5ffb5cdea        "/bin/sh -c 'while t…"   5 minutes ago       Up 5 minutes                                 hoge-default

destroy を実行すると, 俺の HTTP バックエンド は以下のようなログを出力しています.

::1 - - [09/Sep/2018:10:18:57 +0900] "GET /state HTTP/1.1" 200 - 0.0007
::1 - - [09/Sep/2018:10:18:57 JST] "GET /state HTTP/1.1" 200 0
- -> /state
::1 - - [09/Sep/2018:10:19:01 +0900] "POST /state HTTP/1.1" 200 317 0.0006
::1 - - [09/Sep/2018:10:19:01 JST] "POST /state HTTP/1.1" 200 317
- -> /state

apply の時と同様に, 状態を取得してからリソースを削除し, 削除が完了した段階で改めて状態を登録しているようですが…なぜか, リソースが削除されていないようです.

リソースが削除されていないのは, そもそも状態のデータそのものがどこにも登録されていないことが原因のようです. 実際に curl を使ってエンドポイントにアクセスすると, 以下のように何もレスポンスが返ってきていません.

$ curl -XGET localhost:4567/state
$

ということで, 状態を保存して永続化する為のストレージを用意してあげる必要がありそうです.

永続化層の追加

Redis を利用する

ということで, 永続化層として状態を Redis に保存するような実装を俺の HTTP バックエンドに追加していきたいと思います.

require 'sinatra'
require 'redis'

redis = Redis.new host: '127.0.0.1', port: '6379'

get '/state' do
  body = redis.get('state')
  body
end

delete '/state' do
  redis.del('state')
end

post '/state' do
  body = request.body.read.to_s
  redis.set 'state', body
end

Redis は docker イメージを利用してコンテナとしてあげておきます.

改めて, Terraform の操作

apply

事前に Redis 側にデータが何も登録されていないことを確認しておきます.

127.0.0.1:6379> keys *
(empty list or set)

引き続き apply します.

$ terraform apply -lock=false

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:
...
docker_container.hoge: Creation complete after 0s (ID: 074f29992a2083ef7ee0c8abdfe05f55b9b63cbaa47566f83559231068a684f5)

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

# リソースが作成されていることを確認
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                    NAMES
074f29992a20        b5e5ffb5cdea        "/bin/sh -c 'while t…"   About a minute ago   Up About a minute                            hoge-default

Redis に状態が登録されていることを確認します.

127.0.0.1:6379> keys *
1) "state"
127.0.0.1:6379> get state
"{\n    \"version\": 3,\n    \"terraform_version\": \"0.11.8\",\n    \"serial\": 1,\n    \"lineage\": \"3b3166f1-2662-3c2b-b0fd-7c707170f7af\",\n    \"modules\": [\n        {\n            \"path\": [\n                \"root\"\n
 ],\n            \"outputs\": {},\n            \"resources\": {\n                \"docker_container.hoge\": {\n                    \"type\": \"docker_container\",\n                    \"depends_on\": [\n                        \"docker_image.centos\"\n                    ],\n                    \"primary\": {\n                        \"id\": \"2898f8b99470b977e6f215f16df5bc185c37fb8781b38f4671ce0ba1df8ee40c\",\n                        \"attributes\": {\n
          \"bridge\": \"\",\n                            \"command.#\": \"3\",\n                            \"command.0\": \"/bin/sh\",\n                            \"command.1\": \"-c\",\n                            \"command.2\": \"while true ; do sleep 1; hostname -s ; done\",\n                            \"gateway\": \"172.17.0.1\",\n                            \"hostname\": \"hoge-default\",\n                            \"id\": \"2898f8b99470b977e6f215f16df5bc185c37fb8781b38f4671ce0ba1df8ee40c\",\n                            \"image\": \"sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368\",\n                            \"ip_address\": \"172.17.0.3\",\n
\"ip_prefix_length\": \"16\",\n                            \"log_driver\": \"json-file\",\n                            \"must_run\": \"true\",\n                            \"name\": \"hoge-default\",\n                            \"restart\": \"no\"\n                        },\n                        \"meta\": {},\n                        \"tainted\": false\n                    },\n                    \"deposed\": [],\n                    \"provider\": \"provider.docker\"\n                },\n                \"docker_image.centos\": {\n                    \"type\": \"docker_image\",\n                    \"depends_on\": [],\n                    \"primary\": {\n                        \"id\": \"sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368centos:6\",\n                        \"attributes\": {\n                            \"id\": \"sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368centos:6\",\n
                           \"latest\": \"sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368\",\n                            \"name\": \"centos:6\"\n                        },\n                        \"meta\": {},\n
                      \"tainted\": false\n                    },\n                    \"deposed\": [],\n                    \"provider\": \"provider.docker\"\n                }\n            },\n            \"depends_on\": []\n        }\n
   ]\n}\n"
127.0.0.1:6379>

以下のように curl を利用してデータを JSON として取得することも出来ます.

$ curl -XGET localhost:4567/state
{
    "version": 3,
    "terraform_version": "0.11.8",
    "serial": 1,
    "lineage": "3b3166f1-2662-3c2b-b0fd-7c707170f7af",
    "modules": [
        {
            "path": [
                "root"
            ],
            "outputs": {},
            "resources": {
                "docker_container.hoge": {
                    "type": "docker_container",
                    "depends_on": [
                        "docker_image.centos"
                    ],
                    "primary": {
                        "id": "2898f8b99470b977e6f215f16df5bc185c37fb8781b38f4671ce0ba1df8ee40c",
                        "attributes": {
                            "bridge": "",
                            "command.#": "3",
                            "command.0": "/bin/sh",
                            "command.1": "-c",
                            "command.2": "while true ; do sleep 1; hostname -s ; done",
                            "gateway": "172.17.0.1",
                            "hostname": "hoge-default",
                            "id": "2898f8b99470b977e6f215f16df5bc185c37fb8781b38f4671ce0ba1df8ee40c",
                            "image": "sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368",
                            "ip_address": "172.17.0.3",
                            "ip_prefix_length": "16",
                            "log_driver": "json-file",
                            "must_run": "true",
                            "name": "hoge-default",
                            "restart": "no"
                        },
                        "meta": {},
                        "tainted": false
                    },
                    "deposed": [],
                    "provider": "provider.docker"
                },
                "docker_image.centos": {
                    "type": "docker_image",
                    "depends_on": [],
                    "primary": {
                        "id": "sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368centos:6",
                        "attributes": {
                            "id": "sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368centos:6",
                            "latest": "sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368",
                            "name": "centos:6"
                        },
                        "meta": {},
                        "tainted": false
                    },
                    "deposed": [],
                    "provider": "provider.docker"
                }
            },
            "depends_on": []
        }
    ]
}

state list, state show

以下のように state コマンドも実行してみます.

# state list を実行
$ terraform state list
docker_container.hoge
docker_image.centos

# state show を実行
$ terraform state show docker_image.centos
id     = sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368centos:6
latest = sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368
name   = centos:6

永続化された状態が取得出来ていることが判ります.

destroy

destroy も実行してみます.

$ terraform destroy -lock=false
docker_image.centos: Refreshing state... (ID: sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368centos:6)
docker_container.hoge: Refreshing state... (ID: 2898f8b99470b977e6f215f16df5bc185c37fb8781b38f4671ce0ba1df8ee40c)

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  - destroy
...


Destroy complete! Resources: 2 destroyed.

# リソースが削除されていることを確認
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES

Redis に登録されていた状態は以下のように変わって (更新されて) いることを確認します.

127.0.0.1:6379> get state
"{\n    \"version\": 3,\n    \"terraform_version\": \"0.11.8\",\n    \"serial\": 2,\n    \"lineage\": \"3b3166f1-2662-3c2b-b0fd-7c707170f7af\",\n    \"modules\": [\n        {\n            \"path\": [\n                \"root\"\n            ],\n            \"outputs\": {},\n            \"resources\": {},\n            \"depends_on\": []\n        }\n    ]\n}\n"
127.0.0.1:6379>

先程と同様に curl でも確認してみます.

$ curl -XGET localhost:4567/state
{
    "version": 3,
    "terraform_version": "0.11.8",
    "serial": 2,
    "lineage": "3b3166f1-2662-3c2b-b0fd-7c707170f7af",
    "modules": [
        {
            "path": [
                "root"
            ],
            "outputs": {},
            "resources": {},
            "depends_on": []
        }
    ]
}

合わせて state コマンドでも確認してみます.

$ terraform state show docker_image.centos
$ 

先程とは異なり状態の情報は出力されないようになりました.

ロックの実装

ロック処理

まずは, ロックの処理がどのように行われるかを見る為にロック用のエンドポイントを既存のコードに追加します.

post '/lock' do
  status 200
end

delete '/lock' do
  status 200
end

そして, main.tf についても, 以下のようにロック用の定義を追加します.

terraform {
  backend "http" {
    address        = "http://localhost:4567/state"
    lock_address   = "http://localhost:4567/lock"
    lock_method    = "POST"
    unlock_address = "http://localhost:4567/lock"
    unlock_method  = "DELETE"
  }
}

デフォルトでは LOCKUNLOCK メソッドが利用されますが, lock_methodunlock_method に任意のメソッドを定義することが出来ます.

この状態で Terraform の操作を行ってみたいと思います.

以下, plan 実行時の俺の HTTP バックエンドが出力するログです.

::1 - - [10/Sep/2018:00:36:59 +0900] "POST /lock HTTP/1.1" 200 - 0.0053
::1 - - [10/Sep/2018:00:36:59 JST] "POST /lock HTTP/1.1" 200 0
- -> /lock
::1 - - [10/Sep/2018:00:36:59 +0900] "GET /state HTTP/1.1" 200 318 0.0029
::1 - - [10/Sep/2018:00:36:59 JST] "GET /state HTTP/1.1" 200 318
- -> /state
::1 - - [10/Sep/2018:00:36:59 +0900] "DELETE /lock HTTP/1.1" 200 - 0.0060
::1 - - [10/Sep/2018:00:36:59 JST] "DELETE /lock HTTP/1.1" 200 0
- -> /lock

また, 以下は apply 実行時に出力されるログです.

::1 - - [10/Sep/2018:07:27:46 +0900] "POST /lock HTTP/1.1" 200 - 0.0253
::1 - - [10/Sep/2018:07:27:46 JST] "POST /lock HTTP/1.1" 200 0
- -> /lock
::1 - - [10/Sep/2018:07:27:46 +0900] "GET /state HTTP/1.1" 200 318 0.0171
::1 - - [10/Sep/2018:07:27:46 JST] "GET /state HTTP/1.1" 200 318
- -> /state
::1 - - [10/Sep/2018:07:29:00 +0900] "POST /state?ID=db1efcb5-0565-d31f-aa96-fe45b83327f1 HTTP/1.1" 200 2 0.0015
::1 - - [10/Sep/2018:07:29:00 JST] "POST /state?ID=db1efcb5-0565-d31f-aa96-fe45b83327f1 HTTP/1.1" 200 2
- -> /state?ID=db1efcb5-0565-d31f-aa96-fe45b83327f1
::1 - - [10/Sep/2018:07:29:00 +0900] "DELETE /lock HTTP/1.1" 200 - 0.0004
::1 - - [10/Sep/2018:07:29:00 JST] "DELETE /lock HTTP/1.1" 200 0
- -> /lock

ログを見る限りだと, ロック処理は以下のような挙動となるようです.

  • plan の時

1.ロックする
2.状態を取得
3.1 をアンロックする

  • apply 及び, destroy の時

1.ロックする
2.状態を取得する
3.新しい状態で更新する (パラメータにロック ID を付与している)
4.1 をアンロックする

複雑なことやってるんだろうなあと思っていたんですが, 意外にシンプルな感じなので驚きました.

ロック処理を追加したアプリケーション

驚いたところで, ロック処理を追加した俺の HTTP バックエンドは以下のようになりました.

require 'sinatra'
require 'redis'

redis = Redis.new host: '127.0.0.1', port: '6379'

get '/state' do
  state = redis.get('state')
  state 
end

delete '/state' do
  redis.del('state')
end

post '/state' do
  @lock_id = params[:ID]
  if @lock_id
    lock_id = JSON.parse(redis.get('lock'))['ID']
    # Conflict
    halt 409 unless @lock_id == lock_id
  end

  body = request.body.read.to_s
  redis.set 'state', body
end

post '/lock' do
  has_lock_key = redis.exists('lock')
  # Locked
  halt 423 if has_lock_key

  body = request.body.read.to_s
  redis.set 'lock', body
  status 200
end

delete '/lock' do
  redis.del('lock')
  status 200
end

これを起動して簡単に動作確認してみたいと思います.

改めてロック処理の確認

destroy 時にリソースを本当に削除して良いかというメッセージが出力され, yes 又は no を入力しなければいけない状態で…

$ terraform destroy
docker_image.centos: Refreshing state... (ID: sha256:b5e5ffb5cdea24c637511e05e1bbe2c92207ae954559d4c7b32e36433035c368centos:6)
docker_container.hoge: Refreshing state... (ID: 0f39fe1210a431706da1247d3df69750a0d0d6d828004134ab07ce1a42f184b6)

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  - docker_container.hoge

  - docker_image.centos


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

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value:

別の端末から plan を実行してみます.

$ terraform plan

Error: Error locking state: Error acquiring the state lock: HTTP remote state already locked, failed to unmarshal body

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.

ロックされている旨のメッセージが出力されて plan すら失敗します. また, この時に俺の HTTP バックエンド のログを見ると以下のようにステータスコード 423 が返ってきます.

::1 - - [10/Sep/2018:08:13:23 +0900] "POST /lock HTTP/1.1" 423 - 0.0017
::1 - - [10/Sep/2018:08:13:23 JST] "POST /lock HTTP/1.1" 423 0
- -> /lock

また, このタイミングでロック用のエンドポイントに curl を使ってアクセスすると, 以下のような JSON が登録されていることを確認出来ます.

$ curl -s -XGET localhost:4567/lock | jq .
{
  "ID": "c50961ec-b0b4-9d6f-d439-4cc10688356a",
  "Operation": "OperationTypeApply",
  "Info": "",
  "Who": "ahokappa",
  "Version": "0.11.8",
  "Created": "2018-09-09T23:11:42.524759374Z",
  "Path": ""
}

destroy しようとしているのに OperationOperationTypeApply となっているのに若干の違和感を感じますが, ロック ID 等が情報として登録されていることが判ります.

現場からは以上です

ということで, 今回は超スーパーウルトラ簡単な HTTP バックエンドを実装して, 状態がどのように保存されるか, ロックの制御がどのように行われるかを研究してみましたが, 意外にも状態の保存もロックの制御もシンプルに実装されていることが判りました.

ということで, 引き続き, Terraform について研究を続けていきたいと思います.

元記事はこちら

独り Terraform 研究所 (2) 〜 簡単な HTTP Backend を実装して Terraform の挙動を確認する 〜