GKE を格安で使うためにやったこと

GKE を格安で使うためにやったこと

目的

GKEで高機能なマシンを使いたいけどお金がかかるのがネックだったので、コストを最小にするために取り組んだことを書いた

GKE (Google Kubernetes Engine)のドキュメントはこれ

やりたいこと

  • GKEを使ってある程度(1時間以上)時間がかかるバッチ処理をやりたい
  • できれば処理の負荷に応じて動かすマシンのスペックを変えたいのでGAEやFaasよりもKubernetesを使いたい
  • バッチ処理なので、一日のうち稼働していない時間の方が長い
  • できれば使った分だけ課金されるようにしたいが、GKEは後述する通りクラスタが存在する限り課金されてしまう
  • 勉強のためにKubernetesを使いたい ← 一番の動機で他は後付け

そこで、こうすれば安く使えるかも、と思った方法を以下に記載する

ここで紹介する方法でできないこと

以下の方法は、稼働時間が一日数時間のバッチ処理を安く使う方法なもので、一日中動き続ける必要があるサーバには使えない。

料金の確認

まずGKEにかかる料金を確認する

以下を見ると、ノードは秒単位で課金される, とある

ノードとはGKEのPodを動作させるマシンのこと GCPではGoogle Compute Engine(GCE)上のVMのことを指す

料金 | Kubernetes Engine のドキュメント | Google Cloud

ノードの料金 GKE では、Google Compute Engine インスタンスクラスタ内のノードとして使用します。ノードを削除するまでは、Compute Engine の料金設定に基づいて、これらのインスタンスごとに課金されます。Compute Engine リソースは秒単位で課金され、最小使用料金は 1 分間分です。

ではVMはいくらかかるかというと、標準的なマシンで一番スペックの低い、 仮想CPU数 1、メモリ3.75GBのn1-standard-1でも、一か月間継続利用すると24ドル強かかることがわかる(高い)

すべての料金 | Compute Engine ドキュメント | Google Cloud

image

マシンタイプ 仮想 CPU 数 メモリ 料金(米ドル) プリエンプティブル料金(米ドル)
n1-standard-1 1 3.75 $24.2725 $7.30

もしCPU 4、メモリ15GBのマシンを一か月使用したらこれ1台だけで1万円くらいかかることになる

マシンタイプ 仮想 CPU 数 メモリ 料金(米ドル) プリエンプティブル料金(米ドル)
n1-standard-4 4 15GB $97.0900 $29.20

趣味の開発にはとてもそんな高いお金は払えない

プリエンプティブルについては後述する

一か月の使用料金について

【実例】n1-standard-1でHello Worldを表示するだけのサーバを動かした時の費用

以下、n1-standard-1でHello Worldを表示するだけのサーバを3週間近く放置した結果を見てみる

これは、GKEのチュートリアルHello Worldを表示するサーバを作って、うっかりクラスタを消さなかったらこうなった(実体験)

6876円という金額が請求された!

image

SKU プロダクト SKU ID 使用 費用 1 回限りのクレジット 割引 小計
N1 Predefined Instance Core running in Americas Compute Engine 2E27-4F75-95CD 1,289.2 hour ¥4,431 ¥0 ¥-196 ¥4,235
N1 Predefined Instance Ram running in Americas Compute Engine 6C71-E844-38BC 4,834.49 gibibyte hour ¥2,227 ¥0 ¥-99 ¥2,129
Storage PD Capacity Compute Engine D973-5D65-BAB2 176.97 gibibyte month ¥509 ¥0 ¥0 ¥509
Network Inter Zone Egress Compute Engine DE9E-AFBC-A15A 3.06 gibibyte ¥3 ¥0 ¥0 ¥3

なんでこんなに高いの?

SKUについて

まずこのSKUという料金体系が非常にわかりにくいし、どこにもはっきりと書いていない。

CloudSQLを安くするために考えたこと - ludwig125のブログ

以前↑にも書いたけど、多分SKUというのは最小管理単位 (Stock Keeping Unit) の略だと考えられる。

すべての料金  |  Compute Engine ドキュメント  |  Google Cloud

ここには

米ドル以外の通貨でお支払いの場合は、Cloud Platform SKU に記載されている該当通貨の料金が適用されます。

とあるけど自分が見たときはリンク先のドキュメントは何も書いてなかった(ドキュメントのミス?)

ちなみに以前のSKUはこちららしい

料金の内訳

すべての料金  |  Compute Engine ドキュメント  |  Google Cloud

ここにはn1-standard-1 のマシンが一時間あたり$0.0475 と書いてあるので、 このレポートに書いてある1,289.2時間という使用時間に$0.0475をかけて、 当時のドル円のレート約108円をかけると、

1289.2*0.0475*108=6613.596 となった。

これは、SKUの以下の合計金額とほぼ一致する。

  • N1 Predefined Instance Core running in Americas:¥4,431
  • N1 Predefined Instance Ram running in Americas:¥2,227

上の2項目の合算がn1-standard-1のマシンの使用料金に対応していると考えられる。

1289.2という使用時間について

GKEはクラスタを作る際に、デフォルトで3ノード作成される。つまり3台分のVMの料金が発生している。

gcloud container clusters create  |  Cloud SDK  |  Google Cloud

--num-nodes=NUM_NODES; default=3

そのため、VM一台あたりの稼働日数を計算するためには1289.2時間を3で割って さらに24時間で割ればいい。 1289.2/3/24=17.9 ということで、これはレポートにも表示されているVMの稼働した期間(6/20〜7/7くらい)と大体一致する。

プリエンプティティブルなマシンについて

プリエンプティティブルなマシンは、24時間でシャットダウンされるので長い時間がかかるバッチ処理には向いていない

逆に、短い処理、または途中で止まってもいいような処理の場合はプリエンプティティブルで問題ないので、趣味で使う分にはそちらのほうがいい場合もある

格安でGKEを使おうとする記事はプリエンプティブルなマシンについて紹介されていることが多い

どうやったら安く使えるか考える

プリエンプティブルを使っても、ある程度のスペックのマシンを使おうとするとそれなりに高い。

Kubernetesクラスタを稼働させるVMは、サービスを動かしていようがいまいが存在する限り課金される。 自分が作ろうと思っているバッチ処理の場合、一日数時間動いて残りは無駄に課金されることになる。

これは避けたい。

「必要な分だけ課金されるのがいいならサーバレスとか、GAEを頑張れないか」とも思ったけど、 でもKubernetes勉強のために使ってみたい。

それにKubernetesなら必要に応じてマシンのスペックを変えられるという点もいい。

考えた結果、

という発想から考えたのが以下の構成

構成の概要

image

上の横一列はソースコードのビルド処理を表す

  1. githubにpushすると
  2. circleciがビルドジョブを起動して、
  3. docker imageをGCRにpushする。
  4. circleciのcron機能を使って設定した時刻に、GKEクラスタの作成とデプロイを行うcircleciジョブを実行するようにする
  5. GKEのデプロイをする際に、GCRから最新のDockerImageを取得する
  6. kubernetesのcronJobを数分おきに起動するようにして、いつデプロイされても数分後に実行されるようにしておく
  7. cronJobは、何らの処理(ここではcpu情報などをslackに通知する処理とした)をした後で、
  8. 最後にcircleciジョブのAPIを呼び出してGKEクラスタ削除ジョブを実行させる

1〜3までは多くの人がやっている方法。 普通はこの後デプロイジョブを続けて起動させるのが一般的だが、今回の方法ではこのままデプロイはしない。

下の横一列4〜8が今回自分が考えた部分。

プログラミング言語はGo言語を使用する

クラスタ(VM)が存在する期間を最小にすることで、大幅なコスト削減が見込めるはず

詳細な手順

以下、自分の検証を追う形で詳細な手順を紹介する。 結果は一番最後に記載するので、結果だけ知りたい人は「最終的に作ったもの」を見てください

GCRにdocker pushするまで

まずはcircleciのジョブでGCRにdocker pushするところまで行う。(図の黄色枠で囲まれた部分)

image

事前にGKEのチュートリアルのプロジェクトの作成作業が必要。

チュートリアルに沿って以下でプロジェクトを作成しておく

プロジェクトの選択 -> 新しいプロジェクト

image

gke-test-ludwig125-2 という名前でプロジェクトを作成 (gke-test-ludwig125で設定をミスったので作り直した。これ以降の画像は全てgke-test-ludwig125-2 と読み替える必要がある) image

デフォルトのプロジェクトを変更

gcloud config set project gke-test-ludwig125-2

マシンを置く場所を選ぶ 安いところならどこでもいいので、ここでは以下を選択

gcloud config set compute/zone us-west1-c

main.goの作成 ここではログにCPUを出力するだけの簡単なプログラムとした。

package main

import (
    "log"
    "runtime"
)

func main() {
    log.Println("Hello, World!")
    log.Printf("cpu: %d\n", runtime.NumCPU())
    log.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
}

Dockerfileを以下のように作成

FROM golang:1.13-alpine
WORKDIR /go/src/github.com/ludwig125/gke-test
COPY . .
RUN go install github.com/ludwig125/gke-test
CMD ["gke-test"]

これをcircleci上でbuildする

GKEを使うので、DockerのimageはGCRで管理する

GCRについては以下が参考

.circleci/config.yml を以下のように書く

  • 本当はcircleciを2.1で書きたかったが、後述するcircleci APIが2.0にしか対応していないため、仕方なく2.0で書く

.config.yml

version: 2
jobs:
  build:
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
      IMAGE_NAME: gke-test
    docker:
      - image: google/cloud-sdk
    working_directory: /go/src/github.com/ludwig125/gke-test
    steps:
      - checkout
      - setup_remote_docker:
          version: 18.06.0-ce
      - run:
          name: Setup CLOUD SDK
          command: |
            # base64 -i ignore non-alphabet characters
            echo $GCLOUD_SERVICE_KEY | base64 -di > ${HOME}/gcloud-service-key.json
            gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
            gcloud --quiet auth configure-docker
      - run:
          name: Docker Build & Push
          command: |
            docker build -t us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_BUILD_NUM} .
            docker tag us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_BUILD_NUM} us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:latest
            if [ -n "${CIRCLE_TAG}" ]; then
              docker tag us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_BUILD_NUM} us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_TAG}
            fi
            docker push us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}

workflows:
  version: 2
  master-build:
    jobs:
      - build:
          filters:
            branches:
              only: master

上の説明

Setup CLOUD SDK

  • echo $GCLOUD_SERVICE_KEY | base64 -di > ${HOME}/gcloud-service-key.json
    • circleciの環境変数に登録したGCLOUD_SERVICE_KEYをbase64デコードしてcircleciのローカル内に保存
    • base64 -iしないとサービスアカウントのjsonをデコードする際にエラーを出したので必要
  • gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
    • サービスアカウントを上でリダイレクトしたファイルから読み込み
  • gcloud --quiet auth configure-docker
    • サービスアカウントの認証

Docker Build & Push

これをgithubに上げる

circleciの作業

circleciに登録 image

Linux -> go を選んで Start Buildingボタンを押す

このままではcircleciがGKEのプロジェクトにアクセスできないので失敗するはず

サービスアカウントの追加

circleciからプロジェクトを操作するために、 以下の方法でサービスアカウントを作成しておく

image

I AMと管理 -> サービスアカウント 「サービスアカウントを作成」

gke-test で作成

image

【サービスアカウントの追加】権限について

サービス アカウントの権限(オプション) は以下のように

  • Kubernetes Engine」-> 「Kubernetes Engine 管理者」
  • 「Service Accounts」 -> 「サービス アカウント ユーザー」
  • 「ストレージ」-> 「ストレージ管理者」

を選択する

image

【サービスアカウントの追加】Kubernetes Engine 管理者

Kubernetes Engine 開発者ではだめ

  • 管理者ではなく開発者を選んでしまうと、後述のcircleciでclusterの作成と削除で以下のようなエラーが出てしまった
  • 必ずKubernetes 管理者を選ぶ必要がある
bash-4.4# gcloud --quiet container clusters delete gke-test-small-cluster
ERROR: (gcloud.container.clusters.delete) Some requests did not succeed:
- ResponseError: code=403, message=Required "container.clusters.delete" permission(s) for "projects/gke-test-ludwig125-2/zones/us-west1-c/clusters/gke-test-small-cluster". See https://cloud.google.com/kubernetes-engine/docs/troubleshooting#gke_service_account_deleted for more info.

bash-4.4# gcloud services enable container.googleapis.com
ERROR: (gcloud.services.enable) PERMISSION_DENIED: The caller does not have permission

参考:

【サービスアカウントの追加】サービス アカウント ユーザー

また、「サービスアカウント ユーザー」がないと、後述のcircleciでサービスアカウントを使ったクラスタが作成できず、以下のようなエラーがでる

(gcloud.container.clusters.create) ResponseError: code=400, message=The user does not have access to service account "default".

参考:

【サービスアカウントの追加】ストレージ管理者

これがないとGCRにDockerImageを上げられない 以下のようなエラーが出る

The push refers to repository [us.gcr.io/gke-test-ludwig125-2/gke-test]


denied: Token exchange failed for project 'gke-test-ludwig125-2'. Caller does not have permission 'storage.buckets.get'. To configure permissions, follow instructions at: https://cloud.google.com/container-registry/docs/access-control
Exited with code 1

アクセス制御の構成  |  Container Registry  |  Google Cloud に書いてある通り、ストレージ管理者が権限に必要

ここまででIAMを見るとこんな感じ

image

  • Kubernetes Engine 管理者
  • サービスアカウントユーザー
  • ストレージ管理者

の3つが設定されている

【サービスアカウントの追加】キーの作成以降

キーの作成 JSONを選ぶ image

以下の形式のJSONファイルがダウンロードできるので、ダウンロードしておく

{
  "type": "service_account",
  "project_id": "gke-test-ludwig125-2",
  "private_key_id": "XXXXXXX",
  "private_key": "-----BEGIN PRIVATE KEY-----\nXXXXXXXXXXXXX

以下ではservice_account.jsonの名前で保存したものとする

ローカルのディレクトリに置くときは、githubに上がらないようにgitignoreしておく

$cat .gitignore
service_account.json

これをbase64 encodeして、出力結果をコピー cat service_account.json| base64

circleciのEnvironment Variables に上の結果を登録 config yamlの内容に合わせて GCLOUD_SERVICE_KEYという名前のキーにする image

これでcircleciのジョブを再実行 うまくいくとこんな感じ

image

gcr image

ここまでで参考にさせていただいたページ

circleCIとgithubを連携して、簡単にコードをGCRにpushできるようにしてみた - アプリとサービスのすすめ

GKE+CircleCI 2.0で継続的デプロイ可能なアプリケーションをシュッと作る - Eureka Engineering - Medium

GKEのデプロイ

ここからは、GKEのデプロイ以降の部分について記載する(図の黄色枠で囲まれた部分)

image

GKEの手動デプロイ

ここからは、GKEにcrojobをデプロイする方法を検証する。

まずは手動でcronjobをデプロイする方法を確認する。

クイックスタート | Kubernetes Engine のドキュメント | Google Cloud に従って作る

PROJECT_NAME=gke-test-ludwig125-2
CLUSTER_NAME=gke-test-small-cluster
COMPUTE_ZONE=us-west1-c
gcloud config set project $PROJECT_NAME
gcloud config set compute/zone $COMPUTE_ZONE
gcloud container clusters create $CLUSTER_NAME --preemptible --machine-type=g1-small --num-nodes 3 --disk-size 10 --zone $COMPUTE_ZONE --enable-autoscaling --min-nodes=1 --max-nodes=3

コマンドの詳細は以下 gcloud container clusters create  |  Cloud SDK  |  Google Cloud

ちなみに、「gcloud container clusters create」の際に--preemptibleをつけるとマシンがプリエンプティブルになる。

$gcloud container clusters list
NAME                    LOCATION    MASTER_VERSION  MASTER_IP       MACHINE_TYPE  NODE_VERSION  NUM_NODES  STATUS
gke-test-small-cluster  us-west1-c  1.13.7-gke.8    35.203.156.134  g1-small      1.13.7-gke.8  2          RUNNING
[~/go/src/github.com/gke-test] $

バッチ処理をするためにcronjob.yamlを用意する。 cronjob.yamlはdeployment.yamlとあまり変わらないが、

  • scheduleとconcurrencyPolicyを設定している
  • .spec.containersにportがいらない

などの違いがある

k8s/cronjob.yaml

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: gke-test
  labels:
    app: gke-test
spec:
  # cronJob 参考
  # https://en.wikipedia.org/wiki/Cron
  # https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/
  # https://kubernetes.io/ja/docs/concepts/workloads/controllers/cron-jobs/
  schedule: "*/1 * * * *" # 1分おきに起動
  concurrencyPolicy: Forbid # 前のCronが動いていたら動作しない
  jobTemplate:
    spec:
      template:
        metadata:
          labels:
            app: gke-test
        spec:
          containers:
          - name: gke-test-container
            image: us.gcr.io/gke-test-ludwig125-2/gke-test
            imagePullPolicy: Always
            command: ["gke-test"]
            resources:
              requests:
                memory: 512Mi
          restartPolicy: Never # Cron失敗時にコンテナを再起動しない

cron参考

自分はkustomizeを使いたいのでGKE(kubernetes)にdeploy

$kustomize build k8s | kubectl apply -f -
cronjob.batch/gke-test created

※今回はcronjob.yamlのファイルが1つだけなので、kustomizeを使わなくても以下のように直接ファイルを指定しても同じ

$kubectl apply -f k8s/cronjob.yaml
cronjob.batch/gke-test created

結局どちらも以下のyamlをデプロイしているだけなことが、 kustomize buildの結果を見るとわかる(余談)

$kustomize build k8s
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  labels:
    app: gke-test
  name: gke-test
spec:
  concurrencyPolicy: Forbid
  jobTemplate:
    metadata:
      labels:
        app: gke-test
    spec:
      template:
        metadata:
          labels:
            app: gke-test
        spec:
          containers:
          - command:
            - gke-test
            image: us.gcr.io/gke-test-ludwig125-2/gke-test
            imagePullPolicy: Always
            name: gke-test-container
            resources:
              requests:
                memory: 512Mi
          restartPolicy: Never
  schedule: '*/1 * * * *'

kubectl applyの結果、cronjobが登録されていることをget cronjobで確認

$kubectl get cronjob
NAME       SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
gke-test   */1 * * * *   False     0        45s             8m16s

ちょっと待つと最初の1分が来てjobが起動する

$kubectl get job
NAME                  COMPLETIONS   DURATION   AGE
gke-test-1568492880   0/1                      0s

podもCronで指定した時刻にできた

$kubectl get pod
NAME                        READY   STATUS              RESTARTS   AGE
gke-test-1568492880-5mfvr   0/1     ContainerCreating   0          19s
$kubectl get pod
NAME                        READY   STATUS      RESTARTS   AGE
gke-test-1568492880-5mfvr   0/1     Completed   0          37s

cronで指定したとおり、1分おきに動いていることがわかる

  • 時刻は9時間ずれているので、UTCらしい
$kubectl get pod
NAME                        READY   STATUS      RESTARTS   AGE
gke-test-1568492940-pz88m   0/1     Completed   0          2m33s
gke-test-1568493000-bbcl9   0/1     Completed   0          92s
gke-test-1568493060-qdsh2   0/1     Completed   0          32s

$kubectl logs gke-test-1568492940-pz88m
2019/09/14 20:29:18 Hello, World!
2019/09/14 20:29:18 cpu: 1
2019/09/14 20:29:18 GOMAXPROCS: 1
$kubectl logs gke-test-1568493000-bbcl9
2019/09/14 20:30:03 Hello, World!
2019/09/14 20:30:03 cpu: 1
2019/09/14 20:30:03 GOMAXPROCS: 1
$kubectl logs gke-test-1568493060-qdsh2
2019/09/14 20:31:03 Hello, World!
2019/09/14 20:31:03 cpu: 1
2019/09/14 20:31:03 GOMAXPROCS: 1
$

デフォルトでは成功したJobは直近の3つまで残っている https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/

The .spec.successfulJobsHistoryLimit and .spec.failedJobsHistoryLimit fields are optional. These fields specify how many completed and failed jobs should be kept. By default, they are set to 3 and 1 respectively

cronが起動できたことまで確認したので一旦消す。 このあとcircleciから自動でcronJobのデプロイを行う

$kubectl delete cronjob gke-test
cronjob.batch "gke-test" deleted

circleciでGKEにdeploy

上のkubernetesのデプロイをcircleciでできるか確認する。

検証の段階なのでビルド後にそのままデプロイジョブが起動されるようにしている。

前述のcircleci/config.ymlにdeploy部分を追加したものが以下

version: 2
jobs:
  build:
    (まえと同じ)
  deploy:
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
      CLUSTER_NAME: gke-test-small-cluster
      COMPUTE_ZONE: us-west1-c
    docker:
      - image: google/cloud-sdk
    working_directory: /app
    steps:
      - checkout
      - setup_remote_docker:
          version: 18.06.0-ce
      - run:
          name: Setup CLOUD SDK
          command: |
            # base64 -i ignore non-alphabet characters
            echo $GCLOUD_SERVICE_KEY | base64 -di > ${HOME}/gcloud-service-key.json
            gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
      - run:
          name: Setup GKE Cluster Infomation
          command: |
            gcloud config set project $PROJECT_NAME
            gcloud config set container/cluster $CLUSTER_NAME
            gcloud config set compute/zone ${COMPUTE_ZONE}
            gcloud container clusters get-credentials $CLUSTER_NAME
      - run:
          name: Install kustomize
          command: |
            opsys=linux  # or darwin, or windows
            curl -s https://api.github.com/repos/kubernetes-sigs/kustomize/releases |\
                grep browser_download |\
                grep $opsys |\
                cut -d '"' -f 4 |\
                grep kustomize_ |\
                grep -v tar.gz |\
                head -n 1 |\
                xargs curl -O -L
            mv kustomize_*_${opsys}_amd64 kustomize
            chmod u+x kustomize
      - deploy:
          name: Kustomize build and Apply
          command: |
            ./kustomize build ./k8s/ | /usr/bin/kubectl apply -f -      

workflows:
  version: 2
  master-build:
    jobs:
      - build:
          filters:
            branches:
              only: master
      - deploy:
          requires:
            - build

Install kustomize部分の説明

KustomizeのInstall方法は以下に書いてある kustomize/INSTALL.md at master · kubernetes-sigs/kustomize · GitHub

name: Install kustomize
command: |
            opsys=linux
            curl -s https://api.github.com/repos/kubernetes-sigs/kustomize/releases |\
                grep browser_download |\
                grep $opsys |\
                cut -d '"' -f 4 |\
                grep /kustomize/v |\
                sort | tail -n 1 |\
                xargs curl -O -L
            tar xzf ./kustomize_v*_${opsys}_amd64.tar.gz
            ./kustomize version

以上の設定でcircleci(中略)ジョブを実行すると 無事手動のときと同じcronjobの登録、起動が確認できた。

Slack Botの作成

ログに出すだけだと面白くないので、結果をslackに通知するようにしてみる。 この部分はただの趣味なので、クラスタの自動作成削除の本筋とは関係ない。

image

-u で最新を取ってくる

go get -u github.com/nlopes/slack

以下を参考にmain.goを修正

main.go

package main

import (
    "fmt"
    "log"
    "os"
    "runtime"
    "time"

    "github.com/nlopes/slack"
)

func main() {
    start := time.Now()

    res := fmt.Sprintf("cpu: %d\n", runtime.NumCPU())
    res += fmt.Sprintf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))

    token := mustGetenv("SLACK_TOKEN")
    channel := mustGetenv("SLACK_CHANNEL")
    if err := sendSlackMsg(token, channel, res, start); err != nil {
        log.Println(err)
    }
}

func mustGetenv(k string) string {
    v := os.Getenv(k)
    if v == "" {
        log.Fatalf("%s environment variable not set.", k)
    }
    log.Printf("%s environment variable set.", k)
    return v
}

func sendSlackMsg(token, channel, result string, start time.Time) error {
    api := slack.New(token)
    channelID, timestamp, err := api.PostMessage(channel, slack.MsgOptionText(createSlackMsg(start, result), false), slack.MsgOptionUsername("gke-test-Bot"), slack.MsgOptionIconEmoji(":sunny:"))
    if err != nil {
        return fmt.Errorf("failed to send message: %s", err)
    }
    log.Printf("Message successfully sent to channel %s at %s", channelID, timestamp)
    return nil
}

func createSlackMsg(start time.Time, res string) string {
    jst := time.FixedZone("Asia/Tokyo", 9*60*60)
    finish := time.Now()
    processingTime := time.Since(start).Truncate(time.Second)

    msg := "*gke-test が正常に終了しました。*\n"
    msg += fmt.Sprintf("起動時刻: %v\n", start.In(jst).Format("2006-01-02 15:04:05"))
    msg += fmt.Sprintf("終了時刻: %v\n", finish.In(jst).Format("2006-01-02 15:04:05"))
    msg += fmt.Sprintf("所要時間: %v\n\n", processingTime)
    msg += fmt.Sprintf("%s\n", res)
    return msg
}

slackのアイコンに使ったemojiは以下で適当に選ぶか自分でURLを設定する

🎁 Emoji cheat sheet for GitHub, Basecamp, Slack & more

ここではなんとなく:sunny:を選んだ。

明るい気持ちになる!

以下の通り環境変数としてTOKENとCHANNELを設定してローカルで実行してみる

$SLACK_TOKEN=<取ってきたTOKEN> SLACK_CHANNEL=<SLACKで通知したいチャネル> go run main.go
2019/09/16 06:56:17 SLACK_TOKEN environment variable set.
2019/09/16 06:56:17 SLACK_CHANNEL environment variable set.
2019/09/16 06:56:17 Message successfully sent to channel <チャネル> at 1568584577.001500

こんな感じに送れた image

GKEにdeploy

ローカルでcircleciジョブを起動するプログラムができたので、これをGKEにデプロイする

GKEクラスタ作成

[~/go/src/github.com/gke-test] $gcloud container clusters create $CLUSTER_NAME --preemptible --machine-type=g1-small --num-nodes 3 --disk-size 10 --zone $COMPUTE_ZONE --enable-autoscaling --min-nodes=1 --max-nodes=3
中略
Created [https://container.googleapis.com/v1/projects/gke-test-ludwig125-2/zones/us-west1-c/clusters/gke-test-small-cluster].
To inspect the contents of your cluster, go to: https://console.cloud.google.com/kubernetes/workload_/gcloud/us-west1-c/gke-test-small-cluster?project=gke-test-ludwig125-2
kubeconfig entry generated for gke-test-small-cluster.
NAME                    LOCATION    MASTER_VERSION  MASTER_IP      MACHINE_TYPE  NODE_VERSION  NUM_NODES  STATUS
gke-test-small-cluster  us-west1-c  1.13.7-gke.8    35.247.70.150  g1-small      1.13.7-gke.8  3          RUNNING

GKEにローカルからデプロイ。

環境変数を渡してkustomize buildする

[~/go/src/github.com/gke-test] $SLACK_TOKEN=<取ってきたTOKEN> SLACK_CHANNEL=<SLACKで通知したいチャネル>  kustomize build k8s | kubectl apply -f -
secret/slack-info created
cronjob.batch/gke-test created

ちょっと待つとちゃんとできている

[~/go/src/github.com/gke-test] $kubectl get pod
NAME                        READY   STATUS      RESTARTS   AGE
gke-test-1568607120-hdq5z   0/1     Completed   0          2m16s
gke-test-1568607180-g9ctx   0/1     Completed   0          76s
gke-test-1568607240-827cq   0/1     Completed   0          15s
[~/go/src/github.com/gke-test] $k

circlecleでデプロイする

上の修正したmain.goをそのままcircleciでbuildしようとしても以下のように言われてしまう

docker build 時のログ

Step 4/5 : RUN go install github.com/ludwig125/gke-test
---> Running in 0cdddbf7ff52
main.go:10:2: cannot find package "github.com/nlopes/slack" in any of:
    /usr/local/go/src/github.com/nlopes/slack (from $GOROOT)
    /go/src/github.com/nlopes/slack (from $GOPATH)
The command '/bin/sh -c go install github.com/ludwig125/gke-test' returned a non-zero code: 1
Exited with code 1

slackパッケージが見つからないので依存解決にgo moduleを使う

go mod init を使用して現在のディレクトリをモジュールのルートにする

[~/go/src/github.com/gke-test] $go mod init
go: creating new go.mod: module github.com/gke-test

これで以下のファイルが作られる

[~/go/src/github.com/gke-test] $cat go.mod
module github.com/gke-test

go 1.13
[~/go/src/github.com/gke-test] $

test, build またはinstallのどれかを実行するとslackパッケージがgo.modに追加される

[~/go/src/github.com/gke-test] $go install
go: finding github.com/nlopes/slack v0.6.0
go: downloading github.com/nlopes/slack v0.6.0
go: extracting github.com/nlopes/slack v0.6.0

go.modの中身を見ると以下のようになっている

[~/go/src/github.com/gke-test] $cat go.mod
module github.com/gke-test

go 1.13

require github.com/nlopes/slack v0.6.0
[~/go/src/github.com/gke-test] $

Dockerfileを直す

  • Dockerfileにgo moduleのための設定を追加する

最初に作ったDockerfileはこれ

FROM golang:1.13-alpine
# for go mod download
RUN apk add --update --no-cache ca-certificates git

WORKDIR /go/src/github.com/ludwig125/gke-test
COPY go.mod .
COPY go.sum .

RUN go mod download
COPY . .

# go.mod path 'module github.com/gke-test'
RUN go install github.com/gke-test
CMD ["gke-test"]

これでも動くし、プログラムや設定が間違っていた時に検証しやすい。 ただ、格安で使うことを考えると問題がある

Docker Image の大きさを小さくすることで、GKEのデプロイ時にDocker Imageをプルするときの通信費を抑えることができる これは意外とコストとして大きいので、いろいろと参考にして、Imageは以下のscratchを使った

  • ただ、scratchは何にも入っていないので、うまく動かないときにコンテナに入っていろいろ確認するのが難しい
  • 最初は検証しやすいalpineで確認して、全部うまくいったらscratchベースで作るようにしたほうが開発が楽

軽さを追求して最終的に落ち着いたDockerfileが以下

FROM golang:1.13-alpine as builder

RUN mkdir /gke-test
WORKDIR /gke-test

# Install git + SSL ca certificates.
# Git is required for fetching the dependencies.
# Ca-certificates is required to call HTTPS endpoints.
#RUN apk add --update --no-cache ca-certificates git && update-ca-certificates
RUN apk add --update --no-cache ca-certificates

# COPY go.mod and go.sum files to the workspace
COPY go.mod .
COPY go.sum .

# Get dependancies - will also be cached if we won't change mod/sum
RUN go mod download
# COPY the source code as the last step
COPY . .

# Build the binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o /go/bin/gke-test

# Second step to build minimal image
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /go/bin/gke-test /go/bin/gke-test
ENTRYPOINT ["/go/bin/gke-test"]
  • 工夫して直している間に、ENTRYPOINTをCMD ["gke-test"]からENTRYPOINT ["/go/bin/gke-test"]にしてたので注意

Dockerfile作成の参考

実際にDockerImageがどのくらい変わったか

自分の場合は、golang:1.13-alpineをベースに作った時イメージは390MBくらいだったのが、 scratchベースでは8MBくらいになった。

自分の構成では毎回のCronのたびにImageをPullするので、通信費に結構な差が生じるし、デプロイ速度にもかかわる

cronjob側のパスを修正

DockerのENTRYPOINTを変えたので、cronjobのcommandは以下になる

          containers:
          - name: gke-test-container
            image: us.gcr.io/gke-test-ludwig125-2/gke-test
            imagePullPolicy: Always
            command: ["/go/bin/gke-test"]

go.mod対応のDockerfileを使って改めてcirclecleでデプロイ

これでもう一度build & deployのジョブを回すと無事成功 1分ごとに起動するcronからいっぱいslackに通知がくるようになった

image

通知がうっとうしいのでいったんcronjobを消す

$kubectl get cronjob
NAME       SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
gke-test   */1 * * * *   False     0        16s             6m40s
$kubectl delete cronjob gke-test

cronjob.batch "gke-test" deleted

circleci API

まずは試しにGKEclusterのlistを見るだけのcircleciジョブを作り、それをcurlで起動させてみる

以下を参考に、circleciのAPI用のTOKENを作成する

TOKENを作るのはここ

APIで実行するためのジョブを作成する

version: 2
jobs:
(中略)
  list_gke_cluster:
    working_directory: /app
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
    docker:
      - image: google/cloud-sdk:alpine
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Set gcloud
          command: |
              echo $GCLOUD_SERVICE_KEY | base64 -d > ${HOME}/service_account.json
              gcloud auth activate-service-account --key-file ${HOME}/service_account.json
              gcloud config set project $PROJECT_NAME
      - run:
          name: List GKE Cluster
          command: gcloud container clusters list

端末からジョブを起動する

CIRCLE_API_USER_TOKEN=<取得したTOKEN>
curl -u ${CIRCLE_API_USER_TOKEN}: -d build_parameters[CIRCLE_JOB]=list_gke_cluster https://circleci.com/api/v1.1/project/github/ludwig125/gke-test/tree/master

上のcurlは以下と同じことをしている(どちらがしっくりくる書き方かは好みだと思う)

curl -XPOST https://circleci.com/api/v1.1/project/github/ludwig125/gke-test/tree/master --data "build_parameters[CIRCLE_JOB]=list_gke_cluster" --user "${CIRCLE_API_USER_TOKEN}:"

またはJSONであることを明記するなら以下のような書き方もできる

  • この書き方はこの後書くgoプログラムの内容に近いのでより理解がしやすい
curl -XPOST https://circleci.com/api/v1.1/project/github/ludwig125/gke-test/tree/master --user "${CIRCLE_API_USER_TOKEN}:" --header "Content-Type: application/json" -d '{
  "build_parameters": {
    "CIRCLE_JOB": "list_gke_cluster"
  }
}'

成功すると以下のようにGKEのclusterが取得できたことがわかる image

#!/bin/bash -eo pipefail
gcloud container clusters list
NAME                    LOCATION    MASTER_VERSION  MASTER_IP      MACHINE_TYPE  NODE_VERSION  NUM_NODES  STATUS
gke-test-small-cluster  us-west1-c  1.13.7-gke.8    35.199.173.53  g1-small      1.13.7-gke.8  2          RUNNING

circleci APIをgoプログラムの中で実行する

curlで動作確認できたので、次に同じことをgoでやってcircleciジョブを起動させてみる

circleciのEnvironment Variables に以下を追加する

  • CIRCLE_API_USER_TOKEN
    • 中身は先ほど作ったcircleci APIのTOKEN

検証用に以下のようなgoプログラムを作成する

circleci検証用main.go

package main

import (
    "bytes"
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {
    token := mustGetenv("CIRCLE_API_USER_TOKEN")
    defer func() {
        err := requestCircleci(token, "list_gke_cluster")
        if err != nil {
            log.Fatalf("failed to requestCircleci: %v", err)
        }
        log.Println("requestCircleci successfully")
    }()
    log.Println("do task")
}

func mustGetenv(k string) string {
    v := os.Getenv(k)
    if v == "" {
        log.Fatalf("%s environment variable not set.", k)
    }
    log.Printf("%s environment variable set.", k)
    return v
}

func requestCircleci(token, job string) error {
    // 参考
    // https://circleci.com/docs/ja/2.0/api-job-trigger/
    // https://circleci.com/docs/api/#trigger-a-new-job
    client := &http.Client{}
    circleciURL := "https://circleci.com/api/v1.1/project/github/ludwig125/gke-test/tree/master"
    j := fmt.Sprintf(`{"build_parameters": {"CIRCLE_JOB": "%s"}}`, job)
    req, err := http.NewRequest("POST", circleciURL, bytes.NewBuffer([]byte(j)))
    req.SetBasicAuth(token, "")
    req.Header.Set("Content-Type", "application/json")
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    // circleci APIを呼び出すと、201 Created が返ってくるのでチェック
    if resp.StatusCode != 201 {
        return fmt.Errorf("status code Error. %v", resp.StatusCode)
    }

    // レスポンス本文が見たい場合はここのコメントアウトを外す
    // body, err := ioutil.ReadAll(resp.Body)
    // fmt.Println(string(body))
    return nil
}

うまく実行されるとこのようになる

$CIRCLE_API_USER_TOKEN=<取得したTOKEN> go run main.go

2019/09/18 05:52:23 CIRCLE_API_USER_TOKEN environment variable set.
2019/09/18 05:52:24 requestCircleci successfully

circleciの結果 image

うまく目的のジョブが起動しないときは、responseの内容でbuild_parametersが空になっていないか確認してみる(自分はここで結構悩んだ)

失敗したときのResoponse内容

build_parameters" : {}`

成功するときのResoponse内容

  "build_parameters" : {
    "CIRCLE_JOB" : "list_gke_cluster"
  },

ちなみにgo のJSONの扱いの話になるけど、 上のプログラムは以下のようにjson.Marshalを使った方法でもいい。 今回は階層の深いJSONパラメータではないので、単純な文字列結合という手法を使った

// BuildParams is params for circleci API
type BuildParams struct {
    CircleciJobs CircleciJob `json:"build_parameters"`
}

// CircleciJob designate circleci job name
type CircleciJob struct {
    JobName string `json:"CIRCLE_JOB"`
}

func requestCircleci(token, job string) error {
    client := &http.Client{}

    params := BuildParams{
        CircleciJobs: CircleciJob{JobName: job},
    }
    jsonBytes, err := json.Marshal(params)
    if err != nil {
        return fmt.Errorf("failed to Marshal: %v", err)
    }
    circleciURL := "https://circleci.com/api/v1.1/project/github/ludwig125/gke-test/tree/master"
    req, err := http.NewRequest("POST", circleciURL, bytes.NewBuffer(jsonBytes))
    req.SetBasicAuth(token, "")
    req.Header.Set("Content-Type", "application/json")
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != 201 {
        return fmt.Errorf("status code Error. %v", resp.StatusCode)
    }

    return nil
}

最終的に作ったもの

いろいろ工夫して最終的に作った構成がこれ

ファイルの構成

$tree -a
.
├── .circleci
│   └── config.yml
├── Dockerfile
├── circleci.go
├── go.mod
├── go.sum
├── k8s
│   ├── cronjob.yaml
│   └── kustomization.yaml
├── main.go
└── slack.go

.circleci/config.yml

version: 2
jobs:
  build:
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
      IMAGE_NAME: gke-test
    docker:
      - image: google/cloud-sdk
    working_directory: /go/src/github.com/ludwig125/gke-test
    steps:
      - checkout
      - setup_remote_docker:
          version: 18.06.0-ce
      - run:
          name: Setup CLOUD SDK
          command: |
            # base64 -i ignore non-alphabet characters
            echo $GCLOUD_SERVICE_KEY | base64 -di > ${HOME}/gcloud-service-key.json
            gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
            gcloud --quiet auth configure-docker
      - run:
          name: Docker Build & Push
          command: |
            docker build -t us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_BUILD_NUM} .
            docker tag us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_BUILD_NUM} us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:latest
            if [ -n "${CIRCLE_TAG}" ]; then
              docker tag us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_BUILD_NUM} us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}:${CIRCLE_TAG}
            fi
            docker push us.gcr.io/${PROJECT_NAME}/${IMAGE_NAME}
  deploy:
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
      CLUSTER_NAME: gke-test-small-cluster
      COMPUTE_ZONE: us-west1-c
    docker:
      - image: google/cloud-sdk
    working_directory: /app
    steps:
      - checkout
      - setup_remote_docker:
          version: 18.06.0-ce
      - run:
          name: Setup CLOUD SDK
          command: |
            # base64 -i ignore non-alphabet characters
            echo $GCLOUD_SERVICE_KEY | base64 -di > ${HOME}/gcloud-service-key.json
            gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
      - run:
          name: Setup GKE Cluster Infomation
          command: |
            gcloud config set project $PROJECT_NAME
            gcloud config set container/cluster $CLUSTER_NAME
            gcloud config set compute/zone ${COMPUTE_ZONE}
            gcloud container clusters get-credentials $CLUSTER_NAME
      - run:
          name: Create secret
          command: |
            echo -n ${SLACK_TOKEN} > ./k8s/slack_token.txt
            echo -n ${SLACK_CHANNEL} > ./k8s/slack_channel.txt
            echo -n ${CIRCLE_API_USER_TOKEN} > ./k8s/circleci_token.txt
      - run:
          name: Install kustomize
          # ref. https://github.com/kubernetes-sigs/kustomize/blob/master/docs/INSTALL.md#quickly-curl-the-latest-binary
          command: |
            opsys=linux
            curl -s https://api.github.com/repos/kubernetes-sigs/kustomize/releases |\
                grep browser_download |\
                grep $opsys |\
                cut -d '"' -f 4 |\
                grep /kustomize/v |\
                sort | tail -n 1 |\
                xargs curl -O -L
            tar xzf ./kustomize_v*_${opsys}_amd64.tar.gz
            ./kustomize version
      - deploy:
          name: Kustomize build and Apply
          command: |
            ./kustomize build ./k8s/ | /usr/bin/kubectl apply -f -
  list_gke_cluster:
    working_directory: /app
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
    docker:
      - image: google/cloud-sdk:alpine
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Set gcloud
          command: |
              # cloud-sdk:alpineの場合はbase64 -iがない
              echo $GCLOUD_SERVICE_KEY | base64 -d > ${HOME}/service_account.json
              gcloud auth activate-service-account --key-file ${HOME}/service_account.json
              gcloud config set project $PROJECT_NAME
      - run:
          name: List GKE Cluster
          command: gcloud container clusters list
  delete_gke_cluster:
    working_directory: /app
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
      CLUSTER_NAME: gke-test-small-cluster
      COMPUTE_ZONE: us-west1-c
    docker:
      - image: google/cloud-sdk # kubectlを使うのでalpineではない
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Set gcloud
          command: |
              echo $GCLOUD_SERVICE_KEY | base64 -di > ${HOME}/service_account.json
              gcloud auth activate-service-account --key-file ${HOME}/service_account.json
              gcloud config set project $PROJECT_NAME
              gcloud config set container/cluster $CLUSTER_NAME
              gcloud config set compute/zone ${COMPUTE_ZONE} # delete時にもzone or regionの指定が必要
              gcloud container clusters get-credentials $CLUSTER_NAME # kubectlを使うので必要
      - run:
          name: Delete GKE Cluster
          command: |
              # clusterが存在する場合のみdelete
              if [ `gcloud container clusters list | grep $CLUSTER_NAME | wc -l` == 1 ]; then
                # clusterが存在してもStop中のこともあるのでRUNNINGのときだけdelete
                if [ `gcloud container clusters describe $CLUSTER_NAME | grep 'RUNNING' | wc -l` -gt 0 ]; then
                  # deleteには時間がかかるので先にcronjobを消す
                  kubectl delete cronjob gke-test
                  # --quiet をつけないと削除するかどうかy/nの入力を求める表示が出る
                  gcloud --quiet container clusters delete $CLUSTER_NAME
                fi
              else
                echo "$CLUSTER_NAME cluster does not exist"
              fi
      - run:
          name: Check GKE Cluster
          command: gcloud container clusters list
  create_gke_cluster:
    working_directory: /app
    environment:
      PROJECT_NAME: gke-test-ludwig125-2
      CLUSTER_NAME: gke-test-small-cluster
      COMPUTE_ZONE: us-west1-c
      #MACHINE_TYPE: n1-standard-4
      MACHINE_TYPE: g1-small
    docker:
      - image: google/cloud-sdk:alpine
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Set gcloud
          command: |
              # cloud-sdk:alpineの場合はbase64 -iがない
              echo $GCLOUD_SERVICE_KEY | base64 -d > ${HOME}/service_account.json
              gcloud auth activate-service-account --key-file ${HOME}/service_account.json
              gcloud config set project $PROJECT_NAME
              gcloud config set compute/zone ${COMPUTE_ZONE}
      - run:
          name: Create GKE Cluster
          no_output_timeout: 20m # これを防ぐ:Too long with no output (exceeded 10m0s)
          command: |
              if [ `gcloud container clusters list | grep $CLUSTER_NAME | wc -l` == 1 ]; then
                # clusterが存在する場合、ERRORになっていないか確認
                if [ `gcloud container clusters describe $CLUSTER_NAME | grep 'ERROR' | wc -l` != 0 ]; then
                  # うまく作れていないときは消してからもう一度作る
                  gcloud --quiet container clusters delete $CLUSTER_NAME
                  gcloud --quiet container clusters create $CLUSTER_NAME \
                  --machine-type=$MACHINE_TYPE --disk-size 10 --zone $COMPUTE_ZONE \
                  --num-nodes=2
                fi
              elif [ `gcloud container clusters list | grep $CLUSTER_NAME | wc -l` == 0 ]; then
                # clusterが存在しない場合はcreate
                # --quiet をつけないと作成するかどうかy/nの入力を求める表示が出る
                gcloud --quiet container clusters create $CLUSTER_NAME \
                --machine-type=$MACHINE_TYPE --disk-size 10 --zone $COMPUTE_ZONE \
                --num-nodes=2
              else
                echo "$CLUSTER_NAME cluster already exists"
              fi
      - run:
          name: Check GKE Cluster
          command: gcloud container clusters list

workflows:
  version: 2
  master-build:
    jobs:
      - build:
          filters:
            branches:
              only: master
      # buildのあとにdeployを実行したい場合は以下を有効にする
      # - deploy:
      #     requires:
      #       - build
  # cronで定期実行させたい場合は以下を有効にする
   create-deploy:
     triggers:
       - schedule:
           cron: "10 0-6 * * *" # 9-15 in JST
           filters:
             branches:
               only:
                 - master
     jobs:
       - create_gke_cluster
       - deploy:
           requires:
             - create_gke_cluster
  • GKEはg1-smallでは最低でもVM2台ないとリソースが足りずに起動ができなかったので、 --num-nodes=2 とした
  • 日本時間の9時から15時にかけて毎時10分に起動するようにした
    • 時間に特に意味はない。毎日複数回起動できるか確認したかった

Dockerfile

FROM golang:1.13-alpine as builder

RUN mkdir /gke-test
WORKDIR /gke-test

# Install git + SSL ca certificates.
# Git is required for fetching the dependencies.
# Ca-certificates is required to call HTTPS endpoints.
RUN apk add --update --no-cache ca-certificates

# COPY go.mod and go.sum files to the workspace
COPY go.mod .
COPY go.sum .

# Get dependancies - will also be cached if we won't change mod/sum
RUN go mod download
# COPY the source code as the last step
COPY . .

# Build the binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o /go/bin/gke-test

# Second step to build minimal image
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /go/bin/gke-test /go/bin/gke-test
ENTRYPOINT ["/go/bin/gke-test"]

cronjob.yaml

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: gke-test
  labels:
    app: gke-test
spec:
  # cronJob 参考
  # https://en.wikipedia.org/wiki/Cron
  # https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/
  # https://kubernetes.io/ja/docs/concepts/workloads/controllers/cron-jobs/
  schedule: "*/5 * * * *" # 5分おきに起動。頻繁すぎるとcircleciのジョブが間に合わずに複数回実行されてしまう
  concurrencyPolicy: Forbid # 前のCronが動いていたら動作しない
  jobTemplate:
    spec:
      template:
        metadata:
          labels:
            app: gke-test
        spec:
          containers:
          - name: gke-test-container
            image: us.gcr.io/gke-test-ludwig125-2/gke-test
            imagePullPolicy: Always
            command: ["/go/bin/gke-test"]
            resources:
              requests:
                memory: 512Mi
            env:
            - name: SLACK_TOKEN
              valueFrom:
                secretKeyRef:
                  name: slack-info
                  key: token
            - name: SLACK_CHANNEL
              valueFrom:
                secretKeyRef:
                  name: slack-info
                  key: channel
            - name: CIRCLE_API_USER_TOKEN
              valueFrom:
                secretKeyRef:
                  name: circleci-info
                  key: token
          restartPolicy: Never # Cron失敗時にコンテナを再起動しない

kustomization.yaml

kind: Kustomization
apiVersion: kustomize.config.k8s.io/v1beta1
commonLabels:
  app: gke-test

resources:
- cronjob.yaml

generatorOptions:
  disableNameSuffixhash: true
secretGenerator:
- name: slack-info
  files:
  - token=slack_token.txt
  - channel=slack_channel.txt
- name: circleci-info
  files:
  - token=circleci_token.txt

main.go

package main

import (
    "fmt"
    "log"
    "os"
    "runtime"
    "time"
)

func main() {
    start := time.Now()
    ciToken := mustGetenv("CIRCLE_API_USER_TOKEN")
    defer func() {
        err := requestCircleci(ciToken, "delete_gke_cluster")
        if err != nil {
            log.Fatalf("failed to requestCircleci: %v", err)
        }
        log.Println("requestCircleci successfully")
    }()
    slToken := mustGetenv("SLACK_TOKEN")
    channel := mustGetenv("SLACK_CHANNEL")

    // cronで処理をさせたい内容
    // ここでは結果をslackメッセージに渡している
    res := fmt.Sprintf("cpu: %d\n", runtime.NumCPU())
    res += fmt.Sprintf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))

    // 処理が終了したら成功メッセージをslackに通知する
    if err := sendSlackMsg(slToken, channel, res, start); err != nil {
        log.Println(err)
    }
}

func mustGetenv(k string) string {
    v := os.Getenv(k)
    if v == "" {
        log.Fatalf("%s environment variable not set.", k)
    }
    log.Printf("%s environment variable set.", k)
    return v
}

circleci.go

package main

import (
    "bytes"
    "fmt"
    "net/http"
)

func requestCircleci(token, job string) error {
    // 参考
    // https://circleci.com/docs/ja/2.0/api-job-trigger/
    // https://circleci.com/docs/api/#trigger-a-new-job
    client := &http.Client{}
    circleciURL := "https://circleci.com/api/v1.1/project/github/ludwig125/gke-test/tree/master"
    j := fmt.Sprintf(`{"build_parameters": {"CIRCLE_JOB": "%s"}}`, job)
    req, err := http.NewRequest("POST", circleciURL, bytes.NewBuffer([]byte(j)))
    req.SetBasicAuth(token, "")
    req.Header.Set("Content-Type", "application/json")
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    // circleci APIを呼び出すと、201 Created が返ってくるのでチェック
    if resp.StatusCode != 201 {
        return fmt.Errorf("status code Error. %v", resp.StatusCode)
    }

    // レスポンス本文が見たい場合はここのコメントアウトを外す
    // body, err := ioutil.ReadAll(resp.Body)
    // fmt.Println(string(body))
    return nil
}

slack.go

package main

import (
    "fmt"
    "log"
    "time"

    "github.com/nlopes/slack"
)

func sendSlackMsg(token, channel, result string, start time.Time) error {
    api := slack.New(token)
    channelID, timestamp, err := api.PostMessage(channel, slack.MsgOptionText(createSlackMsg(start, result), false), slack.MsgOptionUsername("gke-test-Bot"), slack.MsgOptionIconEmoji(":sunny:"))
    if err != nil {
        return fmt.Errorf("failed to send message: %s", err)
    }
    log.Printf("Message successfully sent to channel %s at %s", channelID, timestamp)
    return nil
}

func createSlackMsg(start time.Time, res string) string {
    jst := time.FixedZone("Asia/Tokyo", 9*60*60)
    finish := time.Now()
    processingTime := time.Since(start).Truncate(time.Second)

    msg := "*gke-test が正常に終了しました。*\n"
    msg += fmt.Sprintf("起動時刻: %v\n", start.In(jst).Format("2006-01-02 15:04:05"))
    msg += fmt.Sprintf("終了時刻: %v\n", finish.In(jst).Format("2006-01-02 15:04:05"))
    msg += fmt.Sprintf("所要時間: %v\n\n", processingTime)
    msg += fmt.Sprintf("%s\n", res)
    return msg
}

起動例

Slackに送られた通知を見るとこんな感じ

image

すべての料金  |  Compute Engine ドキュメント  |  Google Cloud のスペックを参考に、MACHINE_TYPE: n1-standard-4 にすれば以下のようになった。 ちゃんとCPU4になっている

image

毎日の起動でかかる金額

一日あたり、7回起動しても4円という素晴らしい金額になった image

左の方の山は、試行錯誤していた際に、GKEクラスタを丸一日起動させっぱなしにしていた時の料金で、このときは最高81円だった image

それが4円!

一日当たりにかかる料金の内訳

レポートの期間を1日にして、グループ条件をSKUにすると使っているリソースの内訳がわかりやすい

image

上のレポートで課金の発生している部分を以下の表にまとめた。

SKU プロダクト SKU ID 使用
Small Instance with 1 VCPU running in Americas Compute Engine 82AF-89FC-240D 1.55 hour
Multi-Regional Storage Asia Cloud Storage E653-0A40-3B69 0.02 gibibyte month
Multi-Regional Storage US Cloud Storage 0D5D-6E23-4250 0.01 gibibyte month

Small Instance with 1 VCPU running in Americas

VMの料金を表す。1.55 hour使用したことになっている。

毎回クラスタを作る際に --num-nodes=2 を設定してg1-smallのマシンを2台作っているので、1.55時間というのは2台のVMの稼働時間を合わせたものとなっている。 つまり、GKEクラスタが存在する時間は1日あたり 1.55*60/2=46.5分間 ということになる

毎日7回Cronを実行していることを考えると、 GKEクラスタが生成されて削除されるまでの平均の時間 は、46.5/7=6.64分間 ということになる。

この6.64分間というのは、GKEクラスタが生まれてcronjobがそれを消すまでの時間と大体合っていそうだ。 (大体毎時10分から作り始めて15分ごろにcronが起動しているので)

すべての料金  |  Compute Engine ドキュメント  |  Google Cloud を見ると、 g1-smallの1時間あたりの料金は0.03ドルなので、1ドル107円とすると、 1.55hour*0.03ドル*107=4.9755円となる。

マシンタイプ 仮想 CPU 数 メモリ 料金(米ドル) プリエンプティブル料金(米ドル)
g1-small 1 1.70GB $0.03 $0.01

課金額は4円となっているので、ちょっと計算が合わないけど、やすいからいいや。

もしこれが24時間フル稼働しているサーバだとしたら、 24/1.55 * 4 = 62円 くらいかかっていたはずだ。 (実際には一ヶ月フルに使うと継続利用料金が適用されて1日あたりはもう少しやすくなるはずだけど)それが4円で済んだ計算になる。

Multi-Regional Storage Asia

たぶんこれが、Slackやcircleciの通信費

余談だけど、最初に作った時はDockerImageをgolang alpineにして、かつGCRをうっかりasia.gcr.ioにしてしまった。

こうしてしまったことで、デプロイするたびにアジアのGCRから北アメリカのGKEクラスタにDocker ImageをPullする必要が生じていた。 また、このときはDocker Imageがscratchベースではなくgolang-alpineベースだったためImageが大きかったこともあり、通信費が一日12円もかかっていた。 ちょっと工夫するだけで、これを0円に抑えることができた。

参考:イメージの push と pull  |  Container Registry  |  Google Cloud

us.gcr.io は米国でイメージをホストしますが、その場所は、gcr.io によってホストされるイメージからは独立したストレージ バケットです。
eu.gcr.io は、欧州連合でイメージをホストします。
asia.gcr.io は、アジアでイメージをホストします

Multi-Regional Storage US

たぶんこれがGCRとGKEクラスタの通信費

まとめ

circleciのスケジュールとAPIを使って、GKEを格安で使う方法が確立できた

この構成を応用して安くGKEを活用できそう。