【GCP CI/CD】masterブランチにプッシュしたら勝手にGKEにデプロイされるやつを作る

# なに作るの？

1. GitHubのmasterブランチにプッシュされたら、
2. [Cloud Build](https://cloud.google.com/cloud-build/?hl=ja)が勝手にビルド始めて、Dockerイメージを[GCR](https://cloud.google.com/container-registry/)にプッシュ、
3. そのDockerイメージを使って[GKE](https://cloud.google.com/kubernetes-engine/?hl=ja)に自動デプロイ

みたいなものを作ります。

GCPのサービスは可能な限り、[Terraform](https://www.terraform.io/)で管理していきます。

**なるべく詳しい説明をなくし、スピーディに動くものができるように進めていきます。** (~~怠惰~~)

では、はりきっていきましょう！
（わかりにくいところありましたらコメントください。アジャイル的に説明を追加していきます。）

Kubernetesを知らない人は、[@MahoTakaraさんの記事](https://qiita.com/MahoTakara/items/85096f8b2632c802ab22)を読んでおきましょう。

また、今回使うコードはすべて[こちらのリポジトリ](https://github.com/YuitoSato/gcp-yuito-sandbox/)で公開されています。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/92468/b5f89855-9ac4-44cb-f968-ca06215a33bd.png)


こんな感じの自動のビルドステップが並ぶものを作っていきます。

# 用語
GKE(Google Kubernetes Engine)
GCB(Google Cloud Build)
GCR(Google Container Registry)
GSR(Google Cloud Source Repositories)

※略で呼ばれるのが一般的でないものもありますが、省略のため上記の用語を使います。


# 目次
- 前提
- とりあえずHello Worldサーバーを作る
- GCPの各サービスを有効化する
- Terraformで各サービスを作成する
- GCSとGitHubを連携する
- GCBでビルドする
- Deployment Manifestを書く
- Service Manifestを書く
- GCBでデプロイする
- 試してみる
- 課金されないようにお掃除する

# 前提
以下の設定や、ライブラリがインストールされていることが前提となります💁‍♀️

- kubectlがインストールされている
- gcpの空のプロジェクトがとりあえずある
- gcloudがインストールされている
- gcloudのconfigがセットされている
- GCPの課金が有効化されている

```bash
$ kubectl version
Client Version: version.Info{Major:"1", Minor:"11", GitVersion:"v1.11.2", GitCommit:"bb9ffb1654d4a729bb4cec18ff088eacc153c239", GitTreeState:"clean", BuildDate:"2018-08-08T16:31:10Z", GoVersion:"go1.10.3", Compiler:"gc", Platform:"darwin/amd64"}
Server Version: version.Info{Major:"1", Minor:"11+", GitVersion:"v1.11.7-gke.4", GitCommit:"618716cbb236fb7ca9cabd822b5947e298ad09f7", GitTreeState:"clean", BuildDate:"2019-02-05T19:22:29Z", GoVersion:"go1.10.7b4", Compiler:"gc", Platform:"linux/amd64"}
```

```bash
$ gcloud -v
Google Cloud SDK 234.0.0
beta 2019.01.19
bq 2.0.41
core 2019.02.08
gsutil 4.36
kubectl 2018.09.17
Updates are available for some Cloud SDK components.  To install them,
please run:
```

```bash
$ gcloud config list
[compute]
region = asia-northeast1
zone = asia-northeast1-a
[core]
account = hogehoge@gmail.com
disable_usage_reporting = True
project = gcp-yuito-sandbox
```

こんな感じになっていれば大丈夫です。

# とりあえずHello Worldサーバーを作る
なんでもいいですが、今回はNodejsとexpressで簡単なサーバーを作っています。

```ts:server.ts
import express from 'express';

const app = express();

app.get('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
  return res.send('Hello World!!!!!!!');
});

app.listen(3000, () => { console.log('Listen to 3000 port...'); });

export default app;
```

[GitHubのコードはこちら](https://github.com/YuitoSato/gcp-yuito-sandbox/tree/master/nodejs-api)


# GCPの各サービスを有効化する
gcloudで各サービスを有効化します。

```bash
# GCB
$ gcloud services enable cloudbuild.googleapis.com

# GKE
$ gcloud services enable container.googleapis.com

# GCR
$ gcloud services enable containerregistry.googleapis.com

# GSR
$ gcloud services enable sourcerepo.googleapis.com
```

# Terraformで各サービスを作成する
次にTerraformで各サービスを作成していきます

[GitHubのコードはこちら](https://github.com/YuitoSato/gcp-yuito-sandbox/blob/master/terraform/main.tf)

```tf:terraform/main.tf
variable "project" {
  default = "gcp-yuito-sandbox"
}

variable "region" {
  default = "asia-northeast1"
}

variable "zone" {
  default = "asia-northeast1-a"
}

variable "network_name" {
  default = "gcp-yuito-sandbox"
}

provider "google" {
  region = "${var.region}"
  project = "${var.project}"
}

// Network
resource "google_compute_network" "default" {
  name                    = "${var.network_name}"
  auto_create_subnetworks = false
}

// Subnetwork
resource "google_compute_subnetwork" "default" {
  name                     = "${var.network_name}"
  ip_cidr_range            = "10.127.0.0/20"
  network                  = "${google_compute_network.default.self_link}"
  region                   = "${var.region}"
  private_ip_google_access = true
}

// Cluster
resource "google_container_cluster" "default" {
  name               = "${var.network_name}"
  zone               = "${var.zone}"
  initial_node_count = 3
  min_master_version = "1.11.7-gke.4"
  network            = "${google_compute_subnetwork.default.name}"
  subnetwork         = "${google_compute_subnetwork.default.name}"

  enable_legacy_abac = true

  master_auth {
    username = ""
    password = ""
  }

  provisioner "local-exec" {
    when    = "destroy"
    command = "sleep 90"
  }

  node_config {
    oauth_scopes = [
      "https://www.googleapis.com/auth/compute",
      "https://www.googleapis.com/auth/devstorage.read_only",
      "https://www.googleapis.com/auth/logging.write",
      "https://www.googleapis.com/auth/monitoring",
    ]
    preemptible  = true
    machine_type = "g1-small"
  }
}

// Static IP
resource "google_compute_global_address" "ip_address" {
  name = "gcp-yuito-sandbox-static-ip"
}

// Cloud Build
resource "google_cloudbuild_trigger" "default" {
  // 実行トリガーの設定
  trigger_template {
    // ブランチを設定すると「このブランチにコミットがあった時」がトリガーになります
    branch_name = "master"
    // 連携するGitリポジトリ。後で解説します。
    repo_name   = "github_YuitoSato_gcp-yuito-sandbox"
  }

  // Cloud Buildで使用したいymlファイル
  filename = "nodejs-api/infra/staging/cloudbuild.yml"

  // 変更を検知したいパス。今回はnodejs-api/srcコード以下に変更があったらビルドしてデプロイをする。
  included_files = [
    "nodejs-api/src/**"
  ]
}
```

TerraformをGCPに適応していきます。

```bash
$ cd terraform
$ terraform init 
$ terraform plan
# 追加リソースが4つであることを確認
$ terraform apply
```

※ 5分くらい時間がかかります

ここでは細かい説明ば省略させていただきます。
各項目が何を表しているかは、[Terraform × GCPのドキュメント](https://www.terraform.io/docs/providers/google/index.html)がタメになると思います。

# GSRとGitHubを連携する
GSRとGitHubを連携し、GitHubに変更があったらGSRに同じ変更が入るようにします（ミラーリング）
※ ここだけGitHubと連携させるためTerraformで表現できませんでした🙇‍♀️

[まずはこのページで「外部リポジトリの接続」を押します](https://source.cloud.google.com/repo/new)

プロジェクトIDには **先程Terraformで設定したリポジトリ名を設定します。**
名前は揃っていれば何でも大丈夫です。

```terraform/main.tf
// 中略

// Cloud Build
resource "google_cloudbuild_trigger" "default" {
  trigger_template {
    branch_name = "master"

    // ココ！！！
    repo_name   = "github_YuitoSato_gcp-yuito-sandbox"
  }
  
  // 中略
}
```

あとはプロバイダにGitHubを選択し、連携するGitHubのリポジトリを選択すれば完成です。

# GCBでビルドする
次はいよいよビルドしていきます。
GCBでアプリをビルドして、そこからDockerイメージを作成、そのDockerイメージをGCRにプッシュします。

## Dockerfile
まずはDockerfileを書いていきます。
今回はNodejsですが、もちろんなんでも大丈夫です。

```Dockerfile:nodejs-api/infra/staging/Dockerfile

FROM node:11.10.0

# 環境変数はproductionを設定しておいてください。
ENV NODE_ENV=production
ENV LANG C.UTF-8

ARG project_dir=/usr/src/app

ADD ./dist/server.js $project_dir/dist/
ADD ./package.json $project_dir

WORKDIR $project_dir

RUN rm -rf node_modules && \
    npm i

EXPOSE 3000

# 本番環境で動かす時の起動コマンドを書きましょう。
ENTRYPOINT ["node", "./dist/server.js"]
```

TypescriptをコンパイルしたJSファイルをコピーして、npm installしているだけです。


## cloudbuild.yml
次はcloudbuild.yml（GCBの実行ステップを記述していくファイル）を書いてきます。


```yml:nodejs-api/infra/staging/cloudbuild.yml

steps:
  - name: 'node:11.10.0'
    id: 'Clean node_modules'
    args: ['rm', '-rf', 'node_modules']
    dir: 'nodejs-api'

  - name: 'node:11.10.0'
    id: 'Clean Dist Dir'
    args: ['rm', '-rf', 'dist']
    dir: 'nodejs-api'

  - name: 'node:11.10.0'
    id: 'NPM Install'
    args: ['npm', 'install']
    dir: 'nodejs-api'

  - name: 'node:11.10.0'
    id: 'Build Dist File'
    args: ['npm', 'run', 'staging-build']
    dir: 'nodejs-api'

  - name: 'gcr.io/cloud-builders/docker'
    id: 'Build Image'
    args: ['build', '-t', 'gcr.io/gcp-yuito-sandbox/nodejs-api:$SHORT_SHA', '.', '-f', 'infra/staging/Dockerfile']
    dir: 'nodejs-api'

  - name: 'gcr.io/cloud-builders/docker'
    id: 'Push to GCR'
    args: ['push', 'gcr.io/gcp-yuito-sandbox/nodejs-api:$SHORT_SHA']
    dir: 'nodejs-api'
```

**1ステップ1コンテナ**を軸にステップを書いていきます。
`name`のところは使用するイメージを記述します。
`dir`はデフォルトでリポジトリのルートディレクトリですが、 `dir` で設定することができます。

**`name`で指定したイメージをベースとしてコンテナが立ち上がり、**
**`dir`配下にあるファイルがすべてコンテナ内にコピーされ、**
**`args`で処理が実行されます。**

Dockerイメージのビルドや、GCRへのプッシュには、`gcr.io/cloud-builders/docker`というDockerイメージを使うことができます。

また `SHORT_SHA` という環境変数はトリガーベースで実行した時のみ使用できるGCB用の変数だそうです。
最新のコミットハッシュの頭7文字が変数の中に格納されています。
今回はそのコミットハッシュをイメージのタグとして扱います。

## ビルドを試してみる
ここまでで、Terraformでトリガーの設定が終わっているはずなので実際にmasterブランチに対してプッシュをしてみてください。

プッシュした瞬間にGCBでビルドが始まり、GCRにプッシュされたイメージが確認できたら大丈夫です。


# Deployment Manifestを書く
次はDeployment Manifestを書いていきます。

**Deployment Manifestはどう実際にクラスタ内の一つのアプリケーションを動かすかを定義できるアプリケーションの雛形ファイル的なやつです。**

使用するイメージ名や、レプリカの数などを設定することができます。

```yml:nodejs-api/infra/staging/nodejs-api-deployment.yml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: nodejs-api
spec:
  replicas: 2
  template:
    metadata:
      labels:
        run: nodejs-api
    spec:
      containers:
        - name: nodejs-api
        - image: gcr.io/gcp-yuito-sandbox/nodejs-api:COMMIT_SHA
          ports:
            - containerPort: 3000
```

このような形で書いていきます。
[詳しいドキュメントはこちら](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)


# Service Manifest
次はService Manifestを書いていきます。
**Service Manifestはこのアプリケーションには、こういう感じで接続させるよーみたいな通信設定を定義することができるやつです。**

ポート番号やプロトコル、ロードバランスの設定などを簡単に定義できます。

```yml:nodejs-api/infra/staging/nodejs-api-service.yml
apiVersion: v1
kind: Service
metadata:
  name: nodejs-api
spec:
  type: LoadBalancer
  ports:
    - port: 3000
      protocol: TCP
      targetPort: 3000
  selector:
    run: nodejs-api
```

[詳しいドキュメントはこちら](https://kubernetes.io/docs/concepts/services-networking/service/)

`type`に`LoadBalancer`って書くだけで、ロードバランスできるのはすごいですね。。。😇


# GCBでデプロイする
最後にまたGCBに戻ってデプロイの設定をしていきます。

```nodejs-api/infra/staging/cloudbuild.yml
steps:

  # 中略（さっき書いたものの下に書いてください）

  - name: 'gcr.io/cloud-builders/gcloud'
    id: 'Edit Deployment Manifest'
    args:
      - '/bin/sh'
      - '-c'
      - sed -i -e 's/COMMIT_SHA/${SHORT_SHA}/' infra/staging/nodejs-api-deployment.yml
    dir: 'nodejs-api'

  - name: 'gcr.io/cloud-builders/kubectl'
    id: 'Apply Deployment Manifest'
    args: ['apply', '-f', 'infra/staging/nodejs-api-deployment.yml']
    env:
      - 'CLOUDSDK_COMPUTE_ZONE=asia-northeast1-a'
      - 'CLOUDSDK_CONTAINER_CLUSTER=gcp-yuito-sandbox'
    dir: 'nodejs-api'

  - name: 'gcr.io/cloud-builders/kubectl'
    id: 'Apply Service Manifest'
    args: ['apply', '-f', 'infra/staging/nodejs-api-service.yml']
    env:
      - 'CLOUDSDK_COMPUTE_ZONE=asia-northeast1-a'
      - 'CLOUDSDK_CONTAINER_CLUSTER=gcp-yuito-sandbox'
    dir: 'nodejs-api'
```

基本的に
`kubectl apply -f hogehoge.yml`
するとそのymlファイルの設定がクラスタに適応されます。

後半にさしかかり、ちょっと説明が雑になってきました、スイマセン🙇‍♀️

今回は`Deployment`と`Service`の`Manifest`を`apply`コマンドでクラスタに適応しています

しかし、その前に使用するイメージを最新のものにしないといけないので、`sed`コマンドで、`nodejs-api-deployment.yml`を上書きしています。

先程のプッシュ時にコミットハッシュでタグ付けをしているので、ここではコミットハッシュをイメージのタグとして上書きしています。

`sed -i -e 's/COMMIT_SHA/${SHORT_SHA}/' infra/staging/nodejs-api-deployment.yml`

の部分ですね。

# 試してみる
はい、これでCICDの設定は完了しました。
実際にアプリケーションに変更を加えてみましょう。

Hello Worldのビックリマークを減らす。

```nodejs-api/src/app/server.ts
app.get('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
-  return res.send('Hello World!!!');
+  return res.send('Hello World!');
});
```

```bash
$ git add .
$ git commit -m 'decrement !'
$ git push origin master
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/92468/c16a64f3-6f28-46e0-abcc-31e3550ca2de.png)

ビルドが始まりました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/92468/62bdae7a-2816-6a17-be16-c9a49cdeb2ff.png)

頑張ってます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/92468/6506643b-151a-c3f0-1fd1-bb04b7f5fa97.png)

お、終わりましたね。


![image.png](https://qiita-image-store.s3.amazonaws.com/0/92468/204172b5-e86b-5680-9af8-3af759c55b21.png)


ステップも完璧です。

次はkubectlコマンドでpodの状態を確認していきます。
podはクラスタ内で動いているプロセスです。僕のは3分くらいで立ち上がりました。


```bash
$ kubectl get pods
NAME                         READY     STATUS    RESTARTS   AGE
nodejs-api-cf554ff64-kvqrm   1/1       Running   0          1m
nodejs-api-cf554ff64-v6zn6   1/1       Running   0          1m
```

お、デプロイが完了しました！

何も結果返って来ないぞーって人は、 `kubectl` が見るクラスタを設定し忘れているので、

```
$ gcloud container clusters get-credentials クラスタ名 --region リージョン
```

のコマンドを打ってあげてください。


次はserviceも見ていきます。

```bash
$ kubectl get services
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
kubernetes   ClusterIP      10.11.240.1     <none>        443/TCP          18h
nodejs-api   LoadBalancer   10.11.250.101   34.85.35.75   3000:32767/TCP   18h
```

nodejs-apiへの通信ができるようになっていますね。この `EXTERNAL-IP` に書いてあるホストにポート番号を設定してリクエストしてみましょう。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/92468/4d213734-53c0-047f-f91e-f08fbad260a1.png)

無事に!マークは一つになっていますね。めでたしめでたし。

 
# 課金されないようにお掃除する
遊んだらお片付けする。3歳児にもできることですね👶GKEは高いのでちゃんとお片付けしましょう。
お片付けは簡単で **Terraformのコードをコメントアウトするだけです**
（もしかしたらGSRは消しておかないといけないかも）



```tf:terraform/main.tf
variable "project" {
  default = "gcp-yuito-sandbox"
}

variable "region" {
  default = "asia-northeast1"
}

variable "zone" {
  default = "asia-northeast1-a"
}

variable "network_name" {
  default = "gcp-yuito-sandbox"
}

provider "google" {
  region = "${var.region}"
  project = "${var.project}"
}

//// Network
//resource "google_compute_network" "default" {
//  name                    = "${var.network_name}"
//  auto_create_subnetworks = false
//}
//
//// Subnetwork
//resource "google_compute_subnetwork" "default" {
//  name                     = "${var.network_name}"
//  ip_cidr_range            = "10.127.0.0/20"
//  network                  = "${google_compute_network.default.self_link}"
//  region                   = "${var.region}"
//  private_ip_google_access = true
//}
//
//// Cluster
//resource "google_container_cluster" "default" {
//  name               = "${var.network_name}"
//  zone               = "${var.zone}"
//  initial_node_count = 3
//  min_master_version = "1.11.7-gke.4"
//  network            = "${google_compute_subnetwork.default.name}"
//  subnetwork         = "${google_compute_subnetwork.default.name}"
//
//  enable_legacy_abac = true
//
//  master_auth {
//    username = ""
//    password = ""
//  }
//
//  provisioner "local-exec" {
//    when    = "destroy"
//    command = "sleep 90"
//  }
//
//  node_config {
//    oauth_scopes = [
//      "https://www.googleapis.com/auth/compute",
//      "https://www.googleapis.com/auth/devstorage.read_only",
//      "https://www.googleapis.com/auth/logging.write",
//      "https://www.googleapis.com/auth/monitoring",
//    ]
//    preemptible  = true
//    machine_type = "g1-small"
//  }
//}
//
//// Static IP
//resource "google_compute_global_address" "ip_address" {
//  name = "gcp-yuito-sandbox-static-ip"
//}
//
//// Cloud Build
//resource "google_cloudbuild_trigger" "default" {
//  trigger_template {
//    branch_name = "master"
//    repo_name   = "github_YuitoSato_gcp-yuito-sandbox"
//  }
//
//  filename = "nodejs-api/infra/staging/cloudbuild.yml"
//
//  included_files = [
//    "nodejs-api/src/**"
//  ]
//}
```

`resource` と書いている奴らを全部コメントアウトします。

```
$ cd terraform
$ terraform apply
```

`terraform apply`して消し去ります。

これでお掃除は完了です。
お金の管理は僕は責任を負いかねますので、もちろん自己責任でお願いします。

# おわりに
お疲れ様でした！

本当は`skaffold`とか`helm`とかいかにもウェイウェイしてて合コンでモテそうな技術も使おうと思ったのですが、今回の要件では不必要と判断しました。
ローカル環境をクラスタで構築して、その起動の仕方と本番環境を揃えたいみたいなニーズだと必要になってくるんでしょうか？

あと、紹介するの忘れていましたが、本記事は以下のGCPのチュートリアルをかなり参考にしています。
[Cloud Build を使用した GitOps スタイルの継続的デリバリー](https://cloud.google.com/kubernetes-engine/docs/tutorials/gitops-cloud-build?hl=ja)

ぜひお時間ある方は読んでみてください〜

まだまだこの分野は難しそうですね。。。では✋