Summer Internship 2018 at mercari

8/2 から 8/31 までの間、mercari で開催された今年度のインターンシップへ参加してきた。 業種は募集記事にもある "Software Engineer (Microservices Platform)" で、 社内の Kubernates 基盤や Microservices における開発・運用全体に携わる業務を中心に行うチームを選択した。

mercan.mercari.com

簡単にまとめると、とにかく "Be Professional" を体感できた良いインターンシップだった。 以下に参加記という体で自分の感じたこととやったこと、そして上記の理由をまとめる。

動機と選考と印象

今年の夏は長期インターンシップに参加したいという漠然とした心意気だけがあり、 魔法スプレッドシートを見ながら迷ったりしていた。 正直ハッカソン形式や講義形式はそんなに興味が無く、実務をしたいという気持ちのもとで色々見てみると、 研究開発系は「機械学習で〜」とか「AIが〜」という決まり文句が多くて研究分野とマッチしないし、 開発系はふわっとした要項が多いためにどこも同じように見えてしまって、内容というよりは企業のネームバリューで選択する他なかった。 そんな中で "Microservices Platform" という文字列を見つけて「これは!」と思ったのが申し込んだきっかけだった *1

申し込みフォームからの書類選考兼 GitHub その他の選考があり、 それを通過すると簡単なコーディングの選考、最後に選択したチームのメンバーやテックリードと面接をして合否決定という流れだった。 後になって知ったけど、インターンシップを複数の会社応募するのはあるあるらしい。 自分は mercari しか応募しておらず、落ちたら暇だなーと考えていたので悲しいですね。

期間は 8 月いっぱいの予定だったが、月初に学会発表を控えていたので1日だけ遅らせてもらった。 これは働き方とか業務全般に感じたけど、色々な融通が(常識的な範囲内なら)だいたい OK と返事を貰えることが多くて、ストレスフリーな職場でかなり体験が良かった。

入社説明時に「成果を残してください」風なことを何度も言われたのが印象に残っている。 もちろん通常業務でという話なので、目標設定と自分のやり遂げ力が必要っぽいなというのを感じさせられて結構緊張していた。

あとは、メンターランチという制度があって、それを利用して入社後に何度か他のチームや社員の人と六本木の高めなお店へランチへ行くことができる。 下のようなヤバそうなご飯が食べれて、もちろんお金も出るし、誰と行きたいか困ったらメンターさんにお願いすることもできる(自分は結局ほとんどおまかせしていた)。

f:id:everysick:20180911235803j:plain

開発

上記でも書いた通り、自分の配属された Microservices Platform Team では、社内で推進されている Microservices 化に必要な基盤を整える業務を中心としていた。 直近にどういうことをしているかというのは 7 月に開催されていたイベントの資料が詳しい。

tech.mercari.com

入社後にアーキテクチャの説明とか軽い on-boarding を受けた後、目標設定の段階で「あ、これは Kubernetes の前提知識が全然足りない」という現実に直面した。 業務の流れとかアーキテクチャの構成を表面的に理解するまでは良いのだけど、じゃあそれを説明してくださいと言われるとスムーズに行かない感じである。 メンターの方はそれをすっぱり見抜いて、まずは構成把握を兼ねた周辺知識のインプットからというタスク割をしてくださったので助かりました…。

開発では Golang を使っていたのだけど、実は Golang も production-ready なソフトウェアを書いたことがない状態だった。 レビューを通してオレオレコードから Golang らしい丁寧なコードを書く方法や精神論(?)を享受してもらえたのは本当に良い経験だったし、 自身の作業をこなしながらレビューをしてくださったので、全然頭が上がりそうにない。へこへこするしかない。

成果物

メンターさんやチームの方の指導の下で、一ヶ月という間でインプットから当初目標していたアウトプットまでを達成することができた。 成果物の内容は OSS として公開されているし、mercari の tech-blog にも投稿しているので説明は省略する。 忙しさが抜けてきたので OSS の開発も進めていくぞ!

github.com

tech.mercari.com

感想

mercari は新卒だからとかインターンだからという垣根は本当に無くて、 当人が見通しを持てているなら、課題を見つけて解決するという点においてとにかくフラットな場だと思う。 だから、社内でも良い意味で競い合えるような環境が多いし、成果を聞いてから「え、この人若い…」という発見が結構あった。

最も印象に残っていたのは、チームの開発に対する姿勢で、 「これを導入しよう」「これを検証しよう」という段階で時間をかけて必要なインプットを行って、インプットに基づいた試作をチームで共有して議論をして、 課題を解決する"良い形"を見つけるまでそれを繰り返してドキュメントや運用方法をまとめて、最後にそれらを外部発表やブログ化といった外部へのアウトプットまで持っていく。 開発では、この一連のフローを通してその技術の Professional になっていくぞ!というをひしひし感じられたので、何かをやっていく姿勢の手本として見習っていきたい…!

インターンシップの目的として掲げられていた「自分の価値を知る、高める」という課題は個人的に十分に達成できたと思う。 価値を知るという観点では、企業の取り組みと自分の技術スタックの関係を客観的に見れるようになったので就活の参考にもなったし、 高めるという点ではインプットとアウトプットを通して、しっかり達成できていると言いたい。


表面的なことしか書いていない参加記になってしまったけど、こうしてインターンシップに参加してみて、 毎月人々が吸い込まれていく理由とその魅力がわかった気がする。

まだ学生でやっておくべきことも大量に見つかったので Be Professional でやっていくぞ!

*1:元々 Microservices に関わる内容に興味があったことと、外部発表の資料を見ていたというバイアスも大きい気がする

EchoサーバーをI/O Multiplexingに育てる

グリーンスレッドの実装を考えていたらいつの間にかタイトルのようなことをやっていた。 実装した全部載せはリポジトリに置いてあるので、何を考えたかを吐き出す。

github.com

simple echo

echo サーバー( "hello" と受け取って "hello" と返す)のように、 クライアントからの読み込みと書き込み間でコンテキストが発生する通信のサーバーの実装を考える。 シンプルに単一な accept ループを使う場合、一つのクライアントとの読み書きを終えるまで次のクライアントとは何もできない。 read/write の処理ではブロックするし、同期的に全ての読み書きが処理される。 なので処理中に現れる新規のクライアントは listen で指定された backlog に積まれ、次の accept が来るのを待つことになる。

  • simple_echo.c
int main(int argc, char** argv) {
        soc = socket(AF_INET, SOCK_STREAM, 0) == -1;
        bind(soc, (struct sockaddr *)&saddr, saddrlen);
        listen(soc, CONNECTION);

        while(1) {
                acc = accept(soc, (struct sockaddr *)&caddr, &caddrlen);

                do {
                        read(acc, buf, sizeof(buf));
                        write(acc, buf, strlen(buf));
                } while (strcmp(buf, "Bye!!") != 0);

                read(acc, buf, sizeof(buf));

                close(acc);
        }

        close(soc);
        return 0;
}

asynchronous echo

クライアントは同時に複数捌けたほうが効率は良いので、 まずは同期的な処理を改善する。 accept は単体であれば非同期に呼び出しても問題はなく、読み書きも fd 単位で独立していれば非同期にできる。 非同期処理には thread(または process )を用いる。 この方法にも色々種類はあって、例えば accept 毎に thread(process)を作成する方法や、 予め thread(process)を作成しておいてそれぞれで accept ループを実行する方法がある。

thread か process かによっても違いは生じる。 thread は process に比べて軽量であるが、ヒープやファイルディスクリプタを共有しているため、 適切にロックを入れることによって実行順序やデッドロックを考慮する必要がある。 逆に process はまったく別の(実行単位としての)プロセスとしてプログラムが実行されるため、 上記のように注意する点は少ないが、例えば、fork を呼び出すまでに使用していた fd は fork 後に再度有効になるなど、多少は考慮する点がある。

実装は thread を accept 毎に立ち上げるもの、process を accept 毎に立ち上げるもの、 予め固定数の thread を立ち上げておくもの、予め固定数の process を立ち上げておくものを実装した(つまり全部)。 一応コネクション数には上限を設けている。

I/O Multiplexing echo

非同期的に処理を行うことによって複数のクライアントを同時に扱うことができるようになったが、 各 thread(process)別に見てみると、単一なクライアントとの処理を全て終えるまで他のクライアントに関する処理は行っていない。 加えて、read/write 処理中は fd が無効であれば待ちが発生するためブロックされている。 これを epoll を使用して改善を行う。

I/O Multiplexing な状態であるとは、同時に複数の fd に対して状態監視を行うことで、単一の fd の状態によって全体がブロックされるのを防いでいることである。 これを複数 thread(process)な状態で適用する方法は色々あるが、以下の方針を取ることにした。

  • 対象とするプログラムは pre_thread_echopre_fork_echo
  • accept ループは本体行う
  • 各 thread(process)は epoll_wait(2) で受け取った fd に対して一度きりの I/O 操作を行う

これによってどういう状態になるかというと、 各 thread(process)は複数のクライアントと同時にやり取りを行うようになる。 thread(process)別に見るとクライアントとのやり取りは同期的であるが、 read/write による個別のブロッキングを避けるため、待ちの状態が発生しなくなり、効率的に読み書きを行うことが可能になる。

捕捉

epoll は今回 EPOLLONESHOT を適用して epoll_wait で一度受け取った fd は無効化するようにしていたが、 accept と異なってこの処理は複数 thread(process)間で同期的に行う必要があった*1。 ここが今回のハマりポイントで、気がつくまで結構時間を取られた。 epoll_pre_thread_echo.c での実装では、結局 epoll_wait 時には lock を入れなければならなかった。 lock フリーな実装を行う方法として、read/write は結局一つの thread しか成功しないため、 ノンブロッキングな状態で read/write 実行し返り値を見る方法を考えたが、 例えば read する対象のサイズが大きいため realloc が必要になる場合、 複数回の read を発行する必要があり、別 thread での read 防ぐために lock が必要になるなど、本末転倒。 よって実装では epoll_wait に lock を入れている。

epoll_pre_fork_echo.c の実装は不可解な処理をしているが、それは複数 process 間で fd の受け渡しを行うために行っている。 メインプロセス中の accept の返り値でクライアントとの有効な fd を受け取るが、もちろん子プロセスでは有効なはずはない。 そのため、メインプロセスと子プロセス間で sendmsg/recvmsg を使ったやり取りを行った fd を受け渡す。 この辺は全然理解せずに使用したので、いつかまた仕組みを詳しく見たい。

ちなみに、epoll を用いたこれらの実装では、クライアントと echo なんてしていなくて、実際は固定の文字列を返しているだけである。 というのも、I/O Multiplexing な状態で echo のコンテキストスイッチが結構面倒で、 やればできるし実装はいいやということで投げている(epoll_event 構造体メンバのdata.ptrにポインタを入れるだけなので楽といえば楽)。

performance

今回は特に計測を目的としていないが、一応どれほど違いがあるのかについて計測は行った。 結果だけ述べると epoll_pre_fork_echo.c が最も速く、次点で epoll_pre_thread_echo.c という結果だった。 epoll を用いて I/O Multiplexing を行っている実装が高速なのは納得であるが、 実は epoll_pre_fork_echo.cepoll_pre_thread_echoc.c にも明確に差が出ている。 これは上記の lock の問題が効いているのかなぁと思う。真面目に実装したら変わるのだろうか。

ちなみに計測は golang の適当なスクリプトで行った。 https://github.com/Everysick/io_multiplexing_echo_server/blob/master/client.go

reference

Rack::Timeout::RequestTimeoutException の仕組み

Ruby の webserver に使われるミドルウェアである Rack に、timeout を管理する拡張 gem の rack-timeout がある。 その仕事として例えば、レスポンスを返すまでに指定した秒数以上の時間がかかる場合、その秒数で Rack::Timeout::RequestTimeoutException を例外として発生させる。

github.com

タイムアウトすると聞いただけでは、処理を中断させて例外を raise しているんだろうと想像できる。 そのためタイムアウトした処理の場所や環境が取得できないように思えるが、その例外の backtrace にはきちんとタイムアウトした処理の行の backtrace が載っている。 どうせRubyなんだしできるんだろうなと思っていたけど、直感で方法が思いつかなかったのでコード読んでみた*1

rack-timeout/lib/rack/timeout/core.rb

timeout = RT::Scheduler::Timeout.new do |app_thread|
  register_state_change.call :timed_out
  app_thread.raise(RequestTimeoutException.new(env))
end

response = timeout.timeout(info.timeout) do
  begin  @app.call(env)
  rescue RequestTimeoutException => e
    raise RequestTimeoutError.new(env), e.message, e.backtrace
  ensure
    register_state_change.call :completed
  end
end

github

この辺読むだけで良かった、RT::Scheduler::Timeout.new 後にアプリケーションのスレッドで RequestTimeoutException を raise しているのがわかる。 Thread#raiseそのスレッドで例外を発生させる。 しかも backtrace はそのスレッドが処理をしていた行となるので完全に便利。 つまるところアプリケーションの本来の処理を行うスレッドと別のスレッドで秒数をカウントし、 秒数がオーバーしたらアプリケーションのスレッドに向けて例外を発生させるだけというシンプルなものだった。

ちなみにRT::Scheduler::Timeout は下のようなコードになっていて、Thread.currentでアプリケーションスレッドを参照し、 @scheduler で時間に関する処理をしつつ、タイムアウト時には @on_timeout.call を呼んでいる。@scheduler の中身に関しては本質的ではなかったので触れない。

rack-timeout/lib/rack/timeout/support/timeout.rb

ON_TIMEOUT = ->thr { thr.raise Error, "execution expired" }

def initialize(&on_timeout)
  @on_timeout = on_timeout || ON_TIMEOUT
  @scheduler  = Rack::Timeout::Scheduler.singleton
end

def timeout(secs, &block)
  return block.call if secs.nil? || secs.zero?
  thr = Thread.current
  job = @scheduler.run_in(secs) { @on_timeout.call thr }
  return block.call
ensure
  job.cancel! if job
end

github

ちなみに簡素な作りで良ければこういう感じに書ける。

class TimeoutError < StandardError; end

def call_with_timeout(time, &block)
  thr = Thread.current

  Thread.new do
    sleep(time)
    thr.raise(TimeoutError.new)
  end

  return block.call
end

begin
  call_with_timeout(2) do
    3.times do
      sleep(1)
      puts "Hello Thread!"
    end
  end
rescue TimeoutError => e
  puts "timeout!!"
end

実行結果

$ ruby call_with_timeout.rb
Hello Thread!
timeout!!

*1:この記事のコード例は github から引用し、不要な部分を削減しているため注意