MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

SparkでGeoIP2を使うとjava.lang.NoSuchMethodErrorが発生する問題の回避方法

f:id:microad-developers:20180912172353p:plain:w400

マイクロアドのサーバサイドエンジニアの松宮です。今回はプログラミングのTips的な記事になります。

タイトルの通り、「SparkでGeoIP2を使うとjava.lang.NoSuchMethodErrorが発生する問題の回避方法」を説明したいと思っておりまして、というのも、SparkでGeoIP2ライブラリの依存を上手く解決できずに、結構時間を使ってしまったので、犠牲者を増やさないためにもこの場でみなさんに共有したいと思います。

事象と解決策を先に示して、詳細な原因は後述しますので、解決策だけ早く知りたい方は前半まで読んでもらえれば大丈夫です。

前提

  • 言語はScala/Java
  • ビルドツールはsbt or Maven
    • 実行可能なjarを生成するために本記事ではsbt-assemblyを使っている
  • Sparkのバージョンは最新の2.3.2 (2018年8月現在)
  • GeoIP2ライブラリのバージョンは最新の2.12.0 (2018年8月現在)

事象

Spark上でMaxMindのGeoIP2ライブラリを使おうとすると、java.lang.NoSuchMethodErrorが発生してしまう問題があります。
※GeoIP2ライブラリが何かというと、IPアドレスを入れると、地理情報が返ってくるライブラリのことです。
例えば下記の「IPアドレスを入力に国を判定して出力するストリーム処理」を動かそうとすると実行時に例外が発生します。

import java.net.InetAddress

import com.maxmind.geoip2.DatabaseReader
import org.apache.spark.SparkConf
import org.apache.spark.storage.StorageLevel
import org.apache.spark.streaming.{Seconds, StreamingContext}

object Main {

  def main(args: Array[String]): Unit = {

    val sparkConf = new SparkConf()
    val ssc = new StreamingContext(sparkConf, Seconds(10))
    val lines = ssc.socketTextStream("localhost", 9999, StorageLevel.MEMORY_AND_DISK_SER)

    val countries = lines.map { ip =>
      val reader = new DatabaseReader.Builder(
        getClass.getClassLoader.getResourceAsStream("GeoIP2-City.mmdb")
      ).build()

      reader.city(InetAddress.getByName(ip)).getCountry.getName
    }
    countries.print()

    ssc.start()
    ssc.awaitTermination()
  }
}

これをビルドして実行すると下記のエラーが発生します。

$ sbt assembly
$ spark-submit --master local[*] --class Main <ビルドしたjar>
...
Exception in thread "main" java.lang.NoSuchMethodError: com.fasterxml.jackson.databind.node.ArrayNode.<init>(Lcom/fasterxml/jackson/databind/node/JsonNodeFactory;Ljava/util/List;)V
        at com.maxmind.db.Decoder.decodeArray(Decoder.java:272)
        at com.maxmind.db.Decoder.decodeByType(Decoder.java:156)
        at com.maxmind.db.Decoder.decode(Decoder.java:147)
        at com.maxmind.db.Decoder.decodeMap(Decoder.java:281)
        at com.maxmind.db.Decoder.decodeByType(Decoder.java:154)
        at com.maxmind.db.Decoder.decode(Decoder.java:147)
        at com.maxmind.db.Decoder.decode(Decoder.java:87)
        at com.maxmind.db.Reader.<init>(Reader.java:132)
        at com.maxmind.db.Reader.<init>(Reader.java:89)
        at com.maxmind.geoip2.DatabaseReader.<init>(DatabaseReader.java:64)
        at com.maxmind.geoip2.DatabaseReader.<init>(DatabaseReader.java:54)
        at com.maxmind.geoip2.DatabaseReader$Builder.build(DatabaseReader.java:160)
...

GeoIP2のDecoderクラスがArrayNodeクラスを生成しようとして、java.lang.NoSuchMethodError(メソッドがないよ)とのことです。

解決策

なぜNoSuchMethodErrorが発生するかは後述するとして、解決するにはbuild.sbtに下記を記述して、jarを生成(sbt assemblyする)するだけです。

assemblyShadeRules in assembly := Seq(
  ShadeRule.rename("com.fasterxml.jackson.core.**"       -> "shadedjackson.core.@1").inAll,
  ShadeRule.rename("com.fasterxml.jackson.annotation.**" -> "shadedjackson.annotation.@1").inAll,
  ShadeRule.rename("com.fasterxml.jackson.databind.**"   -> "shadedjackson.databind.@1").inAll
)

ちなみに、Mavenの場合はpom.xmlに下記を追加します

<relocations>
  <relocation>
      <pattern>com.fasterxml.jackson.core</pattern>
      <shadedPattern>shadedjackson.core</shadedPattern>
      <pattern>com.fasterxml.jackson.annotation</pattern>
      <shadedPattern>shadedjackson.annotation</shadedPattern>
      <pattern>com.fasterxml.jackson.databind</pattern>
      <shadedPattern>shadedjackson.databind</shadedPattern>
  </relocation>
</relocations>

なぜ発生するのか

SparkもGeoIP2のどちらもjacksonに依存しており、両者で使っているバージョンが異なるために発生します。Sparkが依存するjackson2.6系で、GeoIP2が依存するのは2.7以降です。

そして、GeoIP2ではjackson2.7から追加されたインタフェースを使っているコードがあるため、最低でも2.7以降のバージョンのjacksonを利用する必要があります。具体的にはcom.fasterxml.jackson.databind.node.ArrayNodeの下記のコンストラクタをGeoIP2では利用しようとします。

/**
 * @since 2.7
 */
public ArrayNode(JsonNodeFactory nf, List<JsonNode> children) {
    super(nf);
    _children = children;
}

そのため、2.7以降を使う必要があるのですが、Sparkは起動時にSpark自身が提供する依存をクラスパスに追加するようになっているので、アプリ側で如何に最新のjacksonを使おうが関係ありません。

ちなみに、Sparkが提供するjarは/.../apache-spark/2.3.2/libexec/jars/以下にあります。なのでSparkが提供するjacksonを削除するというのも1つの手ですが、アプリ側でjacksonを使わなくなったら起動しなくなったりすると思うのであまり良い手段ではありません。

そこで、shadeという方法を使ってパッケージ名を変更することで解決します。

shadeによる解決方法

Sparkが提供するjacksonを使わないようにするために、shadeという設定をします。shadeとは依存の名前を変更する機能で、その依存を使う全てのクラスのパッケージ名を変更します。すると、アプリのjar内では名前が変更されたjacksonを使うようになるので、Sparkが提供するjacksonはアプリから参照されなくなります。

では、実際に確認してみましょう。生成したjarから、今回問題になったArrayNode.classDecoder.classを取り出して中身を見ます。

$ jar xf <ビルドしたjar> shadedjackson/databind/node/ArrayNode.class
$ jar xf <ビルドしたjar> com/maxmind/db/Decoder.class
package shadedjackson.databind.node;
...
public class ArrayNode extends ContainerNode<ArrayNode> {
    private final List<JsonNode> _children;

    public ArrayNode(JsonNodeFactory nf) {
        super(nf);
        this._children = new ArrayList();
    }

    public ArrayNode(JsonNodeFactory nf, int capacity) {
        super(nf);
        this._children = new ArrayList(capacity);
    }

    public ArrayNode(JsonNodeFactory nf, List<JsonNode> children) {
        super(nf);
        this._children = children;
    }
...
}
package com.maxmind.db;

...
import shadedjackson.databind.JsonNode;
import shadedjackson.databind.ObjectMapper;
import shadedjackson.databind.node.ArrayNode;
import shadedjackson.databind.node.BigIntegerNode;
import shadedjackson.databind.node.BinaryNode;
import shadedjackson.databind.node.BooleanNode;
import shadedjackson.databind.node.DoubleNode;
import shadedjackson.databind.node.FloatNode;
import shadedjackson.databind.node.IntNode;
import shadedjackson.databind.node.LongNode;
import shadedjackson.databind.node.ObjectNode;
import shadedjackson.databind.node.TextNode;

final class Decoder {
...
    private JsonNode decodeArray(int size) throws IOException {
        List<JsonNode> array = new ArrayList(size);

        for(int i = 0; i < size; ++i) {
            JsonNode r = this.decode();
            array.add(r);
        }

        return new ArrayNode(OBJECT_MAPPER.getNodeFactory(), Collections.unmodifiableList(array));
    }
...
}

すると、ちゃんとパッケージ名が切り替わっているのが確認できます。これによって、Spark自身が提供する古いjacksonを使わず、jarの中に入っているjackson(今回だとshadedjackson)を使えるようになります。

ということで、無事SparkでGeoIP2ライブラリを使うことができました!(ºωº)/

参考資料