KubernetesでWebアプリケーションを構築（サンプル編：SpringBoot）

[前回](https://qiita.com/items/e67f97fa7a5e1e81e608)は、Kubernetesの概要と背景にある考え方について、説明しました。

今回では、実際に簡単なWebアプリケーションをKubernetes上に構築するサンプルをご紹介します。

このサンプルアプリケーションは、SpringBootを使用したWebアプリケーションで、Kubernetesの構成を学ぶ上で、極力シンプルにしたものです。

# アプリケーションの構成

前回の最後にアプリケーションの構成パターンとして、以下の３つを挙げましたが、いずれの場合も設定をどのように行うかは悩みどころです。

1. ミドルウエア（＋設定）のみ
2. ミドルウエア（＋設定）+データのみ
3. ミドルウエア＋アプリケーション＋設定

基本的に、アプリケーションの設定の大部分はdockerイメージにまとめられています。しかし、すべての環境設定がDockerイメージに含まれている場合、特にテスト、ステージング、実稼働環境、または特定の目的のために異なる設定を行いたい場合は、それほど効率的ではありません。

その場合、イメージは共通またはデフォルト設定を持つ必要があり、特定の設定は環境変数またはKubernetesのConfigmapまたはSecretリソースを介して渡されるべきです。環境変数を使用する場合は、アプリケーション側が環境変数を読み取るような作りにする必要があります。configmapは単純な変数だけでなくnginx.confのような設定ファイルも扱うことができます。

データベースに関しては、データが更新されるため、コンテナ運用の原則である「Immutable」の対象にはなりませんし、またデータを永続的に保存する必要があることからPodで構築するのには向いていません。しかし、テスト環境であれば、KubernetesのPodでDBを構築するのもいいです。ここでは、サンプルのためPod上にDBを構築します。通常、DBにはテーブルや初期のマスタデータを登録する必要があります。ここでは、コンテナが作成されたときに初期化スクリプトを指定することによって実現しています。Dockerfileには通常使用されるENTRYPOINTとCMDがありますが、コンテナ起動時に多くのことをしたい場合は、イメージに依存するinitスクリプトを指定できます。このサンプルでは、​​postgresイメージを使い、シェルスクリプトを/docker-entrypoint-initdb.d/ディレクトリに置いてデータベースとテーブルを作成し、マスターデータを登録しています。

# Kubernetesを使ったWebアプリケーションの例
それでは、Kubernetes上で動作するWebアプリケーションを作成します。

始める前に、Kubernetes環境を作成する必要があります。おすすめは以下のとおりです。

- [GKE](https://kubernetes.io/docs/setup/turnkey/gce/)
- [Minikube](https://kubernetes.io/docs/setup/minikube/)
- [Docker Desktop](https://www.docker.com/products/docker-desktop)（これにはKubernetesも含まれます）

この記事はkubernetes自体をインストールする方法を説明しません。公式ページ（上記のリンク）で、十分に理解できると思います。
GKEは12ヶ月間300ドルの無料枠があり、これで勉強やテストに十分です。使用しない場合は、kubernetes環境をシャットダウンして削除するようにしてください。でないと、課金が続き、あっという間に無料枠がなくなります。

以下は、GKEの例で、gcloudコマンドをインストールし、GCPに接続できている状態のあとで、実行するもので、作成と削除は頻繁に使います（サンプルで課金が続かないようにするのと、Immutableアーキテクチャなので使い終わったら削除しましょう）。

```
gcloud init
gcloud container get-server-config --zone asia-northeast1-a

# 作成
gcloud container clusters create samplewebapp --cluster-version 1.12.7-gke.10 --zone asia-northeast1-a --num-nodes 3
gcloud container clusters get-credentials samplewebapp --zone asia-northeast1-a

#削除
gcloud container clusters delete samplewebapp --zone asia-northeast1-a
```

### stern
ログを表示するのに便利な[stern](https://github.com/wercker/stern)も必ずインストールしてください。

### 構造
サンプルアプリケーションは、Kubernetes環境で動作することを目的としたWebアプリケーションの基本設定をテストするためのもので、以下のような構造となっています。

![K8s](https://cdn-images-1.medium.com/max/1600/1*XlZfIMEdWi9cDl9vMWQlbg.jpeg)

アプリケーションは次のサーバで構成されています。

- Webサーバ（nginxを使用）
- アプリケーションサーバ（tomcatを使用[スプリングブートに埋め込まれています]）
- DBサーバ（postgresを使用）
- セッションサーバ（アプリケーションサーバのセッションを維持するためにredisを使用）
- NAS（PVCダイナミックプロビジョニングまたはnfsを使用）

Webサーバとアプリケーションサーバはステートレスなので、複数のPodに拡張できます。

DBとセッションサーバはステートフル（状態を保つ）です。特にDBサーバ上のデータは恒久的に保存する必要があります。セッションサーバは永続的である必要はありませんが、データが失われるとユーザーエクスペリエンスが悪くなり、ユーザーは再度ログインする必要があり、またユーザーが入力した情報が失われる可能性があります。しかしDBと比較すると、それほど重要ではありません。

セッション維持のために、前段のWebサーバにsticky sessionを設定して、セッションIDによって振り分け先のAPサーバを変える方法もありますが（この場合StatefulSetを使う）、Kubernetesでは、APサーバはいつ終了し再起動されるかわからないため、セッション情報はRedisなど外部のサーバに持たせるのがいいでしょう。Podは割当メモリを超過した場合、容易に削除・再作成されます。この点を考慮に入れておく必要があります。

このサンプルでは、DBサーバとセッションサーバの両方に単一のPodを使用します。実稼働環境で使用したい場合は、両方とも、少なくともDBサーバについてはPaaSを使用することを強くお勧めします。

アプリケーションサンプルはToDoを管理するためのもので、わずか3ページで構成されています。認証の機能とテーブルのCRUD機能を提供するだけです。

- ログインページ（ID / PW = admin / 111111）
- ToDoリストページ
- Todo編集ページ

![login](https://cdn-images-1.medium.com/max/1200/1*N4QOhjtwJOdKuxOFjTj2Lg.png)
![apppage](https://cdn-images-1.medium.com/max/1600/1*317zxv_d2-ouYRBYW0eweA.png)

Todoは、ストレージにファイルを保存するテストの目的で、画像のアップロードを含みます。

ここでの目的はKubernetesの設定を知ることです、テストとして、最小限の機能をカバーするのにちょうど十分な、できるだけ簡潔なソースコードを作りました。これをプロダクトに適用する場合は、ヴァリデーション、セキュリティ、保守性、フレームワークの使用などを検討する必要があります。

サンプルコード全体と展開のマニフェストは、次のサイトにあります。ここでソースコードや設定と説明を入手できます。詳細な手順は、こちらのReadme.mdの方に書いています。

[https://github.com/dayan888/springdemo_k8s](https://github.com/dayan888/springdemo_k8s)

上記のソースコードは2つで構成されています。

- アプリケーションのソースコード（Spring Boot）
- デプロイ関連ファイル（Dockerfile、kubernetesマニフェスト、confファイルなど）

基本的には、標準のSpringBootのプロジェクト構造に、Dockerとkubernetesのマニフェストを追加したものです。

# ローカルマシン上に開発環境構築
アプリケーションを改修するためには、開発用にローカルでJavaアプリケーション開発環境、docker image構築環境が必要になり、以下がインストールされている必要があります。

- JDK 8以上
- Docker
- Postgres 9以上
- Redis
- IntelliJ（または他のIDE）
- Docker Hub（アカウントを登録が必要）

dockerをインストールすれば、docker pullで、postgresとredisをローカル環境に簡単にインストールできます。

# Kubernetes環境にデプロイする
早くセットアップしたい場合を考慮し、Kubernetesにマニフェストを適用するためのシェルスクリプトを用意しています。GKEを使用している場合は、deploy/sh/gke.shを実行してください。MinikubeまたはDocker Desktopを使用している場合は、deploy/sh/local.shを実行してください。

このシナリオでは、私個人のdocker hubがイメージリポジトリとして使われています。Dockerfileでイメージを作成することから始めて全プロセスを試す場合は、docker hubのようなレジストリを各自作成し、それに応じてマニフェストを修正する必要があります。

# マニフェストの説明

以下はシステムを構築するための正確な指示ではなく、要点の説明になります。詳細はReadme.mdを参照してください。
実行する際は、適用順序は重要です。従属する変数（特にServiceの名前）が事前に定義されていない場合、Podの作成は失敗します。

デプロイは次の順序で行う必要があります。

- Redis（アプリケーションサーバからアクセス）
- DB（アプリケーションサーバからアクセス）
- アプリケーションサーバ（Webサーバからアクセス）
- Webサーバ（LB / Ingressからアクセス）
- ロードバランサまたは入力

もしくは、配置を作成する前に、まずすべてのServiceを作成します。Serviceは、selectorとして指定した振り分け先のPodが存在しない場合でも作成可能です。
Redisのコンテナはそのまま公式イメージで作成されますが、マニフェストを適用してDeploymentとServiceを作成する必要があります。

# 1.アプリケーションサーバ

#### Dockerfile
コンテナに何を入れるべきかを理解するためには、イメージを作成する方法を理解することが重要です。

```
FROM openjdk:8-jdk-alpine
ARG buildver=0.5.0
#RUN ./gradlew build -x test
COPY build/libs/springdemo-${buildver}.jar /app/springdemo.jar
ENTRYPOINT ["java", "-jar", "/app/springdemo.jar"]
```

まず適切なベースイメージを選択することが重要です。ゼロから作成することもできますが、DockerHubには既に作成済みのイメージがあります。イメージサイズを小さくしたい場合は、alpineとbusyboxが候補となります。しかし、これらは最小限のコマンドしか持っていないので、コンテナを調べてネットワークコマンドを実行したい場合は、適切ではありません。

Javaで実行されるアプリケーションサーバの場合、私はalpineベースのopenjdkイメージを選びました。次に、ビルドされたjarファイルをコピーして、コンテナの起動時に実行されるENTRYPOINTを設定します。このイメージ作成の際、gradleのビルドなどを一緒に行うことができます。このビルドおよびイメージ作成プロセスは、jenkinsなどのCI/CDサーバを使用した場合、若干異なります。

このサンプルでは、サーバ側のJavaのフレームワークとしてSpring Bootを使用しています。これはTomcatは埋め込まれているので、Tomcatをセットアップして設定する必要はなく、可搬性に優れているためコンテナと相性はいいです。ただしSpringのようなJavaのフレームワーク、DIコンテナは起動に時間がかかるため、その点コンテナ時代には向いていないと思います。最終的にネイティブにコンパイルするような仕組みが必要になってくるでしょう。

Spring Bootの設定に関しては、application.ymlを使用し、環境変数とデフォルト変数を以下のように設定します。

```
datasource:
 url: ${DB_URL:localhost}
 driverClassName: org.postgresql.Driver
```
環境変数は、kubernetesマニフェストまたはconfigmapによって設定されます。

#### Deploymentマニフェスト

```
apiVersion: apps/v1
kind: Deployment
metadata:
  name: 
    sbdemo-apserver
spec:
  replicas: 3
  selector:
    matchLabels:
      app: sbdemo-apserver
  template:
    metadata:
      labels:
        app: sbdemo-apserver
    spec:
      containers:
      - name: apserver
        image: dayan888/springdemo:apserver
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
        env:
          - name: SPRING_PROFILES_ACTIVE
            value: "prd"
          - name: DB_URL
            value: "jdbc:postgresql://sbdemo-postgres-service:5432/demodb?user=postgres&password=postgres"
          - name: PIC_DIR
            value: "/opt/picDir"
          - name: REDIS_HOST
            value: "sbdemo-redis-service"
          - name: REDIS_PORT
            value: "6379"     
        volumeMounts:
          - mountPath: "/opt/picDir"
            name: apserver-pvc
      volumes:
      - name: apserver-pvc
        persistentVolumeClaim:
          claimName: sbdemo-apserver-pvc
```

もしこれを初めて見ると、少し複雑に思えるでしょう。特にマニフェストの言葉や構造はわかりにくいです。しかし、実際には何も特別なことはありません。

このマニフェストの主なポイントは次のとおりです。

- 前工程で作成したイメージを使用する
- 冗長性のために3つのPodの複製を作成する
- application.ymlによって参照される環境変数が設定

そして、3つの冗長化されたPodでアップロードされた画像を共有するためのPersistent Volumeをマウントします。画像アップロード機能を加えたのはこのPVを使うためです。
各アプリケーションサーバはアップロードされたイメージを読み書きする必要があるため、永続的なボリュームはReadWriteManyである必要があります。ローカルのkubernetesを使用している場合は、ボリュームにアクセスするノードが1つしかないため、大きな問題にはなりません。しかし、GCEディスクでReadWriteManyをサポートしていないためにGKEを実行する場合は、別の方法を検討する必要があります。

ここでは、k8s.gcr.io/volume-nfsイメージを使用してNFSサーバを作成します。NFSの内部では、Persistent Volume Claimが使用されます。nfsサーバ自体はアプリケーションサーバからマウントされるPersistent Volumeになります。ここは少しややこしいです。

ローカル用とGKE用に別のマニフェストを用意したので、サンプルコードをapplyするときは注意してください。

#### Serviceマニフェスト
これらのコンテナにアクセスするための「Service」を作成する必要があります。Kubernetesでは、サーバ（Pod）とそこへのアクセスを分けており、バッチ処理でない限り、通常セットで作成する必要があります。

```
apiVersion: v1
kind: Service
metadata:
  name: sbdemo-apserver-service
spec:
  type: ClusterIP
  ports:
   - name: "http-port"
     protocol: "TCP"
     port: 8080
     targetPort: 8080
  selector:
    app: sbdemo-apserver
```

主なポイントは以下のとおりです。

- Serviceにアクセスするためのポートとアプリケーションサーバのポート(targetPort)を定義します。
- どのPodへ転送されるべきかに使用されるSelectorを定義します
- 他のPodからのアクセスに使用されるServiceの名前

その後、kubectlを実行してマニフェストを適用します。

```
kubectl apply -f deploy/app/pvc.yaml
kubectl apply -f deploy/app/deployment.yaml
kubectl apply -f deploy/app/service.yaml
```

# 2. Webサーバ

#### Dockerfile

```
FROM nginx:latest
COPY src/main/resources/static/css /usr/share/nginx/html/css
COPY src/main/resources/static/js /usr/share/nginx/html/js
COPY src/main/resources/static/img /usr/share/nginx/html/img
```

nginx画像を使用して、css、js、image、htmlファイルなどの静的コンテンツをコピーします。

#### 構成マップ

nginx.confなどの設定ファイルはイメージに含めて、設定ファイルをソースコードで管理することができます。しかし、環境設定をソースコードから分離して再利用性を高めたい場合は、nginx.confのconfigmapを作成し、それをkubernetesマニフェストを介してコンテナに渡すようにします。

nginx.confでは、必ず出力ログを標準出力に設定してください。Kubernetesのプラクティスでは、ログは細分化せず、すべて標準出力に出し、分類はその後で行います。

```
access_log /dev/stdout;
error_log  /dev/stderr debug;
```

そしてserver.confを使用してバックエンドのアプリケーションサーバを次のようなService名で設定します。

```
location / {
  ...
  proxy_pass http://sbdemo-apserver-service:8080;
  proxy_cookie_path / /;
}
```

それから、kubectlを実行してconfigmapを作成します。

```
kubectl create configmap nginx-conf --from-file=deploy/web/nginx.conf
kubectl create configmap server-conf --from-file=deploy/web/server.conf
```

ここで、configmapのためにマニフェストを使うこともできます、しかし、その内容がファイルであるならば、コマンドだけを使う方がより簡単であると思います。

#### Deploymentマニフェスト

```
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sbdemo-nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: sbdemo-nginx
  template:
    metadata:
      labels:
        app: sbdemo-nginx
    spec:
      containers:
      - name: nginx
        image: dayan888/springdemo:nginx
        ports:
        - containerPort: 80
        readinessProbe:
          httpGet:
            path: /css/style.css
            port: 80
        volumeMounts:
        - name: nginx-conf
          mountPath: /etc/nginx/nginx.conf
          subPath: nginx.conf
        - name: server-conf
          mountPath: /etc/nginx/conf.d/server.conf
          subPath: server.conf
      volumes:
      - name: nginx-conf
        configMap: 
          name: nginx-conf
          items:
            - key: nginx.conf
              path: nginx.conf
      - name: server-conf
        configMap: 
          name: server-conf
          items:
            - key: server.conf
              path: server.conf
```

主な点は次のとおりです。

- 前工程で作成したイメージを使用する
- ConfigMapからconfigファイルを直接ファイルとしてマウントする
- 冗長性を定義する（3つのReplica）
- Readiness Probeを定義します（GKEで実行すると、/でアクセスされたとき200を戻す必要があるため、この設定は重要です）。

次に、このDeployment用のServiceを作成します。

```
apiVersion: v1
kind: Service
metadata:
  name: sbdemo-nginx-service
spec:
  type: ClusterIP
  ports:
   - name: "http-port"
     protocol: "TCP"
     port: 80
     targetPort: 80
  selector:
    app: sbdemo-nginx
```

ここでは特別なことはありません。Service名は後でIngressのマニフェストで使用されます。

# 3.データベース

#### Dockerfile

```
FROM postgres:9.6
COPY deploy/db/init_ddl.sh /docker-entrypoint-initdb.d/
RUN chmod +x /docker-entrypoint-initdb.d/init_ddl.sh
```

データベースは既製のDockerイメージがあります。モジュールを構築したりコピーしたりする必要はありません。postgresql.confで定義されているデフォルトの振る舞いを変更したいかもしれません。その場合はconfigmapが便利です。

今回はデフォルト設定を使用しますが、データベース、テーブル、マスターデータなどを作成する必要があります。そのため、docker-entrypoint-initdb.dを使用して、シェルスクリプトをコピーします。

### StatefulSetマニフェスト
今回は単一のdbサーバ構成のみが使用されますが、冗長性を設定する場合は、マスタとスレーブのdbを区別することが重要です。マスタの再起動時に、同じボリュームをマウントする必要があります。したがって、このサンプルではこれを確実にするためにStatefulSetを使用しています。StatefulSetはDeploymentとほぼ同じですが、違いは再起動時に同じPVC（永続ボリューム）がマウントされることです。

```
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: 
    sbdemo-postgres-sfs
spec:
  serviceName: sbdemo-postgres-service
  replicas: 1
  selector:
    matchLabels:
      app: sbdemo-postgres-sfs
  template:
    metadata:
      labels:
        app: sbdemo-postgres-sfs
    spec:
      containers:
       - name: postgres
         image: dayan888/springdemo:postgres9.6
         ports:
          - containerPort: 5432
         volumeMounts:
         - name: pvc-db-volume
           mountPath: /var/lib/postgresql
  volumeClaimTemplates:
  - metadata:
      name: pvc-db-volume
    spec:
      accessModes:
      - ReadWriteOnce
      resources:
        requests:
          storage: 1G
```

主なポイントは以下のとおりです。

- StatefulSetを使用する
- 前工程で作成したDockerイメージを使用する
- このイメージのpostgresプロセスが参照する/var/lib/postgresqlにPVCがマウントされます。

#### Serviceマニフェスト

```
apiVersion: v1
kind: Service
metadata:
  name: sbdemo-postgres-service
spec:
  type: ClusterIP
  ports:
   - name: "db-port"
     protocol: "TCP"
     port: 5432
     targetPort: 5432
  selector:
    app: sbdemo-postgres-sfs
```
Service名はアプリケーションサーバのapplication.ymlに設定されています。このシナリオでは、Service名は事前に定義されている必要があります。何らかの命名規則が必要となるでしょう。


# 4.Ingress

IngressはHTTPプロトコルのようなL7で動作するロードバランサであり、ホスト名またはURLパスによってトラフィックを振り分けることができます。

ドメイン名ごとに（一意のグローバルIPアドレスを持つ）サービスを1つだけ運用する場合は、LoadBalancerだけで十分です（こちらはL4で動作するロードバランサ）。WebサーバのServiceのTypeを「LoadBalancer」に設定してから、グローバルIPアドレスをDNSサーバ上のドメインに設定します。

しかし、これは柔軟性と拡張性に欠けるので、ingressを使用した方がいいでしょう。

まず、Ingressから転送される各WebサーバのServiceを作成する必要があります。

#### Ingressマニフェスト
```
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: sbdemo-ingress
spec:
  rules:
  - host: sbdemo.example.com
    http:
      paths:
      - path: /*
        backend:
          serviceName: sbdemo-nginx-np
          servicePort: 80
      - path: /v2/*
        backend:
          serviceName: web2
          servicePort: 8080
  backend:
    serviceName: sbdemo-nginx-np
    servicePort: 80
  tls:
  - hosts:
    - sbdemo.example.com
    secretName: tls-sbdemo
```

このサンプルは1つのWebシステムでしかないため、イングレスの機能をテストするために、簡単なWebサーバ（"Hello、xxx"を返すだけ）を追加してみます。

主なポイントは以下のとおりです。

- ホストとパスでルールを定義し、バックエンドのService名とポートにバインドします。
- ホストのSSL証明書を定義します

httpsは現在「必須」であるため、IngressはSSL証明書を管理するのに非常に役立ちます。具体的な手順はサンプルサイトのREADME.mdに書かれています。サンプルでは自己署名証明書を使用しているため、実稼働環境に適用する場合は正しく署名する必要があります。

重要：このサンプルをローカルのkubernetes環境で実行すると、ロードバランサーのみが適用されます。Ingressを有効にするために、IngressをサポートしているKubernetes環境を選ぶか、もしくは多少複雑ですが自身でnginxで構成されたIngress Podを作成する必要があります。

# 結論

このサンプルアプリケーションを作成することで、私自身たくさんのことを学びました。特にPersistent Volumeに苦労しました。驚いたことに、多くのマネージドサービスはReadWriteManyモードを提供していません。そのため、レガシーなNFSサーバを作成しなければなりませんでした。ストレージはまだもっと考慮する余地があります。

Kubernetesマニフェストは扱いが簡単ですが、マニフェストの数が増えたときは、「helm」を適用することを検討するのがいいでしょう。私の仕事場ではAKSとhelmを使っていますが、今回は作る時間がないので、シェルスクリプトを作成しました。

これは、あくまでもサンプルなので多くのことを省いています。実稼働環境を適用するために考慮すべきことがまだたくさんあります。

何か質問・フィードバックがあると嬉しいです。