【2019年4月版】Terraform+Spinnakerがよくね!? → 闇が深い。

# GKE + Spinnaker の構築を Terraform で行ってみた。

[先週の記事で Spinnaker 構築いたしました](https://qiita.com/koinori/items/017d74aa0bffa5ec9f44)が手順が多い上にいろいろ展開させたいので **Terraform で構築できるようにしたらよくね？**っと思いつき、チャレンジしてみました！！が・・・Terraform + GCP の闇にガッツリはまりましたので顛末をご紹介いたします。

Terraform ですが、例の HashiCorp. 御謹製のツールで、**Vagrant のクラウド版**といった感じのツールです。

- https://www.terraform.io/

で、Terraform 頑張ってみましたが、そもそも Spinnaker だけでもかなりいろいろできるものなのでそれだけをサービスしちゃってる会社もあるくらい。

- https://www.armory.io/

(たぶん、Terraform で Spinnaker がっつりやってるはずです。Spinnaker + Terraform で多数のブログが見つかりますもん。)

さらに kubernetes というか GKE に乗せるというのは割と大変でした。はっきり言って **敵はロール**。

~~なんだか Terraform の GCP Provider と GoogleのAPI が微妙にかみ合っていない感じで・・・。~~
とりあえず、動作確認最優先でトライしてみました。

## 対象環境

- OS : Ubuntu 18.04 LTS
- Terraform : 0.11.13
- Terraform : 0.11.13 !!
- Terraform : 0.11.13 !!!
- GCP Provider : 2.5
- Kubernetes Provider : 1.6

まず、OS は Ubuntu でやってます。Windows 10 の git-bash でも試しましたがなんだか微妙です。Linux か mac 版 Terraform がおすすめです。

続いて Terraform のバージョン、大事なポイントなので 3 回書きましたが・・・**そろそろ 0.12 が出るらしい**です。ですがこの記事はあくまで **0.11.13** が対象です！
また issue にも過去のバージョンだとうまくいくとかいろいろ出てます。Terraform はまだまだ 1.0 ではないので毎度、破壊的更新が入っている模様です。

で、GCP Provider だけ妙に進化してて 2.5 です。でもやっぱり本体の API がなんか微妙なんだよね。。。
(特に K8s との相性が良くないかも・・・？)

というわけで以下、手順をご紹介いたします。

# 環境構築

## 1. Google Cloud Platform での準備

### 1-1. API の有効化

Spinnaker をインストールするにあたって以下のサービス API を有効にしておきます。

- Google Identity and Access Management (IAM) API
- Google Cloud Resource Manager API

### 1-1. Terraform 用サービスアカウントの作成

GCP の WEB 管理画面より Terraform 用のサービスアカウントを作成します。
またこのアカウントのロールですが、Spinnaker 用のサービスアカウントにロールを割り当てるためにどのロールを持つべきかいまだに不明なので`オーナー`で作成してしまいました。(これは要検討です。)
作成後、このサービスアカウントのアクセスキーを json 形式でダウンロードしてきます。以下、`account.json` とします。

### 1-2. Cloud Storage でバケットの作成

Terraform は様々なリソース[^1] の状態管理に json ファイルを使用しますが、ローカルだと複数メンバで作業したときに衝突が発生するので GCS を利用してグローバルな状態管理を有効にします。
また GCS のバケット名は**グローバルで一意**である必要があります。適当に名前を付けてください。

[^1]: Terraform 管理対象となるアカウントや VM などといったすべてのオブジェクト。Terraform ではこの*リソース*と*その状態*を主に管理します。

## 2. Terraform のインストール

以下の手順で Terraform をインストールします。

```console
$ git clone https://github.com/tfutils/tfenv.git ~/.tfenv
$ echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bash_profile
$ . ~/.bash_profile
$ tfenv install latest
```

`tfenv` は `nvm` ライクなバージョン管理ツールです。ホント便利。

# 定義ファイルの作成

今回は `main.tf` に全部乗せしてしまいます。
以下の手順で `main.tf` にガリガリ書いていきます。

## Terraform バックエンドの設定

上記で作成したストレージのバケットを参照します。ストレージのバケット名に"sample_terra_spinn_04201503"と名前を付けた場合は・・・

```terraform:main.tf
terraform {
  backend "gcs" {
    bucket  = "sample_terra_spinn_04201503"
    prefix  = "terraform/state"
  }
}
```

こんな感じです。~~terraformの書式にまだQiitaは対応してないのか。~~

## GCP Provider の追加

続いて Google Cloud Platform への接続情報を追加します。各 `variable` の `default` 値はそれぞれ作成したプロジェクトに合わせてください。

```terraform:main.tf
variable "project_id" {
  default = "my-fantastic-terra-spin-project"
}

variable "gcp_zone" {
  default = "us-central1-c"
}

variable "region" {
  default = "us-central1"
}

provider "google" {
  credentials = "${file("account.json")}"
  project     = "${var.project_id}"
  region      = "${var.region}"
  zone        = "${var.gcp_zone}"
  version     = "~> 2.5"
}
```

プロジェクト名や GCP のゾーン、リージョンは K8s と VM の構築でも参照するので変数名としておきます。local でも OK かとおもいます。

- [Google Cloud Platform Provider のドキュメント](https://www.terraform.io/docs/providers/google/index.html)

## Kubernetes Provider の追加

続いて GKE で作成した後の k8s を操作するには別途、K8s 専用のプラグインを使いますので、k8s provider を追加します。

```terraform:main.tf
provider "kubernetes" {
  host                   = "${google_container_cluster.gke.endpoint}"
  username               = "${google_container_cluster.gke.master_auth.0.username}"
  password               = "${google_container_cluster.gke.master_auth.0.password}"
  client_certificate     = "${base64decode(google_container_cluster.gke.master_auth.0.client_certificate)}"
  client_key             = "${base64decode(google_container_cluster.gke.master_auth.0.client_key)}"
  cluster_ca_certificate = "${base64decode(google_container_cluster.gke.master_auth.0.cluster_ca_certificate)}"
  version                = "~> 1.6"
}
```

`google_container_cluster.gke` はまだ存在しませんが、後ほど作成する GKE のクラスタです。
こんな感じで GKE クラスタ -> Kubernetes Provider へ接続情報を渡すとイケました。

- [Adding secrets to kubernetes cluster in GCP with Terraform](https://medium.com/@mclavel/adding-secrets-to-kubernetes-cluster-in-gcp-with-terraform-63239082c454)

## プラグインを実際にインストール

ここでターミナルにて以下のコマンドを打ちます

```console
$ export GOOGLE_CREDENTIALS=$(cat account.json)
$ export GOOGLE_APPLICATION_CREDENTIALS="account.json"
$ terraform init
```

上記の `terraform init`コマンドで`Provider` の記述を認識して実際に`GCP Provider`、`k8s provider`などがローカルにインストールされます。
ただし、GCS を使用すると Google の Default Credentials が使われるので、プロジェクトの設定と合わなくなると思います。
上記のように一時的に、`GOOGLE_CREDENTIALS`と`GOOGLE_APPLICATION_CREDENTIALS` を export してダウンロードしてきた `account.json`を参照するようにします。

.bashrc や.bash_profile などで恒久的に export してしまうと、他のプロジェクトに影響あるので毎度、一時的にしないとダメなのがツラいです。`このディレクトリ以下だけ有効になる環境変数`みたいなツールどっかにあったような。。。

## サービスアカウントの作成

以下の Spinnaker のインストール手順を参考に、Terraform 設定ファイルを起こしていく作業となります。

- https://www.spinnaker.io/setup/quickstart/halyard-gke/

ここでは`halyard`と`spinnaker`用のサービスアカウントを作成し、それぞれに以下のようにロールを割り当てます。

- halyard : `roles/iam.serviceAccountKeyAdmin` と `roles/container.admin`
- spinnaker : `roles/storage.admin` と `roles/browser`

```terraform:main.tf
resource "google_service_account" "halyard" {
  account_id   = "halyard-service-account"
  display_name = "halyard & Spinnaker service account"
}

resource "google_project_iam_member" "halyard-iam-keyadmin" {
  depends_on = ["google_service_account.halyard"]
  role       = "roles/iam.serviceAccountKeyAdmin"
  member     = "serviceAccount:${google_service_account.halyard.email}"
}

resource "google_project_iam_member" "halyard-iam-containeradmin" {
  depends_on = ["google_service_account.halyard"]
  role       = "roles/container.admin"
  member     = "serviceAccount:${google_service_account.halyard.email}"
}

resource "google_service_account" "spin-gcs" {
  account_id   = "spin-gcs-service-account"
  display_name = "halyard & Spinnaker cloud storage service account"
}

resource "google_project_iam_member" "SpinGCS-iam-storageadmin" {
  depends_on = ["google_service_account.spin-gcs"]
  role       = "roles/storage.admin"
  member     = "serviceAccount:${google_service_account.spin-gcs.email}"
}

resource "google_project_iam_member" "SpinGCS-iam-browser" {
  depends_on = ["google_service_account.spin-gcs"]
  role       = "roles/browser"
  member     = "serviceAccount:${google_service_account.spin-gcs.email}"
}
```

`display_name` は説明表示用なので適当で OK です。

"google*project_iam*\*"系のリソースが厄介です。今回はこれがうまくいかなかったので`オーナー`のロールで動かしてます。

また、`google_service_account`リソースは本家サイトのヘルプにもある通り、"eventually consistent" です。

- https://www.terraform.io/docs/providers/google/r/google_service_account.html

API の終了とともにサービスアカウントが作成されるわけではないので、後続処理も*遅延*させる必要があります。[過去の issue で local_exec provisioner 使って sleep してる技も紹介されています](https://github.com/hashicorp/terraform/issues/17726#issuecomment-377357866)が、ここでは depends_on で順番指定して、タイムアウトに間に合っているので OK という感じです。

## GKE で K8s クラスタの作成

```terraform:main.tf
variable "gke_cluster_name" {
  default = "standard-cluster-1"
}

resource "google_container_cluster" "gke" {
  depends_on = ["google_service_account.halyard",
    "google_service_account.spin-gcs",
  ]

  name        = "${var.gke_cluster_name}"
  location    = "${var.gcp_zone}"
  description = "K8s build by Terraform"

  remove_default_node_pool = true
  initial_node_count       = 1
}

resource "google_container_node_pool" "spinnaker" {
  name       = "default-node-pool"
  location   = "${var.gcp_zone}"
  cluster    = "${google_container_cluster.gke.name}"
  node_count = 3
  node_config {
    preemptible  = true
    machine_type = "n1-standard-4"
    metadata {
      disable-legacy-endpoints = "true"
    }
    oauth_scopes = [
      "logging-write",
      "monitoring",
    ]
  }
}
```

とりあえず K8s のハコは`google_container_cluster`と`google_container_node_pool`の 2 リソースで OK です。

## K8s クラスタに Spinnaker 用の権限などを追加

これもいろいろ試しましたが、以下の設定で落ち着いてます。

```terraform:main.tf
resource "kubernetes_cluster_role_binding" "client-admin-binding" {
  metadata {
    name = "client-admin"
  }

  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "ClusterRole"
    name      = "cluster-admin"
  }

  subject {
    kind      = "User"
    name      = "client"
  }
}

resource "kubernetes_namespace" "spinnaker" {
  metadata {
    annotations {
      name = "spinnaker"
    }

    name = "spinnaker"
  }
}

resource "kubernetes_service_account" "spinnaker-sa" {
  depends_on = ["kubernetes_namespace.spinnaker"]
  metadata {
    name      = "spinnaker-service-account"
    namespace = "spinnaker"
  }
}

resource "kubernetes_cluster_role_binding" "spinnaker-binding-admin" {
  depends_on = ["kubernetes_namespace.spinnaker"]
  metadata {
    name = "spinnaker-admin"
  }

  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "ClusterRole"
    name      = "cluster-admin"
  }

  subject {
    kind      = "ServiceAccount"
    name      = "spinnaker-service-account"
    namespace = "spinnaker"
  }
}

resource "kubernetes_cluster_role" "spinnaker-role" {
  metadata {
    name = "spinnaker-role"
  }

  rule {
    api_groups = [""]
    resources  = ["namespaces", "configmaps", "events", "replicationcontrollers", "serviceaccounts", "pods/log"]
    verbs      = ["get", "list"]
  }

  rule {
    api_groups = [""]
    resources  = ["pods", "services", "secrets"]
    verbs      = ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"]
  }

  rule {
    api_groups = ["autoscaling"]
    resources = ["horizontalpodautoscalers"]
    verbs     = ["list", "get"]
  }

  rule {
    api_groups = ["apps"]
    resources = ["controllerrevisions", "statefulsets"]
    verbs     = ["list"]
  }

  rule {
    api_groups = ["extensions", "apps"]
    resources = ["deployments", "replicasets", "ingresses"]
    verbs     = ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"]
  }

  # These permissions are necessary for halyard to operate. We use this role also to deploy Spinnaker itself.
  rule {
    api_groups = [""]
    resources = ["services/proxy", "pods/portforward"]
    verbs     = ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"]
  }
}

resource "kubernetes_cluster_role_binding" "spinnaker-role-bindings" {
  depends_on = ["kubernetes_namespace.spinnaker", "kubernetes_service_account.spinnaker-sa"]
  metadata {
    name = "spinnaker-role-binding"
  }
  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "ClusterRole"
    name      = "spinnaker-role"
  }
  subject {
    kind      = "ServiceAccount"
    name      = "spinnaker-service-account"
    namespace = "spinnaker"
  }
}
```

というか、ここでのキモはやっぱり `client-admin-binding` ですね。。。こうするしかないのかなぁ・・・？

## GCE で Halyard VM の作成

### SSH キーの生成

まず、terraform が vm に ssh 接続するための ssh キー を*パスワードなし*で生成しておきます。

`ssh-keygen`で作成したキーを`ssh/halyard_vm_rsa.pub`と`ssh/halyard_vm_rsa`としておきます。

### VM 定義の記述

以下のように`small`な VM を作成します。

```terraform:main.tf
data "google_compute_image" "ubuntu_image" {
  name    = "ubuntu-1404-trusty-v20190410"
  project = "ubuntu-os-cloud"
}

variable "gcp_account_name" {
  default = "terraform-halyard"
}

resource "google_compute_instance" "halyard" {
  depends_on   = ["google_container_node_pool.spinnaker"]
  name         = "${var.halyard_vm_name}"
  machine_type = "g1-small"

  boot_disk {
    initialize_params {
      image = "${data.google_compute_image.ubuntu_image.self_link}"
    }
  }

  network_interface {
    network       = "default"
    access_config = {}
  }

  service_account {
    email  = "${google_service_account.halyard.email}"
    scopes = ["cloud-platform"]
  }

  metadata {
    "block-project-ssh-keys" = "true"
    "sshKeys"                = "${var.gcp_account_name}:${file("ssh/halyard_vm_rsa.pub")}"
  }

  provisioner "remote-exec" {
    inline = []
    connection {
      type        = "ssh"
      user        = "${var.gcp_account_name}"
      private_key = "${file("ssh/halyard_vm_rsa")}"
      timeout     = "5m"
    }
  }
}
```

`metadata` で pub キーを渡し、remote-exec provisioner で pri キーを使用して接続します。

## Spinnaker のインストール手順を追加

上記の remote-exec provisioner では `inline` の設定が空っぽになっておりました。
ここに halyard の手順書にある通り、怒涛の設定コマンドを追記します。remote-exec provisionar の箇所だけ抜粋すると以下のようになります。

```terraform:main.tf
  provisioner "remote-exec" {
    inline = [
      "KUBECTL_LATEST=$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)",
      "curl -LO https://storage.googleapis.com/kubernetes-release/release/$KUBECTL_LATEST/bin/linux/amd64/kubectl",
      "chmod +x kubectl",
      "sudo mv kubectl /usr/local/bin/kubectl",
      "curl -O https://raw.githubusercontent.com/spinnaker/halyard/master/install/debian/InstallHalyard.sh",
      "sudo bash InstallHalyard.sh --user ${var.gcp_account_name} -y",
      ". ~/.bashrc",
      "gcloud config set container/use_client_certificate true",
      "gcloud container clusters get-credentials ${var.gke_cluster_name} --zone=${var.gcp_zone}",
      "GCS_SA_DEST=~/.gcp/gcp.json",
      "mkdir -p $(dirname $GCS_SA_DEST)",
      "gcloud iam service-accounts keys create $GCS_SA_DEST --iam-account ${google_service_account.spin-gcs.email}",
      "hal config version edit --version $(hal version latest -q)",
      "hal config storage gcs edit --project $(gcloud info --format='value(config.project)') --json-path $GCS_SA_DEST",
      "hal config storage edit --type gcs",
      "hal config provider docker-registry enable",
      "hal config provider docker-registry account add my-gcr-account --address gcr.io --password-file $GCS_SA_DEST --username _json_key",
      "CONTEXT=$(kubectl config current-context)",
      "TOKEN=$(kubectl get secret --context $CONTEXT ${kubernetes_service_account.spinnaker-sa.default_secret_name} -n spinnaker -o jsonpath='{.data.token}' | base64 --decode)",
      "kubectl config set-credentials $${CONTEXT}-token-user --token $TOKEN",
      "kubectl config set-context $CONTEXT --user $${CONTEXT}-token-user",
      "hal config provider kubernetes enable",
      "hal config provider kubernetes account add my-k8s-account --docker-registries my-gcr-account --provider-version v2 --context $CONTEXT",
      "hal config deploy edit --account-name my-k8s-account --type distributed",
      "hal deploy apply",
    ]

    connection {
      type        = "ssh"
      user        = "${var.gcp_account_name}"
      private_key = "${file("ssh/halyard_vm_rsa")}"
      timeout     = "5m"
    }
  }
```

通常の手順に加えて、以下の RBAC の手順も追加してます。

- https://www.spinnaker.io/setup/install/providers/kubernetes-v2/#optional-create-a-kubernetes-service-account
- https://www.spinnaker.io/setup/install/providers/kubernetes-v2/#optional-configure-kubernetes-roles-rbac

これはもう、ここまでくると各コマンドがうまくいくように祈るだけです。

以上で terraform の設定ファイルは作成完了です！

## デプロイと接続

### Terraform の実行

以下の手順で Terraform を実行します。

```
$ terraform plan
$ terraform apply
```

`terraform plan` で実行計画が表示されます。追加、変更、削除など操作予定が表示されますので確認いたします。

`terraform apply` で上記の実行計画が再度、表示され、問題なければ `yes` と入力します。

しばらく待てば、halyard 用 VM と Spinnaker クラスタの出来上がりです。

### Halyard VM に接続

以下のコマンドで Halyard 用 VM に接続します。
例によって **ブラウザから localhost が見れる環境** からです！(トラウマ)

```
$ gcloud compute ssh $HALYARD_HOST \
    --project=$GCP_PROJECT \
    --zone=us-central1-f \
    --ssh-flag="-L 9000:localhost:9000" \
    --ssh-flag="-L 8084:localhost:8084"
```

VM につながったら、以下のコマンドで Spinnaker に接続します。

```
$ hal deploy connect
```

`forwarding`の表示が出たら、ローカルマシンのブラウザを立ち上げましょう。`http://localhost:9000` で Spinnaker の管理画面が表示されたら OK です。

お疲れさまでした！

# Spinnaker の破棄

以下のコマンドで Terraform で構築した GCP の環境を破棄できます。

```
$ terraform destroy
```

ただし、GKE クラスタの削除にはかなり時間がかかる模様です。
デフォルトのタイムアウト(5m)では(たぶん)間に合わないので、その際は手作業で削除しましょう。

# 反省点というか問題点

冒頭にも記載しましたがいくつか、**ロール**で強行突破してしまっている点があります。

- Terraform 用のサービスアカウントが`オーナー`
- GKE クラスタの`client`が`admin`

上記の手順ではここが思いっきり穴なので、引き続き見直していきたいと思います。

いや～大変でしたが、今回はここまでといたします！