Split main.tf into modules, my server was dead

このサイトが動いているサーバはDigitalOceanで、terraformで管理しています。

最近、main.tfを見ていて見づらいなと感じたので(行数は100行もないけれど大半が変更しない行だしいつも目で飛ばしてる行だった。)moduleを使ってスッキリさせようと思い立ちました。

結果、main.tfが大分すっきりしました。

variable "default_key" {}  
variable "mailgun_key" {}

module "droplet" {  
  source = "./modules/digitalocean/droplet"
  default_key = "${var.default_key}"
}

module "networking" {  
  source = "./modules/digitalocean/networking"
  mailgun_key = "{var.mailgun_key}"
}

モジュールについてのメモ

モジュールは2個で、見ての通りdropletを作成するモジュールと、ネットワーク(DNSとfloating ip)の設定をするモジュールに分けました。単純ですね。

ディレクトリツリーは以下のような感じです。hashicorp/best-practicesになんとなーく則っています。

terraform  
├── README.md
├── cloud-config.yml
├── main.tf
└── modules
    └── digitalocean
        ├── droplet
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        └── networking
            ├── main.tf
            ├── outputs.tf
            └── variables.tf

各モジュールの構成はterraform-community-modulesを真似しました。以下のような役割があります

  • main.tf: リソースを書くファイル
  • outputs.tf: 他のtfファイルに渡したいリソースの変数を定義するファイル
  • variables.tf: main.tfで使う変数を定義するファイル

具体的にdropletモジュールの中身を見てみます。

droplet/main.tf

# Create a new SSH Key
resource "digitalocean_ssh_key" "default" {  
    name       = "Default Key"
    public_key = "${var.default_key}"
}

# Create a main droplet
resource "digitalocean_droplet" "main" {  
    image              = "${var.image}"
    name               = "${var.droplet_name}"
    region             = "${var.region}"
    size               = "${var.size}"
    ssh_keys           = ["${digitalocean_ssh_key.default.id}"]
    user_data          = "${file("cloud-config.yml")}"
    private_networking = true
    backups            = true
}

droplet/outputs.tf

output "droplet_id" {  
  value = "${digitalocean_droplet.main.id}"
}

output "droplet_region" {  
  value = "${digitalocean_droplet.main.region}"
}

output "droplet_ip" {  
  value = "${digitalocean_droplet.main.ipv4_address}"
}

droplet/variables.tf

variable "default_key" {}

variable "droplet_name" {  
  default = "ponpokopon.me"
}

variable "image" {  
  default = "centos-7-0-x64"
}

variable "region" {  
  default = "sgp1"
}

variable "size" {  
  default = "512mb"
}

なるべく変数にできるものは変数化しておくとモジュールらしく再利用性が高まっていい感じです。variables.tfでデフォルト値が設定されていないものは(droplet名もデフォルト値無しの変数にすべきですね)、環境変数やterraform.tfvarsを用意して使うことを想定しています。

ここでモジュールを呼び出す側のtfファイルを見ると、「なぜモジュール内で定義している変数(variable "default_key" {}など)を再度定義しているんだろう?」と思われるかもしれません。

variable "default_key" {}  
variable "mailgun_key" {}

module "droplet" {  
  source = "./modules/digitalocean/droplet"
  default_key = "${var.default_key}"
}

module "networking" {  
  source = "./modules/digitalocean/networking"
  mailgun_key = "{var.mailgun_key}"
}

ここは結構ハマってしまった所で、どうやら環境変数やterraform.tfvarsを利用する変数の場合は、モジュール呼び出し側のtfファイルでも定義しなければならないみたいです。ドキュメントのどこかにそのような性質を示す記述があるのかもしれませんが、見つけられていないです。

自分の勝手な解釈ですが、「環境変数やtfvars(要はtf以外の拡張子のファイル)はモジュール呼び出し側(terraform apply実行ディレクトリ)のものが参照される」ので、その関係でこのような書き方をしなければならないのかなと解釈しています。

cloud-config.ymlも同じくapply実行ディレクトリ以下に無いと参照されません。

moduleを作成し終わったら、terraform getでモジュールを読み込みます。モジュールはディレクトリpath以外に、github等のurlも参照できるみたいです。getすると、カレントディレクトリの.terraform/modules以下に名前がハッシュ値のシンボリックリンクが生成されモジュールのソースが参照されるようになります。

ほかには、

  • module 'hoge' {} だとparseエラー。module "hoge" {}じゃないとだめっぽい
  • モジュール間の変数のやり取りはregion = "${droplet_region}"みたいに書く

なところにちょっとハマりました。

モジュールについての感想

モジュール化は楽しいです。うまく使えば実行tfファイルがかなりシンプルになるし、色々使いまわせそうです。

このサイトのドメインの移行を考えていて、移行のためdroplet名やネットワーク設定が微妙に違う環境を立ち上げる際に役立ちそうです。

今回作った程度のモジュールなら公開されたものがあると思いますが、俺が書きたかったんだ!!

さっそくterraform planを実行してみると...

全てdestroyされ作り直させるプランが提示された!!!!!!!なんでじゃ!!!!!!!!

この時会社に居たのでちょうど近くに居たテラフォーマーに相談した所、モジュールは別リソース扱いになるのではという話をして超納得しました。

こりゃヤバイってことで一旦masterブランチに戻りました。念のためterraform planしたら...差分が出るようになってしまった!!しかもdropletの!

ナンデ!?terraform 0.7からplanでtfstateは書き換えなくなったはずでは...!?

The terraform plan command no longer persists state. This makes the command much safer to run, since it is now side-effect free. The refresh and apply commands still persist state to local and remote storage.

terraform/CHANGELOG.md at master · hashicorp/terraform · GitHub

tfstateを手で書き換える秘術に挑戦しようかと思ったけれど、当然リモートとローカル両方のtfstateが書き換わってしまっていたので断念。

もはやこれまで。熱狂的再征服レコンキスタするしか無い!!

以下バックアップ。

  • 既存dropletのスナップショットを作成
  • ghostディレクトリ丸ごと固めてダウンロード
  • ghostのdbをdumpしてダウンロード
  • /var/lib/mackerel-agent/idをダウンロード(新環境でも同一ホストとして認識してほしいので必要)

破壊

渾身のterraform apply。死んだ。

  • なんかfloaring ipが使えない
    • ip枯渇してるから制限してるよと言われる(Due to limited availability we have temporarily disabled IP reservation in this region.)
    • でも管理画面からポチポチすると普通に付与される
    • terraformのタイムアウトな気もしないでもない(ろくに待たずエラー吐くので)
    • digitalocanがapiで使えないようにする制限をかけているとかもありうる?
  • 仕方がないのでfloating ipはやめてdropletのipをリソースの変数で指定したんだけどこれもエラー
    • 422 IP address did not match IPv4 format (e.g. 127.0.0.1)
    • api側でちゃんとした形式でpostしろやってエラーなのでterraform側で変な送り方しちゃってるのか...も?
  • 仕方がないので作成されたdropletのipアドレス確認して直打ち

既に息切れ気味でansibleのplaybook流す。いい感じに見えたがghostが起動しない。ついでにh2oも起動しない。

  • ghostが起動しないのは、新しすぎるnodejsがインストールされてしまったためだった
    • 後でわかったんだけど、nodejs用に入れたリポジトリじゃなくて、epelのリポジトリが使われてしまったのが原因だった
  • h2oが起動しないのは、証明書のpathに証明書が無くて起動失敗してた
    • 証明書取得はletsencryptを使っている
    • でもletsencrypt叩くためにはWebサーバが必要
    • でも証明書ないから設定不備でWebサーバ立たない
    • 無限ループ
    • 解としてはスタンドアロンモードで証明書を取得しておいてからh2oを立ち上げるようなプレイブックを書けば0から立ち上げられそう

もうだめだ。

というわけでいったんスナップショットからdroplet立ち上げてそいつにdnsを切り替えて一時しのぎ。

創造

さて、これで見てくれは復旧したので新環境のセットアップを頑張る。terraformはとりあえずapplyできるようになったのでnodejsのplaybookを直した。

もうここまで来たらnodejsのバージョンを4.x系にあげちゃう(今まで0.10系だった)。ghost的にも4.xがレコメンドされてるし!

nodejsのプレイブックを修正して無事playbook実行できた。dbのdumpを注入して、tarで固めておいたghostも復元。

お次は手動でh2oの設定を書き換えて、証明書を取得する上で必要なletsencryptからの外部アクセスを受けられるようにする。

と ここで気づいたんだけど、dns切り替えないとletsencryptアクセス出来ないじゃん!

泣きながら未完成のサーバにdnsを向けて、letsencryptコマンド叩いて証明書取得。h2oの設定元に戻して再起動。

ようやくghost.ponpokopon.meにアクセスできた!けどなんか文字化けしてる;;

データベースをmysqlからmariadbに変えるときには文字化けして文字コードいじったりしたけど、全く同じ環境(なはず)からのdumpで文字化けするか!?普通!?!?

呆然としながらmariadb再起動したらなんか直った。はえええ???

ともかくこれで全部復元された。

というのが今回main.tfをいい感じにしようという純朴な思いが産んだ悲劇の顛末です。

可用性ああああああああああああ

http://status.ponpokopon.me/

まとめ

  • 既存のterraform資産をmodule化するときは慎重にやる
  • terraform 0.7.0以降のplan安全神話ェ…
    • 今回複数台のMac跨いで作業していて、全部terraform 0.7.x系だったんだけど、マイナーバージョンが違かったのでそれが原因かも試練
  • 構成管理のコード化を頑張ってるつもりでしたが、なんだかんだ手作業が必要になってしまって悔しい
  • ほんこれ ↓

直近のやること

  • nodejs周りの失敗は、0からプロビジョニングする機会が無かったせいなので、ciする(今は空のwerker.ymlだけ用意してある)
    • vagrant使ってるけど毎回作り直しはしないし...
  • ていうか〜 ciの前にテストも書かなきゃだめじゃん
    • ansible使っているのでansible-spec使う予定(今はGemfileには書かれている)
  • terraformで起きたトラブルを調べる
    • 見当つけた所についてコード読む