PostgreSQL のコンテナで複数のDBを作成する方法

# まとめ

* PostgreSQL の Docker公式イメージは、複数のDB作成をサポートしています。
* ただし、複数DBのユーザー名、パスワード等をDBと利用側アプリの双方に渡すのは手間がかかります。
* コンテナ生成時に mitamae を利用すると良い感じにDBが作成できます。
  * mitamae は、イメージ作成時にも利用できます。

# 1つのpostgresコンテナで複数のDBを使用したい

DockerHub上の PostgreSQL の [公式イメージ](https://hub.docker.com/_/postgres/) では、環境変数 POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD を指定することで DB を簡単に作成することができますが、DBの数は1つだけに限られます。

一方で公式ではpostgresコンテナ上に複数のDB作成にも対応できる仕組を用意しています(事例をいろいろ検索していると複数のアプリのために複数のpostgresのコンテナとボリュームを立ち上げている人とかもいましたが)。

公式の "Initialization scripts" の節から引用します。

> このイメージで追加の初期化を行いたい場合は、 `/docker-entrypoint-initdb.d` の下に1つ以上の*.sql、*.sql.gz、または*.shスクリプトを追加してください(必要に応じてディレクトリを作成してください)。
> エントリポイントが initdb を呼び出してデフォルトの postgres ユーザとデータベースを作成した後、サービス開始の前にさらなる初期化を行うために、そのディレクトリ内で見つけた*.sqlファイルを実行し、実行可能な *.shスクリプトを実行し、実行不可能な *.shスクリプトをソースにします。

すでに、 シェルスクリプトと環境変数の組み合わせで複数DBを生成する、という [事例](https://github.com/mrts/docker-postgresql-multiple-databases) が公開されています。

ただし、各DBのユーザーのパスワードをどう設定するか、といったことまでは踏み込んでいません。

# 各DBのパスワードを管理したい

　上記の事例で複数のDBを作成できることは分かりました。しかし、DBを作成するとき同時にユーザーのパスワードを設定したいし、パスワードを決め打ちで git にコミットするといったことは避けたいものです。また、複数のDBを作成したいというケースでは、複数のアプリケーションのコンテナのためそれぞれにDBを作成するわけですが、アプリケーション側にも同じユーザー名とパスワードを渡してあげる必要があります。

　アプリケーション側(ここでは同じ docker-compose 上で稼働するコンテナという前提にします)のDBユーザー名、パスワードも環境変数で渡すのが一般的ですが、その環境変数名はバラバラです。

　このあたりを良い感じで管理するために、yaml ファイルを作成し、パスワードだけは別ファイルに保存しておく、という方法を検討します。

## ディレクトリ構成

Windows上で、Docker Desktop 2.3.0.4 でテストしています。

```
    ├── pg-init-scripts                               コンテナにマウントするディレクトリ
    │   ├── create-multiple-postgresql-databases.sh   PostgreSQL が実行するシェルスクリプト
    │   ├── dbenv.rb                                  mitamae (後述)が参照する rubyスクリプト
    │   ├── dbenv.yaml                                DB の構成を記述した yamlファイル
    │   ├── dbpass.yaml                               DB のパスワード
    │   └── mitamae
    ├── .gitignore
    ├── dbenv.ps1       環境変数ファイルを生成するスクリプト(Windows環境なので Powershell を使っています)
    ├── .app1.env       アプリの環境変数(自動生成)
    ├── .app2.env       アプリの環境変数(自動生成)
    ├── .postgres.env   postgresの環境変数(自動生成)
    └── docker-compose.yaml
```

```yaml:./docker-compose.yaml
version: '3.7'
services:
  app1:
    container_name: app1
    image: app1:latest
    depends_on:
      - postgres
    env_file:
      - ./.app1.env # dbenv.yaml, dbpass.yaml から生成します。後述。
    ports:
      - 8080:8080
  app2:
    container_name: app1
    image: app2:latest
    depends_on:
      - postgres
    env_file:
      - ./.app2.env # dbenv.yaml, dbpass.yaml から生成します。後述。
    ports:
      - 8081:8080
  postgres:
    container_name: postgres
    image: postgres:latest
    env_file:
      - ./.postgres.env # dbenv.yaml, dbpass.yaml から生成します。後述。
    environment:
      DB_PARAM: /docker-entrypoint-initdb.d/dbenv.yaml
      DB_PASS: /docker-entrypoint-initdb.d/dbpass.yaml
    volumes:
      # 初期化スクリプトのディレクトリは、ホストと共有
      - ./pg-init-scripts:/docker-entrypoint-initdb.d
      - data:/var/lib/postgresql/data
    ports:
        - 5432:5432
volumes:
  data:
```

各DBの名、ユーザー名、環境変数名等を独立した yaml ファイルで管理します。キーの名前は、docker-compose.yamlのサービス名に合わせます。postgres本体のユーザー名もここに記載します。

```yaml:./pg-init-scripts/dbenv.yaml
# postgres自身のユーザー名、パスワード
postgres:
  USERNAME:
    key: POSTGRES_USER
    val: postgres
  PASSWORD:
    key: POSTGRES_PASSWORD
# app1 の DB名、ユーザー名、パスワード
app1:
  DB_NAME:
    key: APP1_DB_DATABASE
    val: app1_postgres
  USERNAME:
    key: APP1_DB_USERNAME
    val: app1_postgres
  PASSWORD:
    key: APP1_DB_PASSWORD
# app2 では、ユーザー名がそのままDB名になるという考え方
# 当然、環境変数のネーミングルールもバラバラ
app2:
  USERNAME:
    key: DATABASE_USERNAME
    val: app2_postgres
  PASSWORD:
    key: DATABASE_PASSWORD
```

パスワードファイルは別管理です。こちらは、バージョン管理システムにコミットしないように注意しましょう。

```yaml:./pg-init-scripts/dbpass.yaml
postgres: postgres
app1: postgres_pass1
app2: postgres_pass2
```

# mitamae の利用

　世の中には、yaml を bash でパースする剛の者もいるようですが、ここでは [mitamae](https://github.com/itamae-kitchen/mitamae) を使用します。

　mitamae は、mruby で作成されたプロビジョニングツールです。rubyで作成された Itamae が母体となっていますが、シングルバイナリのツールで rubyのインストールは不要です。yaml のパース、テンプレートエンジンの使用などの機能が取り込まれていてサイズが2.15MB(ver1.11.7の時点で)というコンパクトな点も魅力です。

　mitamae は、./pg-init-scripts/ に保存しておきます(コンテナ内で実行するのでコンテナの環境に合わせた実行形式をダウンロードする必要があります。記事の環境では、mitamae-x86_64-linuxを使用しています)。DB追加のための rubyスクリプトも同じディレクトリに保存します。
スクリプトの改行コードは、LFであることに注意してください。CRLFで保存しているとヒアドキュメントが動きません。私はWindows環境なのでここで少しハマりました。

```ruby:./pg-init-scripts/dbenv.rb
param = open(ENV["DB_PARAM"], 'r') { |f| YAML.load(f.read) }
pass  = open(ENV["DB_PASS"] , 'r') { |f| YAML.load(f.read) }

param.each_pair do |key, value|

  db_name  = value['DB_NAME'] ? value['DB_NAME']['val'] : value['USERNAME']['val']
  username = value['USERNAME']['val']
  password = pass[key]

  next if db_name === 'postgres'

  execute "create DB #{db_name}" do
    command <<-CMD.gsub(/^ */, "")
      psql -v ON_ERROR_STOP=1 --username "#{ ENV["POSTGRES_USER"] }" <<-EOSQL
      CREATE USER #{username} WITH PASSWORD '#{password}';
      CREATE DATABASE #{db_name};
      GRANT ALL PRIVILEGES ON DATABASE #{db_name} TO #{username};
      EOSQL
    CMD
  end
end
```

PostgreSQL が実行するスクリプト本体はこちらです。

```sh:create-multiple-postgresql-databases.sh
#!/bin/bash

set -eu && \
cd /docker-entrypoint-initdb.d && \
./mitamae local ./dbenv.rb
```

コンテナ生成時に mitamae を使用するというアイデアは、 「[Dockerコンテナでの設定ファイル生成にテンプレートとしてERBを使う](https://qiita.com/nowlinuxing/items/6109fb0c153c3cd91cfa)」を参考にさせていただきました(こちらはイメージ作成時に使っています)。

# DB利用側の環境変数ファイルの生成

docker-compose.yaml に記載したように、各コンテナの env_file 欄に指定されたファイルは dbenv.yaml と dbpass.yaml から生成するようにした方が間違いがありません。Windows環境で開発しているので Powershell で書いています(ConvertFrom-Yaml は外部モジュールです。外部モジュールの利用に抵抗がある場合、上記設定ファイルも含めてjsonを使う方法もあります)。mitamaeのダウンロードも、このスクリプトでやっておきましょう。

```powershell:dbenv.ps1
# postgresのアプリ別のDBのユーザー名、パスワードを、postgres
# 設定用、各アプリ設定用の環境変数を設定するためのスクリプト

# yaml を読み込み、.key名.env のファイル名で環境変数=値の内容を
# 書き込む
$obj  = Get-Content '.\pg-init-scripts\dbenv.yaml' -Raw | ConvertFrom-Yaml
$pass = Get-Content '.\pg-init-scripts\dbpass.yaml' -Raw | ConvertFrom-Yaml

$obj.Keys | ForEach-Object {
    $o = $_
    $first = $true
    $content = $obj[ $o ].Keys| ForEach-Object {
        if ($first) {
            "# 自動生成されたファイルです。gitにコミットしないで下さい。`n"
            $first = $false
        }
        $k = $_
        if ($k -eq 'PASSWORD') {
            '{0}={1}' -f $obj[ $o ][ $k ]['key'], $pass[ $o ]
        } else {
            '{0}={1}' -f $obj[ $o ][ $k ]['key'], $obj[ $o ][ $k ]['val']
        }
    }
    $filename = Join-Path (Convert-Path .) ('.{0}.env' -f $o)
    [IO.File]::WriteAllLines($filename, $content)
}

# postgres 初期化用のディレクトリに mitamae をダウンロードする。
$mitamae = '.\pg-init-scripts\mitamae'
$url = 'https://github.com/itamae-kitchen/mitamae/releases/latest/download/mitamae-x86_64-linux'

if (!(Test-Path $mitamae)) {
    Invoke-WebRequest -Uri $url -Outfile $mitamae
}
```

# 実行結果

実行してみましょう。

```powershell
.\dbenv.ps1
```

```bash
docker-compose up -d
```

そして、ログを覗いてみましょう。

```bash
docker-compose logs -f
```

ログはこちら

```
server started

/usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/create-multiple-postgresql-databases.sh
 INFO : Starting mitamae...
 INFO : Recipe: /docker-entrypoint-initdb.d/dbenv.rb
 INFO :   execute[create DB app1_postgres] executed will change from 'false' to 'true'
 INFO :   execute[create DB app2_postgres] executed will change from 'false' to 'true'

/usr/local/bin/docker-entrypoint.sh: ignoring /docker-entrypoint-initdb.d/dbenv.rb

/usr/local/bin/docker-entrypoint.sh: ignoring /docker-entrypoint-initdb.d/dbenv.yaml

/usr/local/bin/docker-entrypoint.sh: ignoring /docker-entrypoint-initdb.d/dbpass.yaml

/usr/local/bin/docker-entrypoint.sh: ignoring /docker-entrypoint-initdb.d/mitamae

waiting for server to shut down....2020-08-xx xx:xx:xx.xxx UTC [46] LOG:  received fast shutdown request
2020-08-xx xx:xx:xx.xxx UTC [46] LOG:  aborting any active transactions
2020-08-xx xx:xx:xx.xxx UTC [46] LOG:  background worker "logical replication launcher" (PID 53) exited with exit code 1
2020-08-xx xx:xx:xx.xxx UTC [48] LOG:  shutting down
2020-08-xx xx:xx:xx.xxx UTC [46] LOG:  database system is shut down
 done
server stopped

PostgreSQL init process complete; ready for start up.
```

良い感じです。PostgreSQL自身からは、*.sh 以外のファイルは無視されているのが分かります。app1_postgres と app2_postgres が作成されました。