Docker開発環境のDBにReadOnlyユーザーを作り、書き込みバグを検知する

この記事は、[Docker Advent Calendar 2019](https://qiita.com/advent-calendar/2019/docker)の19日目の記事です。

# TL;DR

- Dockerを開発環境で使う際に、replica databaseを使うようなアプリケーションにて、書き込みバグを検知する
- MySQLのデータベースにreadOnlyユーザーを作成してアプリケーションから利用する
- レプリカラグの考慮などには対応できないが、最低限書き込みバグを防ぐことができる

# ReadOnlyユーザーを作る

Webアプリケーション開発において、PostgreSQLやMySQLといったRDBMSをシステムのデータベースとして使うケースは多いです。そのような構成において、負荷対策で参照クエリを逃がすと行った用途で**Replicaインスタンス（読み込み専用）**を用意することは多いでしょう。

このシステム構成で、Docker開発環境を作る場合、データベースサービスをローカルのDockerコンテナとして立てる際、**間違えてReplicaデータベースに書き込むバグ作っちゃった**とはならないようにしたいですよね。この目的感のもと採用できる打ち手の一つが、**ReadOnly**ユーザーを作成する方法です。

この方法は、すぐに実践できる手軽さもあり、それなりの数の実践事例を観測しています。たとえば、CakeFest 2019というCakePHPの国際カンファレンスでの「[Working with Database Replications in CakePHP](https://speakerdeck.com/motooka/working-with-database-replications-in-cakephp?slide=23)」という発表の中でも、レプリカデータベースに対して書き込んでしまうようなアプリケーションバグを検知するために、**ReadOnly**ユーザーを作成するという話をしていました。

実際に、筆者の現場でも同様の方法を採用しています。この方法はレプリケーションラグの考慮などを踏まえると完全なシミュレーション方法ではありませんが、初手としては良い方法と考えています。

# 実現例

今回実現する例は、次のような構成です。

- MySQLを利用、MySQLのimageは公式のものを利用します 
- 複数コンテナサービスが必要なため docker-compose を用います

例題として取り上げるものは下記のGitHub repositoryに公開しています。

https://github.com/hgsgtk/health-endpoint

```shell
├── docker-compose.yml
├── docker
│   └── db
│       ├── conf.d
│       │   └── custom_my.cnf
│       └── docker-entrypoint-initdb.d
│           ├── 1_initialize_tables.sql
│           └── 2_readuser.sql
└── src
    └── Dockerfile
```

## docker-compose.yml

早速、docker-compose.ymlを見てみましょう。Compose file formatはversion 3を用いています。

refs: https://docs.docker.com/compose/compose-file/

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

services:
  health-api:
    build:
      context: "./src/"
    ports:
      - "8080:8080"
    depends_on:
      - db
    restart: on-failure
    env_file:
      - ".env"

  db:
    image: mysql:5.6
    ports:
      - "3306:3306"
    expose:
      - 3306
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: healthy
      MYSQL_USER: wruser
      MYSQL_PASSWORD: password
    volumes:
      - ./docker/db/data:/var/lib/mysql:cached
      - ./docker/db/conf.d:/etc/mysql/conf.d:cached
      - ./docker/db/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:cached
```

話を単純にするために２つのサービスのみを取り上げます。Web APIである`heath-api`とMySQLデータベースである`db`を定義しています。

### mysql公式イメージの使い方のおさらい

#### 環境変数
`mysql`の公式イメージの使い方をざっとおさらいすると、`MYSQL_ROOT_PASSWORD`など特定の環境変数を指定することで作成されるrootユーザーのパスワードなどを指定することが出来ます。

https://hub.docker.com/_/mysql の`Environment Variables`を見ることで環境変数で設定できる項目を確認することが出来ます。

#### データの永続化
コンテナが停止したときにデータが消えてしまうと開発環境としては少し物足りませんね。データをコンテナ停止しても永続化された状態にするために、次の記述でボリュームマウントします。

```yaml:docker-compose.yml
    volumes:
      - ./docker/db/data:/var/lib/mysql:cached
```

これは、 https://hub.docker.com/_/mysql の`Caveats > Where to Store Data`に説明されています。Git管理下におくとMySQLのデータがそのままコミットされてしまうので、対象ディレクトリを`.gitignore`に追加しておくのを忘れないようにしましょう。


```:.gitignore
# docker database local mount files
docker/db/data
```

#### Custom MySQL Configuration

デフォルトの設定ではなく、ユーザー自身でMySQLの設定をカスタムしたいという場合は、コンテナ内の`/etc/mysql/conf.d`に設定をマウントします。これは、 https://hub.docker.com/_/mysql の `Using a custom MySQL configuration file` に説明されています。

```yaml:docker-compose.yml
    volumes:
      - ./docker/db/conf.d:/etc/mysql/conf.d:cached
```

例えば、このケースではつぎのような`my.cnf`を設定します。

```:docker/db/cnf.d/custom_my.cnf
[mysqld]
explicit_defaults_for_timestamp=1
symbolic-links=0
sql_mode=TRADITIONAL,NO_AUTO_VALUE_ON_ZERO,ONLY_FULL_GROUP_BY
character-set-server=utf8

[client]
default-character-set=utf8
```

### ReadOnlyユーザーを設定する

ReadOnlyユーザーを設定します。`docker-compose.yml`では次の設定項目が該当します。

```yaml:docker-compose.yml
    volumes:
      - ./docker/db/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:cached
```

まずは、`docker-entrypoint-initdb.d`についてですが、コンテナ内の`/docker-entrypoint-initdb.d`に`.sh`・`.sql`・`.sql.gz`拡張子のファイルをおくと、初期化の実行してくれます。

そのため、この初期化時にReadOnlyユーザーを作るSQLを実行すれば、ReadOnlyユーザーを作成できます。

```sql:docker/db/docker-entrypoint-initdb.d/2_readuser.sql
GRANT SELECT, PROCESS ON *.* TO 'reader'@'%' IDENTIFIED BY 'password';
```

ファイル名ですが、

> Files will be executed in alphabetical order.

という記述が、https://hub.docker.com/_/mysql の `Initializing a fresh instance`にあることが理由です。アルファベット順にソートされ実行されるので、明示的に順番になるように数字を先頭にしています。

### 実際にReadOnlyユーザーが作れているか

実際に、rootアカウントでデータベースにアクセスし、ユーザーが作られているか確認します。`mysql.user`テーブルに対して検索すると次のような結果が得られます。


```sql
SELECT 
    User
FROM
    mysql.user;
```


```
reader
root
wruser
root
```

作成したReadOnlyユーザーである`reader`を確認することが出来ます。このユーザーには、`GRANT`で権限を付けているので、`SELECT`と`PROCESS`が可能なユーザーになっています。

```sql
SELECT 
    User,
    Select_priv,
    Insert_priv,
    Update_priv,
    Delete_priv,
    Process_priv
FROM
    mysql.user
WHERE
	User = 'reader';
```

```
reader	Y	N	N	N	Y
```

これで、このデータベースを使うコンテナサービスが、レプリカ接続にReadOnlyユーザーを使うことで、**誤って書き込みがおこなれても無事失敗するようになります。**

```sql:ReadOnlyユーザーで書き込みをした場合
INSERT INTO customers VALUES (1, now(), now())
```

```
INSERT command denied to user 'reader'@'172.27.0.1' for table 'customers'
```

# 余談: test_データベース

MySQL、とくに**5.6**の挙動に詳しい方であれば、ReadOnlyユーザーと聞いたときに、「テスト用データベースの場合の考慮はいらないだろうか」と疑問に思うかもしれません。MySQL 5.6の挙動として、次のような仕様があります。

> さらに、mysql.db テーブルにはすべてのアカウントが test データベースおよび test_ で始まる名前を持つその他のデータベースにアクセスすることを許可する行が含まれます。これは、デフォルトの匿名アカウントのように、そうでなければ特別な権限を持たないアカウントにも当てはまります。

https://dev.mysql.com/doc/refman/5.6/ja/default-privileges.html

この仕様のままであれば、`test`あるいは`test_`で始まるデータベースの場合、**ReadOnlyユーザーの権限を作っても書き込めてしまうことになります**。これに対して、MySQL管理者への推奨は、

> データベースへのアクセスを、その目的のために明示的に許可を付与されたアカウントのみに制限する場合は、管理者は mysql.db テーブルのこれらの行を削除するとよいでしょう。

とある通り、mysql.dbテーブルから該当行を削除することでこの挙動を無効にする対応が必要と説明しています。

これは、MySQLの公式イメージでは実際意識する必要はありません。なぜなら、イメージの`docker-entrypoint.sh`にて、該当行の削除をしてくれているからです。

```shell
DELETE FROM mysql.db WHERE Db='test' OR Db='test\_%' ;
```

https://github.com/docker-library/mysql/blob/6659750146b7a6b91a96c786729b4d482cf49fe6/5.6/docker-entrypoint.sh#L241

公式イメージを使用すると、このようなコミュニティのナレッジを対応してくれているものを利用できるのが利点ですね。

# 最後に

ReadOnlyユーザーを作る方法は、比較的手軽に実践できるので、まだやっていない方は、一つの選択肢として検討してみてはいかがでしょうか。