CUBE SUGAR CONTAINER

技術系のこと書きます。

dbt (data build tool) を使ってデータをテストする

ソフトウェアエンジニアリングの世界では、自動化されたテストを使ってコードの振る舞いを検証するのが当たり前になっている。 同じように、データエンジニアリングの世界でも、自動化されたテストを使ってデータの振る舞いを検証するのが望ましい。

データをテストするのに使える OSS のフレームワークも、いくつか存在する。 今回は、その中でも dbt (data build tool) を使ってデータをテストする方法について見ていく。 dbt 自体はデータのテストを主目的としたツールではないものの、テストに関する機能も備えている。

また、dbt には WebUI を備えたマネージドサービスとしての dbt Cloud と、CLI で操作するスタンドアロン版の dbt Core がある。 今回扱うのは後者の dbt Core になる。

使った環境は次のとおり。

$ sw_vers
ProductName:    macOS
ProductVersion: 12.2
BuildVersion:   21D49
$ uname -srm                       
Darwin 21.3.0 arm64
$ python -V          
Python 3.9.10
$ pip list | grep dbt
dbt-core                 1.0.1
dbt-extractor            0.4.0
dbt-postgres             1.0.1

もくじ

下準備

今回は、公開されているデータセットをローカルの PostgreSQL に取り込んで、それを dbt でテストしていく。

PostgreSQL をセットアップする

まずは Homebrew を使って PostgreSQL をインストールしよう。 また、データセットをダウンロードするために wget も入れておく。

$ brew install postgresql wget

PostgreSQL のサービスを起動する。

$ brew services start postgresql
 brew services list            
Name       Status  User    File
postgresql started amedama ~/Library/LaunchAgents/homebrew.mxcl.postgresql.plist

データセットとしては seaborn が利用している taxis データセットを使う。 ダウンロードして /tmp に保存する。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/taxis.csv -P /tmp

上記のデータセットに合う形でテーブルの定義を作る。

$ cat << 'EOF' | psql -d postgres
CREATE TABLE IF NOT EXISTS public.taxis (
  id SERIAL NOT NULL,
  pickup TIMESTAMP NOT NULL,
  dropoff TIMESTAMP NOT NULL,
  passengers INT NOT NULL,
  distance FLOAT NOT NULL,
  fare FLOAT NOT NULL,
  tip FLOAT NOT NULL,
  tolls FLOAT NOT NULL,
  total FLOAT NOT NULL,
  color TEXT NOT NULL,
  payment TEXT,
  pickup_zone TEXT,
  dropoff_zone TEXT,
  pickup_borough TEXT,
  dropoff_borough TEXT
);
EOF

先ほどダウンロードした CSV ファイルの内容を上記のテーブルに取り込む。

$ cat << 'EOF' | psql -d postgres
COPY public.taxis (
  pickup,
  dropoff,
  passengers,
  distance,
  fare,
  tip,
  tolls,
  total,
  color,
  payment,
  pickup_zone,
  dropoff_zone,
  pickup_borough,
  dropoff_borough
)
FROM '/tmp/taxis.csv'
WITH (
  FORMAT csv,
  HEADER true
)
EOF

また、データベースを操作するためのユーザ (ROLE) を alice という名前で追加しておく。

$ cat << 'EOF' | psql -d postgres
CREATE ROLE
  alice
WITH
  LOGIN
  PASSWORD 'wonderland'
EOF

ちなみに、ユーザにパスワードは設定してるけど、実は無くても問題はない。 Homebrew でインストールした場合、デフォルトでローカルからの接続が trust になっているため。 パスワードなしでも接続できる。

$ cat /opt/homebrew/var/postgres/pg_hba.conf | sed -e "/^#/d" -e "/^$/d"
local   all             all                                     trust
host    all             all             127.0.0.1/32            trust
host    all             all             ::1/128                 trust
local   replication     all                                     trust
host    replication     all             127.0.0.1/32            trust
host    replication     all             ::1/128                 trust

追加したユーザにテーブルを操作する権限をつけておく。

$ cat << 'EOF' | psql -d postgres
GRANT
  ALL PRIVILEGES
ON
  TABLE public.taxis
TO
  alice
EOF

次のように、ユーザとパスワードを指定してデータを見られることを確認しておく。

$ echo "SELECT * FROM public.taxis LIMIT 5" | psql -d postgres --user alice --password             
Password: 
 id |       pickup        |       dropoff       | passengers | distance | fare | tip  | tolls | total | color  |   payment   |      pickup_zone      |     dropoff_zone      | pickup_borough | dropoff_borough 
----+---------------------+---------------------+------------+----------+------+------+-------+-------+--------+-------------+-----------------------+-----------------------+----------------+-----------------
  1 | 2019-03-23 20:21:09 | 2019-03-23 20:27:24 |          1 |      1.6 |    7 | 2.15 |     0 | 12.95 | yellow | credit card | Lenox Hill West       | UN/Turtle Bay South   | Manhattan      | Manhattan
  2 | 2019-03-04 16:11:55 | 2019-03-04 16:19:00 |          1 |     0.79 |    5 |    0 |     0 |   9.3 | yellow | cash        | Upper West Side South | Upper West Side South | Manhattan      | Manhattan
  3 | 2019-03-27 17:53:01 | 2019-03-27 18:00:25 |          1 |     1.37 |  7.5 | 2.36 |     0 | 14.16 | yellow | credit card | Alphabet City         | West Village          | Manhattan      | Manhattan
  4 | 2019-03-10 01:23:59 | 2019-03-10 01:49:51 |          1 |      7.7 |   27 | 6.15 |     0 | 36.95 | yellow | credit card | Hudson Sq             | Yorkville West        | Manhattan      | Manhattan
  5 | 2019-03-30 13:27:42 | 2019-03-30 13:37:14 |          3 |     2.16 |    9 |  1.1 |     0 |  13.4 | yellow | credit card | Midtown East          | Yorkville West        | Manhattan      | Manhattan
(5 rows)

これでデータベースの準備は整った。

dbt をインストールする

続いては肝心の dbt をインストールする。 dbt は Python で開発されているので、pip を使ってインストールできる。

dbt では、接続するデータベースごとにアダプタと呼ばれるパッケージを切り替えて対応する。 たとえば PostgreSQL なら dbt-postgres というアダプタを使えば良い。 これが、もしバックエンドに BigQuery を使うなら dbt-bigquery を使うことになる。 いずれも、依存関係に本体の dbt-core が入っているので一緒にインストールできる。

$ pip install dbt-postgres

インストールすると dbt コマンドが使えるようになる。

$ dbt --version                                  
installed version: 1.0.1
   latest version: 1.0.1

Up to date!

Plugins:
  - postgres: 1.0.1

これで必要な下準備がすべて整った。

dbt からデータベースに接続する

まずは dbt からデータベースに接続する部分を確認していく。

dbt では dbt_project.yml という設定ファイルが必要になるので、まずは作る。 name はプロジェクト名、version はプロジェクトのバージョン番号、config-version は YAML のコンフィグ形式のバージョン番号を表している。 profile というのは、dbt がデータベースに接続するやり方のことをプロファイルと呼んでいて、それにつけた名前のこと。

$ cat << 'EOF' >> dbt_project.yml
name: taxis
version: 0.0.1
config-version: 2
profile: postgres_taxis
EOF

ちなみに dbt init コマンドを使えば一通りの設定ファイルとディレクトリ構造の入ったボイラープレートを展開することもできる。 ここらへんは好みで。

$ dbt init

次は、上記の設定ファイルで指定した postgres_taxis という名前のプロファイルを用意しよう。 データベースへの接続方法は、パスワードなど秘匿しておきたい情報も多い。 そのため、デフォルトではプロジェクトのディレクトリとは分離して、ホームディレクトリ以下を読むことになっている 1

~/.dbt というディレクトリに profiles.yml という名前で YAML ファイルを作る。 そして、postgres_taxis というプロファイルを定義する。 その下にある target では、デフォルトで使う接続先の環境を指定している。 これは、同じ用途のデータベースであっても、一般的には役割によって複数の環境を用意することになるため。 たとえば、本番 (prod)、ステージング (stg)、開発 (dev) といったように。 その中で、デフォルトで使用するものを指定している。 なお、接続先はコマンドラインオプションの --target で切り替えることができる。 target に指定した名前は、outputs 以下にある設定と対応する。 ここでは local という名前で設定した。 typepostgres を指定することで、アダプタとして dbt-postgres の実装が使われることになる。

$ mkdir -p ~/.dbt
$ cat << 'EOF' > ~/.dbt/profiles.yml                                             
postgres_taxis:
  target: local
  outputs:
    local:
      type: postgres
      threads: 1
      host: localhost
      port: 5432
      user: alice
      pass: wonderland
      dbname: postgres
      schema: dbt_alice
EOF

ちなみに、PostgreSQL ではテーブルの階層構造が <database>.<schema>.<table> という 3 層構造になっている。 上記で dbnameschema を指定しているのは、このため。 ただし、上記で指定している schema は、このプロファイルで使う作業用のスキーマの名前になっている。 つまり、アクセスできるスキーマがこれに限られるわけではない。

さて、プロファイルができたら、まずはデータベースへの接続が上手くいくことを確認しよう。 これには dbt debug コマンドを使う。

$ dbt debug
15:14:54  Running with dbt=1.0.1
dbt version: 1.0.1
python version: 3.9.10
python path: /Users/amedama/.virtualenvs/py39/bin/python
os info: macOS-12.2-arm64-arm-64bit
Using profiles.yml file at /Users/amedama/.dbt/profiles.yml
Using dbt_project.yml file at /Users/amedama/Documents/temporary/dbt-example/dbt_project.yml

Configuration:
  profiles.yml file [OK found and valid]
  dbt_project.yml file [OK found and valid]

Required dependencies:
 - git [OK found]

Connection:
  host: localhost
  port: 5432
  user: alice
  database: postgres
  schema: dbt_alice
  search_path: None
  keepalives_idle: 0
  sslmode: None
  Connection test: [OK connection ok]

All checks passed!

どうやら、無事に接続できたようだ。 データベースに接続できることを確認するのも、ある意味で健全性のテストと言えるかもしれない。

source freshness をテストする

さて、データベースに接続できることが分かった。 次の一手としては、すでにデータベースに取り込まれているテーブルをソース (source) として定義する。 ソースを定義しておくと、別の場所から参照できたり、それに対してテストが書けたりする。

テストの観点は色々とあるけど、まずは source freshness を確認してみよう。 これは、ソースの特定のカラムに含まれる最新のタイムスタンプが、現在時刻からどれくらい離れているかを検証するもの。 たとえば DWH へのデータの取り込みが何らかの理由で遅延したり、あるいは停止しているのを見つけるのに利用できる。

ソースを定義するには models というディレクトリを作って、そこに YAML の設定ファイルを追加する。 さっきも似たような作業があったと思うけど、基本的に dbt はユーザから見える部分のほとんどが YAML と SQL で成り立っている。 sources 以下に name でスキーマを指定して、その下に tables でテーブルを指定する。 以下であれば postgres.public.taxis という階層構造のテーブルを定義していることになる。 そして、その下にある freshnessloaded_at_field という項目で source freshness の設定をする。

$ mkdir -p models
$ cat << 'EOF' > models/taxis.yml
version: 2

sources:
  - name: public
    tables:
      - name: taxis
        freshness:
          warn_after:
            count: 1
            period: hour
          error_after:
            count: 1
            period: day
        loaded_at_field: pickup::timestamp
EOF

上記の設定では、pickup というカラムの最新の時刻が現在時刻から 1h 以上離れると警告、1d 以上離れるとエラーになる。 カラムの時刻は UTC を基準にする点に注意が必要。 つまり、JST を使っている場合には、UTC に変換する必要がある 2

設定できたら dbt source freshness コマンドを実行しよう。 これで、ソースに含まれる最新のタイムスタンプと現在時刻が比較される。

$ dbt source freshness
15:16:57  Running with dbt=1.0.1
15:16:57  Partial parse save file not found. Starting full parse.
15:16:57  Found 0 models, 0 tests, 0 snapshots, 0 analyses, 165 macros, 0 operations, 0 seed files, 1 source, 0 exposures, 0 metrics
15:16:57  
15:16:57  Concurrency: 1 threads (target='local')
15:16:57  
15:16:57  1 of 1 START freshness of public.taxis.......................................... [RUN]
15:16:57  1 of 1 ERROR STALE freshness of public.taxis.................................... [ERROR STALE in 0.02s]
15:16:57  
15:16:57  Done.

当たり前だけど、実行は失敗する。 サンプルデータのタイムスタンプは、最新のレコードでも 2019 年になっている。 1h とか 1d なんて単位ではない離れ方をしている。

$ echo "SELECT MAX(pickup), NOW() FROM public.taxis" | psql -d postgres

         max         |              now              
---------------------+-------------------------------
 2019-03-31 23:43:45 | 2022-02-04 00:26:35.521179+09
(1 row)

試しに現在時刻との乖離が小さなデータを追加してみよう。 時刻の計算に GNU date を使いたいので Homebrew を使って coreutils をインストールする。

$ brew install coreutils

pickup が現在時刻から 2h 前のレコードを追加する。 これなら実行したときエラーではなく警告になるはずだ。 他のカラムについては適当な値で埋めた。

$ PICKUP=$(TZ=UTC gdate "+%Y-%m-%d %H:%M:%S" --date '2 hour ago')
$ DROPOFF=$(TZ=UTC gdate "+%Y-%m-%d %H:%M:%S" --date '1 hour ago')
$ cat << EOF | psql -d postgres
INSERT INTO taxis (
  pickup,
  dropoff,
  passengers,
  distance,
  fare,
  tip,
  tolls,
  total,
  color,
  payment,
  pickup_zone,
  dropoff_zone,
  pickup_borough,
  dropoff_borough
) VALUES (
  '${PICKUP}',
  '${DROPOFF}',
  1,
  1.5,
  7,
  2.0,
  0,
  12.5,
  'yellow',
  'credit card',
  'Lenox Hill West',
  'UN/Turtle Bay South',
  'Manhattan',
  'Manhattan'
);
EOF

先ほどと同じコマンドを実行すると、今度はたしかに警告 (WARN) に変わっている。

$ dbt source freshness
15:31:35  Running with dbt=1.0.1
15:31:35  Found 0 models, 0 tests, 0 snapshots, 0 analyses, 165 macros, 0 operations, 0 seed files, 1 source, 0 exposures, 0 metrics
15:31:35  
15:31:35  Concurrency: 1 threads (target='local')
15:31:35  
15:31:35  1 of 1 START freshness of public.taxis.......................................... [RUN]
15:31:35  1 of 1 WARN freshness of public.taxis........................................... [WARN in 0.01s]
15:31:35  Done.

同じ要領で pickup と現在時刻の差が 1h 未満のデータを入れてみよう。

$ PICKUP=$(TZ=UTC gdate "+%Y-%m-%d %H:%M:%S" --date '15 min ago')
$ DROPOFF=$(TZ=UTC gdate "+%Y-%m-%d %H:%M:%S" --date '10 min ago')
$ cat << EOF | psql -d postgres
INSERT INTO taxis (
  pickup,
  dropoff,
  passengers,
  distance,
  fare,
  tip,
  tolls,
  total,
  color,
  payment,
  pickup_zone,
  dropoff_zone,
  pickup_borough,
  dropoff_borough
) VALUES (
  '${PICKUP}',
  '${DROPOFF}',
  3,
  2.16,
  9,
  1.1,
  0,
  13.4,
  'yellow',
  'cash',
  'Midtown East',
  'Yorkville West',
  'Manhattan',
  'Manhattan'
);
EOF

今度は実行が成功 (PASS) した。

$ dbt source freshness
15:33:36  Running with dbt=1.0.1
15:33:36  Found 0 models, 0 tests, 0 snapshots, 0 analyses, 165 macros, 0 operations, 0 seed files, 1 source, 0 exposures, 0 metrics
15:33:36  
15:33:36  Concurrency: 1 threads (target='local')
15:33:36  
15:33:36  1 of 1 START freshness of public.taxis.......................................... [RUN]
15:33:36  1 of 1 PASS freshness of public.taxis........................................... [PASS in 0.01s]
15:33:36  Done.

これで source freshness の確認ができるようになった。

generic test を使ってテストを書く

さて、続いてはデータの中身を見るテストを書いていこう。 dbt を使ってテストを書くやり方には generic test と singular test の 2 つがある。 まずは、より汎用性の高い generic test から見ていこう。

generic test を使うには YAML の設定を追加するだけで良い。 次の設定では、ソースの taxis テーブルに含まれるいくつかのカラムに対してテストを用意している。 それぞれの名前から内容はなんとなく分かるはずだけど、念の為に書いておくと次のようなルールになっている。

  • id カラムは一意で NULL の値がないこと
  • pickup カラムは NULL の値がないこと
  • color カラムは yellowgreen の値だけあること
$ cat << 'EOF' > models/taxis.yml
version: 2

sources:
  - name: public
    tables:
      - name: taxis
        columns:
          - name: id
            tests:
              - unique
              - not_null
          - name: pickup
            tests:
              - not_null
          - name: color
            tests:
              - accepted_values:
                  values: ['yellow', 'green']
EOF

なお、先ほど確認した source freshness の設定は、簡単のために上記の設定ファイルからは省いた。

設定ファイルを作ったら dbt test コマンドでテストを実行する。

$ dbt test
09:38:01  Running with dbt=1.0.1
09:38:01  Found 0 models, 4 tests, 0 snapshots, 0 analyses, 165 macros, 0 operations, 0 seed files, 1 source, 0 exposures, 0 metrics
09:38:01  
09:38:01  Concurrency: 1 threads (target='local')
09:38:01  
09:38:01  1 of 4 START test source_accepted_values_public_taxis_color__yellow__green...... [RUN]
09:38:01  1 of 4 PASS source_accepted_values_public_taxis_color__yellow__green............ [PASS in 0.03s]
09:38:01  2 of 4 START test source_not_null_public_taxis_id............................... [RUN]
09:38:01  2 of 4 PASS source_not_null_public_taxis_id..................................... [PASS in 0.01s]
09:38:01  3 of 4 START test source_not_null_public_taxis_pickup........................... [RUN]
09:38:01  3 of 4 PASS source_not_null_public_taxis_pickup................................. [PASS in 0.03s]
09:38:01  4 of 4 START test source_unique_public_taxis_id................................. [RUN]
09:38:01  4 of 4 PASS source_unique_public_taxis_id....................................... [PASS in 0.02s]
09:38:01  
09:38:01  Finished running 4 tests in 0.19s.
09:38:01  
09:38:01  Completed successfully
09:38:01  
09:38:01  Done. PASS=4 WARN=0 ERROR=0 SKIP=0 TOTAL=4

テストが実行されて成功 (PASS) した。

ちなみに、それぞれのテストケースは、いずれも SQL を使って実現されている。 先ほどの実行で、どのような SQL が発行されているかは、デフォルトで logs ディレクトリに生成されるログを読むと分かる。 テストは原則として「失敗するときに一致するレコードが出る SQL」になっている。 つまり、先ほどテストが成功したということは「発行した SQL に一致するレコードがなかった」ことを意味する。

また、上記ではソースに対してテストを書いたけど、モデルなど dbt に登場するその他のオブジェクトに対しても同じ要領でテストが書ける。 ただし、今回は簡単のためにソースに対してだけテストを書いていく。

テスト用マクロの入ったパッケージを利用する

先ほどはカラムの値が一意か NULL がないかといったテストを書いた。 ただ、dbt Core が組み込みで用意しているテスト用のマクロは多くない。 早々に「こういうテストが書きたいのに!」という場面が出てくる。 そんなときは、お目当てのテストに使えるマクロが入ったパッケージがないか探してみるのが良い。 dbt には dbt Hub というリポジトリに登録されているパッケージをインストールする仕組みがある。

たとえば dbt-utils というパッケージをインストールしてみよう。 このパッケージはテスト専用ではないものの、テストに使えるマクロがいくつか用意されている。 インストールしたいパッケージは packages.yml という名前の設定ファイルに書く。

$ cat << 'EOF' > packages.yml                
packages:
  - package: dbt-labs/dbt_utils
    version: 0.8.0
EOF

バージョンの指定は必須なので dbt Hub を見て記述する。

hub.getdbt.com

設定ファイルの用意ができたら dbt deps コマンドを実行する。

$ dbt deps

すると、デフォルトで dbt_packages というディレクトリにパッケージがインストールされる。

$ ls dbt_packages 
dbt_utils

このディレクトリには、パッケージに対応するリポジトリの内容がそのままダウンロードされている。 実にシンプルな仕組み。

$ ls dbt_packages/dbt_utils 
CHANGELOG.md        README.md       dbt_project.yml     etc         macros
LICENSE         RELEASE.md      docker-compose.yml  integration_tests   run_test.sh

パッケージをダウンロードできたら、実際に dbt-utils に含まれるテスト用のマクロを使ってみよう。 たとえば accepted_range というマクロを使うとカラムが取りうる値の範囲を指定できる。 以下では tip カラムの値が 0.0 ~ 33.2 になることを確認している。

$ cat << 'EOF' > models/taxis.yml
version: 2

sources:
  - name: public
    tables:
      - name: taxis
        columns:
          - name: tip
            tests:
              - dbt_utils.accepted_range:
                  min_value: 0.0
                  max_value: 33.2
                  inclusive: true
EOF

次のとおり tip カラムの値は 0.0 ~ 33.2 になっている。

$ echo "SELECT MAX(tip) AS max_tip, MIN(tip) AS min_tip FROM public.taxis" | psql -d postgres
 max_tip | min_tip 
---------+---------
    33.2 |       0
(1 row)

テストを実行してみよう。

$ dbt test
14:56:08  Running with dbt=1.0.1
14:56:08  Unable to do partial parsing because a project dependency has been added
14:56:09  Found 0 models, 1 test, 0 snapshots, 0 analyses, 352 macros, 0 operations, 0 seed files, 1 source, 0 exposures, 0 metrics
14:56:09  
14:56:09  Concurrency: 1 threads (target='local')
14:56:09  
14:56:09  1 of 1 START test dbt_utils_source_accepted_range_public_taxis_tip__True__33_2__0_0 [RUN]
14:56:09  1 of 1 PASS dbt_utils_source_accepted_range_public_taxis_tip__True__33_2__0_0... [PASS in 0.03s]
14:56:09  
14:56:09  Finished running 1 test in 0.08s.
14:56:09  
14:56:09  Completed successfully
14:56:09  
14:56:09  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1

ちゃんと成功した。

ちなみに、テスト用のマクロが色々と入っているパッケージとしては dbt-expectations というのがある。

hub.getdbt.com

custom generic test を定義する (引数なし)

探しても使えそうなマクロが見つからないときは、独自の generic test を自分で書くこともできる。 公式のドキュメントでは custom generic test と呼んでいる。

試しに、カラムの値が正の値かをテストするマクロを定義してみよう。 前述したとおり、書くのは「失敗するときに一致するレコードが出る SQL」となる。 ただし、純粋な SQL ではなくて Jinja2 というテンプレートエンジンの構文を使って書いていく。

custom generic test を定義するには、tests/generic ディレクトリ以下に SQL を記述する。 以下では is_positive という名前で定義しており、カラムの値が 0 未満のレコードを抽出する。

$ mkdir -p tests/generic 
$ cat << 'EOF' > tests/generic/is_positive.sql 
{% test is_positive(model, column_name) %}

select
  *
from
  {{ model }}
where
  {{ column_name }} < 0

{% endtest %}
EOF

上記のテストを使ってみよう。 passengers カラムの内容をチェックする。

$ cat << 'EOF' > models/taxis.yml
version: 2

sources:
  - name: public
    tables:
      - name: taxis
        columns:
          - name: passengers
            tests:
              - is_positive
EOF

$ dbt test

custom generic test を定義する (引数あり)

先ほどの custom generic test には、追加の引数がなかった。 次は追加の引数があるものを定義してみよう。

以下では、特定の値よりも大きな値がカラムに含まれないことを確認する custom generic test を定義している。 引数にはしきい値を表す value と、境界値を含むかを表したフラグの inclusive をつけている。

$ cat << 'EOF' > tests/generic/max.sql 
{% test max(model, column_name, value, inclusive=true) %}

select
  *
from
  {{ model }}
where
  {{ column_name }} > {{- "=" if inclusive }} {{ value }}

{% endtest %}
EOF

上記を使ってみよう。 以下では passengers の最大値が 6 であることを確認している。 なお、inclusive のデフォルト値は false で上書きしている。

$ cat << 'EOF' > models/taxis.yml
version: 2

sources:
  - name: public
    tables:
      - name: taxis
        columns:
          - name: passengers
            tests:
              - max:
                  value: 6
                  inclusive: false
EOF

テストを実行すると成功する。

$ dbt test
15:27:22  Running with dbt=1.0.1
15:27:22  Found 0 models, 1 test, 0 snapshots, 0 analyses, 354 macros, 0 operations, 0 seed files, 1 source, 0 exposures, 0 metrics
15:27:22  
15:27:22  Concurrency: 1 threads (target='local')
15:27:22  
15:27:22  1 of 1 START test source_max_public_taxis_passengers__False__6.................. [RUN]
15:27:22  1 of 1 PASS source_max_public_taxis_passengers__False__6........................ [PASS in 0.02s]
15:27:22  
15:27:22  Finished running 1 test in 0.06s.
15:27:22  
15:27:22  Completed successfully
15:27:22  
15:27:22  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1

ちなみに inclusive オプションを true にしたり、あるいは削ってデフォルト値にするとテストは失敗する。 これは passengers カラムの最大値が 6 のため。

$ echo "SELECT MAX(passengers) FROM public.taxis" | psql -d postgres 
 max 
-----
   6
(1 row)

singular test を使ってテストを書く

generic test は汎用的なテストをマクロとして定義した上で、それを色々な場所から YAML の設定で使うものだった。 一方で、特定の用途に特化したテストを書きたいときもあるはず。 そんなときは単発の SQL を実行するだけの singular test を使うと良い。

singular test を書くときは tests ディレクトリ以下に、直接 SQL のファイルを記述する。 以下では postgres.public.taxispassengers カラムに正の値しかないことを確認するテストを書いている。 ようするに、先ほど引数なしの custom generic test で検証した内容をベタ書きしているだけ。

$ cat << 'EOF' > tests/taxis_passengers_positive.sql 
select
  *
from
  {# sources に定義してあるテーブルの名前を取得できる #}
  {{ source('public', 'taxis') }}
where
  passengers < 0
EOF

純粋に singular test の結果だけ見たいので、先ほどの custom generic test は設定ファイルから削る。

$ cat << 'EOF' > models/taxis.yml                               
version: 2

sources:
  - name: public
    tables:
      - name: taxis
EOF

テストを実行してみよう。

$ dbt test
15:36:50  Running with dbt=1.0.1
15:36:50  Found 0 models, 1 test, 0 snapshots, 0 analyses, 354 macros, 0 operations, 0 seed files, 1 source, 0 exposures, 0 metrics
15:36:50  
15:36:50  Concurrency: 1 threads (target='local')
15:36:50  
15:36:50  1 of 1 START test taxis_passengers_positive..................................... [RUN]
15:36:50  1 of 1 PASS taxis_passengers_positive........................................... [PASS in 0.02s]
15:36:50  
15:36:50  Finished running 1 test in 0.06s.
15:36:50  
15:36:50  Completed successfully
15:36:50  
15:36:50  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1

ちゃんと実行されて成功したことがわかる。

まとめ

今回は dbt を使ってデータをテストする方法について書いた。

参考

docs.getdbt.com

docs.getdbt.com


  1. 変更したいときはコマンドラインオプションを使って場所を指定できる

  2. タイムゾーンを UTC に変換するやり方は公式ドキュメントでいくつか紹介されている

Linux の PID Namespace について

Linux のコンテナ仮想化を構成する機能の一つに Namespace (名前空間) がある。 Namespace は、カーネルのリソースを隔離して扱うための仕組みで、リソース毎に色々とある。 今回は、その中でも PID (Process Identifier) を隔離する PID Namespace を扱ってみる。

使った環境は次のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal
$ uname -rm
5.4.0-96-generic aarch64
$ unshare --version
unshare from util-linux 2.34
$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ ps --version
ps from procps-ng 3.3.16

PID と PID Namespace

まず、PID の前提について確認しておく。 PID は 1 から始まって、新しいプロセスができるたびにインクリメントされた整数がプロセスの識別子として付与される。 PID の数が割り当てられる上限に達したときは、また 1 に戻って空いている数字が割り当てられる。 たとえば Ubuntu 20.04 LTS であれば、上限は以下に設定されているようだ。

$ cat /proc/sys/kernel/pid_max 
4194304

プロセスが一意に識別できなくなるため、同じ PID を持ったプロセスが複数できることはない。 しかし、コンテナ仮想化においては、コンテナの中では独立した PID を見せたい。 そこで、PID Namespace によって、PID のリソースをシステムから隔離して扱うことができる。 言いかえると、コンテナの中で PID が 1 から始まるのは PID Namespace によって実現されている。

下準備

下準備として、あらかじめ unshare(1) のために util-linux をインストールしておく。 また、unshare(2) を呼び出すコードをビルドするために build-essential をインストールする。

$ sudo apt-get update
$ sudo apt-get install -y util-linux build-essential

unshare(1) を使って PID Namespace を使ってみる

まずはコマンドラインツールの unshare(1) から PID Namespace を使ってみよう。

unshare(1) を使って PID Namespace を新たに作るには --pid オプションを使う。 また、同時に --fork オプションと --mount-proc オプションもつけた方が良い。 この理由は後ほど説明する。 起動するプログラムとしては bash を指定しておこう。

$ sudo unshare --pid --fork --mount-proc bash

起動した bash では、PID が 1 になっていることがわかる。 また、ps(1) でも PID が 1 から振り直されていることが確認できる。 ちゃんとシステムから独立した PID が利用できているようだ。

# echo $$
1
# ps
    PID TTY          TIME CMD
      1 pts/0    00:00:00 bash
      8 pts/0    00:00:00 ps

さて、それでは先ほど --pid とは別で追加で指定したオプションについて見ていこう。 まずは --fork オプションから。 このオプションをつけないと何が起こるだろうか。 一旦、先ほど起動した bash は終了した上で、改めて unshare(1) を使おう。 今度は --pid オプションだけつける。

$ sudo unshare --pid bash
bash: fork: Cannot allocate memory

すると fork: Cannot allocate memory というエラーになってしまう。 とはいえ、一応エラーにはなりつつも bash は起動しているようだ。 しかし、何をするにしても同じ fork: Cannot allocate memory というエラーになってしまう。

# ls
bash: fork: Cannot allocate memory

このエラーの原因は、次の stackoverflow の質問に詳しい解説がある。

stackoverflow.com

かいつまんで説明すると、こういうことらしい。 まず、unshare(1) から起動した bash は、新たに作成した PID Namespace には所属しない。 代わりに、bash が最初に (fork(2) によって) 生成するサブプロセスが、新たに作成した PID Namespace に所属することになる。 新たに作成した PID Namespace では、PID が 1 から始まるため、bash のサブプロセスが PID 1 になる。 しかし、bash のサブプロセスは直後に終了するため、PID 1 のプロセスがいなくなる。 Linux において PID 1 のプロセスは特別な意味を持つことから、それがいなくなることで上記のエラーが生じているらしい。

一応、サブプロセスを起動しないタイプのシェルを利用すればエラーは出ない。 たとえば Ubuntu 20.04 LTS の sh は dash を使っているらしいので、指定してみよう。 しかし、その場合はそもそも起動したシェルが新しい PID Namespace に所属していないので何も意味がない。

$ sudo unshare --pid sh
# echo $$
1136

ということで --fork オプションが必要な理由がわかった。 続いては --mount-proc オプションについて。 今度はこのオプションを付けないで実行してみよう。

$ sudo unshare --pid --fork bash

一見すると何も問題なさそうに見えるけど、ps(1) なんかを呼び出すと随分と大きな数字が見える。 そもそも、隔離して見えないはずの unshare(1) の PID が見えているのはどうしたことか。

# ps
    PID TTY          TIME CMD
    959 pts/0    00:00:00 sudo
    960 pts/0    00:00:00 unshare
    961 pts/0    00:00:00 bash
    968 pts/0    00:00:00 ps

これは、/proc ファイルシステムが、PID Namespace を隔離する前の状態のままであることが原因。 要するにシステムの状態が見えたままということ。 Ubuntu 20.04 LTS の ps (=procps-ng) は /proc ファイルシステムを見ているので、さもありなん。

# ls /proc | egrep ^[0-9] | sort -n | tail -n 5
974
989
990
991
992

この状態は /proc ファイルシステムをマウントし直せば解消できる。

# mount -t proc proc /proc
# ls /proc | egrep ^[0-9] | sort -n | tail -n 5
1
20
21
22
23

つまり、これこそが --mount-proc オプションがやっていたこと、というわけだ。

ちなみに、上記のように新しいプロセスでマウントし直すやり方を取ると、そのままではマウントのプロパゲーションが起こってしまう。 この振る舞いについては以下のエントリで説明している。

blog.amedama.jp

要するに、上記のようなことをしたければ --mount オプションをつける必要がある。 あるいは、次のコマンドを使って事前にプロパゲーションを無効にしても良い。

$ sudo mount --make-private /

unshare(2) を使って PID Namespace を使ってみる

さて、続いては unshare(2) から PID Namespace を扱ってみよう。 ソースコードは、以下のコマンドと等価なものにする。

$ sudo unshare --pid --fork --mount-proc bash

早速だけどサンプルコードを以下に示す。 まず、unshare(2) で Mount Namespace と PID Namespace を新たに作成している。 その上で fork(2) で子プロセスを作っている。 この子プロセスが新しく作った PID Namespace に所属することになる。 その上で、マウントのプロパゲーションを無効にした上で /proc ファイルシステムをマウントし直している。 そして、最後にシェルを起動している。

#define _GNU_SOURCE

#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/mount.h>

int main(int argc, char *argv[]) {
    // Mount & PID Namespace を作成する
    if (unshare(CLONE_NEWPID | CLONE_NEWNS) != 0) {
        fprintf(stderr, "Failed to create a new PID namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // fork(2) で子プロセスを作る
    pid_t pid = fork();
    if (pid < 0) {
        fprintf(stderr, "Failed to fork a new process: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    if (pid != 0) {
        // 親プロセスは wait(2) で子プロセスの完了を待つ
        wait(NULL);
        exit(EXIT_SUCCESS);
    }

    // 以降は子プロセスの処理

    // ルート以下のマウントプロパゲーションを再帰的に無効にする
    if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) {
        fprintf(stderr, "cannot change root filesystem propagation: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // mount(2) で /proc ファイルシステムをマウントする
    if (mount("proc", "/proc", "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL) != 0) {
        fprintf(stderr, "Failed to mount /proc: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記に適当な名前をつけて保存したらビルドして実行する。

$ gcc -std=c11 -Wall example.c
$ sudo ./a.out

シェルの PID を確認すると、ちゃんと 1 になっている。

# echo $$
1

ps(1) を実行しても、PID がリセットされていることがわかる。

# ps
    PID TTY          TIME CMD
      1 pts/0    00:00:00 bash
      8 pts/0    00:00:00 ps

PID Namespace が異なっているのは /proc ファイルシステムの以下を見ても確認できる。

# ls -l /proc/self/ns/pid
lrwxrwxrwx 1 root root 0 Jan 23 01:29 /proc/self/ns/pid -> 'pid:[4026532130]'
# exit
$ ls -l /proc/self/ns/pid
lrwxrwxrwx 1 ubuntu ubuntu 0 Jan 23 01:29 /proc/self/ns/pid -> 'pid:[4026531836]'

いじょう。

参考

man7.org

man7.org

man7.org

C: glibc のバージョンをライブラリ関数・定数から取得する

今回は glibc のバージョンをライブラリ関数と定数から取得する方法について。 結論から先に述べると gnu_get_libc_version(3) か、定数の __GLIBC____GLIBC_MINOR__ から得られる。

使った環境は次のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal
$ uname -rm
5.4.0-92-generic aarch64
$ /lib/aarch64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.2) stable release version 2.31.
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 9.3.0.
libc ABIs: UNIQUE ABSOLUTE
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

もくじ

下準備

あらかじめ C のソースコードをビルドするのに必要なパッケージをインストールしておく。

$ sudo apt-get update
$ sudo apt-get -y install build-essential

ソースコード

早速だけど以下にサンプルコードを示す。 ライブラリ関数であれば gnu_get_libc_version(3) から、定数だと __GLIBC____GLIBC_MINOR__ でバージョンが得られる。 また、gnu_get_libc_release(3) にはリリース情報が入っている。

#include <stdio.h>
#include <stdlib.h>
#include <gnu/libc-version.h>

int main(int argc, char *argv[]) {
    printf("GNU libc version: %s\n", gnu_get_libc_version());
    printf("GNU libc release: %s\n", gnu_get_libc_release());
    printf("GNU libc version (constant): %d.%d\n", __GLIBC__, __GLIBC_MINOR__);
    return EXIT_SUCCESS;
}

上記に適当な名前をつけてビルドする。

$ gcc -std=c11 -Wall example.c

実行すると、次のとおりちゃんとバージョンが取得できている。

$ ./a.out 
GNU libc version: 2.31
GNU libc release: stable
GNU libc version (constant): 2.31

いじょう。

Linux の UTS Namespace について

Linux のコンテナ仮想化を構成する要素の 1 つに、カーネルの Namespace (名前空間) という機能がある。 Namespace には色々とあるけど、今回はホスト名と NIS (Network Information Service) 1 ドメイン名を隔離する仕組みを提供している UTS Namespace について扱ってみる。

具体的には、unshare(1) と unshare(2) を使ってホスト名が隔離される様子を観察する。 unshare(2) というのは Namespace を操作するためのシステムコール。 そして、unshare(1) は同名のシステムコールを利用したコマンドラインツールになっている。

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-92-generic aarch64
$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

もくじ

下準備

まずは、事前に必要なパッケージをインストールしておく。 unshare(1) を使うために util-linux を、C のコードをビルドするために build-essential を入れておく。

$ sudo apt-get update
$ sudo apt-get install -y util-linux build-essential

unshare(1) を使って UTS Namespace を使ってみる

まずは unshare(1) から UTS Namespace を使ってみよう。 あらかじめ、システムのホスト名と NIS ドメイン名を確認しておく。

$ hostname
focal
$ domainname
(none)

上記から、ホスト名は focal で、NIS ドメイン名は設定されていないことが分かる。

現在の Namespace の情報も確認しておこう。 procfs の内容から、今は 4026531838 という識別子の UTS Namespace であることが確認できる。

$ file /proc/$$/ns/uts
/proc/949/ns/uts: symbolic link to uts:[4026531838]

続いて、unshare(1) を使って新しく UTS Namespace を作って bash を立ち上げる。

$ sudo unshare --uts bash

procfs の内容から、UTS Namespace の識別子が 4026532128 に変わったことが分かる。

# file /proc/$$/ns/uts
/proc/1068/ns/uts: symbolic link to uts:[4026532128]

ここで hostname(1) と domainname(1) を使ってホスト名と NIS ドメイン名を変更してみよう。

# hostname host.example.com
# domainname example

次のとおり、ちゃんと変更された。

# hostname
host.example.com
# domainname
example

しかし、上記の変更はあくまで新しく作られた UTS Namespace 上での操作に過ぎないはず。 exit して、元のシェルに戻ってみよう。

# exit
exit

UTS Namespace の識別子は、もちろん元に戻る。

$ file /proc/$$/ns/uts
/proc/949/ns/uts: symbolic link to uts:[4026531838]

ホスト名と NIS ドメイン名も元に戻っている。 つまり、ホスト名と NIS ドメイン名は、ちゃんと UTS Namespace によって隔離されていた。

$ hostname
focal
$ domainname
(none)

unshare(2) を使って UTS Namespace を使ってみる

続いては unshare(2) を使って UTS Namespace を使ってみよう。 今度は C のコードを使うことになるけど、長くなるのでホスト名とドメイン名で分けることにする。 といっても、両者は呼び出すシステムコールが {set,get}hostname(2) になるか {set,get}domainname(2) になるか位しか違いはない。

ホスト名を変更する

まずはホスト名から。 コメントに処理の説明は書いてあるけど、unshare(1) で UTS Namespace を新しく作ってから sethostname(2) と gethostname(2) を発行している。 そして、最終的には bash を起動している。

#define _GNU_SOURCE
  
#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    // UTS Namespace を作成する
    if (unshare(CLONE_NEWUTS) != 0) {
        fprintf(stderr, "Failed to create a new UTS namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // sethostname(2) でホスト名を変更する
    char const hostname[] = "host.example.com";
    if (sethostname(hostname, strlen(hostname)) != 0) {
        fprintf(stderr, "Failed to set hostname: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // gethostname(2) でホスト名を確認する
    char hostname_buf[254];
    if (gethostname(hostname_buf, sizeof(hostname_buf) / sizeof(hostname_buf[0])) != 0) {
        fprintf(stderr, "Failed to get hostname: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    printf("gethostname: %s\n", hostname_buf);

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記をコンパイルする。

$ gcc -Wall example.c 

できたバイナリを実行する。

$ sudo ./a.out 
gethostname: host.example.com

sethostname(2) で変更されたホスト名が gethostname(2) で取得できている。

また、立ち上がったシェルを hostname(1) を発行しても、ちゃんとホスト名が変更されていることがわかる。

# hostname
host.example.com

procfs で確認できる UTS Namespace の識別子もちゃんと変わっている。 どうも識別子は使い回されてるっぽいけど。

# file /proc/$$/ns/uts
/proc/1288/ns/uts: symbolic link to uts:[4026532128]

もちろん、シェルから抜ければホスト名は元に戻る。

# exit
exit
$ hostname
focal

NIS ドメイン名を変更する

同様に NIS ドメイン名でも試してみる。 基本的にさっきのコードで発行するシステムコールが setdomainname(2) と getdomainname(2) に変わっただけ。

#define _GNU_SOURCE
  
#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    // UTS Namespace を作成する
    if (unshare(CLONE_NEWUTS) != 0) {
        fprintf(stderr, "Failed to create a new UTS namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // setdomainname(2) でNIS ドメイン名を変更する
    char const hostname[] = "example";
    if (setdomainname(hostname, strlen(hostname)) != 0) {
        fprintf(stderr, "Failed to set NIS domain name: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // getdomainname(2) で NIS ドメイン名を確認する
    char hostname_buf[64];
    if (getdomainname(hostname_buf, sizeof(hostname_buf) / sizeof(hostname_buf[0])) != 0) {
        fprintf(stderr, "Failed to get NIS domain name: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    printf("getdomainname: %s\n", hostname_buf);

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記をコンパイルする。

$ gcc -Wall example.c 

できたバイナリを実行する。

$ sudo ./a.out 
getdomainname: example

ちゃんと NIS ドメイン名が変更されている。

立ち上がったシェルで domainnname(1) を実行しても、変更されていることがわかる。

# domainname
example

procfs で確認できる UTS Namespace の識別子もちゃんと変わっている。

# file /proc/$$/ns/uts
/proc/1338/ns/uts: symbolic link to uts:[4026532128]

もちろん、シェルから抜ければホスト名は元に戻る。

# exit
exit
$ domainname
(none)

ばっちり。

まとめ

今回は UTS Namespace を使ってホスト名と NIS ドメイン名の変更が隔離される様子を観察した。


  1. 使ったことがないけどディレクトリサービスの一種らしい

Linux: fork(2) で子プロセスの終了理由を判定する

今回は fork(2) で子プロセスの終了理由を判定してみる。 結論から先に述べると、子プロセスの終了を待つとき wait(2) に int のポインタを渡すと終了理由をセットしてくれる。 それをマクロで判定していけば良い。

linuxjm.osdn.jp

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-91-generic aarch64

もくじ

下準備

あらかじめ C のビルドに必要な build-essential をインストールしておく。

$ sudo apt-get update
$ sudo apt-get install -y build-essential

子プロセスの終了理由を判定してみる

以下に、子プロセスの終了理由を「exit(3)」「シグナル」「その他」に判定するサンプルコードを示す。 前述したとおり、親プロセスが wait(2) で子プロセスの終了を待つときに、引数として int のポインタを渡してやる。 すると、終了理由を示す数値がセットされるので、それをマクロで判定してやる。 子プロセスでは bash を起動している。

#define _GNU_SOURCE

#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

int main(int argc, char *argv[]) {
    // プロセスを fork(2) でフォークする
    pid_t pid = fork();
    if (pid < 0) {
        fprintf(stderr, "Failed to fork a new process: %s\n", strerror(errno));
        exit(-1);
    }

    if (pid != 0) {
        // PID が非ゼロは親プロセス
        // wait(2) で子プロセスの完了を待つ
        int status;
        wait(&status);

        // 子プロセスの終了の仕方を出力する
        if (WIFEXITED(status)) {
            // WIFEXITED() が非ゼロなら exit(3) による終了
            // WEXITSTATUS() で終了コードが得られる 
            printf("exit(3), status=%d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            // WIFSIGNALED() が非ゼロならシグナルによる終了
            // WTERMSIG() でシグナル番号が得られる
            printf("signal, sig=%d\n", WTERMSIG(status));
        } else {
            // それ以外の終了
            printf("aborted");
        }

        exit(EXIT_SUCCESS);
    }

    // 以降は子プロセスでの処理

    // 子プロセスでシェル (Bash) を起動する
    char* const exec_argv[] = {"bash", NULL};
    if (execvp(exec_argv[0], exec_argv) < 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", exec_argv[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

ちなみに、理由を知る必要がないときは wait(2) の引数に NULL を指定すれば良い。

上記をコンパイルする。

$ gcc -Wall example.c 

現在の PID は 927 だった。

$ echo $$
927

先ほどコンパイルしたバイナリを実行する。

$ ./a.out

すると、fork(2) した子プロセスで新たに bash が起動する。

$ echo $$
1135

試しに組み込みの exit コマンドでプロセスを終了してみよう。 ステータスコードには適当に 10 を指定する。

$ exit 10
exit
exit(3), status=10

上記のとおり、ちゃんと WIFEXITED のブロックに入ってステータスコードが得られた。

続いてはシグナルで終了させてみよう。

$ echo $$
927
$ ./a.out
$ echo $$
1536

子プロセスに KILL シグナルを送る。

$ kill -KILL 1536
signal, sig=9

上記のとおり、ちゃんと WIFSIGNALED のブロックに入ってシグナルによる終了と判定された。

いじょう。 それ以外のパターンは、どう確認すれば良いのかな。

Linux: util-linux を gdb でデバッグする

util-linux に含まれるコマンドの振る舞いを動的に解析したい場面があったので、手順を書き残しておく。

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-91-generic aarch64

下準備

まずは、システムにインストールされている util-linux のバージョンを調べておく。 今回使った環境では 2.34 らしい。

$ dpkg -l | grep util-linux
ii  util-linux                     2.34-0.1ubuntu9.1                     arm64        miscellaneous system utilities

ただし、システムに入っているものはリリース版なので、シンボルなどが削除されてしまっている。

$ file $(which unshare)
/usr/bin/unshare: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=3c2afa819eb8040c947103fe980e2d88acc92c2b, for GNU/Linux 3.7.0, stripped

そこで、自分でソースコードを落としてきてビルドする。 まずはビルドに必要なパッケージ一式を入れる。 また、後ほど使うデバッガとして gdb も入れておく。

$ sudo apt-get -y install build-essential gdb

続いては肝心の util-linux の tarball をダウンロードする。

$ wget -O - https://mirrors.edge.kernel.org/pub/linux/utils/util-linux/v2.34/util-linux-2.34.tar.gz | tar zxvf -

ビルドする。

$ cd util-linux-2.34/
$ ./configure && make

これで util-linux に含まれている諸々がビルドできた。

$ ls
ABOUT-NLS         addpart          chrt           ctrlaltdel  fsck        ipcs          libmount.la      lscpu       mkfs.minix  readprofile   sfdisk       tools
AUTHORS           agetty           col            delpart     fsck.minix  isosize       libsmartcols     lsipc       mkswap      rename        stamp-h1     umount
COPYING           autogen.sh       colcrt         disk-utils  fsfreeze    kill          libsmartcols.la  lslocks     mount       renice        sulogin      unshare
ChangeLog         bash-completion  colrm          dmesg       fstrim      last          libtcolors.la    lslogins    mountpoint  resizepart    swaplabel    utmpdump
Documentation     blkdiscard       column         eject       getopt      ldattach      libtool          lsmem       namei       rev           swapoff      uuidd
Makefile          blkid            config         fallocate   hardlink    lib           libuuid          lsns        nologin     rfkill        swapon       uuidgen
Makefile.am       blkzone          config.h       fdformat    hexdump     libblkid      libuuid.la       m4          nsenter     rtcwake       switch_root  uuidparse
Makefile.in       blockdev         config.h.in    fdisk       hwclock     libblkid.la   logger           mcookie     partx       schedutils    sys-utils    wall
NEWS              cal              config.log     fincore     include     libcommon.la  login-utils      mesg        pivot_root  script        taskset      wdctl
README            chcpu            config.status  findfs      ionice      libfdisk      look             misc-utils  po          scriptreplay  term-utils   whereis
README.licensing  chmem            configure      findmnt     ipcmk       libfdisk.la   losetup          mkfs        prlimit     setarch       tests        wipefs
aclocal.m4        choom            configure.ac   flock       ipcrm       libmount      lsblk            mkfs.bfs    raw         setsid        text-utils   zramctl

中身を見ると、ちゃんとシンボルなどが残っている。

$ file unshare
unshare: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=a70bad2506d9972f359c141b679d33fdfe03e58d, for GNU/Linux 3.7.0, with debug_info, not stripped

動作することも確認しておこう。 試しに unshare(1) を実行する。 以下では、PID Namespace を新たに作っている。

$ sudo ./unshare --fork --pid --mount-proc bash

新たに起動したシェルで確認すると、ちゃんと PID が 1 からリセットされている。

# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.3   8592  3528 pts/0    S    16:54   0:00 bash
root           8  0.0  0.2  10188  2816 pts/0    R+   16:54   0:00 ps aux

動作確認が終わったら終了しておこう。

# exit

gdb でデバッグする

動作確認ができたので、続いては gdb を使って動的解析する。 まずは gdb 経由で unshare(1) を起動する。

$ sudo gdb ./unshare

ブレークポイントとして、とりあえず適当にメイン関数を設定する。 ここはまあご自由に。

(gdb) break main
Breakpoint 1 at 0x2940: file sys-utils/unshare.c, line 288.

ちなみに、デバッグ途中で fork(2) する場合には、親プロセスと子プロセスのどちらを追跡するか設定しておく必要がある。 ここでは子プロセスを追跡する場合。

(gdb) set follow-fork-mode child

設定が終わったらコマンドライン引数を渡して実行する。 設定したブレークポイントまで処理が進むはず。

gdb) run --fork --pid --mount-proc bash
Starting program: /home/ubuntu/util-linux-2.34/unshare --fork --pid --mount-proc bash

Breakpoint 1, main (argc=5, argv=0xfffffffff638) at sys-utils/unshare.c:288
288    {

次のように、ちゃんとメイン関数でブレークしている。

(gdb) l
283    
284        exit(EXIT_SUCCESS);
285    }
286    
287    int main(int argc, char *argv[])
288    {
289        enum {
290            OPT_MOUNTPROC = CHAR_MAX + 1,
291            OPT_PROPAGATION,
292            OPT_SETGROUPS,

あとは通常の gdb のやり方で調べていけば良い。

いじょう。

PostgreSQL のテーブルに CSV でデータを読み込む

今回は PostgreSQL のテーブルに CSV ファイル経由でデータを読み込む方法について。 ちょくちょくやり方を調べている気がするのでメモしておく。

使った環境は次のとおり。

$ sw_vers
ProductName:    macOS
ProductVersion: 12.1
BuildVersion:   21C52
$ uname -rm
21.2.0 arm64
$ psql --version           
psql (PostgreSQL) 14.1

もくじ

下準備

まずは PostgreSQL をインストールする。 ついでに、CSV をダウンロードするために wget も入れる。

$ brew install postgresql wget

PostgreSQL のサービスを開始する。

$ brew services start postgresql
$ brew services list
Name       Status  User    File
postgresql started amedama ~/Library/LaunchAgents/homebrew.mxcl.postgresql.plist

今回は Seaborn に同梱されている Taxis データセットを使う。 あらかじめ CSV ファイルをダウンロードしておく。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/taxis.csv -P /tmp
$ head -n 5 /tmp/taxis.csv
pickup,dropoff,passengers,distance,fare,tip,tolls,total,color,payment,pickup_zone,dropoff_zone,pickup_borough,dropoff_borough
2019-03-23 20:21:09,2019-03-23 20:27:24,1,1.6,7.0,2.15,0.0,12.95,yellow,credit card,Lenox Hill West,UN/Turtle Bay South,Manhattan,Manhattan
2019-03-04 16:11:55,2019-03-04 16:19:00,1,0.79,5.0,0.0,0.0,9.3,yellow,cash,Upper West Side South,Upper West Side South,Manhattan,Manhattan
2019-03-27 17:53:01,2019-03-27 18:00:25,1,1.37,7.5,2.36,0.0,14.16,yellow,credit card,Alphabet City,West Village,Manhattan,Manhattan
2019-03-10 01:23:59,2019-03-10 01:49:51,1,7.7,27.0,6.15,0.0,36.95,yellow,credit card,Hudson Sq,Yorkville West,Manhattan,Manhattan

続いて、上記のデータに適合するテーブルを用意する。 データベースは、デフォルトで postgres が作られている。

$ cat << 'EOF' | psql postgres
CREATE TABLE IF NOT EXISTS taxis (
  pickup TIMESTAMP,
  dropoff TIMESTAMP,
  passengers INT,
  distance FLOAT,
  fare FLOAT,
  tip FLOAT,
  tolls FLOAT,
  total FLOAT,
  color TEXT,
  payment TEXT,
  pickup_zone TEXT,
  dropoff_zone TEXT,
  pickup_borough TEXT,
  dropoff_borough TEXT
);
EOF
CREATE TABLE

CSV ファイルを読み込む

CSV ファイルのインポートには COPY を使う。 これは PostgreSQL 独自の文なので、使い方はドキュメントを参照する。

www.postgresql.jp

実際に、COPY を使って taxis テーブルに /tmp/taxis.csv ファイルを読み込む。 CSV ファイルは先頭行がヘッダになっているので、読み飛ばす設定を WITH 以下に入れる。

$ cat << 'EOF' | psql postgres
COPY taxis
FROM '/tmp/taxis.csv'
WITH (
  FORMAT csv,
  HEADER true
)
EOF

以下のとおり、確認するとファイルの内容が読み込まれていることがわかる。

$ cat << 'EOF' | psql postgres                               
\pset pager off
SELECT * FROM taxis LIMIT 5
EOF
Pager usage is off.
       pickup        |       dropoff       | passengers | distance | fare | tip  | tolls | total | color  |   payment   |      pickup_zone      |     dropoff_zone      | pickup_borough | dropoff_borough 
---------------------+---------------------+------------+----------+------+------+-------+-------+--------+-------------+-----------------------+-----------------------+----------------+-----------------
 2019-03-23 20:21:09 | 2019-03-23 20:27:24 |          1 |      1.6 |    7 | 2.15 |     0 | 12.95 | yellow | credit card | Lenox Hill West       | UN/Turtle Bay South   | Manhattan      | Manhattan
 2019-03-04 16:11:55 | 2019-03-04 16:19:00 |          1 |     0.79 |    5 |    0 |     0 |   9.3 | yellow | cash        | Upper West Side South | Upper West Side South | Manhattan      | Manhattan
 2019-03-27 17:53:01 | 2019-03-27 18:00:25 |          1 |     1.37 |  7.5 | 2.36 |     0 | 14.16 | yellow | credit card | Alphabet City         | West Village          | Manhattan      | Manhattan
 2019-03-10 01:23:59 | 2019-03-10 01:49:51 |          1 |      7.7 |   27 | 6.15 |     0 | 36.95 | yellow | credit card | Hudson Sq             | Yorkville West        | Manhattan      | Manhattan
 2019-03-30 13:27:42 | 2019-03-30 13:37:14 |          3 |     2.16 |    9 |  1.1 |     0 |  13.4 | yellow | credit card | Midtown East          | Yorkville West        | Manhattan      | Manhattan
(5 rows)

いじょう。