CUBE SUGAR CONTAINER

技術系のこと書きます。

データベーススペシャリスト試験に合格した

今回 2023 年秋期のデータベーススペシャリスト試験を受験して合格できた。 後から振り返ることができるように、受験に関する諸々について書き留めておく。

www.ipa.go.jp

受験のモチベーションとしては、データベースについて漠然とした知識の不足を感じていたため。 試験の勉強を通して、その不足を補いたい気持ちがあった。 また、情報処理安全確保支援士試験とネットワークスペシャリスト試験に合格していたことも影響している。 データベーススペシャリスト試験に合格すれば、午後が記述式の高度試験が全て揃う状況だった 1

もくじ

試験内容について

データベーススペシャリスト試験の内容は午前 I, II と午後 I, II の 4 つに分かれている。 すべての内容で基準点 (60 / 100 点) を満たせば合格になる。

今回の受験におけるスコアは次のとおり。 午後 I と II が、どちらもギリギリだったことが分かる。

データベーススペシャリスト試験のスコア

午前 I と II については四択の選択問題で、午後 I と II が記述問題になっている。 午前 I は他のスキルレベル 4 の試験と共通のため、午前 II 以降が試験で独自の問題になる。

午前 I については、別のスキルレベル 4 の試験で基準点以上を取っているか、応用情報技術者試験に合格している場合、2 年以内は申請すれば受験が免除される。 今回のケースでは、2023 年の春期にネットワークスペシャリスト試験に合格していたため、申請して受験が免除された。

勉強方法について

情報処理技術者試験において、午前の選択問題は一定の割合で過去に出題された問題がほとんどそのまま出題される。 そのため、ひたすら過去問を解くだけで対策できる。 今回に関しても、直近の数年分を順番に解いていった。 そして 80% 程度が安定して取れるようになった時点で午前の対策を完了とした。

午後問題も、基本的には過去問を解いて対策した。 なお、データベーススペシャリスト試験の午後問題には、午後 I と午後 II の両方で必ず出題されるジャンルがある。 それが「概念データモデルと関係スキーマ」という問題で、受験者からは「お絵かき問題」という通称で呼ばれることがある。 お絵かき問題と呼ばれる所以は、未完成の状態で与えられる概念データモデルの図に線を書き入れて完成させるため。

そして、過去問を解いていくと、どうやら自分が「お絵かき問題」を苦手としていることが分かってきた。 より具体的には、正解を導くことはできても時間がかかってしまう。 この点は、特に午後 I において問題となった。 なぜなら、午後 I はとにかく時間との戦いになるため。 午後 I は 90 分で大問 3 問から 2 問を選んで解くため、1 問あたり 45 分しか使えない。 分からない部分があっても、時間配分を決めてある程度は見切りをつけていかないと、最終的に全く時間が足りなくなる。

午後の採点において、空欄は確実にゼロ点になるとしても、何かそれっぽいことさえ書いてあれば多少の部分点は期待できる。 そのため、途中までの回答が完璧でも、時間を使い果たして残りの空欄が目立つような事態は避けなければいけない。 午後 II は、1 問あたりの文章量は増えるものの 120 分で大問 2 問から 1 問を選んで解くため、時間の制約は相対的に緩くなる。

直近数年の傾向において「お絵かき問題」は午後 I で 1 問、午後 2 で 1 問が出題されていた。 つまり、いずれにおいても解かずに済ませることは可能だった。 そういった背景から、本番において「お絵かき問題」は選ばないことをあらかじめ決めた。

この選択をするリスクは、スコアが出題内容に大きく依存する点にあった。 なぜなら、お絵かき問題以外には、それほど明確な出題傾向が見られないため。 とはいえ「お絵かき問題」の回答スピードを短期間で上げることは難しいと判断してリスクは受容することにした。 私見ながら、データベーススペシャリスト試験は、他の試験よりも事前に必要とされる知識の量は相対的に少なく感じる。 その代わりに、長文から素早く正解を読み取る力が試される。

なお、受験した 2023 年の秋期試験においては、午後 I でお絵かき問題が問 1 と問 2 の 2 問で出題された。 つまり、そもそもの前提が崩れて午後 I ではお絵かき問題を最低 1 問は選ぶ必要に迫られた。 なお、問 3 に関しては比較的得意とする SQL によるデータ分析を扱う出題だったため、なんとか命拾いした感じだった。

その他の勉強としては、自分の中で理解が足りていない部分などを把握するために以下の書籍を購入して読んだ。 ただ、受験を済ませた上での感想としては、あまり内容が十分とは言えないように思う。 この点は、受験対策という観点と、リレーショナルデータベースを扱う上での知識という観点の、どちらにも当てはまる。

所感について

データベーススペシャリスト試験の出題傾向において、直近ではデータ基盤や SQL によるデータ分析を問う出題が増えている。 この点は、以前よりもアナリスト系の職種が取得を目指しやすくなったかもしれない。 特に、日頃から SQL を業務で使っている場合には、SQL に関する問いはボーナス問題になる 2

なお、自分自身に関して言えば「データベーススペシャリスト」という名称には実力が全く追いついていないように感じている。 この点は、試験とは別枠で今後も勉強していきたい。


  1. エンベデッドスペシャリスト試験は 2023 年の秋期から午後 II が論述式に変更された
  2. ウィンドウ関数や JOIN の種類を問う問題が頻出する

Polars と PySpark / スケールアップとスケールアウト

これは Polars Advent Calendar 2023 の 4 日目に対応したエントリです。

qiita.com


Polars と PySpark は操作方法が似ていると言われることがある。 そこで、今回はいくつかの基本的な操作について、実際に両者でコードを比較してみた。 また、それぞれの思想的な違いなどについても私見を述べる。

最初に書いたとおり、これは Polars のアドベントカレンダーのために書かれたエントリになる。 そのため、まずは簡単に PySpark 自体の説明をしておきたい。

PySpark というのは、Apache Spark という分散処理フレームワークを Python から操作するためのインターフェイスになる。 Apache Spark 自体は Scala で書かれているため、py4j というライブラリを使って Python バインディングを提供している。

Polars と比較対象になることからも分かるように、PySpark にもデータフレームの API が存在している。 ただし、データフレームは最初からあったわけではなく、バージョン 1.3 から追加された。 Apache Spark は RDD (Resilient Distributed Dataset; 耐障害性分散データセット) と呼ばれるデータ構造を動作の基本的な単位としている。 そのため、RDD という低レベル API に対する高レベル API としてデータフレームも作られている。

なお、Apache Spark は、あくまで複数台の計算機で構成されたクラスタ (以下、計算機クラスタ) の上で分散処理をするための仕組みに過ぎない。 そのため、計算機クラスタを用意して、それらのリソースを管理する部分は別でやる必要がある。 計算機クラスタのリソースを管理する部分は、次のような選択肢がある 1

  • Standalone Server 2
  • Hadoop YARN
  • Kubernetes

なお、上記の仕組みを使わずに、シングルノード (ローカルモード) で利用することもできる。 そのため、今回のエントリもローカルモードを使って検証していく。 ただし、シングルノードでの利用は、テストを目的とする以外にはほとんどメリットがない。 また、ローカルモードで動作したコードが分散処理させたときに動かないパターンもあるので注意が必要になる 3

もくじ

思想の違いについて

Polars と PySpark の思想の違いは、タイトルにもあるとおりスケールアップとスケールアウトのアプローチで説明できる。

PySpark は、大きなデータであっても複数の計算機が分担しながら処理をする。 それぞれの計算機が担当するのは元のデータの一部分なので、個々の計算機が持つ CPU コアやメモリの上限に影響を受けにくい (スケールアウト)。 一方で、事前に計算機クラスタを用意する必要があったり、分散処理のオーバーヘッド 4 が存在するといったデメリットもある。

Polars は、それ単体では基本的にシングルノードでしか処理ができない。 そのため、大きなデータを扱うときは、相応に CPU コアやメモリをたくさん積んだ計算機を使うことになる (スケールアップ)。 事前の準備が簡単で、処理のオーバーヘッドも少ない一方、処理できるデータの規模については劣る 5

とはいえ、本当に Apache Spark のような分散処理をしなければ対応できないデータが世の中にどれだけあるのか、という話もある。 Polars や DuckDB といった、シングルノードでの分析におけるスケーラビリティを改善するソフトウェアが台頭しつつあるのは、その流れを反映してのことだろう。

そして、Polars と PySpark は「どちらを使うか」という二者択一とは限らない。 たとえば PySpark で記述した分散処理において、個々の計算機で実行する処理の中身が Polars になっている、というパターンもありうる。 特に UDF (User Defined Function) を書くような場面では、分散処理していても個々の計算機の CPU 資源を有効に使えていない、というケースは十分に考えられる。

下準備

前置きが長くなってしまったけど、ここからは実際に Polars と PySpark を使っていく。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-89-generic x86_64
$ python -V
Python 3.10.12
$ pip list | egrep "(polars|pyspark|pandas|scikit-learn)"
pandas          2.1.3
polars          0.19.18
pyspark         3.5.0
scikit-learn    1.3.2

まずは必要なパッケージをインストールする。 前述したとおり Apache Spark は Scala で書かれているため、PySpark の動作には Java のランタイムが必要になる。

$ sudo apt-get install python3-venv openjdk-8-jdk

Python の仮想環境を用意してライブラリをインストールする。

$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install "polars[pyarrow]" pyspark pandas scikit-learn

Python のインタプリタを起動する。

(venv) $ python

あらかじめ scikit-learn を使って OpenML から適当なデータセットをダウンロードしておく。 ここでは Diamonds データセットにした。 この段階では Pandas のデータフレームとして読み込んでいる。

>>> from sklearn.datasets import fetch_openml
>>> df_pandas, _ = fetch_openml(
...     "diamonds",
...     version=1,
...     as_frame=True,
...     return_X_y=True,
...     parser="pandas"
... )
>>> df_pandas.head()
   carat      cut color clarity  depth  table     x     y     z
0   0.23    Ideal     E     SI2   61.5   55.0  3.95  3.98  2.43
1   0.21  Premium     E     SI1   59.8   61.0  3.89  3.84  2.31
2   0.23     Good     E     VS1   56.9   65.0  4.05  4.07  2.31
3   0.29  Premium     I     VS2   62.4   58.0  4.20  4.23  2.63
4   0.31     Good     J     SI2   63.3   58.0  4.34  4.35  2.75

そして、Pandas のデータフレームを、それぞれのフレームワークのデータフレームに変換しておこう 6

Polars

Polars では polars.from_pandas() という関数を使うことで、Pandas のデータフレームから Polars のデータフレームに変換できる。

>>> import polars as pl
>>> df_polars = pl.from_pandas(df_pandas)
>>> df_polars
shape: (53940, 9)
┌───────┬───────────┬───────┬─────────┬───┬───────┬──────┬──────┬──────┐
│ carat ┆ cut       ┆ color ┆ clarity ┆ … ┆ table ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---       ┆ ---   ┆ ---     ┆   ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ cat       ┆ cat   ┆ cat     ┆   ┆ f64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═══════════╪═══════╪═════════╪═══╪═══════╪══════╪══════╪══════╡
│ 0.23  ┆ Ideal     ┆ E     ┆ SI2     ┆ … ┆ 55.03.953.982.43 │
│ 0.21  ┆ Premium   ┆ E     ┆ SI1     ┆ … ┆ 61.03.893.842.31 │
│ 0.23  ┆ Good      ┆ E     ┆ VS1     ┆ … ┆ 65.04.054.072.31 │
│ 0.29  ┆ Premium   ┆ I     ┆ VS2     ┆ … ┆ 58.04.24.232.63 │
│ …     ┆ …         ┆ …     ┆ …       ┆ … ┆ …     ┆ …    ┆ …    ┆ …    │
│ 0.72  ┆ Good      ┆ D     ┆ SI1     ┆ … ┆ 55.05.695.753.61 │
│ 0.7   ┆ Very Good ┆ D     ┆ SI1     ┆ … ┆ 60.05.665.683.56 │
│ 0.86  ┆ Premium   ┆ H     ┆ SI2     ┆ … ┆ 58.06.156.123.74 │
│ 0.75  ┆ Ideal     ┆ D     ┆ SI2     ┆ … ┆ 55.05.835.873.64 │
└───────┴───────────┴───────┴─────────┴───┴───────┴──────┴──────┴──────┘

PySpark

次に PySpark の場合、まずは SparkSession のインスタンスを作成する必要がある。

>>> from pyspark.sql import SparkSession
>>> spark = SparkSession.builder.getOrCreate()

作成した SparkSession のインスタンスで sparkContext というプロパティを参照しておこう。 すると、master=local[*] という値が確認できる。 これは、前述したシングルノードのローカルモードで動作していることを示している。

>>> spark.sparkContext
<SparkContext master=local[*] appName=pyspark-shell>

PySpark では SparkSession#createDataFrame() というメソッドを使うと Pandas のデータフレームを PySpark のデータフレームに変換できる。

>>> df_spark = spark.createDataFrame(df_pandas)

ただし、得られたデータフレームの変数を参照しても、中身が表示されない。 これは PySpark が遅延評価を操作の基本としている点が関係している。 まだ、この段階では「Pandas のデータフレームを PySpark のデータフレームに変換する」という処理を、これからすることしか決まっていない。 つまり、データフレームの内容は、実際に評価を実行しない限りは得られない 7

>>> df_spark
DataFrame[carat: double, cut: string, color: string, clarity: string, depth: double, table: double, x: double, y: double, z: double]

試しに head() メソッドを実行してみよう。 すると、評価が実行されて Row クラスのインスタンスが入ったリストとして結果が得られる。

>>> df_spark.head(n=5)
[Row(carat=0.23, cut='Ideal', color='E', clarity='SI2', depth=61.5, table=55.0, x=3.95, y=3.98, z=2.43), Row(carat=0.21, cut='Premium', color='E', clarity='SI1', depth=59.8, table=61.0, x=3.89, y=3.84, z=2.31), Row(carat=0.23, cut='Good', color='E', clarity='VS1', depth=56.9, table=65.0, x=4.05, y=4.07, z=2.31), Row(carat=0.29, cut='Premium', color='I', clarity='VS2', depth=62.4, table=58.0, x=4.2, y=4.23, z=2.63), Row(carat=0.31, cut='Good', color='J', clarity='SI2', depth=63.3, table=58.0, x=4.34, y=4.35, z=2.75)]

上記は見にくいので、結果を行単位で処理したいときはまだしも、中身を軽く見たいだけなら普段は show() メソッドを使った方が良いだろう。

>>> df_spark.show(n=5)
+-----+-------+-----+-------+-----+-----+----+----+----+
|carat|    cut|color|clarity|depth|table|   x|   y|   z|
+-----+-------+-----+-------+-----+-----+----+----+----+
| 0.23|  Ideal|    E|    SI2| 61.5| 55.0|3.95|3.98|2.43|
| 0.21|Premium|    E|    SI1| 59.8| 61.0|3.89|3.84|2.31|
| 0.23|   Good|    E|    VS1| 56.9| 65.0|4.05|4.07|2.31|
| 0.29|Premium|    I|    VS2| 62.4| 58.0| 4.2|4.23|2.63|
| 0.31|   Good|    J|    SI2| 63.3| 58.0|4.34|4.35|2.75|
+-----+-------+-----+-------+-----+-----+----+----+----+
only showing top 5 rows

これで下準備が整った。

特定のカラムを取り出す

まずは特定のカラムを取り出してみよう。

Polars

Polars であれば特定のカラムを取り出すのに select() メソッドを使う。

>>> df_polars.select(["x", "y", "z"])
shape: (53_940, 3)
┌──────┬──────┬──────┐
│ x    ┆ y    ┆ z    │
│ ---  ┆ ---  ┆ ---  │
│ f64  ┆ f64  ┆ f64  │
╞══════╪══════╪══════╡
│ 3.953.982.43 │
│ 3.893.842.31 │
│ 4.054.072.31 │
│ 4.24.232.63 │
│ …    ┆ …    ┆ …    │
│ 5.695.753.61 │
│ 5.665.683.56 │
│ 6.156.123.74 │
│ 5.835.873.64 │
└──────┴──────┴──────┘

上記は単純にカラム名を文字列で指定しているけど、代わりに Expression を使うことも考えられる。

>>> df_polars.select([pl.col("x"), pl.col("y"), pl.col("z")]).head(n=5)
shape: (5, 3)
┌──────┬──────┬──────┐
│ x    ┆ y    ┆ z    │
│ ---  ┆ ---  ┆ ---  │
│ f64  ┆ f64  ┆ f64  │
╞══════╪══════╪══════╡
│ 3.953.982.43 │
│ 3.893.842.31 │
│ 4.054.072.31 │
│ 4.24.232.63 │
│ 4.344.352.75 │
└──────┴──────┴──────┘

PySpark

PySpark の場合も select() メソッドを使うのは変わらない。 ただし、前述したとおり遅延評価が原則なのでメソッドの返り値からそのまま結果を確認することはできない。

>>> df_spark.select(["x", "y", "z"])
DataFrame[x: double, y: double, z: double]

繰り返しになるけど、処理した結果を得るためには評価しなければいけない。

>>> df_spark.select(["x", "y", "z"]).show(n=5)
+----+----+----+
|   x|   y|   z|
+----+----+----+
|3.95|3.98|2.43|
|3.89|3.84|2.31|
|4.05|4.07|2.31|
| 4.2|4.23|2.63|
|4.34|4.35|2.75|
+----+----+----+
only showing top 5 rows

また、PySpark に関しても、文字列でカラムを指定する代わりに Polars の Expression に相当する指定方法がある。

>>> from pyspark.sql import functions as F
>>> df_spark.select([F.col("x"), F.col("y"), F.col("z")]).show(n=5)
+----+----+----+
|   x|   y|   z|
+----+----+----+
|3.95|3.98|2.43|
|3.89|3.84|2.31|
|4.05|4.07|2.31|
| 4.2|4.23|2.63|
|4.34|4.35|2.75|
+----+----+----+
only showing top 5 rows

特定のカラムを追加する

続いてはデータフレームにカラムを追加するパターンを試してみよう。

Polars

Polars であれば with_columns() メソッドを使う。 ここでは xy カラムの内容を足して x_plus_y というカラムを追加した。

>>> df_polars.with_columns((pl.col("x") + pl.col("y")).alias("x_plus_y")).head(n=5)
shape: (5, 10)
┌───────┬─────────┬───────┬─────────┬───┬──────┬──────┬──────┬──────────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ … ┆ x    ┆ y    ┆ z    ┆ x_plus_y │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆   ┆ ---  ┆ ---  ┆ ---  ┆ ---      │
│ f64   ┆ cat     ┆ cat   ┆ cat     ┆   ┆ f64  ┆ f64  ┆ f64  ┆ f64      │
╞═══════╪═════════╪═══════╪═════════╪═══╪══════╪══════╪══════╪══════════╡
│ 0.23  ┆ Ideal   ┆ E     ┆ SI2     ┆ … ┆ 3.953.982.437.93     │
│ 0.21  ┆ Premium ┆ E     ┆ SI1     ┆ … ┆ 3.893.842.317.73     │
│ 0.23  ┆ Good    ┆ E     ┆ VS1     ┆ … ┆ 4.054.072.318.12     │
│ 0.29  ┆ Premium ┆ I     ┆ VS2     ┆ … ┆ 4.24.232.638.43     │
│ 0.31  ┆ Good    ┆ J     ┆ SI2     ┆ … ┆ 4.344.352.758.69     │
└───────┴─────────┴───────┴─────────┴───┴──────┴──────┴──────┴──────────┘

PySpark

PySpark は withColumns() というメソッドを使う。 見て分かるとおり PySpark は命名規則にキャメルケースを利用している。 Python は PEP8 に代表されるスネークケースを利用したコーディング規約を採用する場合が多いため違和感を覚えるかもしれない。

>>> df_spark.withColumns({"x_plus_y": F.col("x") + F.col("y")}).show(n=5)
+-----+-------+-----+-------+-----+-----+----+----+----+-----------------+
|carat|    cut|color|clarity|depth|table|   x|   y|   z|         x_plus_y|
+-----+-------+-----+-------+-----+-----+----+----+----+-----------------+
| 0.23|  Ideal|    E|    SI2| 61.5| 55.0|3.95|3.98|2.43|             7.93|
| 0.21|Premium|    E|    SI1| 59.8| 61.0|3.89|3.84|2.31|             7.73|
| 0.23|   Good|    E|    VS1| 56.9| 65.0|4.05|4.07|2.31|8.120000000000001|
| 0.29|Premium|    I|    VS2| 62.4| 58.0| 4.2|4.23|2.63|             8.43|
| 0.31|   Good|    J|    SI2| 63.3| 58.0|4.34|4.35|2.75|             8.69|
+-----+-------+-----+-------+-----+-----+----+----+----+-----------------+
only showing top 5 rows

条件で行を絞り込む

次に特定の条件で行を絞り込んでみよう。

Polars

Polars の場合は filter() メソッドで実現できる。 以下では、試しにカラット数が 3 以上の行を取り出している。

>>> df_polars.filter(pl.col("carat") > 3).head(n=5)
shape: (5, 9)
┌───────┬─────────┬───────┬─────────┬───┬───────┬──────┬──────┬──────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ … ┆ table ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆   ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ cat     ┆ cat   ┆ cat     ┆   ┆ f64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═════════╪═══════╪═════════╪═══╪═══════╪══════╪══════╪══════╡
│ 3.01  ┆ Premium ┆ I     ┆ I1      ┆ … ┆ 58.09.18.975.67 │
│ 3.11  ┆ Fair    ┆ J     ┆ I1      ┆ … ┆ 57.09.159.025.98 │
│ 3.01  ┆ Premium ┆ F     ┆ I1      ┆ … ┆ 56.09.249.135.73 │
│ 3.05  ┆ Premium ┆ E     ┆ I1      ┆ … ┆ 58.09.269.255.66 │
│ 3.02  ┆ Fair    ┆ I     ┆ I1      ┆ … ┆ 56.09.119.025.91 │
└───────┴─────────┴───────┴─────────┴───┴───────┴──────┴──────┴──────┘

PySpark

PySpark も filter() メソッドが使える。 同じ条件で絞り込んだ場合、記述方法もほとんど変わらない。

>>> df_spark.filter(F.col("carat") > 3).show(n=5)
+-----+-------+-----+-------+-----+-----+----+----+----+
|carat|    cut|color|clarity|depth|table|   x|   y|   z|
+-----+-------+-----+-------+-----+-----+----+----+----+
| 3.01|Premium|    I|     I1| 62.7| 58.0| 9.1|8.97|5.67|
| 3.11|   Fair|    J|     I1| 65.9| 57.0|9.15|9.02|5.98|
| 3.01|Premium|    F|     I1| 62.2| 56.0|9.24|9.13|5.73|
| 3.05|Premium|    E|     I1| 60.9| 58.0|9.26|9.25|5.66|
| 3.02|   Fair|    I|     I1| 65.2| 56.0|9.11|9.02|5.91|
+-----+-------+-----+-------+-----+-----+----+----+----+
only showing top 5 rows

特定の値で集約する

次に特定の値で集約して、それぞれのグループについて要約統計量などを求めてみよう。

Polars

Polars では、group_by() メソッドで集約してから、計算する内容を agg() メソッドなどで指定する。 ここでは試しに color カラムで集約して carat カラムの平均を、それぞれのグループで計算してみよう。

>>> df_polars.group_by("color").agg(pl.mean("carat").alias("color_mean_carat"))
shape: (7, 2)
┌───────┬──────────────────┐
│ color ┆ color_mean_carat │
│ ---   ┆ ---              │
│ cat   ┆ f64              │
╞═══════╪══════════════════╡
│ J     ┆ 1.162137         │
│ G     ┆ 0.77119          │
│ I     ┆ 1.026927         │
│ D     ┆ 0.657795         │
│ F     ┆ 0.736538         │
│ E     ┆ 0.657867         │
│ H     ┆ 0.911799         │
└───────┴──────────────────┘

PySpark

PySpark であっても、やり方はほとんど変わらない。 ただし、メソッド名はキャメルケースなので group_by() ではなく groupBy() になる。

>>> df_spark.groupBy("color").agg(F.mean("carat").alias("color_mean_carat")).show()
+-----+------------------+
|color|  color_mean_carat|
+-----+------------------+
|    F|0.7365384615384617|
|    E|0.6578666938858808|
|    D| 0.657794833948337|
|    J|1.1621367521367514|
|    G|0.7711902231668448|
|    I|1.0269273330874222|
|    H| 0.911799132947978|
+-----+------------------+

まとめ

さて、ここまで Polars と PySpark の基本的な操作方法を簡単に比べてきた。 両者を見比べて、どのように感じられただろうか。 たしかに、操作方法として似通っている部分はあるようだ。

一方で、PySpark については遅延評価が原則となる点が使い勝手として大きく異なっている。 個人的な感想を述べると、遅延評価しか使えないのは結構めんどくさい。 インタラクティブに操作しているときなどは特に、すぐに結果を見せてほしくなる。

これまで、評価のタイミングは決め打ちになっているフレームワークが多かった。 たとえば Pandas は即時評価しか使えず、PySpark は遅延評価しか使えない。 遅延評価は最適化を効かせやすいというメリットがある一方で、インタラクティブな操作にはあまり向いていないように感じる 8。 そうした意味で、即時評価と遅延評価を使い分けることができる Polars は、使いやすさとパフォーマンスのバランスを上手くとった API になっているのではないだろうか。

とはいえ、もしもシングルノードで捌ききれないようなデータに直面したときは、PySpark (Apache Spark) の存在を思い出してもらいたい。 あるいは、選択肢のひとつになるかもしれないので。


  1. 以前は Apache Mesos もサポートされていたがバージョン 3.2 で非推奨になった
  2. Apache Spark が組み込みで提供している計算機クラスタを管理する仕組み
  3. せめてシングルノードでも内部で擬似的に分散処理をするような構成にするのが望ましい (Hadoop YARN Pseudo Distributed Mode など)
  4. PySpark の場合は Scala と Python の間で SerDe のオーバーヘッドもある
  5. Apache Spark であればテラバイトのデータを処理するのも割りと普通という印象がある
  6. CSV ファイルを読み込んでも良かったけど試しやすさを優先した
  7. 遅延評価を積み重ねる処理を Transformation、評価を実行する処理を Action という
  8. Vaex のように遅延評価を原則としていてもインタラクティブな操作で使い勝手を落としにくいように工夫した実装も存在する

Python: scikit-learn の set_output API で pandas との食べ合わせが改善された

これまで scikit-learn が提供する TransformerMixin の実装 1 は、出力に NumPy 配列を仮定していた。 そのため、pandas の DataFrame を入力しても出力は NumPy 配列になってしまい、使い勝手が良くないという問題があった。 この問題は、特に PipelineColumnTransformer を使って処理を組むときに顕在化しやすい。

しかし、scikit-learn v1.2 で set_output API が追加されたことで、この状況に改善が見られた。 そこで、今回は set_output API の使い方について書いてみる。

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

$ sw_vers    
ProductName:        macOS
ProductVersion:     13.6.2
BuildVersion:       22G320
$ python -V
Python 3.10.13
$ pip list | egrep "(pandas|scikit-learn)"
pandas          2.1.3
scikit-learn    1.3.2

もくじ

下準備

まずは必要なパッケージをインストールしておく。

$ pip install scikit-learn pandas

そして Python のインタプリタを起動しておこう。

$ python

scikit-learn と pandas の食べ合わせの悪さについて

初めに、前述した scikit-learn と pandas の食べ合わせの悪さについて確認しておく。

最初にサンプルとして Iris データセットを pandas の DataFrame の形式で読み込む。

>>> from sklearn.datasets import load_iris
>>> X, y = load_iris(as_frame=True, return_X_y=True)
>>> type(X)
<class 'pandas.core.frame.DataFrame'>
>>> X.head()
   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0                5.1               3.5                1.4               0.2
1                4.9               3.0                1.4               0.2
2                4.7               3.2                1.3               0.2
3                4.6               3.1                1.5               0.2
4                5.0               3.6                1.4               0.2
>>> type(y)
<class 'pandas.core.series.Series'>
>>> y.head()
0    0
1    0
2    0
3    0
4    0
Name: target, dtype: int64

そして scikit-learn の TransformerMixin を実装したサンプルとして StandardScaler のインスタンスを用意する。

>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()

先ほど読み込んだ Iris データセットの DataFrame を学習および変換してみよう。

>>> X_scaled = scaler.fit_transform(X)

すると、返り値は NumPy 配列になってしまう。

>>> type(X_scaled)
<class 'numpy.ndarray'>
>>> X_scaled[:5]
array([[-0.90068117,  1.01900435, -1.34022653, -1.3154443 ],
       [-1.14301691, -0.13197948, -1.34022653, -1.3154443 ],
       [-1.38535265,  0.32841405, -1.39706395, -1.3154443 ],
       [-1.50652052,  0.09821729, -1.2833891 , -1.3154443 ],
       [-1.02184904,  1.24920112, -1.34022653, -1.3154443 ]])

pandas の DataFrame を入れたのに返ってくるのが NumPy 配列だと、特にパイプライン的に処理を繰り返すような場面で使い勝手が良くない。

TransformerMixin#set_output(transform="pandas") を指定する

では、次に今回の主題となる set_output API を使ってみよう。

やることは単純で、先ほど用意した StandardScaler インスタンスに対して set_output() メソッドを呼ぶ。 このとき、引数として transform="pandas" を指定するのがポイントになる。

>>> scaler.set_output(transform="pandas")
StandardScaler()

この状態で、もう一度 DataFrame を入力として学習と変換をしてみよう。

>>> X_scaled = scaler.fit_transform(X)

すると、今度は返ってくる値の型が pandas の DataFrame になっている。

>>> type(X_scaled)
<class 'pandas.core.frame.DataFrame'>
>>> X_scaled.head()
   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0          -0.900681          1.019004          -1.340227         -1.315444
1          -1.143017         -0.131979          -1.340227         -1.315444
2          -1.385353          0.328414          -1.397064         -1.315444
3          -1.506521          0.098217          -1.283389         -1.315444
4          -1.021849          1.249201          -1.340227         -1.315444

このように TransformerMixin のサブクラスは、set_output API を使うことで pandas の DataFrame を返すことができるようになった。

ちなみに学習済みのインスタンスについて、途中から返す値の型を変えることもできる。 試しに set_output() メソッドで transform="default" を指定して元に戻してみよう。

>>> scaler.set_output(transform="default")
StandardScaler()

そして既存のデータを学習せずに変換だけしてみる。 すると、今度は NumPy 配列が返ってきた。

>>> X_scaled = scaler.transform(X)
>>> type(X_scaled)
<class 'numpy.ndarray'>
>>> X_scaled[:5]
array([[-0.90068117,  1.01900435, -1.34022653, -1.3154443 ],
       [-1.14301691, -0.13197948, -1.34022653, -1.3154443 ],
       [-1.38535265,  0.32841405, -1.39706395, -1.3154443 ],
       [-1.50652052,  0.09821729, -1.2833891 , -1.3154443 ],
       [-1.02184904,  1.24920112, -1.34022653, -1.3154443 ]])

scikit-learn の Pipeline と組み合わせて使う

また、set_output API は Pipeline についても対応している。 試しに PCAStandardScaler を直列に実行する Pipeline を用意して試してみよう。

先ほどはインスタンスの生成と set_output() メソッドの呼び出しを行で分けていた。 今度はメソッドチェーンで一気に設定してみよう。

>>> from sklearn.decomposition import PCA
>>> from sklearn.pipeline import Pipeline
>>> steps = [
...     ("pca", PCA()),
...     ("normalize", StandardScaler()),
... ]
>>> pipeline = Pipeline(steps=steps).set_output(transform="pandas")

作成した Pipeline でデータの学習と変換をしてみよう。

>>> X_transformed = pipeline.fit_transform(X)
>>> type(X_transformed)
<class 'pandas.core.frame.DataFrame'>
>>> X_transformed.head()
       pca0      pca1      pca2      pca3
0 -1.309711  0.650541 -0.100152 -0.014703
1 -1.324357 -0.360512 -0.755094 -0.643570
2 -1.409674 -0.295230  0.064222 -0.129774
3 -1.339582 -0.648304  0.113227  0.491164
4 -1.331469  0.665527  0.323182  0.398117

ちゃんと pandas の DataFrame で返ってきていることが確認できる。

最終段を BaseEstimator のサブクラスにした上で予測まで扱えることも確認しておこう。 先ほどのパイプラインの最終段に LogisticRegression を挿入する。

>>> from sklearn.linear_model import LogisticRegression
>>> steps = [
...     ("pca", PCA()),
...     ("normalize", StandardScaler()),
...     ("lr", LogisticRegression()),
... ]
>>> pipeline = Pipeline(steps=steps).set_output(transform="pandas")

データを train_test_split() 関数で分割する。

>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=42)

学習データを Pipeline に学習させる。

>>> pipeline.fit(X_train, y_train)
Pipeline(steps=[('pca', PCA()), ('normalize', StandardScaler()),
                ('lr', LogisticRegression())])

テストデータを Pipeline に推論させる。

>>> y_pred = pipeline.predict(X_test)

推論した内容を Accuracy で評価する。

>>> from sklearn.metrics import accuracy_score
>>> accuracy_score(y_test, y_pred)
0.8947368421052632

ひとまず予測の精度については横に置いておくとして、ちゃんと機能していそうなことが確認できた。

scikit-learn の ColumnTransformer と組み合わせて使う

さらに実用上は、特定のカラムに絞って処理をする ColumnTransformer と組み合わせて使うことになるはず。 この点も確認しておこう。

カテゴリ変数の処理をしたいので Diamonds データセットを読み込んでおく。

>>> from sklearn.datasets import fetch_openml
>>> X, y = fetch_openml(
...     "diamonds",
...     version=1,
...     as_frame=True,
...     return_X_y=True,
...     parser="pandas"
... )
>>> X.head()
   carat      cut color clarity  depth  table     x     y     z
0   0.23    Ideal     E     SI2   61.5   55.0  3.95  3.98  2.43
1   0.21  Premium     E     SI1   59.8   61.0  3.89  3.84  2.31
2   0.23     Good     E     VS1   56.9   65.0  4.05  4.07  2.31
3   0.29  Premium     I     VS2   62.4   58.0  4.20  4.23  2.63
4   0.31     Good     J     SI2   63.3   58.0  4.34  4.35  2.75
>>> y.head()
0    326
1    326
2    327
3    334
4    335
Name: price, dtype: int64

ちなみに、常に TransformerMixin を実装したクラスで pandas の DataFrame を返してほしいときは、次のようにしてグローバルに設定できる。

>>> from sklearn import set_config
>>> set_config(transform_output="pandas")

カテゴリ変数には OneHotEncoder を、連続変数には StandardScaler をかける ColumnTransformer を次のように用意する。

>>> from sklearn.preprocessing import OneHotEncoder
>>> from sklearn.preprocessing import StandardScaler
>>> ct_settings = [
...     ("cat_onehot", OneHotEncoder(sparse_output=False), ["cut", "color", "clarity"]),
...     ("num_normalize", StandardScaler(), ["carat", "depth", "table", "x", "y", "z"]),
... ]
>>> ct = ColumnTransformer(ct_settings)

試しにデータを学習および変換してみると、次のように結果が pandas の DataFrame で返ってくる。

>>> ct.fit_transform(X).head()
   cat_onehot__cut_Fair  cat_onehot__cut_Good  ...  num_normalize__y  num_normalize__z
0                   0.0                   0.0  ...         -1.536196         -1.571129
1                   0.0                   0.0  ...         -1.658774         -1.741175
2                   0.0                   1.0  ...         -1.457395         -1.741175
3                   0.0                   0.0  ...         -1.317305         -1.287720
4                   0.0                   1.0  ...         -1.212238         -1.117674

[5 rows x 26 columns]

上記の ColumnTransformer のインスタンスを、さらに Pipeline に組み込んでみよう。 最終段には RandomForestRegressor を配置する。

>>> from sklearn.ensemble import RandomForestRegressor
>>> steps = [
...     ("preprocessing", ct),
...     ("rf", RandomForestRegressor(n_jobs=-1)),
... ]
>>> pipeline = Pipeline(steps)

データを Random 5-Fold で RMSE について交差検証してみよう。

>>> from sklearn.model_selection import cross_validate
>>> from sklearn.model_selection import KFold
>>> folds = KFold(n_splits=5, shuffle=True, random_state=42)
>>> cv_result = cross_validate(pipeline, X, y, cv=folds, scoring="neg_root_mean_squared_error")

すると、次のようにテストデータのスコアが得られた。

>>> cv_result["test_score"]
array([-552.59282602, -536.20769256, -582.69130436, -559.43303878,
       -533.75354186])

上記についても性能の高低は別として、エンドツーエンドで評価まで動作することが確認できた。

まとめ

今回は scikit-learn v1.2 で追加された set_output API を試してみた。 TransformerMixin を実装したクラスが pandas の DataFrame を返せるようになったことで両者の食べ合わせが以前よりも良くなった。

参考

scikit-learn.org


  1. 具体例として OneHotEncoderStandardScaler などが挙げられる

Network Namespace と HAProxy を使って HTTP/2 を試す

今回は OSS のリバースプロキシである HAProxy 1 を使って HTTP/2 を試してみる。 HTTP/2 は HTTP/1.1 に存在するパフォーマンス面の課題を解決することを目的に生み出された。 Google が開発していた SPDY というプロトコルがベースになっている。

HTTP/1.1 では、HTTP/1.0 からの改良点として 1 つの TCP コネクション上で複数のリクエスト・レスポンスをやり取りできるようになった。 とはいえ、複数のリクエスト・レスポンスはあくまで順番に処理する必要がある。 そのため、重いコンテンツがあると後続の処理が待たされてしまう問題 (HTTP Head-of-Line Blocking) があった。 シンプルに解決する方法としてはサーバに接続する TCP のコネクションを増やすことが考えられる。 しかし、あまり増やすとサーバ側の負荷につながる。 そこで、HTTP/2 ではプロトコルをバイナリ化した上で、ストリームという単位で通信を多重化できるようになっている。 それぞれのストリームでは、フレームという単位でメッセージがやり取りされる。

ただし、HTTP/2 の登場によって HTTP/1.1 が不要になったかというと、そうではない 2。 あくまで効率を改善するためのプロトコルなので、必ず対応しなければいけないわけでもない。 また、システム全体が HTTP/2 に対応している必要は必ずしもない。 たとえば前段のリバースプロキシや Web サーバが HTTP/2 を喋って、バックエンドとは HTTP/1.1 で通信するパターンが考えられる。 今回試す構成も、このパターンになっている。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic aarch64
$ haproxy -v
HAProxy version 2.4.22-0ubuntu0.22.04.2 2023/08/14 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2026.
Known bugs: http://www.haproxy.org/bugs/bugs-2.4.22.html
Running on: Linux 5.15.0-88-generic #98-Ubuntu SMP Mon Oct 2 15:18:56 UTC 2023 x86_64
$ curl -V
curl 7.81.0 (x86_64-pc-linux-gnu) libcurl/7.81.0 OpenSSL/3.0.2 zlib/1.2.11 brotli/1.0.9 zstd/1.4.8 libidn2/2.3.2 libpsl/0.21.0 (+libidn2/2.3.2) libssh/0.9.6/openssl/zlib nghttp2/1.43.0 librtmp/2.3 OpenLDAP/2.5.16
Release-Date: 2022-01-05
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp 
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM NTLM_WB PSL SPNEGO SSL TLS-SRP UnixSockets zstd
$ openssl version
OpenSSL 3.0.2 15 Mar 2022 (Library: OpenSSL 3.0.2 15 Mar 2022)
$ tshark --version
TShark (Wireshark) 3.6.2 (Git v3.6.2 packaged as 3.6.2-2)

もくじ

下準備

まずは下準備として、利用するパッケージをインストールする。

$ sudo apt-get -y install haproxy curl python3 iproute2 tshark

ネットワークを作成する

次に Network Namespace を使って仮想的なネットワークを作成していく。

まずは HTTP クライアント、リバースプロキシ、Web サーバに対応する Network Namespace を作成する。

$ sudo ip netns add client
$ sudo ip netns add proxy
$ sudo ip netns add server

それぞれの Network Namespace をつなぐための Virtual Ethernet デバイスのインターフェイスを作成する。

$ sudo ip link add client-veth0 type veth peer name proxy-veth0
$ sudo ip link add server-veth0 type veth peer name proxy-veth1

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set client-veth0 netns client
$ sudo ip link set proxy-veth0 netns proxy
$ sudo ip link set proxy-veth1 netns proxy
$ sudo ip link set server-veth0 netns server

インターフェイスが利用できるように状態を UP に設定する。

$ sudo ip netns exec client ip link set client-veth0 up
$ sudo ip netns exec proxy ip link set proxy-veth0 up
$ sudo ip netns exec proxy ip link set proxy-veth1 up
$ sudo ip netns exec server ip link set server-veth0 up

また、Web サーバに対応する Network Namespace の server についてはループバックインターフェースの状態も UP に設定する。

$ sudo ip netns exec server ip link set lo up

最後に、各インターフェイスに IP アドレスを付与する。

$ sudo ip netns exec client ip address add 192.0.2.1/24 dev client-veth0
$ sudo ip netns exec proxy ip address add 192.0.2.254/24 dev proxy-veth0
$ sudo ip netns exec proxy ip address add 198.51.100.254/24 dev proxy-veth1
$ sudo ip netns exec server ip address add 198.51.100.1/24 dev server-veth0

なお、HAProxy は L7 で動作するリバースプロキシなので、L3 のルーティングを有効にする (net.ipv4.ip_forward=1) 必要はない。

Web サーバ (Python) をセットアップする

今回は HAProxy を使った HTTP/2 の検証が主題になる。 そのため、Web サーバについては簡単のために Python の簡易 Web サーバを利用する。

まずは HTTP でやり取りするコンテンツとして HTML ファイルを用意する。

$ mkdir -p /var/tmp/www
$ cat << 'EOF' > /var/tmp/www/index.html
<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>
EOF

そして、次のようにして上記のコンテンツを配信する Web サーバを起動する。

$ sudo ip netns exec server python3 -m http.server -b 0.0.0.0 -d /var/tmp/www 80

ひとまず、別のターミナルを開いて curl(1) でアクセスできることを確認しておこう。

$ sudo ip netns exec server curl http://198.51.100.1
<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

リバースプロキシ (HAProxy) をセットアップして HTTP/2 を試す

ここからは HAProxy を設定して、実際に HTTP/2 の通信を試していく。

なお、HTTP/2 の通信を開始する方法には、以下の 2 つがある。

  • Starting HTTP/2 for "https" URIs
  • Starting HTTP/2 with Prior Knowledge

前者の Starting HTTP/2 for "https" URIs は、通信に TLS (Transport Layer Security) を利用することが前提となる。 その上で、HTTP/1.1 と HTTP/2 のいずれを使うかを ALPN (Application-Layer Protocol Negotiation) を使って決定する。

後者の Starting HTTP/2 with Prior Knowledge は、事前にその Web サーバが HTTP/2 のサービスを提供していることを何らかの方法で知っていることが前提となる。 前提知識は必要となるものの、こちらであれば平文で通信をやり取りできる。

なお、過去には HTTP/1.1 のリクエストで Upgrade: h2c というヘッダを使って HTTP/2 にプロトコルをアップグレードする方法も定義されていた 3。 しかし、このやり方は利用が広がらなかったことから現在は廃止されている。 また、HAProxy もこのやり方を実装していない。

Starting HTTP/2 with Prior Knowledge

まずは平文で通信の内容を確認できる Starting HTTP/2 with Prior Knowledge から試す。

はじめに HAProxy の設定ファイルを次のように用意する。 ポイントは frontend web_proxybind *:80proto h2 としているところ。 これで、HAProxy が TCP の 80 番ポートで Starting HTTP/2 with Prior Knowledge なリクエストを受け付けられるようになる。

$ cat << 'EOF' > haproxy.cfg
defaults
  mode http
  timeout client 1m
  timeout server 1m
  timeout connect 10s

frontend web_proxy
  bind *:80 proto h2
  default_backend web_servers

backend web_servers
  server s1 198.51.100.1:80
EOF

上記の設定ファイルを使って HAProxy を実行する。

$ sudo ip netns exec proxy haproxy -f haproxy.cfg

この状態で Web クライアントからリバースプロキシにリクエストを送る。 curl(1) では --http2-prior-knowledge というオプションをつけることで Starting HTTP/2 with Prior Knowledge に対応したリクエストを送ることができる。

$ sudo ip netns exec client curl --include --http2-prior-knowledge http://192.0.2.254
HTTP/2 200 
server: SimpleHTTP/0.6 Python/3.10.12
date: Sun, 05 Nov 2023 07:48:57 GMT
content-type: text/html
content-length: 127
last-modified: Sun, 05 Nov 2023 07:40:23 GMT

<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

先頭に HTTP/2 200 とあるように、通信が HTTP/2 でやり取りされていることが確認できる。

実際のコンテンツを持っている Python の Web サーバには HTTP/1.1 でリクエストが送られている。 HAProxy は、あくまでアプリケーションレイヤーでのプロキシに過ぎない。

$ sudo ip netns exec server python3 -m http.server -b 0.0.0.0 -d /var/tmp/www 80
198.51.100.254 - - [05/Sun/2023 09:56:13] "GET / HTTP/1.1" 200 -

ちなみに、理屈の上では HTTP/2 (Starting HTTP/2 with Prior Knowledge) と HTTP/1.1 を同じエンドポイントで提供できる。 しかし、HAProxy ではその機能を実装していないため、オプションをつけずにリクエストしてもレスポンスは得られない。

$ sudo ip netns exec client curl --include http://192.0.2.254
curl: (52) Empty reply from server

先に述べたとおり Starting HTTP/2 with Prior Knowledge では平文で通信の内容を観察できる。 試しに tshark(1) を使って通信の内容を確認してみよう。 tshark(1) は -Y http2 オプションを使うことで、表示する内容を HTTP/2 に絞ることができる。 それぞれの行が HTTP/2 のフレームに対応している。

$ sudo ip netns exec proxy tshark -Y http2 -i proxy-veth0
...
    4 0.000122686    192.0.2.1192.0.2.254  HTTP2 90 Magic
    5 0.000129571    192.0.2.1192.0.2.254  HTTP2 93 SETTINGS[0]
    6 0.000132302    192.0.2.1192.0.2.254  HTTP2 79 WINDOW_UPDATE[0]
    8 0.000161449    192.0.2.1192.0.2.254  HTTP2 103 HEADERS[1]: GET /
    9 0.000169075  192.0.2.254192.0.2.1    HTTP2 96 SETTINGS[0], SETTINGS[0]
   11 0.000190964    192.0.2.1192.0.2.254  HTTP2 75 SETTINGS[0]
   12 0.000850319  192.0.2.254192.0.2.1    HTTP2 321 HEADERS[1]: 200 OK, DATA[1] (text/html)

最初にクライアントから送られている Magic は、これから始める通信が Starting HTTP/2 with Prior Knowledge であることを伝えている。 この通信はコネクションプリフェイス (Connection Preface) と呼ばれる。

    4 0.000122686    192.0.2.1 → 192.0.2.254  HTTP2 90 Magic

具体的には PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n という文字列に対応するバイト列を送っている。 先に述べたとおり、理屈の上では最初に送られてくるのがコネクションプリフェイスかどうかで HTTP/2 と HTTP/1.1 の処理を分岐できる。 しかし HAProxy では、この機能を実装していない。

次の SETTINGS は、HTTP/2 で通信する上での様々なパラメータを設定したり確認応答 (ACK) を返すためのフレームである。

    5 0.000129571    192.0.2.1 → 192.0.2.254  HTTP2 93 SETTINGS[0]

フレームの右端にある [0] はストリームの番号 (識別子) を表している。 0 番のストリームは制御のために特別に利用される。

次の WINDOW_UPDATE は HTTP/2 のフロー制御に用いられるフレームである。 データの受信者は、コネクション全体か、あるいは特定のストリームで受信できるデータ量を、このフレームで相手に通知する。

    6 0.000132302    192.0.2.1 → 192.0.2.254  HTTP2 79 WINDOW_UPDATE[0]

上記はストリームの番号に 0 番が指定されているため、コネクション全体に対して通知している。

次の HEADERS は HTTP/1.1 のリクエストに相当する。 HTTP/1.1 ではテキストでやり取りしていたメソッドやパスなどの情報を、HTTP/2 では TLV (Type-Length-Value) 形式のフィールドとして表現している。

    8 0.000161449    192.0.2.1 → 192.0.2.254  HTTP2 103 HEADERS[1]: GET /

そして、最後の HEADERSDATA は HTTP/1.1 のレスポンスに相当する。 HTTP のステータスコードや Content-Type などの情報を HEADERS で返した上で、コンテンツを DATA で送っている。

   12 0.000850319  192.0.2.254 → 192.0.2.1    HTTP2 321 HEADERS[1]: 200 OK, DATA[1] (text/html)

Starting HTTP/2 for "https" URIs

続いては TLS が前提となって ALPN でプロトコルをネゴシエートする Starting HTTP/2 for "https" URIs を試す。

TLS を利用するためには証明書が必要になる。 そこで、まずは動作確認用の自己署名証明書を作成する。

まずは秘密鍵を作る。

$ openssl genpkey -algorithm ed25519 -out private.key

続いて証明書署名要求を作る。 サブジェクトの部分は適当。

$ openssl req -new -key private.key -out cert.csr -subj "/C=JP/ST=Tokyo/L=Chiyoda-ku/O=Example Corp./OU=Example Dept./CN=www.example.jp"

そして自己署名証明書を作る。

$ openssl x509 -signkey private.key -in cert.csr -req -days 365 -out cert.pem

HAProxy は証明書のフォーマットとして秘密鍵も必要とする。 そこで /var/tmp/haproxy/certs 以下に証明書と秘密鍵を連結したものを作成する。 あくまで動作確認用ではあるが、秘密鍵の取り扱いには注意することを示すためにディレクトリのパーミッションを 400 に変更している。

$ mkdir -p /var/tmp/haproxy/certs
$ cat cert.pem private.key > /var/tmp/haproxy/certs/haproxy.pem
$ sudo chmod -R 400 /var/tmp/haproxy/certs

HAProxy の設定ファイルを作成する。 ポイントは frontend web_proxybind :443 に対して証明書の場所と alpn h2,http/1.1 を指定しているところ。 特に後者の alpn h2,http/1.1 は、ALPN を使って HTTP/2 と HTTP/1.1 をネゴシエートすることを示している。

$ cat << 'EOF' > haproxy.cfg
global
  ssl-default-bind-options ssl-min-ver TLSv1.2

defaults
  mode http
  timeout client 1m
  timeout server 1m
  timeout connect 10s

frontend web_proxy
  bind :443 ssl crt /var/tmp/haproxy/certs/haproxy.pem alpn h2,http/1.1
  default_backend web_servers

backend web_servers
  server s1 198.51.100.1:80
EOF

ssl-default-bind-options ssl-min-ver TLSv1.2 は、TLS 1.0 ~ 1.1 が 2021 年に RFC 8996 で非推奨となったことに対応して入れている。

設定ファイルを元に HAProxy を起動する。

$ sudo ip netns exec proxy haproxy -f haproxy.cfg

自己署名証明書なので --insecure オプションをつけて curl(1) を実行する。 すると、HTTP/2 でリクエストが処理されることが確認できる。

$ sudo ip netns exec client curl --include --insecure https://192.0.2.254
HTTP/2 200 
server: SimpleHTTP/0.6 Python/3.10.12
date: Sun, 05 Nov 2023 09:56:13 GMT
content-type: text/html
content-length: 127
last-modified: Sun, 05 Nov 2023 06:49:15 GMT

<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

上記は ALPN によって、使用するプロトコルが HTTP/2 と HTTP/1.x でネゴシエートされた結果である。 そのため、明示的にクライアント側で HTTP/1.x が使いたい旨を指定すれば、そのようにもなる。 たとえば curl(1) で --http1.1 オプションをつけてみよう。

$ sudo ip netns exec client curl --include --http1.1 --insecure https://192.0.2.254
HTTP/1.0 200 OK
server: SimpleHTTP/0.6 Python/3.10.12
date: Sun, 05 Nov 2023 10:25:07 GMT
content-type: text/html
content-length: 127
last-modified: Sun, 05 Nov 2023 06:49:15 GMT
connection: keep-alive

<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

上記から、HTTP/2 の代わりに HTTP/1.0 で結果が返ってくることが確認できる 4

まとめ

今回は Network Namespace で作成したネットワーク上で Haproxy を使って HTTP/2 を試してみた。

  • HTTP/2 と HTTP/1.x はアプリケーションレイヤーのプロキシでプロトコルを変換できる
  • HTTP/2 の通信を始めるには 2 つのやり方がある
    • Starting HTTP/2 for "https" URIs
      • クライアントは TLS の ALPN で HTTP/2 が利用したい旨を伝える
    • Starting HTTP/2 with Prior Knowledge
      • クライアントは Connection Preface と呼ばれるバイト列を送ることで HTTP/2 を利用する旨を伝える
      • 通信を平文でやり取りできる

参考文献

www.rfc-editor.org

www.rfc-editor.org

www.rfc-editor.org


  1. https://www.haproxy.org/
  2. この点は HTTP/3 の登場でも変わらない
  3. https://www.rfc-editor.org/rfc/rfc7540.html#section-3.2
  4. HTTP/1.1 でないのは Python の簡易 Web サーバの実装に依存している

Network Namespace と Libreswan で IPsec VPN を試す (Route-based / VTI デバイス)

今回は Linux の Network Namespace で作ったネットワーク上で Libreswan を動かして IPsec VPN を試してみる。 なお、Libreswan には、いくつかの動作モードがある。 今回は、その中でも Route-based VPN using VTI と呼ばれる動作モードを利用する。 これは VTI (Virtual Tunnel Interface) というインターフェイスを作成して、そこに明示的な経路を指定することで一致するパケットを暗号化するというもの。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic x86_64
$ ipsec --version
Linux Libreswan 3.32 (netkey) on 5.15.0-87-generic

もくじ

下準備

まずは必要なパッケージをインストールする。

$ sudo apt-get install libreswan iproute2 tcpdump

デフォルトで有効になる systemd のサービスは利用しないため停止しておく。

$ sudo systemctl stop ipsec
$ sudo systemctl disable ipsec

ネットワークを作成する

次に Network Namespace を使ってネットワークを作成する。 作成するネットワークの論理構成を以下に示す。

ネットワーク構成 (論理)

上記で router1203.0.113.1router2203.0.113.2 の間に IPsec VPN のトンネルを作る。 router1router2dummy0 インターフェイスは検証を簡単にするためにホストの代わりとして作っている。 なお、後述する VTI デバイスのインターフェイスに直接同じ IP アドレスを振っても構わない。 今回は、一応インターフェイスくらいは分けておくかという気持ちで dummy インターフェイスを作成している。

まずは必要な Network Namespace を作成する。

$ sudo ip netns add router1
$ sudo ip netns add router2

Network Namespace 同士をつなぐ Virtual Ethernet デバイスのインターフェイスを作成する。

$ sudo ip link add rt1-veth0 type veth peer name rt2-veth0

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set rt1-veth0 netns router1
$ sudo ip link set rt2-veth0 netns router2

インターフェイスの状態を UP に設定する。

$ sudo ip netns exec router1 ip link set rt1-veth0 up
$ sudo ip netns exec router2 ip link set rt2-veth0 up

それぞれのインターフェイスに IP アドレスを付与する。

$ sudo ip netns exec router1 ip address add 203.0.113.1/24 dev rt1-veth0
$ sudo ip netns exec router2 ip address add 203.0.113.2/24 dev rt2-veth0

それぞれの Network Namespace に IPsec トンネルを作成する上で必要なカーネルパラメータを設定する。

$ sudo ip netns exec router1 sysctl net.ipv4.ip_forward=1
$ sudo ip netns exec router1 sysctl net.ipv4.conf.default.send_redirects=0
$ sudo ip netns exec router1 sysctl net.ipv4.conf.default.accept_redirects=0
$ sudo ip netns exec router1 sysctl net.ipv4.conf.default.rp_filter=0
$ sudo ip netns exec router2 sysctl net.ipv4.ip_forward=1
$ sudo ip netns exec router2 sysctl net.ipv4.conf.default.send_redirects=0
$ sudo ip netns exec router2 sysctl net.ipv4.conf.default.accept_redirects=0
$ sudo ip netns exec router2 sysctl net.ipv4.conf.default.rp_filter=0

それぞれの Network Namespace に dummy インターフェイスを追加して IP アドレスを付与する。

$ sudo ip netns exec router1 ip link add dummy0 type dummy
$ sudo ip netns exec router1 ip link set dummy0 up
$ sudo ip netns exec router1 ip address add 192.0.2.1/24 dev dummy0
$ sudo ip netns exec router2 ip link add dummy0 type dummy
$ sudo ip netns exec router2 ip link set dummy0 up
$ sudo ip netns exec router2 ip address add 198.51.100.1/24 dev dummy0

以上で、先ほど示したネットワークの論理構成が完成した。

ひとまず router1203.0.113.1router22023.0.113.2 の間で疎通があることを確認する。

$ sudo ip netns exec router1 ping -c 3 203.0.113.2 -I 203.0.113.1
PING 203.0.113.2 (203.0.113.2) from 203.0.113.1 : 56(84) bytes of data.
64 bytes from 203.0.113.2: icmp_seq=1 ttl=64 time=0.078 ms
64 bytes from 203.0.113.2: icmp_seq=2 ttl=64 time=0.071 ms
64 bytes from 203.0.113.2: icmp_seq=3 ttl=64 time=0.075 ms

--- 203.0.113.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2116ms
rtt min/avg/max/mdev = 0.071/0.074/0.078/0.003 ms

また、現状では 192.0.2.1198.51.100.1 の間に疎通がないことも確認する。

$ sudo ip netns exec router1 ping -c 3 198.51.100.1 -I 192.0.2.1
PING 198.51.100.1 (198.51.100.1) from 192.0.2.1 : 56(84) bytes of data.

--- 198.51.100.1 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2096ms

これは、経路の設定がされていないため。 もちろん経路さえ追加すれば疎通は取れるが、経路は VPN で暗号化されないため平文でやり取りされる。

$ sudo ip netns exec router1 ip route show
192.0.2.0/24 dev dummy0 proto kernel scope link src 192.0.2.1 
203.0.113.0/24 dev rt1-veth0 proto kernel scope link src 203.0.113.1 
$ sudo ip netns exec router2 ip route show
198.51.100.0/24 dev dummy0 proto kernel scope link src 198.51.100.1 
203.0.113.0/24 dev rt2-veth0 proto kernel scope link src 203.0.113.2 

IPsec VPN を構成する

ここからは Libreswan を使って IPsec VPN を構成していく。

はじめに NSS database を初期化する。 初期化しておかないと後述する Pluto デーモンを起動できない。

$ sudo ipsec initnss
Initializing NSS database

次に、それぞれの Network Namespace 上で動作する Libreswan の設定ファイルなどを置くためのディレクトリを用意する。

$ mkdir -p /var/tmp/router1
$ mkdir -p /var/tmp/router2

そして、それぞれのディレクトリに IPsec の事前共有鍵 (Pre-Shared Key; PSK) を用意する。

$ cat << 'EOF' > /var/tmp/router1/ipsec.secrets
203.0.113.1 203.0.113.2 : PSK "DeadBeef"
EOF
$ cat << 'EOF' > /var/tmp/router2/ipsec.secrets 
203.0.113.2 203.0.113.1 : PSK "DeadBeef"
EOF

同様に Libreswan の設定ファイルも用意する。 ikev2=yes を設定することで明示的に IKEv2 を有効にしている。

$ cat << 'EOF' > /var/tmp/router1/ipsec.conf
conn %default
    authby=secret
    auto=add
    ikev2=yes
conn myvpn
    left=203.0.113.1
    leftsubnet=192.0.2.0/24
    right=203.0.113.2
    rightsubnet=198.51.100.0/24
    mark=42/0xffffffff
    vti-interface=ipsec0
    vti-routing=yes
    vti-shared=no
EOF
$ cat << 'EOF' > /var/tmp/router2/ipsec.conf
conn %default
    authby=secret
    auto=add
    ikev2=yes
conn myvpn
    left=203.0.113.2
    leftsubnet=198.51.100.0/24
    right=203.0.113.1
    rightsubnet=192.0.2.0/24
    mark=42/0xffffffff
    vti-interface=ipsec0
    vti-routing=yes
    vti-shared=no
EOF

設定ファイルにフォーマット的な問題がないかは次のようにして確認できる。 何も出力がなければフォーマット的な問題がないと分かる。 ただし、設定内容の妥当性までは検証してくれないので過度な期待はしない方が良い。

$ /usr/libexec/ipsec/addconn --config /var/tmp/router1/ipsec.conf --checkconfig
$ /usr/libexec/ipsec/addconn --config /var/tmp/router2/ipsec.conf --checkconfig

次に、それぞれの Network Namespace 上で ipsec verify コマンドを実行して、すべて [OK] になっていることを確認する。 [OK] 以外の表示があると、何か動作に不都合のある設定がされていることが分かる。

$ sudo ip netns exec router1 ipsec verify
Verifying installed system and configuration files

Version check and ipsec on-path                     [OK]
Libreswan 3.32 (netkey) on 5.15.0-87-generic
Checking for IPsec support in kernel                [OK]
 NETKEY: Testing XFRM related proc values
         ICMP default/send_redirects                [OK]
         ICMP default/accept_redirects              [OK]
         XFRM larval drop                           [OK]
Pluto ipsec.conf syntax                             [OK]
Checking rp_filter                                  [OK]
Checking that pluto is running                      [OK]
 Pluto listening for IKE on udp 500                  [OK]
 Pluto listening for IKE/NAT-T on udp 4500         [OK]
 Pluto ipsec.secret syntax                          [OK]
Checking 'ip' command                                [OK]
Checking 'iptables' command                          [OK]
Checking 'prelink' command does not interfere with FIPS  [OK]
Checking for obsolete ipsec.conf options              [OK]
$ sudo ip netns exec router2 ipsec verify
Verifying installed system and configuration files

Version check and ipsec on-path                     [OK]
Libreswan 3.32 (netkey) on 5.15.0-87-generic
Checking for IPsec support in kernel                [OK]
 NETKEY: Testing XFRM related proc values
         ICMP default/send_redirects                [OK]
         ICMP default/accept_redirects              [OK]
         XFRM larval drop                           [OK]
Pluto ipsec.conf syntax                             [OK]
Checking rp_filter                                  [OK]
Checking that pluto is running                      [OK]
 Pluto listening for IKE on udp 500                  [OK]
 Pluto listening for IKE/NAT-T on udp 4500         [OK]
 Pluto ipsec.secret syntax                          [OK]
Checking 'ip' command                                [OK]
Checking 'iptables' command                          [OK]
Checking 'prelink' command does not interfere with FIPS  [OK]
Checking for obsolete ipsec.conf options              [OK]

以上で準備ができたので Libreswan の Pluto デーモンを起動する。 オプションで動作ディレクトリや設定ファイルに先ほど作成したものを指定する。 また、デバッグメッセージをターミナルに出したいので --nofork--stderrlog をつけて実行する。 こうすればデーモンとして動作せず、ログを標準エラー出力に出せる。 まずは router1 の方から。

$ sudo ip netns exec router1 ipsec pluto \
  --nofork \
  --stderrlog \
  --rundir /var/tmp/router1 \
  --config /var/tmp/router1/ipsec.conf \
  --secretsfile /var/tmp/router1/ipsec.secrets

ログからエラーなどが生じていないことを確認する。

次に、新しくターミナルを別に開いて router2 の Pluto デーモンを起動する。

$ sudo ip netns exec router2 ipsec pluto \
  --nofork \
  --stderrlog \
  --rundir /var/tmp/router2 \
  --config /var/tmp/router2/ipsec.conf \
  --secretsfile /var/tmp/router2/ipsec.secrets

デーモンを起動できたら ipsec auto コマンドを使って IPsec VPN のコネクションを開始する。

$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --start myvpn

コマンドを実行すると IPsec VPN のセッションが確立される。 確立されたセッションは ipsec show コマンドで確認できる。

$ sudo ip netns exec router1 ipsec show
192.0.2.0/24 <=> 198.51.100.0/24 using reqid 16393
$ sudo ip netns exec router2 ipsec show
198.51.100.0/24 <=> 192.0.2.0/24 using reqid 16389

セッションが確立されると自動的に router1router2ipsec0 という名前で VTI が作成される。

$ sudo ip netns exec router1 ip address show ipsec0
5: ipsec0@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1480 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ipip 203.0.113.1 peer 203.0.113.2
    inet6 fe80::200:5efe:cb00:7101/64 scope link 
       valid_lft forever preferred_lft forever
$ sudo ip netns exec router2 ip address show ipsec0
5: ipsec0@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1480 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ipip 203.0.113.2 peer 203.0.113.1
    inet6 fe80::200:5efe:cb00:7102/64 scope link 
       valid_lft forever preferred_lft forever

そして、宛先が VTI の経路が対向のサブネット (rightsubet) から自動的に設定される。 ようするに経路にマッチして VTI にフォワードされたパケットが自動的に暗号化されるということ。

$ sudo ip netns exec router1 ip route show
192.0.2.0/24 dev dummy0 proto kernel scope link src 192.0.2.1 
198.51.100.0/24 dev ipsec0 scope link 
203.0.113.0/24 dev rt1-veth0 proto kernel scope link src 203.0.113.1
$ sudo ip netns exec router2 ip route show
192.0.2.0/24 dev ipsec0 scope link 
198.51.100.0/24 dev dummy0 proto kernel scope link src 198.51.100.1 
203.0.113.0/24 dev rt2-veth0 proto kernel scope link src 203.0.113.2

ちなみに ipsec.conf で VTI 関連の設定をしない場合には作成や経路の設定は自動的にはされない。 その場合は、次のように手動で設定することもできる。

$ sudo ip netns exec router1 ip tunnel add ipsec0 mode vti local 203.0.113.1 remote 203.0.113.2 key 42
$ sudo ip netns exec router1 ip link set ipsec0 up
$ sudo ip netns exec router1 ip route add 198.51.100.0/24 dev ipsec0
$ sudo ip netns exec router2 ip tunnel add ipsec0 mode vti local 203.0.113.2 remote 203.0.113.1 key 42
$ sudo ip netns exec router2 ip link set ipsec0 up
$ sudo ip netns exec router2 ip route add 192.0.2.0/24 dev ipsec0

動作を確認する

さて、ここまでで正常に IPsec VPN が確立されたようなので動作を確認しよう。

まずは、最初の方で確認した dummy0 インターフェイス同士の IP アドレスで疎通を確認しておく。

$ sudo ip netns exec router1 ping 198.51.100.1 -I 192.0.2.1
PING 198.51.100.1 (198.51.100.1) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 198.51.100.1: icmp_seq=1 ttl=64 time=0.188 ms
64 bytes from 198.51.100.1: icmp_seq=2 ttl=64 time=0.266 ms
64 bytes from 198.51.100.1: icmp_seq=3 ttl=64 time=0.316 ms
...

今度は、ちゃんと ping に疎通があることが確認できる。

では、次にパケットをキャプチャしてみる。 まずは router1ipsec0 インターフェイスから。

$ sudo ip netns exec router1 tcpdump -tnl -i ipsec0
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ipsec0, link-type RAW (Raw IP), snapshot length 262144 bytes
IP 192.0.2.1 > 198.51.100.1: ICMP echo request, id 10575, seq 29, length 64
IP 198.51.100.1 > 192.0.2.1: ICMP echo reply, id 10575, seq 29, length 64
IP 192.0.2.1 > 198.51.100.1: ICMP echo request, id 10575, seq 30, length 64
IP 198.51.100.1 > 192.0.2.1: ICMP echo reply, id 10575, seq 30, length 64
IP 192.0.2.1 > 198.51.100.1: ICMP echo request, id 10575, seq 31, length 64
IP 198.51.100.1 > 192.0.2.1: ICMP echo reply, id 10575, seq 31, length 64
...

上記から ipsec0 インターフェイスの時点では平文で内容が見えることが分かる。 これは IPsec VPN のトンネルの出入り口を見ているため。

では、続いて router1rt1-veth0 インターフェイスをキャプチャしよう。

$ sudo ip netns exec router1 tcpdump -tnl -i rt1-veth0
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on rt1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1 > 203.0.113.2: ESP(spi=0x2ef3568a,seq=0x30), length 120
IP 203.0.113.2 > 203.0.113.1: ESP(spi=0xe12a2591,seq=0x30), length 120
IP 203.0.113.1 > 203.0.113.2: ESP(spi=0x2ef3568a,seq=0x31), length 120
IP 203.0.113.2 > 203.0.113.1: ESP(spi=0xe12a2591,seq=0x31), length 120
IP 203.0.113.1 > 203.0.113.2: ESP(spi=0x2ef3568a,seq=0x32), length 120
IP 203.0.113.2 > 203.0.113.1: ESP(spi=0xe12a2591,seq=0x32), length 120
...

すると、今度はパケットが ESP (Encapsulating Security Payload) になっており、中身が見えない。 これは IPsec VPN のトンネルで暗号化された部分を見ているため。

続いて IKE SA と Child SA を確立している部分の通信も確認してみよう。 一旦 ping は止めておく。 そして、ipsec auto コマンドを使って VPN のコネクションを一旦削除する。

$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --delete myvpn

続いて、次のようにして IPsec と IKE の通信が確認できるように tcpdump を実行する。

$ sudo ip netns exec router1 tcpdump -tnl -i rt1-veth0 esp or udp port 500 or udp port 4500 or tcp port 4500
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on rt1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes

準備ができたら IPsec VPN のコネクションを次のようにして張りなおす。

$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --add myvpn
$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --start myvpn

すると tcpdump のターミナルに次のような出力が得られる。

$ sudo ip netns exec router1 tcpdump -tnl -i rt1-veth0 esp or udp port 500 or udp port 4500 or tcp port 4500
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on rt1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1.500 > 203.0.113.2.500: isakmp: parent_sa ikev2_init[I]
IP 203.0.113.2.500 > 203.0.113.1.500: isakmp: parent_sa ikev2_init[R]
IP 203.0.113.1.500 > 203.0.113.2.500: isakmp: child_sa  ikev2_auth[I]
IP 203.0.113.2.500 > 203.0.113.1.500: isakmp: child_sa  ikev2_auth[R]

isakmp: parent_sa となっているのが IKE SA を確立している部分だろう。 ikev2_init[I]ikev2_init[R][I][R] はイニシエータとレスポンダを表している。 そして isakmp: child_sa が Child SA を確立している部分のはず。 Child SA が確立されると IPsec VPN が確立されたことになる。

まとめ

今回は次の内容を実施した。

  • Network Namespace を使ってネットワークを作成する
  • ネットワークに Libreswan を使って IPsec VPN を確立する
  • IPsec に関する通信をパケットキャプチャする

参考

libreswan.org

Network Namespace と nftables で Destination NAT を試す

今回は Network Namespace で作ったネットワーク上で nftables 1 を使った Destination NAT を試してみる。 このエントリは、以下のエントリの続きとなっている。

blog.amedama.jp

上記は Source NAT だったのが、今回は Destination NAT になっている。 使っているネットワーク構成は変わらない。

Destination NAT は、よく「ポートを開ける」とか「ポートを開放する」といった表現をされるもの。 ようするに LAN 側のノードに対して、インターネットを起点とした通信を可能にする。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic x86_64
$ nft --version
nftables v1.0.2 (Lester Gooch)

もくじ

下準備

あらかじめ必要なパッケージをインストールしておく。

$ sudo apt-get -y install nftables iproute2 tcpdump

nftables のサービスを稼働させる。

$ sudo systemctl start nftables
$ sudo systemctl enable nftables

nftables のカーネルモジュールがロードされていることを確認する。

$ lsmod | grep ^nf_tables
nf_tables             258048  0

Network Namespace でネットワークを作成する

作成するネットワークの論理構成を以下に示す。 構成は Source NAT で使ったものと変わらない。

ネットワーク構成 (論理)

203.0.113.0/24 をグローバル、192.0.2.0/24 をプライベートなセグメントに見立てている。 Destination NAT の例では、宛先 IP アドレスが 203.0.113.254 のパケットを lan の持っている 192.0.2.1 に書き換える。 ただし、書き換えるのはトランスポート層のプロトコルが TCP でポート番号が 54321 の場合だけに限る。

ここからは Network Namespace を使ってネットワークを作成していく。 といっても Source NAT の時とやることは変わらない。 まずは Network Namespace を作成する。

$ sudo ip netns add lan
$ sudo ip netns add router
$ sudo ip netns add wan

次に Network Namespace 同士をつなぐ veth インターフェイスを追加する。

$ sudo ip link add lan-veth0 type veth peer name gw-veth0
$ sudo ip link add wan-veth0 type veth peer name gw-veth1

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set lan-veth0 netns lan
$ sudo ip link set gw-veth0 netns router
$ sudo ip link set gw-veth1 netns router
$ sudo ip link set wan-veth0 netns wan

インターフェイスの状態を UP に設定する。

$ sudo ip netns exec lan ip link set lan-veth0 up
$ sudo ip netns exec router ip link set gw-veth0 up
$ sudo ip netns exec router ip link set gw-veth1 up
$ sudo ip netns exec wan ip link set wan-veth0 up

lan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec lan ip address add 192.0.2.1/24 dev lan-veth0
$ sudo ip netns exec lan ip route add default via 192.0.2.254

router について、インターフェイスに IP アドレスを付与する。 また、ルータとして動作するようにカーネルパラメータの net.ipv4.ip_forward1 を設定する。

$ sudo ip netns exec router ip address add 192.0.2.254/24 dev gw-veth0
$ sudo ip netns exec router ip address add 203.0.113.254/24 dev gw-veth1
$ sudo ip netns exec router sysctl net.ipv4.ip_forward=1

最後に wan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec wan ip address add 203.0.113.1/24 dev wan-veth0
$ sudo ip netns exec wan ip route add default via 203.0.113.254

nftables を設定する

ここからは nftables を使って Destination NAT を設定していく。

nftables の設定は nft list ruleset コマンドで確認できる。 初期状態では何も設定されていないため、結果は何も表示されない。

$ sudo ip netns exec router nft list ruleset

テーブルを追加する

まずは nft create table コマンドでテーブルを追加する。 テーブルはアドレスファミリとチェーンタイプを指定して追加する。

$ sudo ip netns exec router nft create table ip nat

上記ではアドレスファミリが ip でチェーンタイプが nat のテーブルを作っている。

テーブルを追加すると、次のように nft list ruleset の結果にテーブルが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
}

チェーンを追加する

続いて、処理のタイミングを表すチェーンをテーブルに追加する。 以下では先ほど作った ip nat のテーブルに PREROUTING という名前でチェーンを追加している。 カッコ内は追加するチェーンの種類と、処理されるタイミングを示している。

$ sudo ip netns exec router nft add chain ip nat PREROUTING { type nat hook prerouting priority dstnat\; }

次のように nft list ruleset の結果にチェーンが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
    }
}

ルールを追加する

最後に、具体的な処理を表すルールをチェーンに追加する。 以下では、先ほど作った ip nat テーブルの PREROUTING チェーンにルールを追加している。

$ sudo ip netns exec router nft add rule ip nat PREROUTING ip daddr 203.0.113.254 tcp dport 54321 dnat to 192.0.2.1

上記では、宛先 IP アドレスが 203.0.113.254 で TCP の宛先ポートが 54321 ポートを 192.0.2.1 に Destination NAT で転送するように指定している。

次のように nft list ruleset の結果にルールが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
        ip daddr 203.0.113.254 tcp dport 54321 dnat to 192.0.2.1
    }
}

動作を確認する

すべての設定が完了したので、ここからは動作を確認していこう。

まずは lan で nc(1) を使って TCP の 54321 ポートで通信を待ち受けておく。

$ sudo ip netns exec lan nc -lnv 54321

次に、別のターミナルを開いて、同様に wan 側でも TCP の 54321 ポートに関する通信をキャプチャできるようにしておく。

$ sudo ip netns exec wan tcpdump -tnl -i wan-veth0 "tcp and port 54321"

さらに別のターミナルを開いて lan 側でも TCP の 54321 ポートに関する通信をキャプチャできるようにする。

$ sudo ip netns exec lan tcpdump -tnl -i lan-veth0 "tcp and port 54321"

準備ができたら wan で nc(1) を使って router203.0.113.254 の 54321 ポートに接続する。

$ sudo ip netns exec wan nc 203.0.113.254 54321

その上で lan 側のキャプチャを確認してみよう。 すると 203.0.113.1 が送信元で 192.0.2.1 を宛先にしたパケットを起点に通信が発生していることが確認できる。

$ sudo ip netns exec lan tcpdump -tnl -i lan-veth0 "tcp and port 54321"
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on lan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1.40154 > 192.0.2.1.54321: Flags [S], seq 2852554981, win 64240, options [mss 1460,sackOK,TS val 2906027089 ecr 0,nop,wscale 7], length 0
IP 192.0.2.1.54321 > 203.0.113.1.40154: Flags [S.], seq 3924803357, ack 2852554982, win 65160, options [mss 1460,sackOK,TS val 451299770 ecr 2906027089,nop,wscale 7], length 0
IP 203.0.113.1.40154 > 192.0.2.1.54321: Flags [.], ack 1, win 502, options [nop,nop,TS val 2906027089 ecr 451299770], length 0

上記は Destination NAT によって、宛先 IP アドレスが書き換えられたことによって生じている。

同様に wan 側のキャプチャも確認しよう。 すると、こちらでは 203.0.113.1 が送信元で 203.0.113.254 が宛先のパケットが起点になっていることが確認できる。

$ sudo ip netns exec wan tcpdump -tnl -i wan-veth0 "tcp and port 54321"
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1.49200 > 203.0.113.254.54321: Flags [S], seq 3470368251, win 64240, options [mss 1460,sackOK,TS val 2906122441 ecr 0,nop,wscale 7], length 0
IP 203.0.113.254.54321 > 203.0.113.1.49200: Flags [S.], seq 2999511574, ack 3470368252, win 65160, options [mss 1460,sackOK,TS val 451395122 ecr 2906122441,nop,wscale 7], length 0
IP 203.0.113.1.49200 > 203.0.113.254.54321: Flags [.], ack 1, win 502, options [nop,nop,TS val 2906122441 ecr 451395122], length 0

つまり、宛先 IP アドレスが 203.0.113.254 で TCP の宛先ポート番号も 54321 番だったことから、先ほど設定した nftables のルールに合致した。 その結果として Destinatio NAT の処理が実行されて宛先 IP アドレスが 203.0.113.254 から 192.0.2.1 に書き換えられたというわけ。

まとめ

今回は Network Namespace を使って作成したネットワーク上で、nftables を使って Destination NAT を試してみた。

参考

manpages.ubuntu.com

Network Namespace と nftables で Source NAT を試す

今回は Network Namespace で作ったネットワーク上で nftables 1 を使った Source NAT (Network Address Translation) を試してみる。 nftables は、Linux で長らく使われてきた iptables 2 などのプログラムを置き換えることを志向したフレームワーク。 nftables と iptables は、どちらも Linux の netfilter 3 という仕組みを使って実装されたフロントエンドに相当する。

netfilter は Linux カーネルのプロトコルスタックにおいて、処理のタイミング毎に用意されたフックポイントにコールバックを登録できる仕組みのこと。 登録されるコールバックの中でパケットやフレームを処理することで、ファイアウォールや NAT といった機能を実現できる。

Source NAT を使うと、少数のグローバルアドレスを、多数のプライベートアドレスで共有できるようになる。 つまり、限られた資源である IPv4 のグローバルアドレスを節約することができる。 なお、アドレスを節約するためには NAT の一種である NAPT (Network Address Port Translation) である必要がある。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic x86_64
$ nft --version
nftables v1.0.2 (Lester Gooch)

もくじ

下準備

あらかじめ必要なパッケージをインストールしておく。

$ sudo apt-get -y install nftables iproute2 tcpdump

nftables のサービスを稼働させる。

$ sudo systemctl start nftables
$ sudo systemctl enable nftables

nftables のカーネルモジュールがロードされていることを確認する。

$ lsmod | grep ^nf_tables
nf_tables             258048  0

Network Namespace でネットワークを作成する

まずは今回作成するネットワークの論理構成を以下に示す。

ネットワーク構成 (論理)

203.0.113.0/24 をグローバル、192.0.2.0/24 をプライベートなセグメントに見立てている。 Source NAT では、送信元 IP アドレスが 192.0.2.0/24 のパケットを router の持っている 203.0.113.254 に書き換える。

ここからは Network Namespace を使ってネットワークを作成していく。 まずは Network Namespace を作成する。

$ sudo ip netns add lan
$ sudo ip netns add router
$ sudo ip netns add wan

次に Network Namespace 同士をつなぐ veth インターフェイスを追加する。

$ sudo ip link add lan-veth0 type veth peer name gw-veth0
$ sudo ip link add wan-veth0 type veth peer name gw-veth1

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set lan-veth0 netns lan
$ sudo ip link set gw-veth0 netns router
$ sudo ip link set gw-veth1 netns router
$ sudo ip link set wan-veth0 netns wan

インターフェイスの状態を UP に設定する。

$ sudo ip netns exec lan ip link set lan-veth0 up
$ sudo ip netns exec router ip link set gw-veth0 up
$ sudo ip netns exec router ip link set gw-veth1 up
$ sudo ip netns exec wan ip link set wan-veth0 up

lan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec lan ip address add 192.0.2.1/24 dev lan-veth0
$ sudo ip netns exec lan ip route add default via 192.0.2.254

router について、インターフェイスに IP アドレスを付与する。 また、ルータとして動作するようにカーネルパラメータの net.ipv4.ip_forward1 を設定する。

$ sudo ip netns exec router ip address add 192.0.2.254/24 dev gw-veth0
$ sudo ip netns exec router ip address add 203.0.113.254/24 dev gw-veth1
$ sudo ip netns exec router sysctl net.ipv4.ip_forward=1

最後に wan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec wan ip address add 203.0.113.1/24 dev wan-veth0
$ sudo ip netns exec wan ip route add default via 203.0.113.254

nftables を設定する

ここからは nftables を使って Source NAT を設定していく。

nftables は基本的に nft(8) で設定する。 設定は nft list ruleset コマンドで確認できる。 初期状態では何も設定されていないため、結果は何も表示されない。

$ sudo ip netns exec router nft list ruleset

テーブルを追加する

まずは nft create table コマンドでテーブルを追加する。 テーブルはアドレスファミリとチェーンタイプを指定して追加する。

$ sudo ip netns exec router nft create table ip nat

上記ではアドレスファミリが ip でチェーンタイプが nat のテーブルを作っている。

テーブルを追加すると、次のように nft list ruleset の結果にテーブルが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
}

チェーンを追加する

続いて、処理のタイミングを表すチェーンをテーブルに追加する。 以下では先ほど作った ip nat のテーブルに POSTROUTING という名前でチェーンを追加している。 カッコ内は追加するチェーンの種類と、処理されるタイミングを示している。

$ sudo ip netns exec router nft add chain ip nat POSTROUTING { type nat hook postrouting priority srcnat\; }

チェーンを追加すると nft list ruleset の結果にチェーンが表示されるようになる。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
    }
}

ルールを追加する

最後に、具体的な処理の内容を表すルールをチェーンに追加する。 以下では先ほど作った ip nat テーブルの POSTROUTING チェーンにルールを追加している。

$ sudo ip netns exec router nft add rule ip nat POSTROUTING oifname "gw-veth1" ip saddr 192.0.2.0/24 masquerade

上記は送信元 IP アドレスが 192.0.2.0/24 で出力先のインターフェイスが gw-veth1 のときに IP マスカレードを実行するという意味になる。

ルールが追加されると nft list ruleset の実行結果は次のようになる。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
        oifname "gw-veth1" ip saddr 192.0.2.0/24 masquerade
    }
}

動作を確認する

必要な設定が全て終わったので、ここからは動作を確認しよう。

lan から wan の IP アドレスに宛てて ping を打ってみよう。

$ sudo ip netns exec lan ping 203.0.113.1
PING 203.0.113.1 (203.0.113.1) 56(84) bytes of data.
64 bytes from 203.0.113.1: icmp_seq=1 ttl=63 time=0.164 ms
64 bytes from 203.0.113.1: icmp_seq=2 ttl=63 time=0.110 ms
64 bytes from 203.0.113.1: icmp_seq=3 ttl=63 time=0.107 ms
...

別のターミナルを開いて、まずは lan のインターフェイスでパケットをキャプチャする。

$ sudo ip netns exec lan tcpdump -tnl -i lan-veth0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on lan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 203.0.113.1: ICMP echo request, id 23567, seq 11, length 64
IP 203.0.113.1 > 192.0.2.1: ICMP echo reply, id 23567, seq 11, length 64
IP 192.0.2.1 > 203.0.113.1: ICMP echo request, id 23567, seq 12, length 64
IP 203.0.113.1 > 192.0.2.1: ICMP echo reply, id 23567, seq 12, length 64
IP 192.0.2.1 > 203.0.113.1: ICMP echo request, id 23567, seq 13, length 64
IP 203.0.113.1 > 192.0.2.1: ICMP echo reply, id 23567, seq 13, length 64

この段階では、送信元 IP アドレスは 192.0.2.1 のまま。

続いては wan のインターフェイスでパケットをキャプチャしよう。

$ sudo ip netns exec wan tcpdump -tnl -i wan-veth0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.254 > 203.0.113.1: ICMP echo request, id 23567, seq 22, length 64
IP 203.0.113.1 > 203.0.113.254: ICMP echo reply, id 23567, seq 22, length 64
IP 203.0.113.254 > 203.0.113.1: ICMP echo request, id 23567, seq 23, length 64
IP 203.0.113.1 > 203.0.113.254: ICMP echo reply, id 23567, seq 23, length 64
IP 203.0.113.254 > 203.0.113.1: ICMP echo request, id 23567, seq 24, length 64
IP 203.0.113.1 > 203.0.113.254: ICMP echo reply, id 23567, seq 24, length 64

今度は送信元 IP アドレスが 203.0.113.254 になっている。 つまり、ちゃんと router の持っている IP アドレスに送信元が書き換えられていることが確認できる。

まとめ

今回は Network Namespace を使って作成したネットワーク上で、nftables を使って Source NAT を試してみた。

補足

iptables のルールを nftables のルールに変換する

nftables には、iptables など旧来のプログラムから移行するためのツールが用意されている。 たとえば iptables から移行する際には iptables-translate(8) が利用できる。 使用する際は iptables コマンドを iptables-translate コマンドに書き換えて実行する。 すると、nftables 版の設定に書き換えられた結果が表示される。

$ iptables-translate -t nat \
    -A POSTROUTING \
    -s 192.0.2.0/24 \
    -o gw-veth1 \
    -j MASQUERADE

ただし、利用する上で注意点がある。 iptables ではデフォルトで用意されているテーブルやチェーンがあった。 しかし nftables にはデフォルトで用意されるテーブルやチェーンが基本的にない。 そのため、必要に応じて自分で作る必要がある。

ネットワーク図の作成について

ネットワーク図は以下の nwdiag 4 の定義で作成した。

nwdiag {

  network {
    address = '192.0.2.0/24';
    lan[address = 'lan-veth0, 192.0.2.1'];
    router[address = 'gw-veth0, 192.0.2.254'];
  }

  network {
    address = '203.0.113.0/24';
    router[address = 'gw-veth1, 203.0.113.254'];
    wan[address = 'wan-veth0, 203.0.113.1'];
  }
}

参考

manpages.ubuntu.com