CircleCI 2.0 で PostgreSQL を使う時のチューニング小ネタメモ

CircleCI 2.0 のドキュメントに[書かれている小ネタ](https://circleci.com/docs/2.0/postgres-config/#optimizing-postgres-images)ですが、つい最近まで知らず、試してみたので紹介します。

### TL;DR

- データベースディレクトリに tmpfs を使う Docker イメージもあるよ(`circleci/postgres:*-ram`)
- tmpfs を使わない版では書き込み性能のチューニングがされているよ
- 普通の書き込みでは差が出ないみたいだよ

### CircleCI 曰く

tmpfs をマウントしたディレクトリを PostgreSQL のデータベースディレクトリとして利用する事で、テスト実行時間を短縮出来る可能性があります、とのこと。

> ### Optimizing Postgres Images
>
> The circleci/postgres Docker image uses regular persistent storage on disk. Using tmpfs may make tests run faster and may use fewer resources. To create a Dockerfile for your own project and potentially reduce the duration of tests, consider adding the following line to the pre-built image.
>
> ```docker:Dockerfile
> FROM circleci/postgres
> ENV PGDATA /dev/shm/pgdata/data
> ```
>
> https://circleci.com/docs/2.0/postgres-config/#optimizing-postgres-images

CircleCI は PostgreSQL の Docker イメージ [circleci/postgres](https://hub.docker.com/r/circleci/postgres/) を Docker Hub で公開しています。この Docker イメージでは、データベースディレクトリのパスを環境変数 `PGDATA` で設定しています。つまり、この環境変数値を変更して利用すれば良いですよ、と書いているわけですね。

ドキュメントでは Dockerfile の内容が例示されているため、自分でイメージをビルドする必要がある様にも読めますが、実際にはこのバージョンのイメージも提供されています。[circleci/postgres](https://hub.docker.com/r/circleci/postgres/) の `-ram` で終わるタグがこれに該当します。

### Docker イメージの違い

一例として、 `circleci/postgres:9.6-alpine` と `circleci/postgres:9.6-alpine-ram` の違いを簡単に比べてみましょう。Docker の知識が浅いため、ここでは `docker image inspect` を使って簡単に比較します。些事を除けば以下の二点が違いの様です。

- 環境変数 `PGDATA` が `/var/lib/postgresql/data` から `/dev/shm/pgdata/data` に変わっている
- `CMD` 長々としたがシェルスクリプトから `PGDATA` の設定程度に変わっている

```
fish$ diff -u (docker image inspect circleci/postgres:9.6-alpine | psub) (docker image inspect circleci/postgres:9.6-alpine-ram | psub) \
  | grep -v '"\(Id\|Created\|Container\|Image\)"' | grep -v '"circleci/postgres'
--- /var/folders/v8/t4kc4n8138b1mzcrgch8bp4m0000gn/T//.psub.D1QQ33vKe3  2017-11-11 20:00:08.000000000 +0900
+++ /var/folders/v8/t4kc4n8138b1mzcrgch8bp4m0000gn/T//.psub.qLdz8EXsAt  2017-11-11 20:00:08.000000000 +0900
@@ -1,16 +1,16 @@
 [
     {
         "RepoTags": [
         ],
         "RepoDigests": [
         ],
         "Parent": "",
         "Comment": "",
         "ContainerConfig": {
             "Hostname": "785fa172cfca",
             "Domainname": "",
@@ -30,17 +30,18 @@
                 "PG_MAJOR=9.6",
                 "PG_VERSION=9.6.5",
                 "PG_SHA256=06da12a7e3dddeb803962af8309fa06da9d6989f49e22865335f0a14bad0744c",
-                "PGDATA=/var/lib/postgresql/data",
+                "PGDATA=/dev/shm/pgdata/data",
                 "POSTGRES_USER=root",
                 "POSTGRES_DB=circle_test"
             ],
             "Cmd": [
                 "/bin/sh",
                 "-c",
-                "if [ -e /usr/local/share/postgresql/postgresql.conf.sample ]; then         postgresfile=/usr/local/share/postgresql/postgresql.conf.sample;     else         postgresfile=/usr/share/postgresql/postgresql.conf.sample;     fi &&     echo fsync=off >> $postgresfile &&    echo synchronous_commit=off >> $postgresfile &&    echo full_page_writes=off >> $postgresfile &&    echo bgwriter_lru_maxpages=0 >> $postgresfile"
+                "#(nop) ",
+                "ENV PGDATA=/dev/shm/pgdata/data"
             ],
             "ArgsEscaped": true,
             "Volumes": {
                 "/var/lib/postgresql/data": {}
             },
@@ -72,7 +73,7 @@
                 "PG_MAJOR=9.6",
                 "PG_VERSION=9.6.5",
                 "PG_SHA256=06da12a7e3dddeb803962af8309fa06da9d6989f49e22865335f0a14bad0744c",
-                "PGDATA=/var/lib/postgresql/data",
+                "PGDATA=/dev/shm/pgdata/data",
                 "POSTGRES_USER=root",
                 "POSTGRES_DB=circle_test"
             ],
@@ -80,7 +81,7 @@
                 "postgres"
             ],
             "ArgsEscaped": true,
             "Volumes": {
                 "/var/lib/postgresql/data": {}
             },
```

環境変数 `PGDATA` の違いだけだと思っていたのですが、見逃せない違いもあるようです。以下、該当スクリプトのワンライナーを整形したものです。

```sh
if [ -e /usr/local/share/postgresql/postgresql.conf.sample ]
then
    postgresfile=/usr/local/share/postgresql/postgresql.conf.sample
else
    postgresfile=/usr/share/postgresql/postgresql.conf.sample
fi

echo fsync=off >> $postgresfile
echo synchronous_commit=off >> $postgresfile
echo full_page_writes=off >> $postgresfile
echo bgwriter_lru_maxpages=0 >> $postgresfile
```

`-ram` の方では行っていない、PostgreSQL の性能に関わる設定を加えていますね。これは…。

### PostgreSQL の設定項目

詳しい説明は PostgreSQL のドキュメント [19\.5\. ログ先行書き込み（WAL）](https://www.postgresql.jp/document/9.6/html/runtime-config-wal.html)を参考にしてください。

- `fsync=off`
- `synchronous_commit=off`
- `full_page_writes=off`
- `bgwriter_lru_maxpages=0`

簡単に言えば、書き込み系の信頼性を割り切り性能を向上させるための設定ですね。

### ブレイクタイム

雲行きが怪しくなってきました。通常のイメージがチューニング済みであるため、大抵のケースで tmpfs 版と大差なさそうな気配を感じます。

話を少し変えて、tmpfs について少し触れておきましょう。といっても tmpfs 自体の詳しい説明はしません。

- [tmpfs \- Wikipedia](https://ja.wikipedia.org/wiki/Tmpfs)

ここで触れたい事は `circleci/postgres:9.6-alpine-ram` の `/dev/shm` についてです。

```
$ docker container run --rm circleci/postgres:9.6-alpine-ram df /dev/shm
Filesystem           1K-blocks      Used Available Use% Mounted on
shm                      65536         0     65536   0% /dev/shm
```

ご覧いただけたでしょうか。データベースディレクトリとして利用するはずの `/dev/shm` は、最大 64 MB としてマウントされているのですね。つまり、もし 64 MB では足りないという場合は、別途 tmpfs でマウントする必要があります。

```
$ docker container run --rm --tmpfs /dev/shm2:rw,size=536870912,mode=1777 circleci/postgres:9.6-alpine-ram df /dev/shm2
Filesystem           1K-blocks      Used Available Use% Mounted on
tmpfs                   524288         0    524288   0% /dev/shm2
```

自分でマウントできるという点は良いのですが、 `.circleci/config.yml` の `jobs.{name}.docker` のところでどう書くのかはが分かりませんでした。

```yaml
version: 2
jobs:
  build:
    docker:
      - image: circleci/postgres:9.6-alpine
        # ここで tmpfs を定義する方法はないように見える。
    steps:
      - checkout
      ...
```

CircleCI 2.0 では任意の docker コマンドや docker-compose コマンドを実行出来るので、必要に応じてコンテナを作成する事になりそうです。

#### tips

CircleCI 2.0 で `docker` (`docker-compose`) コマンドを実行してコンテナを作るためには、 `setup_remote_docker` を使って独立したコンテナ環境(リモート環境)を立ち上げる必要がありますが、このリモート環境とプライマリコンテナ(`jobs.{name}.docker.image` の一つ目)はネットワーク接続できない関係にあります。つまり、 `docker` コマンドを使って立ち上げた PostgreSQL サーバに接続するためには、クライアントとなるコンテナも立ち上げる必要があります。

- [Running Docker Commands \- CircleCI](https://circleci.com/docs/2.0/building-docker-images/)

一方で `jobs.{name}.docker` に列挙したコンテナ同士については、プライマリコンテナからポートフォワードされている様な関係になるため、全てが localhost で動いているように扱えます。

### 試してみる

前置きが長くなりましたが、そろそろ両バージョンの Docker イメージを使ってみましょう。

ひとまず、ベンチマークとして適切なテストケースかどうかは度外視します。単純なデータベースの読み書きの時点で差が出るのかを確かめるため、10,000 行のレコードを持つテーブルを用意し、行をランダムに更新してみます。

```ruby:gen.rb
#!/usr/bin/env ruby

N = 10_000

open('test.sql', 'w') do |f|
  f.puts("CREATE TABLE test (id INT, name text);\n")

  f.puts('INSERT INTO test (id, name) VALUES (1, \'1\')')
  2.upto(N) do |n|
    f.puts("  ,(#{n}, '#{n}')")
  end
  f.puts(";\n")

  (1..N).to_a.shuffle.each do |n|
    f.puts "UPDATE test SET name = name || ' changed' WHERE id = #{n};"
  end
end
```

以下、macOS 上で実行した結果ですが、特別な差は見られませんでした。

```
$ set container_id (docker container run -d -p 55432:5432 --rm circleci/postgres:9.6-alpine)
$ time psql -q -h localhost -p 55432 -U root -d circle_test -c '\timing' -f test.sql
...
       10.26 real         0.13 user         0.17 sys
$ docker container stop $container_id
```

```
$ set container_id (docker container run -d -p 55432:5432 --rm circleci/postgres:9.6-alpine-ram)
$ time psql -q -h localhost -p 55432 -U root -d circle_test -c '\timing' -f test.sql
...
       10.69 real         0.13 user         0.17 sys
$ docker container stop $container_id
```

以下の様な `.circleci/config.yml` を書いて CircleCI 上でも実行しましたが、同様の結果になりました。ジョブごとに 10 秒近い差が出ることもありましたが、CircleCI ではよくある事なので誤差ですね。

```yaml
version: 2
jobs:
  "circleci/postgres:9.6-alpine":
    docker:
      - image: circleci/golang:latest
      - image: circleci/postgres:9.6-alpine
    steps:
      - checkout
      - run:
          name: setup
          command: sudo apt-get install -y postgresql-client-9.6
      - run:
          name: wait
          command: ./wait-for-it.sh localhost:5432
      - run:
          name: psql
          command: time psql -q -h localhost -p 5432 -U root -d circle_test -c '\timing' -f test.sql > /dev/null

  "circleci/postgres:9.6-alpine-ram":
    docker:
      - image: circleci/golang:latest
      - image: circleci/postgres:9.6-alpine-ram
    steps:
      - checkout
      - run:
          name: setup
          command: sudo apt-get install -y postgresql-client-9.6
      - run:
          name: wait
          command: ./wait-for-it.sh localhost:5432
      - run:
          name: psql
          command: time psql -q -h localhost -p 5432 -U root -d circle_test -c '\timing' -f test.sql > /dev/null

workflows:
  version: 2
  compare:
    jobs:
      - "circleci/postgres:9.6-alpine"
      - "circleci/postgres:9.6-alpine-ram"
```

![image](https://user-images.githubusercontent.com/18269/32689948-82605cd4-c731-11e7-9307-997861ee1fb0.png)
![image](https://user-images.githubusercontent.com/18269/32689951-925b892e-c731-11e7-8389-f3a7c2499abf.png)


### まとめ

以上、CircleCI 2.0 のドキュメントに書かれていた小ネタでした。

Rails(RSpec) を使っている場合、transactional fixtures などの仕組みを利用してそもそも DB にコミットしないケースが多いかと思います。一方、ウェブブラウザを利用した E2E テストなどプロセス(DB セッション)が分かれる様なテストでは、もしかしたら役に立つかも知れません。その場合も、単純な読み書きでおさまるのであれば差はないかもしれません。

テストの際の PostgreSQL のディスク I/O でお困りの方は試してみると良いのかもしれません。

### 追記: `-ram` でなくてよい

`circleci/postgres:9.6-alpine` でもデータベースディレクトリを環境変数 `PGDATA` で指定する仕組みなので、これを tmpfs をマウントしたディレクトリに変更すれば済む話であったりします。

```yaml
    docker:
      - image: circleci/golang:latest
      - image: circleci/postgres:9.6-alpine
        environment:
          PGDATA: /dev/shm/pgdata/data
    steps:
      - checkout
      - run:
          name: setup
          command: sudo apt-get install -y postgresql-client-9.6
      - run:
          name: wait
          command: ./wait-for-it.sh localhost:5432
      - run:
          name: psql
          command: time psql -q -h localhost -p 5432 -U root -d circle_test -c '\timing' -f test.sql > /dev/null
```