Webアプリをk8s上に構築してみた (Service, PersistentVolume, StatefulSet使用)

k8sの基礎を学ぶため、Webアプリの一般的な構成(Web, API, DB)をローカル環境(Mac)に構成してみました
[Learn Kubernetes Baics](https://kubernetes.io/docs/tutorials/kubernetes-basics/)などを一通りやった程度の知識レベル(Pod, Serviceなどの概念やkubectlの基本的なコマンドを知っている)から始めたので、全くの手探りでしたがなんとかできました・・！
似たようなことでつまずいている人の助けになればと思ってこの記事を書きました

# 行なったこと(まとめ)

```
* Web = Vue.js, TypeScriptで実装され、Nginxでホストされるアプリ
    * クライアントが直接アクセスする
    * いい感じにAPIサーバの場所を知っている (冗長化を考えるとIP指定とか嫌)
* API = Express.js, TypeScriptで実装されたNode.jsアプリ
    * CORSを考えるとWebと同じホストとしてクライアントからアクセスできるようにしたい
    * いい感じにDBの場所を知っている (同上)
* DB = Redis (Dockerhubの公式イメージを使用)
    * privateなアクセスだけを許可したい
    * 再起動後もデータは残っていて欲しい
* 全体
    * 異なるタイミングでデプロイしたい
```

上記のような環境を作りたいと思った、ので、次のように実現しました

* Webサーバ、APIサーバ、DBをそれぞれ異なるPodに構築したい
  * → Webサーバ、APIサーバをDeployment、DBをStatefulSetでそれぞれ構築
* クラスタの外から直接アクセスできるのはWebサーバだけにしたい
  * → WebサーバはLoadBalancer, APIサーバはNodePort, DBはClusterIPのServiceをそれぞれ設定
* クライアントからはWebサーバもAPIサーバも同じホストとしてアクセスできるようにしたい
  * → リバースプロキシ(Nginx)をWebサーバ内に構築し、リクエストのパスで同Pod内のhtmlかAPIサーバに振り分けるようにした
* データを永続化させたい
  * → LocalなPersistentVolumeをDBにマウントした

# 行ったこと(詳細)

依存の順番を考えてDB→API→Webから説明をしていますが、必要に応じてお好きな順番で読んでください
上記まとめではイメージしやすいように使用言語などについて書きましたが、アプリの実装方法などに関してはこの記事では扱っていません

## 動作確認環境 (ローカル)
* macOS Mojave: v10.14.2
* Docker Desktop Community: v2.0.5.0
* Kubernetes: v1.14.3

## DB (Redis)

APIサーバからアクセスでき、かつ永続化するRedisのPodを1つ作ります
今回は[Local Volume](https://kubernetes.io/blog/2019/04/04/kubernetes-1.14-local-persistent-volumes-ga/)という手段で、クラスタ内のNode上のパスをRedisのコンテナにマウントすることで永続化を目指します

* [StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/)を使ってPodにRedisを構築します
    * StatefuleSetは基本的にはDeploymentと同じなのですが、名前の通りステートフルなアプリケーションに向いているという点で異なります。ボリュームを付与したり、Podのネットワーク上の名前を一意に保ったりしてくれます
    * ボリュームを付与するために、DeploymentではなくStatefulSetを選択しました
* [PersistentVolume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)とPersistentVolumeClaimで、必要なボリュームをマウントすることでデータを永続化します
    * PersistentVolumeはクラスタ上に配置されたボリューム
    * PersistentVolumeClaimは必要なボリュームの要件
    * どんなボリュームが欲しいかをPersistentVolumeClaimとして記述すると、その内容に合うPersistentVolumeがリソースに割り当てられるという関係性です
* PersistentVolumeでは、Node内のパスをマウントするように設定します
    * Node内のパスをマウントする機能をLocal Volumeと呼びます (以下参照)
        * [Volumes#local](https://kubernetes.io/docs/concepts/storage/volumes/#local)
        * [Kubernetes: Local Volumeの検証](https://qiita.com/ysakashita/items/67a452e76260b1211920)
        * [hostPathとlocalのPersistentVolumeの違い](https://qiita.com/sotoiwa/items/09d2f43a35025e7be782)
* 同じクラスタ内のAPIサーバからアクセスできるように、ClusterIPのServiceを作成します
   

### StatefulSet

* Podの詳細
    * Redisの公式イメージから、6379番ポートを開けているコンテナを作成します
    * Podには`app: redis`というラベルをつけます
        * Service(後述)との紐づけをするときに、Podを特定するために使います
    * `volumeMounts`ではマウント先としてコンテナ上のパスを指定します
* ボリューム要件の詳細(PersistentVolumeClaim)
    * `volumeClaimTemplates`でどんなPersistentVolumeが欲しいかを記述します
    * `accessModes`に`ReadWriteOnce`を指定したボリュームは単体のNodeからの読み書きが可能になります
    * `storageClassName`でStorageClass(後述)の名前を指定します
* その他
    * `serviceName`には紐付けるService(後述)の名前を設定します


```yaml:reids-sts.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-sts
  labels:
    app: redis
spec:
  serviceName: "redis"
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:5.0.5-alpine
        ports:
          - containerPort: 6379
        volumeMounts:
        - name: redis-volume
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: redis-volume
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 1Gi
      storageClassName: local-storage
```

### PersistentVolume

Local Volumeを実現するには以下のような設定が必要です

* `local.path`にマウントしたいNode上のパスを指定します
* `accessModes`に`ReadWriteOnce`を指定します
* `persistentVolumeReclaimPolicy`に`Retain`を設定します
    * PersistentVolumeClaimが削除されたときにボリュームを残す(=手動で削除するようにする)設定となります
* `nodeAffinity`で使用するNodeを指定します
    * 以下は`docker-desktop`という名前のNodeだけを使用するように限定しています
* `storageClassName`でStorageClass(後述)の名前を指定します

```yaml:redis-pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: redis-local-pv
spec:
  capacity:
    storage: 1Gi
  volumeMode: Filesystem
  accessModes: [ "ReadWriteOnce" ]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /XXX/YYY/data
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - docker-desktop
```

ちなみに、Docker Desktop(Mac)ではPreferenceのFile SharingでMac-Node間のボリュームマウントの設定ができます。つまり、Mac-コンテナ間でボリュームマウントをできるので、File Sharingに設定されていたMac上のパスを設定しました

### StorageClass

こちらもLocal Volume用の設定です
ボリュームの割り当てを遅延させることが推奨されているため、StorageClassで実現します
[StorageClass](https://kubernetes.io/docs/concepts/storage/storage-classes/)を使うと、PersistentVolumeClaimの内容からPersistentVolumeを配置や割り当てる際のオプションを指定できます

* `volumeBindingMode: WaitForFirstConsumer`を設定すると、そのVolumeに紐づけられるPodが作成されたときにはじめて割り当てが行われるようになります
* 上記StatefulSet, PersistentVolumeで設定したものと同じ名前をつけます

```yaml:local-storage-class.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
```


### Service

* Serviceには4つのtype([ServiceTypes](https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types))がありますが、この記事で扱うのはそのうちの3つで、大雑把に以下のように説明できます
    * ClusterIP: クラスタ外からアクセスできない。指定したポートでクラスタ内からアクセスできる
    * NodePort: NodeのIPとポートを指定すればクラスタ外からもアクセスできる
    * LoadBalancer: Serviceにクラスタ外からもアクセス可能なIPが付与される
* このDBは同じクラスタ内のAPIサーバからだけアクセスできれば良いので、ClusterIPを使用します
    * 同じクラスタ内からは`name`で指定した`redis`という名前でドメイン解決することができるようになります
    * よって、APIサーバからはホスト名=`redis`、ポート番号=`6379`でRedisにアクセスできるようになります
* `selector`で`app: redis`のラベルがついたPod(上記StatefulSetで構築したもの)を指定しています


```yaml:redis-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: redis
  labels:
    app: redis
spec:
  type: ClusterIP
  ports:
  - port: 6379
  selector:
    app: redis

```
## APIサーバ

### Dockerイメージ
* 特別な設定はありませんが、下記のファイルで`sample/sample-api`というイメージをビルドします
* このアプリ内ではホスト名=`redis`、ポート番号=`6379`でRedisにアクセスするように設定しています

```Dockerfile:Dockerfile
FROM node:lts-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "serve"]
```
### Deployment

* `sample/sample-api`というイメージから3000番ポートが開いたコンテナをPod内に作成します
* `replicas`を2に設定することで、同じ内容のPodが2つ作成されるようになります
* イメージはDockerhubに存在しないものを使用しているので、`imagePullPolicy: Never`を設定し、必ずローカルのイメージを使用させるようにしています

```yaml:sample-api-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-api-deployment
  labels:
    app: sample-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: sample-api
  template:
    metadata:
      labels:
        app: sample-api
    spec:
      containers:
      - name: sample-api
        image: sample/sample-api
        imagePullPolicy: Never
        ports:
          - containerPort: 3000
```

### Service
* このAPIサーバは、クライアント(=クラスタ外)からアクセスする必要があるが、IPアドレスは公開する必要がないのでNodePortタイプのServiceを作成します
    * 同じクラスタ内からは`sample-api`という名前でドメイン解決することができるようになります
    * Webサーバのリバースプロキシ(後述あり)は、APIサーバへのリクエストのみをホスト名=`sample-api`、ポート番号=`3000`に転送します
    * よって、クライアントはAPIサーバのIPアドレスなどを知らずとも、Webサーバ経由でAPIサーバにアクセスできるようになります
* `selector`で`app: sample-api`のラベルがついたPodを指定しています
* Serviceは`port`へのアクセスをPodの`containerPort`へ転送します

```yaml:sample-api-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: sample-api
  labels:
    app: sample-api
spec:
  type: NodePort
  ports:
  - port: 3000
  selector:
    app: sample-api
```


## Webサーバ

### Dockerイメージ
* 下記のファイルで`sample/sample-web`というイメージをビルドします
* nginxのデフォルトの設定ファイルをカスタムの設定ファイルで上書くようにしています

```Dockerfile:Dockerfile
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY ./nginx-proxy.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```

次はnginxの設定です。

* 80番ポートに/api/で始まるパスでリクエストがくればAPIサーバへ転送します。
    * proxy_passに設定されているのは`http://[APIサーバのService名]:[APIサーバのServiceのポート]/`です
    * よって、Webアプリ内でAPIのリクエスト先を`http://localhost/api/XXX`と設定すれば、リクエストは正しくNginx経由でAPIサーバに転送されます
* 80番ポートに/で始まるパスでリクエストがくれば同コンテナ内にあるHTMLファイルを返します

```conf:nginx-proxy.conf
server {
    listen 80 default_server;

    location /api/ {
        proxy_pass http://sample-api:3000/;
    }

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}
```

### Deployment

* `sample/sample-web`というイメージから80番ポートが開いたコンテナをPod内に作成します
* `replicas`を2に設定することで、同じ内容のPodが2つ作成されるようになります
* イメージはDockerhubに存在しないものを使用しているので、`imagePullPolicy: Never`を設定し、必ずローカルのイメージを使用させるようにしています

```yaml:sample-web-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-web-deployment
  labels:
    app: sample-web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: sample-web
  template:
    metadata:
      labels:
        app: sample-web
    spec:
      containers:
      - name: sample-web
        image: sample/sample-web
        imagePullPolicy: Never
        ports:
          - containerPort: 80
```

### Service
* クラスタ外からアクセスしたいので、LoadBalancerタイプのServiceを作成します
* LoadBalancerタイプのServiceはクラスタ外からアクセスできるIPアドレスを持ちます。また、Serviceは`port`へのアクセスを`targetPort`へ(そしてPodの`containerPort`へ)とマッピングします。
* `selector`で`app: sample-web`のラベルがついたPodを指定しています

```sample-web-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: sample-web
  labels:
    app: sample-web
spec:
  type: LoadBalancer
  ports:
  - name: sample-web
    protocol: TCP
    port: 80
    targetPort: 80
  selector:
    app: sample-web

```
## 動かしてみる
* Dockerイメージ(Web, API)をビルドします
* 上記のyamlファイル全てに対して `kubectl apply -f [yamlファイル]`を実行します
    * 実行しやすいようにyamlファイルは全て一つのファイルにまとめても良いです
* `kubectl get service` を実行して`sample-web`のEXTERNAL-IPとPORTを確認します
* ブラウザで `http://[EXTERNAL-IP]:[PORT]`を開いてみる
    * アプリが動いていることが確認できます
* `kubectl delete -f [yamlファイル]`するとそのファイルに関連するリソースを全て消すことができます
    * 今回の設定ではPersistentVolumeClaimは手動で消す必要があります

データの書き込みがあった上で、再び`kubectl apply`すると、Redisのデータが永続化できていることが確認できます