CUBE SUGAR CONTAINER

技術系のこと書きます。

Linux TC (Traffic Control) の netem (Network Emulator) で劣悪なネットワーク環境を再現する

今回は Linux TC (Traffic Control) に実装されている netem (Network Emulator) という qdisc (Queueing Discipline) を使って、劣悪なネットワーク環境を再現する方法について書いてみる。 Linux TC は、文字通り Linux のトラフィックを制御するための機能で、qdisc という形で様々な制御のやり方が実装されている。 その中でも netem という qdisc には、意図的にフレームの送出にディレイを入れたりドロップするといった機能が備わっている。 これを使うと、実際に環境を用意してテストするのがなかなか難しいようなネットワークの状況を再現できる。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.1 LTS"
$ uname -srm
Linux 5.15.0-53-generic aarch64
$ tc -V
tc utility, iproute2-5.15.0, libbpf 0.5.0
$ iperf3 -v
iperf 3.9 (cJSON 1.7.13)
Linux jammy 5.15.0-53-generic #59-Ubuntu SMP Mon Oct 17 18:55:29 UTC 2022 aarch64
Optional features available: CPU affinity setting, IPv6 flow label, SCTP, TCP congestion algorithm setting, sendfile / zerocopy, socket pacing, authentication

もくじ

下準備

まずは、使用するパッケージをインストールする。

$ sudo apt-get -y install iproute2 iputils-ping iperf3

今回は Network Namespace で作った仮想的なネットワーク環境で netem の機能を試す。 あらかじめ、ns1ns2 という Network Namespace を作っておく。

$ sudo ip netns add ns1
$ sudo ip netns add ns2

次に Virtual Ethernet インターフェイスで veth0veth1 を作って Network Namespace 同士をつなぐ。

$ sudo ip link add veth0 type veth peer name veth1
$ sudo ip link set veth0 netns ns1
$ sudo ip link set veth1 netns ns2
$ sudo ip netns exec ns1 ip link set veth0 up
$ sudo ip netns exec ns2 ip link set veth1 up

それぞれのインターフェイスに IP アドレスとして 192.0.2.1/24192.0.2.2/24 を付与する。

$ sudo ip netns exec ns1 ip address add 192.0.2.1/24 dev veth0
$ sudo ip netns exec ns2 ip address add 192.0.2.2/24 dev veth1

これで、ひとまず IP で疎通がある状態になった。 RTT に遅延はほとんどなく 100us を下回っている。

$ sudo ip netns exec ns1 ping -c 3 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.097 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.085 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.081 ms

--- 192.0.2.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2137ms
rtt min/avg/max/mdev = 0.081/0.087/0.097/0.006 ms

何もしない状態で iperf3 を使って帯域幅を確認しておく。 まずは、新しくターミナルを開いて ns2 の方でサーバを立ち上げる。

$ sudo ip netns exec ns2 iperf3 -s
-----------------------------------------------------------
Server listening on 5201
-----------------------------------------------------------

そして ns1 の方でクライアントを立ち上げる。 次のとおり、70Gbps ほど出ている。

$ sudo ip netns exec ns1 iperf3 -c 192.0.2.2
Connecting to host 192.0.2.2, port 5201
[  5] local 192.0.2.1 port 60428 connected to 192.0.2.2 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.00   sec  8.26 GBytes  70.9 Gbits/sec    0   3.07 MBytes       
[  5]   1.00-2.00   sec  8.68 GBytes  74.6 Gbits/sec    0   3.07 MBytes       
[  5]   2.00-3.00   sec  8.63 GBytes  74.1 Gbits/sec    0   3.07 MBytes       
[  5]   3.00-4.00   sec  8.72 GBytes  74.9 Gbits/sec    0   3.07 MBytes       
[  5]   4.00-5.00   sec  8.71 GBytes  74.8 Gbits/sec    0   3.07 MBytes       
[  5]   5.00-6.00   sec  8.60 GBytes  73.9 Gbits/sec    0   3.07 MBytes       
[  5]   6.00-7.00   sec  8.70 GBytes  74.8 Gbits/sec    0   3.07 MBytes       
[  5]   7.00-8.00   sec  8.62 GBytes  74.1 Gbits/sec    0   3.07 MBytes       
[  5]   8.00-9.00   sec  8.72 GBytes  74.9 Gbits/sec    0   3.07 MBytes       
[  5]   9.00-10.00  sec  8.73 GBytes  75.0 Gbits/sec    0   3.07 MBytes       
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  86.4 GBytes  74.2 Gbits/sec    0             sender
[  5]   0.00-10.04  sec  86.4 GBytes  73.9 Gbits/sec                  receiver

iperf Done.

サーバは、ひとまず立ち上げたままにしておこう。

レイテンシを大きくする

まずは、モバイルネットワークだったり通信先が地球の反対側にあるような、レイテンシの大きな状況を再現してみる。

Linux TC は tc(8) というコマンドを使って操作する。 そして、Linux TC の qdisc はネットワークインターフェイスに設定される。 試しに tc qdisc show サブコマンドを使って ns2veth1 インターフェイスに設定されている qdisc を確認してみよう。

$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc noqueue 0: root refcnt 2

当然だけど、初期状態ではインターフェイスに何も qdisc が設定されていない。

ここで、tc qdisc add サブコマンドを使って ns2veth1 に netem qdisc を設定してみよう。 qdisc の名前である netem の後ろには、その qdisc 固有のパラメータを指定する。 delay 200ms 100ms というのは、100ms のジッターのある 200ms のディレイを挿入する、という意味になる。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem delay 200ms 100ms

なお、netem の前に入っている root は「そのインターフェイスのルートの qdisc を設定する」という意味になる。 というのも、1 つのインターフェイスには複数の qdisc を親子関係のあるツリー構造で設定できるため。 そのため、操作する位置を指定する必要がある。 ただし、今回は簡単のために qdisc の位置は root しか扱わない。

さて、再度 tc qdisc show サブコマンドで確認すると、今度は qdisc として netem が設定されていることがわかる。

$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8002: root refcnt 2 limit 1000 delay 200ms  100ms

この状態で、最初に確認したのと同じように ping(8) を使ってみよう。 すると、今度は RTT が 200 ~ 300ms 前後にまで増加している。

$ sudo ip netns exec ns1 ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=300 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=326 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=287 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=292 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=202 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=303 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=262 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=292 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=216 ms
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=298 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9389ms
rtt min/avg/max/mdev = 202.402/277.962/326.268/37.638 ms

どうやら、ちゃんとディレイが入ったようだ。 これこそが netem の効果というわけ。

実験し終わったら、設定していた qdisc をインターフェイスから削除しよう。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

ここからは、同じように次の流れで各機能を説明していく。

  1. qdisc を設定する
  2. 動作を確認する
  3. qdisc を削除する

また、それぞれの設定を基本的には単独で試していくけど、実際には複数を組み合わせることもできる。

パケットロスを生じさせる

続いてはいわゆるパケロス 1 を起こしてみよう。 先ほどと同じように ns2veth1 に qdisc を設定する。 この点は、後述するすべての実験において変わらない。 変わるのは netem の設定が loss random 30% となっている点だ。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem loss random 30%

確認すると、ちゃんと qdisc が設定されている。 これで 30% の確率でパケットロスが生じるはず。

$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8002: root refcnt 5 limit 1000 loss 30%

実際に ping を打って試してみよう。

$ sudo ip netns exec ns1 \
    ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.138 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.101 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.162 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.150 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.114 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=0.093 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=0.110 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 7 received, 30% packet loss, time 9255ms
rtt min/avg/max/mdev = 0.093/0.124/0.162/0.024 ms

実行する毎に結果は変わるものの、上記ではちゃんと 30% のパケットが失われた。 なお、設定しているのが ns2 のインターフェイスなので、失われているのは戻りの ICMP Echo Reply になる。

実験し終わったら qdisc を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

データを破損させる

続いてはフレームのデータを破損させる。 これはようするにビットを化けさせるということ。 引き起こされる事象は、破損が生じた箇所によって異なる。 以下では corrupt 50% と設定することで 50% の確率でフレームのいずれかのビットが化けることになる。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem corrupt 50%
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8005: root refcnt 2 limit 1000 corrupt 50%

この状況で ping を打ってみよう。

$ sudo ip netns exec ns1     ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
ping: Warning: time of day goes back (-9007199254740929us), taking countermeasures
ping: Warning: time of day goes back (-9007199254740648us), taking countermeasures
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.000 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.184 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.178 ms
wrong data byte #29 should be 0x1d but was 0x19
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 19 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 35 36 37 
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.201 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.190 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.112 ms
wrong data byte #36 should be 0x24 but was 0x4
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 4 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 35 36 37 
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=0.209 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=0.209 ms
wrong data byte #35 should be 0x23 but was 0x21
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 21 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 35 36 37 
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=0.214 ms
wrong data byte #53 should be 0x35 but was 0x75
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 75 36 37 
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=0.220 ms
wrong data byte #45 should be 0x2d but was 0x29
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 29 2e 2f 
#48    30 31 32 33 34 35 36 37 

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9401ms
rtt min/avg/max/mdev = 0.000/0.171/0.220/0.064 ms

上記では、どうやらフレームやパケットのヘッダ部分が破損することはなかったようだ。 というのも、ヘッダが破損すればチェックサムの不一致によって破棄されるはずなので。 その代わり、ICMP のペイロードが破損したことで wrong data というエラーが記録されている。 たとえば icmp_seq=3 においては、0x1d であるはずの箇所が 0x19 だったらしい。

実験が終わったら qdisc を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

パケットを重複させる

続いてはパケットを重複させてみる。 これには duplicate という設定を使う。 以下では 50% の確率でパケット (フレーム) が重複するようになっている。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem duplicate 50%
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 800b: root refcnt 5 limit 1000 duplicate 50%

ping を打ってみよう。

$ sudo ip netns exec ns1     ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.163 ms
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.164 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.150 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.171 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.172 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.156 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.158 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.160 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.161 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.086 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=0.159 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=0.109 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=0.141 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=0.142 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=0.091 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, +5 duplicates, 0% packet loss, time 9359ms
rtt min/avg/max/mdev = 0.086/0.145/0.172/0.026 ms

上記で (DUP!) という表示が、重複したパケットの到着を表している。

実験が終わったら qdisc を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

パケットの順序を入れ替える

続いてはパケットの順序を入れ替えてみる。 いわゆるリオーダと呼ばれる事象だ。 以下では reorder 25% の設定で 25% の確率でパケットの順序を入れ替えている。 ただし、reorder を設定するときは delay の設定も必ず必要になるため 2s のディレイを入れている。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem delay 2s reorder 25%
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8007: root refcnt 2 limit 1000 delay 2s reorder 25% gap 1

ディレイも組み合わせる必要があるのは、リオーダを実装しているメカニズムに起因している。 基本的な考え方は次のとおり。

  1. あるフレームが netem qdisc にキューイングされる
  2. ディレイによって (1) のフレームの送出 (デキュー) が遅延される
  3. 別のフレームが netem qdisc にキューイングされる
  4. 一定の確率で (3) のフレームが netem qdisc から即座に送出される
  5. ディレイが完了した上で (1) のフレームが送出される

実際に ping を使って試してみよう。

$ sudo ip netns exec ns1 \
    ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.128 ms
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=2041 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=2024 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=2002 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.073 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=2010 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=2047 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=2046 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=2041 ms
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=2027 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9344ms
rtt min/avg/max/mdev = 0.073/1623.841/2046.608/811.998 ms, pipe 2

上記において icmp_seq=2icmp_seq=6 は順序が入れ替わっている。 また、TTL が順序の入れ替わっていないものに比べると極端に短いことも確認できる。 これは、前述した動作原理によって引き起こされている。

実験が終わったら qdisc の設定を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

帯域幅を狭くする

続いては帯域幅を狭くしてみる。 これには rate を設定すれば良い。 以下では帯域幅を 1Mbps に制限している。 limit はキューの大きさで、これが小さいと流量が大きいときに恐らくキュー溢れが生じて安定しないため念の為大きくしている。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem rate 1mbit limit 100k
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8001: root refcnt 2 limit 102400 rate 1Mbit

iperf3 を使って帯域幅を確認しよう。 サーバをもし止めてしまっていたときはもう一度立ち上げ直す。 実行時のポイントとしては -R オプションを使って、サーバからクライアントへダウンロードする方向でトラフィックを流しているところ。 というのも netem はフレームの送出 (egress) に作用するため。 クライアントからサーバへのアップロードの方向にトラフィックを流すと、帯域幅が制限される ns2 の egress は TCP の ACK がチョロチョロと流れるだけなので意味がない。

$ sudo ip netns exec ns1 iperf3 -R -c 192.0.2.2
Connecting to host 192.0.2.2, port 5201
Reverse mode, remote host 192.0.2.2 is sending
[  5] local 192.0.2.1 port 33828 connected to 192.0.2.2 port 5201
[ ID] Interval           Transfer     Bitrate
[  5]   0.00-1.00   sec   130 KBytes  1.06 Mbits/sec                  
[  5]   1.00-2.00   sec   119 KBytes   971 Kbits/sec                  
[  5]   2.00-3.00   sec   117 KBytes   961 Kbits/sec                  
[  5]   3.00-4.01   sec   116 KBytes   940 Kbits/sec                  
[  5]   4.01-5.02   sec   119 KBytes   967 Kbits/sec                  
[  5]   5.02-6.01   sec   116 KBytes   960 Kbits/sec                  
[  5]   6.01-7.00   sec   115 KBytes   945 Kbits/sec                  
[  5]   7.00-8.00   sec   116 KBytes   950 Kbits/sec                  
[  5]   8.00-9.02   sec   119 KBytes   962 Kbits/sec                  
[  5]   9.02-10.05  sec   122 KBytes   965 Kbits/sec                  
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.18  sec  2.50 MBytes  2.06 Mbits/sec    0             sender
[  5]   0.00-10.05  sec  1.16 MBytes   968 Kbits/sec                  receiver

iperf Done.

上記から、ちゃんと1Mbps に帯域幅が制限されていることが確認できる。

終わったら qdisc の設定を削除しよう。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

幅のあるディレイを加える

続いては、最初に実験したレイテンシを大きくするのと似ている。 ただし、今度はある程度の幅を持たせたランダム性のあるディレイを加える。 これには slot という設定を使えば良い。 以下では最小 100 最大 1000ms という幅のあるディレイを設定している。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem slot 100ms 1000ms
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8008: root refcnt 2 limit 1000 slot 100ms 1s

ping で動作を確認してみよう。

$ sudo ip netns exec ns1 \
    ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=1160 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=123 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=260 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=614 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=695 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=173 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=674 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=791 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=546 ms
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=971 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9130ms
rtt min/avg/max/mdev = 123.187/600.742/1159.726/320.913 ms, pipe 2

上記から 100 ~ 1000ms 前後のバラついたディレイが入っていることが確認できる。

終わったら qdisc の設定を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

まとめ

今回は Linux TC の netem qdisc に実装されている機能を一通り試した。 この機能を使えば、実際に用意するのが難しいような、ネットワークでごく稀に生じる事象も再現できる。


  1. 厳密にはフレームのレベルで起こっている事象ではあるものの面倒なので以降を含めあえて曖昧に書いている部分がある

Ubuntu で iputils をビルドして異なるバージョンの ping(8) を使う

Ubuntu にインストールされている ping(8) の挙動が、バージョンによって微妙に異なることに気づいた。 そこで、あらかじめインストールされているのとは異なるバージョンをビルドして動作を確認することにした。 今回は、その手順についてメモしておく。 なお、タイトルにある iputils というのは ping(8) が含まれるパッケージの名前を示している。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.4 LTS"
$ uname -srm
Linux 5.4.0-131-generic aarch64

現在 (2022-11-13) の Ubuntu 20.04 LTS にはバージョンが s20190709 の iputils がインストールされている。

$ ping -V
ping from iputils s20190709

下準備

まずはビルドするのに必要なパッケージ群をインストールしておく。

$ sudo apt-get -y install \
    build-essential \
    meson \
    libcap-dev \
    xsltproc

GitHub から tarball をダウンロードする

続いて、ビルドしたい iputils を確認して GitHub からダウンロードする。 今回は現時点 (2022-11-13) での最新リリース (20211215) を選んだ。

github.com

ダウンロードして展開する。

$ wget -O - https://github.com/iputils/iputils/archive/refs/tags/20211215.tar.gz | tar zxvf -

あとはビルドするだけ。

$ cd iputils-20211215/
$ ./configure && make

builddir ディレクトリ以下に、ビルドされたバイナリができる。

$ ls builddir/
arping       clockdiff      compile_commands.json  git-version.h  meson-logs     ping   rdisc@exe  tracepath@exe
arping@exe   clockdiff@exe  config.h               libcommon.a    meson-private  po     test
build.ninja  common@sta     doc                    meson-info     ninfod         rdisc  tracepath

試しにビルドされた ping(8) を使ってみよう。

$ ./builddir/ping/ping -V
ping from iputils 20211215
$ ./builddir/ping/ping -c 3 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=59 time=14.8 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=59 time=12.4 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=59 time=18.9 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2046ms
rtt min/avg/max/mdev = 12.395/15.365/18.869/2.669 ms

ちゃんと動いているようだ。

めでたしめでたし。

Python: category_encoders の CatBoostEncoder を用いた OrderedTS の算出と多値分類タスクへの拡張について

データ分析コンペなどでよく利用される Target Encoding という特徴量抽出 (Feature Extraction) の手法がある。 これは、ターゲット (目的変数) の情報に基づいて、カテゴリ変数ごとの期待値を説明変数として利用するもの。

Target Encoding には、いくつかの計算方法があり、中にはリーク (Data Leakage) のリスクが大きいものもある。 詳しくは、このブログでも以下のエントリで述べている。

blog.amedama.jp

今回のエントリでは、いくつかある計算方法の中でも OrderedTS (Ordered Target Statistics) について扱う。 OrderedTS の詳しい説明については、前述したエントリを参照してもらいたい。 本当にざっくりと説明すると、データに順序があると仮定して、各時点での期待値を説明変数とするやり方。 このやり方はリークのリスクが相対的に低いとされている。

このエントリでは、category_encoders というカテゴリ変数の特徴量抽出を扱ったパッケージにおいて OrderedTS の算出に使われる CatBoostEncoder というクラスの実装を見ていく。 さらに、CatBoostEncoder 自体は二値分類と回帰タスクにしか対応していないため、それを多値分類に拡張してみる。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6.1
BuildVersion:   21G217
$ python -V
Python 3.10.8
$ pip list | grep -i category-encoders
category-encoders 2.5.1.post0

もくじ

下準備

あらかじめ category_encoders と pandas をインストールしておく。

$ pip install category_encoders pandas

二値分類を扱ったサンプルコード

まずは、二値分類のデータを想定したデータを使って振る舞いを見ていく。 以下にサンプルコードを示す。 このサンプルコードでは、フルーツの銘柄のカテゴリ変数と、それに対応する何らかの二値の目的変数を持ったデータを扱う。 具体的には CatBoostEncoder を使って、学習データとテストデータを想定した内容に対してカテゴリ変数の OrderedTS を算出している。 目的変数は、たとえば美味しく感じたかどうかとでも考えてもらえれば良いと思う。 学習データを見ると、フルーツの銘柄ごとに美味しく感じたかどうかの割合 (期待値) が異なることが確認できる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
from category_encoders import CatBoostEncoder


def main():
    # カテゴリ変数と二値の目的変数から成る学習データを想定したデータフレームを用意する
    data = [
        ("apple", 0),
        ("apple", 0),
        ("apple", 1),
        ("banana", 0),
        ("banana", 1),
        ("banana", 1),
        ("cherry", 1),
        ("cherry", 1),
        ("cherry", 1),
    ]
    train_df = pd.DataFrame(data=data, columns=["fruits", "y"])
    train_x = train_df[["fruits"]]
    train_y = train_df["y"]

    # 集計に使うカテゴリ変数を "cols" 引数に指定する
    encoder = CatBoostEncoder(cols=["fruits"])

    # OrderedTS を求める
    encoder.fit(train_x, train_y)
    encoded_train = encoder.transform(train_x, train_y)

    # 結果を表示する
    train_df.loc[:, "ordered_ts"] = encoded_train
    print("== OrderedTS (train) ==")
    print(train_df)

    # ラベルのついていないテストデータを想定したデータフレームを用意する
    data = [
        ("apple",),
        ("apple",),
        ("banana",),
        ("banana",),
        ("cherry",),
        # unseen データ
        ("dates",),
    ]
    test_df = pd.DataFrame(data=data, columns=["fruits"])
    test_x = test_df[["fruits"]]

    # OrderedTS を求める
    encoded_test = encoder.transform(test_x)

    # 結果を表示する
    test_df.loc[:, "ordered_ts"] = encoded_test
    print("== OrderedTS (train) ==")
    print(test_df)


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行する。 実行結果を以下に示す。

$ python catboostencoder.py 
== OrderedTS (train) ==
   fruits  y  ordered_ts
0   apple  0    0.666667
1   apple  0    0.333333
2   apple  1    0.222222
3  banana  0    0.666667
4  banana  1    0.333333
5  banana  1    0.555556
6  cherry  1    0.666667
7  cherry  1    0.833333
8  cherry  1    0.888889
== OrderedTS (train) ==
   fruits  ordered_ts
0   apple    0.416667
1   apple    0.416667
2  banana    0.666667
3  banana    0.666667
4  cherry    0.916667
5   dates    0.666667

上記から、学習データに対して各時点での OrderedTS が付与されていることが確認できる。 たとえば「apple」は、最初のデータ (index: 0) については期待値が 0.666667 となっている。 しかし、その次のデータ (index: 1) では 0.333333 に下がっている。 そして、最後のデータ (index: 2) では 0.222222 まで下がった。 これは、各時点で得られた「apple」の目的変数を元にして、カテゴリの期待値が更新されながら計算されていることを意味する。

また、テストデータについては、学習データから得られた最終的な期待値を使って OrderedTS が求められている。 さらに、テストデータだけに登場する初見のデータ (dates) については全体の平均 (6 / 9 = 0.666667) で埋められている。

CatBoostEncoder の実装について

さて、基本的な考え方は分かった。 しかし、実際にどのように OrderedTS が求められているのかがまだ分からない。 先述の当ブログのエントリで扱ったナイーブな考え方では、最初の「apple」は NaN か 0 になるはずではないだろうか。

これには、少数のデータを含むカテゴリや、順序において最初の方のデータが極端な値を取らないようにするスムージングの処理が関係している。 ここからは CatBoostEncoder の実装について見ていこう。 以下は、現時点の最新バージョン (v2.5.1) の CatBoostEncoder の、学習データに対して OrderedTS を算出する処理である。

github.com

該当部分を以下に引用する。 初見では意図が分かりにくいと思うので、ここから一つずつ説明していく。

X[col] = (temp['cumsum'] - y + self._mean * self.a) / (temp['cumcount'] + self.a)

まず、上記で temp['cumsum'] が各時点での目的変数の累積和を表している。 そこから元の目的変数 y を引くことで、ある時点での計算からその時点の目的変数が含まれないようにしている。 これは、ある時点での計算にその時点の目的変数が含まれるとリークが生じる (その時点で目的変数は判明していないはずなので) ため。 これは言いかえると目的変数を 1 つ前方にシフトしているのと同義になる。

同様に temp['cumcount'] は目的変数の累積カウントを表している。 さて、ナイーブな実装であれば、OrderedTS は次のようにすれば良いはずだ。

X[col] = (temp['cumsum'] - y) / (temp['cumcount'])

先ほどの式を以下に再度示す。 見ると、上記の式に self._meanself.a という変数が追加された形になっていることがわかる。 つまり、これがスムージングのパラメータということになる。

X[col] = (temp['cumsum'] - y + self._mean * self.a) / (temp['cumcount'] + self.a)

self._mean には、すべてのカテゴリをまたいだ目的変数の平均が入っている。 そして、self.a はスムージングの強さを指定するハイパーパラメータ (デフォルトは 1) になっている。 そして、分子には「self._mean * self.a」が、分母には「self.a」が加えられている。 このスムージングの考え方を端的に言い表すと「そのカテゴリに、平均の値 (self._mean) を持ったレコードが、あらかじめ self.a 個だけ入っていることにする」となる。 そのため、先ほど各カテゴリの OrderedTS の値は、目的変数の全体の平均 (6/9 = 0.666667) から始まったわけだ。 最初から、目的変数の平均を持ったデータが 1 つだけ入っていることになっていたから。 つまり、よりスムージングを強くしたければ、self.a を大きくすることで、平均のデータがあらかじめたくさん入っていることにすれば良い。

ちなみに、ナイーブな OrderedTS は CatBoostEncoder のスムージングパラメータの a0 を指定することで求めることができる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
from category_encoders import CatBoostEncoder


def main():
    data = [
        ("apple", 0),
        ("apple", 0),
        ("apple", 1),
        ("banana", 0),
        ("banana", 1),
        ("banana", 1),
        ("cherry", 1),
        ("cherry", 1),
        ("cherry", 1),
    ]
    train_df = pd.DataFrame(data=data, columns=["fruits", "y"])
    train_x = train_df[["fruits"]]
    train_y = train_df["y"]

    # スムージングパラメータの「a」に 0 を指定することでナイーブな OrderedTS を求める
    encoder = CatBoostEncoder(cols=["fruits"], a=0)

    encoder.fit(train_x, train_y)
    encoded_train = encoder.transform(train_x, train_y)

    train_df.loc[:, "ordered_ts"] = encoded_train
    print("== OrderedTS (train) ==")
    print(train_df)

    data = [
        ("apple",),
        ("apple",),
        ("banana",),
        ("banana",),
        ("cherry",),
        ("dates",),
    ]
    test_df = pd.DataFrame(data=data, columns=["fruits"])
    test_x = test_df[["fruits"]]

    encoded_test = encoder.transform(test_x)

    test_df.loc[:, "ordered_ts"] = encoded_test
    print("== OrderedTS (test) ==")
    print(test_df)


if __name__ == '__main__':
    main()

実行結果は次のとおり。 各カテゴリの最初の値が NaN になっていたり、数値が入った項目についてもより極端な値になっている。

$ python catboostencoder.py
== OrderedTS (train) ==
   fruits  y  ordered_ts
0   apple  0         NaN
1   apple  0         0.0
2   apple  1         0.0
3  banana  0         NaN
4  banana  1         0.0
5  banana  1         0.5
6  cherry  1         NaN
7  cherry  1         1.0
8  cherry  1         1.0
== OrderedTS (test) ==
   fruits  ordered_ts
0   apple    0.333333
1   apple    0.333333
2  banana    0.666667
3  banana    0.666667
4  cherry    1.000000
5   dates    0.666667

これで CatBoostEncoder の具体的な実装がわかった。

多値分類タスクへの拡張について

さて、便利な CatBoostEncoder ではあるが、弱点もある。 それは、多値分類タスクにそのままでは対応していないところ。 Target Encoding を多値分類タスクに適用するためには、目的変数を One-Hot Encoding する必要がある。 つまり、目的変数が各クラスになる割合を One-vs-All な二値分類タスクに落としこむ。 クラスごとの二値分類タスクにした上で、それぞれで Target Encoding すれば良い。

事前に目的変数を One-Hot Encoding してクラスごとに Target Encoding する部分は愚直に実装することもできる。 ただ、それだと記述量が増えて煩雑になるので CatBoostEncoder のラッパークラスを MultiClassCatBoostEncoder として実現してみた。 以下にサンプルコードを示す。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
from category_encoders import CatBoostEncoder
from sklearn.preprocessing import OneHotEncoder
from sklearn.base import BaseEstimator
from sklearn.base import TransformerMixin


class MultiClassCatBoostEncoder(TransformerMixin, BaseEstimator):
    """CatBoostEncoder を多値分類タスクに適用するためのラッパークラス"""

    def __init__(self, *args, **kwargs):
        self._args = args
        self._kwargs = kwargs
        self._one_hot_encoder = OneHotEncoder()
        self._cat_boost_encoders = []

    def fit(self, X, y=None):
        if y is None:
            raise TypeError("fit() missing argument: ''y''")

        # 多値の目的変数を One-Hot 表現に直す
        y_onehot = self._one_hot_encoder.fit_transform(y.values.reshape(-1, 1))
        # 学習データに含まれるクラス数
        num_of_classes = y_onehot.shape[1]

        # クラスごとに CatBoostEncoder を学習する
        for i in range(num_of_classes):
            encoder = CatBoostEncoder(*self._args, **self._kwargs)
            encoder.fit(X, y_onehot[:, i].toarray().reshape(-1))
            self._cat_boost_encoders.append(encoder)

        return self

    def transform(self, X, y=None):
        # ラベルがあれば One-Hot 表現に直す
        y_onehot = (self._one_hot_encoder.transform(y.values.reshape(-1, 1))
                    if y is not None else
                    None)

        # クラスごとにエンコードしていく
        encoded_list = []
        for i, encoder in enumerate(self._cat_boost_encoders):
            if y_onehot is not None:
                # ラベルがある場合は学習データに対する適用
                encoded_series = encoder.transform(X, y_onehot[:, i].toarray().reshape(-1))
            else:
                # ラベルがない場合はテストデータに対する適用
                encoded_series = encoder.transform(X)
            encoded_list.append(encoded_series)

        # エンコードした結果を連結して返す
        concat_encoded = pd.concat(encoded_list, axis=1)
        # カテゴリの値を元にカラム名を設定する
        concat_encoded.columns = self._one_hot_encoder.categories_
        return concat_encoded

    def fit_transform(self, X, y=None, **fit_params):
        if y is None:
            raise TypeError("fit_transform() missing argument: ''y''")
        return self.fit(X, y, **fit_params).transform(X, y)


def main():
    # カテゴリ変数と多値の目的変数から成る学習データを想定したデータフレームを用意する
    data = [
        ("apple", 1),
        ("apple", 1),
        ("apple", 2),
        ("banana", 1),
        ("banana", 2),
        ("banana", 2),
        ("cherry", 2),
        ("cherry", 3),
        ("cherry", 3),
    ]
    train_df = pd.DataFrame(data=data, columns=["fruits", "y"])
    train_x = train_df[["fruits"]]
    train_y = train_df["y"]

    encoder = MultiClassCatBoostEncoder(cols=["fruits"])
    encoded_train = encoder.fit_transform(train_x, train_y)
    print("== OrderedTS (train) ==")
    print(encoded_train)

    # ラベルのついていないテストデータを想定したデータフレームを用意する
    data = [
        ("apple",),
        ("apple",),
        ("banana",),
        ("banana",),
        ("cherry",),
        # unseen データ
        ("dates",),
    ]
    test_df = pd.DataFrame(data=data, columns=["fruits"])
    test_x = test_df[["fruits"]]

    # 各クラスに対応したエンコーダで OrderedTS を求める
    encoded_test = encoder.transform(test_x)
    print("== OrderedTS (test) ==")
    print(encoded_test)


if __name__ == '__main__':
    main()

実行結果は次のとおり。 それぞれのデータが、各クラスに属する期待値がクラスごとに計算されている。 テストデータで初見のカテゴリについては、各クラスの平均で埋められている。

$ python multiclass.py 
== OrderedTS (train) ==
          1         2         3
0  0.333333  0.444444  0.222222
1  0.666667  0.222222  0.111111
2  0.777778  0.148148  0.074074
3  0.333333  0.444444  0.222222
4  0.666667  0.222222  0.111111
5  0.444444  0.481481  0.074074
6  0.333333  0.444444  0.222222
7  0.166667  0.722222  0.111111
8  0.111111  0.481481  0.407407
== OrderedTS (test) ==
          1         2         3
0  0.583333  0.361111  0.055556
1  0.583333  0.361111  0.055556
2  0.333333  0.611111  0.055556
3  0.333333  0.611111  0.055556
4  0.083333  0.361111  0.555556
5  0.333333  0.444444  0.222222

いじょう。

まとめ

今回は category_encoders の CatBoostEncoder が、どのように OrderedTS を計算しているのか確認した。 さらに、CatBoostEncoder を多値分類タスクに適用するための拡張についても紹介した。

Python: Pandas で np.float16 はサポートされていない

まったく知らなかったんだけど、Pandas はカラムの型として NumPy の float16 (16 ビット浮動小数点型) をサポートしていない。 これは、以下の Issue で説明されている。 どうやら、プラットフォームによっては float16 を利用できないため対応が難しいらしい 1

github.com

サポートされていなくても、カラムの型としては指定できる。 そして、なんとなく動いているようにも見えてしまうので知らないとハマる。 メモリを節約するために、高い精度が必要ないカラムには指定したくなる場面もあるだろうから。

今回は、どんな場面でこの問題に気づいたのか述べる。 使った環境は次のとおり。

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6
BuildVersion:   21G115
$ uname -srm
Darwin 21.6.0 arm64
$ python -V
Python 3.10.6
$ pip list | grep pandas
pandas          1.5.0

もくじ

下準備

あらかじめ Pandas をインストールしておく。

$ pip install pandas

そして、Python のインタプリタを起動しておく。

$ python

サンプルのデータとして次のようなデータフレームを用意する。 月ごとのフルーツの値段が縦持ち (Long Data) で表されているイメージ。

>>> import pandas as pd
>>> data = {
...     "yyyymm": [
...         "2021-09",
...         "2021-09",
...         "2021-09",
...         "2021-09",
...         "2021-10",
...         "2021-10",
...         "2021-10",
...     ],
...     "name": [
...         "apple",
...         "banana",
...         "cherry",
...         "dates",
...         "apple",
...         "banana",
...         "cherry",
...     ],
...     "price": [
...         100,
...         120,
...         200,
...         180,
...         110,
...         130,
...         210,
...     ]
... }
>>> df = pd.DataFrame(data)
>>> df.set_index(["yyyymm", "name"], inplace=True)
>>> df
                price
yyyymm  name         
2021-09 apple     100
        banana    120
        cherry    200
        dates     180
2021-10 apple     110
        banana    130
        cherry    210

問題を再現する

先ほどのデータを横持ち (Wide Data) にするために unstack() する。 現状では price カラムの型は int64 なので、この操作は成功する。

>>> df.dtypes
price    int64
dtype: object
>>> df.unstack()
         price                     
name     apple banana cherry  dates
yyyymm                             
2021-09  100.0  120.0  200.0  180.0
2021-10  110.0  130.0  210.0    NaN

ただし、2021-10dates の値が存在しないため NaN が挿入される。 NaN は整数型には無いので、自動的に型がキャストされている。

>>> df.unstack().dtypes
       name  
price  apple     float64
       banana    float64
       cherry    float64
       dates     float64
dtype: object

では、次にカラムの型を float16 にするとどうなるだろうか。

>>> import numpy as np
>>> df = df.astype({
...     "price": np.float16,
... })

先ほどと同じように unstack() してみよう。 すると、今度は例外になってしまった。

>>> df.dtypes
price    float16
dtype: object
>>> df.unstack()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/pandas/core/frame.py", line 9102, in unstack
    result = unstack(self, level, fill_value)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/pandas/core/reshape/reshape.py", line 477, in unstack
    return _unstack_frame(obj, level, fill_value=fill_value)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/pandas/core/reshape/reshape.py", line 506, in _unstack_frame
    return unstacker.get_result(
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/pandas/core/reshape/reshape.py", line 216, in get_result
    values, _ = self.get_new_values(values, fill_value)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/pandas/core/reshape/reshape.py", line 284, in get_new_values
    libreshape.unstack(
  File "pandas/_libs/reshape.pyx", line 21, in pandas._libs.reshape.__pyx_fused_cpdef
TypeError: No matching signature found

このように、カラムの型が float16 だと失敗する操作があることがわかる。

ちなみに、当たり前だけどカラムの型を float32 にした場合は上手くいく。 これは、サポートされている型なので。

>>> df = df.astype({
...     "price": np.float32,
... })
>>> df.dtypes
price    float32
dtype: object
>>> df.unstack()
         price                     
name     apple banana cherry  dates
yyyymm                             
2021-09  100.0  120.0  200.0  180.0
2021-10  110.0  130.0  210.0    NaN

また、欠損値がない場合にも上手くいく。 これは、どうやら問題は欠損値を埋める処理に起因しているようなので。

>>> df = df.astype({
...     "price": np.float16,
... })
>>> df.drop(index=("2021-09", "dates"))
                price
yyyymm  name         
2021-09 apple   100.0
        banana  120.0
        cherry  200.0
2021-10 apple   110.0
        banana  130.0
        cherry  210.0
>>> df.drop(index=("2021-09", "dates")).unstack()
         price              
name     apple banana cherry
yyyymm                      
2021-09  100.0  120.0  200.0
2021-10  110.0  130.0  210.0

このように、なんとなく動いているようにも見えてしまうので注意が必要になる。

まとめ

Pandas は NumPy の float16 をサポートしていないので、カラムの型に使うのはやめよう。


  1. 英語だと「モグラたたき」のことを「a rabbit hole」と表現するんだね

pyenv を使って Ubuntu に複数バージョンの Python をインストールする

今回は Python のインストールマネージャである pyenv を使って、簡単に複数のバージョンの Python を Ubuntu にインストールする方法を書く。 Python でソフトウェアを開発する際には、バージョン間の差異に配慮する必要がある。 そのため、開発する環境で使う Python のバージョンと実行する環境で使う Python のバージョンは、最低でもマイナーバージョンまでは揃えて開発することが望ましい。 そんなとき、開発ターゲットが色々とあるようなときはインストールマネージャが便利に使える。

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

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04 LTS
Release:    22.04
Codename:   jammy
$ uname -srm
Linux 5.15.0-41-generic aarch64
$ pyenv --version
pyenv 2.3.2

もくじ

pyenv をインストールする

pyenv のインストール方法はいくつかあるけど、今だと pyenv-installer のスクリプトを使うのが一番ラクそう。

$ curl https://pyenv.run | bash

インストールできたら、表示に沿って必要な設定をシェルに追加する。

$ cat << 'EOF' >> ~/.bashrc
export PYENV_ROOT="$HOME/.pyenv"
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
EOF
$ exec "$SHELL"

これで pyenv コマンドが使えるようになる。

$ pyenv --version
pyenv 2.3.2

あとは必要に応じて update しておこう。

$ pyenv update

pyenv で Python をビルドする

続いて pyenv を使って Python をビルドする。

ただし、その前に Python のビルドに必要となるパッケージをインストールする必要がある。 必要なパッケージは pyenv の wiki に記載がある。

github.com

上記を参照して必要なパッケージをインストールする。

$ sudo apt-get update; sudo apt-get install make build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \
libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev

あとは pyenv install コマンドを使って特定のバージョンの Python をビルドできる。

$ pyenv install <version>

今回はシステムの Python が 3.10 系になっている。

$ python3 -V
Python 3.10.4

そのため、例としては Python 3.9 系をビルドしてみよう。 現時点 (2022-07) での最新版である 3.9.13 を指定する。

$ pyenv install 3.9.13

なお、デフォルトではビルドの際のログが最低限しか残らない。 そのため、必要に応じて -v オプションをつけると良い。 テキストファイルにログを残したいときは tee(1) と組み合わせて次のようにする。

$ pyenv install -v 3.9.13 2>&1 | tee build.log

で、ログを確認してみると、実は先述の wiki のパッケージだけだとビルドされない Optional なモジュールがあることが分かる。

$ grep -A 3 "The necessary bits to build these optional modules were not found:" build.log 
The necessary bits to build these optional modules were not found:
_dbm                  _gdbm                                    
To find the necessary bits, look in setup.py in detect_modules() for the module's name.

もし気になるようならビルドに必要なパッケージをインストールし直した上で、再度ビルドすれば良い。

$ sudo apt-get install libgdbm-dev libdb-dev
$ pyenv uninstall -f 3.9.13
pyenv: 3.9.13 uninstalled
$ pyenv install -v 3.9.13 2>&1 | tee build.log

最後に、pyenv で使用する Python のバージョンを ~/.pyenv/version に記載する。 この中で system はシステムにインストールされているデフォルトのバージョンを指す。 複数のパッチバージョンなどをインストールしているときは、ここで使うものを指定できる。

$ cat << 'EOF' > ~/.pyenv/version
system
3.9.13
EOF

これで、python3.9 が実行できるようになった。

$ python3.9 -V
Python 3.9.13

もちろん、先ほど ~/.pyenv/version に書いた通り、システムの Python も引き続き使うことができる。

$ python3 -V
Python 3.10.4

同様のやり方で複数のバージョンの Python をインストールできる。 ちなみに、Miniforge など Conda 系の実装も pyenv からインストールできるので便利。

いじょう。

まとめ

今回は pyenv を使って複数のバージョンの Python を Ubuntu にインストールする方法を紹介した。 なお、pyenv は Ubuntu だけでなく macOS など、色々な Unix 系の OS で利用できる。

また、Python の仮想環境の機能は pyenv の本体には組み込まれていない。 そのため pyenv-virtualenv プラグインを入れたり、自分で venv や virtualenv と組み合わせる必要がある。

blog.amedama.jp

Ubuntu の APT で入る Python 仮想環境系のパッケージを使う

最近は Docker などのコンテナ技術の台頭もあって、プログラミング言語に固有の仮想環境を使う人は以前より減った印象がある。 とはいえ、手元でササッと検証するときなどには便利なことに変わりはない。 今回は、Ubuntu を使って APT で入る Python の仮想環境系のパッケージについてまとめておく。 具体的には以下のとおりで、Conda 系に関しては扱わない。

  • python3-venv
  • python3-virtualenv
  • python3-virtualenvwrapper

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

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04 LTS
Release:    22.04
Codename:   jammy
$ uname -srm
Linux 5.15.0-41-generic aarch64
$ dpkg -l | egrep "(python3-venv|python3-virtualenv)"
ii  python3-venv                    3.10.4-0ubuntu2                         arm64        venv module for python3 (default python3 version)
ii  python3-virtualenv              20.13.0+ds-2                            all          Python virtual environment creator
ii  python3-virtualenv-clone        0.3.0-2                                 all          script for cloning a non-relocatable virtualenv (Python3)
ii  python3-virtualenvwrapper       4.8.4-4                                 all          extension to virtualenv for managing multiple environments (Py3)

もくじ

python3-venv

最初に紹介するのは Python が組み込みで提供する仮想環境の機能である venv モジュール。 Ubuntu の場合は、機能が固有のパッケージに分離されているので、個別にインストールする必要がある。

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

$ sudo apt-get install python3-venv

これで、Python の標準ライブラリとして venv モジュールが使えるようになる。 仮想環境を作るには次のようにする。 <name> には仮想環境の名前を入れるけど、これは要するに後述するディレクトリの名前になる。

$ python3 -m venv <name>

例えば example という名前で環境を作る。

$ python3 -m venv example

すると、実行したディレクトリに example というディレクトリができる。 この中に Python の実行環境が作られる。

$ find example -maxdepth 2 
example
example/bin
example/bin/pip3.10
example/bin/Activate.ps1
example/bin/python3
example/bin/python3.10
example/bin/pip
example/bin/activate
example/bin/activate.csh
example/bin/pip3
example/bin/activate.fish
example/bin/python
example/include
example/lib
example/lib/python3.10
example/pyvenv.cfg
example/lib64

環境を使うにはディレクトリの bin ディレクトリの中にある activate ファイルを source コマンドで読み込む。

$ source example/bin/activate

仮想環境を有効にして pip3 コマンドを使ってインストールされているパッケージを表示してみる。 すると、パッケージがほとんど入っていないまっさらな環境であることがわかる。

(example) $ pip3 list
Package    Version
---------- -------
pip        22.0.2
setuptools 59.6.0

あとはここに任意のパッケージをインストールして使えば良い。 これなら、システムの環境を汚すことなく検証ができる。

仮想環境から抜けるときは deactivate コマンドを実行する。

$ deactivate 

仮想環境を削除したいときは、単に前述のディレクトリを削除すれば良い。

python3-virtualenv

次に紹介するのは virtualenv パッケージで、こちらは venv よりも昔からある。 とはいえ、オプションなどを含めて使い勝手はほとんど venv と変わらない。

ひとまずインストールしておこう。

$ sudo apt-get install python3-virtualenv

仮想環境を作るときは virtualenv コマンドを使う。

$ virtualenv <name>

今回も example という名前で仮想環境を作ってみる。 なお、先ほど venv で作ったディレクトリが残ったままだとうまくいかないはずなので、必要に応じて削除する。

$ virtualenv example

実行すると、こちらも example という名前のディレクトリの中に Python の実行環境が作られる。

$ find example -maxdepth 2 
example
example/bin
example/bin/pip3.10
example/bin/wheel3
example/bin/deactivate.nu
example/bin/python3
example/bin/pip-3.10
example/bin/activate.nu
example/bin/activate_this.py
example/bin/wheel-3.10
example/bin/python3.10
example/bin/pip
example/bin/activate.ps1
example/bin/activate
example/bin/activate.csh
example/bin/wheel3.10
example/bin/wheel
example/bin/pip3
example/bin/activate.fish
example/bin/python
example/.gitignore
example/lib
example/lib/python3.10
example/pyvenv.cfg

仮想環境を有効にするやり方も、venv と同じ。

$ source example/bin/activate

仮想環境の中ではインストールされているパッケージがまっさらなのも同じ。

(example) $ pip3 list
Package    Version
---------- -------
pip        22.0.2
setuptools 59.6.0
wheel      0.37.1

仮想環境から抜ける方法も変わらない。

$ deactivate 

仮想環境を削除するのもディレクトリを削除すれば良いだけ。

python3-virtualenvwrapper

続いて紹介する virtualenvwrapper は、その名のとおり virtualenv のラッパーとして動作するスクリプトになっている。

ひとまずインストールする。

$ sudo apt-get install python3-virtualenvwrapper

インストールできたら、パッケージに含まれるシェルスクリプトを source コマンドであらかじめ読み込む。 これは、必要に応じてシェルの設定ファイルに記述すれば良い。

$ source /usr/share/virtualenvwrapper/virtualenvwrapper.sh

仮想環境を作るには mkvirtualenv コマンドを使う。

$ mkvirtualenv <name>

先ほどと同じように example という名前で作る場合には、次のとおり。

$ mkvirtualenv example

virtualenvwrapper の場合は、カレントワーキングディレクトリの下に仮想環境の入ったディレクトリが作られることはない。 また、環境を作った時点で自動的に環境がアクティベートされる。

(example) $ pip3 list
Package    Version
---------- -------
pip        22.0.2
setuptools 59.6.0
wheel      0.37.1

仮想環境から抜ける方法は virtualenv と変わらない。

(example) $ deactivate 

仮想環境のディレクトリは、デフォルトではホームディレクトリに .virtualenvs というパスにある。 以下に example というディレクトリが確認できる。

$ ls -1 ~/.virtualenvs/
example
get_env_details
initialize
postactivate
postdeactivate
postmkproject
postmkvirtualenv
postrmvirtualenv
preactivate
predeactivate
premkproject
premkvirtualenv
prermvirtualenv

作成済みの仮想環境をまた有効にするときは workon コマンドを使う。

$ workon example
(example) $

仮想環境を削除するときは、あらかじめ仮想環境から抜けた上で rmvirtualenv コマンドを実行する。

(example) $ deactivate
$ rmvirtualenv example

このように virtualenvwrapper は、あくまで virtualenv を便利にするためのラッパースクリプトに過ぎない。

いじょう。

Ubuntu の APT で特定パッケージの依存関係を調べる

今回は Debian 系のパッケージ管理で使われる APT (Advanced Packaging Tool) で特定パッケージの依存関係を調べる方法について。 結論から先に述べると apt-cache depends サブコマンドと apt-cache rdepends サブコマンドを使って調べられる。

使った環境は次のとおり。 最近の Ubuntu であれば特にディストリビューションのバージョンに依存する話ではないはず。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04 LTS
Release:    22.04
Codename:   jammy
$ uname -srm
Linux 5.15.0-41-generic aarch64
$ apt --version
apt 2.4.5 (arm64)

もくじ

依存しているパッケージを調べる

特定のパッケージが依存しているパッケージを調べるには apt-cache depends コマンドが使える。 <pkg-name> にはパッケージ名が入る。

$ apt-cache depends <pkg-name>

例えば python3 パッケージで試してみよう。

$ apt-cache depends python3
python3
  PreDepends: python3-minimal
  Depends: python3.10
  Depends: libpython3-stdlib
  Suggests: python3-doc
  Suggests: python3-tk
  Suggests: python3-venv
  Replaces: python3-minimal

上記において、実際に依存しているパッケージが PreDependsDepends として表示される。 Suggests は推奨なので無くても動作はする。

依存されているパッケージを調べる

反対に、そのパッケージがどのパッケージから依存されているか知りたいときは apt-cache rdepends サブコマンドを使う。

$ apt-cache rdepends <pkg-name>

今度も python3 パッケージで試してみよう。 ただし、めちゃくちゃ数が多いので head(1) を使って先頭だけ表示する。

$ apt-cache rdepends python3 | head
python3
Reverse Depends:
  python3-drslib
  cockpit-ws
  cockpit-storaged
  virtualbox
  virtualbox
  zfs-test
  xdg-desktop-portal-tests
  systemd-tests

なお、同じパッケージ名が複数出るときは、候補となるパッケージのバージョンごとに出てるっぽい

依存(して|されて)いるパッケージを一度に調べる

ちなみに apt-cache showpkg サブコマンドを使えば依存関係を一度に見ることもできる。 ただし、依存関係以外の情報も色々と載っているので、依存関係だけ知りたいときは少し見にくいかも。

$ apt-cache showpkg <pkg-name>

python3 パッケージを試すなら次のようにする。

$ apt-cache showpkg python3

上記を実行すると依存しているパッケージが Dependencies として、依存されているパッケージが Reverse Depends として確認できる。

いじょう。