【さくらのクラウド】アーカイブを利用したバックアップ取得

平成30年北海道胆振東部地震で被災された皆さまに謹んでお見舞い申し上げます。

今回の震災では石狩リージョン(石狩第1/第2ゾーン)において非常用発電機設備を起動・停止するための制御回路の故障により専用サーバの一部に影響が出ていたとのことですが、 クラウドをはじめとしたその他のサービスには影響は出なかった模様です。
とはいえ一時は石狩データセンターの自家発電設備により稼働しているという状態になっていました。

support.sakura.ad.jp

現在は北海道電力からの電力供給が復旧したとのことですが、仮に非常用発電設備の燃料供給が滞ったり商用電源の復旧が遅れた場合はサービスの停止といった影響が出ていた可能性もあります。

なので、これを機に利用しているクラウド上のサービスのBCP/DRといった運用について再確認をされる方も多いと思います。

この記事ではその一環として、さくらのクラウド上で稼働しているサーバのディスクを ローカルマシンや石狩以外のサーバにバックアップするための方法をご紹介いたします。

バックアップ方法あれこれ

さくらのクラウドにてディスクのバックアップを取得するにはいくつかの方法があります。

サーバ内のデータ領域などを自前でバックアップする方法については定期的にデータベースのダンプを取得する/静的ファイルは冗長化されたストレージに格納するなど様々な方法がありますが、環境に応じて方法が異なり一概にどの方法が良いとは言いにくい面があります。 このため、この記事では比較的汎用的に適用できる、2番目のさくらのクラウドが提供しているアーカイブ機能を中心に扱います。

アーカイブ機能

さくらのクラウドでのアーカイブ機能とは、仮想ディスクからバックアップを作成する機能 のことを指します。

knowledge.sakura.ad.jp

現在稼働中のサーバのディスクのイメージバックアップを取得する機能ですね。

取得したアーカイブはサーバ作成時のソースとして用いる他に、FTPSを用いてダウンロードしたり、他ゾーンにコピーしたりすることができます。

また、アーカイブはサイズと保存日数により課金されるようになっています。

2018/09時点でのアーカイブ価格

f:id:febc_yamamoto:20180907124313p:plain

アーカイブをどこに保存するか

取得したアーカイブの保存先候補としては大きく分けると以下のようなものがあります。

  • 作成元ディスクと同一ゾーン内に保存
  • 他ゾーンにコピーして保存
  • ローカルマシンなどにダウンロードして保存

それぞれに一長一短があります。

保存先 長所 短所
同一ゾーン - コピー\転送の時間がないので高速
- 後述する自動バックアップ機能が利用可能
- ゾーン\リージョン障害の場合に利用できない可能性
他ゾーンにコピー - ゾーン\リージョン障害時に影響を受けにくい - 作成時にコピー\転送分の時間がかかる
- 作成はコントロールパネルから行う必要がある(API非対応)
ローカルマシンなどにダウンロード - プラットフォーム障害の影響を受けにくい
- ダウンロード後にアーカイブを削除することでコストを抑えられる
- ダウンロードに時間がかかる
- 復元時に再度アップロードする時間がかかる

実際にはそれぞれをバランスよく組み合わせて利用することになるかと思います。

次に保存先ごとにアーカイブ取得方法について見ていきます。

アーカイブ取得方法

作成元ディスクと同一ゾーンに保存

最も単純な方法です。コントロールパネルやCLIから作成元ディスクを指定してアーカイブを作成します。

コントロールパネルの場合

アーカイブ作成画面にて作成元ディスクを指定して作成します。

f:id:febc_yamamoto:20180907124336p:plain

この方法は自動バックアップというさくらのクラウドの機能を利用して自動化することも可能です。 詳細は以下のドキュメントを参照してください。

manual.sakura.ad.jp

CLIの場合

さくらのクラウド CLI Usacloudでも取得可能です。 Usacloudのセットアップ(APIキー設定など)をあらかじめ行っておけば以下のように取得できます。

# アーカイブ作成
$ usacloud archive create --source-disk-id <ディスクID> --name <作成するアーカイブの名称>

他ゾーンにコピーして保存

次に他ゾーンにコピーする方法です。
ディスクのアーカイブ作成は同一ゾーンに対してのみ行えるようになっています。
このため、まずは 作成元ディスクと同一ゾーンに保存 の手順でアーカイブを作成し、その後そのアーカイブを他ゾーンにコピーするという手順になります。

なお、アーカイブを他ゾーンにコピーする方法はコントロールパネルでのみ可能です。APICLIからは実行できませんのでご注意ください。

作成自体は簡単で、アーカイブ作成画面にてコピー元アーカイブのゾーンとアーカイブを選択して作成するだけです。

f:id:febc_yamamoto:20180907124635p:plain

ただ、一時的なものだと思いますが、他ゾーンにコピーする処理が混雑しているのか、石狩->東京への転送が503エラーとなることがありました。

f:id:febc_yamamoto:20180907124431p:plain

この場合、少し時間を置いて試すか、次に紹介する ローカルマシンなどにダウンロードして保存 の方法を試すのが良さそうです。

ローカルマシンなどにダウンロードして保存

さくらのクラウドアーカイブはFTPSによるダウンロードが行えるようになっています。

コントロールパネルやAPIでダウンロードを指示するとFTPS接続情報(ホスト名/ユーザー名/パスワードなど)が発行されます。 これを用いてwinSCPFileZillaなどのFTPクライアントツールでFTPSによるダウンロードを行います。

ダウンロードするアーカイブはあらかじめ作成しておく必要がありますので、まずは 作成元ディスクと同一ゾーンに保存 の手順でアーカイブを作成しておきます。

コントロールパネルの場合

保存したアーカイブの詳細画面の右上にFTPを開始というボタンがあります。

f:id:febc_yamamoto:20180907130005p:plain

これをクリックするとFTPS接続情報が表示されます。

f:id:febc_yamamoto:20180907130023p:plain

この情報を用いてFTPクライアントツールでダウンロードを行います。 ダウンロード完了後は FTPを完了ボタンでFTPS接続情報を無効化しておきましょう。

CLIの場合

UsacloudはFTPSクライアント機能を持っています。以下のようにすればアーカイブのダウンロードが可能です。

# この例では /tmp/archive.rawに保存
$ usacloud archive download --file-destination /tmp/archive.raw <アーカイブのID or 名称>

標準入力への出力も対応しているため、そのままgzipに渡して圧縮も可能です。

$ usacloud archive download -y <アーカイブのID or 名称> | gzip -c > /tmp/archive.raw

参考までに、アーカイブ取得〜ダウンロード〜gzip圧縮までを行うシェルスクリプトを掲載しておきます。

#!/bin/bash

# 対象ディスクの名称を設定
SOURCE_DISK_NAME=<ディスク名>
# ゾーン
ZONE=<対象ゾーン(is1a or is1b or tk1a)>

# =====================================

# ディスク名からIDに変換
SOURCE_DISK_ID=`usacloud --zone $ZONE disk read -q ${SOURCE_DISK_NAME}`
if [ -z "$SOURCE_DISK_ID" ]; then
  echo "Disk '${SOURCE_DISK_NAME} is not found'
  exit
fi

# アーカイブ作成
ARCHIVE_ID=`usacloud --zone $ZONE archive create -y -q --source-disk-id=${SOURCE_DISK_ID} --name ${SOURCE_DISK_NAME}`

# ダウンロード & gzip
usacloud --zone $ZONE archive download -y $ARCHIVE_ID | gzip -c > /tmp/${ARCHIVE_ID}.raw.gz

# アーカイブ削除
$ usacloud archive delete -y ${ARCHIVE_ID}

オプション: usacloud+curlでのダウンロード

以下の記事にあるようにcurlコマンドを利用することも可能です。

qiita.com

Usacloudと併用すると以下のような感じになります。

# アーカイブのID
ARCHIVE_ID=<アーカイブのID>
# ftp-openコマンドでFTP接続情報を取得、--formatオプションでcurlコマンドを作りevalする
eval $(usacloud archive ftp-open -y --format 'curl -u {{.User}}:{{.Password}} --ftp-ssl --ftp-pasv -k --compressed ftp://{{.HostName}}:21/archive.img -o /tmp/${ARCHIVE_ID}.raw' ${ARCHIVE_ID})

# 終わったらFTPクローズして削除
$ usacloud archive ftp-close -y ${ARCHIVE_ID}
$ usacloud archive delete -y ${ARCHIVE_ID}

UsacloudのFTPS処理はあまり最適化してない(すいません…)のでこちらの方が早いかも?と思い試してみましたがあまり変わらずでした。
ちゃんと試したら違いがあるかもしれませんが今回はそこまで調べていません。また機会があれば試してみます。

組み合わせ

アーカイブ取得の各方法は単独で利用するのではなく組み合わせて利用することが多いと思います。
普段のバックアップとして自動バックアップを用い、予算に応じて他ゾーンへのコピーやローカルマシンへのダウンロードなどを組み合わせるといった具合です。

各環境に最適な方法をご検討ください。

おまけ:その他のバックアップツール

ググって見たらバックアップツールはUsacloud以外にもちょいちょいありました。

qiita.com

itexp.hateblo.jp

まとめ

この記事では様々なアーカイブ取得方法について紹介しました。

障害はどんなプラットフォームでも避けて通れないものです。 普段から障害を考慮した設計、バックアップ、訓練を行っておきたいものですね。

障害に負けずがんばっていきましょう!!

おまけ: コミュニティによるサポート

さくらのクラウドのユーザーコミュニティSacloudにはSlackワークスペースがあります。

ツールの使い方をはじめとしたQAなどを行えますので、お困りの際はぜひご参加ください。

さくらのクラウド ユーザーコミュニティSacloudのSlackワークスペース

https://slack.usacloud.jp

以上です。

Terraform for さくらのクラウド 最新情報(2018年10月版)

本日Terraform for さくらのクラウド v1.7をリリースしました。

github.com

Terraform for さくらのクラウドはこまめなバージョンアップを行なっており、最新情報を追いきれていない方もいらっしゃるかと思います。
そこで今回は最近追加された機能のサマリーとして影響の大きな変更や新規追加された機能などをピックアップしてご紹介します。

最近追加された/変更された主な機能一覧

最近(直近3ヶ月くらい)で追加/変更された大きなものとして以下のようなものがあります。

サーバ/ディスク関連
アプライアンス関連
その他

他にも細々した変更も行われています。すべての変更内容はCHANGELOG.mdにまとめられています。

順番にご紹介していきます。

サーバ/ディスク関連

サーバ/ディスク新プラン対応

2016年7月から新規申し込み受付を停止していた石狩第1ゾーンが先日再び利用できるようになりました。

www.sakura.ad.jp

これにより石狩第1/石狩第2/東京第1と3つのゾーンが再び利用可能になり、ゾーンをまたぐ冗長構成の構築が容易になりました。

また、これに伴いサーバ/ディスクプランも新しくなりました。

manual.sakura.ad.jp

従来プランと比較して値下げされているほか、大容量ディスク(最大4TB)が利用できるようになりました。

Terraform for さくらのクラウドではv1.7以降で新プランに対応しており、v1.7以降でサーバを作成すると自動的に新プランでサーバ作成されるようになっています。

1点ご注意いただきたいのが、Terraform for さくらのクラウドでは旧プランの利用はサポートしていないという点です。

これまで石狩第1ゾーンを利用していた方(2016/7以前から利用しており、かつ石狩第1ゾーンにリソースを作成していた)に限り引き続き旧プランも利用可能なのですが、 Terraform for さくらのクラウドでは新プランにのみ対応しています。 もしTerraformでも旧プランを利用したいという方がいらっしゃいましたらGitHubなどからご要望ください。

ディスクの修正関連のパラメータをサーバリソースに移動

これまでディスクの修正機能で設定する項目についてはディスクリソースにて指定するようになっていました。

これまでのディスクの修正関連パラメータの指定例

data sakuracloud_archive "ubuntu" {
  os_type = "ubuntu"
}

# ディスクリソース
resource sakuracloud_disk "foobar" {
  name = "mydisk"
  source_archive_id = "${data.sakuracloud_archive.ubuntu.id}"

  # ディスクの修正関連のパラメータ 
  hostname = "myserver"
  password = "p@ssw0rd"
  ssh_key_ids = ["100000000000", "200000000000"]
  disable_pw_auth = true
  note_ids = ["100000000000", "200000000000"]

}

# サーバリソース
resource sakuracloud_server "foobar" {
  name = "myserver"
  disks = ["${sakuracloud_disk.foobar.id}"]
}

ホスト名やパスワードといった項目はサーバではなくディスクに紐づいていることからディスクリソース内で設定を行うようにしていましたが、 少々直感的ではなく、どこで設定すべきか迷うことがありました。

また、一部の項目(主にネットワーク関連)についてはディスクとサーバとを接続した後に改めてディスクの修正APIを呼ぶ必要があり、 これまでもディスクリソース/サーバリソースの両方でディスクの修正APIを呼び出していました。

これらの改善のため、ディスクの修正関連パラメータはサーバリソース側で指定するように変更されました。

変更後のディスクの修正関連パラメータの指定例

data sakuracloud_archive "ubuntu" {
  os_type = "ubuntu"
}

# ディスクリソース
resource sakuracloud_disk "foobar" {
  name = "mydisk"
  source_archive_id = "${data.sakuracloud_archive.ubuntu.id}"
}

# サーバリソース
resource sakuracloud_server "foobar" {
  name = "myserver"
  disks = ["${sakuracloud_disk.foobar.id}"]

  # ディスクの修正関連のパラメータ 
  hostname = "myserver"
  password = "p@ssw0rd"
  ssh_key_ids = ["100000000000", "200000000000"]
  disable_pw_auth = true
  note_ids = ["100000000000", "200000000000"]
}

なお、最新版のv1.7では従来通りディスクリソースで指定する方法も引き続きサポートされています。
ただし非推奨となっており将来のバージョンで指定できなくなる予定ですので早めの移行をお勧めします。

移行はtfファイル上に記載しているディスクの修正関連のパラメータをコピペでサーバリソース側に移動するだけでOKです。 (terrafory applyするタイミングで再度ディスクの修正が行われます。)

アプライアンス関連

続いてアプライアンス関連についてです。

石狩第1ゾーンでの利用可能リソース拡大

これまで石狩第1ゾーンではデータベースアプライアンスやモバイルゲートウェイの利用はできませんでした。
しかし先ほども紹介した石狩第1ゾーンの受付再開に伴いこれらのリソースが利用可能となりました。

これにより、ほぼ全てのリソースが全ゾーンで利用可能な状態となりました。
例外は以下2つです。

  • 専有ホスト(東京第1/石狩第2でのみ利用可能)
  • 自動バックアップ(現在、石狩第1での受付は停止中)

Terraform for さくらのクラウドではv1.7以降で石狩第1ゾーンを指定可能となっています。

モバイルゲートウェイでのトラフィックコントロール機能

SIMからさくらのクラウドへ直結することでデバイスのセキュアな通信を行えるようにする「セキュアモバイルコネクト」という機能があります。

www.sakura.ad.jp

専用のSIM(Amazonなどで購入可能)を監視カメラやドライブレコーダーといったSIMのさせるデバイスで利用することで、インターネットを経由することなくさくらのクラウドへ直結させることができるようになります。

セキュアモバイルコネクトを利用するにはSIMを購入することに加え、さくらのクラウド側にSIMとの通知の受け口となる「モバイルゲートウェイ」を作成/設定する必要があります。 モバイルゲートウェイは月額6480円/台、SIM一枚あたり月額12円の基本利用料、通信量課金として1MBあたり6円となっています。 (モバイルゲートウェイあたり1万枚のSIMが登録可能、またモバイルゲートウェイには毎月500MBまでのデータ通信量が含まれている)

Terraform for さくらのクラウドではSIMの登録、モバイルゲートウェイの作成/設定、SIMとモバイルゲートウェイの紐付けが行えるようになっています。

SIM/モバイルゲートウェイを利用する例

# SIMの定義
resource sakuracloud_sim "sim" {
  name              = "example-sim"
  iccid             = "<SIMに記載されているICCID>"
  passcode          = "<SIMに記載されているPasscode>"
  imei              = "<端末識別番号(IMEIロックする場合のみ)>"
  #enabled          = true
  mobile_gateway_id = "${sakuracloud_mobile_gateway.mgw.id}" # 接続するモバイルゲートウェイのID
  ipaddress         = "192.168.100.2"                        # SIMに割り当てるIPアドレス        
}

# モバイルゲートウェイの定義
resource sakuracloud_mobile_gateway "mgw" {
  name        = "example-mobile-gateway"
  dns_server1 = "8.8.8.8" # DNSサーバ1
  dns_server2 = "8.8.4.4" # DNSサーバ2
}

モバイルゲートウェイでのトラフィックコントロール機能

今月初めにモバイルゲートウェイに「トラフィックコントロール」機能が追加されました。

cloud-news.sakura.ad.jp

これは指定した通信量を超えたら速度制限を実施するというようなことができる機能です。

これもTerraformから設定できるようになっています。

# モバイルゲートウェイの定義
resource sakuracloud_mobile_gateway "mgw" {
  name        = "example-mobile-gateway"
  dns_server1 = "8.8.8.8" # DNSサーバ1
  dns_server2 = "8.8.4.4" # DNSサーバ2
  
  # トラフィックコントロール
  traffic_control = {
    quota                = 256  # 通信量閾値(MB)
    auto_traffic_shaping = true # 帯域制限 有効/無効
    band_width_limit     = 64   # 帯域制限値(Kbps)
    enable_email         = true # Emailでの通知
    enable_slack         = true # Slackでの通知
    slack_webhook        = "https://hooks.slack.com/services/xxx/xxx/xxx"
  }
}

データベースアプライアンスでのリードレプリカ機能

データベースアプライアンスにてレプリケーションの設定が行えるようになりました。

cloud-news.sakura.ad.jp

現在はPostgreSQLでのリードレプリカ(読み取り専用のスレーブデータベース)の作成がサポートされています。

リードレプリカの作成例

# マスターとなるデータベースアプライアンスの定義
resource sakuracloud_database "master" {
  database_type = "postgresql" # 現在、リードレプリカ作成はPostgreSQLのみ対応
  plan          = "10g"
  user_name     = "defuser"
  user_password = "<パスワード>"
  
  # レプリケーション用ユーザーのパスワードを指定することでレプリケーションが有効になる
  replica_password = "<パスワード>" 

  switch_id     = "${sakuracloud_switch.sw.id}"
  ipaddress1    = "192.168.11.101"
  nw_mask_len   = 24
  default_route = "192.168.11.1"

  name        = "master"
}

# リードレプリカの定義
resource sakuracloud_database_read_replica "foobar" {
   
  master_id  = "${sakuracloud_database.foobar.id}" # マスター側データベースのID
  ipaddress1 = "192.168.11.111"                    # IPアドレス
  
  # IPアドレス以外のネットワーク関連項目が未指定の場合、マスター側から引き継ぐ
  #switch_id     = "${sakuracloud_switch.sw.id}"
  #nw_mask_len   = 24
  #default_route = "192.168.11.1"  

  name = "slave"
}

マスター側でレプリケーションを有効にしておく必要がある点に注意が必要です。
レプリケーションを有効にする(かつレプリケーションのマスターとして構成する)には、マスター側でレプリケーション用ユーザーのパスワードを指定すればOKです。
(replica_passwordという項目です)

リードレプリカ側ではマスターとなるデータベースアプライアンスのIDを指定します。
IPアドレス以外のネットワーク関連の設定(接続するスイッチやネットワークマスク長、デフォルトルート)は指定しなかった場合はマスター側と同じ値が設定されます。

当然ですがマスター側とネットワーク的に疎通できるようなネットワーク構成となるようにしてください。

シンプル監視でのBasic認証

シンプル監視にてHTTP/HTTPSでの監視時にBasic認証が行えるようになりました。

cloud-news.sakura.ad.jp

以下のように利用します。

# HTTP監視の例
resource sakuracloud_simple_monitor "mymonitor" {
  target = "<監視対象のFQDN or IPアドレス>"

  health_check = {
    protocol   = "http"
    delay_loop = 60
    path       = "/"
    status     = "200"
    
    username   = "foo" # Basic認証 ユーザー名
    password   = "bar" # Basic認証 パスワード
  }

  notify_email_enabled = true
}

その他: Terraform公式サイトのスタイルを用いて英語版ドキュメントをプレビュー

Terraform for さくらのクラウドでは英語版ドキュメントを用意しています。
英語版ドキュメントについてはTerraform公式サイト(terraform.io)と同じスタイルで確認できる仕組みを用意しています。

gitとdockerが利用できればGitHubからソース一式を取得してmake serve-english-docsコマンドを実行することでプレビューが行えます。

# GitHubからソース一式をクローン
$ git clone https://github.com/sacloud/terraform-provider-sakuracloud.git
$ cd terraform-provider-sakuracloud

# プレビュー
# make serve-english-docs

この後http://localhost:4567/docs/providers/sakuracloudをブラウザなどで開くことでプレビューできます。

f:id:febc_yamamoto:20181017201559p:plain

終わりに

ということで、今回は最近のTerraform for さくらのクラウドで追加/変更となった機能を紹介しました。
もしご要望や不具合の報告などありましたらぜひGitHubにておしらせください。

以上です。

Rio v0.0.3はKubernetes/Istioをどう利用しているの?

Rio v0.0.3がリリースされましたね。

github.com

以前書いたRioの記事ではスタンドアロン版を試しましたので、 今回は既存KubernetesクラスタとしてDocker for Macを利用してRioを試してみようと思います。

前回と重複する内容も含まれますが、今回はRioKubernetes/Istioをどう利用しているのかといった点を中心にみていきたいと思います。
(前回の記事でIstio周りもうちょい詳しく知りたかったという声もあったような気がしますので)

前回の記事(スタンドアロン版のRio)

febc-yamamoto.hatenablog.jp

必要なもの

おそらくDocker for Windowsでも動くと思いますが手元に環境がないため今回は試してません。

また、大事な注意点として、Rio v0.0.3時点では簡単にアンインストールする手段は提供されていません!!

Docker for Macをファクトリーリセットするか地道に手で一つ一つ消していくくらいしかアンインストール方法がないため、お手元で試される方はご注意ください。


<宣伝>
クラウド上に使い捨てKubernetesクラスタを建てる場合はTerraform+RKEがおすすめです。

github.com

Rioのインストール(Mac)

まずは手元のMacRioをインストールしておきます。
RioはGoで書かれたシングルバイナリですのでダウンロード&展開&PATHの通った場所に配置すればOKです。

#rio v0.0.3のダウンロード
$ curl -L -o rio.tgz https://github.com/rancher/rio/releases/download/v0.0.3/rio-v0.0.3-darwin.tar.gz

#展開
$ tar zxvf rio.tgz 

#PATHの通った場所に移動
$ sudo mv rio-v0.0.3-darwin/rio /usr/local/bin/

#確認
$ rio -v

# "rio version v0.0.3" と表示されればOK

Rioサーバ側コンポーネントのデプロイ

Rioスタンドアロン版の場合はrio serverコマンドを実行することでembedなk8s(k3sとか呼ばれてるやつ)を起動してそこに各種コンポーネントをデプロイします。
既存のk8sクラスタを利用する場合はkubectlの向き先に対してデプロイを行います。今回はこちらを利用します。

kubectlコマンドの接続先の確認

始める前にkubectlの向き先がDocker for Macになっているか確認しておいてください。

$ kubectl get nodes

NAME                 STATUS    ROLES     AGE       VERSION
docker-for-desktop   Ready     master    2d        v1.10.3

NAMEの部分がdocker-for-desktopになっていればOKですね。

現時点ではRioのデプロイを行うにはcluster-admin権限が必要です。
この辺は今後改善されるっぽいです。

Rioコンポーネントのデプロイ

それではrio loginコマンドを実行してみましょう。

$ rio login

[1] Connect to remote Rio server
[2] Install Rio in existing Kubernetes
Select Number [1] 

こんな感じで選択肢が表示されます。今回は既存のk8sクラスタにデプロイしますので2を選択してください。

その後以下のようなログが表示されるはずです。

INFO[0068] Installing Rio                               
INFO[0071] Waiting to connect to Rio                    
INFO[0111] Log in successful  

Log in successfulと表示されるまで数分かかるかもしれません。気長に待ちましょう。 (手元のマシンでは1分ほどでした)

Rioを使ってみる

コンテナ起動 & 確認

まずは動かしてみます。 docker runするような感覚でrio runを実行することでコンテナの起動が可能です。
ここではDockerHubのnginxイメージを利用してfoobarというサービスを起動します。

Rioでは「サービス」という概念があります。k8sやDockerでのserviceとは役割が異なり、単なる同じ役割を持つコンテナの集合ということです。 詳細はREADME.mdのServiceセクションを参照してください。

#DockerHubのnginxイメージを用いてコンテナ起動
$ rio run --name foobar nginx 

default-265db573:foobar  # k8s上でのネームスペース:サービス名が表示される

#起動しているか確認
$ rio ps

NAME      IMAGE     CREATED              SCALE     STATE     ENDPOINT   DETAIL
foobar    nginx     About a minute ago   1         active               

#foobarサービスのコンテナを確認
$ rio ps foobar

NAME                     IMAGE     CREATED         NODE                 IP          STATE     DETAIL
foobar/f6985f578-schpv   nginx     2 minutes ago   docker-for-desktop   10.1.3.58   running   

起動したようですね。 dockerコマンドやkubectlコマンドなどと同じく、コンテナへのattachやexecも可能です。

# bashを実行(--interactive + --ttyオプションを指定 = dockerと同じ)
$ rio exec -it foobar bash

root@foobar-f6985f578-schpv:/# 

# 以下のようにサービス名/コンテナ名まで指定してもOK
# rio exec -it foobar/f6985f578-schpv bash

次にk8sからはどう見えてるか確認してみます。

kubectlでKubernetes側から確認

# ネームスペースの確認
$ kubectl get namespaces

NAME               STATUS    AGE
default            Active     2d
default-265db573   Active    10m  # rio runで作成されたネームスペース
docker             Active     2d
istio-095b8502     Active    15m 
kube-public        Active     2d
kube-system        Active     2d
rio-defaults       Active    15m 
rio-system         Active    16m

Rioがいくつかnamespaceを作成してますね。rio run実行後に表示されたdefault-265db573というものも見えます。
実際のコンテナはこのネームスペースに配置されています。

それではこのネームスペースに何があるのか確認していきましょう。
実際に手元で試す際はdefault-265db573の部分をそれぞれの手元で表示されたものに置き換えてください。

$ kubectl get all -n default-265db573

NAME                         READY     STATUS    RESTARTS   AGE
pod/foobar-f6985f578-schpv   2/2       Running   0          14m

NAME             TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/foobar   ClusterIP   10.97.38.245   <none>        80/TCP    14m

NAME                     DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/foobar   1         1         1            1           14m

NAME                               DESIRED   CURRENT   READY     AGE
replicaset.apps/foobar-f6985f578   1         1         1         14m

ServiceとDeploymentが作成され、そこからReplicaSetとPodも生えてますね。
それぞれ少し詳しくみておきます。

# Serviceの確認
$ kubectl get svc -o wide -n default-265db573
NAME      TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE       SELECTOR
foobar    ClusterIP   10.97.38.245   <none>        80/TCP    19m       app=foobar,rio.cattle.io/namespace=default-265db573,rio.cattle.io/service=foobar,rio.cattle.io=true

# Deploymentの確認
$ kubectl get deploy -o wide -n default-265db573 
NAME      DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE       CONTAINERS           IMAGES                                    SELECTOR
foobar    1         1         1            1           20m       foobar,istio-proxy   nginx,docker.io/istio/proxy_debug:1.0.0   app=foobar,rio.cattle.io=true,rio.cattle.io/namespace=default-265db573,rio.cattle.io/revision=latest,rio.cattle.io/service=foobar

# Podの確認
$ kubectl get pod -n default-265db573
NAME                     READY     STATUS    RESTARTS   AGE
foobar-f6985f578-schpv   2/2       Running   0          22m

# Podの詳細
$ kubectl describe pod -n default-265db573 foobar-f6985f578-schpv

Name:           foobar-f6985f578-schpv
Namespace:      default-265db573
Node:           docker-for-desktop/192.168.65.3
Labels:         app=foobar
                pod-template-hash=925419134
                rio.cattle.io=true
                rio.cattle.io/namespace=default-265db573
                rio.cattle.io/revision=latest
                rio.cattle.io/service=foobar
Status:         Running
Init Containers:
  istio-init:
    # ...
  enable-core-dump:
   # ...
Containers:
  foobar:
    Image:          nginx
    Port:           <none>
    Host Port:      <none>
    # ...
  istio-proxy:
    Image:         docker.io/istio/proxy_debug:1.0.0
    Mounts:
      /etc/certs/ from istio-certs (ro)
      /etc/istio/proxy from istio-envoy (rw)
    # ...
Volumes:
  istio-envoy:
    Type:    EmptyDir (a temporary directory that shares a pod s lifetime)
    Medium:  Memory
  istio-certs:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  istio.default
    Optional:    true

Events:
  Type    Reason                 Age   From                         Message
  ----    ------                 ----  ----                         -------
  Normal  Created                23m   kubelet, docker-for-desktop  Created container
#...

Istio+指定したnginxが起動してますね。

次にRioでサービス内のコンテナをスケールアウトさせてみましょう。

スケールアウトさせてみる

rio scaleコマンドでスケールさせることが出来ます。

$ rio scale foobar=3
default-265db573:foobar

#確認
$ rio ps
NAME      IMAGE     CREATED          SCALE     STATE     ENDPOINT   DETAIL
foobar    nginx     38 minutes ago   3         active               

#詳細確認
$ rio ps foobar
NAME                     IMAGE     CREATED              NODE                 IP          STATE     DETAIL
foobar/f6985f578-wbgwj   nginx     About a minute ago   docker-for-desktop   10.1.3.60   running   
foobar/f6985f578-nrmhk   nginx     About a minute ago   docker-for-desktop   10.1.3.59   running   
foobar/f6985f578-schpv   nginx     39 minutes ago       docker-for-desktop   10.1.3.58   running   

Kubernetes側もみておきます。

#Deploymentの確認
$ kubectl get deploy -n default-265db573
NAME      DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
foobar    3         3         3            3           40m

#Podの確認
$ kubectl get pod -n default-265db573
NAME                     READY     STATUS    RESTARTS   AGE
foobar-f6985f578-nrmhk   2/2       Running   0          2m
foobar-f6985f578-schpv   2/2       Running   0          41m
foobar-f6985f578-wbgwj   2/2       Running   0          2m

きちんとスケールアウトされてました。

サービスの削除

削除はrmサブコマンドで行います。

#削除
$ rio rm foobar
default-265db573:foobar

#確認
$ rio ps
NAME      IMAGE     CREATED   SCALE     STATE     ENDPOINT   DETAIL

外部からアクセス可能にしてみる(expose相当)

次に起動したサービスを外部からアクセス可能にしてみます。 外部からアクセス可能にするにはrio run時に-p(--publish)オプションを指定します。
(rio editなどでもOK)

# run時にオプションを指定して80番ポートを公開
$ rio run --name foobar -p 80/http nginx
default-265db573:foobar

# 確認
$ rio ps
NAME      IMAGE     CREATED         SCALE     STATE     ENDPOINT                                 DETAIL
foobar    nginx     8 seconds ago   1         active    http://foobar.default.127.0.0.1.nip.io   

今度はENDPOINTというのが割り当てられましたね。 この辺は前回の記事で説明した通りノードのIPアドレスにを指すようなFQDNが割り振られます。

起動したらブラウザなどでENDPOINTのURLを開いてみましょう。

f:id:febc_yamamoto:20181003013156p:plain

非常に簡単に公開できましたね。

Kubernetes側からのぞいてみる(外部からのアクセス編)

またKubernetes側を確認してみます。 まずはServiceです。

$ kubectl get svc -n default-265db573 -o wide
NAME      TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE       SELECTOR
foobar    ClusterIP   10.97.38.245   <none>        80/TCP    1h        app=foobar,rio.cattle.io/namespace=default-265db573,rio.cattle.io/service=foobar,rio.cattle.io=true

前と変わってないですね。RioはIstioを使ってますのでそちらの方を確認なければいけませんね。 なおIstioについては以下を読んでおくと以降をスムーズに読めると思います。

istio.io

# Istio Gatewayの確認
$ kubectl get gateways --all-namespaces
NAMESPACE        NAME          AGE
istio-095b8502   rio-gateway   1h

# Gatewayの詳細確認
$ kubectl get gateways -n istio-095b8502 rio-gateway -o yaml
#適当に省略してます
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: rio-gateway
  namespace: istio-095b8502
spec:
  selector:
    gateway: external
  servers:
  - hosts:
    - '*'
    port:
      number: 80
      protocol: HTTP

ホスト名問わず80番ポートへのアクセスを扱うようになってますね。
セレクタに指定されているgateway: externalについても確認しておきます。

#全ネームスペースからgateway: externalというラベルを持つサービスを取得
$ kubectl get svc --all-namespaces -l gateway=external

NAMESPACE        NAME      TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
istio-095b8502   rio-lb    LoadBalancer   10.102.122.149   localhost     80:31752/TCP   1h

Type: LoadBalancerなServiceが作成されてますね。これで外部からのアクセスを受け付けています。
(実はDocker for Mac18.03.0-ce-rc1-mac54以降でType: LoadBalancerが利用できるようになってたりします)

docs.docker.com

次にIstioのVirtualServiceについてみてみます。

$ kubectl get virtualservices -n default-265db573
NAME      AGE
foobar    52m

#詳細確認
$ kubectl get virtualservices -n default-265db573 -o yaml foobar
# 適当に省略してます
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: foobar
  namespace: default-265db573
spec:
  gateways:
  - mesh
  - rio-gateway.istio-095b8502.svc.cluster.local
  hosts:
  - foobar
  - foobar:80
  - foobar.default.127.0.0.1.nip.io
  - foobar.default.127.0.0.1.nip.io:80
  http:
  - match:
    - gateways:
      - mesh
      - rio-gateway.istio-095b8502.svc.cluster.local
      port: 80
    route:
    - destination:
        host: foobar # foobar.default-265db573.svc.cluster.localと解釈される
        port:
          number: 80
        subset: latest
      weight: 100

先ほど確認したGatewayrio-gatewayにバインドされていますね。
また、ホスト名としてfoobarfoobar.default.127.0.0.1.nip.ioが指定されています。
後者は先ほどrio psで確認したENDPOINTに表示されていたものですね。

destinationも指定されているようです。latestという名前のsubset宛てですね。
IstioのDestinationRuleも確認してみます。

$ kubectl get destinationrules -n default-265db573
NAME      AGE
foobar    1h

#詳細確認
$ kubectl get destinationrules -n default-265db573 -o yaml foobar
#適当に省略してます
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: foobar
  namespace: default-265db573
spec:
  host: foobar
  subsets:
  - labels:
      rio.cattle.io/revision: latest
    name: latest

latestという名前のsubsetが定義されています。
rio.cattle.io/revision: latestというラベルを持つPod宛てということですね。

#宛先Podの確認
$ kubectl get pod -l rio.cattle.io/revision=latest -n default-265db573
NAME                      READY     STATUS    RESTARTS   AGE
foobar-6554b575b7-fz7fz   2/2       Running   0          1h

ということでrio runの裏ではIstioを上手く使って頑張っているようです。

次はカナリアリリースを試してみます。

カナリアリリース

Rioカナリアリリースをサポートしています。 先ほどはfoobarサービスとしてnginxイメージを利用しましたので、Apache(httpd)を使うように更新しカナリアリリースしてみます。

まずfoobarサービスに対するステージング環境をデプロイします。

#Apache(httpd)イメージを指定、foobar:v2とする
$ rio stage --image=httpd foobar:v2

#確認
# rio ps
NAME        IMAGE     CREATED          SCALE     STATE     ENDPOINT                                    DETAIL
foobar      nginx     40 minutes ago   1         active    http://foobar.default.127.0.0.1.nip.io      
foobar:v2   httpd     40 minutes ago   1         active    http://foobar-v2.default.127.0.0.1.nip.io   

foobar:v2がデプロイされました。ENDPOINTをブラウザで開いて確認しておきます。

f:id:febc_yamamoto:20181003013646p:plain

昔懐かしい"It works!"が表示されていますね。


続いて早速カナリアリリースしてみます。
元のfoobarサービスのENDPOINTに対してアクセスした際に30%の確率で更新版であるfoobar:v2が表示されるようにしてみます。

$ rio weight foobar:v2=30%

foobarのエンドポイントhttp://foobar.default.127.0.0.1.nip.ioにアクセスし何度かリロードしてると表示が切り替わるのが確認できるはずです。
(キャッシュに注意してくださいね)

では裏ではどうなってるのか、Kubernetes側を確認してみましょう。

Kubernetes側からのぞいてみる(カナリアリリース編)

まずVirtualServicesを確認してみます。(ちなみにGatewayは変更なし)

$ kubectl get virtualservices -n default-265db573
NAME        AGE
foobar      1h
foobar-v2   9m

予想通りfoobar-v2が増えてますね。 これは先ほどまでで確認したfoobarと同等ですので省略し、カナリアリリースしているfoobarの方がどう変わったか詳しく見ておきます。

$ kubectl get virtualservices -n default-265db573 -o yaml foobar
#適当に省略してます
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: foobar
  namespace: default-265db573
spec:
  gateways:
  - mesh
  - rio-gateway.istio-095b8502.svc.cluster.local
  hosts:
  - foobar
  - foobar:80
  - foobar.default.127.0.0.1.nip.io
  - foobar.default.127.0.0.1.nip.io:80
  http:
  - match:
    - gateways:
      - mesh
      - rio-gateway.istio-095b8502.svc.cluster.local
      port: 80
    route:
    - destination:
        host: foobar
        port:
          number: 80
        subset: latest
      weight: 70
    - destination:
        host: foobar
        port:
          number: 80
        subset: v2
      weight: 30

spec.http.routeの子要素が追加されてますね。

指定通りv2の方は30%、latestは70%となっています。

念のためDestinationRulesの方も載せておきます。

$ kubectl get destinationrules -n default-265db573 -o yaml foobar
#適当に省略してます
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: foobar
  namespace: default-265db573
spec:
  host: foobar
  subsets:
  - labels:
      rio.cattle.io/revision: latest
    name: latest
  - labels:
      rio.cattle.io/revision: v2
    name: v2

こちらでもRioがIstioを上手く使って頑張っているようですね。

ステージング => 本番へ昇格(プロモート)させる

最後にfoobar:v2を昇格させfoobarの代わりにfoobar:v2を利用するようにしてみます。

$ rio promote foobar:v3

# 確認
$ rio ps
NAME      IMAGE     CREATED             SCALE     STATE     ENDPOINT                                 DETAIL
foobar    httpd     About an hour ago   1         active    http://foobar.default.127.0.0.1.nip.io   

イメージがhttpdに変わってますね。
v2というラベルも無くなっているのもポイントです。

#プロモートするとタグは無くなる
$ kubectl get pod -l rio.cattle.io/revision=v2 -n default-265db573 
No resources found.

#その代わりにこれまでのv2にはlatestというタグが改めて付与される
$ kubectl get pod -l rio.cattle.io/revision=latest -n default-265db573 
NAME                      READY     STATUS    RESTARTS   AGE
foobar-69c4b7944c-2jcxt   2/2       Running   0          1m

まとめ

ということで今回はRioKubernetes/Istioをどう扱っているかについてみてみました。

まだまだ不安定な部分も結構あったりしますが、今もGitHub大きめのPRが上がってきてたりと開発は進んでる様子なので今後に期待しています。

以上です。

Kubernetes完全ガイド (impress top gear)

Kubernetes完全ガイド (impress top gear)

Docker/Kubernetes 実践コンテナ開発入門

Docker/Kubernetes 実践コンテナ開発入門

Terraform Provider実装 入門(4): スキーマ定義 後編

f:id:febc_yamamoto:20180914185855p:plain

目次(未確定)

前回スキーマ定義として必須項目であるType(データ型)について扱いました。
今回はschema.Schemaの他のフィールドについて見ていきます。

前回、*schema.Schemaに最低限指定しないといけないフィールドとして以下2つを挙げました。

  • Type
  • Optional/Required/Computedを1つ以上

Typeは前回扱いましたので、今回はもう一方のOptional/Required/Computedについて扱います。

入出力動作(Required/Optional/Computed)

項目の入力/出力動作を決めるフィールドです。 これらは1つ以上指定(trueに設定)する必要があります。

それぞれの意味は以下の通りです。

  • Optional : bool : trueの場合、省略可能になる
  • Required : bool : trueの場合、必須となる
  • Computed : bool : trueの場合、値は算出される(値が未指定の場合のみ)

これらを組み合わせて指定するのですが中には組み合わせ出来ないものもありますので、取りうるパターンは以下4つだけです。

入出力動作で指定可能な組み合わせ

  • Required: true : 入力必須な項目
  • Optional: true : 省略可能な項目
  • Optional:true かつ Computed: true : 省略可能な項目(省略した場合はリソースが算出する)
  • Computed: true : 出力専用の項目(値はリソースが算出する)

このうち、Computedは出力専用、それ以外は入力項目となります。

特殊な項目: 入力専用

また、少々特殊なものとして、Required/Optionalを使いつつResourceData.Set()を呼ばない事で入力専用の項目を作ることも可能です。

詳しくはサンプル実装を動かしながら見ていきます。

サンプル実装inoutリソースでの動作確認

これらの項目の動作確認のためにminimum_providerinoutというリソースを実装しています。 今回も手を動かしながら確認できるようにソースを以下に置いています。

github.com

前回ソースコードをクローンされた方はinoutブランチをチェックアウトしビルドしておいてください。

# チェックアウト
$ cd $GOPATH/github.com/yamamoto-febc/terraform-provider-minimum
$ git fetch -a
$ git checkout inout

# ビルド
$ dep ensure
$ go build -o terraform-provider-minimum
$ terraform init

inoutリソースでのスキーマ定義

inoutリソースはRequired/Optional/Computedの組み合わせを確認するために以下のようにスキーマ定義しています。

func resourceMinimumInOut() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "required": {
                Type:     schema.TypeString,
                Required: true,
            },
            "optional": {
                Type:     schema.TypeString,
                Optional: true,
            },
            "optional_computed": {
                Type:     schema.TypeString,
                Optional: true,
                Computed: true,
            },
            "computed": {
                Type:     schema.TypeString,
                Computed: true,
            },
            "input_only": {
                Type:     schema.TypeString,
                Optional: true,
            },
        },
    }
}

先ほどの指定可能な組み合わせ4つそれぞれに対応した項目があります。
また、入力専用の項目としてinput_onlyという項目も定義しています。

このうち、computedについては現在の日時(RFC3339形式)が値として設定されます。
また、optional_computedはtfファイルで値が指定されなかった場合に現在の日時を値として設定するようにしています。

実際に動きを見てみましょう。

Requiredの動作確認

まずは以下のようにinoutリソースに対してどの項目も未指定の状態のtfファイルを作成してapplyしてみます。

resource minimum_inout "inout" { }

applyするとRequired: trueの項目が未指定である旨を表示してエラーとなってくれます。

$ terraform apply

Error: minimum_inout.inout: "required": required field is not set
Requiredの注意点

注意点として、Requiredは値がtfファイル上で指定されていればOKと判定します。

例として、以下のようにスキーマ定義していた場合、

       Schema: map[string]*schema.Schema{
            "string": {
                Type: schema.TypeString,
                Required: true,
            },
            "int": {
                Type: schema.TypeInt,
                Required: true,
            },
            "bool": {
                Type: schema.TypeBool,
                Required: true,
            },
            "list": {
                Type: schema.TypeList,
                Elem: &schema.Schema{Type: schema.TypeString},
                Required: true,
            },
        },

次のようなtfファイルであればapply出来てしまいます。

resource dummy_required "example" {
  string = ""
  bool   = false
  int    = 0
  list   = []
}

Required: trueでも有効な値が指定されたとは限らない点にご注意ください。
この辺りは必要であれば後述するValidateFuncフィールドを用いてバリデーションを行う事で対応可能です。

Computedの動作確認

次にComputedな項目について確認してみます。
tfファイルで必須項目であるrequired項目に値を設定してみましょう。

resource minimum_inout "inout" {
  required = "required"
}

applyするとcomputedoptional_computedに現在日時が値として設定されているはずです。

$ terraform apply

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

# Stateを確認
$ terraform show
minimum_inout.inout:
  id = 596549384
  computed = 2018-09-17T20:49:49+09:00           #現在日時が設定されている
  optional = 
  optional_computed = 2018-09-17T20:49:49+09:00  #現在日時が設定されている
  required = required

なお、Computed: trueのみの項目にtfファイル上で値を設定しようとすると当然ながらエラーになります。

# !正しくないtfファイルの例!
resource minimum_inout "inout" {
  required          = "required"
  
  // Computed: trueのみの項目には値の指定は出来ない
  computed          = "computed"
}
$ terraform apply

Error: minimum_inout.inout: "computed": this field cannot be set

Optional+Computedの動作確認

次にtfファイル上でoptional_computedに値を設定してみます。

resource minimum_inout "inout" {
  required          = "required"
  optional_computed = "optional_computed"
}

applyすると今度はoptional_computedに現在日時ではなくtfファイルで指定した値が設定されているはずです。

$ terraform apply

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

# Stateを確認
$ terraform show
minimum_inout.inout:
  id = 596549384
  computed = 2018-09-17T20:54:06+09:00
  optional = 
  optional_computed = optional_computed  #tfファイルで設定した値が反映された
  required = required

Optional+Computedの使い所

なぜこんな項目が指定するのか疑問に思う方もいらっしゃるかもしれません。
これは、指定しなければプラットフォーム側が自動で割り当ててくれる、といった項目のためのものです。

例えばAWSのEC2におけるAZ(アベイラビリティゾーン)などが該当します。

参考: AWS: EC2リソースでのスキーマ定義(AZ部分抜粋)

ソース: https://github.com/terraform-providers/terraform-provider-aws/blob/v1.36.0/aws/resource_aws_instance.go#L61-L66

func resourceAwsInstance() *schema.Resource {
    return &schema.Resource{
        // ...
        Schema: map[string]*schema.Schema{
            // ...
            "availability_zone": {
                Type:     schema.TypeString,
                Optional: true,
                Computed: true,
                ForceNew: true,
            },
            // ...
        },
    }
}    

入力専用の項目

スキーマ定義をしてResourceData.Set()を呼ばない項目は入力専用の項目となります。
この場合、tfファイルからの入力でStateには保存されますが、ResourceData.Set()が呼ばれないので実リソースの値が反映される事がないということです。

この場合でもtfファイルを変更すればTerraformが変更を検知してくれますので実リソースへの反映は可能です。

使いどころとしては実リソースから値が取得できないような項目、例えばパスワードといった項目があります。
入力専用の項目はソースコードtfschemaといったツールでスキーマ定義を確認しただけではそうと判断できないので注意が必要です。

また、入力専用の項目は実リソースの値がStateに反映されないため、実リソースを(コントロールパネルなどを利用して)直接変更しても検知できない問題があります。
このため、実リソースの状態とtfファイルやterraform.tfstateの内容が食い違う可能性があります。
これを避けるにはTerraformで管理している実リソースについてはTerraform以外の手段での変更をなるべく避けるなど、運用面での工夫も必要となります。


これでschema.Schemaの最低限の設定項目を押さえました。
残りのフィールドについても見ていきましょう。

なお、ここからはサンプルを別ブランチに格納しています。

github.com

schemaブランチをチェックアウトしてビルドしておいてください。

# チェックアウト
$ cd $GOPATH/github.com/yamamoto-febc/terraform-provider-minimum
$ git fetch -a
$ git checkout schema

# ビルド
$ dep ensure
$ go build -o terraform-provider-minimum
$ terraform init

デフォルト値(Default/DefaultFunc)

その名の通り未指定の場合のデフォルト値を指定します。
Defaultには値を直接指定し、DefaultFuncにはデフォルト値を返すfuncを指定します。funcのシグニチャfunc() (interface{}, error)です。

なぜか両方指定することも可能ですが、両方指定した場合はDefaultが優先されます。
該当部分のソース: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/schema.go#L257-L273

それぞれを詳しく見ていきましょう。

Default/DefaultFuncで共通の部分

まず両者で共通しているのは以下の点です。

  • Computed: trueの場合は指定できない
  • TypeListまたはTypeSetの場合は指定できない

基本的にOptional: trueなプリミティブ型の項目に指定しますが、DefaultFuncについては例外的にRequired: trueでも指定できます。

DefaultFuncのみRequired: trueでも設定できる

DefaultFuncは値の決定時に指定のfuncを実行する事で動的に値を決定するものです。
これを利用して、「APIキーなどのtfファイルに直接記載して欲しく無いけど必要な情報」を環境変数から取得する、といった使い方ができます。

サンプルで動きを確認してみましょう。
今回はminimumプロバイダーにschemaリソースというschema.Schemaの様々なフィールドを試すためのリソースを用意しました。

Required: trueかつDefaultFuncを設定しているdefault_func_requiredという項目を以下のように定義しています。

func resourceMinimumSchema() *schema.Resource {
    return &schema.Resource{
        // ...
        Schema: map[string]*schema.Schema{
            // ...
         
            "default_func_required": {
                Type:     schema.TypeString,
                Required: true,
                DefaultFunc: schema.EnvDefaultFunc("MINIMUM_DEFAULT", nil),
            },
         
            // ...
        },
    }
}

DefaultFuncにschemaパッケージで提供されているヘルパー関数EnvDefaultFuncというfuncを指定しています。
これは与えられた環境変数名(第1引数)の値を参照し、設定されていればそれを、なければ第2引数(ここではnil)の値を返してくれるものです。

default_func_requiredRequired: trueなので指定しないとterraform apply時にエラーになります。

tfファイルまたは環境変数MINIMUM_DEFAULTで値を指定するとterraform apply出来るようになります。

DefaultFuncで利用できるヘルパー関数

先ほども利用していましたが、schemaパッケージではDefaultFuncで利用できるヘルパー関数を提供しています。

  • EnvDefaultFunc: 前述の通り
  • MultiEnvDefaultFunc: シグニチャfunc ([]string, interface{}) SchemaDefaultFunc
    第1引数が配列になっており、環境変数名を複数指定可能。第2引数はEnvDefaultFuncと同様

Default/DefaultFuncの定義を変更する際の注意点

Defaultに指定する値、またはDefaultFuncで返す値を変更するのはbreaking-changeになります。

例えばリソースが以下のように定義されているとします。

       Schema: map[string]*schema.Schema{
            // ...
         
            "value": {
                Type:     schema.TypeString,
                Optional: true,
                Default: "before",
            },
         
            // ...
        },

デフォルト値が設定されているため、tfファイルにvalue項目は指定しなくてもよいですよね。

resource dummy_resource {
  name = "foobar"
  # value = "" # valueは指定せずデフォルトのまま
}

この状態でプロバイダーのバージョンアップを行いデフォルト値を変更したとします。

       Schema: map[string]*schema.Schema{
            // ...
         
            "value": {
                Type:     schema.TypeString,
                Optional: true,
                Default: "after",
            },
         
            // ...
        },

すると、これまでこのリソースを利用していた場合はプロバイダーのバージョンアップをしただけで影響を受けてしまいます。

特に後述するForceNew: true(変更時に削除/作成が行われる)な項目の場合だと影響が大きくなる可能性があります。
このため、出来るだけ変更しないで済むような値を設定する事が望ましいです。

とはいえどうしても変更を避けられない場合もあります。
そのような場合はRefresh処理(第2回を参照)で呼ばれるReadの中で適切にResourceData.Set()する、 または次回以降に扱うスキーママイグレーション処理を行なって適切な値を設定するといった対応が必要となります。

値変更/保存時の挙動(DiffSuppressFunc/ForceNew/StateFunc)

次に値の変更や保存時の挙動を指定するためのフィールドです。

差分の検出を抑制するDiffSuppressFunc

Terraformはplanapply実行時に実リソースとtfファイルでの指定の差分を検出してくれます。
通常はデフォルトの差分検出を利用すれば良いですが、中には差分を無視したいケースもあります。

例えばtfファイルと実リソースで表記方法が違うだけで意味合いが同じといった場合などです。 具体的な用途としては

  • 大文字/小文字の違いを無視する
  • SSH公開鍵で末尾の改行有無を無視する

といったものがあります。

型はschema.SchemaDiffSuppressFuncとなっており、シグニチャfunc(key, old, new string, d *ResourceData) boolです。

第1〜3引数は項目のキー、変更前の値、変更後の値となっています。 第4引数はCRUD操作時に渡されるものと同じ*ResourceDataです。

同じとみなす場合はtrueを、差分ありとみなす場合はfalseを返すようにします。

例として、大文字/小文字の違いを無視したい場合は以下のようにします。

   Schema: map[string]*schema.Schema{
        // ...
     
        "diff_suppress_func": {
            Type:     schema.TypeString,
            Optional: true,
            DiffSuppressFunc: func(key, old, new string, d *ResourceData) bool {
                return strings.ToLower(old) == strings.ToLower(new) // 小文字に変換して比較  
            },
        },
     
        // ...
    },

なお、DiffSuppressFuncスキーマ内の項目単位で制御しますが、リソース全体で制御するためのものとしてschema.ResourceCustomizeDiffというフィールドもあります。
こちらについては次回以降の記事で扱います。

変更されたらリソースを削除-作成するForceNew

ForceNew: trueにすると変更後のapply実行時にリソースのUpdateではなく、DestroyしてCreateするようになります。
これは作成時のみ指定が可能で以後変更できないような項目などで利用します。

また、第2回で触れた、Updateを省略する時の条件にも利用されます。
Updateを省略するにはComputed: trueでない項目全てにForceNew: trueをつけておく必要があります。
Updateを省略 = 更新できない = 更新時は削除->作成が必要なリソース、ということですね。

乱用すると思わぬところでリソースの削除が行われる原因となりえますので適度で適切な利用を心がけましょう。

Stateへの格納の際に呼ばれるフックStateFunc

Stateに値を格納する時にフックを通じて値の加工を行う事ができます。
例えば長い文字列をStateに格納する代わりにハッシュ値を保存するといった使い方です。

以下のように利用します。

   Schema: map[string]*schema.Schema{
        // ...
     
        "state_func": {
            Type:     schema.TypeString,
            Optional: true,
            StateFunc: func(value interface{}) string {
                return hash(value.(string)) // 値のハッシュ値を返す
            },
        },
     
        // ...
    },
// ...

func hash(v string) string {
    return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
}

StateFuncResourceData.Set()を呼んだ際には利用されないことに注意が必要です。
ResourceData.Set()を呼ぶ際はStateFuncと同様の処理を自分で行う必要があります。

   d.Set("state_func", hash(value.StateFunc)) // StateFuncと同様の処理

結構面倒ですね。なのでStateFuncは基本的にアップロードするファイルのコンテンツといった入力専用(ResourceData.Set()をしない)項目に対して用いられる事が多いです。

本来パスワードなどの難読化といった用途でも利用できるはずなのですが、ざっとTerraform Providers配下を探してみても見当たりませんでした(よく探せばあるのかも?)。

パスワードなどのセンシティブなデータの扱いについては次のSensitiveも参照してください。

センシティブなデータをマスクするSensitive

Sensitive: trueにすると、ログや標準出力への出力時に値がマスクされるようになります。

マスクされている様子
$ terraform apply

  + minimum_schema.schema_example
      id:                    <computed>
      sensitive:             <sensitive>

注意点として、Sensitive: trueにしてもStateに保存される際にはマスクされません。
例えばバックエンドがローカルの場合に作成されるterraform.tfstateファイルには生の値がそのまま保存されます。

また、Outputとして利用する場合にも同様にマスクされません。
(Output側で改めてSensitive指定すればマスクされます。参考:https://www.terraform.io/docs/configuration/outputs.html)

一応ソースコードのコメントにはFuture versions of Terraform may encrypt these valuesとありますので、今後対応されるのかもしれません。
https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/schema.go#L195-L199

バリデーション(ValidateFunc/ConflictsWith)

項目のバリデーションで利用するフィールドです。

入力値のバリデーションを行うValidateFunc

入力値に対するバリデーションを行うためのfuncを指定可能です。
型はschema.SchemaValidateFuncで、シグニチャfunc(value interface{},key string) (warns []string,errs []error)です。

以下は入力された文字列の長さが1から5の間でなければエラーとする例です。

   Schema: map[string]*schema.Schema{
        // ...
     
        "validate_func": {
            Type:     schema.TypeMap,
            Optional: true,
         
            // ValidateFunc: validation.IntBetween(1, 5)と同じ
            ValidateFunc: func(value interface{}, k string) (ws []string, es []error) {
                v , ok := value.(string)
                if !ok {
                    es = append(es, fmt.Errorf("expected type of %s to be string", k))
                    return
                }
                if !(1 <= len(v) && len(v) <= 5) {
                    es = append(es, fmt.Errorf("expected length of %s to be in the range (%d - %d), got %s", k, 1, 5, v))
                }
                return
            },
        },
     
        // ...
    },

引数に項目の値とキーが渡されますのでそれらを利用してバリデーションを行います。 戻り値は警告とエラーを配列です。バリデーションの結果をそれぞれに格納してください。

組み込みのバリデーション関数

github.com/hashicorp/terraform/helper/validationパッケージにはValidateFuncで利用できる関数が用意されています。

github.com

なるべくこれらを利用し、不足する機能についてのみ自前実装するようにしましょう。
先ほどのvalidate_funcの例であればvalidation.IntBetween(1, 5)のように利用します。

ValidateFuncの注意点

現在はTypeListTypeSetは非対応です。
https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/schema.go#L192

これらのバリデーションを行いたい場合はElemで複合型を指定してそちらにValidateFuncを書く(もちろんプリミティブ型のみ)か、
Create/Updateなどの中で自分でバリデーションを実装するかしないといけません。

コメントにはValidateFunc currently only works for primitive typesと書いてますが実はTypeMapはバリデーションできたりします。

複数の項目の同時指定を防ぐConflictsWith

ConflictsWithは同時に指定できない項目の名称を文字列の配列で指定します。

   Schema: map[string]*schema.Schema{
        // ...

        "conflicts_with1": {
            Type:          schema.TypeString,
            Optional:      true,
            ConflictsWith: []string{"conflicts_with2"},
        },
        "conflicts_with2": {
            Type:          schema.TypeString,
            Optional:      true,
            ConflictsWith: []string{"conflicts_with1"},
        },   
 
        // ...
    },

両方を指定すると以下のようなエラーメッセージを出してくれます。

$ terraform validate

Error: minimum_schema.schema_example: "conflicts_with1": conflicts with conflicts_with2

Error: minimum_schema.schema_example: "conflicts_with2": conflicts with conflicts_with1

警告/エラーメッセージ(Deprecated/Removed)

プロバイダーのバージョンアップなどでスキーマ定義が変更になり利用しなくなる/なった項目が発生した場合に利用します。
どちらも文字列を指定しておくと、その項目を利用している場合に警告/エラーを表示してくれます。
なお、Removedの場合はterraformコマンド自体を異常終了してくれます。

   Schema: map[string]*schema.Schema{
        // ...

        "deprecated": {
            Type:       schema.TypeString,
            Optional:   true,
            Deprecated: "deprecated",
        },
        "removed": {
            Type:       schema.TypeString,
            Optional:   true,
            Removed: "removed",
        },
 
        // ...
    },

これらを指定していると以下のような警告/エラーメッセージを出してくれます。

$ terraform validate

Warning: minimum_schema.schema_example: "deprecated": [DEPRECATED] deprecated

Error: minimum_schema.schema_example: "removed": [REMOVED] removed

なお、両者ともOptional: trueでデフォルト値なしにしておかないと常に警告/エラーが出ますのでご注意ください。

第4回 まとめ

第4回ではスキーマ定義に利用するschena.Schema型のフィールドについて扱いました。
次回はスキーマを持つリソースの操作を行うためのschema.ResourceDataについて見ていきます。

以上です。

Terraform: Up and Running: Writing Infrastructure as Code

Terraform: Up and Running: Writing Infrastructure as Code

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス

Terraform Provider実装 入門(3): スキーマ定義 前編

f:id:febc_yamamoto:20180914185855p:plain

目次(未確定)

前回はCustom Providerにリソースを追加し基本となるCRUD操作を実装してみました。
今回はリソースで扱う入出力項目であるスキーマの定義について詳しくみていきます。

なお、今回も手を動かしながら確認できるようにソースを以下に置いています。

https://github.com/yamamoto-febc/terraform-provider-minimum/tree/types

前回ソースコードをクローンされた方はtypesブランチをチェックアウトしビルドしておいてください。

#チェックアウト
$ cd $GOPATH/github.com/yamamoto-febc/terraform-provider-minimum
$ git fetch -a
$ git checkout types

#ビルド
$ dep ensure
$ go build -o terraform-provider-minimum main.go

schema.Schemaでのスキーマ定義

前回作成したbasicリソースではスキーマを以下のように定義していました。

import (
    // [...中略...]
    "github.com/hashicorp/terraform/helper/schema"
)

func resourceMinimumBasic() *schema.Resource {
    return &schema.Resource{
        // [...中略...]
     
        Schema: map[string]*schema.Schema{
            "name": {
                Type:     schema.TypeString,
                Required: true,
            },
        },
    }
}

文字列型で必須なnameという項目一つだけの単純なものです。
項目を増やすにはSchemaに項目を追加していくだけでOKです。(もちろんCRUD操作の中でd.Set()を適度に呼び出すなど適切に実装する必要があります)
各項目はSchemaに設定するmapの要素であるschema.Schemaのフィールドを適切に設定することでデータ型や制約、デフォルト値などを柔軟に制御可能です。

まずはschema.Schema型について詳しくみていきます。

schema.Schema

schema.Schema型はリソースが扱う入出力項目それぞれの振る舞いを定義するための構造体です。

以下のようなフィールドが定義されています。 ("フィールド名(型) : 説明" で表記)

  • データ型
    • Type(schema.ValueType) : データ型
  • 入出力動作の指定(1つ以上の指定必須)
    • Optional(bool) : trueの場合、省略可能になる
    • Required(bool) : trueの場合、必須となる
    • Computed(bool) : trueの場合、作成時に計算(算出)される(値が未指定の場合のみ)
  • デフォルト値関連
    • Default(interface{}) : デフォルト値(値で指定)
    • DefaultFunc(schema.SchemaDefaultFunc) : デフォルト値(funcで指定)
  • 値変更/保存時の挙動関連
    • DiffSuppressFunc(schema.SchemaDiffSuppressFunc) : 差分検出で使用するfunc
    • ForceNew(bool) : trueの場合は変更時にUpdateではなくDestroy -> Createが行われる
    • StateFunc(schema.SchemaStateFunc) : Stateへの格納の際に呼ばれるフック
  • センシティブなデータの扱い
    • Sensitive(bool) : trueの場合、この項目をログ/標準出力に出力する際にマスクされる
  • TypeがTypeList or TypeSetの場合に使用するフィールド
    • Elem(interface{}) : ListまたはSetの各要素のデータ型
    • MaxItems(int) : ListまたはSetに格納できる最大数(境界含む/1以上の場合のみ有効)
    • MinItems(int) : ListまたはSetに格納できる最小数(境界含む/1以上の場合のみ有効)
    • PromoteSingle(bool) : trueの場合、単一の値として指定された場合に自動的にリストに変換する(プリミティブ型のみで有効)
  • TypeがTypeSetの場合に使用するフィールド
    • Set(schema.SchemaSetFunc) : 項目のハッシュ値算出で使用するfunc
  • バリデーション関連
    • ConflictsWith([]string) : 同時に指定できない項目の名称を指定
    • ValidateFunc(schena.SchemaValidateFunc) : 入力値のバリデーションで使用するfunc(プリミティブ型のみで有効)
  • 警告/エラーメッセージ関連
    • Deprecated(string) : 設定されている場合、この項目を利用すると警告メッセージを出す
    • Removed(string) : 設定されている場合、この項目を利用するとエラーメッセージを出す(validateplanapplyを異常終了させる)
  • v0.11時点では実装されていないフィールド
    • Description(string)
    • InputDefault(string)
    • ComputedWhen([]string)

必須項目

これらのうち、最低限指定しないといけないのは以下2つです。

  • Type
  • Optional/Required/Computedを1つ以上

まずはTypeからみていきます。

データ型を決めるType

Typeはデータ型を表します。以下の値が指定可能です。

  • TypeBool - bool
  • TypeInt - int
  • TypeFloat - float64
  • TypeString - string
  • TypeList - []interface{}
  • TypeMap - map[string]interface{}
  • TypeSet - *schema.Set

右側はResourceData.Get()を呼んだ時の戻り値の実際の型を表しています。
ResourceData.Get()の戻り値はinterface{}ですので、項目のデータ型に応じて適切にキャストする必要があります。

それぞれの特徴を順に見ていきます。

プリミティブ型(Bool/Int/Float/String)

それぞれのプリミティブ型を示します。特に説明不要ですね。
tfファイル上は値の書き方にバリエーションがある点には留意しておいてください。
(このへんはTerraformの実装というよりHCLの実装によります)

Terraform v0.12ではHCLの後継であるHCL2への切り替えが進められています。

以下にtfファイルの書き方例を記載しておきます。

TypeBool
resource minimum_bool "bool" {
  value = true 

  // 文字列で指定してもOK
  #value = "true"   #OK 
  #value = "foobar" #これはNG

  // 数値もOK(true=1,false=0)
  #value = 0  # OK
  #value = 2  # これはNG  
  #value = -1 # これはNG

  // 数値を文字列で指定してもOK
  #value = "0"  # OK
  #value = "2"  # これはNG
  #value = "-1" # これはNG
}
TypeInt
resource minimum_int "int" {
  value = 1

  // 8進数/16進数/指数もOK
  #value = 0777   # 8進数 
  #value = 0xFFFF # 16進数
  #value = 1e10   # 指数表記
 
  // 文字列で指定してもOK
  #value = "1" 
  
  // 範囲(golangのintの範囲)
  #value = -9223372036854775808
  #value = 9223372036854775807
  
  #value = 1.1 # これはNG
}

TypeString

resource minimum_string "string" {
  value = "foobar"
  
  // 数値で指定してもOK(文字列に変換される)
  #value = 0777   # 8進数 
  #value = 0xFFFF # 16進数
  #value = 1e10   # 指数表記
}

List型(TypeList)

次にTypeListを見ていきます。TypeListはプリミティブ型、または複合型のリストを表します。
スキーマ定義は以下のようになります。

func resourceMinimumList() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeList,
                Optional: true,
                Elem:     &schema.Schema{Type: schema.TypeString},
                // MaxItems: 1,
                // MinItems: 1,
            },
        },
    }
}

TypeListにする場合、Elemは必須です。Elemでリストの各要素のデータ型を指定する必要があります。
リスト内の要素がプリミティブ型の場合は*schema.Schemaを、複合型の場合は*schema.Resourceを指定します。 多段ネストも可能です。

なお、後述するTypeMapと違いElemschema.ValueTypeを直接指定することはできません。

正しくないElem指定の例
// !正しくないElem指定の例!
func resourceMinimumList() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeList,
                Optional: true,
                Elem:     schema.TypeString, // TypeListではValueTypeを直接指定できない
            },
        },
    }
}

次にリスト内に複合型を利用する場合のスキーマ定義です。

リスト内に複合型を利用する例
func resourceMinimumNestedList() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeList,
                Optional: true,
                Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                        "name": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "value": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "description": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                    },
                },
            },
        },
    }
}

また、リストの要素数MinItems/MaxItemsで制限可能です。
いずれもデフォルトは0で、1以上の数値を指定すると有効になります。
(これらは後述するTypeSetでも利用可能です)

List型を含むリソースを利用するtfファイルは以下のようになります。

# プリミティブ型のリスト
resource minimum_list "list" {
  value = ["list0", "list1", "list2"]
}

# 複合型のリスト
resource minimum_nested_list "nested-list" {
  value = [
    {
      name        = "name1"
      value       = "value1"
      description = "description1"
    },
    {
      name        = "name2"
      value       = "value2"
      description = "description2"

      // 定義していない項目はちゃんとエラーにしてくれる
      # foo = "bar"
    },
  ]
}

List型の項目に対しResourceData.Get()すると[]interface{}が返ってきます。
各要素がプリミティブ型の場合は単にキャスト(TypeStringならstringへ)すればOKです。
複合型の場合はmap[string]interface{}になります。

上記のminimum_nested_listリソースの場合は以下のようにします。

func resourceMinimumNestedListRead(d *schema.ResourceData, meta interface{}) error {
    // ...
    values := d.Get("value").([]interface{}) // まず[]interface{}にキャスト
    for _ , v := range values {
      element := v.(map[string]interface{}) // 各要素はmap[string]interface{}
     
      name := element["name"].(string)
      value := element["value"].(string)
      desc := element["description"].(string)
     
      // ...
    }    
}

Map型(TypeMap)

続いてTypeMapを見ていきます。TypeMapはその名の通りキーと値をペアで持ちます。キーは文字列である必要があります。
スキーマ定義は以下のようになります。

func resourceMinimumMap() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeMap,
                Optional: true,
            },
        },
    }
}

リソースの実装時、Mapの項目に対しResourceData.Get()するとmap[string]interface{}が返ってきます。

また、Map型でも要素の型をElemで指定できますが、少々クセがある点に注意が必要です。

Map型の注意点

現状はMap型の項目に対しElemを指定した際は以下のような挙動となっています。

  • Elemが未指定の場合は各要素をTypeStringとみなす
  • Elemschema.ValueType(TypeIntとかTypeStringとか)が指定された場合
    • プリミティブ型であればそのまま使う
    • 以外の場合はTypeStringとみなす
  • Elem*schema.Schemaが指定された場合
    • *schema.SchemaTypeがプリミティブ型であればそのまま使う
    • 以外の場合はTypeStringとみなす
  • Elem*schema.Resourceが指定された場合
    • TypeStringとみなす

要はMapの各要素はプリミティブ型でないとダメということです。

この挙動はリストのように複合型を利用したい場合に問題が発生します。
例えば以下のようにスキーマ定義します。

// !問題のあるスキーマ定義!
func resourceMinimumInvalidMap() *schema.Resource {
    return &schema.Resource{
        // ...
        
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeMap,
                Optional: true,
                Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                        "value1": {
                            Type:     schema.TypeString,
                            Required: true,
                        },
                        "value2": {
                            Type:     schema.TypeInt,
                            Optional: true,
                        },
                        "value3": {
                            Type:     schema.TypeBool,
                            Optional: true,
                        },
                    },
                },
            },
        },
    }
}

Map型の各要素として以下のような複合型を指定しています。

  • value1: 文字列型/必須
  • value2: Int型
  • value3: Bool型

このリソースを利用するtfファイルは以下の通りです。

resource minimum_invalid_map "invalid" {
  value = {
    value1 = "value1"
    value2 = 2
    value3 = true
  }
}

terraform validateterraform applyも問題なく行えるはずです。
しかし、以下のようにした場合はどうでしょうか?

resource minimum_invalid_map "invalid" {
  value = {
    # value1 = "value1" # Required=trueの項目をコメントアウト
    value2 = "foo"      # Int型の項目に文字列を指定
    value3 = "bar"      # Bool型の項目に文字列を指定
    value4 = "not exists" # 定義していない項目を指定
  }
}

なんとterraform validateterraform applyも問題なく行えてしまいました。
これはElemにプリミティブ型以外を指定してしまったために、各要素がTypeStringとみなされてしまうからです。

このようにMap型でElemを使う場合には直感的でない挙動となりますので、 もしtfファイル上で複合型を利用したい場合は後述のTypeSetか、MaxItems=1にしたTypeListを使う方法を検討してください。

なお、この辺のTypeMapの挙動については対応が進められてはいるのですが現時点では止まっちゃってるっぽいです。

github.com

Set型(TypeSet)

最後にTypeSetをみていきます。TypeSetは値のハッシュ機能付きのリストです。 スキーマ定義は以下のようになります。

func resourceMinimumSet() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeSet,
                Optional: true,
                Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                        "name": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "value": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "description": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                    },
                },
                Set: func(v interface{}) int {
                    var buf bytes.Buffer
                    m := v.(map[string]interface{})
                 
                    // nameとvalueの値からハッシュ値を生成
                    keys := []string{"name", "value"}
                    for _, key := range keys {
                        if v, ok := m[key]; ok {
                            buf.WriteString(fmt.Sprintf("%s-", v.(string)))
                        }
                    }
                    return hashcode.String(buf.String())
                },
            },
        },
    }
}

TypeListの時と同じくElemが必須となっています。
違いはSetの部分ですね。
これは値からハッシュ値を算出する部分となっています。 (なおSetを利用せず、ResourceData.Set()するときにschema.NewSet()を利用する方法もあります。)

ポイントはハッシュ値が同一なTypeSetの要素はまとめられるという性質を持っている点です。

tfファイルでの例を見てみましょう。
この例のminimum_setリソースでは、各要素のnamevalueを元にハッシュ値を算出しています。

まずはハッシュ値が異なる要素の場合です。

resource minimum_set "set" {
  value = [
    {
      name        = "name1"
      value       = "value1"
      description = "description1"
    },
    {
      name        = "name2"
      value       = "value2"
      description = "description2"
    },
  ]
}

terraform applyすると以下のようになります。

$ terraform apply

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:

  + minimum_set.set
      id:                           <computed>
      value.#:                      "2"
      value.1049404950.description: "description1"
      value.1049404950.name:        "name1"
      value.1049404950.value:       "value1"
      value.741132560.description:  "description2"
      value.741132560.name:         "name2"
      value.741132560.value:        "value2"


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

minimum_set.setvalueの要素として2つの要素が作成される様子が確認できますね。
次にtfファイルを修正しnamevalueを同一の値にしてハッシュ値が同じになるようにしてみます。

$ terraform apply

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:

  + minimum_set.set
      id:                           <computed>
      value.#:                      "1"
      value.1049404950.description: "description2"
      value.1049404950.name:        "name1"
      value.1049404950.value:       "value1"


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

今度はminimum_set.setvalueの要素として1つの要素が作成される様子が確認できますね。
前述の通りハッシュ値が同じ要素はまとめられるという性質によりこうなっています。

一見するとめんどくさいだけなようにも思えますが、もちろん役に立つ場面が存在します。
例えばDNSのAレコードを扱う場合を考えてみます。

Aレコードは一つのホスト名に対し複数のIPアドレスを指定できますよね。
この場合単純にホスト名をキーにしたマップを利用するというわけにはいかないでしょう。

正しくない例: レコードをマップで表す場合
# !正しくない例! レコードをマップで表す場合
resource dummy_dns_zone "zone" {
    zone = "example.com"
 
    records = {
        "www" = "192.2.0.1", # mapなので重複したキーは指定できない 
        "www" = "192.2.0.2", #
    }
}

単純にリストにすればキーに当たるホスト名は重複させられますが、今度はホスト名/IPアドレスの組み合わせが重複する可能性があります。

正しくない(かもしれない)例: レコードをリストで表す場合
# !正しくない(かもしれない)例! レコードをリストで表す場合
resource dummy_dns_zone "zone" {
    zone = "example.com"

    # リストの場合は値の重複が起こり得る        
    records = [
      {
          name = "www",
          ip   = "192.2.0.1",
      },
      {
          name = "www",
          ip   = "192.2.0.1",
      },
    ]
}

ResourceData.Get()を行なった際に手動でバリデーションを行えば良いのですが、TypeSetであればこの辺りが楽に行えるようになっています。

# レコードをセット(TypeSet)で表す場合
resource dummy_dns_zone "zone" {
    zone = "example.com"

    # TypeSetの場合はハッシュ生成に使う値が同じならまとめられる
    records = [
      {
          name = "www",
          ip   = "192.2.0.1",
      },
      {
          name = "www",
          ip   = "192.2.0.1",
      },
    ]
}

注意点として、TypeSetを用いることで、tfファイルには2レコード書いたつもりなのに(書き間違えにより意図せず)1レコードしか作成されない!といったことが発生し得ます。
TypeListを利用してバリデーションを実装することで重複チェックを行う方法もありますので、適度に使い分けましょう。

また、TypeSetを利用した場合、リソースのテストが書きにくい(リソースIDがハッシュ値になるため)という問題もあります。
この辺りは次回以降の記事でテスティングフレームワークを扱う際に改めて触れます。


Typeは以上です。これらを組み合わせてリソースに対する入出力項目を定義していくことになります。
各データ型の特徴をしっかり押さえておきましょう。

第3回 まとめ

第3回ではスキーマ定義に利用するschena.Schema型のフィールドのうち、データ型を表すTypeについて扱いました。
次回はschema.Schemaの他のフィールドについて見ていきます。

以上です。

Terraform: Up and Running: Writing Infrastructure as Code

Terraform: Up and Running: Writing Infrastructure as Code

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス

Terraform Provider実装 入門(2): リソース実装 基礎

f:id:febc_yamamoto:20180914185855p:plain

目次(未確定)

前回はCustom Providerとして最低限の実装を行いTerraformから利用できるようにするところまででした。
今回は引き続きTerraformでのCustom Providerの実装の基礎的な部分について扱います。
前回作成したterraform-provider-minimumにリソース操作のための実装を追加していきます。

プロバイダーで扱うリソースの実装

それでは早速minimumプロバイダーで扱うリソースを実装してみます。

minimumプロバイダーの仕様

Terraformプロバイダーの多くは対象プラットフォーム(例:AWS/Azure/GCP)が提供するAPIを用いてプラットフォーム上のリソースに対するCRUD操作を行うという形で実装されますが、 minimumプロバイダーはサンプル実装ということで簡易的にローカルマシン上にファイルを作成するように実装します。

ここでは例としてminimumプロバイダーに対しbasicというリソースを実装してみます。
以下のようにnameという属性一つだけをもつシンプルなリソースです。

resource minimum_basic "example" {
  name = "foobar"
}

terraform applyを実行するとカレントディレクトリに以下のようなJSONファイルが作成されます。

{
  "Name": "foobar"
}

それでは早速リソースの実装を行なっていきます。

なお、basicリソースを実装したソース一式は以下に置いています。

github.com

前回ソースコードをクローンされた方はbasicブランチをチェックアウトしておいてください。

$ cd $GOPATH/github.com/yamamoto-febc/terraform-provider-minimum
$ git checkout basic

basicリソースの実装

リソース実装の手順は以下の通りです。

  • (1) *schema.Resourceを返すfuncを作成
  • (2) Provider()に(1)で実装したfuncを追記

順に見ていきます。

(1) *schema.Resourceを返すfuncを作成

schema.Resourceとは、リソースに対するCRUDを行うfuncや入出力項目のスキーマ定義を持つ構造体です。
ソース: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L23-L137

リソースとしての最低限の実装は以下の通りです。

package minimum

import  "github.com/hashicorp/terraform/helper/schema"

func resourceMinimumBasic() *schema.Resource {
    return &schema.Resource{
        // CRUD操作用のfuncをそれぞれ指定
        Create: resourceMinimumBasicCreate,
        Read:   resourceMinimumBasicRead,
        Update: resourceMinimumBasicUpdate, // 省略可
        Delete: resourceMinimumBasicDelete,
     
        Schema: map[string]*schema.Schema{
            // ここに入出力項目を定義
        },
    }
}

func resourceMinimumBasicCreate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceMinimumBasicRead(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceMinimumBasicUpdate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceMinimumBasicDelete(d *schema.ResourceData, meta interface{}) error {
    return nil
}

schema.Resourceはいくつかのフィールドを持っていますが、リソースとして動作させるには最低限以下の項目をセットする必要があります。

  • Create: リソース作成用のfunc
  • Read: リソース参照用のfunc
  • Update: リソース更新用のfunc(特殊なリソースでは省略可)
  • Delete: リソース削除用のfunc
  • Schema: 入出力項目の定義

CRUD用のfuncはfunc (*schema.ResourceData, interface{}) errorというシグニチャを持ちます。
このうちUpdateは省略可能ですが、省略するにはいくつか条件がありますので次回以降の記事で改めて解説します。
それぞれのfuncの書き方にはお作法がありますので後ほどCRUDそれぞれについて見ていきます。

Schemaにはこのリソースで扱う入出力項目をmap[string]*schema.Schemaとして記述します。
mapのキーが項目名となり、項目のデータ型や制約などを*schema.Schemaとして記述します。

Schemaの定義

今回のbasicリソースではnameという文字列型の項目を一つだけ定義しておきます。

func resourceMinimumBasic() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "name": {
                Type:     schema.TypeString,
                Required: true,
            },
        },
    }
}

*schema.Schemaには他にもいくつかのフィールドがありますが、それらは次回以降に扱います。

Createの実装

続いてCreate用のfuncを実装します。
Createでは以下の処理を行う必要があります。

  • tfファイルからの入力を取得
  • 各プラットフォームのAPIなどを用いて実リソースの作成
  • *schema.ResourceDataSetId()を呼び出してリソースIDを設定
  • 必要に応じて*schema.ResourceDataSet()を呼び出してリソースの値を設定

basicリソースでは以下のように実装します。

var client = state.NewDriver("basic")

func resourceMinimumBasicCreate(d *schema.ResourceData, meta interface{}) error {
    // tfファイルからの入力を取得
    name := d.Get("name").(string)

    // プラットフォームのAPIなどを用いて実リソースの作成
    // (ここではローカルマシン上にJSONファイルを保存)
    value := &state.BasicValue{
        Name: name,
    }
    id, err := client.Create(value) // 実リソース作成 - リソースのIDを戻り値として受け取る
    if err != nil {
        return err
    }

    // リソースIDを設定
    d.SetId(id)
 
    // リソースの値を設定
    return resourceMinimumBasicRead(d, meta)
}

第1引数のd*schema.ResourceData型となっています。
これはリソースに対する入出力値を扱う構造体で、tfファイルからの入力値の取得やTerraformが管理するリソースの値=Stateへのアクセスなどを担当します。
TerraformでのStateについては以下を参照してください。

www.terraform.io

第2引数のmetaAPIキーといったプロバイダー固有の設定を各リソースに受け渡すためのものです。
通常APIクライアントなどが設定されています。
今回は利用していませんが、次回の記事でこの引数を利用するようにリファクタしてみます。

tfファイルからの入力値の取得

まずはd.Get("項目名")でtfファイルからの入力値を取得しています。戻り値はinterface{}になっていますので、適切な型にキャストします。

プラットフォームのAPIを利用して実リソースの作成

入力値が出揃ったらプラットフォームのAPIを利用して実リソースの作成を行います。
この際にリソースごとに一意なIDを発行する必要があります。

今回はサンプルとしてプラットフォームのAPIを呼び出す代わりにローカルマシン上にJSONファイルとして保存しておくようにしています。
実際の処理はstateパッケージに実装していますので興味のある方はソースを眺めてみてください。
(値をinterface{}型として受け取り、JSONにして保存、IDはランダムな数値を返す実装です)

リソースIDの設定

実リソースを作成しIDが発行されたらd.SetId()を呼び出してリソースIDの設定を行います。
Terraformはこのfuncを抜けた後にd.SetId()を通じてリソースIDが設定されていか判定し、設定されている場合はリソースの持つ値をStateに保存します。
(特にバックエンドの設定をしていない場合はカレントディレクトリにterraform.tfstateというファイルが作成されます)

リソースの値の設定

最後にd.Set()を呼び出してリソースの値をキーごとに設定していきます。 設定した値は最終的にStateに保存されます。 (なお、d.SetId()d.Set()の順番はどちらが先でもOKです)

ここで直接d.Set()を呼んでも良いのですが、後ほどReadを実行するときにもd.Set()を実行することになりますので、実装の重複を避けるためにRead用のfuncを直接呼び出すようにしています。

Readの実装

続いてReadを実装していきます。
Readでは以下の処理を行う必要があります。

  • *schema.ResourceDataId()を呼び出してリソースのIDを取得
  • 取得したIDを用いて各プラットフォームのAPIなどを呼び出し実リソースのデータを参照
  • *schema.ResourceDataSet()を呼び出してリソースの値を設定

一言で言うと、リソースのIDを用いて実リソースの最新リソースを参照し、リソースの値を設定を行います。

basicリソースでは以下のように実装します。

func resourceMinimumBasicRead(d *schema.ResourceData, meta interface{}) error {
    // リソースのIDを取得
    id := d.Id()

    // IDを用いてプラットフォームのAPIを呼び出し、実リソースを参照する        
    data, err := client.Read(id)
    if err != nil {
        // 実リソースが見つからなかったら
        if state.IsStateNotFoundError(err) {
            // *Point* IDを空にしてnilを返す
            d.SetId("")
            return nil
        }
        return err
    }
    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    // 実リソースの最新の情報をリソースデータに設定
    d.Set("name", value.Name) // nolint

    return nil
}
実装時の注意点: 実リソースが存在しなかった場合の対応

ポイントは実リソースが見つからなかった場合の処理です。
IDを元にプラットフォーム上を検索してみたけど見つからなかったと言う場合には d.SetId(""")を呼びリソースIDをクリアした上でnilを返します。
こうすることでTerraformとしてはリソースは存在しないものとして認識し、再度作成が必要といった判断を行ってくれます。

ここでは、IDによる検索で予期せぬエラーが発生した場合と実リソースが見つからなかった場合を戻り値などから区別できることが必要です。
実リソースが存在しない場合にもエラーを返してしまった場合、terraform planterraform destroyの実行ができないという状況になる可能性もあります。

というのも、Terraformはplanapplydestroyの実行の前にStateを最新の状態にするためにRefreshという処理を行います。
Refreshは単に各リソースのReadを呼び出すのですが、Readがエラーを返すとそこで処理が中断されてしまうからです。 実リソースが存在しない場合はTerraformではもはや手の打ちようがないですので、d.SetId("")を呼び出すことでStateからリソースの値をクリアします。

ごく稀にこの辺の処理を正しく行えていないリソースがあり、applyもdestroyもできないという状況になることがあります。
そのような場合の応急処置としてterraform.tfstateファイルから該当リソース部分を手作業で削除という泥臭い解決方法もあります。

IDを元に最新の実リソースを参照できるようになった後はd.Set()を呼び出してリソースの値を設定していきます。

Updateの実装

続いてUpdateの実装を行います。 Updateでは以下の処理を行う必要があります。

  • *schema.ResourceDataId()を呼び出してリソースのIDを取得
  • 各プラットフォームのAPIなどを呼び出しIDに対応する実リソースの値を更新
  • *schema.ResourceDataSet()を呼び出してリソースの値を設定

Createと同じくd.Get()などでtfファイルからの入力値を取得して実リソースの値を更新していきます。
*schema.ResourceDataには以下のようないくつかのヘルパー関数がありますので、変更のあった項目だけ更新するといったことも可能です。

*schema.ResourceDataの変更検知用関数
  • d.HasChange("<項目名>"): 対象項目が変更されているかを判定(戻り値: bool)
  • d.GetChange("<項目名>"): 対象項目の変更前/変更後の値を取得(戻り値: interface{}, interface{})

basicリソースでは以下のように実装します。

func resourceMinimumBasicUpdate(d *schema.ResourceData, meta interface{}) error {
    id := d.Id()

    // IDを元に実リソースを検索
    data, err := client.Read(id)
    if err != nil {
        // 実リソースが見つからなかったら
        if state.IsStateNotFoundError(err) {
            d.SetId("")
            return nil
        }
        return err
    }

    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    // set updated values
    if d.HasChange("name") {
        value.Name = d.Get("name").(string)
    }

    // save values
    if err := client.Update(id, value); err != nil {
        return err
    }

    return resourceMinimumBasicRead(d, meta)
}

ここでもReadと同じくIDを用いて実リソースを検索し、見つからなかったらd.SetId("")を呼び出した上でnilを返しています。

あとはd.HasChange()で変更があった項目をセットした上でプラットフォームのAPIを呼び出し実リソースを更新します。

Createと同じくRead用のfuncを直接呼び出すことでd.Set()を行っています。

Deleteの実装

続いてDeleteの実装を行います。 Deleteでは以下の処理を行う必要があります。

  • *schema.ResourceDataId()を呼び出してリソースのIDを取得
  • 各プラットフォームのAPIなどを呼び出しIDに対応する実リソースの値を削除

CreateUpdateと比較するとシンプルな実装になることが多いですね。

basicリソースでは以下のように実装します。

func resourceMinimumBasicDelete(d *schema.ResourceData, _ interface{}) error {
    id := d.Id()
    if err := client.Delete(id); err != nil {
        if !state.IsStateNotFoundError(err) {
            return err
        }
    }
    d.SetId("")
    return nil
}

IDを用いてプラットフォームのAPIで実リソースの削除を行うだけですね。
なお、Deleteではエラーを返さなければd.SetId("")があとで呼ばれるようになっていますのでここでのd.SetId("")は省略可能だったりします。
(公式ガイドにもその旨が書いてあります。明確にするためにあえて書いているとのことです。)

参考: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L197-L203

以上でリソースの基本的な実装ができました。

余談: Existsは実装すべき?

schema.Resourceのソースを読むと、Existsというfuncがあり、実装を推奨(recommended)すると言うコメントが書かれています。

参考: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L59-L86

   // The functions below are the CRUD operations for this resource.
    //
    // [...中略...]
    //
    // Exists is a function that is called to check if a resource still
    // exists. If this returns false, then this will affect the diff
    // accordingly. If this function isn't set, it will not be called. It
    // is highly recommended to set it. The *ResourceData passed to Exists
    // should _not_ be modified.
    Create CreateFunc
    Read   ReadFunc
    Update UpdateFunc
    Delete DeleteFunc
    Exists ExistsFunc

これはReadで行っていた、IDによる実リソースの存在確認を実装するためのもので、セットされているときだけ呼ばれるようになっています。
推奨という割には実装していないリソースもチラホラと散見されます。(例: AWSプロバイダーのEC2リソース aws_instanceなど)

実際にはReadが正しく実装されていればExistsは実装しなくても構いません。
Readとの違いは呼ばれるタイミングです。

ExistsReadの間にはスキーママイグレーション処理(次回以降に扱う予定)が行われています。
参考: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L319-L352

このあたりの処理が重くなるようであればExistsを実装した方が良いですが、最初はReadの実装のみで十分でしょう。

(2) Provider()に(1)で実装したfuncを追記

リソースの実装ができましたので、Provider()にリソースを登録しておきます。

minimum/provider.goを以下のようにします。

package minimum

import (
    "github.com/hashicorp/terraform/helper/schema"
    "github.com/hashicorp/terraform/terraform"
)

// Provider returns a terraform.ResourceProvider.
func Provider() terraform.ResourceProvider {
    return &schema.Provider{
        Schema: map[string]*schema.Schema{},
        ResourcesMap: map[string]*schema.Resource{
            "minimum_basic": resourceMinimumBasic(), // この行を追記
        },
    }
}

これでbasicリソースの実装ができました。

動作確認

実際にビルドして試してみましょう。 tfファイルはbasicブランチの直下にexample.tfがというファイルを置いていますのでそれを利用します。

# ビルド
$ go build -o terraform-provider-minimum main.go

# init & apply
$ terraform init
$ terraform apply

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:

  + minimum_basic.example
      id:   <computed>
      name: "foobar"


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

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: < yesを入力

minimum_basic.example: Creating...
  name: "" => "foobar"
minimum_basic.example: Creation complete after 0s (ID: 2012225891)

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

applyが成功するとカレントディレクトリにbasic-nnnnnnnn.json(nはランダムな数字)というファイルが作成されているはずです。
tfファイルやJSONファイルを書き換えてapplyを実行したりdestroyを実行したりと色々試してみてください。

basicリソースのソース全体

まとめとしてbasicリソースのソース全体を載せておきます。

package minimum

import (
    "encoding/json"

    "github.com/hashicorp/terraform/helper/schema"
    "github.com/yamamoto-febc/terraform-provider-minimum/state"
)

var client = state.NewDriver("basic")

func resourceMinimumBasic() *schema.Resource {
    return &schema.Resource{
        Create: resourceMinimumBasicCreate,
        Read:   resourceMinimumBasicRead,
        Update: resourceMinimumBasicUpdate,
        Delete: resourceMinimumBasicDelete,
        Schema: map[string]*schema.Schema{
            "name": {
                Type:     schema.TypeString,
                Required: true,
            },
        },
    }
}

func resourceMinimumBasicCreate(d *schema.ResourceData, meta interface{}) error {
    name := d.Get("name").(string)
    value := &state.BasicValue{
        Name: name,
    }

    id, err := client.Create(value)
    if err != nil {
        return err
    }

    d.SetId(id)
    return resourceMinimumBasicRead(d, meta)
}

func resourceMinimumBasicRead(d *schema.ResourceData, _ interface{}) error {
    id := d.Id()
    data, err := client.Read(id)
    if err != nil {
        if state.IsStateNotFoundError(err) {
            d.SetId("")
            return nil
        }
        return err
    }

    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    d.Set("name", value.Name) // nolint

    return nil
}

func resourceMinimumBasicUpdate(d *schema.ResourceData, meta interface{}) error {
    id := d.Id()

    // read current state
    data, err := client.Read(id)
    if err != nil {
        if state.IsStateNotFoundError(err) {
            d.SetId("")
            return nil
        }
        return err
    }

    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    // set updated values
    if d.HasChange("name") {
        value.Name = d.Get("name").(string)
    }

    // save values
    if err := client.Update(id, value); err != nil {
        return err
    }

    return resourceMinimumBasicRead(d, meta)
}

func resourceMinimumBasicDelete(d *schema.ResourceData, _ interface{}) error {
    id := d.Id()
    if err := client.Delete(id); err != nil {
        if !state.IsStateNotFoundError(err) {
            return err
        }
    }
    d.SetId("")
    return nil
}

第2回 まとめ

第2回では単純なリソースの実装を行ってみました。 操作したいリソースにAPIさえ提供されていれば意外と簡単に実装できますね。

次回はスキーマ定義(schema.Schema)についてもう少し深掘りしてみます。

以上です。

Terraform: Up and Running: Writing Infrastructure as Code

Terraform: Up and Running: Writing Infrastructure as Code

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス

Terraform Provider実装 入門(1): Custom Providerの基礎

今回はTerraformから提供されているprovider frameworkを利用した独自のプロバイダーの実装について扱います。
カスタムプロバイダーについての基本的な知識〜実装上の注意点などをサンプル実装を通じて見ていきます。

注:この記事はTerraform v0.11に対応しています。

=== UPDATE: 2022/05
この記事執筆当時と筆者の状況が変わり、この一連の記事は完結する見込みがほぼ無くなっています。
また、Terraform側も進化しており、Terraform SDKが生まれ、SDK v2に変わり、現在ではTerraform Plugin Frameworkというものが生まれています。

この一連の記事の内容はSDK v2でも若干の調整(エラーハンドリングなど)だけで通用しますが、Plugin Frameworkについては全く新しい作りとなっていること、新規にプロバイダーを作成する場合はPlugin Frameworkの利用を検討するように公式ドキュメントに書かれていますので、これらの点に留意しつつお読みください。

www.terraform.io

=== UPDATE ここまで

はじめに

この記事は主にカスタムプロバイダーの実装をする方やAWS/GCP/Azureなどの既存のプロバイダーで発生した問題の解決のためにソースを読む/修正するといった方向けです。
このため、Terraform自体についての説明はかなり省いています。
とはいえ必要に応じて触れますので最低限以下の2点を押さえておけばOKです。

  • Terraformの基本的なコマンド(init/apply/destroyなど)の利用方法について
  • tfファイルの基本的な書き方について

Terraformを利用したことがある方であれば問題ないでしょう。

また、この記事は以下のような内容には触れず、より実装寄りの内容を中心とします。

  • どんな場合にカスタムプロバイダーが必要なのか
  • リソース提供側はどのようなAPIがあると望ましいのか

ということで早速本題に入っていきます。

目次(未確定)

1ポストでは収まらない量になりそうなので複数回に分けて投稿します。
今の所は以下のような感じになる予定です。(確定したら目次を更新します)

Terraform Custom Providerの基本

まずは前提知識としてTerraformでのプロバイダーの扱いについて押さえておきます。

前提知識: Terraformでのプロバイダーの扱い

Terraformはコアな処理を担当するTerraform本体とAWS/GCP/Azureといった各プラットフォームに依存した処理を担当するプロバイダーとで実行ファイルが分離されています。
Terraform本体は必要に応じてプロバイダーの実行ファイルを見つけだしてRPCを行います。

Terraform v0.11の時点ではRPCにnet/rpcが使われています。 v0.12以降ではgRPCに切り替える方向に進んでいます。 参考: v0.12でのprotoファイル

プロバイダーの実行ファイルの探し方についてはこの記事の本筋から外れるため省略します。 細かい例外はありますが、ひとまず以下のルールを覚えておけばOKです。

  • HashiCorp社が配布しているプロバイダーについてはterraform init時に自動でインストールされる
  • サードパーティにより配布されているプロバイダーについては以下のディレクトリから検索される

参考: https://www.terraform.io/docs/extend/how-terraform-works.html#discovery

いくつか例を通じて動きを見ておきます。

HashiCorp社により配布されているプロバイダーのインストール

この例はHashiCorp社により配布されているArukasプロバイダーをインストールする例です。

www.terraform.io

tfファイルを用意しterraform initを実行することでカレントディレクトリの.terraform/配下にプラグインがインストールされます。

# tfファイルを作成
$ echo "provider arukas{}" > test.tf

# プロバイダーをインストール
$ terraform init

# 確認
$ tree -a .

.
├── .terraform
│   └── plugins
│       └── darwin_amd64
│           ├── lock.json
│           └── terraform-provider-arukas_v1.0.0_x4
└── test.tf

なお、今回はtfファイルにproviderブロックを記載していますが、providerブロックは省略可能です。(プロバイダーの実装によります)
以下のようにいきなりリソース定義のみを書く形でもterraform init実行時にtfファイルが解析され必要なプロバイダーの検出が行われます。

resource "arukas_container" "foobar" {
  name      = "example"
  image     = "nginx:latest"
  ports = {
    protocol = "tcp"
    number   = "80"
  }
}

サードパーティにより配布されているプロバイダーのインストール

次にサードパーティにより配布されているプロバイダーをインストールする例です。
Terraform v0.11時点ではサードパーティプロバイダーを自動でインストールする仕組みはまだありません。
なので、自分でプロバイダーの実行ファイルをダウンロードし、前述の検索対象ディレクトリに格納しておく必要があります。

以下はさくらのクラウド向けプロバイダーをダウンロードして利用する例です。 ここではプロバイダーの実行ファイルは~/.terraform.d/plugins配下に格納します。

# プロバイダーの実行ファイルをダウンロード
$ curl -sL -o provider.zip https://github.com/sacloud/terraform-provider-sakuracloud/releases/download/v1.6.1/terraform-provider-sakuracloud_1.6.1_darwin-amd64.zip

# 展開して配置
$ unzip provider.zip ; rm provider.zip
$ mv terraform-provider-sakuracloud_v1.6.1_x4 ~/.terraform.d/plugins/

# tfファイルを作成
$ echo "provider sakuracloud{}" > test.tf

# プロバイダーをインストール
$ terraform init

# 確認
$ tree -a .

.
├── .terraform
│   └── plugins
│       └── darwin_amd64
│           └── lock.json
└── test.tf

先ほどと違い、.terraformディレクトリ配下にプラグインの実行ファイルが格納されないことに注意してください。

当記事ではサンプルのカスタムプロバイダーを作成し実際に動かして動作を確認していきます。
その際にこちらの方法を用いてカスタムプロバイダーをインストールし利用するようにします。

Custom Providerの実装

それでは本題のカスタムプロバイダーの実装について見ていきます。

最初に、公式ドキュメントのガイドの中にWriting Custom Providersというドキュメントが 用意されていますので目を通しておきます。

www.terraform.io

このドキュメントによると、カスタムプロバイダーを作成するには最低限以下が必要ということです。

  • (1) terraform.ResourceProviderを返すfunc
  • (2) plugin.Serve()に(1)のfuncを指定して呼び出すエントリーポイント
  • (3) terraform-provider-<プロバイダー名>という名前でビルド

それぞれを詳しく見ていきます。実際に手を動かしながら確認出来るようにソース一式を以下に準備しました。

github.com

サンプルとしてterraform-provider-minimumというカスタムプロバイダーを作成してみます。 以降はこのサンプルを元に各項目を見ていきます。

カスタムプロバイダーのサンプルのソースコードを取得

まずはサンプルをローカルマシンにクローンし、strucrureブランチをチェックアウトしてください。

# *GOPATHを設定していない場合は$HOME/goに読み替えてください*

# ソース一式を取得 & structureブランチのチェックアウト
$ go get github.com/yamamoto-febc/terraform-provider-minimum
$ cd $GOPATH/src/github.com/yamamoto-febc/terraform-provider-minimum
$ git checkout structure

以下のようなファイルが含まれているはずです。

$ tree .
 .
├── Gopkg.lock
├── Gopkg.toml
├── main.go
└── minimum
    └── provider.go

それでは順番に見ていきます。

(1) terraform.ResourceProviderを返すfunc

まずはminimum/provider.goを見てみます。

package minimum

import (
    "github.com/hashicorp/terraform/helper/schema"
    "github.com/hashicorp/terraform/terraform"
)

// Provider returns a terraform.ResourceProvider.
func Provider() terraform.ResourceProvider {
    return &schema.Provider{
        ResourcesMap: map[string]*schema.Resource{
            // ここにリソースの定義を書いていく
        },
    }
}

ここではterraform.ResourceProviderを返すfuncProvider()を定義しています。terraform.ResourceProviderはinterfaceです。

https://github.com/hashicorp/terraform/blob/v0.11.8/terraform/resource_provider.go#L19

このインターフェースを実装することでTerraform本体とのやり取りが可能になります。
実際にはこのインターフェースを実装したstructschema.Providerが用意されていますので、これを利用します。

https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/provider.go#L25

現在はリソースの定義は空になっています。次回記事でここに定義を追記していきます。

(2) plugin.Serve()に(1)のfuncを指定して呼び出すエントリーポイント

次にエントリーポイントであるmain.goを見てみます。

package main

import (
    "github.com/hashicorp/terraform/plugin"
    "github.com/yamamoto-febc/terraform-provider-minimum/minimum"
)

func main() {
    plugin.Serve(&plugin.ServeOpts{
        ProviderFunc: minimum.Provider,
    })
}

plugin.Serveに引数として(1)で定義したProvider()を指定しています。
plugin.Serve()を実行することで、Terraform本体とRPCでやり取りするための諸々を行ってくれます。
これによりカスタムプロバイダーの作成者はRPCについて意識することなくそれぞれが担当するリソースの操作に集中できます。

(3) terraform-provider-<プロバイダー名>という名前でビルド

あとはgo buildコマンドでビルドするだけです。Terraformは前提知識の項で触れたようにカスタムプロバイダーを検出してくれる仕組みがありますが、
そのためにはカスタムプロバイダーの実行ファイルを特定のディレクトリに配置することに加え、 実行ファイルの名前をterraform-provider-<プロバイダー名>という形式にしておく必要があります。

このため、ビルド時に-oオプションで出力されるファイル名を指定する必要があります。 また、ビルドのために依存ライブラリを用意しておく必要があります。

今回はdepで依存関係を管理するようにしておきましたので、dep ensureを実行してからビルドするようにしてください。

# depがない場合は以下でインストール
# go get -u github.com/golang/dep/cmd/dep

$ dep ensure
$ go build -o terraform-provider-minimum main.go

これでカレントディレクトリにterraform-provider-minimumという実行ファイルが作成されているはずです。

余談1: カスタムプロバイダーでの依存ライブラリの管理方法

Terraform本体はgovendorを利用して依存ライブラリを管理していますが、 本体とカスタムプロバイダーは実行ファイルが分かれているためビルドさえできれば無理にgovendorを利用する必要はありません。

terraform-providers配下のプロバイダーにおいても統一されてない状況で、 AWS/GCP/AzureRMといったプロバイダーはgovendorを利用していますが、 Herokuプロバイダーなどではdepが使われています。

動作確認

ビルドしたカスタムプロバイダーが動作するか確認します。

実際にtfファイルを作成しterraform initを実行してみましょう。 terraform versionでminimumプロバイダーが表示されていればOKです。

# tfファイルを作成
$ echo "provider minimum{}" > test.tf

# プロバイダーをインストール
$ terraform init

# 確認
$ terraform version

Terraform v0.11.8
+ provider.minimum (unversioned)

余談2: プロバイダーのバージョンについて

terraform versionを実行するとminimumプロバイダーのバージョンがunversionedになっているのに気づかれたかもしれません。
実はプロバイダーの実装としてはバージョン情報を持っておらず、プロバイダーのファイル名からバージョン情報を取得しています。

先ほどArukasプロバイダーを自動でインストールする例が出てきましたが、そこではプロバイダーのファイル名は以下のようになっていました。

terraform-provider-arukas_v1.0.0_x4

プロバイダーのファイル名は正式には以下の形式を持ちます。

terraform-<プラグインのタイプ>-<名称>_v<プラグインのバージョン>_x<プラグインプロトコルのバージョン>

リリース時のバージョニング処理

HashiCorp社が配布しているプロバイダーの場合はリリース時にCIサーバによってバージョンが決定されファイル名が付与されます。
(CIにはTeamCityが利用されており、CI/CDパイプラインの中でCHANGELOG.mdの解析をしてリリースするバージョンを決定しています)

サードパーティのプロバイダーについてはこの辺の処理を自前で行う必要があります。
参考までに、さくらのクラウド向けプロバイダーではこの辺の処理を行なっていますので興味のある方はMakefileあたりから眺めてみてください。

余談3: ソースコードのレイアウト/パッケージ構成について

公式ガイドのWriting Custom Providersexampleプロバイダーとminimumプロバイダーとで ソースコードのレイアウトが違うことにお気付きの方もいらっしゃるかと思いますが、これはterraform-providersでのレイアウトに合わせているからです。

公式ガイドでのサンプルexampleプロバイダーのソースコードレイアウト
.
├── main.go
└── provider.go
minimumプロバイダーのソースコードレイアウト
 .
├── main.go
└── minimum          # プロバイダー固有コードを格納するパッケージ(リソースを追加する際もここに追加する)
    └── provider.go

公開予定の無い自作プロバイダーであればどちらを選んでも良いですが、特に理由がなければ後者のように別途パッケージを設けて その中にプロバイダー固有のコードを置き、ルートディレクトリにはmain.goだけおく形にしておくのが良さそうです。


ここまででカスタムプロバイダーとしての最低限の体裁が整いました。
しかし、リソースの実装が空ですので現時点では特に何もできない状態です。
実装は次回行います。

第1回 まとめ

今回はカスタムプロバイダーの基礎知識をおさえ、カスタムプロバイダーとしての最小限のコードを書いた上でビルドしてTerraformから利用してみました。
今回はリソースの実装が空のためtfファイルを書く機会もなかったですが、次回の記事でリソース実装を追加していくことでよりプロバイダーらしくしていきます。

以上です。

Next: リソース実装 基礎 - schema.Resourceでのリソース実装の基礎

[asin:B06XKHGJHP:detail]