🐾

Docker で PostgreSQL コンテナ立て

2023/10/22に公開

PostgreSQL コンテナ立て

なんとなく立てたくなったので備忘録。別にデータベース屋でもインフラ屋でもないけど、みんなの遊び場を作ってと言われたときのためにこれくらいパッとできるようになっておきたい。

参考文献

チュートリアルを参考に立てる

深いことは考えずにとりあえず公式イメージのところに簡単なコマンドがあるので docker-compose で同じようなことができるようにする。

https://hub.docker.com/_/postgres

ディレクトリ構成

ディレクトリ構成
sandbox/
  ├ docker-compose.yml
  └ postgresql
    └ Dockerfile
docker-compose.yml
version: '3.7'

services:
  db:
    restart: always
    build:
      context: postgresql
      dockerfile: Dockerfile
    environment:
      POSTGRES_PASSWORD: password
Dockerfile
FROM postgres:16.0

起動

$ docker-compose up

psql を用いたアクセス

他のコンテナからアクセスしようとすると Docker Network を作成しないといけないので、データベースが起動しているのと同じコンテナにアクセスして psql を使用する。上に作成した通りのディレクトリ構成とコマンドで起動していればコンテナ名は sandbox_db_1 になっていると思うが、docker container ls で確認してもよい。

$ docker exec -it sandbox_db_1 psql -U postgres

以下のコマンドでデータベース内のユーザーを一覧できる。

psql
postgres=# select * from pg_user;
output
 usename  | usesysid | usecreatedb | usesuper | userepl | usebypassrls |  passwd  | valuntil | useconfig
----------+----------+-------------+----------+---------+--------------+----------+----------+-----------
 postgres |       10 | t           | t        | t       | t            | ******** |          |
(1 row)

なんか知らないユーザーがいるが、初期化されたばかりのシステムには常に定義済みのスーパーユーザー(習慣的に postgres というユーザー)があり、他のロールを追加するには最初はこのユーザーを用いて接続しなければならないらしい。ソースは以下の文書。

https://www.postgresql.jp/document/15/html/database-roles.html

クライアントコンテナを立てる: Adminer

docker-compose は自動でデフォルトの Docker Network を作成するので、同じ docker-compose.yml に書いてあるコンテナからであれば特に何か追加設定しなくてもデータベースにアクセスできる。公式イメージのところには Adminer というデータベース管理クライアントをホストするコンテナを立てる例が書いてあったので、やってみる。

ディレクトリ構成などはそのままに docker-compose.yml のみを以下のように編集する。

docker-compose.yml
version: '3.7'

services:
  db:
    restart: always
    build:
      context: postgresql
      dockerfile: Dockerfile
    environment:
      POSTGRES_PASSWORD: password

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

起動

$ docker-compose up

Adminer を介して PostgreSQL にアクセスする

ブラウザのアドレスに http://[ホストのアドレス]:8080 を入力すれば Adminer にアクセスできる。データベース種類には PostgreSQL、ユーザ名は postgres、パスワードは docker-compose.ymlPOSTGRES_PASSWORD に設定した文字列(今回は password)を指定する。データベースは空欄でよい。

ここまでの設定がうまく行っていれば以下のようにデータベースにアクセスできる。

あとからクライアントコンテナを立てる

データベース管理用のクライアントコンテナを常に立てておくのはセキュリティ上よろしくない[1]。必要なときだけクライアントコンテナを立てて繋ぎ、必要なくなればクライアントコンテナを落とすというのが定石となるだろう。直にデータベースコンテナにアクセスして psql を叩くことも可能だがそれはそれで怖いので psql も別のコンテナで起動したいかもしれない。

あとからクライアントコンテナをデータベースコンテナに接続できるようにするためには Docker Network を作成して両方のコンテナを接続し、クライアントからデータベースが見えるようにしてやらねばならない。

psql を起動するコンテナについて自前で psql を使える Ubuntu イメージなどを作成してもよいが、データベースコンテナの PostgreSQL とバージョンを揃えるとなると割と面倒臭いので、データベースコンテナと同じイメージからもうひとつコンテナを起動してクライアント用にするほうが賢い。

ディレクトリ構成

あまりよい使い方ではないが、クライアント起動用のスクリプトは Makefile に書いておく[2]

ディレクトリ構成
sandbox/
  ├ Makefile
  ├ docker-compose.yml
  └ postgresql
    └ Dockerfile
docker-compose.yml
version: '3.7'

services:
  db:
    restart: always
    build:
      context: postgresql
      dockerfile: Dockerfile
    environment:
      POSTGRES_PASSWORD: password
    networks:
      - postgres_network

networks:
  postgres_network:
    driver: bridge
Makefile
.PHONY: psql
psql:
	sudo docker run -it --rm --network=sandbox_postgres_network postgres \
		psql -h sandbox_db_1 -U postgres

.PHONY: adminer
adminer:
	sudo docker run -it --rm -p 8080:8080 --network=sandbox_postgres_network adminer

作成されるコンテナや Docker Network には自動的に接頭辞(ディレクトリ名)や接尾辞(重複回避のための番号)がついてしまうため sandbox_postgres_network となり、ディレクトリ名を変えたときにこの Makefile を編集しなければならないという面倒がある。

これを回避するには状況に分けて2種類の対処法がある。

1. 同じサーバー内で同じ構成で複数のサービスを立ち上げる可能性がある場合

docker-compose が接頭辞を勝手につけるのはこの場合に使用するリソース名が被らないようにするためである[3]docker-compose の命名規則をちゃんと調べないとすべてのケースに対応できるとは言えない(特にコンテナ名の接尾辞となる番号は自動取得が難しい)が、とりあえず以下のようにコマンド実行前にディレクトリ名を自動取得することで応急処置的に対処できる。

Makefile
basename = $(shell basename ${PWD})

.PHONY: psql
psql:
	sudo docker run -it --rm --network=${basename}_postgres_network postgres \
		psql -h ${basename}_db_1 -U postgres

.PHONY: adminer
adminer:
	sudo docker run -it --rm -p 8080:8080 --network=${basename}_postgres_network adminer

2. 接頭辞など勝手に付与せずリソース名が固定されてほしい場合

同じサーバー内でリソース名の重複の回避は開発者側の責任であるという決まりのもとで、プロジェクトのトップディレクトリの名前(今回は sandbox)を変えたりしてもリソース名を固定する方法はある。以下の Qiita を参照。

https://qiita.com/satodoc/items/188a387f7439e4ec394f

変更点だけ書けば以下のようになる。

docker-compose.yml
services:
  db:
    container_name: postgres_db
    ...

networks:
  postgres_network:
    name: postgres_network
    ...
Makefile
.PHONY: psql
psql:
	sudo docker run -it --rm --network=postgres_network postgres \
		psql -h postgres_db -U postgres

.PHONY: adminer
adminer:
	sudo docker run -it --rm -p 8080:8080 --network=postgres_network adminer

起動

まず docker-compose でデータベースを立ち上げてからクライアントのほうを起動する。

データベース起動
$ docker-compose up
クライアント起動
# psql
$ make psql

# adminer
$ make adminer

デリケートな変数を secret にする

POSTGRES_PASSWORD のようなデリケートな変数を環境変数に格納したり、ましてや docker-compose.yml のようなファイルに直書きするのはよくない。間違って GitHub にアップロードしてしまうかもしれないし、アプリケーションがクラッシュしたときのレポートにデバッグのために環境変数が書き込まれて第三者から覗き見られる状態になってしまうかもしれないからである。

https://blog.diogomonica.com//2017/03/27/why-you-shouldnt-use-env-variables-for-secret-data/

docker-compose には Docker Secrets という秘密情報受け渡し用の設定が用意されている[4]

https://docs.docker.com/compose/use-secrets/

公式イメージのページには POSTGRES_INITDB_ARGS, POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB の4つの環境変数については接尾辞 _FILE をつけることで Docker Secrets で指定したファイルから読み取って使用できると書かれている。

ディレクトリ構成

シークレット情報を書くファイルは誤って git add してしまうことがないようにプロジェクトの外部(今回はホームディレクトリ直下に置いた secrets というディレクトリの中)に置くことにする。

ディレクトリ構成
~/secrets
  └ sandbox
    └ postgresql
      └ db_password.txt

sandbox/
  ├ docker-compose.yml
  └ postgresql
    └ Dockerfile
docker-compose.yml
version: '3.7'

services:
  db:
    restart: always
    build:
      context: postgresql
      dockerfile: Dockerfile
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    networks:
      - postgres_network
    secrets:
      - db_password

networks:
  postgres_network:
    driver: bridge

secrets:
  db_password:
    file: ${HOME}/secrets/sandbox/postgresql/db_password.txt
~/secrets/sandbox/postgresql/db_password.txt
password

docker-compose.yml 内の環境変数は展開されてホストのものが使用されるため ${HOME} と書けばホストのホームディレクトリを参照することができるが、docker-compose コマンドを sudo を介して使用する場合は環境変数 HOME がデフォルトで sudo した先のユーザ(root ユーザなど)のホームディレクトリで書き換えられてしまう。これを防ぐには /etc/sudoers.d に適当なファイル(my_setting など)を作成し、以下の設定を追記する[5]

/etc/sudoers.d/my_setting
Defaults	env_keep+="HOME"

env_keepsudo 時に引き継がれる環境変数を指定する。権限は 0440 が推奨されているので正しく権限設定しておく。

$ sudo chmod 0440 /etc/sudoers.d/my_setting

コンテナ作成時に SQL コマンドを実行する

データベースを起動したときになんかテストデータが入っていて欲しいときがある。ユーザーとか作成しておきたいし、CSV だったら Python とかで簡単に作れるので起動時に読み込めたら嬉しいかもしれない。

公式イメージのページによれば、公式イメージは起動時に /docker-entrypoint-initdb.d 以下にある *.sql, *.sql.gz, *.sh を設定された言語ロケールでの辞書順で実行してくれるようだが、勘違いや言語ロケール由来のバグを防ぐにはシェルスクリプトから明示的に実行順を指定する。

これらのスクリプトはコンテナが作成されるときに一度だけ実行される。作成済みの停止したコンテナを再起動したときには実行されないことに注意する。

ディレクトリ構成

コンテナ側 /docker-entrypoint-initdb.d にはホスト側 initdb.d に配置した init.sh をコピー[6]し、その中でコンテナ側 /scripts に配置した SQL スクリプトを実行する。追加するユーザーのパスワードはホスト側でプロジェクト外に配置した user_password.txt に記載し、コンテナ側で read_only マウントして読み取る。

パスワードファイルの配置について

本来ならホスト側 user_configs/password.txt も Docker Secrets のような安全な仕組みを通してコンテナに受け渡すべきだと思うが、いろいろと試行錯誤してみて仕方なくこの配置になっている。

まず前提としてパスワードを環境変数としてコンテナに渡す方法と、プロジェクト内にパスワードを記載したファイルを作成する方法は却下である。いずれもちょっとした手違いや不注意でパスワードが平文で流出する可能性があり、人間が気を付けないと起こるタイプのミスは起こるべくして起こるからである。

最初に Docker Secrets を使うことを考えたが、Docker Secrets はコンテナ側には起動中のみマウントされていて、root 権限がないと読み取ることができない。/docker-entrypoint-initdb.d 以下にあるスクリプトが実行されるときには既にデータベースの管理ユーザー(デフォルトでは postgres という Linux ユーザー)に切り替わっているため Docker Secrets を参照することはできない。読み取れるようにするにはデフォルトのデータベース起動プロセスよりも前に root 権限で postgres ユーザーからもアクセスできる場所にパスワードファイルを移動するようなハックを行う必要があり、下手をしたら実行ユーザーを root から戻し忘れたりといった危険があるので却下とした。

次にリポジトリの外に passwords.txt を配置してイメージのビルド時にコンテナ側に COPY でコピーする方法を考えたが、Docker がビルドされるときにはビルドコンテキストという概念があり、ビルド時に指定したディレクトリの外側にあるファイルをコピーすることはできない。この方法で行うならばビルド前にパスワードファイルを一度コンテキストとなるディレクトリ内にコピー、ビルド後に削除する必要があり、ビルドの工程に複数のコマンドを用いるのが ugly なので却下とした。

最後にリポジトリの外に passwords.txt を配置してコンテナの起動時にバインドマウントする方法があり、今回はこれを採用している。この方法の欠点はデータベースを実行している postgres ユーザーからアクセスできる場所にパスワードが平文で保管されているということだが、手順を複雑化して暗号化することは可能であるし、実際にセキュアな運用を考えればこれはコンテナ作成時に仮設定するワンタイムパスワードであって外部から定期的にパスワードを変更するだろうから問題ない。

ディレクトリ構成
~/secrets
  └ sandbox
    └ postgresql
      ├ secrets
      │ └ db_password.txt
      └ settings
        └ user_password.txt

sandbox/
  ├ docker-compose.yml
  └ postgresql
    ├ Dockerfile
    ├ initdb.d
    │ └ init.sh
    └ scripts
      ├ create_user.sql
      ├ create_table.sql
      ├ read_csv.sql
      └ weather.csv
docker-compose.yml
version: '3.7'

services:
  db:
    restart: always
    build:
      context: postgresql
      dockerfile: Dockerfile
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - ${HOME}/sandbox_configs/postgresql/settings:/settings:ro
    networks:
      - postgres_network
    secrets:
      - db_password

networks:
  postgres_network:
    driver: bridge

secrets:
  db_password:
    file: ${HOME}/sandbox_configs/postgresql/secrets/db_password.txt
Dockerfile
FROM postgres:16.0

COPY initdb.d /docker-entrypoint-initdb.d
COPY scripts /scripts
init.sh
#!/bin/bash
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
    -f /scripts/create_user.sql
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
    -f /scripts/create_table.sql
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
    -f /scripts/read_csv.sql
create_user.sql
\set password `cat /settings/user_password.txt`

CREATE USER new_user1 WITH PASSWORD :'password';
create_table.sql
CREATE TABLE weather (
    city            varchar(80),
    temp_lo         int,           -- 最低気温
    temp_hi         int,           -- 最高気温
    prcp            real,          -- 降水量
    date            date
);

-- いくつか初期データを入れておく
INSERT INTO weather (city, temp_lo, temp_hi, prcp, date)
    VALUES ('San Francisco', 43, 57, 0.0, '1994-11-29');

INSERT INTO weather (city, temp_lo, temp_hi, prcp, date)
    VALUES ('New York', 40, 52, 20.1, '1994-11-29');

INSERT INTO weather (city, temp_lo, temp_hi, prcp, date)
    VALUES ('Los Angeles', 41, 55, 11.6, '1994-11-29');
read_csv.sql
-- CSVファイルをコピーしてテーブルに挿入
\COPY weather FROM '/scripts/weather.csv' WITH CSV HEADER;
weather.csv
city,temp_lo,temp_hi,prcp,date
Chicago,37,49,0.5,1994-11-29
Houston,50,68,0.8,1994-11-29
Miami,65,80,0,1994-11-29
Seattle,37,48,2.3,1994-11-29
Denver,29,53,0.7,1994-11-29

データを永続化する

PostgreSQL のデータはデフォルトで /var/lib/postgresql/data に保存される(コンテナ作成時に環境変数 PGDATA で他のディレクトリを指定することもできる)ので、このディレクトリにホスト側のファイルシステムをマウントしておけばデータは自動的に永続化される。したがって以下を追記するだけでよい。

services:
  db:
    ...
    volumes:
      ...
      - ${HOME}/postgresql_data:/var/lib/postgresql/data
    ...

おしまい

バックアップとかレプリケーションも設定したかったけど記事が長くなったのでまた今度。

脚注
  1. 多分。そもそもデータベース用のコンテナを外部に直接公開することはないし、公開するとしてもせいぜい社内とか部内なのでクライアントを立てっぱなしにしても問題はないと思うが、内部の馬鹿がイタズラしたり、なんらかの手違いで外から見えちゃうこと(攻撃者が内部のネットワークにバックドアを仕込んでいてそれを踏み台にしている可能性)もありえる。攻撃者にとってはアクセスポイントが増えるほど攻撃の手がかりが増えることになるので、使わないアクセスポイントは閉じておくのが基本である。 ↩︎

  2. コマンドごとにスクリプトファイルを作成する方法のほうが適切なのかもしれないが、私の脳が管理できるファイル数はあまり多くないのでファイル数が増えるのがあまり好きではない。そうしていたこともあるが、同じコンテナでも微妙に設定を変えた複数の起動方法を用意することがあり、起動方法ごとにスクリプトファイルを作成しているとあっという間にディレクトリが散らかって管理できなくなった。ChatGPT にも代替案を聞いたが docker-composemake かスクリプトファイルでやれと言われたのでいいかなって。 ↩︎

  3. もし複数のサービスでリソース名が被っていると、そもそも docker-compose up で起動できないか、よしんば構成がまったく同じでサービスの起動ができても複数のサービスでリソースが共有されてしまってサービスはバグるし、docker-compose down でひとつのサービスを停止したときに他のサービスと共有されているリソースも破棄してしまう。かなりデストラクティブなことになるので docker-compose はデフォルトでリソースの重複を回避するようにできている。 ↩︎

  4. Docker Swarm や Kubernetes を使っているなら秘密情報を受け渡すための仕組みがあるようなのでそちらを使うべきである。 ↩︎

  5. この設定は逆に sudo 先のホームディレクトリで書き換えられることを想定しているすべてのプログラムに影響を与えるので注意する。他にも方法はあるので各自で調べて現行のシステムに影響の出ない方法で環境変数の引き継ぎを行うこと。 ↩︎

  6. あるいは Read Only でホスト側のディレクトリをマウントしてもよい。うっかり書き込み権限を残したままマウントしてしまうと、脆弱性を突かれたときに init.sh に悪意のあるスクリプトを仕込まれるかもしれず、新たに作成されたコンテナがそのスクリプトを実行してしまうかもしれない。 ↩︎

Discussion