GKEハンズオンをつくってみた -Vue.js + Spring Boot アプリケーション-


Kubernetesについて学んだことのアウトプットとして自分で簡単なMVCアプリ(Vue.js + Spring Boot + MySQL)をGKEにデプロイしてみました。以下にソースコードアップしてます。(期待通りの動きしなかったらGithub上で教えてください)

ソースコードを見れば動きは大抵分かりますが、作成した中での設計ポイントについて以下解説していきます。シンプルなMVCアーキテクチャですが、ドキュメントには設計方針的な部分がまとめて書かれていないので、自分も迷った部分などをシェアハピできれば嬉しいです。

GitHub - yellow-high5/easy-mvc-gke: Easy MVC Application for Google Kubernetes Engine

設計した流れ

「開発環境ではDocker環境上で実行できる(docker-compose up一発で立ち上げ)。本番環境ではシェルスクリプト一発でGKE上にアプリケーションをデプロイして実行できる」という理想を念頭に置いて設計しました。
ローカル上ではDBコンテナを簡単に立ててOKですが、GKE上では永続化するストレージはきちんとKubernetesクラスターの外部に持っておくことが一般的のようです。詳しくはコンテナ運用のおすすめの方法にも記載されていますが、コンテナ内部をステートレスに保つことが重要になってきます。また、永続ストレージにアクセスするためのユーザー名やパスワードはシークレットリソースで定義しておきます。

配置構成については、Redisなどのキャッシュ用途が強く、コンテナと結合度が高いコンポーネントがあったりすると配置が変わってくるかもしれません...。

ローカル環境

ローカル環境での構築は、ごく普通に各アプリのフォルダごとにDockerfileをビルドして実行するdocker-compose.ymlを作成していくだけです。

Dockerfileの記述

それぞれVue.jsのコンテナ化SpringBootのコンテナ化についてのドキュメントが非常に分かりやすいです。npmはマルチステージビルドを利用しており、SpringBootは事前にシェル上でビルドしてからコンテナ化しているケースが紹介されています。

Vue.jsはビルドして静的コンテンツとしてNGINXにデプロイしてしまうと、APIへのURLなどは動的に変更できなくなるのでビルド段階で環境変数を渡しておくようにします。

Vue.jsのマルチステージビルド
# ビルド環境
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG API_PATH
ENV VUE_APP_API_ORIGIN=${API_PATH}
RUN npm run build

# 本番環境(NGINX)
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Cloud Shellは起動時にすでにmvnコマンドまたはgradleコマンドがあるので、本番実行用のシェルスクリプトにmvn, gradleが使えちゃいます。自分はこれを知らず、「Cloud Shellにmvnをインストールするのはダルい」と勘違いしていて、SpringBootのDockerfileでマルチステージビルドを利用しています。Javaはnpmと違ってビルド時間が長いので、もしかしたらシェル上でビルドした方が良かったのかもしれません。どちらにせよ、ビルドをコンテナでするか、シェル上でするかの違いになります。

SpringBootのマルチステージビルド
# ビルド環境
FROM maven:3.6-jdk-8 as build-stage
WORKDIR /app
COPY src /app/src
COPY pom.xml /app
RUN mvn install 

# 本番環境
FROM openjdk:8-jdk-alpine
COPY --from=build-stage /app/target/*.jar app.jar
COPY wait-for ./
RUN chmod 700 ./wait-for
# ローカル環境では、docker-composeのentrypointに上書きされる
ENTRYPOINT ["java","-Dspring.profiles.active=gke","-jar","app.jar"]

# mvnがインストールされているならmvn install実行後に以下でDockerビルドした方が早い
# FROM openjdk:8-jdk-alpine
# VOLUME /tmp
# ARG JAR_FILE=target/*.jar
# COPY ${JAR_FILE} app.jar
# ENTRYPOINT ["java","-Dspring.profiles.active=gke","-jar","app.jar"]

DB接続情報については、Spring Bootの定義ファイルのresourcesフォルダ配下のapplication.yml(application.properties)に記載します。dockerで構築する場合とgkeで構築する場合で接続情報を分けておき、-Dspring.profiles.activeプロパティで起動環境を指定する形にしています。

コンテナ間の接続

あとはコンテナ構築で厄介になるのが①Client-API間、②API-DB間のコネクションです。
①については、ソースコードを見てもらうとわかると思いますが、環境変数で定義して繋いでいます。

client:
    build:
      context: ./vuejs-app-gke/.
      args:
        - API_PATH=http://localhost:8080
    container_name: vuejs
    environment:
      NODE_ENV: development
    ports:
      - "80:80"
    networks:
      - frontend

②については、DBが起動してからでないとアプリ側のコネクションエラーが発生してしまうので、DBの準備完了まで待つようにwait-for-itやdockerizeのようなラッパー用のスクリプトを利用するべきらしく、自分はこのスクリプトを使ってます。depends_onなどのdocker-compose上で依存関係を指定していても、DB起動までは待ってくれてもDB準備完了までは待ってくれないようです。

api:
    build: ./springboot-app-gke/.
    container_name: springboot
    depends_on: 
      - "database"
    # DockerfileにあるENTRYPOINTを上書きしている
    entrypoint: sh -c "./wait-for database:3306 -t 60 -- java -Dspring.profiles.active=docker -jar app.jar"
    ports:
      - "8080:8080"
    networks:
      - frontend
      - backend

Compose の起動順番を制御 — Docker-docs-ja 17.06.Beta ドキュメント

GKE

Kubernetesクラスターの作成からクラスターへのアプリデプロイまでの大まかな流れは以下の感じでした。
Kubernets Engine APIとService Networking APIをEnabledにする必要があります。

1. VPCネットワーク作成
2. サブネット作成
3. ファイアウォール設定
4. Kubernetesクラスターを作成
5. VPCネットワーク内でIPアドレス範囲を割り当てる
6. プライベート接続の作成
7. CloudSQL MySQLインスタンス(プライベートIPアドレス割り当て)の作成
8. YAMLファイル書き換え
9. ConfigMapを作成
10. API(SpringBoot)イメージをビルド&プッシュ
11. kubectlでバックエンド用のリソースを適用
12. フロントエンド(Vue.js)イメージをビルド&プッシュ
13. kubectlでフロントエンド用のリソースを適用

1-7については、GCPの一般的なドキュメントを読めば書いてあることを実行していくだけなので詳細は省略します。

ポイントとしてはCloudSQL用のプライベートIPを取得するところだと思います。ローカルではコンテナをホストに公開して開発を行った方が良いですが、本番のGCP内部ではDBへのアクセスはプライベートIPを使って接続するのが一般的です。

また、今回GKE上に作ったAPIは外部公開されてしまう仕様となっていますが、内部APIとして公開するにはCloud IAP for GKEの有効化が必要になるようです。

8-13については、Kuberntes上でアプリ構築とそれに対するリソースを定義を行っています。プロジェクト構成はmanifestsフォルダに各リソースのYAMLファイルを作り、サービス単位(FrontendとBackend)でそれらをまとめたものをall-in-oneフォルダに格納しています。

バックエンドサービスが先に立ち上がってIPを確立してくれないと、フロントエンドのアクセスする対象のIPが見つからなくなるので、シェル内での実行順序は試行錯誤しました。

イメージビルドとGCRヘプッシュ

Dockerfileをビルドしてプッシュするには、GKEのチュートリアルだとgcloud builds submitコマンドを使って構築しているケースがよく見られましたが、今回はdockerコマンドを使ってます。理由はフロントエンドビルド時にAPIのURLを--build-argで渡したかったからです。gcloud builds submitでもDockerfileに変数を渡せるのであれば、ぜひ知りたいです...。

# echo "=> STEP10: Build and Push Container Image -Backend-"
docker build --tag gcr.io/${PROJECT_ID}/springboot-app-gke springboot-app-gke
docker push gcr.io/${PROJECT_ID}/springboot-app-gke

# echo "=> STEP12: Build and Push Container Image -Frontend-"
docker build --build-arg API_PATH=http://${API_SERVICE_IP}:${API_SERVICE_PORT} --tag gcr.io/${PROJECT_ID}/vuejs-app-gke vuejs-app-gke
docker push gcr.io/${PROJECT_ID}/vuejs-app-gke

ワークロードとサービスの定義

Podをデプロイするリソースをワークロードと呼んでいます。ReplicationControllerを使って、フロントエンドもバックエンドもそれぞれ3つのPodを立てるようにしています。

フロントエンドのワークロード定義
apiVersion: v1
kind: ReplicationController
metadata:
  name: frontend
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app:  easy-mvc
        tier: frontend
    spec:
      containers:
      - name: vue
        image: gcr.io/YOUR_PROJECT_ID/vuejs-app-gke
        resources:
          requests:
            cpu: 100m
            memory: 100Mi
        ports:
        - containerPort: 80

ワークロードの作成が終わったら、外部からの接続ができるようにサービスを定義します。spec.selector.appで先ほど建設したワークロードを指定してあげれば、あっという間に外部IPを取得してくれます。

フロントエンドのサービス定義
apiVersion: v1
kind: Service
metadata:
  name: frontend
  labels:
    app: easy-mvc
    tier: frontend
spec:
  type: LoadBalancer
  ports:
  - port: 80
  selector:
    app: easy-mvc
    tier: frontend

あとはkubectl apply -fを使えば、リソースが作成されます。GUIコンソール上でも作成できますが、やはり作成にはyamlファイルを自分で用意してコマンドを叩いた方が楽だと思います。GUIコンソールは、作成したリソースの確認に使うのが良いでしょう。

SecretとConfigMap

SecretオブジェクトConfigMapオブジェクトについては、ドキュメントでも説明されていますが、シンプルに以下のような棲み分けで考えました。

Secret ConfigMap
機密度が高いDB認証やSSL認証 Pod実行に必要な引数や変数

Secretリソースの定義には、パスワードをbase64でエンコードしてKey-Value形式で保存します。

apiVersion: v1
kind: Secret
metadata:
  name: easy-mvc-secret
type: Opaque
data:
  # base64で暗号化している
  mysql_username: c3ByaW5nYm9vdA==   # => springboot
  mysql_password: cEBzc3cwcmQ=       # => p@ssw0rd

SecretやConfigMapに保存した情報をワークロード定義ファイルから参照する場合は、secretKeyRefやconfigMapKeyRefなどのプロパティを指定してあげます。

バックエンドのワークロードから情報を参照する
apiVersion: v1
kind: ReplicationController
metadata:
  name: backend
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app:  easy-mvc
        tier: backend
    spec:
      containers:
      - name: springboot
        image: gcr.io/YOUR_PROJECT_ID/springboot-app-gke
        env:
        - name: CLOUDSQL_MYSQL_HOST
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: db.host
        - name: CLOUDSQL_MYSQL_USER
          valueFrom:
            secretKeyRef:
              name: easy-mvc-secret
              key: mysql_username
        ...

        [省略]

感想

GKEは開発するのにとにかく使いやすいです。Kubernetesの学習コストを高く感じる人は、GKE上で手を動かして理解していくことをオススメします。

個人的に作っていて悩まさせられたのは、環境変数の受け渡しと、ローカル/本番共通して使えるDockerfileの定義です。

環境変数の受け渡しの部分をシェルスクリプトやdocker-composeでの上書きや、ConfigMap/Secretリソースの作成で対応せねばならないのがなかなかに考えさせられます。ここら辺のコンテナ間接続(あるいはコンテナ-インスタンス間接続)がシンプルにできると嬉しいのになと思うことはあります。環境変数については、DIコンテナのようにシングルトンな設計が実現できてくれれば嬉しいのですが、自分が知らないだけでベストプラクティスがあるのでしょうか...?

ちなみに今回構築したPodがどんな感じで配置されていたのか絵にしてみたら、こんな感じでした。

アプリをデプロイするだけだと、GKEの圧倒的な抽象力に怠けてKubnernetesのオーケストレーション機能をあまり深く見れませんでした。アクセス負荷などの実験ができれば、Kubernetesが真価を発揮しそうなのでまた記事書けるように勉強します。