Docker で PostgreSQL コンテナ立て
PostgreSQL コンテナ立て
なんとなく立てたくなったので備忘録。別にデータベース屋でもインフラ屋でもないけど、みんなの遊び場を作ってと言われたときのためにこれくらいパッとできるようになっておきたい。
参考文献
- https://hub.docker.com/_/postgres
- https://docs.docker.com/compose/use-secrets/
- https://www.postgresql.org/download/linux/ubuntu/
- https://docs.docker.com/compose/compose-file/compose-file-v3/#variable-substitution
- https://www.postgresql.jp/document/15/html/tutorial-table.html
チュートリアルを参考に立てる
深いことは考えずにとりあえず公式イメージのところに簡単なコマンドがあるので docker-compose
で同じようなことができるようにする。
ディレクトリ構成
sandbox/
├ docker-compose.yml
└ postgresql
└ Dockerfile
version: '3.7'
services:
db:
restart: always
build:
context: postgresql
dockerfile: Dockerfile
environment:
POSTGRES_PASSWORD: password
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
以下のコマンドでデータベース内のユーザーを一覧できる。
postgres=# select * from pg_user;
usename | usesysid | usecreatedb | usesuper | userepl | usebypassrls | passwd | valuntil | useconfig
----------+----------+-------------+----------+---------+--------------+----------+----------+-----------
postgres | 10 | t | t | t | t | ******** | |
(1 row)
なんか知らないユーザーがいるが、初期化されたばかりのシステムには常に定義済みのスーパーユーザー(習慣的に postgres
というユーザー)があり、他のロールを追加するには最初はこのユーザーを用いて接続しなければならないらしい。ソースは以下の文書。
クライアントコンテナを立てる: Adminer
docker-compose
は自動でデフォルトの Docker Network を作成するので、同じ docker-compose.yml
に書いてあるコンテナからであれば特に何か追加設定しなくてもデータベースにアクセスできる。公式イメージのところには Adminer というデータベース管理クライアントをホストするコンテナを立てる例が書いてあったので、やってみる。
ディレクトリ構成などはそのままに 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.yml
で POSTGRES_PASSWORD
に設定した文字列(今回は password
)を指定する。データベースは空欄でよい。
ここまでの設定がうまく行っていれば以下のようにデータベースにアクセスできる。
あとからクライアントコンテナを立てる
データベース管理用のクライアントコンテナを常に立てておくのはセキュリティ上よろしくない[1]。必要なときだけクライアントコンテナを立てて繋ぎ、必要なくなればクライアントコンテナを落とすというのが定石となるだろう。直にデータベースコンテナにアクセスして psql
を叩くことも可能だがそれはそれで怖いので psql
も別のコンテナで起動したいかもしれない。
あとからクライアントコンテナをデータベースコンテナに接続できるようにするためには Docker Network を作成して両方のコンテナを接続し、クライアントからデータベースが見えるようにしてやらねばならない。
psql
を起動するコンテナについて自前で psql
を使える Ubuntu イメージなどを作成してもよいが、データベースコンテナの PostgreSQL とバージョンを揃えるとなると割と面倒臭いので、データベースコンテナと同じイメージからもうひとつコンテナを起動してクライアント用にするほうが賢い。
ディレクトリ構成
あまりよい使い方ではないが、クライアント起動用のスクリプトは Makefile
に書いておく[2]。
sandbox/
├ Makefile
├ docker-compose.yml
└ postgresql
└ Dockerfile
version: '3.7'
services:
db:
restart: always
build:
context: postgresql
dockerfile: Dockerfile
environment:
POSTGRES_PASSWORD: password
networks:
- postgres_network
networks:
postgres_network:
driver: bridge
.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
の命名規則をちゃんと調べないとすべてのケースに対応できるとは言えない(特にコンテナ名の接尾辞となる番号は自動取得が難しい)が、とりあえず以下のようにコマンド実行前にディレクトリ名を自動取得することで応急処置的に対処できる。
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 を参照。
変更点だけ書けば以下のようになる。
services:
db:
container_name: postgres_db
...
networks:
postgres_network:
name: postgres_network
...
.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 にアップロードしてしまうかもしれないし、アプリケーションがクラッシュしたときのレポートにデバッグのために環境変数が書き込まれて第三者から覗き見られる状態になってしまうかもしれないからである。
docker-compose
には Docker Secrets という秘密情報受け渡し用の設定が用意されている[4]。
公式イメージのページには 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
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
password
docker-compose.yml
内の環境変数は展開されてホストのものが使用されるため ${HOME}
と書けばホストのホームディレクトリを参照することができるが、docker-compose
コマンドを sudo
を介して使用する場合は環境変数 HOME
がデフォルトで sudo
した先のユーザ(root
ユーザなど)のホームディレクトリで書き換えられてしまう。これを防ぐには /etc/sudoers.d
に適当なファイル(my_setting
など)を作成し、以下の設定を追記する[5]。
Defaults env_keep+="HOME"
env_keep
は sudo
時に引き継がれる環境変数を指定する。権限は 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
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
FROM postgres:16.0
COPY initdb.d /docker-entrypoint-initdb.d
COPY scripts /scripts
#!/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
\set password `cat /settings/user_password.txt`
CREATE USER new_user1 WITH PASSWORD :'password';
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');
-- CSVファイルをコピーしてテーブルに挿入
\COPY weather FROM '/scripts/weather.csv' WITH CSV HEADER;
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
...
おしまい
バックアップとかレプリケーションも設定したかったけど記事が長くなったのでまた今度。
-
多分。そもそもデータベース用のコンテナを外部に直接公開することはないし、公開するとしてもせいぜい社内とか部内なのでクライアントを立てっぱなしにしても問題はないと思うが、内部の馬鹿がイタズラしたり、なんらかの手違いで外から見えちゃうこと(攻撃者が内部のネットワークにバックドアを仕込んでいてそれを踏み台にしている可能性)もありえる。攻撃者にとってはアクセスポイントが増えるほど攻撃の手がかりが増えることになるので、使わないアクセスポイントは閉じておくのが基本である。 ↩︎
-
コマンドごとにスクリプトファイルを作成する方法のほうが適切なのかもしれないが、私の脳が管理できるファイル数はあまり多くないのでファイル数が増えるのがあまり好きではない。そうしていたこともあるが、同じコンテナでも微妙に設定を変えた複数の起動方法を用意することがあり、起動方法ごとにスクリプトファイルを作成しているとあっという間にディレクトリが散らかって管理できなくなった。ChatGPT にも代替案を聞いたが
docker-compose
かmake
かスクリプトファイルでやれと言われたのでいいかなって。 ↩︎ -
もし複数のサービスでリソース名が被っていると、そもそも
docker-compose up
で起動できないか、よしんば構成がまったく同じでサービスの起動ができても複数のサービスでリソースが共有されてしまってサービスはバグるし、docker-compose down
でひとつのサービスを停止したときに他のサービスと共有されているリソースも破棄してしまう。かなりデストラクティブなことになるのでdocker-compose
はデフォルトでリソースの重複を回避するようにできている。 ↩︎ -
Docker Swarm や Kubernetes を使っているなら秘密情報を受け渡すための仕組みがあるようなのでそちらを使うべきである。 ↩︎
-
この設定は逆に
sudo
先のホームディレクトリで書き換えられることを想定しているすべてのプログラムに影響を与えるので注意する。他にも方法はあるので各自で調べて現行のシステムに影響の出ない方法で環境変数の引き継ぎを行うこと。 ↩︎ -
あるいは Read Only でホスト側のディレクトリをマウントしてもよい。うっかり書き込み権限を残したままマウントしてしまうと、脆弱性を突かれたときに
init.sh
に悪意のあるスクリプトを仕込まれるかもしれず、新たに作成されたコンテナがそのスクリプトを実行してしまうかもしれない。 ↩︎
Discussion