Akka Httpの中で使われているFastFutureがおもしろかったので紹介

akka-httpの中で使われている FastFuture が面白かったので紹介します。 Scalaの標準の scala.concurrent.Future と基本的な挙動は同じですが、パフォーマンス面で有利になるような実装になっています。

既存のFutureのどこがパフォーマンス的に不利なのか

Future(123).map(_ * 2).map(_ + 234)

のような処理があった時、123 を生成するスレッドと、その値を2倍するスレッドと、 234 を加算するスレッドが別になる。これは flatMap にも言えて

for {
  a <- Future { calculateA() }
  b <- Future { calculateB(a) }
  c <- Future { calculateC(b) }
} yield c

みたいな処理があった時、 calculateA, calculateB, calculateC 3つのメソッドが別のスレッドで実行される。 map でチェーンするのも、flatMap をチェーンさせる(for文)のも Future が作られるもののこの書き方だと直列に実行されるので別スレッドを割り当てるのはコンテキストスイッチを発生するのとCPUのキャッシュが汚染されるのでパフォーマンス的に無駄がある。 型をあわせるだけの目的であれば Future.successful を使えばスレッドが割り当てられないので型をあわせるだけならこれが使われることが多い。 しかしながら、実は Future#map を呼ぶと内部で別スレッドの割当が行われるので Future.successful を使っていてもスレッドの切り替わりは完全に防げているわけではない。

例えば、 scala.concurrent.Future の実装

  def map[S](f: T => S)(implicit executor: ExecutionContext): Future[S] = { // transform(f, identity)
    val p = Promise[S]()
    onComplete { v => p complete (v map f) }
    p.future
  }

map した中身を処理するときに新たに Future を生成しているのでスレッドの切り替わりが発生している。

akka.http.scaladsl.util.FastFuture

実装

https://github.com/akka/akka-http/blob/master/akka-http-core/src/main/scala/akka/http/scaladsl/util/FastFuture.scala

implicit class EnhancedFuture[T](val future: Future[T]) extends AnyVal {
  def fast: FastFuture[T] = new FastFuture[T](future)
}

このimplicitを使って Future#fast を呼べば FastFuture が作れる。

Future(123).fast
=> FastFuture[Int]

FastFuture の一番のポイントは以下で

private case class FulfilledFuture[+A](a: A) extends Future[A] { ... }
private case class ErrorFuture[+A](a: A) extends Future[A] { ... }

def map[B](f: A ⇒ B)(implicit ec: ExecutionContext): Future[B] =
  transformWith(a ⇒ FastFuture.successful(f(a)), FastFuture.failed)

def transformWith[B](s: A ⇒ Future[B], f: Throwable ⇒ Future[B])(implicit executor: ExecutionContext): Future[B] = {
  def strictTransform[T](x: T, f: T ⇒ Future[B]) =
    try f(x)
    catch { case NonFatal(e) ⇒ ErrorFuture(e) }

  future match {
    case FulfilledFuture(a) ⇒ strictTransform(a, s)
    case ErrorFuture(e)     ⇒ strictTransform(e, f)
    case _ ⇒ future.value match {
      case None ⇒
        val p = Promise[B]()
        future.onComplete {
          case Success(a) ⇒ p completeWith strictTransform(a, s)
          case Failure(e) ⇒ p completeWith strictTransform(e, f)
        }
        p.future
      case Some(Success(a)) ⇒ strictTransform(a, s)
      case Some(Failure(e)) ⇒ strictTransform(e, f)
    }
  }
}

transformWith メソッド内で Future を継承した FulfilledFuture もしくは ErrorFuture であれば Future ではなく Try として実行される。それ以外(ふつうのFuture)であれば通常の Future と同じ挙動をする。

いいこと

Future#fast を呼ぶだけで FastFuture が作れて、これを使うと別スレッドの割り当てが発生しないので直列に実行される場合の Future のチェーンにおいて無駄なコンテキストスイッチが発生せずパフォーマンス面で効率がよい。

わるいこと

サービス層とリポジトリ層で ExecutionContext を分けている場合に意図的に割り当てるスレッドプールを変えたいような場合でもスレッドが切り替わらないのでimplicitで ExecutionContext を渡してあげても意図したとおりにスレッドが変わらない。

scala.concurrent.Futureに取り込まれないのか?

議論はありました。が、 map だけ特別扱いするのはおかしいということになり、取り込まれてはいません。 https://contributors.scala-lang.org/t/upates-to-scala-concurrent-future-wrt-breaking-changes/1281/26

Huawei系Android端末のアプリ内の地図で "google play services are updating" という表示が出たまま地図が表示されない件

Huawei Mate 9を使ってるのですが、気がついたらアプリ内に地図が表示されなくなっていて、代わりに "google play services are updating" という一文だけが表示されていました。P 10, P10 liteでも発生したという話も見かけます。

https://lh3.googleusercontent.com/-wmHAml1-Hs0/Wpy33EYUoVI/AAAAAAAAACc/TACEu7GB1ekDdij-5zcFQ2JB0ZiDEMNtwCL4CGAYYCw/s1600/S80305-105955.jpg ( https://productforums.google.com/forum/#!topic/play/xfLz_9emKs0 より)

対処方法

https://productforums.google.com/forum/#!topic/play/xfLz_9emKs0 ここのフォーラムで議論されている内容が近そうです。 自分の端末の場合は以下のような対応で治りました。

設定 → アプリと通知 → アプリ → Google Play開発者サービス → 無効にする → "google play services are updating" が出ていたアプリを開く → Google Play開発者サービスを更新するか聞かれる → 更新する → 地図が問題なく表示されるようになる

サイトブロッキングが話題なのでDNSブロッキングを実現するための方法を検証してみた

本日、NTTコミュニケーションズNTTドコモNTTぷららの3社はサイトブロッキングを実施するとの方針を発表しましたね。

www.ntt.com www.asahi.com

民間事業者による自主的な取組としてサイトブロッキングを行が行われるという建前のため、もし自分が電気通信事業者で意思決定をする立場にあった場合はこのような何のメリットもなく訴訟リスクしかないような事をやろうとは思いませんし、個人的にも報道されているような手法でのサイトブロッキングは反対の立場です。ただ、技術的にはどのように実現すればよいのか興味があるので検証してみました。

サイトブロッキングを実現するための方法の一つとして、「DNSブロッキング」という手法があるそうです。ISPがユーザに対して提供しているDNSキャッシュサーバでブロックしたいサイトの名前解決をできないようにする手法です。ただこの手法を用いても、ユーザがISPの管理外の 1.1.1.1, 1.0.0.1, 8.8.8.8, 8.8.4.4 のようなpublic DNS serverを使えば回避できてしまうのであまり意味はありません。

DNSブロッキングに対応したDNSサーバを構築する

今回はOSとしてubuntu16.04の上でDNSキャッシュリゾルバであるunboundを構築して設定していきます。

まずはふつうのDNSフルリゾルバを構築する

まずはユーザに提供するようのDNSフルリゾルバを作成します。 aptコマンドを使ってunboundをインストールします。

ubuntu@dns-resolver01:~$ sudo apt install unbound

ubuntu@dns-resolver01:~$ sudo service unbound status
● unbound.service
   Loaded: loaded (/etc/init.d/unbound; bad; vendor preset: enabled)
  Drop-In: /run/systemd/generator/unbound.service.d
           └─50-insserv.conf-$named.conf, 50-unbound-$named.conf
   Active: active (running) since Mon 2018-04-23 06:57:24 UTC; 17min ago
     Docs: man:systemd-sysv-generator(8)
   CGroup: /system.slice/unbound.service
           └─2297 /usr/sbin/unbound

インストールコマンドを1行打つだけでunboundがインストールされて起動されていることがわかりますね。この状態だと外部からのアクセスに応答しないようになっているので、private IP アドレスからの問い合わせには応答するようにしてみましょう。

/etc/unbound/unbound.conf.d/dns-cache.conf というファイルを作成し、以下の内容を記述します。

server:
    interface: 0.0.0.0
    access-control: 10.0.0.0/8 allow
    access-control: 172.16.0.0/12 allow
    access-control: 192.168.0.0/16 allow
    access-control: 127.0.0.1/32 allow

完了したら、以下のコマンドで再起動をします。

ubuntu@dns-resolver01:~$ sudo service unbound restart

これで別のホストからのアクセスにも応答できるはずです。別ホストからDNSサーバとして参照してみた時の結果が以下です。 10.55.48.99 というのは今回構築したサーバのIPアドレスです。

ubuntu@ubuntu01:~$ dig +short google.com @10.55.48.99
216.58.197.174

DNSによるサイトブロッキングを実現する

ここからが本題です。特定のサイトへアクセスできないようにするにはどうすればよいのでしょうか。サイトブロッキングの対象とされている anitube.se にアクセスできないようにしてみましょう。まずは特に設定を入れない状態で anitube.se の名前解決をしてみます。

ubuntu@ubuntu01:~$ dig +short anitube.se
104.20.203.27
104.20.202.27

2つのIPアドレスが返ってきましたね。名前解決ができています。これを解決できないようにしたいということです。

Unboundのpython拡張

Unboundにはpythonスクリプトで拡張をする機能があります。ドキュメントを参考にして実装しました。 https://unbound.net/documentation/pythonmod/examples/example0.html

gist.github.com

def filter_domain(qstate, id):
    domain = qstate.qinfo.qname_str.rstrip('.')
    if domain in block_domains:
        qstate.return_rcode = RCODE_NXDOMAIN
        qstate.ext_state[id] = MODULE_FINISHED
    else:
        qstate.ext_state[id] = MODULE_WAIT_MODULE

filter_domainという箇所でクエリで問い合わせられているドメインブラックリストに入っていれば名前解決を続行せずに NXDOMAIN を即答するようになっています。

このスクリプト/etc/unbound/unbound/domain_filter.py に設置します。

Unboundの設定

UnboundのPython拡張をインストールします。

ubuntu@dns-resolver01:~$ sudo apt install python-unbound

/etc/unbound/unbound.conf.d/dns-cache.conf を以下のように書き換えます。

server:
    interface: 0.0.0.0
    access-control: 10.0.0.0/8 allow
    access-control: 172.16.0.0/12 allow
    access-control: 192.168.0.0/16 allow
    access-control: 127.0.0.1/32 allow
    module-config: "python validator iterator"
python:
    python-script: "/etc/unbound/domain_filter.py"

ドメインのフィルタ対象の一覧ファイルを /etc/unbound/block_domains.txt に作成します。1行1ドメインで列挙していきます。

anitube.se

unboundを再起動します。

ubuntu@dns-resolver01:~$ sudo service unbound restart

動作確認

ubuntu@ubuntu01:~$ dig anitube.se @10.55.48.99

; <<>> DiG 9.10.3-P4-Ubuntu <<>> anitube.se @10.55.48.99
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 17521
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;anitube.se.                    IN      A

;; Query time: 0 msec
;; SERVER: 10.55.48.99#53(10.55.48.99)
;; WHEN: Mon Apr 23 10:01:15 UTC 2018
;; MSG SIZE  rcvd: 39

anitube.se を名前解決しようとしてもレコードが返ってこなくなりました。 ログを見るかぎり、結果はキャッシュされているようなので毎回実行されるわけではなさそうです。

追記

BINDだとRPZという機能を使えばDNSブロッキングができるらしい。職場の方に教えてもらいました。

sbt 1.x 系にアップグレードしたらCIでコンパイルキャッシュが効かなくなったので対処した

業務で開発しているプロジェクトで、sbtを0.13.17 から 1.1.1にアップグレードして喜んでいたらCIでキャッシュが効かなくなってしまい、ビルド時間が遅くなるという事象を経験したのでどのような調査をしたのかと、対処方法を書きます。ちなみにCIはCircleCI2.0を使っています。

コンパイルキャッシュが効かなくなる現象の確認

プロジェクトの作成

環境を用意します。Ubuntu16.04上にsbtをインストールしました。手順は以下のものを参考にしました。

sbt Reference Manual — Linux への sbt のインストール

hello と出力するだけのプロジェクトをつくります。 scala/scala-seed.g8 のテンプレートを使いました。

ubuntu@c01:~$ sbt new scala/scala-seed.g8
[info] Set current project to ubuntu (in build file:/home/ubuntu/)

A minimal Scala project.

name [Scala Seed Project]: hello-world

Template applied in ./hello-world

コンパイルと実行

動作確認をします。 sbt run を2度実行しています。

ubuntu@c01:~$ cd hello-world/
ubuntu@c01:~/hello-world$ sbt run
[info] Loading project definition from /home/ubuntu/hello-world/project
[info] Updating ProjectRef(uri("file:/home/ubuntu/hello-world/project/"), "hello-world-build")...
[info] Done updating.
[info] Compiling 1 Scala source to /home/ubuntu/hello-world/project/target/scala-2.12/sbt-1.0/classes ...
[info] Done compiling.
[info] Loading settings from build.sbt ...
[info] Set current project to hello-world (in build file:/home/ubuntu/hello-world/)
[info] Updating ...
[info] Done updating.
[info] Compiling 1 Scala source to /home/ubuntu/hello-world/target/scala-2.12/classes ...
[info] Done compiling.
[info] Packaging /home/ubuntu/hello-world/target/scala-2.12/hello-world_2.12-0.1.0-SNAPSHOT.jar ...
[info] Done packaging.
[info] Running example.Hello
hello
[success] Total time: 1 s, completed Mar 21, 2018 6:38:59 AM

ubuntu@c01:~/hello-world$ sbt run
[info] Loading project definition from /home/ubuntu/hello-world/project
[info] Loading settings from build.sbt ...
[info] Set current project to hello-world (in build file:/home/ubuntu/hello-world/)
[info] Packaging /home/ubuntu/hello-world/target/scala-2.12/hello-world_2.12-0.1.0-SNAPSHOT.jar ...
[info] Done packaging.
[info] Running example.Hello
hello
[success] Total time: 0 s, completed Mar 21, 2018 6:39:17 AM

1度目はコンパイルが走っていて、2度目はコンパイルキャッシュが効いているため、コンパイルを行わずに実行されていることがわかります。

CI上で行われているのと同様にコンパイルキャッシュの保存/展開操作を行う

CI上で行う操作は、

  1. ソースコードのチェックアウト
  2. (キャッシュがあれば)キャッシュの展開
  3. コンパイル・テスト
  4. キャッシュの作成・保存

です。

CI上の環境と同じ事を行ってみましょう。ソースコードはチェックアウトされたとみなして、初回のテストなのでキャッシュは存在しません。なので3のコンパイル・テストの工程からはじめます。

コンパイル・テスト

ubuntu@c01:~/hello-world$ sbt test
[info] Loading project definition from /home/ubuntu/hello-world/project
[info] Loading settings from build.sbt ...
[info] Set current project to hello-world (in build file:/home/ubuntu/hello-world/)
[info] HelloSpec:
[info] The Hello object
[info] - should say hello
[info] Run completed in 567 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 1 s, completed Mar 21, 2018 6:52:09 AM

先程実行した時のコンパイルキャッシュが残っているのでコンパイルは走っていませんね。テストもきちんとパスしました。

キャッシュの作成・保存

tarコマンドを使って、 target ディレクトリ以下を1つのアーカイブにまとめます。

その他にも実際のCIでは ~/.sbt~/.ivy2 といったディレクトリもキャッシュされるようにする必要があります。今回はこの2つのディレクトリは問題の事象に関係がなかったので target ディレクトリだけを対象にします。

ubuntu@c01:~/hello-world$ tar cvf cache.tar target/
target/
target/streams/
target/streams/$global/
target/streams/$global/dependencyPositions/
target/streams/$global/dependencyPositions/$global/
[... 以下略]
target/scala-2.12/hello-world_2.12-0.1.0-SNAPSHOT.jar

実際のCIであれば、生成された cache.tar ファイルを保存して、次回のビルド時にこのファイルを展開してあげればコンパイルキャッシュが効くはず…です。

キャッシュの展開

先程生成したキャッシュを展開してみます。tarでアーカイブしたものをそのまま展開して上書きしただけなので特に変わりはないはずです。

ubuntu@c01:~/hello-world$ ls
build.sbt  cache.tar  project  src  target

ubuntu@c01:~/hello-world$ tar xvf cache.tar
target/
target/streams/
target/streams/$global/
target/streams/$global/dependencyPositions/
target/streams/$global/dependencyPositions/$global/
[...以下略]
target/scala-2.12/hello-world_2.12-0.1.0-SNAPSHOT.jar

再びテスト実行

再びテストを実行させてみます。

ubuntu@c01:~/hello-world$ sbt test
[info] Loading project definition from /home/ubuntu/hello-world/project
[info] Loading settings from build.sbt ...
[info] Set current project to hello-world (in build file:/home/ubuntu/hello-world/)
[info] Updating ...
[info] Done updating.
[info] Compiling 1 Scala source to /home/ubuntu/hello-world/target/scala-2.12/classes ...
[info] Done compiling.
[info] Compiling 1 Scala source to /home/ubuntu/hello-world/target/scala-2.12/test-classes ...
[info] Done compiling.
[info] HelloSpec:
[info] The Hello object
[info] - should say hello
[info] Run completed in 640 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 7 s, completed Mar 21, 2018 7:04:58 AM

なんと、コンパイルが再び走ってしまっています。キャッシュが効いていないということですね。sbt0.13.17を使っていたときはこのやり方でうまくいっていたのですが…。

原因

どのディレクトリをtarでアーカイブしてからリストアするとこの事象が発生するのか、 target ディレクトリ以下を順番に掘り下げて行って調査したところ、 target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOTディレクトリ以下のファイルが影響しているということがわかりました。

ubuntu@c01:~/hello-world/target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOT$ ls -l
total 3
-rw-rw-r-- 1 ubuntu ubuntu  647 Mar 21 07:04 resolved.xml.properties
-rw-rw-r-- 1 ubuntu ubuntu 1943 Mar 21 07:04 resolved.xml.xml

このディレクトリにはファイルが2つあります。アーカイブなのでファイルの中身が変わるはずはありませんが、一応md5ハッシュの値を展開前と展開後で比べてみます。

# 展開前
ubuntu@c01:~/hello-world/target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOT$ md5sum resolved.xml.*
d369e9bf8b52bddc7da2f93e58b6b86f  resolved.xml.properties
2c8964010ef6a2835dce1c3f2cc8853d  resolved.xml.xml

# 展開後
ubuntu@c01:~/hello-world/target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOT$ md5sum resolved.xml.*
d369e9bf8b52bddc7da2f93e58b6b86f  resolved.xml.properties
2c8964010ef6a2835dce1c3f2cc8853d  resolved.xml.xml

当たり前ですが、ハッシュ値は一致しています。

もう一点疑う余地があるとすれば、タイムスタンプです。

# 展開前
ubuntu@c01:~/hello-world/target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOT$ ls -l
total 3
-rw-rw-r-- 1 ubuntu ubuntu  647 Mar 21 07:04 resolved.xml.properties
-rw-rw-r-- 1 ubuntu ubuntu 1943 Mar 21 07:04 resolved.xml.xml

# 展開後
ubuntu@c01:~/hello-world/target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOT$ ls -l
total 3
-rw-rw-r-- 1 ubuntu ubuntu  647 Mar 21 07:04 resolved.xml.properties
-rw-rw-r-- 1 ubuntu ubuntu 1943 Mar 21 07:04 resolved.xml.xml

特に変わりはないように思えますね…。ここでファイル作成時刻のタイムスタンプはもっと詳細な粒度で保持しているはず、という事を思い出しました。 ls コマンドの --full-time オプションで表示ができるようです。

# 展開前
ubuntu@c01:~/hello-world/target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOT$ ls -l --full-time
total 3
-rw-rw-r-- 1 ubuntu ubuntu   647 2018-03-21 07:04:52.724370184 +0000 resolved.xml.properties
-rw-rw-r-- 1 ubuntu ubuntu  1943 2018-03-21 07:04:52.716369762 +0000 resolved.xml.xml

# 展開後
ubuntu@c01:~/hello-world/target/scala-2.12/resolution-cache/com.example/hello-world_2.12/0.1.0-SNAPSHOT$ ls -l --full-time
total 3
-rw-rw-r-- 1 ubuntu ubuntu  647 2018-03-21 07:04:52.000000000 +0000 resolved.xml.properties
-rw-rw-r-- 1 ubuntu ubuntu 1943 2018-03-21 07:04:52.000000000 +0000 resolved.xml.xml

おっと…!これですね。タイムスタンプが秒単位で丸められています。ここのタイムスタンプが完全に一致していないためにプロジェクトに変更が加えられたと認識されて、フルでコンパイルが走ってしまっているのではないかという当たりがつけられました。どうやらtarは標準ではファイルのタイムスタンプは秒未満は切り捨てるようです。zipは標準だと2秒単位でタイムスタンプを丸めるようです。

コンパイルキャッシュが効くようにするために対処した

社内でtarでアーカイブを作成するときにナノ秒単位でタイムスタンプを保持する方法がないか聞いたところ、以下のリンクを教えてもらいました。

unix.stackexchange.com

GNU tarを使っているときにposixフォーマットでアーカイブを作成するとよいということがわかりました。 --format posix をつけるとよいということですね。

再び実験

キャッシュを作ってからリストアする

ubuntu@c01:~/hello-world$ tar --format posix -cvf cache.tar target/
target/
target/streams/
target/streams/$global/
target/streams/$global/dependencyPositions/
target/streams/$global/dependencyPositions/$global/
[... 以下略]
target/scala-2.12/hello-world_2.12-0.1.0-SNAPSHOT.jar

ubuntu@c01:~/hello-world$ tar xvf cache.tar
target/
target/streams/
target/streams/$global/
target/streams/$global/dependencyPositions/
target/streams/$global/dependencyPositions/$global/
[...以下略]
target/scala-2.12/hello-world_2.12-0.1.0-SNAPSHOT.jar

テストを実行する

再びテストを実行してみます。

ubuntu@c01:~/hello-world$ sbt test
[info] Loading project definition from /home/ubuntu/hello-world/project
[info] Loading settings from build.sbt ...
[info] Set current project to hello-world (in build file:/home/ubuntu/hello-world/)
[info] Updating ...
[info] Done updating.
[info] HelloSpec:
[info] The Hello object
[info] - should say hello
[info] Run completed in 710 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 2 s, completed Mar 21, 2018 7:25:45 AM

今度はちゃんとコンパイルキャッシュが効きました!これで安心してCIをたくさん回せます。

ちなみにCircleCI2.0の persist_to_workspace 機能を使っている時も同様な事象が起きますので、自前で上記の手法でtarアーカイブを作成してリストアするとよいでしょう。

まとめ

  • sbt 1.xではコンパイルキャッシュが使えるかどうかの判定にファイルのナノ秒単位のタイムスタンプを使っているように見える
  • sbt 1.xではコンパイルキャッシュ関連のファイルが作成されたタイムスタンプと、それがリストアされたときのタイムスタンプが完全に一致していないとコンパイルキャッシュが効かない
  • tar --format posix オプションでナノ秒単位のファイルのタイムスタンプが保存できる