Docker + Ktor + MySQL + NGINX(SSL) の環境構築

Docker環境で、kotlinのWebフレームワークであるKtorを動かす方法をまとめます。
## 動作環境
GCP の Container-Optimized OS で確認しました。また、docker-composeを使って構築しました。
### Docker
version 19.03.6
### docker-compose
version 1.24.0 
Compose file version 3
### Ktor
version 1.3.2

# システム構成
![server-architecture-simple.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/247791/13d07778-de7f-6d1e-112c-aa0331d44aef.png)

`internet`以外は、docker-composeのDockerコンテナ名（`container_name`）と紐付いています。

ディレクトリ構成は以下になっています。

```
docker-compose.yml
app/
  Dockerfile
  wait-for-mysql.sh
  src/
  ... Ktorのgradleプロジェクトのファイル群 ...
db/
  Dockerfile
  my.cnf
```

- `app`: Ktorのソースなどを格納
  - Dockerfile: `ktor`コンテナのビルド用Dockerfile 
- `db`: MySQLコンテナ関連のファイルを格納
  - Dockerfile: `mysql_host`コンテナのビルド用Dockerfile
  - my.cnf: MySQL設定ファイル

## Ktor
それでは各コンテナのDockerfileとdocker-composeのサービス定義をみていきます。
まず`ktor`のコンテナです。基本的には[公式のクイックスタート](https://jp.ktor.work/servers/deploy/containers.html)に沿って構築します。KtorのコンテナをビルドするDockerfileは次のようになっています。

```Dockerfile:app/Dockerfile
FROM openjdk:8-jre-alpine

# ktor
ENV APPLICATION_USER ktor
ENV KTOR_ENV production
RUN adduser -D -g '' $APPLICATION_USER

RUN mkdir /app
RUN chown -R $APPLICATION_USER /app

USER $APPLICATION_USER

# あらかじめビルドしておいたjarファイルをコンテナにコピー
# ここではktorapp-0.0.1.jarというjarファイルをパッケージング済みである前提です
COPY ./build/libs/ktorapp-0.0.1.jar /app/ktorapp.jar
COPY ./wait-for-mysql.sh /app/wait-for-mysql.sh
WORKDIR /app

# mysqlクライアントをインストール
USER root
RUN apk add mysql-client
RUN chmod 755 /usr/bin/mysql

# ktorユーザーに戻す
USER $APPLICATION_USER

# DBパスワードは設定ファイルのものを使用する
ARG DB_ROOT_PASS
ENV DB_PASS=${DB_ROOT_PASS}

CMD ["sh", "-c", "./wait-for-mysql.sh mysql_host root ${DB_PASS} java -server -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:InitialRAMFraction=2 -XX:MinRAMFraction=2 -XX:MaxRAMFraction=2 -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+UseStringDeduplication -jar ktorapp.jar"]
```

クイックスタートと異なるのは、MySQLコンテナが起動完了するのを待つスクリプトを追加している点です。
DBユーザーはrootユーザ固定にしてしまっていますが、パスワードは.envファイルから読み込んだものを使用しています。
また、`CMD`実行時にDBパスワードの変数を展開するために、[こちらの回答](https://stackoverflow.com/a/40454758)を参考にして変更しています。変更しないと、変数名`DB_PASS`がそのまま文字列として実行されてしまっていました。

```Dockerfile
CMD ["java", "-server", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseCGroupMemoryLimitForHeap", "-XX:InitialRAMFraction=2", ...]
```
↓ 

```Dockerfile
CMD ["sh", "-c", "./wait-for-mysql.sh mysql_host root ${DB_PASS} java -server -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:InitialRAMFraction=2 ..."]
```

MySQLコンテナの起動完了を待つスクリプトは以下です。
[Dockerの公式](https://docs.docker.com/compose/startup-order/)にあったサンプルはPostgresになっていますが、[こちらの記事](https://qiita.com/shiena/items/47437f4f7874bf70d664)はMySQLになっていましたので、参考にさせていただきました。

```sh:wait-for-mysql.sh
#!/bin/sh
# wait-for-mysql.sh

set -e

host="$1"
shift
user="$1"
shift
password="$1"
shift
cmd="$@"

until /usr/bin/mysql -h"$host" -u"$user" -p"$password" &> /dev/null
do
  >&2 echo "MySQL is unavailable - sleeping"
  sleep 1
done

>&2 echo "MySQL is up - executing command"
exec $cmd
```

ここまでが`ktor`コンテナをビルド・起動するためのスクリプトです。次に`ktor`コンテナを使用しているdocker-composeのサービスの箇所をみていきましょう。

```docker-compose.yml
version: "3"

services:
  # Ktorアプリケーション
  app:
    container_name: ktor
    expose:
      - 8080
    environment:
      - VIRTUAL_HOST=virtual.host.example
      - VIRTUAL_PORT=8080
      - LETSENCRYPT_HOST=virtual.host.example
      - LETSENCRYPT_EMAIL=mailaddress@mail.example
    build: 
      context: ./app
      args:
        DB_ROOT_PASS: $DB_ROOT_PASS
    depends_on:
      - db
    networks:
      - reverse-proxy
    
# ... 省略 ...

networks:
  reverse-proxy:
    external: true
```

`build`で構築するDockerfileがある`app`ディレクトリを指定し、引数としてDBのパスワードを渡しています。それをDockerfileのなかで解釈し、MySQL起動完了待ちスクリプトに渡していました。

後述するDBコンテナやnginxのリバースプロキシ、Let's Encrypt companionなどもすべて同一のネットワークを使用するので、ここでは`revese-proxy`という既存のネットワークを使うようにしています。（ひとつの`docker-compose.yml`に全てのコンテナ定義を書く場合はデフォルトの同一ネットワークが適用されるので不要かもしれませんが...）
## DB
`mysql_host`のコンテナです。
docker-composeのサービス定義は[こちら](https://qiita.com/ucan-lab/items/b094dbfc12ac1cbee8cb)を参考にしました。ただ、Container Optimized OSだとMySQLコンテナがログ(`/var/log/mysql`)への書き出し権限がないため起動に失敗しましたが、その問題は[こちら](https://blueskyarea.hatenablog.com/entry/2019/12/02/220749)を参考に解決しました。ありがとうございます。

まず、コンテナをビルドするDockerfileです。

```Docker:db/Dockerfile
FROM mysql:8.0
RUN mkdir /var/log/mysql
RUN chown mysql:mysql /var/log/mysql
```

イメージをダウンロードして、ログ出力先ディレクトリを作成し権限を設定しています。
次に、docker-composeのサービス定義の箇所です。

```docker-compose.yml
services:
  # MySQL
  db:
    build: 
      context: ./db
    container_name: mysql_host
    volumes:
      - db-store:/var/lib/mysql
      - db-logs:/var/log/mysql
      - ./db/my.cnf:/etc/mysql/conf.d/my.cnf
    environment:
      - MYSQL_DATABASE=${DB_NAME}
      - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASS}
      - TZ=${TZ}
    ports:
      - ${DB_PORT}:3306
    networks:
      - reverse-proxy

# ... 省略 ...

volumes:
  db-store:
  db-logs:
```

今回はDBユーザーはrootユーザーのみ使用していますので、DB情報の環境変数は以下のみセットしています。

- `MYSQL_DATABASE`: DB名
- `MYSQL_ROOT_PASSWORD`: rootユーザーのパスワード

実は最初はDBユーザーの環境変数`MYSQL_USER`に`root`で指定してたんですが、そうすると、MySQLコンテナが起動したときに`root`というユーザーを作成しようとして起動に失敗してしまうんですね...。
参考: https://stackoverflow.com/a/45086773
Ktorからrootユーザーではなく別のユーザーでアクセスしたい方は適宜セットしてください。

設定値の読み込みは`.env`ファイルを使用しています。

```:.env
DB_NAME=<db name>
DB_ROOT_PASS=<password>
DB_PORT=3306
TZ=Asia/Tokyo
```

## リバースプロキシ

`nginx_proxy`コンテナです。Ktorへリクエストを転送するリバースプロキシになります。このコンテナとLet's Encryptを使ってSSL証明書を発行するコンテナ（`letsencrypt_companion`）については、Ktorクイックスタートの[SSL通信にするためのガイド](https://ktor.io/quickstart/guides/ssl.html#docker)を参考に構築しました。

[jwilder/nginx-proxy](https://hub.docker.com/r/jwilder/nginx-proxy/)という素晴らしいイメージをそのまま使うので、docker-composeのサービス定義のみです。

```docker-compose.yml
  nginx:
    image: jwilder/nginx-proxy
    container_name: nginx_proxy
    environment: 
      - NGINX_PROXY_CONTAINER=nginx_proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - certs:/etc/nginx/certs:ro
      - vhost:/etc/nginx/vhost.d
      - nginx-html:/usr/share/nginx/html
      - /var/run/docker.sock:/tmp/docker.sock:ro
    restart: always
    labels:
      - "com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy=true"
    networks:
      - reverse-proxy
    
  # ...

volumes:
  certs:
  vhost:
  nginx-html:
```

- `certs:/etc/nginx/certs:ro`の`ro`は read only モードとのことですが、このコンテナからは証明書を読み込むだけで書き込みは必要ないのでつけています。
- `NGINX_PROXY_CONTAINER=nginx_proxy`と`label`は、Let's Encrypt companionコンテナがnginxプロキシコンテナを識別するために使用するもののようです

上記で3つの`volumes`を定義しています。それらはSSL証明書発行のために使用されるので、後述のLet's Encrypt companionコンテナと共有しています。それぞれのボリュームについて解説します。

![docker-volumes.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/247791/80ba8a30-8166-2cf7-ad24-a338ff9eb19d.png)
※ 一部の矢印が点線なのはread onlyにしているのを表現したかったからです...

### `certs`ボリューム
発行されたSSL証明書が格納されます。`nginx_proxy`コンテナからは読み込むだけなので`ro`(read only)にします。
### `vhost`ボリューム
nginxのlocationなど設定ファイルが格納されるボリュームです。Let's EncryptでSSL証明書を発行する際に、[Let's Encryptから検証のためのリクエストが送信される](https://letsencrypt.org/ja/docs/challenge-types/#http-01-%E3%83%81%E3%83%A3%E3%83%AC%E3%83%B3%E3%82%B8)ので、そのリクエストを受け付けるlocation設定がこちらのボリュームに格納されるようです。
ためしに、コンテナを作成したあとにボリュームのなかを見てみると...

```bash
$ docker volume inspect ktorapp_vhost
[
    {
        "CreatedAt": "2020-08-10T06:43:59Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "ktorapp",
            "com.docker.compose.version": "1.24.0",
            "com.docker.compose.volume": "vhost"
        },
        "Mountpoint": "/var/lib/docker/volumes/ktorapp_vhost/_data",
        "Name": "ktorapp_vhost",
        "Options": null,
        "Scope": "local"
    }
]
```

`/var/lib/docker/volumes/ktorapp_vhost/_data`というところにデータが格納されているようです。

```bash
$ sudo ls -la /var/lib/docker/volumes/ktorapp_vhost/_data
total 12
drwxr-xr-x 2 root root 4096 Aug 10 06:43 .
drwxr-xr-x 3 root root 4096 Aug  8 13:37 ..
-rw-r--r-- 1 root root  278 Aug 10 06:43 default
```

`default`というファイルがありました。みてみると、

```bash
$ sudo cat /var/lib/docker/volumes/ktorapp_vhost/_data/default
## Start of configuration add by letsencrypt container
location ^~ /.well-known/acme-challenge/ {
    auth_basic off;
    auth_request off;
    allow all;
    root /usr/share/nginx/html;
    try_files $uri =404;
    break;
}
## End of configuration add by letsencrypt container
```

URLパスの`ドメイン/.well-known/acme-challenge/`へのアクセスは`/usr/share/nginx/html`配下を見にいくようなlocation設定を追加しているようです。これはコンテナの`nginx-html`ボリュームのマウントポイント=`/usr/share/nginx/html`と一致しています。

### `nginx-html`ボリューム

Let's Encryptによる検証のためのファイルが格納されます。こちらも見てみましょう。

```
$ sudo ls -la /var/lib/docker/volumes/ktorapp_nginx-html/_data/
total 20
drwxr-xr-x 3 root root 4096 Aug 10 06:43 .
drwxr-xr-x 3 root root 4096 Aug  8 13:37 ..
drwxr-xr-x 3 root root 4096 Aug  8 14:01 .well-known
-rw-r--r-- 1 root root  494 Nov 19  2019 50x.html
-rw-r--r-- 1 root root  612 Nov 19  2019 index.html
```

`.well-known` がありました。さらにこの下に `acme-challenge`ディレクトリがあり、そこに検証用ファイルが生成されるみたいです。

## Let’s Encrypt companion

`letsencrypt_companion`コンテナです。こちらも[jrcs/letsencrypt-nginx-proxy-companion](https://github.com/nginx-proxy/docker-letsencrypt-nginx-proxy-companion)という素晴らしいDockerイメージをそのまま使うだけです。Let's Encryptを使ってSSL証明書を自動で発行してくれます。さきほど紹介した`certs` `vhost` `nginx-html` ボリュームを使用して検証のためのlocation設定やファイル作成を行い、SSL証明書を発行します。

```docker-compose.yml
  letsencrypt:
    image: jrcs/letsencrypt-nginx-proxy-companion
    container_name: letsencrypt_companion
    depends_on:
      - nginx
    volumes:
      - certs:/etc/nginx/certs:rw
      - vhost:/etc/nginx/vhost.d
      - nginx-html:/usr/share/nginx/html
      - /var/run/docker.sock:/var/run/docker.sock:ro
    restart: always
    networks:
      - reverse-proxy
```

さきほどのリバースプロキシのコンテナとは異なり、`certs`へのアクセス権限が書き込み可（`rw`）になっていることに注意してください。

# コンテナ起動
実際にコンテナの生成/起動を行ってみます。
## 前提
Container Optimized OS では、docker-composeコマンドはエイリアス設定を行わないとできないみたいですので、チュートリアルを参考に設定しました。
[Running Docker Compose with Docker](https://cloud.google.com/community/tutorials/docker-compose-on-container-optimized-os)
## 手順
まず、とても微妙なのですが...ローカル環境でKtorアプリケーションをビルド・パッケージングします...。ここもサーバー側でやりたいのでいずれ更新します・・・。

```bash
> ./gradlew build
```

パッケージングしたjarファイルをGCPサーバーにアップロードします。ここでは`プロジェクトパス/app/build/libs`配下におきます。そしてdocker-composeでコンテナ群を起動します。

```bash
$ docker-compose up
Network reverse-proxy declared as external, but could not be found. Please create the network manually using `docker network create reverse-proxy` and try again.
```
`reverse-proxy`ネットワークがないと怒られました。

```bash
$ docker network create reverse-proxy
fdf7cedf66ea285984eabbc77b49a1b27720b06e774d3d39975fd46066fa59c7
```

もう一回チャレンジしてみます。

```bash
$ docker-compose up
... 省略 ...
ktorapp        | MySQL is unavailable - sleeping
ktorapp        | MySQL is up - executing command
ktorapp        | 2020-08-11 07:32:29.390 [main] TRACE Application
```

今度はできました！以上です。

# そのほかの参考記事
[Docker、ボリューム(Volume)について真面目に調べた](https://qiita.com/gounx2/items/23b0dc8b8b95cc629f32)

[Docker network 概論](https://qiita.com/TsutomuNakamura/items/ed046ee21caca4a2ffd9)

[docker コマンドの --build-arg で設定した値を Dockerfile の CMD で利用する方法](https://qiita.com/shts/items/9435efc718a7a618d8f3)