【20時間学習】Kubernetesお試し編

Josh Kaufman氏によると、正しく学習を行えば20時間で新たなスキルを身につけることができるそうです。
今回は氏の学習方法を参考に、「Kubernetes・Docker」について20時間学習を行ってみたので、その結果をまとめたいと思います。なおこの記事は学習ノートとしての側面の方が強いので注意してください。

# はじめに

## 学習前の知識や技術

Dockerfileはほとんど書いたことがない。どのタイミングで利用するべきなのか理解していない。可能な限りdocker-composeのymlファイルだけで良いのでは？と考えている。

docker-compose.ymlには共通した内容を書いて、`-f`オプションで環境ごとにymlファイルを使い分けると良いらしい。

dockerignoreファイルには何を指定するのが良いのかを理解していない。

Kubernetesはコンテナ運用をサポートしてくれるため、大規模な環境では使うことが多くなってきているらしい。開発環境でも利用することで、本番環境との差異を減らすことができる。
クラスターやPodという用語の存在は知っているが詳細な意味までは分からない。
オーバースペックすぎて大半のケースでは必要なさそうなイメージがある。

kindで環境のみ構築したが、実際に動かしたことは一度もない。

## 学習目標

Dockerに関してはDockerfileとdocker-composeファイルに留めて、Kubernetesに集中しました。
とはいえKubernetesはかなり学習コストが高そうに見えたため目標は控えめです。

- Dockerfileの適切な利用法を理解する
- docker-composeファイルの適切な利用法を理解する
- Kubernetesの簡単な概要を理解する
- DockerとKubernetesの比較ができるようにする
- Kubernetesで環境を構築し動作させてみる
- [Developer Roadmap](https://roadmap.sh/kubernetes)を参考に知識を深めていく

# Dockerfileについて

DockerfileはDockerイメージを作成する手順が記述されたテキストファイルです。
`docker build`コマンドを実行するとファイルやディレクトリのコピーや依存関係の追加など記述されている命令を実行し、Dockerイメージをビルドできます。

Docker Composeを利用している場合、公式イメージで十分の場合はわざわざDockerfileを用意する必要はないのではと考えていたため、逆にどのような場面で用意するべきなのかを調べてみました。

結果として、公式イメージでは不十分、例えばwgetやcurl, vimなど追加コマンドをインストールしたい場合や、Node.jsで`npm install`コマンドを実行してアプリケーションの依存関係をインストールしたい場合に用意する。という結論に至りました。

実際に、依存関係のインストールとビルドイメージサイズの削減を目的としたDockerfileを作成してみました。
なおDockerfileの内容は[Next.jsのGithubリポジトリ](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile)を参考にしました。

```Dockerfile
FROM node:current-alpine AS base
WORKDIR /app

FROM base AS dependencies
COPY package*.json ./
RUN npm install --omit=dev

FROM base AS builder
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS production
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER node
```

`create-next-app`で環境構築したアプリケーションを例にマルチステージビルドを行っています。
このDockerfileでビルドしたイメージは後々Docker Composeで利用しています。

dependenciesステージでは依存関係のインストールを行い、builderステージではビルドプロセスを実行しています。そしてproductionステージではアプリケーションに必要なコードだけをコピーしています。
`docker images`コマンドでDockerイメージのサイズを確認してみると、`node_modules`をコピーしていないproductionステージのイメージサイズがかなり小さいことがわかります。

```
$ docker images next*
REPOSITORY          TAG       IMAGE ID       CREATED          SIZE
next-production     latest    3469bb3b0015   12 seconds ago   190MB
next-builder        latest    acfe359c32a5   13 seconds ago   573MB
next-dependencies   latest    662eb4a07791   35 seconds ago   648MB
```

# docker-composeファイルについて

docker-composeファイルは複数のコンテナを定義し、それらを連携させてアプリケーションを実行するためのYAML形式の設定ファイルです。docker-composeファイルでは、コンテナ間のネットワーク設定やマウント設定などを定義することができます。

docker-composeファイルは環境ごとに分けるという考え方のほか、override.ymlだけ作成して.gitignoreに追加するという考え方もあるようです。
今回は分けたい環境の数が少ない（ない）ため、overrideする方法を試してみました。

```yaml:docker-compose.yml
version: '3.4'

services:
  web:
    image: next-production:latest
    ports:
      - 3000:3000
    command: [sh, -c, node server.js]
```

上記は先の章でビルドしたイメージを利用しているdocker-composeファイルです。
そして開発環境用に以下のような`docker-compose.override.yml`ファイルを作成してみました。

```yaml:docker-compose.override.yml
version: '3.4'

services:
  web:
    image: node:current-alpine
    working_dir: /usr/src/app
    volumes:
      - ./services/web:/usr/src/app
    ports:
      - 3000:3000
    command: [sh, -c, npm run dev]
    tty: true
```

`docker-compose up`コマンドを実行するとベースと`docker-compose.override.yml`の定義をもとに環境が構築されます。

overrideファイルだけの方法を試してみましたが、やはり環境ごとにファイルを分けた方が便利なような気がしました。

# KubernetesでNginx × Node環境を構築する

ここではKubernetesを使ってNginxとNode環境を構築してみようと思います。Nginx経由でNode.jsアプリケーションにアクセスするという簡単な構成です。
なおKindを使ってKubernetes環境を構築したため、クラスター作成部分など一部ほかとは異なっている可能性があります。

## クラスターの作成

まずはクラスターの作成を行っていきます。
クラスターを作成する前に、Node.jsアプリケーションでExpressのコードを動かすためのマウントの設定を記述した設定ファイルを用意します。（Dockerfileの章でビルドしたイメージを使えば設定ファイルを用意する必要はなかったのにと後悔しています...）

```yaml:kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  # extraPortMappings:
  # - containerPort: 30080
  #   hostPort: 8080
- role: worker
  extraMounts:
  - hostPath: <絶対パス>/services
    containerPath: /services
    readOnly: true
```

設定ファイルの内容としては、コントロールプレーンとワーカーノードがあり、そのワーカーノード（Dockerコンテナで実現されています）にホスト側のソースコードをマウントするようにしています。コメントアウトされた`nodes.extraPortMappings`という設定がありますが、これはコントロールプレーン（というよりDockerコンテナ）とホスト側のポートマッピング設定です。しかしわたしの環境では設定しなくても動作したため、一旦コメントアウトにしました。

この設定ファイルをもとに`kind`コマンドでクラスターを作成します。

```bash
$ kind create cluster --config kind-config.yaml
$ kind get clusters
kind
$ kubectl config get-contexts
CURRENT   NAME        CLUSTER     AUTHINFO    NAMESPACE
*         kind-kind   kind-kind   kind-kind
$ kubectl get node # ノード一覧の表示
```

kindで作成したクラスターは`kind-`というプレフィックスが付くようです。kind側では`○○`という名前でクラスター名を指定しますが、Kubernetes側では`kind-○○`という名前を使うことになります。
また、`--name`オプションを使うことでクラスター名を自由に変えることができます。このオプションを利用したとしても、`kind-`というプレフィックスが付くため注意してください。デフォルトの名前は`kind`です。

## NginxのDeployment・Service・ConfigMap作成

クラスター（とノード）の用意ができたため、続いてNginx環境を構築します。

まずはConfigMapの作成を行います。
ConfigMapは機密性の低いデータを保存するためのオブジェクトです。ConfigMapを使用することで環境固有の設定をコンテナイメージから分離できるため、アプリケーションを簡単に移植できるようになります。
また、機密性の高い情報を扱うためのSecretというオブジェクトも用意されています。

今回はNginxの設定ファイルからConfigMapを作成します。
ConfigMapはマニフェストファイルから作成する方法と作成時にファイルそのものを渡す方法とがありますが、設定ファイルは少し複雑なため、作成時にファイルそのものを渡す方法を取ることにしました。

設定ファイルの中身としては一般的な内容ですが、わたしの環境下では名前解決部分が特殊だったため、その部分を抜粋します。

```nginx:nginx.conf
# 省略

location /nodejs/ {
    resolver kube-dns.kube-system.svc.cluster.local;

    set $upstream nodejs-service.default.svc.cluster.local:3000/;
    proxy_pass http://$upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
}

# 省略
```

```bash
$ kubectl create configmap nginx-config --from-file=nginx.conf
$ kubectl get configmap nginx-config -o yaml # マニフェストファイル形式でConfigMapの内容を表示
```

続いてDeploymentとServiceの作成を行います。オブジェクトごとにマニフェストファイルを用意するのは手間がかかるため、`---`で区切ることで1つのファイルにまとめました。

```yaml:nginx-manifest.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 2 # Pod数
  selector:
    matchLabels:
      app: nginx-deployment
  template:
    metadata:
      labels:
        app: nginx-deployment
    spec:
      containers:
      - name: nginx
        image: nginx:stable-alpine
        ports:
        - containerPort: 80
        volumeMounts:
        - mountPath: /etc/nginx/nginx.conf
          name: config-volume
          subPath: nginx.conf # 単一のファイルをマウントする場合は必須
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 200m
            memory: 256Mi
      volumes:
      - name: config-volume
        configMap:
          name: nginx-config

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  selector:
    app: nginx-deployment
  ports:
  - port: 80
    targetPort: 80
    nodePort: 30080
  type: NodePort # 外部からアクセスできるようにする
```

ファイルの用意ができたため、`kubectl apply`コマンドでリソースの作成を行います。

```bash
$ kubectl apply -f nginx-manifest.yaml
$ kubectl get deployment
$ kubectl get pod
$ kubectl get service
```

`kubectl get pod`コマンドで表示されたSTATUSがRunningとなっていれば正常に動作しています。初回のリソース作成時はDockerイメージをプルするのに時間がかかる可能性があります。
また、上手く動作していない場合は以下のコマンドで詳細情報を確認することができます。

```bash
$ kubectl descirbe pod
$ kubectl logs <リソース名>
```

ダッシュボードをデプロイすることでKubernetesリソースの情報を視覚的に確認することもできます。詳細は[こちら](https://kubernetes.io/ja/docs/tasks/access-application-cluster/web-ui-dashboard/)を参照してください。

![kubernetes-dashboard-result.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594132/b4a223a4-fadd-6241-8924-3859fed352b5.jpeg)


リソースの作成後にConfigMapでマウントした内容を変更したい場合は、ConfigMapの再作成後にPodの再起動を行うことで変更を反映させることができます。

```bash
# ConfigMapを再作成する
$ kubectl rollout restart deployment <デプロイメント名> # 今回の場合はnginx-deployment
```

## NodeのDeployment・Service作成

続いてNode環境の構築も行います。アプリケーションのソースコードに関しては省略します。

```yaml:node-manifest.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nodejs-deployment
spec:
  selector:
    matchLabels:
      app: nodejs-deployment
  template:
    metadata:
      labels:
        app: nodejs-deployment
    spec:
      containers:
        - name: nodejs
          image: node:current-alpine
          command: ["npm", "run", "start"]
          workingDir: /usr/src/app
          ports:
            - containerPort: 3000
          volumeMounts:
            - name: source-code
              mountPath: /usr/src/app
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 200m
              memory: 256Mi
      volumes:
        - name: source-code
          hostPath:
            path: /services/node-sample
            type: Directory

---
apiVersion: v1
kind: Service
metadata:
  name: nodejs-service
spec:
  selector:
    app: nodejs-deployment
  ports:
    - port: 3000
      targetPort: 3000
  type: ClusterIP # クラスター内部にのみ公開
```

Node.jsアプリケーションはNginx経由でアクセスできれば十分なため、Serviceオブジェクトの`spec.type`フィールドにはClusterIPを指定しました。
その部分以外ではNginxのマニフェストファイルと大きな違いはありません。

```bash
$ kubectl apply -f node-manifest.yaml
# Nginxと同様にkubectl get 〇〇 コマンドでリソースの状態を確認することができる
```

## 動作確認をする

ここまでの章で構築したNginx × Node環境にアクセスしてみたいと思います。
今回Kubernetesの環境構築に利用したkindはクラスターをDockerコンテナとして作成するため、まずコンテナに割り振られているIPアドレスを取得します。

```bash
$ kubectl get node
$ kubectl describe node <node名> | grep InternalIP
InternalIP: <コンテナに割り振られているIPアドレス>
```

IPアドレスを取得できたため、`IPアドレス:30080`と`IPアドレス:30080/nodejs/`にアクセスしてみたいと思います。

```bash
$ curl <IPアドレス>:30080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
...

$ curl <IPアドレス>:30080/nodejs/
{"message":"Hello World"}
```

無事に動作していることを確認することができました。

# まとめ

今回は「Kubernetes・Docker」についての学習を20時間行いその結果をまとめてみました。

20時間フルにKubernetesの学習に使った訳ではありませんでしたが、それでも「触れる」というレベルになることができたと思います。

反省点として、学習コストが高すぎたため途中でより良い方法を知ったとしても試す時間がなかったことです。
例えばKustomizeというマニフェストファイル管理ツールがあるそうですが、時間が足りなかったため試すことができませんでした。
またnodejs-deploymentにMySQLコンテナを追加してSecretオブジェクトと永続化を試してみたかったのですが時間が足りませんでした。

とはいえこれは仕方のないことなのかなと。今後も学習を続けて上記の内容に挑戦してみたいと思います。

次回はデータベース選定・データベース設計やデータ構造とアルゴリズムなど苦手な分野に挑戦してみたいなと考えています。