スクリーンショット_2020-01-09_15

個人開発で使う GKE

私が個人で開発・運用している Qoodish という Web サービスは、主要なコンポーネントを Google Kubernetes Engine (GKE) 上にホスティングしています。

大規模なサービスを展開するために使われるイメージが強い Kubernetes (k8s) ですが、個人開発で作るような小規模なサービスでも様々なメリットがあります。

参考: Kubernetes は辛いのか?

運用コストの削減

開発も運用も基本的に一人で行う個人開発においては、いかに運用コストを下げてアプリケーションの開発やサービスの設計に時間を割いていくかというのは重視されるポイントだと思います。

k8s を使うことで、必然的に Infrastructure as Code が実現され、アプリケーションをコンテナ上でステートレスに運用するということが可能になります。

k8s 導入以前は VM の状態管理や障害時の復旧作業に悩まされていたものですが、k8s は放置していても基本的に YAML で宣言したとおりに動いてくれているという安心感があります。(マスターノード周辺を管理する必要のない、GKE のようなマネージドサービスだからこそ得られる安心感ではあると思います)。

柔軟性

小規模にサービスを運用するにあたっての選択肢として、Firebase のような MBaaS や Cloud Run のような PaaS 系のサービスが検討されると思います。
Qoodish もいくつかのコンポーネントを Firebase を使ってホスティングしていた時期があったのですが、少し凝ったことを実現しようとすると手間がかかったり、複雑なアーキテクチャになってしまったりします (たとえば、動的なアプリケーションを展開するのに Firebase Hosting と Cloud Functions / Cloud Run を組み合わせる必要があるなど)。

k8s はそのあたり非常に柔軟性が高く、k8s のエコシステムの中でやりたいことを素直に実現できるという感触があります。
個人開発とはいえ、長く運用しているとやりたいことも増えてくるので、ある時点からはインフラの柔軟性というのも重視すべきポイントになるかと思います。

スクリーンショット 2020-01-12 20.28.32

この記事では、実際に 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 のドキュメントに載っています。

Ingress による Cloud CDN

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 のドキュメントに載っています。

Google マネージド SSL 証明書の使用

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 の実行

上記の公式ドキュメントにも書かれている通り、プリエンプティブル VM は「24 時間以内に強制的にシャットダウンされる」「ノード内のポッドは通知なしで強制的にシャットダウンされる」「新しいインスタンスがいつ使用可能になるかは保証されない (※)」といった制約があるので、これらの制約が受け入れられるかどうか慎重に判断する必要があります。
(※ これまで運用してきて、実際にインスタンスが割り当てられなかったことは今のところないです)。

各制約を受け入れた上で、プリエンプティブルノードを利用しつつ最大限安定したサービス運用を行っていくために、いくつかの Tips をここに挙げておきたいと思います。

1. マルチゾーンクラスタでノード / ポッドを複数ゾーンに配置する

プリエンプティブルノードは 24 時間以内にシャットダウンされます。全ノードをプリエンプティブルノードで運用する場合は、サービス断を避けるためには最低でも 2 ノード以上で運用しておくのが無難でしょう。プリエンプティブルノードなので、通常ノードに比べれば 2 ノードでも全然安いです。

クラスタのノードロケーションとして 2 つのゾーンを指定し、ノード数を 1 とすると、1 ゾーンにつき 1 ノードを作成し、合計 2 ノードのゾーン間冗長構成クラスタとすることができます。

マルチゾーンクラスタを作成する

このとき、アプリケーションコンテナのレプリカ数には合計ノード数と同じ 2 を指定します (あるいはそれ以上)。
k8s では、デフォルトで SelectorSpreadPriority というスケジューリングポリシーの設定が優先して働くため、同種のポッドがあった場合は複数のノードにポッドを分散配置してくれます (ただし、Persistent Volume のアタッチ状況などによっては分散されなかったりするので注意が必要です)。
レプリカ数が 2 であれば、1 ゾーン (1 ノード) につき 1 ポッドが配置されます。

kube-scheduler によるスケジューリング

以上の設定により、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 の全体アーキテクチャについて語ります (予定)。

この記事が気に入ったらサポートをしてみませんか?