CUBE SUGAR CONTAINER

技術系のこと書きます。

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

battery で Apple Silicon な MacBook のバッテリー充電に上限を設ける

リチウムイオン電池を使ったバッテリーは、一般的に残量が 0% や 100% の付近にあると劣化が進みやすい。 また、充電サイクルの回数が増える毎に、少しずつではあるが着実に劣化していく。 バッテリーが劣化すると、設計上の容量よりも電気を蓄える力が落ちて機器の稼働時間の短縮につながる。

最近の macOS には、バッテリーの寿命を延ばす目的で「バッテリー充電の最適化」という機能がある。 これは、機器の使われ方をデバイスが学習して、なるべく残量が 100% の状態を維持し続けないようにするもの。 この機能を有効にしていると、たまに充電が 80% 前後でしばらく止まることがある。 しかし、この機能を使ってもユーザが明示的に充電を止めることはできない。

そこで、今回は battery というアプリケーションを紹介する。 このアプリケーションを使うと Apple Silicon な MacBook で明示的にバッテリーの充電に上限を設定できる。

github.com

充電中に指定したバッテリーの残量まで到達すると、充電が自動で停止してそれ以上は増えない。 また、充電が停止している間も動作用の電源はケーブルから供給され続ける。 そのため、バッテリーの充電サイクルがむやみに増加することもない。 結果として、バッテリーの寿命を伸ばせる可能性がある。

もくじ

下準備

battery のインストール方法はいくつかある。 その中でも Homebrew からインストールするのが、おそらく一番カンタンだと思う。

$ brew install --cask battery

battery を実行する

battery は CUI と GUI の両方のアプリケーションがある。

GUI のアプリケーションは /Applications 以下にインストールされる。

$ open /Applications/battery.app

アプリケーションを起動すると、ツールバーにバッテリーのアイコンが現れる。 この状態で、自動でバッテリーの充電に 80% の上限が設定される。 設定は SMC (System Management Controller) に書き込まれるため、マシンを再起動しても設定はそのままになるようだ。

CUI に関しては battery というコマンドがインストールされる。 このコマンドで充電される上限を設定することもできる。 というより GUI は、この CUI の単なるラッパーのようだ。

たとえば battery status コマンドでバッテリーの充電状態を確認できたりもする。 以下は 80% に到達したことで充電が停止した際のログ。

$ battery status
10/15/23-17:19:32 - Battery at 80% (attached; remaining), smc charging disabled
10/15/23-17:19:32 - Your battery is currently being maintained at 80%

ちなみに上記のコマンドは、内部的に smc-command という実装を使っているようだ。 これは Mac の SMC に対して特定のキー・バリューを書き込むもの。

github.com

なお、SMC に書き込みをするとファンの回転数の制御などもできる。 そのため、上記のリポジトリには「使い方によってはマシンに不可逆なダメージを与えうるので何があっても自己責任だよ」という注意書きがある。

まとめ

今回は battery というアプリケーションを使って Apple Silicon な MacBook のバッテリー充電に上限を設定する方法を書いた。

Python: tarfile で tar ファイルを圧縮・展開する

Python の標準ライブラリには tarfile というモジュールがある。 このモジュールを使うと tar 形式で複数のファイルをまとめることができる。 また tarfile モジュールは gzip や bzip2 といった形式の圧縮・展開もサポートしている。 今回は、そんな tarfile モジュールで、利用する場面の多い tar 形式でまとめて gzip 形式で圧縮したファイル (tar.gz) を扱ってみる。

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

$ sw_vers
ProductName:        macOS
ProductVersion:     13.5
BuildVersion:       22G74
$ python -V
Python 3.10.12
$ tar --version
bsdtar 3.5.3 - libarchive 3.5.3 zlib/1.2.11 liblzma/5.0.5 bz2lib/1.0.8

もくじ

tar ファイルを展開する

まずは extraction.txt というテキストファイルが 1 つ入った tar.gz ファイルを用意する。 ファイルは tar(1) コマンドを使って作成して、名前は extraction.tar.gz にした。

$ echo "Hello, World" > extraction.txt
$ tar czvf extraction.tar.gz extraction.txt 
a extraction.txt
$ file extraction.tar.gz 
extraction.tar.gz: gzip compressed data, last modified: Tue Aug 20 03:03:04 2023, from Unix, original size modulo 2^32 2048

上記のファイルを Python の tarfile モジュールで展開してみる。 以下のサンプルコードでは、アーカイブに含まれるファイルのバイト列を読み取ってファイル名と共に出力している。 tarfile.open() 関数は、モードに "r" を指定すると自動的に圧縮アルゴリズムを読み取って展開の処理をしてくれる。

"""任意の圧縮方式を使った tar ファイルを展開するサンプルコード"""

import tarfile


def main():
    filepath = "extraction.tar.gz"
    with tarfile.open(filepath, mode="r") as tar:
        # アーカイブに含まれるファイルの情報を一覧で取得する
        members: list[tarfile.TarInfo] = tar.getmembers()
        for tar_info in members:
            # ファイル名または TarInfo を指定してファイルオブジェクトにアクセスできる
            with tar.extractfile(tar_info) as file_fp:
                # ファイルの内容を出力する
                print(f"{tar_info.name}: {file_fp.read()}")


if __name__ == "__main__":
    main()

上記を実行してみよう。

$ python extraction.py
extraction.txt: b'Hello, World\n'

ちゃんと含まれている extraction.txt に "Hello, World" という文字列に解釈できるバイト列が含まれることが確認できた。

tar ファイルを作成する

続いては tar ファイルを作成するサンプルコードを示す。 tarfile モジュールの API は、すでにファイルシステムに存在するファイルを扱うものが多い。 しかし、オンメモリのデータをアーカイブにすることも、もちろんできる。 以下では io.BytesIO を使ってファイルライクオブジェクトを作成し、それをアーカイブに含めている。 アーカイブは compression.tar.gz という名前で、アーカイブに含まれるファイル名は compression.txt にした。 なお、gzip 形式で圧縮する場合には tarfile.open() 関数の mode 引数に "w:gz" を指定する。

"""gzip 形式で圧縮した tar ファイルを作成するサンプルコード"""

import tarfile
import io


def main():
    # アーカイブに含めるファイルの内容を用意する
    file_buffer = io.BytesIO()
    file_buffer.write("Hello, World\n".encode("ascii"))
    file_buffer.seek(0)

    # "w:gz" モードでファイルを開くことで gzip で圧縮した tar ファイルになる
    filepath = "compression.tar.gz"
    with tarfile.open(filepath, mode="w:gz") as tar:
        # アーカイブに含めるファイルの名前を指定する
        tar_info = tarfile.TarInfo(name="compression.txt")
        # アーカイブに含めるファイルのサイズを指定する
        tar_info.size = len(file_buffer.getvalue())
        # アーカイブにファイルを追加する
        tar.addfile(tar_info, fileobj=file_buffer)


if __name__ == "__main__":
    main()

上記を実行してみよう。

$ python compression.py

すると compression.tar.gz という名前でアーカイブのファイルができる。

$ file compression.tar.gz 
compression.tar.gz: gzip compressed data, was "compression.tar", last modified: Tue Aug 20 10:50:49 2023, max compression, original size modulo 2^32 10240

tar(1) コマンドを使って展開してみよう。

$ tar zxvf compression.tar.gz              
x compression.txt

すると compression.txt というファイルができる。 内容を確認してみよう。

$ cat compression.txt        
Hello, World

ちゃんとサンプルコードで使った文字列が書き込まれている。

ちなみに、今回はアーカイブの直下にファイルを配置した。 もし、展開したときにファイルをディレクトリに入れたいときは TarInfo の引数 name にスラッシュを含めよう。

まとめ

今回は Python の tarfile モジュールを使って tar ファイルを圧縮・展開してみた。

参考

docs.python.org