kawasin73のブログ

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

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 株式会社の皆さんには感謝申し上げます。ありがとうございました。


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

ロバストなプロトコルを設計して実装した

どうも、ロバストと愉快な仲間たちのロバストこと、かわしんです。そうです、私がロバストです。

さて、8 月 13 日から 8 月 17 日までの 4 泊 5 日で セキュリティネクストキャンプ に参加してきました。

セキュリティネクストキャンプとは、セキュリティ・キャンプ全国大会 と同時期に開催されるネクストな感じのセキュリティキャンプです。 全国大会は 22 才までの一生に一度しか参加できないのですが、もう一度参加したいという卒業生をターゲットに今年から新しく始まったそうです。このネクストキャンプは 25 才までの学生であれば(詳しくは公式の募集要項を確認してください)卒業生でなくても同等の技術スキルがあれば参加することができます。

応募には審査があり、今年は 13 人の応募から 6 人が選ばれたそうです。

僕はセキュリティキャンプの存在を去年知った時にはすでに 24 才になっており参加できないことを残念に思っていたのですが、今年ネクストキャンプの存在を Twitter で知り参加することにしました。

講義としては、暗号化アルゴリズムの AES を FPGA に実装したり、XSS 文字列の検知するエンジンを機械学習で実装したり、複数の CPU アーキテクチャエミュレータに実装された特殊命令のアルゴリズムを解析してそれをエミュレータに実装し直したりなど、主に手を動かして演習するタイプの講義が多く非常に勉強になりました。

参考 : セキュリティ・ネクストキャンプ2019 プログラム:IPA 独立行政法人 情報処理推進機構

実習講義が多い分、キャンプ開催前の事前学習やキャンプ中の宿題などタスクが多く眠れない充実した楽しい 5 日間を過ごすことができました。

ロバストプロトコルを考案せよ

さて、その授業の中で一番僕が楽しみにしていたのがこの「ロバストなプロトコルを考案せよ」という講義です。

これは、10BASE-T の LAN ケーブルの 4 対のツイストペアの 1 つにノイズを載せ、そのケーブルを使ってなるべく多くのファイル転送ができるようなプロトコルを考えて実装し、最終日にコンテストを行うものでした。僕はこのコンテストで1位をとり、ロバスト賞をいただきました。なので、僕こそがロバストです。

ファイルとしては事前に用意された 1000 個の 100KB 程度のランダムなバイナリファイルを転送し、チェックサムを確認して内容が正しいファイルのみをカウントします。間違ったファイルによるペナルティはありません。また出力するファイル名は元のファイルと異なっていても問題ないというレギュレーションでした。

ノイズはだいたい ping パケットが 50% 失敗する程度のノイズが発生させられます。

イメージとしてはこんな感じです。ノイズインジェクターブラックボックスになっており、ピンを接続して利用します。

f:id:kawasin73:20190819220451j:plain

この画像のケーブルは講師の今岡先生が作成してくださったものですが、事前学習で自分でケーブルの被膜を剥ぐのは結構難しかったです。

僕の Robustp

この課題に対して僕は Robustp という独自プロトコルを設計して実装しました。(正確には実装しながら設計を進化させていますが)

github.com

これは、簡単にいうと UDP の上に TCP や QUIC のようなプロトコルを載せている感じです。

設計

f:id:kawasin73:20190819221652p:plain

Robustp は内部で 2 つのレイヤーに別れています。TCP を参考にしたファイル単位でセグメントの位置やファイルの完成などを管理する ファイル層 と、セグメントを TransID という一意で単調増加する ID で管理して再送やウィンドウ制御を行う トランスポート層 です。

ファイル層 は、各ファイルは一意な Fileno が割り当てられた FileContext 構造体で表現され、ファイルデータを分割したセグメントは Offset が割り当てられた FileSegment 構造体で一意に管理されます。これは TCP を参考にしており、TCP のポート番号が Fileno に対応し、TCP のシーケンス番号が Offset に対応しています。

ファイル層は各ファイルのどのデータが送信済み・受信済みであるのかとファイルのデータが揃っているのかを管理します。ファイル層では TCP と同じように Partial ACK を実現し、受信した全てのパケットの位置を ACK メッセージに詰めて応答し、無駄な再送を防ぎます。

トランスポート層FileSegment をラップした TransSegmentTransId を紐づけて管理します。それぞれの TransSegmentWindowManager 内のウィンドウバッファで管理され、WindowManager は、ウィンドウサイズ制御RTTの計測再送タイマー欠損したACKの即時再送 などの機能を提供します。TransId は QUIC での Delivery Order Id の考え方を参考にして実装されており、再送が必要な場合は新しい TransId を割り当てて再送することで RTT 計測のロジックがシンプルになります。

最初は TCP を参考にファイル層のみで実装をして FilenoOffset (実装当時は Seqno だった)でセグメントを一意に管理して再送を行なっていましたが、再送パケットの RTT 計測ができなくなる課題を抱えていて悩んでいました。

その時にちょうど ぺトロン 君に QUIC の論文 1 を教えてもらい、読んでみると QUIC では再送などを管理する Delivery Order Id とデータの位置を管理する Stream Offset を分離しているという考え方を知りました。それを TransID という形でトランスポート層を分離して実装しました。

Add TransId · kawasin73/robustp@c585c66 · GitHub

ちょうどこのコミットで対応していますが、ファイル層の操作をうまく抽象化していたのでそこまで載せ替えるのは大変ではなかったです。

それぞれのメッセージは、Ethernet の 1 フレームに収まるように 起動時にコマンド引数で渡される MTU(1500 バイト)からセグメントのサイズを調整します。複数のフレームに UDP パケットが分割されると 1 つのフレームが落ちただけでパケット全体をロスしてしまうのを防ぐためです。

ファイル層とトランスポート層の間はシンプルな 4 つのインターフェイスだけが定義されています。

ファイル層からトランスポート層へは SendersendThread() メソッド内に定義された sendSegment() マクロを呼び出します。このマクロは、WindowManager.Push() メソッドを呼び出してセグメントをウィンドウに追加してパケットを送信します。

トランスポート層からファイル層へは以下の 3 つのインターフェイスFileSegment に定義されています。

  • TransId 付きのデータメッセージを作成する PackMsg()
  • ACK メッセージを受信した時に呼び出す Ack()
  • セグメントを再送する時に Partial ACK によってファイル層が知っている再送の必要がないかどうかの情報を問い合わせる IsCompleted()

この辺りの機能の分離とインターフェイス設計は綺麗にできたと自分では満足しています。

ヘッダはこんな感じになっています。

f:id:kawasin73:20190820012418p:plain

赤い部分がファイル層のためのヘッダで、青い部分がトランスポート層のためのヘッダです。どちらかというと TransId を先頭の方に持ってきたかったのですが、これは歴史的経緯です。変更するのがめんどくさかったのでこのままにしました。

コンポーネント構成と提供する機能

f:id:kawasin73:20190819225517p:plain

送信側のコンポーネントはこのように設計されて実装されています。

上の FileContextFileSegment がファイル層、WindowManagerEnqueuerReceiverトランスポート層です。FileSegmentWindowManager からのファイル層へのインターフェイスになっています。

WindowManager の導入が一番難しかったです。このコミットで対応していますが、WindowManager にどの機能(タイマーや RTT 収集など)を含めるかを悩みました。

Add WindowManager · kawasin73/robustp@35836c5 · GitHub

CongestionControlAlgorithm による輻輳制御アルゴリズムの抽象化

TCP では様々な輻輳制御アルゴリズムが提案されています。Robustp ではそれを CongestionControlAlgorithm というインターフェイスで抽象化して輻輳制御アルゴリズムを差し替え可能にしています。

それぞれのアルゴリズムは以下の 2 つのメソッドを実装したものであり、最終的には本番では、Vegas という RTT の遅延を元にしたアルゴリズムにデータロスト時のペナルティを加えた独自のアルゴリズムを実装してコンテストに挑みました。

type CongestionControlAlgorithm interface {
    Add(status uint8, sendAt time.Time, rtt time.Duration)
    WindowSize() int
}

RTOCalclater による RTO 計算の抽象化

また、RTT(Round-Trip Time)から再送タイマーの長さ RTO(Retransmission Time Out)を算出するアルゴリズムTCP では複数提案されています。

これも RTOCalclater というインターフェイスで抽象化して差し替え可能にしています。本番では実験する時間がなくてシンプルに RTT の倍の時間を RTO とする RTOCalclater を設定しました。

type RTOCalclater interface {
    Update(rtt float64, rttTable []float64) time.Duration
}

再送メッセージがウィンドウを溢れた場合の再送バッファ

ウィンドウサイズは RTT やメッセージのロストによって小さくなったり大きくなったりします。そのため、パケットの再送時にはウィンドウサイズが小さくなって再送できない場合があります。その場合は Enqueuer 内の再送バッファに一時的に貯めておいて、ウィンドウサイズを忠実に守るように工夫をしました。

欠損 ACK の即時再送

今回のコンテストの特徴として 1 本のケーブルのみを占有して利用します。送信したメッセージは全て同じ通信路を通るためメッセージの順序の入れ替わりはありえません。

TCP では 3 回同じ ACK を受け取ったらそれ以前のメッセージはロストしたものとして再送する高速再送アルゴリズムが提案されていますが、今回の講義では 1 つでも TransId が飛んだ ACK メッセージが返ってきた場合はそれ以前のメッセージはロストしたものとして即時に再送するナイーブな実装になっています。

もし IP ネットワークの上をメッセージが流れる場合はもうちょっと緩めの判定をする必要があります。

整合性の検査

データペイロードがノイズによって書き換わっていないかの検証は、UDPチェックサム機能を利用して、Robustp 自体では実装していません。UDPチェックサムで整合性の検証をサボることができたのは IP ソケットではなく UDP ソケットを選んだ理由の1つです。

ここまでの機能の設計と実装は、DMM でのインターンの時に学んだシンプルな実装を愚直に積み重ねていく経験がなかったらできていなかったと思います。DMM でインターンして身に付けることができたスキルを自分で実感しました。

Go の採用

この Robustp は Go で実装しました。

この一番の理由は クロスプラットフォーム に対応しており OS ごとの違いを抽象化して隠蔽してくれているからです。コンテストは貸与された Windows PC で行うレギュレーションがあったので、開発を macOS で行う僕としてはクロスプラットフォームは必須でした。Go では UDP ソケットは net.UDPConn という構造体で抽象化されています。

クロスプラットフォームでありながら OS に近い機能のインターフェイスを提供してくれているので今回の課題にはぴったりでした。

GC 言語なので ガバガバとメモリを確保できる気楽さ もあります。今回は速さを競い、プログラムのメモリ量などは気にしないので普段のプロダクトよりも気楽にメモリを確保するプログラムを書いていました。

Queue を C などで実現しようとすると、リングバッファや要素のずらしなどをしないといけませんが、Go ではスライスで以下のシンプルな表記で Queue が実装できて新しいバッファの確保などは Go のスライスが行なってくれます。もちろんスライスの拡張時にコピーやメモリの確保・破棄が起こるのでリングバッファほどのパフォーマンスは出ませんが、初期のプロトタイプの実装では素早く Queue を実現できます。

var queue []Item
// push
queue = append(queue, Item{})
// pop
item, queue = queue[0], queue[1:]

コンパイルされるため そこそこ速い ということもあります。もちろん C ほどの速度は出ませんが 10BASE-T を使う以上、極上の速さは求められないと考えて Go にしました。

並列・並行処理が簡単 に行えることも Go の便利な点です。1 プロセスでクライアント側の送信スレッド、受信スレッドと、サーバ側の送受信スレッドの 3 並列のプログラムにする必要があったので便利でした。

結果

60 秒間の間に送信する試行をクライアント側ポートとサーバ側ポートを変えて 2 回行いました。

結果は、1回目が 399 ファイルの転送に成功して全てのファイルの中身が壊れておらず、2 回目は 475 ファイルの転送に成功して全てのファイルが壊れていないという結果になり、堂々の 1 位を受賞しました。

3 位は TCP の上に grpc を載せて転送して 65 ファイルと 36 ファイル、4 ~ 6 位の転送ファイル数は 1 桁だったので圧勝と言えます。

僕が特に面白かったのは 2 位の aso 君で、1 回目は 347 ファイルを転送して 163 ファイルが正常なファイルで、2 回目は 799 ファイルを転送して792 ファイルが正常という驚異のパフォーマンスを見せていました。残念ながら 2 回目の試行は制限時間内に終わらなかったため参考記録ですが、驚異の結果です。

彼は、プロトコルなどなく、各ファイルを 50 KB の 2 つのブロックに分割し UDP パケットにして片っ端から送りつけ続け、全ファイルを送り終わったらまた先頭から送り直し、受け側では連続する2つの UDP パケットを結合して保存するだけでレスポンスはしないというシンプルなものでした。

何も考えないでファイルを片っ端から送り続けてそのうちのいくつかが正常ファイルだったらいいねという大胆なこんなんプロトコルじゃねーだろとも言えるシステムですが、不正ファイルが無視されるというレギュレーションを逆手に取ったスマートな方法だったと思います。これには度肝を抜かれました。

今回のケーブルはツイストペアの 1 対にだけノイズを載せます。全二重通信では、上りか下りのどちらかだけにノイズがのるため、ノイズがのらない方が上りになる 2 回目では圧倒的なパフォーマンスが得られたようです。そりゃそうか。

やり残したこと

事前学習で実質プログラムを書いていたのはキャンプが始まる前日の1日で、キャンプ中も他の講義の宿題で忙しく隙間時間で実装していたので実質 2~3 日分くらいしか実装していません。(最終日の夜は朝の5時半まで徹夜して時間を稼ぎました)

なので、もちろん効率の悪い部分だったり、やりきれなかった機能が残っています。ざっと羅列していきます。

  • writev によるメモリコピーの削減
    • ファイルデータメモリから送信バッファへコピーしているのを writev などによって効率化できれば速くなっていたはず
  • Receiver がセグメントサイズを暗黙知に依存している
    • プロトコルヘッダーはバイト単位なので任意のセグメント分けができるが、実装を簡単にするためにクライアント・サーバの両方に同じ MTU を渡し、MTU を元に共通のセグメントサイズを持つという実装にしていた
    • 実際の世界ではセグメントサイズは一定ではないはずなので任意のセグメントサイズに対応できるように区間木などのデータ構造を導入する
  • window をリングバッファにする
    • Go のスライスを使った簡易な Queue になっているのでリングバッファに書き換える
    • リングバッファによってメモリコピーやメモリアロケーションが減るはず
  • TransId のローテーション
    • 今回は 60 秒程度しか動かさないので TransId はインクリメントさせるだけだった。32 bit 整数なのでローテーションに対応する必要がある。
  • 輻輳制御アルゴリズムをもっと試す
    • Cubic とか試してみたかった
    • 体力の限界で Vegas アルゴリズムを実装するところで諦めてしまった
  • ファイル送信完了の仕組み
    • ファイル送信が完了したらサーバ側の FileContext を削除できるようにするとメモリ効率が上がる。コンテストではメモリリークは関係ないので対応していなかった
    • 実現するためにはファイル転送の開始と終了をクライアントとサーバ側で同期する必要がある。SYN/ACK, FIN/ACK などの TCP でのコネクション管理をファイルごとにする必要がある
  • ファイル名も送る
    • プロトコルの中にファイル名も送る仕組みを入れると実用的になる
  • リンク層を相手にプロトコル構築
    • そもそも IP + UDP の上に乗っかっているので MAC アドレスを直接指定して送信しあう TCP/IP スタックのようなものを実装しても良かった
    • プログラムの規模が大きくなるので実装コスト的に UDP で甘えてしまった
    • UDP ではチェックサムが異常なファイルは全て捨てていたが、データ訂正の仕組みを入れるなどもできる

最後に

疲れました。とっても。でもとても楽しかったです。

来週の月曜(8/26)からは Klab での TCP/IP プロトコルスタック自作のインターンを受講してきます。奇しくも今回の経験がめちゃめちゃ役に立ちそうです。

また、マスタリング TCP / IP を本屋さんに買いに行った時に出会ったこの本がものすごく役に立ったので共有しておきます。

TCP技術入門 ――進化を続ける基本プロトコル (WEB+DB PRESS plusシリーズ)

TCP技術入門 ――進化を続ける基本プロトコル (WEB+DB PRESS plusシリーズ)

それでは。

JWT はユーザに渡すな

JWT とかいてジョットと詠む。どうも、かわしんです。タイトルは釣りです。

この記事は、JWT はログアウトが大変 という内容です。以下で挙げるデメリットを許容できるなら JWT をユーザに渡してもいいと思います。

ログアウトとは、管理者による強制ログアウトや、ユーザのパスワードの変更による全トークンの無効化をさします。ログアウトによって全てまたは一部のトークンが無効化されるユースケースです。

JWT におけるログアウト

JWT はログアウトが大変です。なんかいい方法がないものかと調べていましたが、以下の記事に落ち着きました。

javascript - Invalidating JSON Web Tokens - Stack Overflow

この記事の結論としては、いい方法などない というものです。残念でした。

JWT をログアウトに対応させる方法は ブラックリストを用意するトークンの有効期限を短くする の 2 つだけです。

NOTE : ここではマイクロサービスのことが念頭にあります。

ブラックリスト方式

ブラックリストを用意する方法では、無効化されたトークンをブラックリストに保存します。

ブラックリストトークンは、そのトークンの一番最後の有効期限まで保存し続けます。そのあとは TTL なりで削除しても大丈夫です。

そして、各サービスは JWT の検証の際にブラックリストにアクセスして JWT が無効化されていないかをチェックします。

これでは、JWT のメリットであるトークンの検証の分散・独立・ステートレス性が失われてしまいます。

一方で、ブラックリストに保存するのは無効化されたトークンだけなので、ブラックリストのストレージサイズは全てのトークンを中央に保存するよりは小さくなります。検索も速くなるでしょう。

有効期限を短く設定する方式

トークンの有効期限を短く設定し、頻繁に JWT の更新をさせることで、JWT の更新時に無効化されたトークンの更新を弾く方法です。

JWT のメリットであるトークンの検証の分散・独立・ステートレス性は保たれます。

一方で有効期限を短くすることでどの程度短くするかによりますが、トークンの更新リクエストが頻繁に発生することになります。

また、最大のデメリットとして 即時無効化ができない という重大な欠点があります。セキュリティ的にまずいです。多分 JWT を使ってるサービスはこれを許容してるんだと思いますが、実装を手抜きするために仕様を捻じ曲げてセキュリティ的なデメリットを容認してしまうのは個人的にはしたくないです。

マイクロサービスにおける JWT

さて、マイクロサービスにおける認証を調べているとクライアントからのアクセストークンを API Gateway で内部のトークンに変換する方法がしばしば紹介されています。(Microservices Authentication and Authorization Solutionsマイクロサービスシステムにおける認証ストラテジ など)

最初はなんでこんな複雑なことをするんだと思っていたのですが、JWT のログアウトの問題を調べているうちに納得しました。

内部のトークンが JWT の場合、Gateway で利用する変換テーブルからアクセストークンを削除すれば JWT はなくなり使われなくなってしまうので、即時ログアウトが実現できます。

JWT は Gateway 内部のプライベートなネットワークの中でのみ利用されるため JWT 自体はログアウトのことを考える必要がありません。

Gateway の中でアクセストークンの変換が高速にできるのかよという疑問はありますが、そこはクラウドプロバイダーの謎テクノロジーが解決しているのでしょう。

マイクロサービスの文脈では複数のサービスを利用することやサービスが別のサービスに連鎖的にリクエストすることがありえます。各サービスが中央の認証テーブルにアクセスするコストが JWT を使うことにより削減されます。これは連携するサービスの数が多いほどこの効果は大きくなるはずです。

JWT をユーザに渡さず、プライベートネットワークの内部だけで使うという方法は一般的なユースケースに適っていて、いい JWT の使い方だと思います。Gateway 周りの実装がじょっとめんどくさそうですが。