kawasin73のブログ

技術記事とかいろんなことをかくブログです

ネストした JSON を CSV に自動変換する Python ライブラリを作った

プログラマーあるある、なにかと独自のミニ言語を作りがち。どうも、かわしんです。どうしても簡潔にやりたいことを表現するためにミニ言語つくりがちですよね。JSON で構文作ると長いし。

さて、ACES Inc. という東大の松尾研究室発の AI ベンチャーがあるのですが、そこの創業者メンバーと学科の同期だったので最近お手伝いしながらアーキテクチャ設計をしたり Python をゴリゴリ書いたりしています。ちなみに僕はディープラーニング機械学習もしてないです。

その中で推論結果が dictlist を組み合わせたデータ構造で返ってくるのですが、それを JSONCSV の両方で保存したいという仕様がありました。JSON への変換は json.dumps() を使えば一発なので自明ですが、CSV への変換は list が含まれた時にそれをどのように展開するかが自明ではありません。

推論アルゴリズムはたくさんあってそれごとに CSV への変換コードを書くのは効率的でないので、CSV へ自動で変換するライブラリを実装しました。

github.com

特徴としては、以下のようなものが挙げられます。

  • 配列を CSV の複数行に展開する
  • 展開された配列のインデックス番号の出力に対応
  • データ構造を解析して CSV の列情報 fieldnames を自動で生成

例としては以下のような感じです。これで大体の使い方を感じ取ってください。

import io
from nested_csv import NestedDictWriter
data = [
  {"hello": {"world": "value0"}, "list": [[1,2,3], [4,5,6]], "fixed": [1,2]},
  {"hello": {"world": "value1"}, "list": [[7,8], [10,11]], "fixed": [3,4]},
]
fieldnames = ['hello.world', 'list[id]', 'list[][id]', 'list[][]', 'fixed[1]']
file = io.StringIO()
w = NestedDictWriter(file, fieldnames)
w.writeheader()
w.writerows(data)
file.seek(0)
file.read()
# hello.world,list[id],list[][id],list[][],fixed[1]
# value0,0,0,1,2
# value0,0,1,2,2
# value0,0,2,3,2
# value0,1,0,4,2
# value0,1,1,5,2
# value0,1,2,6,2
# value1,0,0,7,4
# value1,0,1,8,4
# value1,1,0,10,4
# value1,1,1,11,4

既存のライブラリ

まず、Python には標準の csv パッケージがあります。csv パッケージには csv.writercsv.DictWriter が用意されています。

csv — CSV File Reading and Writing — Python 3.8.0 documentation

csv.writer は各行を writerow() メソッドにデータのリストを渡すことで出力します。リストの各要素が各列の要素に対応します。

csv.DictWriter は辞書のキーの一覧のリスト(fieldnames)を渡して初期化し、各行を writerow() メソッドにデータの辞書(dict)を渡すことで出力します。各列の順序は初期化時に渡したキーのリストの順序になります。

しかし、残念ながら csv.DictWriter はネストした辞書形式に対応していません。そこでネストしたデータ構造に対応しているものを Github で探すと、あるにはあるのですがネストした辞書をフラットな辞書に変換する程度のことしかしていなくて、辞書に含まれた リストの対応 が考慮されていません。

そこでネストした辞書にも辞書に含まれたリストにも対応する CSV 変換パッケージを作ることにしました。

nested_csv パッケージ

github.com

インターフェイスとしては、csv パッケージの csv.DictWriter を踏襲して __init__() writerow() writerows() writeheader() を同じような引数体系にしました。ただし、csv.DictWriter は継承するのではなく内部にインスタンスを持って利用し、ネストしたデータ構造をフラットな辞書形式に変換して csv.DictWriter.writerow() に渡すという方針にしました。

一番の特徴としてはネストしたリストに対応するということです。ネストしたリストをどのように出力するかはかなり悩みましたが、 POSIX 原理主義過激派 の方が書かれた JSONシェルスクリプトでパースする以下の記事の考え方を参考にして、SQL でいう OUTER JOIN のような形で 1 つのデータを複数行に展開 するようにしました。

jq、xmllintコマンドさようなら。俺はパイプが好きだから - Qiita

リストを扱うために、[][id][<number>] の 3 つの文法を導入しました。詳しい文法の中身は後述します。

また、データの型をリスト fieldnames にして初期化するわけですが、いちいちこの fieldnames を自分の手で設定するのは面倒臭いです。ここは自動化できるため generate_fieldnames() という関数を用意して実際のデータを 1 つ渡すと構造を解析して自動で fieldnames を生成するようにしました。

あと、Python 3.5 3.6 3.7 で正しく動くことを確認しています。また、全て標準パッケージ で実装されているので余計なライブラリのインストールは必要ありません。

fieldnames の記法

generate_fieldnames() を利用すれば自動で fieldnames を生成できますが、以下の2つの場合には CSV への変換フォーマットを設定する必要があります。

  • 推論結果に配列が含まれる場合
    • 配列の要素数 0 要素の時に自動型生成がされない
  • CSV の列の順序に意味がある時
    • 型自動生成では、各キーを文字列の辞書順に並べて生成する

これらに当てはまる時は、fieldnames となる文字列のリストを指定する必要があります。その記法は以下の通りです。

オブジェクトのキー

  • オブジェクトの各キーは、. (ドット)区切りで指定する
JSON : {"a": {"b": 1, "c": 2}}
fieldnames : ["a.b", "a.c"]
--- CSV ---
1,2

素数の固定された配列

  • 素数の固定された配列は、[<index>] で指定する
JSON : {"a": [1,2], "b": [0]}
fieldnames : ["a[0]", "a[1]", "b[0]"]
--- CSV ---
1,2,0

素数不定な配列

  • 素数不定な配列は、[] で指定する
    • [] の配列の要素は CSV での複数の行に展開されて表示される
    • 1 つのフィールドに複数の [] を指定することもでき、その場合はそれぞれの全ての組み合わせが CSV の行に展開される
    • 複数のフィールド間で配列の位置の整合性を取る必要がある。以下の例はエラーになる
      • "a[]" + "a.b" : 配列表現とキー表現で不整合
      • "a[]" + "b.c[]" : 配列が異なる階層にある場合(CSV への展開が論理的にできないため)
      • "a[][]" + "b[].c[]" : 配列が異なる階層にある場合(全ての配列が同じ階層である必要がある)
    • 異なるフィールドで同じ階層に配列がある場合は、それらのフィールドの中で最長の配列の長さの回数 CSV に展開される。その際短い配列の中身は `` (空文字)として出力される
JSON : {"a": [[{"x": 1}], [{"x": 2}, {"x": 3}]], "b": [4, 5, 6], "c": [[7, 8, 9]]}
fieldnames : ["a[][].x", "b[]", "c[][]"]
--- csv ---
1,4,7
,4,8
,4,9
2,5,
3,5,
,5,
,6,
,6,
,6,

素数不定な配列のインデックス番号

  • [] で指定した配列の順序を [id] で出力することができる
    • ただし、前項の [] で登録されていない配列に対する [id] を指定することはできない
    • また、前項の [] と整合性の取れない [id] を指定することはできない
JSON: {"a": [[1,2,3], [4,5]}
fieldnames : ["a[id]", "a[][id]", "a[][]"]
--- csv ---
0,0,1
0,1,2
0,2,3
1,0,4
1,1,5
1,2,

工夫した点

工夫した点は 3 点あります。

1 点目は、内部での型情報の構造の持ち方です。fieldnamesインスタンスの初期化時にコンパイルされて、構造の整合性のチェックを行った上で writerow() で参照しやすいような形に変換されます。これによって複数回呼ばれる writerow() で素早く CSV を出力できるようにしています。

そのためにリストのためのデータ構造が 摩訶不思議なもの になってしまいました。全部タプルとリストでデータ構造を作ったからで適切な名前をつければマシになるとは思いますが、辞書とかクラスとかはなんかパフォーマンスが悪くなりそうなので使ってません。

2 点目は、複数ネストしたリストの展開です。リストが何段ネストするかはわからないため、可変の深さの for 文を実装する必要があります。これ、スタックとか使って自力で実装するのめんどくさいなと思ってましたが、Python には itertools.product という便利な Util 関数があり、これを使って実現できました。

3 点目は、必要のない展開処理をスキップするようにしたことです。可変長のリストが含まれる場合は、シンプルな辞書の値は複数行に渡って同じものが出力されます。また、ネストしたリストを出力する場合も深いリストを出力している間は浅いリストの値は変わりません。そのため、複数の出力行に渡って 値のキャッシュ current を持っておき、更新するリストの値だけを変更して無駄な代入をスキップするようにしました。

最後に

機械学習の分野では依然として CSV ファイルでの出力が好まれる現場があるそうです。複雑な構造の JSON から CSV に変換するのって結構悩みがちですが、このパッケージを使えば楽に自動で変換することができるので、ぜひ使っていただきたいです。

また、将来への TODO として、CSV を読み取る NestedDictReader も作れればいいかなという野望もあります。

以前 Twilter を作った時も独自のフィルター言語を策定して実装しましたが、こういう単一目的に特化した小さな文法って結構便利なんですよね・・・。

Twitter をフィルタリングする Twilter を作った - kawasin73のブログ

現場からは以上です。

GitHub Actions Meetup Tokyo β で登壇しました。

先頭を 駆くる者には 落とし穴 ハマっては埋め ハマっては埋め

どうも、かわしんです。昨日サイボウズ株式会社で開催された「GitHub Actions Meetup Tokyo β」という非公式の Github Actions の勉強会で LT をしてきました。

gaugt.connpass.com

発表スライドはこれです。

内容としては Github Actions のダメなところをディスるというものでしたが、会場に到着すると非公式のイベントなのに Github の中の人がいらっしゃっるというアクシデントがあり、平謝りしながら発表をすることになりました。

発表した内容は以前のこの記事の内容です。

kawasin73.hatenablog.com

Github Actions の一番のセキュリティ的な問題点として、バージョンを Git タグで指定しているため 3rd Party の Action が差し替えられる危険性があることを指摘しましたが、フィードバックでコミットハッシュを指定すれば差し替えられることはないということを聞きました。確かにその通りなので、fork せずにコミットハッシュを直に設定することでしのいでいこうと思います。

また、初めての試みとして一首詠んでから LT を始め、最後に一首詠んで締めてみましたがなかなかウケが良かったのがよかったです。

最後に発表中のツイートを引用して筆をおきたいと思います。

Github Actions でハマった点と解決方法

先頭を 歩く者には 落とし穴 ハマっては埋め ハマっては埋め

どうも、かわしんです。先駆者ってのはかっこいいとは思いますが、僕自身は新しい技術は流行り始めてから少し待って、落とし穴が一通り埋め尽くされたあたりで使い始めて爆速で開発するのが好きです。

さて、Github Actions が最近パブリックベータになり、プライベートリポジトリで利用してみました。その上で、結構ハマったりめんどくさかったりした点があったので解決策とともに共有したいと思います。

なお、Github Actions がどのようなものなのかの説明はみなさんご存知だと思うので省略させていただきます。1

プライベートリポジトリのインストール

今回は Python パッケージで Pipenv を利用してパッケージをインストールしていたのですが、インストールするパッケージのなかに Github 上のプライベートリポジトリからダウンロードするものがありました。鍵の情報を Pipfile に記載したくはないので https ではなく ssh を利用してダウンロードするようにしていました。2

$ pipenv install git+ssh://git@<location>:<user_or_organization>/<repository>@<branch_or_tag>#<package_name>

そのため、CI 上での pipenv install でのパッケージのインストールにはプライベートリポジトリへのアクセス権を ssh に設定する必要があります。

SSH 鍵の設定はこの Github Action が対応しています。

github.com

環境変数に pem 方式でテキスト化された秘密鍵を設定すると ssh-add で SSH Agent に設定 し、Github の公開鍵を known_hosts ファイルに追記する というものです。公開鍵をダウンロードする Github リポジトリの Deploy Keys に設定すればダウンロードできるようになります。

しかし、このリポジトリには以下の問題があります。

そもそも第三者の Action である

この Action は webfactory というドイツの会社が公開しているのですが第三者であるため、秘密鍵を扱う Action としては信頼性に欠ける というものがあります。

現状の Action の内容はコードを読んだ限り問題はないのですが、この会社が将来的に Action の内容を書き換えて Force Push されても利用者は気づくことができません。秘密鍵の権限は限定されているため影響は限られますが、好ましいことではありません。

短期的な解決策としてはこのリポジトリを fork して改竄が不可能なようにすることです。しかし、この方法は元リポジトリがアップデートされるたびに追従しないといけなかったり、そもそも利用者全員が fork するということ自体が面倒で理想的ではありません。

根本的な解決策としては、この Action を公式の Action に取り込んでもらうことです。Github が管理する Action であれば信頼できますし、そもそも秘密鍵Github に登録しているので Github がこの Action を改竄するメリットがありません。公式の Action は次の Organization で公開されています。

github.com

ここに追加されると嬉しいです。一応 Github へはお問い合わせでフィードバックしておきました。

なぜか git clone できない

意気揚々と CI を回してみますが、pipenv install をしても private リポジトリのインストールがされません。(これは愚痴なのですが、インストールに失敗しているくせに pipenv install は成功である 0 コードを返すためパッケージのインストールに失敗していることに気づけません。この後の Flake8 がコケる理由がこれだったのですが、パッケージがインストールされていないことに気づくまで時間を無駄にしました。 Pipenv のパッケージマネージャとしての品質の低さにはうんざりです。 3

さて、内部で行われている git clone を直接行ってみると以下のエラーが表示されます。

Host key verification failed.
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
##[error]Process completed with exit code 128.

これはサーバ鍵(Github 側の鍵)の検証に失敗しているという意味です。known_hosts にあらかじめ ssh-agent Action が Github の公開鍵を記載しているため、問題ないはずなのですが失敗します。

そこで独自に known_hosts を以下のコマンドで上書きしてみましたが git clone に失敗します。

ssh-keyscan github.com > $HOME/.ssh/known_hosts

おそらく git clone でみている known_hosts ファイルが別の場所にあるのだと思うのですが、それを探す気力が無くなっていたので サーバ鍵の検証を無視する ことにして解決しました。

GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" pipenv install -v --system --dev --skip-lock --ignore-pipfile

GIT_SSH_COMMAND という環境変数git が利用する ssh コマンドを差し替えることができます。今回は Github が運営する CI から Githubリポジトリgit clone するため中間者攻撃は考えにくいです。そのため、サーバ鍵の検証をスキップしてもセキュリティ上のリスクは小さいと考えて許容しました。

actions はセマンティクスバージョニングに対応していない

github.com

webfactory/ssh-agent の README には webfactory/ssh-agent@v0.1 と指定するように書いてありました。(現在は修正されています)

タグは v0.1.0v0.1.1 があるのですが、Github Actions はセマンティクスバージョニングには対応しておらず完全一致で Action を決めているようです。

We do not currently apply any semantic versioning semantics, we only match a literal release tag. However we may revisit this policy during the beta period.

Solved: Version numbering for Actions - GitHub Community Forum

README を信用したばっかりにこれで結構ハマりました。注意しましょう。

jobs.<job_id>.services の docker コンテナが不便

docker-compose と同じように jobs.<job_id>.services に設定することで複数の Docker コンテナを起動できます。この仕組みを利用してテストに必要な MySQL サーバを起動していたのですが、非常に使いにくかったです。

手元の docker-compose では utf8mb4 に対応するために command:mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci と設定して Docker コンテナの実行コマンドを差し替えていました。

しかし、jobs.<job_id>.servicescommand の差し替えをサポートしていません。また、設定ファイルのマウントをしようと思いましたが、ファイルやディレクトリのマウントはできるものの Docker コンテナの起動時はまっさらなイメージで起動し steps の実行はその後であるため設定ファイルを差し込むことが不可能でした。

設定できるのは環境変数だけであるため、環境変数で設定できない Docker イメージは全て滅びろ という Github の強いメッセージが伝わってきます。

解決策としては、自分で CMD を差し替えたり設定ファイルを注入した Docker イメージを作成し、 DockerHub などに公開してそれを利用するというものになります。面倒臭いです。Github Actions のためだけに DockerHub にリポジトリを作って Dockerfile を書いてって、本当にめんどくさかったです。これも、Github にフィードバックしておきました。

CI 上でデバッグする方法

github.com

これが便利でした。注意点としては、sudo コマンドに依存しているため sudo がない環境では apt-get install -y sudo などをしてインストールする必要があります。

まとめ

パブリックベータだけあって、ちょっと変なことをしようとすると色々不便でした。が、Github Actions 自体の設計や並列性などは素晴らしいと思うのでこれからどんどん発展していって欲しいです。

現場からは以上です。

Pipenv と Docker を使った開発環境のベストプラクティス

イテレーションの速さがあなたの生産性を左右する。どうも、かわしんです。生産性の高いプログラマって1つ1つの試行が素早い(自動化しているかツールを使っている)ためにものすごいスピードで開発できていると思うんですよね。

さて、最近 Python で開発をしているのですが、世の中の Docker と Pipenv の開発環境を調べてもろくなものがなかったので、自分でテンプレートを作りました。いわゆる「俺の考える最強の Pipenv + Docker 開発環境」というやつです。

リポジトリはこちらになります。

github.com

特徴としては、以下の2つが大きいです。

  • pipenv install をコンテナ起動時に行うため、docker イメージを作り直す必要がない
  • pipenv shell 相当の仮想環境のアクティベートを自動で行う

なぜ Docker + Pipenv なのか

Docker は、pypy パッケージ以外のライブラリの環境(apt instal するもの)を隔離して複数の開発者間で再現させるために必要です。

Pipenv は PipfilePipfile.lock を使ったバージョン固定の仕組みと packagesdev-packages の分離の機能があるため、pip ではなく Pipenv を利用したいです。Docker を使う場合環境の分離は達成されるため、Pipenv の仮想環境の仕組みは必要ないですがモダンなパッケージ管理機能を提供するものは Pipenv しかないのでこれを使いたいです。

つまり、Docker と Pipenv のそれぞれを利用する合理的なメリット があります。(docker 使わなくていいじゃ〜んとか、pip でいいじゃ〜んとかは言わないでください)

Pipenv の辛いところ

Pipenv の辛いところは installupdatelock の仕組みが直感に反していて使いづらいとか、lock が遅すぎるとか、重複した install のスキップが遅いとかありますが、一番の辛いところは、仮想環境の virtualenv の仕組みとパッケージ管理の仕組みが密結合している ということにあります。

一応 pipenv install --system というコマンドがありシステムグローバルにパッケージをインストールするオプションがあり、世の中の pipenv と docker の環境の説明記事ではこれを利用していますが、開発環境としての利用には全く向いていません。

確かに docker と virtualenv の 2 重の仮想環境を避けることができますが、pipenv lock pipenv graph などのパッケージ管理の機能は virtualenv による仮想環境を必須としており、新しいパッケージをインストールするときに 結局仮想環境が作られ、2 重にパッケージのインストールがされてしまいます

既存の pip はモダンなパッケージ管理ツールとしては機能不足(packagesdev-packages の分離やパッケージのゆるいバージョン指定と lock ファイルの仕組みなど)、pipenv は 仮想環境機能との密結合 とそれぞれのコマンドが遅い(lock が致命的に遅い)など、Python のパッケージ管理ツールには満足いくものがない ので、誰かモダンな Python のパッケージ管理ツールを作れば流行ると思います。(Vendoring の仕組みを使えばできるはずです。Ruby の bundler とかで達成されているもの)

解決策

そこで僕の考え出したのが以下の環境です。

github.com

特徴を列挙しておきます。

  • 開発環境では、Docker の中に pipenv の仮想環境を作る
    • 開発環境なのでオーバーヘッドは許容できる。また、pipenv graph などのコマンドを満足に使えるメリットの方が大きい。
  • docker-compose build では pypy パッケージのインストールは行わず、ENTRYPOINT でインストールする。virtualenv の環境を Docker Volume に載せる
    • 新しいパッケージのインストールでいちいち docker イメージを作り直す必要がなくなる。
    • Docker Volume にインストールしたパッケージが載っているのでコンテナを作り直してもインストールし直す必要がない。
    • 高速に気軽にライブラリインストールを試すイテレーションを回せる
  • 自動で pipenv の仮想環境をアクティベートする
    • docker-compose run でも docker-compose exec <service> bash でも、もちろん docker-compose up でも、pipenv の仮想環境内で実行される。
    • いちいち pipenv shell などをして仮想環境のアクティベートをする必要はない。
    • pipenv のアクティベートを気にしなくてよくて、めんどくさくない

ではファイルを列挙していきます。

docker-compose.yml

version: "3"
services:
  app:
    build:
      context: .
      dockerfile: "Dockerfile.dev"
    command: python main.py
    volumes:
      - .:/app
      - python-packages:/root/.local/share
volumes:
  python-packages:

開発環境のための Dockerfile.dev を利用します。

あと、docker volume を virtualenv の仮想環境が配置される /root/.local/share に割り当てています。

Dockerfile.dev

FROM python:3.7.4

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    build-essential \
    ca-certificates \
    git \
    && apt-get clean && \
    rm -rf /var/lib/apt/lists/*

ENV ENTRYKIT_VERSION 0.4.0

RUN wget https://github.com/progrium/entrykit/releases/download/v${ENTRYKIT_VERSION}/entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
    && tar -xvzf entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
    && rm entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
    && mv entrykit /bin/entrykit \
    && chmod +x /bin/entrykit \
    && entrykit --symlink

WORKDIR /app

RUN pip install --upgrade pip && pip install pipenv

RUN echo "if [[ -z \"\${VIRTUAL_ENV}\" ]]; then" >> /root/.bashrc && \
    echo "source \$(pipenv --venv)/bin/activate" >> /root/.bashrc && \
    echo "fi"                                    >> /root/.bashrc

COPY scripts/ /opt/bin/

ENTRYPOINT [ \
    "prehook", "/opt/bin/docker-setup.sh", "--", \
    "/opt/bin/docker-entrypoint.sh"]

ENTRYPOINT のフックに Entrykit を利用しています。別にこれでなくてもいいのですが、docker stopSIGTERM を実行プロセスに伝搬させるために利用しています。

ENTRYPOINT では /opt/bin/docker-setup.shpipenv install を、/opt/bin/docker-entrypoint.sh で virtualenv の仮想環境のアクティベートを行なっています。

また、.bashrc に、source \$(pipenv --venv)/bin/activate を設定しています。docker-compose exec をした時は ENTRYPOINT が回避されてしまうので仮想環境のアクティベートを .bashrc でフックして行います。(そのため、docker-compose execbash 以外が実行されるとアクティベートされなくなりますが、基本的に開発環境で exec する時は bash 以外ないのでこれでよしとしています。)

しかし、docker-compose run したときに bash を実行されると /opt/bin/docker-entrypoint.sh.bashrc で 2 重にアクティベートされてしまいます、これを防ぐために VIRTUAL_ENV という環境変数を確認しています。

scripts/docker-setup.sh

#!/usr/bin/env bash

pipenv --venv > /dev/null || pipenv install --skip-lock --dev --ignore-pipfile

コンテナの初回起動時にだけ pipenv install を行います。それ以降の起動時には、pipenv --venv に成功するので pipenv install をスキップすることができます。(すでにインストール済みの場合でも pipenv install は遅いのでスキップします)

scripts/docker-entrypoint.sh

#!/usr/bin/env bash

if [[ -z "${VIRTUAL_ENV}" ]]; then
    source "$(pipenv --venv)/bin/activate"
fi

exec "$@"

virtualenv のアクティベートを行なってから command に指定されたものを実行しています。

Dockerfile

FROM python:3.7.4-slim

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    ca-certificates \
    git \
    && apt-get clean && \
    rm -rf /var/lib/apt/lists/*

ENV WORKDIR /app/

WORKDIR ${WORKDIR}

COPY Pipfile Pipfile.lock ${WORKDIR}

RUN pip install pipenv --no-cache-dir && \
    pipenv install --system --deploy && \
    pip uninstall -y pipenv virtualenv-clone virtualenv

COPY . $WORKDIR

CMD ["python", "main.py"]

これは本番環境のための Dockerfile です。pipenv install --system --deploy によってパッケージをシステムグローバルにインストールしています。

また、CMD の実行には pipenv は必要ないので pypy パッケージのインストール後に pipenv はアンインストールしています。

世の中の記事の一部には、pipenv lock -r /tmp/requirments.txt && pip install -r /tmp/requirements.txt をしてるものもありましたが、pipenv lock -r で virtualenv の仮想環境が作られるのでメリットはないと思っています。

まとめ

以上となります。

pipenv よりマシな Python のパッケージ管理ツールが出てくることを待ってます。

UDP の Length はなんのためにあるのか?

1 バイトの無駄も許さない。どうも、かわしんです。1024 回繰り返すと 1 KB の無駄になります。

先週 TCP/IP スタックを自作した 1 のですが、その講義中ずっと気になっていたことがあったのでそれを深掘りします。

TCP / IP スタックを自作した人なら誰でも感じると思うのですが、TCP の後に UDP を見ると、なぜか UDP のヘッダには Length というフィールドがあることに気づきます。

                  0      7 8     15 16    23 24    31
                 +--------+--------+--------+--------+
                 |     Source      |   Destination   |
                 |      Port       |      Port       |
                 +--------+--------+--------+--------+
                 |                 |                 |
                 |     Length      |    Checksum     |
                 +--------+--------+--------+--------+
                 |
                 |          data octets ...
                 +---------------- ...

                      User Datagram Header Format

https://tools.ietf.org/html/rfc768

多分、UDP を見ただけだとふーんって感じで流してしまうと思うのですが、TCP を実装した後の冴えた頭ではどうしても引っかかります。

TCP のヘッダには長さを表すフィールドがないのです。

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             data                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

                            TCP Header Format

          Note that one tick mark represents one bit position.

                               Figure 3.

https://tools.ietf.org/html/rfc793#section-3.1

では、TCP はどのようにデータの長さを知るのかというと IP ヘッダに含まれる Total Length というフィールド 2 によって全体の長さを知り、そこから IP ヘッダ自体の長さを引くことで TCP セグメント全体の長さを知ります。

だったら思うわけですね、UDP の Length フィールドいらないじゃん って。TCP みたいに IP ヘッダの長さから UDP セグメントの長さを知ればいいじゃんって。

UDP ヘッダは 8 バイト固定長なので IP の Total Length から IP ヘッダの長さと UDP ヘッダの長さを引けば UDPペイロードの長さがわかるはずです。UDPLength フィールドをなくすことで 1 セグメントあたり 2 バイトも 多くのデータを送ることができます。

これがすごく気になったので、なぜ UDP ヘッダに Length フィールドがあるのか、その理由を求めてネットの海をさまよいました。

なぜ UDP には Length フィールドがあるのか

とりあえず RFC を読んでみます。UDPRFCRFC 768 - User Datagram Protocol です。わずか 3 ページと短いです。

さて、この RFC では Length については以下のように記述されており、ここからはなぜ Length フィールドができたのかを推し量ることはできません。

Length is the length in octets of this user datagram including this header and the data. (This means the minimum value of the length is eight.)

そこで、なぜ UDP に Length フィールドがあるのかをググってみました。やはり、いろんな人が同じ疑問を持っていたらしく、様々なところで議論されていました。

が、結論から言えば、なぜそうなったのかはわかりませんでした 。どれも推測の域を出ることができず、確固たるソースをもってこの疑問に答えているものは1つもありませんでした。

ですが、いい線をいってるなと思う説がいくつかありましたのでここに紹介します。

ヘッダサイズを 32 ビットの倍数に揃えるために、余った 16 ビットを Length フィールドに割り当てた

32 ビットにアラインされていた方がハードウェアとして扱いやすいために 16 ビット余った領域にいい感じな Length を割り当てられたという説です。確かに理由の1つではありそうです。

これはどちらかというと UDPLength フィールドは冗長であることを認める主張です。なくてもいいので。

UDP Lite プロトコルでは Length フィールドは上書きされている。だから Length フィールドは必要ない

UDP を発展させたプロトコルとして UDP Lite というプロトコルがあるそうです。これは、UDP が領域の一部に間違いがあった場合にセグメント全体を破棄してしまうのに対して、チェックサムを計算する領域を指定することでデータの一部に間違いがあっても有効なセグメントとするプロトコルです。

UDPLength フィールドは Checksum Coverage になり、ヘッダを含める UDP Lite セグメントの先頭の範囲だけチェックサムを計算するようになります。おそらく UDP のヘッダやデータに含まれるアプリケーションプロトコルのヘッダ領域だけは正しいことを確認して、データの部分はエラー訂正を行うことを目論んでいるのだと思いました。

確かに、Length フィールドは上書きされているので冗長で必要なかったということがわかります。

UDP-Lite - Wikipedia

UDPLength と IP からの Length が食い違うことでデータの Validation ができる

それはチェックサムでやるのではないでしょうか・・・。IP からの Length を信用しない場合は、TCP も信用できなくなってしまいます。

ですが、データの信頼性を向上させるための Validation の1つにはなります。

IP に依存せず UDP だけで完結するべきだから Length の情報もヘッダに含めるべきだ

この流派には 2 つあるように感じました。

  • UDP は IP に依存せずどんなプロトコルの上でも動けるように IP の Length には依存するべきでない
  • UDPTCP と違ってデータグラムである。カーネル内では1メッセージごとバッファリングされるから Length の情報は必要になる

まず、前者ですが UDP の pseudo header は IP ヘッダ由来の Length に依存するため、この指摘は半分妥当ではありません。もしチェックサムを無効にする場合は確かにその通りだと思います。

UDPRFC の「IP Interface」の項を読むと以下のように書かれており、UDP は IP の Length は必須とはしていないようです。つまり、pseudo header の LengthUDP Header から計算できるということなのでしょうか。

The UDP module must be able to determine the source and destination internet addresses and the protocol field from the internet header.

一方で TCPRFC の「TCP/Lower-Level Interface」では、下層として IP と同等の機能を提供すればどのプロトコルでも許容するとしています。その IP として要求する機能については以下のように記述しています。

Any lower level protocol will have to provide the source address,
destination address, and protocol fields, and some way to determine
the "TCP length", both to provide the functional equivlent service
of IP and to be used in the TCP checksum.

https://tools.ietf.org/html/rfc793#page-51

ここでは、明確に TCP length を要求しているので、UDP では IP レイヤーからの length は必須としていないと考えることができます。

次に後者のカーネル内での扱われ方に着目した説ですが、これも一理あります。

TCP はストリームなので、1セグメント1セグメントの情報はストリーム(Receive Buffer)にデータがコピーされた時点で破棄されます。

一方で UDP はデータグラムなので、アプリケーションがソケットから読むときに1セグメントごとを読み出します。そのため 1 セグメントごとの長さなどの情報は、読み出されるまで本質的にカーネル内に保存される必要があります。

この考え方は一理ありますが、カーネル側で新しいメモリ領域を確保してそこで長さを管理すればいいのでそこまで強い理由にはならないのかなと思いました。

まとめ

僕の結論としては、UDP の下層を IP に限定する場合は UDPLength フィールドは不必要です。 32 ビットアラインのための埋め合わせの意味合いが強いと思います。

UDP を IP に限定しない汎用的なプロトコルと考える場合は、下層が Length を提供する機能を持たない可能性があるため、Length フィールドは必要になると言えます。

だいたいこんな感じでした。この辺りに知見のある方は僕のツイッターアカウント @kawasin73 まで、ぜひ教えていただければと思います。

参考文献

僕が読んだ記事は以下の通りです。

stackoverflow.com

stackoverflow.com

これは、UDP の Length というよりは、pseudo header の Length との重複のことをいっているみたいでした。

kplug-list.kernel-panic.narkive.com

FIN -> FIN/ACK -> ACK という TCP の幻想

掴んで離さぬコネクション。どうも、かわしんです。しがみつかずに適切なタイミングで離しましょう。

この1週間で RFC を読みながら TCP/IP プロトコルスタックを自作した 1 のですが、その時にコネクションの終了処理でハマったので後学のために書き残しておきます。

一言でまとめると FIN -> FIN/ACK -> ACK は間違っていて、正しくは FIN/ACK -> FIN/ACK -> ACK であったという話です。

ちなみに、僕が自作した TCP/IP プロトコルスタックはこれです。

github.com

現象

それは TCP のリスナーと close 処理が出来上がってコネクション管理のテストをしていた時のことでした。

自作 TCP スタックでポートを Listen して Accept したらすぐにサーバ側からコネクションを切断するというテストコードを書いて実行し、Linux 内の telnet から接続して即座に接続が切断されるかどうかをテストしていました。

しかし、接続には成功するのですが なぜか接続は切断されません 。サーバからは確かに FIN セグメントが送られているのに telnet からは ACKFIN/ACK が送信されていないのです。 netstat で確認しても telnet のコネクションは ESTABLISHED 状態のままです。

逆に telnet から切断する時のパケットを観察すると切断時には FIN セグメントではなく FIN/ACK セグメントを送っているようでした。そのため、自作プロトコルスタックの CLOSE 処理で FIN ではなく FIN/ACK セグメントを送るようにすると無事 telnet から FIN/ACK が返ってきてコネクションの切断ができるようになりました

RFC を読み直す

TCP の基本的な仕様は RFC 793 にまとめられていますが、その中の 3.9 Event Processing の CLOSE Call では、終了命令がユーザから来た時に FIN セグメントを送信するように規定しています。

    ESTABLISHED STATE

      Queue this until all preceding SENDs have been segmentized, then
      form a FIN segment and send it.  In any case, enter FIN-WAIT-1
      state.

僕はこの規定を読んで実装していたのですが、うまく動きません。

そこで 3.5 Closing a Connection も読むとその中の実例ではコネクションを切断する側は、FIN ではなく FIN/ACK を送っています。

      TCP A                                                TCP B

  1.  ESTABLISHED                                          ESTABLISHED

  2.  (Close)
      FIN-WAIT-1  --> <SEQ=100><ACK=300><CTL=FIN,ACK>  --> CLOSE-WAIT

  3.  FIN-WAIT-2  <-- <SEQ=300><ACK=101><CTL=ACK>      <-- CLOSE-WAIT

  4.                                                       (Close)
      TIME-WAIT   <-- <SEQ=300><ACK=101><CTL=FIN,ACK>  <-- LAST-ACK

  5.  TIME-WAIT   --> <SEQ=101><ACK=301><CTL=ACK>      --> CLOSED

  6.  (2 MSL)
      CLOSED

                         Normal Close Sequence

                               Figure 13.

どうやら FIN/ACK を送ることが想定されているようです。

次に受信側の仕様を見てみます。3.9 Event Processing の SEGMENT ARRIVES をみると Step 5 の fifth check the ACK field では以下のように記述されています。

    fifth check the ACK field,

      if the ACK bit is off drop the segment and return

FIN フラグのチェックは、eighth, check the FIN bit, と Step 8 であるため、そこに到達する前に ACK の付いてないセグメントは無視されてしまう ようです。

そのため、コネクションの終了処理では、FIN ではなく FIN/ACK を送らなくてはなりません。 それなら FIN segment を送ると書かずに FIN/ACK を送るというように明確に書いておいてくれ

まとめ

FIN -> FIN/ACK -> ACK のフローは、正しくは FIN/ACK -> FIN/ACK -> ACK でした。

ESTABLISHED 状態になった後は基本的に全てのセグメントに ACK をつける必要があります。FIN 単体のセグメントは不正なセグメントとみなされて無視されてしまいます。

StackOverflow でも同じような質問がされていました。( linux - FIN omitted, FIN-ACK sent - Stack Overflow )その回答では以下のように説明されています。

1. client: FIN  (will not send more) 
2. server: ACK (received the FIN)
.. server: sends more data..., client ACKs these data 
3. server: FIN (will not send more)
4. client: ACK (received the FIN)

Note that the packet you see in step#1 might have an ACK inside too. But this ACK just acknowledges data send before by the server.

FIN には ACK をつけることができると言っていますが 大嘘 です。むしろ ACK は必須で、つけないと無視されてしまいます。 RFC をよく読めばわかることです。(僕もよく読んでないので人のことはあまり言えませんが)

世の中には雰囲気で回答して雰囲気で理解して approve していることが多いのだなと感じます。ちゃんと 1 次ソースをあたらないと嘘の情報に惑わされてしまうといういい教訓になりました。

TCP/IP プロトコルスタックを自作した

RFC は裏切らない。どうも、かわしんです。僕は RFC に裏切られました。

さて、今週の頭から4日間開催された KLab Expert Camp に参加して、TCP/IP プロトコルスタックを実装してきました。今回はその体験記を書いていこうと思います。

成果物ですが、こちらになります。

github.com

ネットワークデバイスの抽象化、EthernetARP、IP、TCP を実装しました。使用言語は C 言語です。詳しくは後半で説明します。

KLab Expert Camp とは

今回参加したのは KLab 株式会社の実践的なインターンプログラムである KLab Expert Camp です。記念すべき第 1 回目として「TCP/IPプロトコルスタック自作開発」が開催されました。

応募ページはこれです。

https://klab-hr.snar.jp/jobboard/detail.aspx?id=vtS4F0TN0tQ

実はこのインターンは 21 卒以降の学生が対象であり大学4年の僕は対象外なのですが、どうしても参加したいとツイッター上で言ったところ、講師の 山本さん のご好意で特別に参加させていただけることになりました。本当にありがとうございました。

このインターンプログラムは講師の山本さんが実装して公開されている学習用の TCP/IP スタック microps がベースになっています。(マイクロピーエスと呼ぶらしいです)

github.com

プログラム自体は、山本さんの講義を受けながら microps の実装を理解する「基本コース」と、microps をベースに各自自由に何かを作る「発展コース」の 2 つを選ぶことができました。

僕は「発展コース」を選び microps を写経しながら TCP の実装を改善していました。

そして、無事修了することができました。

f:id:kawasin73:20190830155259j:plain

最後の修了証もそうですが、KLab さんのインターンはすごく待遇がよくて、懇親会での寿司やピザ、お昼ご飯、交通費全支給、遠方からの参加者の宿泊の手配など運営の方の気配りと予算がすごくて、すごくよかったので学生のみんなは参加したほうがいいと思います。(宣伝)

頑張ったこと

インターンが始まる 2 日前の 24 日から実装を始めて、全部で 6 日間実装していました。

まず、microps の下のレイヤーから写経して、都度テストで動くことを確認しながら進めていきました。やりたいことは TCP での通信だったので必要のない ICMP や IP のルーティング機能、DHCP などは飛ばしています。

僕が実装した順番はコミットログを見るとわかりやすいと思います。

github.com

IP を実装した後に ARP を実装したのですが、案外 ARP の実装が量が多くて大変でした。

全体として 5000 行くらいを書いていたことになります。

f:id:kawasin73:20190830164805p:plain

TCPRFC を実装する

全体的に microps はコメントは少ないですが実装の内容はシンプルでわかりやすかったです。また、ネットワークデバイスの抽象化などの設計もよくできていて参考になりました。しかし、TCP の実装をみるとシンプルな分色々な処理が TODO になっていたので仕様に忠実に実装するために、tcp.c は自力で RFC を読みながら実装しました。

実は昔に DMM の時のメンターさんから RFC を読んで実装する訓練をした方がいいとアドバイスされていて、ずっとやってみたかったのでちょうどいいきっかけになりました。RFC を実装することは、1 次ソースを読むことでTCP の正確な仕様を理解できるだけでなく、標準化されたドキュメントの書き方を学べたり、仕様を忠実に実装する訓練にもなります。

TCPRFC は複数にわたりますが、基礎は RFC 793 に全て書いてあります。

そして、RFC 793 の 3.9 Event Processing には TCP の基本的な処理がそれぞれのイベントごとそしてコネクションの状態ごとに全て記述してあります。中身は英語ですが、If xxxxxx then set yyyy to zzzz のように擬似コードっぽく書かれているのでそれをそのまま C 言語に書き直しました。

一番辛かったのは 65 ページ から始まる SEGMENT ARRIVESイベントのイベントハンドリングを実装していた時です。相手からの TCP セグメントのヘッダを読んで処理を行うのですが、このイベントだけで RFC の 12 ページを使って記述されています。長い。

そして、このイベントでは全部で 8 ステップに渡ってヘッダのフラグなどを検査して内部状態を変えたり TCP セグメントを送信したりします。TCP はコネクションごとにステートマシンで表され、 LISTEN, SYN-SENT, SYN-RECEIVED, ESTABLISHED, FIN-WAIT-1, FIN-WAIT-2, CLOSE-WAIT, CLOSING, LAST-ACK, TIME-WAIT, CLOSED の 11 個のうちのどれか状態を持ちます。そのため、8 ステップの各ステップでそれぞれの状態ごとに処理を書かなくてはいけません。複数の状態で処理が共通していたりするので実際には 88 種類よりは少ないですが、それでも十分多いです。正直最後の方は心が折れかけました。

実装した機能

正直 TCP の最低限の機能を実装するので精一杯でパフォーマンスの向上などはできませんでした。microps から発展させたことはだいたいこんな感じです。

  • tcp_rx のイベントハンドリングの処理を RFC に忠実に実装した
  • tcp_api_send で 1500 バイトまでの送信にしか対応していないのを、セグメント化に対応して任意の長さのバイト列を送信できるようにした
  • フロー制御を実装した
  • 再送タイムアウトだけでなく、ユーザタイムアウト、TIME-WAIT タイムアウトに対応した

最初は輻輳制御アルゴリズムをいくつか試してみるとか息巻いていたんですが、結局は輻輳制御をするところまでたどり着けませんでした。

また、microps のいくつかのバグを発見してフィードバックのプルリクエストを作成しました。

感想

C でのバイナリプロトコルの処理方法 を学ぶことができたのがよかったです。TCP は固定長のヘッダ(オプションによって拡張されますが)であるため、セグメントポインタをヘッダ構造体のポインタにキャストすることでゼロコピーでヘッダを解析できます。

もちろんエンディアンはネットワークバイトオーダなので利用時に変換をしないといけないですが、このバイト列にヘッダ構造体を被せるだけでヘッダのパースができる感覚は新鮮で、さすが生のメモリを操作する C だなと思いました。

microps では複数のネットワークデバイスの種類に対応しているのですが C 言語での抽象化 の方法を学びました。以下のようなオペレーションの関数ポインタを集めた構造体をデバイスの種類ごとに定義して多態性を実現します。

struct rawdev_ops {
  int (*open)(struct rawdev *dev);
  void (*close)(struct rawdev *dev);
  void (*rx)(struct rawdev *dev, void (*callback)(uint8_t *, size_t, void *),
             void *arg, int timeout);
  ssize_t (*tx)(struct rawdev *dev, const uint8_t *buf, size_t len);
  int (*addr)(struct rawdev *dev, uint8_t *dst, size_t size);
};

そして、案外 RFC は曖昧 であることも知れました。RFC自然言語で書かれているためどうしても曖昧さがあり、その解釈を間違えると正しく動きません。

ここのコメント にまとめてありますが、SYN-RECEIVED 状態で ACK を受け取った時に ESTABLISHED 状態に移行したあと、次のステップを処理すると思っていたのですが実は同じステップの ESTABLISHED 状態の処理を繰り返すことが期待されていたようでした。switch - case 文で break するか fall through するかはこの記述だけでは曖昧でした。

  if the ACK bit is on

    SYN-RECEIVED STATE

      If SND.UNA =< SEG.ACK =< SND.NXT then enter ESTABLISHED state
      and continue processing.

        If the segment acknowledgment is not acceptable, form a
        reset segment

RFC には裏切られることもあるので注意が必要です。

まとめ

RFC を読んで 6 日間で TCP/IP スタックを作り上げるのは結構大変でしたがなんとか形になるものが完成してよかったです。

また、このような素晴らしいインターンプログラムを素晴らしいサポートで開催していただいた KLab 株式会社の皆さんには感謝申し上げます。ありがとうございました。


最終日の成果発表の資料です。