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
マシンタイプ | 仮想 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 |
趣味の開発にはとてもそんな高いお金は払えない
プリエンプティブルについては後述する
一か月の使用料金について
- ちなみに、1ヶ月継続利用すると割引が発生する
【実例】n1-standard-1でHello Worldを表示するだけのサーバを動かした時の費用
以下、n1-standard-1でHello Worldを表示するだけのサーバを3週間近く放置した結果を見てみる
これは、GKEのチュートリアルでHello Worldを表示するサーバを作って、うっかりクラスタを消さなかったらこうなった(実体験)
6876円という金額が請求された!
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なら必要に応じてマシンのスペックを変えられるという点もいい。
考えた結果、
- 「GKEクラスタが存在する限り課金されるのが嫌なら、バッチ処理の直前にクラスタ作って、終わったらクラスタ削除すればいいんじゃない?」
- 「バッチ処理ならKubernetesのDeploymentとかServiceとかいらないんじゃない?cronjobだけでいいんじゃない?」
という発想から考えたのが以下の構成
構成の概要
上の横一列はソースコードのビルド処理を表す
- githubにpushすると
- circleciがビルドジョブを起動して、
- docker imageをGCRにpushする。
- circleciのcron機能を使って設定した時刻に、GKEクラスタの作成とデプロイを行うcircleciジョブを実行するようにする
- GKEのデプロイをする際に、GCRから最新のDockerImageを取得する
- kubernetesのcronJobを数分おきに起動するようにして、いつデプロイされても数分後に実行されるようにしておく
- cronJobは、何らの処理(ここではcpu情報などをslackに通知する処理とした)をした後で、
- 最後にcircleciジョブのAPIを呼び出してGKEクラスタ削除ジョブを実行させる
1〜3までは多くの人がやっている方法。 普通はこの後デプロイジョブを続けて起動させるのが一般的だが、今回の方法ではこのままデプロイはしない。
下の横一列4〜8が今回自分が考えた部分。
プログラミング言語はGo言語を使用する
クラスタ(VM)が存在する期間を最小にすることで、大幅なコスト削減が見込めるはず
詳細な手順
以下、自分の検証を追う形で詳細な手順を紹介する。 結果は一番最後に記載するので、結果だけ知りたい人は「最終的に作ったもの」を見てください
GCRにdocker pushするまで
まずはcircleciのジョブでGCRにdocker pushするところまで行う。(図の黄色枠で囲まれた部分)
事前にGKEのチュートリアルのプロジェクトの作成作業が必要。
チュートリアルに沿って以下でプロジェクトを作成しておく
プロジェクトの選択 -> 新しいプロジェクト
gke-test-ludwig125-2
という名前でプロジェクトを作成
(gke-test-ludwig125で設定をミスったので作り直した。これ以降の画像は全てgke-test-ludwig125-2 と読み替える必要がある)
デフォルトのプロジェクトを変更
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については以下が参考
- イメージの push と pull | Container Registry | Google Cloud
- イメージの管理 | Container Registry | Google Cloud
- Google Cloud SDK のドキュメント | Cloud SDK | Google Cloud
.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
- gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json
- サービスアカウントを上でリダイレクトしたファイルから読み込み
- gcloud --quiet auth configure-docker
- サービスアカウントの認証
Docker Build & Push
- Docker build してlatestタグをつけてからGCRにpushしている
- circleciで実行する場合、CIRCLE_TAGが取れるので、CIRCLE_TAGもtagにつける
- GKEクラスタをアメリカに作るので、GCRもアメリカにあったほうがDocker Imageのpullの際のデータ通信が安くなるのではと考えたので、us.gcr.ioを指定している
- 参考:イメージの push と pull | Container Registry | Google Cloud
これをgithubに上げる
circleciの作業
circleciに登録
Linux -> go を選んで Start Buildingボタンを押す
このままではcircleciがGKEのプロジェクトにアクセスできないので失敗するはず
サービスアカウントの追加
circleciからプロジェクトを操作するために、 以下の方法でサービスアカウントを作成しておく
I AMと管理 -> サービスアカウント 「サービスアカウントを作成」
gke-test
で作成
【サービスアカウントの追加】ロールについて
ここで書いている構成にするためには、以下のロール(権限を管理している)が必要(2021/02/09時点)
- Kubernetes Engine 管理者
- サービス アカウント ユーザー
- ストレージ管理者
- Compute インスタンス管理者
サービス アカウントの権限(オプション) は以下のようにそれぞれ、
- 「Kubernetes Engine」-> 「Kubernetes Engine 管理者」
- 「Service Accounts」 -> 「サービス アカウント ユーザー」
- 「ストレージ」-> 「ストレージ管理者」
- 「Compute Engine」->「Compute インスタンス管理者」(<-2021/02/09追記 1月のバージョンからないと怒られるようになったので)
を選択する
(「Compute インスタンス管理者」は後から必要になったので画像にはない)
【サービスアカウントの追加】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".
参考:
- google cloud platform - gcloud: The user does not have access to service account "default" - Stack Overflow
- Cloud IAM ポリシーの作成 | Kubernetes Engine のドキュメント | Google Cloud
- サービス アカウント | Cloud Identity and Access Management のドキュメント | Google Cloud
- Istio有効化したGKEクラスタをTerraformで作成する - Qiita
- gcloud iam service-accounts list | Cloud SDK | Google Cloud
- google-compute-engine – Compute Instance Admin権限を持つGCEサービスアカウント - コードログ
- Compute Engine IAM の役割 | Compute Engine ドキュメント | Google Cloud
【サービスアカウントの追加】ストレージ管理者
これがないと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 に書いてある通り、ストレージ管理者が権限に必要
【サービスアカウントの追加】Compute インスタンス管理者
2021/02/09 追記
1/20以降、これがないと、Kubernetesのデプロイ前に以下の設定時にエラーが出るようになった。
確実な原因は特定できず
root@b0b686c15028:~# gcloud config set compute/zone us-central1-f Updated property [compute/zone]. ERROR: (gcloud.config.set) Some requests did not succeed: - Required 'compute.zones.list' permission for 'projects/<自分のプロジェクトID>'
compute.zones.list
が必要だというので、以下を参考に、compute.zones.*
を権限に含む、Compute インスタンス管理者 をロールに追加した
https://cloud.google.com/compute/docs/access/iam#compute.instanceAdmin.v1
【サービスアカウントの追加】ここまでのロール確認
ここまででIAMを見るとこんな感じ
- Kubernetes Engine 管理者
- サービスアカウントユーザー
- ストレージ管理者
- Compute インスタンス管理者
の4つが設定されている
【サービスアカウントの追加】キーの作成以降
キーの作成 JSONを選ぶ
以下の形式の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
という名前のキーにする
これでcircleciのジョブを再実行 うまくいくとこんな感じ
gcr
ここまでで参考にさせていただいたページ
circleCIとgithubを連携して、簡単にコードをGCRにpushできるようにしてみた - アプリとサービスのすすめ
GKE+CircleCI 2.0で継続的デプロイ可能なアプリケーションをシュッと作る - Eureka Engineering - Medium
GKEのデプロイ
ここからは、GKEのデプロイ以降の部分について記載する(図の黄色枠で囲まれた部分)
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がいらない
などの違いがある
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に通知するようにしてみる。 この部分はただの趣味なので、クラスタの自動作成削除の本筋とは関係ない。
-u
で最新を取ってくる
go get -u github.com/nlopes/slack
以下を参考にmain.goを修正
- slack - GoDoc
- slack/messages.go at master · nlopes/slack · GitHub
- golang で始める Slack bot 開発 - at kaneshin
- こちらも詳しい
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
こんな感じに送れた
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作成の参考
- Create the smallest and secured golang docker image based on scratch
- Using go mod download to speed up Golang Docker builds
- ca-certificates を入れておくことで、SSL通信が可能になる
- Install Certificates in Alpine Image to establish Secured Communication (SSL/TLS) - By
- お前のDockerイメージはまだ重い💢💢💢 - Speaker Deck
- scratch, busybox, alpineなどがわかりやすくまとまっている
- Building Minimal Docker Containers for Go Applications | Codeship | via @codeship
- scratchイメージをgoでどう使うかを具体的に紹介している
- SSL接続のためにca-certificates.crtが必要とか、ないとどうなるかとか具体例とともに理由を書いてあっていい
実際に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に通知がくるようになった
通知がうっとうしいのでいったん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で実行するためのジョブを作成する
- kubectlは使わないので、imageはgoogle/cloud-sdk:alpineでいい
APIはcircleci version2.1には対応していないらしい残念
-
現在のところ、CircleCI 2.1 と Workflows を使用する場合には、単一のジョブをトリガーすることができません。
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が取得できたことがわかる
#!/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 に以下を追加する
検証用に以下のような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の結果
うまく目的のジョブが起動しないときは、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に送られた通知を見るとこんな感じ
すべての料金 | Compute Engine ドキュメント | Google Cloud
のスペックを参考に、MACHINE_TYPE: n1-standard-4
にすれば以下のようになった。
ちゃんとCPU4になっている
毎日の起動でかかる金額
一日あたり、7回起動しても4円という素晴らしい金額になった
左の方の山は、試行錯誤していた際に、GKEクラスタを丸一日起動させっぱなしにしていた時の料金で、このときは最高81円だった
それが4円!
一日当たりにかかる料金の内訳
レポートの期間を1日にして、グループ条件をSKUにすると使っているリソースの内訳がわかりやすい
上のレポートで課金の発生している部分を以下の表にまとめた。
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を活用できそう。