個人開発で使う GKE
私が個人で開発・運用している Qoodish という Web サービスは、主要なコンポーネントを Google Kubernetes Engine (GKE) 上にホスティングしています。
大規模なサービスを展開するために使われるイメージが強い Kubernetes (k8s) ですが、個人開発で作るような小規模なサービスでも様々なメリットがあります。
運用コストの削減
開発も運用も基本的に一人で行う個人開発においては、いかに運用コストを下げてアプリケーションの開発やサービスの設計に時間を割いていくかというのは重視されるポイントだと思います。
k8s を使うことで、必然的に Infrastructure as Code が実現され、アプリケーションをコンテナ上でステートレスに運用するということが可能になります。
k8s 導入以前は VM の状態管理や障害時の復旧作業に悩まされていたものですが、k8s は放置していても基本的に YAML で宣言したとおりに動いてくれているという安心感があります。(マスターノード周辺を管理する必要のない、GKE のようなマネージドサービスだからこそ得られる安心感ではあると思います)。
柔軟性
小規模にサービスを運用するにあたっての選択肢として、Firebase のような MBaaS や Cloud Run のような PaaS 系のサービスが検討されると思います。
Qoodish もいくつかのコンポーネントを Firebase を使ってホスティングしていた時期があったのですが、少し凝ったことを実現しようとすると手間がかかったり、複雑なアーキテクチャになってしまったりします (たとえば、動的なアプリケーションを展開するのに Firebase Hosting と Cloud Functions / Cloud Run を組み合わせる必要があるなど)。
k8s はそのあたり非常に柔軟性が高く、k8s のエコシステムの中でやりたいことを素直に実現できるという感触があります。
個人開発とはいえ、長く運用しているとやりたいことも増えてくるので、ある時点からはインフラの柔軟性というのも重視すべきポイントになるかと思います。
この記事では、実際に GKE 上で Web サービスを運用して得られた知見や、GKE を小さく使うにあたっておさえておきたい Tips をまとめていきます。
自分と同じように個人 or 少人数で Web サービスを開発・運用している方々の何かの参考になれば幸いです。
GKE (k8s) の概要や基本的な説明については、Google Cloud のドキュメントがよくまとまっていてわかりやすかったです。k8s とはそもそもなんぞや?という方はぜひこちらも覗いてみてください。
BackendConfig を使って Cloud CDN を設定し、静的コンテンツを配信する
個人開発に関わらず、Web アプリケーションにおいて JavaScript や画像ファイル、manifest.json などといった静的コンテンツの配信は付き物です。
Firebase Hosting であればデフォルトで CDN (Fastly) が機能し、高速なコンテンツ配信が可能となっていますが、GKE でも k8s の BackendConfig リソース経由で Cloud CDN を設定することで、同等の構成を比較的簡単に実現することが可能です。
具体的な実装方法は Google Cloud のドキュメントに載っています。
apiVersion: cloud.google.com/v1beta1
kind: BackendConfig
metadata:
name: cdn-backend-config
spec:
cdn:
enabled: true
cachePolicy:
includeHost: true
includeProtocol: true
includeQueryString: false
apiVersion: v1
kind: Service
metadata:
name: web
annotations:
beta.cloud.google.com/backend-config: '{"ports": {"http":"cdn-backend-config"}}'
spec:
type: NodePort
selector:
app: web
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
静的コンテンツの配信元としては、Qoodish の場合は Express をコンテナとして k8s クラスタ内に立ち上げています。上記の Cloud CDN の設定をしておくことで、キャッシュがある場合は Express に負荷をかけることなく、Cloud CDN が静的コンテンツに対するリクエストをさばいてくれます。
Express での静的ファイルの提供
Cache-Control などの設定はアプリケーションの特性に合わせてよしなに設定しましょう。
Google マネージド SSL 証明書で Ingress を HTTPS 終端にする
規模に関わらず、現代の Web アプリケーションにおいて HTTPS 対応は必須です。
Firebase Hosting や Cloud Run では、ユーザーが特に設定することなくアプリケーションのエンドポイントが HTTPS 化されています。
GKE でも、Google マネージド SSL 証明書を利用することで簡単に Ingress のエンドポイントを HTTPS 化することが可能です。
例によって、具体的な実装方法は Google Cloud のドキュメントに載っています。
apiVersion: networking.gke.io/v1beta1
kind: ManagedCertificate
metadata:
name: qoodish-certificate
spec:
domains:
- qoodish.com
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: qoodish-ingress
annotations:
kubernetes.io/ingress.global-static-ip-name: qoodish-static-ip
networking.gke.io/managed-certificates: qoodish-certificate
kubernetes.io/ingress.allow-http: "true"
spec:
backend:
serviceName: web
servicePort: 80
rules:
- http:
paths:
- path: /api/*
backend:
serviceName: api
servicePort: 80
Google マネージド SSL 証明書の実態は Let's Encrypt で、更新の手間やコストがかからないといった点は非常に便利なのですが、ワイルドカードに対応していないといった制約には注意する必要があります。
⚠ 2020/1/27 追記
マネージド証明書をプロビジョニングする際、Let's Encrypt の仕様上 Ingress の 443 番および 80 番ポートを開放していないとプロビジョニングが失敗します。ご注意ください。
ベスト・プラクティス - 80 番ポートを開放しよう
プリエンプティブルノードを使ってインスタンスのコストを大幅に削減する
GKE で k8s クラスタを立ち上げると、Compute Engine の VM としてノードが立ち上がるのですが、このノードのオプションとして「プリエンプティブル」を有効化しておくと、通常のノードに比べて料金を大幅に削減することができます。収益が出ない状況でもモチベーションを維持し、長くサービスを続けていくためには、低価格でサービスを運用していくことは極めて重要です。
上記の公式ドキュメントにも書かれている通り、プリエンプティブル VM は「24 時間以内に強制的にシャットダウンされる」「ノード内のポッドは通知なしで強制的にシャットダウンされる」「新しいインスタンスがいつ使用可能になるかは保証されない (※)」といった制約があるので、これらの制約が受け入れられるかどうか慎重に判断する必要があります。
(※ これまで運用してきて、実際にインスタンスが割り当てられなかったことは今のところないです)。
各制約を受け入れた上で、プリエンプティブルノードを利用しつつ最大限安定したサービス運用を行っていくために、いくつかの Tips をここに挙げておきたいと思います。
1. マルチゾーンクラスタでノード / ポッドを複数ゾーンに配置する
プリエンプティブルノードは 24 時間以内にシャットダウンされます。全ノードをプリエンプティブルノードで運用する場合は、サービス断を避けるためには最低でも 2 ノード以上で運用しておくのが無難でしょう。プリエンプティブルノードなので、通常ノードに比べれば 2 ノードでも全然安いです。
クラスタのノードロケーションとして 2 つのゾーンを指定し、ノード数を 1 とすると、1 ゾーンにつき 1 ノードを作成し、合計 2 ノードのゾーン間冗長構成クラスタとすることができます。
このとき、アプリケーションコンテナのレプリカ数には合計ノード数と同じ 2 を指定します (あるいはそれ以上)。
k8s では、デフォルトで SelectorSpreadPriority というスケジューリングポリシーの設定が優先して働くため、同種のポッドがあった場合は複数のノードにポッドを分散配置してくれます (ただし、Persistent Volume のアタッチ状況などによっては分散されなかったりするので注意が必要です)。
レプリカ数が 2 であれば、1 ゾーン (1 ノード) につき 1 ポッドが配置されます。
以上の設定により、1 つのノードが落ちたとしてもサービス断が発生しない冗長構成を低コストで実現できます。
ただし、全プリエンプティブルノードを一斉にシャットダウンされるという可能性はあるので、お金に余裕がある人はプリエンプティブルノードに加えて通常ノードを混ぜておくのが良いです。
ついでに、ノードのマシンタイプは n1-standard-1 が無難です (g1-small でも動くには動きますがやや頼りないです)。
2. Descheduler でポッドの偏りを解消する
プリエンプティブルノードが削除されると、削除されたノードに配置されていたポッドは生きているノードに退避します。
ノードが再び作成されると、新規作成されたノードにポッドが帰ってくるのかと思いきや、そういった機能は今のところデフォルトでは備わっていません。
プリエンプティブルノードを使いつつ、ノード / ゾーン間の冗長構成を維持していくには、ポッドの偏りを解消する仕組みが必要です。
そこで使われるのが Descheduler です。
参考:
・図で理解する Descheduler
・Kubernetes DeschedulerでPodを再配置する
・Kubernetes: Kubernetes Descheduler とは
公式の README に記載の通り、Descheduler は k8s の Job として稼働させることが可能です。
examples を参考に CronJob の manifest を作成し、自身のクラスタにデプロイしておきましょう。
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
name: descheduler-cluster-role
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "watch", "list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "watch", "list", "delete"]
- apiGroups: [""]
resources: ["pods/eviction"]
verbs: ["create"]
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: descheduler-sa
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: descheduler-user
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: descheduler-cluster-role
subjects:
- kind: ServiceAccount
name: descheduler-sa
namespace: kube-system
---
kind: ConfigMap
apiVersion: v1
metadata:
name: descheduler-policy-configmap
namespace: kube-system
data:
policy.yaml: |
apiVersion: descheduler/v1alpha1
kind: DeschedulerPolicy
strategies:
RemoveDuplicates:
enabled: true
RemovePodsViolatingInterPodAntiAffinity:
enabled: true
RemovePodsViolatingNodeAffinity:
enabled: true
params:
nodeAffinityType:
- requiredDuringSchedulingIgnoredDuringExecution
LowNodeUtilization:
enabled: true
params:
nodeResourceUtilizationThresholds:
thresholds:
cpu: 20
memory: 20
pods: 20
targetThresholds:
cpu: 50
memory: 50
pods: 50
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: descheduler-cronjob
namespace: kube-system
spec:
schedule: "*/30 * * * *"
concurrencyPolicy: Allow
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
metadata:
name: descheduler-pod
annotations:
scheduler.alpha.kubernetes.io/critical-pod: ""
spec:
containers:
- name: descheduler
image: gcr.io/{{ Project ID }}/descheduler:{{ tag }}
volumeMounts:
- mountPath: /policy-dir
name: policy-volume
command:
- /bin/descheduler
- --policy-config-file=/policy-dir/policy.yaml
- --evict-local-storage-pods
- --max-pods-to-evict-per-node=10
- -v=1
restartPolicy: Never
serviceAccountName: descheduler-sa
volumes:
- name: policy-volume
configMap:
name: descheduler-policy-configmap
Descheduler のコンテナイメージについては、イメージをビルド ~ Google Container Registry にプッシュするコマンドが提供されています。
下記のようにリポジトリをクローン後、make コマンドを実行しましょう。
$ git clone git@github.com:kubernetes-sigs/descheduler.git
$ cd descheduler
# イメージのビルド
$ make image
# Makefile 内の REGISTRY の値を自身の GCR エンドポイントに書き換えて実行します
$ make push
3. Persistent Volume でデータを永続化する
プリエンプティブルノードが削除される際は、当然ながらノードのディスク領域も綺麗さっぱり削除されます。
プリエンプティブルノードの有効期間を超えて保持しておきたいデータがある場合は、Persistent Volume を使いましょう。
Qoodish では、StatefulSet と Persistent Volume を利用して Redis を 1 ゾーン (1 ノード) につき 1 ポッド (+ 1 ボリューム) ずつ稼働させています。
(本当は Memorystore for Redis を使いたいのですが、小規模に使えるプランがない...)。
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
type: ClusterIP
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
spec:
selector:
matchLabels:
app: redis
serviceName: redis
replicas: 2
template:
metadata:
labels:
app: redis
spec:
terminationGracePeriodSeconds: 10
containers:
- name: redis
image: redis:5.0.7-alpine
args: ["--appendonly", "yes"]
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
cpu: 100m
memory: 64Mi
ports:
- containerPort: 6379
volumeMounts:
- name: redis-pv
mountPath: /data
volumeClaimTemplates:
- metadata:
name: redis-pv
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
Cloud Build で CI / CD を構成する
安定したサービス運営に CI / CD は不可欠です。
CI / CD を実現するツールは様々なものがありますが、GCP では Cloud Build を利用することで小さくシンプルな CI / CD パイプラインを構成することができます。
Cloud Build を使用した GitOps スタイルの継続的デリバリー
以下は GitHub のブランチへのプッシュをトリガーとして実行するビルドのサンプルです。
Rufo による lint と Rails test の実行後、Kaniko によるコンテナイメージのビルドおよび Container Registry へのプッシュを行っています。
GitHub でのビルドの実行
GitHub アプリトリガーを作成する
# cloudbuild.yaml
steps:
- name: gcr.io/cloud-builders/gcloud
id: decrypt-dev-secrets
entrypoint: bash
args:
- -c
- |
gcloud kms decrypt --ciphertext-file=.env.development.enc --plaintext-file=.env --location=global --keyring=qoodish-key-ring --key=qoodish-key
- name: gcr.io/$PROJECT_ID/docker-compose
id: docker-compose-up
entrypoint: bash
args:
- -c
- |
docker-compose up -d
waitFor:
- decrypt-dev-secrets
- name: gcr.io/$PROJECT_ID/docker-compose
id: rufo-check
entrypoint: bash
args:
- -c
- |
docker-compose exec -T api bundle exec rufo --check ./app
waitFor:
- docker-compose-up
- name: gcr.io/$PROJECT_ID/docker-compose
id: rails-test
entrypoint: bash
args:
- -c
- |
sleep 10
docker-compose exec -T api bundle exec rails db:setup
docker-compose exec -T api bundle exec rails test -b -v
waitFor:
- docker-compose-up
- name: gcr.io/kaniko-project/executor
id: kaniko-build
args:
- --cache=true
- --cache-ttl=6h
- --destination
- gcr.io/$PROJECT_ID/$REPO_NAME:$BRANCH_NAME
- --destination
- gcr.io/$PROJECT_ID/$REPO_NAME:$COMMIT_SHA
waitFor:
- rufo-check
- rails-test
Qoodish では k8s の manifest 類を専用のリポジトリで管理しており、こちらのリポジトリの master ブランチ更新 (コンテナイメージのタグ更新など) をトリガーとして実行するビルドを設定しています。
# cloudbuild.yaml
steps:
- id: kube-get-credentials
name: gcr.io/cloud-builders/gcloud
args:
- container
- clusters
- get-credentials
- --zone
- ${_GKE_ZONE}
- ${_GKE_CLUSTER_NAME}
- id: kube-apply
name: gcr.io/cloud-builders/kubectl
entrypoint: bash
args:
- -c
- |
kubectl apply -f kube
env:
- CLOUDSDK_COMPUTE_ZONE=${_GKE_ZONE}
- CLOUDSDK_CONTAINER_CLUSTER=${_GKE_CLUSTER_NAME}
Cloud Build のサービスアカウントに GKE クラスタへのアクセス権限を付与し、kubectl apply コマンドを実行させています。
Blue / Green Deployment 等、もう少しきちんとやるなら Spinnaker などの CD に特化したツールが必要ですが、上記のようにシンプルな CI / CD パイプラインであれば Cloud Build だけでも実現可能です。
(とは言え、やはりマネージド CD サービスがほしいと思う今日この頃...)。
まとめ
以上、「小規模に GKE を使う」をテーマに Tips をまとめてきました。
また今後、GKE 上で Web サービスを運用していく中で知見が溜まってきたら紹介したいと思います。
よい GKE ライフを!
次回は Qoodish の全体アーキテクチャについて語ります (予定)。
この記事が気に入ったらサポートをしてみませんか?