Kafka Streamを使ったストリーム処理の概要と運用時の考慮点

最近、仕事で分散ストリーム処理に手を出していて、その基盤としてApache KafkaとKafka Streamsを使うことにしたので、動作概要とストリーム処理のイメージについてまとめておく。

kafkaそのものについては今更説明の必要は無いだろうと思う。

Kafka Streamsはkafkaを基盤にして分散ストリーム処理を簡単に書くためのDSLライブラリ。

https://kafka.apache.org/documentation/streams/

延々流れてくるデータを変換して別のtopicに流したり、時間のウインドウを区切ってカウントした結果を流したり、みたいなのがサクっと書ける。 Apache Flinkなんかと似た様なことができる。

Kafka Streamsが良いのは以下の点。

  • ただのConsumer/Producerのラッパーなのでfat-jarファイル一つで簡単に動かせる
  • 可用性やデータの分散をkafkaのconsumer groupとtopicの仕組みに載せているので別途ミドルウェアを必要としない

なので、kafkaが動作している環境ならJavaCLIツールを書く様な感じで分散ストリーム処理が実装できる。

簡単な例だとこんな感じになる。

public class EventCountStream {
    static Topology buildStream(Properties props) {
                StreamsBuilder builder = new StreamsBuilder();
                builder.stream("input-event-topic", Consumed.with(Serdes.String(), Serdes.Integer());
                builder.groupByKey().count();
                builder.to("counted");
                return builder.build(props);
    }

    public static void main(String[] args) {
        String bootstrapServers = envs.getOrDefault("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092");

        Properties props = new Properties();
        props.put(StreamsConfig.APPLICATION_ID_CONFIG, "repro-event-count-stream");
        props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
        props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
        props.put(StreamsConfig.NUM_STANDBY_REPLICAS_CONFIG, "1");
        props.put(StreamsConfig.REPLICATION_FACTOR_CONFIG, "3");
        props.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, "10000");

        props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "50000");
        props.put(StreamsConfig.mainConsumerPrefix(ConsumerConfig.MAX_POLL_RECORDS_CONFIG), "500");
        props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, "2500000");

        Topology topology = buildStream(props);

        System.out.println(topology.describe());

        final KafkaStreams streams = new KafkaStreams(topology, props);
        final CountDownLatch latch = new CountDownLatch(1);

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("graceful stop");
            streams.close(Duration.ofSeconds(60));
            System.out.println("stream stopped");
            latch.countDown();
        }, "shutdown"));

        try {
            streams.start();
            latch.await();
        }
        catch (Throwable e) {
            e.printStackTrace();
            System.exit(1);
        }

        System.exit(0);
    }
}

Kafka Streamsのコンセプトモデルと実行モデル

ここでは、今、現時点で自分が理解しているKafka Streamsの動作について解説する。

大まかな概念としては、https://kafka.apache.org/23/documentation/streams/core-conceptsに書かれているが、StreamBuilderクラスに生えているDSLメソッドを使ってノードを作成し、それをグラフ上に繋げていくことでストリーム処理のパイプラインを構築することになる。

例えば、あるトピックからデータを取得するSourceノード、値を変換するProcessorノード、グルーピングするProcessorノード、aggregationを行うProcessorノード、別のトピックに書き出すSinkノード等を繋げて処理を構築することになる。

実際の実行モデルについてはhttps://kafka.apache.org/23/documentation/streams/architectureに記述がある。

Kafkaはトピックのパーティション毎にどのConsumerが処理を担当するかを固定で割り当てて、処理対象が重複するのを防ぐ機能があり、それをConsumer Groupと呼ぶ。 slideshareで見つけた資料から引用させてもらうとこんな感じ。

Kafka Streamsの基本的な実行モデルでは、Consumer Groupの各パーティション割り当て毎にタスクという実行単位が作られる。タスクは一連のストリーム処理を実行する。 (実際には全てのパーティションからデータを取得するソースノードや、処理ノードのグラフ構造が複数のトポロジに分割されるケースもあり、1:1で対応している訳ではない) また、Kafka Streamsは一ノードにつき任意のスレッド数で実行できて、そのスレッドをStream Threadと呼ぶ。 各タスクはStream Threadの中で起動し、一つのスレッドの中に複数のタスクを持つことができる。

f:id:joker1007:20190828183036p:plain

つまり、ソーストピックのパーティションの数だけ実行タスクが生成されるが、それぞれのタスクの実行主体はKafka Streamsワーカーのノード数 x スレッド数に分散して割り当てられる。 その割り当てをKafkaのConsumer Groupを使って管理している。 注意点として、一つのストリームスレッドで複数のタスク(つまり複数のパーティション)を処理できるが、スレッドが共有だと一つのパーティションのデータを処理している間は他のパーティションのデータ処理は待たされることになる。

コードレベルでの実行イメージ

Kafka Streamsはラムダで一連の処理が繋がっているかの様にコードを書くことができるが、実際に一つ一つのラムダ式として記述された内容は常に同一のスレッドや同一のノードで実行される訳ではない。 例えば、レコードのキーを変更する操作を行うとrepartitionが発生し、一旦kafkaの中間トピックを介して別タスクに処理が移ってから続きの処理が実行されることになる。その別タスクは完全に別ノードで実行されるかもしれないし、別のスレッドで実行されるかもしれない。 なので、各ラムダに書いたものは完全に独立した処理としてコードを書かなければならない。スコープ外から持ち込めるのはstaticで各ノードで共通であることが分かっているものぐらいしか使ってはいけない。

また、ストリーム処理を実装する上では、あるパーティションのロカリティを考慮した実装することが重要になる。 kafkaはデフォルトのパーティショナーにmurmur2 hashを利用しており、同一のキー, 同一のパーティション数であれば同じパーティション番号にデータが配置される。 そして、同じトポロジ内の同一パーティション番号は同じStream Threadに処理が割り当てられる。 それによって、あるキーでグルーピングした処理は同一のStream Threadで処理されることが分かるので、集計処理を行うことが可能になっている。 こういう仕組みになっているため、Stream DSLより低レイヤのAPIを利用してマニュアルで状態管理をしつつ集計する時には、その処理がどういうキーでパーティションされていてどういう単位でState Store(ワーカー内部に持っている状態管理用のKey Value Store)を持っているかを考えながら実装する必要がある。

KStreamとKTable

Stream DSLで構築したパイプラインは大きく分けてKStreamとKTableという二種類のモデルで表現される。 公式リファレンスによると、KStreamはレコードストリームの抽象、KTableは変更ログストリームの抽象と説明されている。もうちょっと詳しく説明するとそれぞれ以下の様な感じで動く。

  • KStreamは一つ一つのレコードがデータとして完結しており、全てのレコード一つ一つをそのままストリームデータとして扱う。
  • KTableは一つのレコードは変更履歴情報であり、ストリーム処理で流れるデータは変更履歴を集約した最新の状態を表すレコードになる。 テーブル上に存在する更新可能なレコードの様に扱うことができるのでKTableというのだと思う。

Kafkaはストリームバッファなので、KTableの様なデータを扱うためにはストリームの開始時点から現在までのデータを全て読み出して再生しないと最新の状態を復元できない。 しかし、毎回そんなことをやってられないので、Kafka Streamsでは起動時にレストア処理が走って、ソーストピックからデータを取得して各ワーカーのローカルにあるState Storeという領域に最新の情報を保存する形になっている。 State StoreはデフォルトでPersistent State StoreとIn Memory State Storeが存在する。Persistent State StoreはRocksDBという埋め込み型のデータベースをKVSとして利用してデータを保存している。

Kafka Streamsではグルーピングして集計するという様な状態を保持したストリーム処理はKTableとして表現される。そういった処理には状態復元のデータソースになる中間トピックと各ワーカーにState Storeが必要になる。 当然両方ともストレージを消費するので、KTableとして扱うデータが多ければ多い程各ワーカーのローカルストレージ領域が必要になる。

KTableデータの保持期間

ストリーム処理でデータを集計する際には、タイムウインドウを区切って集計するのが一般的。 もし、タイムウインドウを1時間で集計しているとして、そんなデータを何週間も保持しなくて良いことの方が多いだろう。 そういう処理で無駄なデータを保持し続けない様にするために、集計処理毎にデータ保持期間を設定できる様になっている。

運用時の考慮点

Kafka Streamsは状態を保持する必要がある機能はKafka本体にデータを保持する仕組みになっているので、基本的にワーカーを増やすだけで処理をスケールアウトさせることができる。 しかし、前述した様なState Storeのレストア処理やConsumer Groupへの参加申請に伴うrebalance処理にそれなりに時間を要する。

そのため、頻繁にワーカーが出入りする様なオートスケールを使ったコスト節約方法とはかなり相性が悪い。 ちなみにrebalanceにかかる時間に対する改善策は開発コミュニティでも議論されていて、今後は高速化されるかもしれない。

KIP-345: Introduce static membership protocol to reduce consumer rebalances - Apache Kafka - Apache Software Foundation

そういう訳なので、レストアスループットを向上させるためのチューニングや頻繁にノード数を増減させなくていい様なスケーリングが必要になる。 また、一定以上接続が無いとConsumer Groupから離脱してしまうため、いくつか安定稼動のために重要なconfigがあったりする。

  • max.poll.interval.ms この設定値を越えてpollingが行われないとConsumer Groupから離脱する。通信状況や負荷などによって処理が詰まった場合、復活したり離脱したりを繰り返して延々処理が進まなくなる可能性があるので、ある程度余裕を持った値に設定しておいた方が良さそう。
  • session.timeout.ms この設定値より長い期間heartbeatがbrokerに届かなかった場合、Consumer Groupから離脱する。これも上記と同様の理由である程度余裕を持たせた方が良いだろう。
  • max.poll.records 一回のpollingで取得するレコード数。一回pollingしてからそのレコードバッチに対して処理が行われるため、スループット効率を狙い過ぎると、max.poll.interval.msを越えてしまってConsumer Groupから変に離脱してしまう可能性がある。
  • max.partition.fetch.bytes あるパーティションから一度に取得できるデータ量。レストア処理のスループットに影響する。
  • receive.buffer.bytes TCPのRCVBUFの値を設定する。デフォルトが65535と現在のネットワーク構成からすると少ない値になっているので、設定しなおした方が良い。

また、当たり前の話だが、そもそもデータ量を減らすのは大事なので、バイナリシリアライズフォーマットを採用したりしてトラフィックを削減するのも重要になる。

終わりに

まだ本格的に運用しているという段階ではないので、他にも考慮点やチューニングポイントが出てくるだろうと思う。 特にConsumer Groupのrebalanceとレストア処理のリードタイムについては多少不安が残っている。

そうは言っても、必要な機能は十分に揃っている印象で、追加のミドルウェア無しで実用的な分散ストリーム処理が書けるのは非常にありがたい。 もうしばらく調査と実験を続行して本格運用に乗せていく予定。

RubyKaigi 2019で登壇してきました #rubykaigi

4/18 - 4/20で開催されていたRubyKaigi 2019に参加し登壇してきました。

今回の登壇内容は「Pragmatic Monadic Programming in Ruby」というタイトルです。 スライドは以下。

speakerdeck.com

実装はこちらです。READMEの整備が全然出来てないのと、APIがまだ変わる可能性があるので、gemとして正式リリースするのはまだやってないのですが、近々ちゃんとリリースします。

github.com

Ruby黒魔術師として、自分が良いなと思っているデザインパターンであるモナドを、Rubyの世界で理想的に表現するならどうなるかということを考えて発表テーマとしました。 今回、ASTがRubyレベルから簡単に触れる様になったので、それを活用してみたかったというのもあります。

内容としてはAST変換を駆使して、あるパターンの変数代入構文を乗っ取り、flat_mapのネストに置き換えた状態でProcとして再評価することで、モナド記法を実現しました。 厳密にモナド則を守る機構は用意してないですが、Scalaでも確かflatMapがあればfor式は使えたはずなので、その辺りは割り切りかなと思っています。 大体、Scalaの発想をパクった感じですね。

今回の発表の後で、ASTやその周辺技術を応用した面白いライブラリを作ってみたくなったという話も聞いたので、何か伝えられるものはあったのかなと感じています。

反省点

今回、話をしたいことを色々詰め込み過ぎた感じがあり、実装の紹介が中途半端になってしまったなと自分では思っています。

アフターパーティで松田さんと話をしましたが、RubyKaigiの登壇者は前提知識のレベルとかある程度すっ飛ばして、もう話しをしたいコアの所ばかり喋っているから面白いという話が出ました。 私もその通りだなと思いますし、予習資料だけ提供して色々振り切ってしまった方が盛り上がる発表になったかもなあと反省しました。 去年はコンビ発表で、ソロでRubyKaigiに出るのは初登壇だったので、ちょっと気負い過ぎたのかもしれない。

来年は今年みたいに4パラトラックにするのは、どうやっても無理だという話も耳にしたので、CFPの競争率は更に上がることが予想されます。そんな中で来年も上手く刺さりそうなproposalが出せるかは全く分かりませんが、今回の反省を活かせる様に何とか頑張っていきたいと思います。

体調不良について

今回、開催日前日に急に38度を越える発熱があり、関係者や友人各位に多大な心配をかけてしまい、申し訳ありませんでした。

前日まで全く異常が無く前触れも何も無かったので、正直大分焦りました。

結果的にこじらせることはなく、何とか初日の登壇もこなせましたし、2日目には平熱まで熱が下がり無事RubyKaigiを楽しむことができて、本当に良かったなと思っています。特にインフルじゃなくて本当に良かった……。

(前日に美味しいものを食べたり、ESMスポンサードのクルーズに行く予定があったのに、全てキャンセルすることになったのは、めちゃめちゃ辛かったですが……)

今回得た教訓は、やはりロキソニンとユンケルは効くということです。 後、無理のし過ぎは良くない。

しかし、俺の肉体に何があったんだろうか……。ストレスとか緊張とかかなあ……。

セッションについて

特に良いなあと思ったのは、初日のFibers Are the Right Solution - RubyKaigi 2019かな。 リアルなWebアプリケーションを高速化しようとしたら、非同期I/Oは重要になるしマイクロサービス化してくると影響も大きくなる。 現実的な高速化施策として、納得感が強かった。

後は、Pattern matching - New feature in Ruby 2.7 - RubyKaigi 2019ですね。 マジで待望の機能です。文法はまだexperimentalですが、既にtrunkで使えるってことなので、早速試して手触りを確かめているところです。 挙動をアドホックに切り替えたい時はRefinementsがちゃんと使えるということなので、これでRefinementsのユースケースも増えるし最高では!という気持ちです。

今回、LTのスピーカー勢が非常にエクストリームな人ばかりで、大体趣味の話がおかしくて面白かった。 「○○したくなると思うんですけどー」みたいな語りで言ってる内容が大体ブッ飛んでる。
(他人のこと言えないだろ、というツッコミもあるかもしれませんが……)

後、内容に割と関連性のあるトークが多くて、ストーリー性が垣間見えて良かった。 令和繋がりだったり、Auto Differential繋がりだったり、TracePoint繋がりだったり。

スポンサー業

今回、私の所属企業であるReproでRubyKaraokeスポンサーをやらせてもらいました。ランク的にはプラチナスポンサー相当になります。

f:id:joker1007:20190422201939j:plain

RubyKaraokeは非常に楽しいし、カラオケは自分の大事な趣味の一つなので、スポンサードできて非常に光栄です。 100を越える参加者が来てくれて、そして概ね楽しんでいただけた様だし、スタッフとして手伝ってくれた弊社メンバーも楽しめた様なので、良いパーティに出来たかなと思っています。

実は私はRubyKaigiのスポンサー企業の一員としてRubyKaigiに参加するのはこれが初めてだったりします。 それがRubyKaraokeスポンサーだったのは運命みたいなもんですねw

しかし、RubyKaraokeは例年非常に深夜まで続くので、3日目の午前のセッションに間に合わないことが多いんで、それが良し悪しだなあと……。
(いや自分の理性の問題もあるんですが、今回ばっかりは自分ではどうにもできない……。)

f:id:joker1007:20190422202058j:plain

Juanitoさんにいきなりマイクを渡されて、残酷な天使のテーゼを歌ったら、めっちゃ褒められたw

飲食

体調の問題もあり、食事方面では不完全燃焼感が強い。写真も余りストックが無いので割愛。 ホテルの近くにあった、長浜ナンバーワンが割と美味しい長浜ラーメンで良かったなという感じです。

魚はとても美味しくて、やっぱ海側ってのは強いなと実感できる美味さでした。 ベストシーズンでは無かったみたいですが、ゴマ鯖は美味しかった。

イカが食えなかったのが、本当に残念でならない。

まとめ

今年のRubyKaigiも最高に楽しかった。それしか言うことは無いですね。 今年も開催してくれてありがとうございました!スタッフの皆様、スピーカー、スポンサーブースの皆様お疲れ様でした。 来年は「まつもと」でお会いしましょう! See you next RubyKaigi or other Ruby Conferences.

RubyConf 2018に登壇するためにL.Aまで行ってきた

RubyConf 2018のRubyKaigi関連トラックに採択されたので、登壇するためにロサンゼルスまで行ってきました。 RubyKaigiトラックということで、内容はRubyKaigiの再演でした。

学生の時にめっちゃ貧乏だったのもあって、今迄海外に行く機会が全く無かったので、実はこれが初の海外旅行であり、当然ながら英語で発表するのも初めてだったので大分緊張しましたが、本番ではそれなりにウケたという手応えがあったので、今迄の登壇経験が上手いこと活きたんだろうと思っています。(後で松田さんとそらはーさんに、めっちゃ発音突っ込まれたけど……)

共同登壇者であるtagomorisさんには、トーク以外でも色々と滞在中にお世話になったので改めて感謝を!ありがとうございます!

f:id:joker1007:20181119163501j:plain f:id:joker1007:20181119163515j:plain f:id:joker1007:20181119163525j:plain

録画はあるらしいので、もうちょっとしたらyoutubeとかに上がるだろうと思います。

発表の仕方については、流石に普段英語全然喋ってないので、テンパって飛ぶとヤバイだろうと事前に原稿をある程度書いて発表者ノートにまとめておくというスタイルでやりました。プレゼンの原稿ちゃんと書いたのは、これが初めて。 一応、そのまんま読むのは止めようと、イメトレして何パターンか作っておいたので、読みながら適宜合わせるぐらいの事は一応できたと思う。

質疑応答にめっちゃ身構えてたんですが、結果的にはRyan Davisに"Good Job"って言われて嬉しかったという、テンパらずに済む形になって助かりました。 登壇後にも何人かにTerrible Workとか、Amazingとか言われたので、通じなかったってことは無さそうで安心しました。 初の海外登壇としては、成功だったと言って良いでしょう!

初のUSAについて

全体的な印象としては、やっぱ色々雑な国だなという感じですw (というか日本がキッチリし過ぎというか)

トイレで流すボタンが反応しなかったり、シャワーの出し方が分からなかったり、水量と水温を一つのレバーで操作しようとする謎UIだったり、自販機で売ってるものの値段が全く分からなくて1ドル札しか使えなかったり売り切れ状態が分からなかったりする。

一方で、フリーWiFiが本当にあちこちにある。これは素晴らしい。

後、Uber/Lyftが真の力を発揮できて、どこに行くにもサクっとタクシーに乗れて決済も簡単に済むというのはめっちゃ便利だった。

そして、とりあえず何でもでかい。

人もでかいし、歩道も通りもでかい。 交差点間の距離がめっちゃ長い上に単位がフィートなんで全然感覚分からなくて、コンビニが2ブロック向こうかーと思って歩いたら15分ぐらいかかったりする。日本じゃ長くて3分だぞ……。

食べ物

肉とビールに関しては最高だった。特にロサンゼルスはローカルブルワリーが一杯あるみたいで、ビール好きにとっては最高の街だと思います。

しかし、何でもかんでも油入ってる感じだし、マッシュポテトやアスパラがもう完全にバターの味だったw 後、海産物系は全体的に選択に失敗した感じで、大体駄目でした。

昼間っから自家醸造したクラフトビール飲めるのは最高!しかも安い!

f:id:joker1007:20181119165208j:plain

ステーキは美味いし、物価の差を考えるとかなり安い!

f:id:joker1007:20181119165220j:plain

美味いと評判だったThe CounterのBTOバーガー。実際めっちゃ美味かった。

f:id:joker1007:20181119165516j:plain

フィッシュマーケットみたいな所でシュリンプカクテル頼んだけど、クソ辛かった……。

f:id:joker1007:20181119165751j:plain

今回のRubyConfの会場近くにKarl Straussという最高に良いブルワリーがあって、カンファレンス中は毎日そこに通っていたし、大体面子がAsakusa.rbだったので、最早Karl StraussはAsakusa.rbのLA支部と言って差し支えないレベルだと思う。本当に最高だったのでLAに行く機会がある人は、是非行くべきだと思う。

f:id:joker1007:20181119170210j:plain

観光

カンファレンス後に1日空けておいたので、その日にロングビーチまで観光に行きました。 一番の目的は、アイオワ級戦艦のネームシップであるアイオワを観に行くことです。中がミュージアムになっていて色々見て回れる様になっている。 確か映画「バトルシップ」に出てきた戦艦はアイオワ級ミズーリで兄弟艦にあたるはずですが、実際見るとまんま映画で見た奴だ!ってなりました。

f:id:joker1007:20181119170655j:plain f:id:joker1007:20181119170717j:plain

後は、役目を終えてホテルになってる豪華客船クイーンメリー号と近くにある潜水艦スコーピオンを見に行きました。

なんというか、アイオワもそうだったがでか過ぎる!

f:id:joker1007:20181119170900j:plain

後はこいつに乗りました!電動で動くキックボードみたいなやつ。

f:id:joker1007:20181119171106j:plain

実際乗ってみたら、めっちゃ楽しかったけど、思いの外加速が凄くてアクセス全開にしたら30km/hぐらい出るし、車輪がすげー小さいので段差とかあるとめっちゃ危ない。 こんなもん国土がクソ広くて道が無駄に広い国じゃないと危なくて乗れたもんじゃないし、絶対日本の公道で乗るのは無理だと思う。 というかアメリカでも十分に危険だと思うので、その内規制されるんじゃなかろうか……。今の内に乗れて良かったと思う。

まとめ

というわけで、RubyConfで登壇しにロサンゼルスまで行き、ついでに観光してきた訳ですが、全体としてはとても良い経験になりました。 アメリカを一部でも経験できたし、美味いビールと美味い肉は食えたし、英語で登壇するという経験ができたので、RubyKaigiでも英語で話せるかもなあという自信が付きました。(やるかは分からんけど……。そもそもCFP通るか分からんけど……) しかし、リスニングは結構苦労するというか、周りがごちゃごちゃしてると全然分からんし、早口で喋られると何言ってるか分からなくなる。ギリギリコミュニケーションは取れなくもないという感じでしたね。 もうちょい慣らさんとなあ……。

まあ色々ありましたが、今回こういう機会を得ることができたのも、トークに採択してくれた松田さんとRubyConfのおかげです。本当にありがとうございました!

本番環境でcassandraの運用を開始してみた

職場でcassandraの運用を開始したので、選定理由とか運用してみて得た知見や所感等を書いてみようと思う。 (まだ十分に知見を得たとは良い難いので、間違った印象を得ていたり、より良いオペレーションに気付いていない可能性があります。)

cassandraの採用理由

そもそもの目的はホットデータの書き込み先としての利用です。要件としては以下の様なものがあります。

  • エンドユーザー端末の数に比例するので、書き込みのスケーラビリティが必要
  • 書き込みデータは更新される
  • 一件単位、もしくは数秒単位の短かいサイクルでバッファチャンクを書き込む必要がある
  • prestoで読み込める (最悪connectorは書いてもいいが、できれば避けたい)
  • 一度に数百万件ぐらいまで読み込む可能性があるので、それなりに読み込みもスケールして欲しいし、レンジスキャンにもある程度効率が求められる。

元々、データ加工で潰しが効く様にkafkaの利用も検討してたんですが、データが頻繁に更新される可能性があるので一意のキーでデータが特定できてupsertが可能であり、書き込みレイテンシを小さく保つことができそうということでcassandraに倒しました。

その他、Apache Kuduとかも調査はしましたが、ちょっと新し過ぎるのとprestoとの接続性に不安があったので、見送りました。

cassandraの構築方法と現在のクラスタ規模

ディスクとネットワークにかなりのI/Oがかかるので、コンテナは使わずにpackerでAMIを生成し、EC2で普通に立ててます。

cassandraにはseedという概念があり、クラスタの情報を提供するための初期ノードが必要になります。 なので、seedは固定でローカルIPを降って3台立ててあり、他のノードはAutoscalingで起動できる様にしてあります。 クラスタ全体では10台で運用しています。テーブルは現時点で3種類あり、一番大きいテーブルで50GB程のデータが入ってます。 今後利用範囲を広げていく予定です。

現時点での利用スタイル

利用開始当初は、アプリケーション本体のコードを弄らずに実験するために、fluentdを経由してデータを投入していました。 元々fluentdにデータを流していたので、copyして軽く加工するだけで出力先をコントロールできる様になっています。 で、データを投入するためにfluentdのプラグインを書きました。(いくつか既存のものがあったんですが古かった) ; github.com

cassandraは挿入は一件単位で行うことになるので、fluentdの様にbufferチャンクを溜めて処理するのは余り相性が良くないとは思いますが。 というのもfluentdはバッファチャンク単位でリトライすることになるので、チャンクの中の一行がエラーになった場合にどうリトライするかを考えるのが面倒くさい。 とりあえず無視するか全体リトライするかの二択を選べる様にはしましたが……。

現在は利用スタイルが固まったので、rubyのcassandra-driverを使ってアプリケーションから直接データ投入する様に変更中です。

メトリック収集

会社ではdatadogを利用しており、datadogにcassandraのインテグレーションがあるのでそれを利用しています。 実態はdatatdog-agentがJMXを介してmbeanから値を取得する形になります。

とりあえず以下の値を収集してます。

  • CPU利用率
  • 読み書きのレイテンシ
  • ディスク利用量 (デバイス全体と、テーブル毎のディスク利用量)
  • 書き込み件数
  • sstableの数
  • keycache hit ratio
  • drop message rate
  • pending task count
  • JVM系のメトリック (heap, nonheap, gc等)

加えて、プロセス監視もdatadogでやってます。

cassandraの特性についての理解

アーキテクチャの細かい解説は、datastaxのドキュメントを読むのが分かり易いと思いますが、cassandraは大まかには以下の様に動きます。

書き込み時

  • レコードのパーティションキーからcoodinatorを決定する
  • coordinatorに書き込みをしにいく
  • まずmemtableというオンメモリのテーブルにデータを書き込む
  • 一定時間、一定データ量毎にflushされてsstableと呼ばれるファイルに書き出される
  • sstable内のデータは不変
  • データが肥大化しない様に定期的にコンパクションが実行されてsstableがマージされる
  • 更新なり削除なりが発生した時はtombstoneマーカーが付与されて、コンパクションタイミングで削除される
  • 整合性レベルに応じた応答が返ってきたら、クライアントにレスポンスを返す

読み込み時

  • パーティションキーを元にcoordinatorにデータを要求する
  • memtableを確認し、あれば取得する
  • 行キャッシュを確認し、あれば取得する
  • ブルームフィルタとkey cacheを使ってsstableの読み出し先を特定する
  • それでも見つからない場合は、sstableに含まれるパーティションインデックスからデータの場所を特定し読み込む
  • 整合性レベルに応じた結果が集まったら、レスポンスを返す

書き込みは基本的にメモリに書かれるし追記しか起きないのでスケールさせやすい。現在のうちの利用規模だと一件辺りの書き込みレイテンシは100μsぐらいで完了してます。(ネットワークレイテンシは別)

しかし、読み込みの時はパーティションを特定してクエリするのが必須になるため、クエリのワークロードに合わせてパーティションキーを設計する必要がある。

実際、パーティションキーを指定しない形でクエリすることは基本できない様になっている。 この点で、読み込みに関してはデータ構造がリッチなKVSに近い。

オリジナルのデータが同じでも参照方法に合わせて、テーブルを作る方が良いとされている様です。 その分書き込みの数が増えるが、書き込みはスケールさせやすいので読み込み形式に合わせた構造を優先した方が良いということだと思います。

prestoからの読み込み

パーティションキーを指定しない場合は全パーティションからデータを読むことになる。 パーティションキーを指定すれば必要なデータだけ取ってくれる。うまくパーティションが分散できれば、大量に読み込んでもそれなりのスピードがでる。クエリパターンに合わせたテーブル設計が大事。 パーティションキー設計や必要なカラムの絞り込みが上手くできないと10倍から100倍ぐらい負荷が変わる。

prestoノード30台、cassandraノード10台で300万件のuser_idを取得してもクエリ自体は3秒ぐらいで終わるし、負荷もかなり軽い。 ただ、1行辺りのデータ量が増えるとsstableの読み出しに結構な負荷がかかるので、読み込みが詰まる傾向があった。

また、キーキャッシュやchunk cache (sstableのキャッシュ)にちゃんとデータを載せておいて、実際のディスクI/Oが発生しない様にしておかないと読み込みが安定しない。

セカンダリインデックスについて

ノード毎にインデックスが作成される、パーティションキーを指定しなくてもクエリ可能になるが、大量のパーティションをスキャンすると異様に遅くなるので、少数のパーティションの中でデータを更に絞り込むのには役に立つが、パーティションキーの代わりに使える様な便利なものではない。

余りデータのカーディナリティが低くても高くても駄目で、程々に共通して存在するデータが有効らしい。また更新が頻発するカラムだとindex更新に負荷がかかるので、利用を避けるべきです。 別途パーティションキーの異なるテーブルを作って、用途に合わせてデータを投入していく方が良さそうです。

cassandraの運用

ノード追加

設定ファイルにseed情報とSnitchの設定を書いて起動すると自動でクラスタに所属します。 起動時にbootstrapと呼ばれる処理が走り、自動でデータの再分散が行われます。

同時に複数台追加しようとすると、bootstrapが競合するので、追加は1台づつでないと事故ったり止まったりします。 急激にクラスタを増強できないので、多少の余裕を見てハンドリングする必要がある。

ノードの削除

正常起動しているノードをクラスターから削除する場合は、nodetool decomissionコマンドを削除したいノードで実行する。 データの再分散が行われて完了したらクラスタから情報が削除されます。

ノード故障

ノードが故障した場合は色々考慮する自体が増えます。

書き込みに関してはhinted handoffという機能により、故障ノードへの書き込み要求をcoordinatorが一時的に受け取ることができます。 ノードが復帰したら、hintファイルに蓄積された書き込みデータが同期される。hintが溜まり過ぎるとノードの復帰に時間がかかる。

ノードが復帰不能の場合は、nodetool removenodeコマンドにnode idを渡してノード情報をクラスタから削除する。この時点でhintファイルが溜まっていれば削除されるはずです。 ノードを同数に戻す場合は代わりに新しく1台追加します。

起動時にJVMオプションを渡して、あるIPのノードを置き換えるという指定をすることで、そのノードの役目を引き継ぐことができる様ですが、オペレーションが複雑化するので諦めて、故障した奴をクラスタから削除し、新規にノードを追加しなおして再分散した方が楽に思える。 ただし、これはクラスタが持ってるデータ規模等に左右されると思います。

seedノード故障

seedノードが故障するとめちゃくちゃ面倒なことになる。

seedノードは起動時に特別扱いされており、bootstrapが行われない。そのため、一回通常ノードとして構成してbootsrapを完了してからseedノードとして起動しなおすことになる。 同一IPで新しくノードを構成しなおそうとすると、非常にややこしいので諦めて新しくノードを追加しbootstrapが終わった時点で、seedにしたいノードのIPを各ノードの設定ファイルに追加し、順番にcassandraを再起動するというのが一番マシな手順ではないかと思います。

同じIPで構成しなおしたい場合は、一回seedsリストから除外してcassandraクラスタ全体を再起動した後、ノードを追加してbootstrapが終わった後に、再度seedsリストにIPを追加して、またクラスタ全体を再起動する必要があるようです。

クラスタを構成するためにseedノードにかなり依存しているので、ここが本当にややこしい。同時にseedノードが全部死んだ時にどうやって復帰すればいいのかは良く分かっていない。生き残っているノードを新しいseedとして指定した上で、クラスタ全体を再起動し、新しくノードを復活させて設定ファイルをこまごま調整すれば元に戻ると思いますが。

今は10数台ぐらいの規模なのでどうってことは無いのですが、これが100台規模になってくるとseedzeノード故障は死ぬ程面倒なのでは、と思うので大規模クラスタ運用している人の知見が欲しい所です。

全体的に見て、ノード自体が無事でプロセスが短時間死んだ程度ならどうとでもなりそうですが、ノードごと死ぬとなるとかなりオペレーションが面倒だったり再分散の負荷がかかるので、作業タイミングに注意した方が良さそうです。

全体感

10台ぐらいで構築するなら、それ程苦労はしなかった。(ある程度インフラの自動構成やイメージ作成に知見があるからですが) モニタリングもdatadogと組み合わせれば割と簡単にdashboardが構築できました。監視さえしっかりしていれば、落ち着いて運用できそうです。

問題はやはりハードウェア故障でノードごと突然死する場合で、そういう場合にデータロストしない様にかつ過剰な負荷がかからない様にオペレーションするのはかなり面倒な気配がビンビンします。

書き込みは本当に早いし、スケールするので安心感がある。読み込みはとにかくクエリパターンを事前にちゃんと考えて、それに合わせたパーティション設計をすることが必須です。まずクエリありきでテーブル構造考えないと、読み込みにめっちゃ負荷がかかることになります。パーティションキーとクエリパターンを中心にしたデータ設計は慣れるまで苦労しそうです。

マテビューが入ったことで、クエリパターンに合わせた柔軟なテーブル構成が実現できそうな気配がありますが、実験してみた所書き込みプロセスが不安定になってOOMで落ちたりしたので、もうちょい知見を積むなりリソース調整をマスターする必要がある。後、現時点ではprestoから読み込む対象として利用できないのが、うちの環境だと辛い点でした。

とりあえず、ホットデータを利用しやすくしつつスケーラビリティを確保する、という当初の目的は達成できそうなので、しばらく運用を継続して様子を見ていこうと思います。

Asakusa.rbとは私にとってどんな場所なのか

Asakusaの方面から来ました

今回、RubyKaigiに来た人は何度かこの画像を目にしたかもしれません。

f:id:joker1007:20180606213950j:plain

昔からAsakusa.rbに良く参加している人が発表資料を作る時に、良く使われている画像です。 いかにもシンボリックでアングルが良い写真なので、これが定番として使われています。私とモリスさんも今回のRubyKaigiで使わせてもらいました。

最近のAsakusa.rbに良く参加していて、今回のRubyKaigiに登壇していたメンバーは私が覚えている限りで以下の通り。(id表記 敬称略)

  • @tagomoris & me
  • @hsbt
  • @koic
  • @spikeolaf
  • @youchan
  • @aycabta
  • LT Speakers
    • @tad
    • @284km
    • @youchan

全員の資料を見てないので、全ての発表でこの画像が出てたかは分からないですが、めっちゃ多いですね。1日二人以上のペース。(抜けてたらマジでごめんなさい)

(2018/6/7 1:26 追記) 正確にはAsakusa.rbは通常のmeetupに一度でも参加したことがある人がメンバーであり、その定義から行くとAsakusa.rbと関係が無いトークの方が少ないレベルですね。また、参加したことがあるメンバーの居住地がAsakusaと呼ばれるというローカルルールがあります。そのため、世界各国にAsakusaがあるのですw (浅草のAsakusaに住んでいる俺)

そんなこんなで、Asakusa.rbというコミュニティの存在感がかなり示された結果、懇親会で何度も質問を受けることになりました。「Asakusa.rbって普段どういうことしてるんですか?」と。 後、Asakusa.rbがヤバイハッカー集団っぽくて怖いという話も良く聞きますね。

直接質問くれた人には色々と自分が思っていることを話しましたが、なんか需要ありそうだしせっかくのタイミングなので私にとってのAsakusa.rbをブログに書いておくことにしました。

Asakusa.rbはmeetup

Asakusa.rbは毎週火曜日に、大体神田から上野辺りの範囲で会議室やコミュニティスペースを借りて開かれているmeetupです。 ググるesaの公開ページが出てくるので、そこにメンバー登録して、毎週のmeetupの開催案内に「参加します」とコメントすれば、後は当日にフラっと行くだけです。 仕事が忙しい時は参加できなかったり、体調不良だったりする時もあるので、私も常に毎週参加している訳ではないですが、良く参加するメンバーが居たり、たまに参加するメンバーが居たり、色々です。

基本的には場所と時間のお知らせだけ書いてあり、テーマはありません。 各自、何かやることや話したいこと、相談したいことを適当に持ち込みます。コメントに「今日はgemのメンテやります」とか「資料作成やる予定です」とか書くことが多い。 とは言え、別に何も無くてもブラっと参加して良い会です。毎週時間になったら集まれる場所がある、というのがAsakusa.rbです。

meetupとは

Asakusa.rbはいわゆる勉強会ではありません。勉強する時間を確保するための場所として使っても全然OKだと思いますが、Rubyに関わることを中心にして人と人が会って話をする場所がAsakusa.rbです。

持ち込んだことや最近コード書いてて思ったこと等をベースに、雑談するのがメインと言っても良いでしょう。

初めてAsakusaに来る場合は、相談事があると一番入っていきやすいんじゃないかなと思ってます。皆色々教えてくれますし、これ以上ないぐらいに詳しい人から話を聞くチャンスがあります。 最近、仕事でこういう問題があったからgem作ってみたんだけどどうですかねーとか、こういう時どうやってデバッグしてるんですかーとか、そういう話があると「オッ」ってなった人が教えてくれたり、それは分からんなー(分からんもんは分からんw)ってなったりします。

他の人同士が話している内容でも、自分が興味ありそうなら首を突っ込んで話に混じっていくのが一番楽しめると思います。そういう事を切っ掛けに普段からある疑問なり困り事を質問するのも良さそうです。

とは言え、毎週技術の話ばかりをしている訳ではなく、どこかの美味い食事処の話やゲームの話、ガジェットの話をしてたりする時間も良くあります。

Asakusa.rbと飲み会

最近のAsakusa.rbには、主催の松田さんを筆頭にお酒と美味しいご飯が好きなメンバーが大勢居ます。なので終わった後に飲んで帰りますかーという日もしばしばあります。(毎回ではない) ただ結構meetupの終わりが遅いので、22:30ぐらいから飲みに行ったりすることがあります。 Asakusa.rbは地域コミュニティなので近所に住んでる人が多く、良い店にあたると終電という概念が存在しなくなるので、そちらにも参加する場合はタイムマネジメントが重要です。他の人の終電を気にする人は余り居ないと思われますw

こんな感じでお酒を飲んでワイワイ喋るのが好きな人も多いです。

RubyKaigiとAsakusa.rb

何故、RubyKaigiでこんなにAsakusa.rbメンバーの登壇者が多いのかというと、メンバーに高いレベルでコードと向き合ってる人が多いというのもあると思いますが、大きな要因は毎週松田さんと会う機会があるという点にあると私は思っています。

毎週、松田さんがRubyKaigiのために奔走している姿をチラチラ目にしている中で、CFP出して欲しいという話を毎度聞くことになる訳で、こりゃ是非とも出してKaigiを盛り上げる力になりたいと思う訳です。更にRubyKaigiがどういう思いを持って運営されているか、どういう発表内容が望まれているかという点について、質問したり聞き易い立場に居ます。 なので、技術力という点を抜きにしても、RubyKaigiを楽しんでいく上で恵まれたコミュニティであり、そういう部分が表われているのかなあと思います。

(ただ、RubyKaigiのトーク審査は鬼厳しいので、それだけで通る程甘くはない……。)

Asakusa.rbは怖い?

現時点の私としては全く怖くなくて、ただ単に楽しい集まりなのですが、かつての自分を思い返すと、そういう気持ちも分からなくはないという感じです。 私が初めてAsakusa.rbに行ったのはもう4年から5年前ぐらい(良く覚えてない)だと思いますが、初めての頃はちょっとビビりながら参加していたと思います。 その感覚をより正確に言うなら「畏れ多い」とかでしょうか。端的に表現すると「怖い」ってなるのは分からなくはない。

ただ、実際の所、Rubyが好きな人が集まってワイワイ雑談してるのがメインの会であり、本当に怖いことなんか特に何も無い訳です。バリバリコードレビューしている訳でもなく、全員が常にRubyをハックしている訳でもないです。私も仕事が終わってなくて普通に仕事しながら回りの雑談を聞いている、みたいな時がよくあります。

もし、コードを書いている様に見せかけたり、言い方が悪いかもしれませんが日々の活動がハッタリっぽい人だと話に入っていきづらいかもしれませんが。そういう振舞いは嫌いな人が多いと思います。

何だかんだでコミュニティって、継続的に参加しているメンバーがいて、ある程度話がしやすい関係性というものが既に構築されている場所です。新しいメンバーを歓迎する工夫があったとしても、やっぱり慣れない集団に参加していこうとするのは勇気が必要な行為だと思います。(特に我々はコミュ力に難のある側の人間なので……)

Asakusa.rbに限らず、何度も参加して入れそうな話題に首を突っ込んで、ちょっとづつ慣れていくというのはどこのコミュニティでも必要なことじゃないかと思います。ただ、皆Rubyが好きでコードを書くのが好きでそれに関する話をするのも好きだ、という前提があるのでそこを取っ掛かりに話をしやすいのは、どこのRubyコミュニティでも共通のことだと思います。Asakusa.rbももちろんそうです。

Asakusa.rbでは新しいメンバーが来ると、全員自己紹介して普段何をしているのかとか今日はこういう話がしたくて来た、という時間を取っています。そこを切っ掛けに声をかけにいくのも良いと思います。 しかし、その時間は松田さんが来たタイミングで始まるので、たまにその時間がえらく遅くなる時があるのがハードルの一つかもしれないw

後、飲み会が不定期でしかも時間がかなり遅いので、そこから交流するのもハードルが高いかもしれない……。

Asakusa.rbと海外からのゲスト

松田さんを始め、海外カンファレンスで登壇経験があるメンバーも多いので、たまに海外から日本に遊びに来た人がmeetupに参加してくれることがあります。そういう時は常に英語って訳ではないですが、英語比率が上がりますし、自己紹介も英語になります。(私は余り上手くないです……)

英語が得意でない場合は、初参加のタイミングとしては、ちょっとハードルがあるかもしれません。ただRubyKaigiで多少なりとも海外から来たRubyistと交流をするための練習としては良い経験が得られると思います。私もじわじわ慣れてきたのかなーという感じです。毎年のRubyKaigiでちょっとづつ海外から来た方と話をする時間を増やすことが出来ているのはAsakusa.rbのおかげもあると思います。

ちなみに、Asakusa.rbに海外ゲストが来る時は交流の比率を上げるために、早めの時間から飲み会に移行することが多いですね。

Wellcome Asakusa.rb

色々と書かせていただきましたが、これらは私が感じているAsakusa.rbです。これが正しいという訳でも無いですし、回によってイメージが変わることもあります。何度も参加していればきっと楽しくなるし得られるものもあると私は思っていますが、全ての人にとってそうでないこともあるでしょう。

私としてはとても楽しい会なので、これからも定期的に参加するつもりだし、新しく色々な人が参加してくれて刺激が得られるのも嬉しいことなので、RubyKaigiやこのエントリ等を通じて興味を持った人がいれば、是非気楽にAsakusa.rbに参加してみてください。もちろんAsakusa.rbに限らず、他にも多数存在する地域.rbにも興味があれば是非参加してみてください。それぞれ特色ある集まりです。楽しんで刺激が得られる空間が見つかるかもしれません。

やっぱり仲間が居ると楽しいものだし、ただでさえ最高に楽しいRubyKaigiがもっと楽しくなります。

Wellcome Ruby Community !!

俺史上最高のRubyKaigi 2018

仙台で行われたRubyKaigi 2018に参加してきました。 RubyKaigiは毎年最高のイベントなのですが、今年は総合的に今迄で最も良い体験ができたRubyKaigiでした。

実績解除

今回のRubyKaigiで初めてメインスピーカーとして登壇することができました。 私が初めてRubyKaigiに参加したのが、確か2011年で1stシーズンのFINALとして開催された時でした。 そして、その頃からずっとThe RubyKaigiのメインスピーカーというのは憧れの場であったのですが、7年の歳月を経て辿り着くことができたことを本当に嬉しく思っています。

f:id:joker1007:20180604172956j:plain

今回の発表内容は「Hijacking Ruby Syntax in Ruby」というタイトルで話をさせていただきました。

Asakusa.rbで雑談してたのが切っ掛けで、@tagomoris さんと盛り上がってRubyのダイナミックな機能を活用した様々なハックに手を出す機会があり、その内容を元に @tagomorisさんと二人で発表しました。 TracePointやRefinementsやBinding、そしてRubyに存在する様々なフックをこれでもかという程使った内容になっているので、それらの内容に興味がある人には是非資料を見ていただきたいですね。

会場の客足も良くて椅子が足りなくなるぐらいだったし、TLや後の懇親会での評判を聞く限りではかなり楽しんでもらえた様で、自分としても大成功だったと言って良いでしょう。 特に嬉しかったのが、Refinementsの作者であるShugo Maedaさんがブログで印象に残った発表として紹介してくれたことです。これはマジでめっちゃ嬉しい。

一緒に発表してくれた @tagomoris さんにも超感謝しております。ありがとうございました! & お疲れ様でした! (追記: モリスさんのエントリ)

その他のセッションについて

@maciejmensfeld さんのkarafkaについての発表は、普段の業務的にも気になる発表で、とても良い話でした。発表後の飲み会で質問が出来たのも良かった。 Karafka - Ruby Framework for Event Driven Architecture - RubyKaigi 2018

また、羽角さんのmruby/cについての発表は内容もさることながら、私としては憧れざるを得ないCOOLさがあって最高でした。RubyKaigiという場所で、私も大好きな日本酒である十旭日の話をするのはカッコ良過ぎですね。実際にmruby/cという新しい技術を使って旭日酒造さんとビジネス事例を生み出しているという点でも凄い話だと思います。(29BYはRubyが酒造に活用された十旭日らしいので、是が非でも飲まなければならない) Firmware programming with mruby/c - RubyKaigi 2018

後、金子さんのASTにカラム情報を入れて正確な位置情報を取れる様にするという仕事は可能性が色々広がりそうで、聞いていて楽しかった。 RNode with code positions - RubyKaigi 2018

仙台という場所

魚介メインで大分美味しいものが食べられた。ホヤ、牡蠣、穴子、どれも美味かった。 そしてお酒も美味い。日本の地方都市開催は多少お金はかかるかもしれないが、めっちゃ楽しめるので素晴らしいと思う。

f:id:joker1007:20180531213342j:plain f:id:joker1007:20180531213107j:plain f:id:joker1007:20180602232539j:plain f:id:joker1007:20180602224943j:plain

ジョジョ

仙台、つまりS市 杜王町ジョジョを愛して止まない自分としては、この町はRubyKaigiの場所として最高だった。 浮かれて3日間、ジョジョTシャツを毎日着てたぐらいです。ちなみに上着はSPW財団のジャケットを着てました。

f:id:joker1007:20180603123817j:plain

ちゃんと、吉良吉影の領収書も貰った!

RubyKaigi 2018 IN 仙台

RubyKaigiそのものの経験としても、初スピーカー実績解除できて最高だったし、仙台観光もできて美味しいものも食べれて最高だったし、むかでやで領収書も貰えて最高だった。 つまり、最高だった訳です。(語彙)

ありがとうRubyKaigi。来年も楽しみにしてます。

f:id:joker1007:20180604183426j:plain f:id:joker1007:20180604183415j:plain

Professional Rails on ECS (rails developer meetup 2017)

このエントリはRails developer meetup 2017で発表した内容をブログとして書き出したものです。 サンプルのスニペットが多いので資料の代わりにエントリとして公開します。 スライド用のmarkdownを元に起こしたものなので、少し読み辛いかもしれませんがご容赦ください。

ECSとは

  • Dockerコンテナを稼動するためのクラスタを管理してくれるサービス
  • 使えるリソースを計測し、自動でコンテナの配置先をコントロールしてくれる
  • kubernetesではない。最近、kubernetesが覇権取った感があって割と辛い
  • 今はEC2が割とバックエンドに透けて見えるのだが、Fargateに超期待
  • ECS or EKS :tired_face:

RailsアプリのDockerize

オススメの構成

  • 実際にデプロイするimageは一つにする
    • 例えばstagingやproduction等のデプロイ環境の違いはイメージでは意識しない
  • 手元で開発する様のDockerfileは分ける

FROM ruby:2.4.2

ENV DOCKER 1

# install os package
RUN <package install>

# install yarn package
WORKDIR /yarn
COPY package.json yarn.lock /yarn/
RUN yarn install --prod --pure-lockfile && yarn cache clean

# install gems

WORKDIR /app
COPY Gemfile Gemfile.lock /app/
RUN bundle install -j3 --retry 6 --without test development --no-cache --deployment

# Rails app directory
WORKDIR /app
COPY . /app
RUN ln -sf /yarn/node_modules /app/node_modules && \
  mkdir -p vendor/assets tmp/pids tmp/sockets tmp/sessions && \
  cp config/unicorn.rb.production config/unicorn.rb

ENTRYPOINT [ \
  "prehook", "ruby -v", "--", \
  "prehook", "ruby /app/docker/setup.rb", "--" ]

CMD ["bundle", "exec", "unicorn_rails", "-c", "config/unicorn.rb"]

ARG git_sha1 # どのコミットなのか中から分かる様にする

RUN echo "${git_sha1}" > revision.log
ENV GIT_SHA1 ${git_sha1}

docker build


assets:precompile

RailsのDocker化における鬼門の一つ

  • S3 or CDNを事前に整備しておくこと
  • ビルド時に解決するがビルド自体とは独立させる
  • docker buildした後で、docker runで実行する

  • ビルドサーバーのボリュームをマウントし、assets:precompileのキャッシュを永続化する
  • キャッシュファイルが残っていれば、高速にコンパイルが終わる
  • manifestをRAILS_ENV毎にrenameしてS3に保存しておく この時、コミットのSHA1を名前に含めておく。(build時にargで付与したもの)
docker run --rm \
  -e RAILS_ENV=<RAILS_ENV> -e RAILS_GROUPS=assets \
  -v build_dir/tmp:/app/tmp app_image_tag \
  rake \
    assets:precompile \
    assets:sync \
    assets:manifest_upload

prehook

ENTRYPOINTで強制的に実行する処理で環境毎の差異を吸収する

  • ERBで設定ファイル生成
  • 秘匿値の準備
  • assets manifestの準備
    • さっきRAILS_ENV毎に名前付けてuploadしてたのをDLしてくる

秘匿値の扱い

  • 設定ファイル自体を暗号化してイメージに突っ込む
    • 環境変数で直接突っ込むとECSのconsoleに露出する
    • 値の種類が多いと環境変数管理する場所が必要になる
  • コンテナ起動時に起動環境の権限で複合化できると良い
    • prehookで複合化処理を行う

yaml_vault

https://github.com/joker1007/yaml_vault

  • Rails5で入った、encrypted secrets.ymlの拡張版
  • AWS-KMS, GCP-KMSに対応している
  • KMSを利用すると秘匿値にアクセスできる権限をIAMで管理できる
  • クラスタに所属しているノードのIAM Roleで複合化
  • 設定をファイルに一元化しつつ安全に管理できる
  • Railsの場合、secrets.ymlをメモリ上で複合化して起動できる
    • ファイルに展開後の値が残らない

開発環境

docker-composeとディレクトリマウントで工夫する


version: "2"
services:
  datastore:
    image: busybox
    volumes:
      - mysql-data:/var/lib/mysql
      - vendor_bundle:/app/vendor/bundle
      - bundle:/app/.bundle

  app:
    build:
      context: .
      dockerfile: Dockerfile-dev
    environment:
      MYSQL_USERNAME: root
      MYSQL_PASSWORD: password
      MYSQL_HOST: mysql
    depends_on:
      - mysql
    volumes:
      - .:/app
    volumes_from:
      - datastore
    tmpfs:
      /app/tmp/pids


  mysql:
    image: mysql
    environment:
      MYSQL_ROOT_PASSWORD: password
    ports:
      - '3306:3306'
    volumes_from:
      - datastore

Macの場合

  • ちなみにボリュームマウントが死ぬ程遅いので、何らかの工夫が必要
  • dinghyかdocker-syncで頑張る
    • どっちも辛い
  • Mac捨てるのがオススメ

俺の開発スタイル

  • 開発用Dockerfileでzshや各種コマンドを入れておく
  • docker-compose run --service-ports app zsh
  • シェルスクリプトで自分の.zshrcやpeco等をコピーしdocker exec zsh
  • ファイルの編集だけはホストマシンで行い、後は基本的にコンテナ内で操作する

set -e

container_name=$1

cp_to_container()
{
  if ! docker exec ${container_name} test -e $2; then
    docker cp -L $1 ${container_name}:$2
  fi
}

cp_to_container ~/.zshrc /root/.zshrc
if ! docker exec ${container_name} test -e /usr/bin/peco; then
  docker exec ${container_name} sh -c "curl -L -o /root/peco.tar.gz https://github.com/peco/peco/releases/download/v0.4.5/peco_linux_amd64.tar.gz && tar xf /root/peco.tar.gz -C /root && cp /root/peco_linux_amd64/peco /usr/bin/peco"
fi

docker exec -it ${container_name} sh -c "export TERM=${TERM}; exec zsh"

デプロイの前に

ECSの概念について


TaskDefinition

  • 1つ以上のコンテナ起動定義のセット
    • イメージ、CPUのメモリ使用量、ポート、ボリューム等
    • 物理的に同じノードで動作する
  • docker-composeの設定一式みたいなもの
  • kubernetesでいうPod

Task

  • TaskDefinitionから起動されたコンテナ群
  • 同一のTaskDefinitionから複数起動される

Service

  • Taskをどのクラスタでいくつ起動するかを定義する
  • ECSが自動でその数になるまで、コンテナを立てたり殺したりする
  • コンテナの起動定義はTaskDefinitionを参照する
  • コンテナが起動したノードをALBと自動で紐付ける
  • kubernetesにも似た概念がある

ECSへのデプロイの基本

  1. TaskDefinitionを更新
  2. Serviceを更新
  3. 後はECSに任せる

ecs_deploy

https://github.com/reproio/ecs_deploy

  • capistrano plugin
  • TaskDefinitionとServiceの更新を行う
  • Service更新後デプロイ状況が収束するまで待機する
  • 更新したTaskDefinitionのrevisionを他のタスクで参照できる
  • TaskDefinitionやServiceの定義はRubyのHashで行う
    • Hash化できれば何でも良いので、YAMLでもJSONでも

Why use Capistrano

  • 既存の資産が多数ある
    • slack通知のフックとか
  • デプロイのコマンドが変化しない
  • 設定ファイルの場所や定義も大きく変化しない

個人別ステージング環境

  • アプリサーバーだけで良いなら容易に実現可能
  • RDB等のデータストアを個別に持つなら色々難しい
  • 弊社はアプリサーバーだけ個別にデプロイ可能
  • データストアを弄る場合はフルセットの環境を使い、そこを占有する

インフラの準備

terraform等で以下のものを準備する

  • ALBを一つ用意する
  • 個人別のサブドメインをRoute53に定義
  • ALBのTarget Groupを個人別に定義
  • ALBのホストベースルーティングを定義

その後capistranoにmemberという環境を定義し、各メンバーが自分の名前でtarget_ groupやTaskDefinitionの名前を使ってデプロイ出来る様に諸々を変数化する


terraformの例

module "acm" {
  source = "../acm"
}

resource "aws_alb" "developers" {
  name = "developers"
  internal = false
  subnets = ["your-subnet-id"]
  security_groups = ["your-security-group-id"]
  idle_timeout = 120
}

resource "aws_alb_target_group" "per_developer" {
  count = "${length(var.users)}"

  name = "${element(var.users, count.index)}"
  port = 8080
  protocol = "HTTP"
  vpc_id = "your-vpc-id"
  deregistration_delay = 0

  health_check {
    path                = "/health_check"
    interval            = 45
    matcher             = 200
    unhealthy_threshold = 8
  }
}

resource "aws_alb_listener" "developers" {
  load_balancer_arn = "${aws_alb.developers.arn}"
  port = 443
  protocol = "HTTPS"
  ssl_policy = "ELBSecurityPolicy-2016-08"
  certificate_arn = "your-certificate-arn"

  default_action {
    target_group_arn = "${element(aws_alb_target_group.per_developer.*.arn, 0)}"
    type = "forward"
  }
}

resource "aws_alb_listener_rule" "per_developer" {
  count = "${length(var.users)}"

  listener_arn = "${aws_alb_listener.developers.arn}"
  priority     = "${count.index + 1}"

  action {
    target_group_arn = "${element(aws_alb_target_group.per_developer.*.arn, count.index)}"
    type = "forward"
  }

  condition {
    field  = "host-header"
    values = ["${element(var.users, count.index)}-*.example.com"]
  }
}

resource "aws_route53_record" "per_developer_app" {
  count = "${length(var.users)}"

  zone_id = "${var.zone_id}"
  name = "${element(var.users, count.index)}-app.${var.domain_name}"
  type = "A"
  alias {
    name = "${aws_alb.developers.dns_name}"
    zone_id = "${aws_alb.developers.zone_id}"
    evaluate_target_health = true
  }
}

デプロイ定義の例

set :rails_env, :staging
set :branch, ENV["GIT_SHA1"] || (`git rev-parse HEAD`).strip
set :slackistrano, false

raise "require DEVELOPER env" unless ENV["DEVELOPER"]

target_group_arn = Aws::ElasticLoadBalancingV2::Client.new.describe_target_groups(names: [ENV["DEVELOPER"]]).target_groups[0].target_group_arn

set :ecs_services, [
  {
    cluster: "staging-per-member",
    name: "#{ENV["DEVELOPER"]}-#{fetch(:rails_env)}",
    task_definition_name: "#{ENV["DEVELOPER"]}-staging",
    desired_count: 1,
    deployment_configuration: {maximum_percent: 100, minimum_healthy_percent: 0},
    load_balancers: [
      target_group_arn: target_group_arn,
      container_port: 8080,
      container_name: "nginx",
    ],
  },
]

Autoscale (近い内に不要になる話)

Fargate Tokyoリージョンはよ!

  • 現時点でECS ServiceのスケールとEC2のスケールは独立している
  • Service増やしてもEC2のノードを増やさないとコンテナを立てるところがない
  • 増やすのは簡単だが減らす時の対象をコントロールできない

というわけでデフォルトで良い方法がない。


ecs_deploy/ecs_auto_scaler

https://github.com/reproio/ecs_deploy

  • CloudWatchをポーリングして自分でオートスケールする :cry:
  • Serviceの数を制御し、EC2の数はServiceの数に合わせて自動で収束させる
  • スケールインの際は、コンテナが動作していないノードを検出して落とす
  • コンテナが止まるまではEC2のノードは落とさない

polling_interval: 60

auto_scaling_groups:
  - name: ecs-cluster-nodes
    region: ap-northeast-1
    buffer: 1 # タスク数に対する余剰のインスタンス数

services:
  - name: app-production
    cluster: ecs-cluster
    region: ap-northeast-1
    auto_scaling_group_name: ecs-cluster-nodes
    step: 1
    idle_time: 240
    max_task_count: [10, 25]

    scheduled_min_task_count:
      - {from: "1:45", to: "4:30", count: 8}
    cooldown_time_for_reach_max: 600
    min_task_count: 0
    upscale_triggers:
      - alarm_name: "ECS [app-production] CPUUtilization"
        state: ALARM
    downscale_triggers:
      - alarm_name: "ECS [app-production] CPUUtilization (low)"
        state: OK

ecs_auto_scaler自体もコンテナに

  • ecs_auto_scalerはシンプルなforegroundプロセス
  • 簡単なDockerfileでコンテナ化可能
  • こいつ自身もECSにデプロイする

まあ、Fargateで不要になると思う


コマンド実行とログ収集

ECSにおいて特定のノードにログインするというのは負けである rails runnerやrakeをSSHで実行とかやるべきではない そのサーバーの運用管理をしなければならなくなる


wrapbox

https://github.com/reproio/wrapbox

  • ECS用のコマンドRunner
    • 半端に汎用性を持たせようとしたんでコードが微妙に……
  • TaskDefinitionを生成、登録し、即起動する
  • 終了までステータスをポーリングし待機する
  • タスク起動権限はIAMでクラスタ単位で管理できる
  • 慣れるとSSHとかデプロイが不要でむしろ楽

default:
  region: ap-northeast-1
  container_definition:
    image: "<ecr_url>/app:<%= ENV["GIT_SHA1"]&.chomp || ENV["RAILS_ENV"] %>"
    cpu: 704
    memory: 1408
    working_directory: /app
    entry_point: ["prehook", "ruby /app/docker/setup.rb", "--"]
    environment:
      - {name: "RAILS_ENV", value: "<%= ENV["RAILS_ENV"] %>"}

wrapboxで実行したコマンドログの取得

  • papertrailにログを転送し、別スレッドでポーリングしてコンソールに流すことができる
  • 原理的に他のログ集約サービスでも実現可能だが、現在papertrailしか実装はない

default:
  # 省略
  log_fetcher:
    type: papertrail # Use PAPERTRAIL_API_TOKEN env for Authentication
    group: "<%= ENV["RAILS_ENV"] %>"
    query: wrapbox-default
  log_configuration:
    log_driver: syslog
    options:
      syslog-address: "tcp+tls://<papertrail-entrypoint>"
      tag: wrapbox-default

db:migrate

capistranoのhookを利用しwrapboxで実行する

def execute_with_wrapbox(executions)
  executions.each do |execution|
    runner = Wrapbox::Runner::Ecs.new({
      cluster: execution[:cluster],
      region: execution[:region],
      task_definition: execution[:task_definition]
    })
    parameter = {
      environments: execution[:environment],
      task_role_arn: execution[:task_role_arn],
      timeout: execution[:timeout],
    }.compact
    runner.run_cmd(execution[:command], **parameter)
  end
end

desc "execution command on ECS with wrapbox gem (before deployment)"
task :before_deploy do
  execute_with_wrapbox(Array(fetch(:ecs_executions_before_deploy)))
end

set :ecs_executions_before_deploy, -> do
  # ecs_deployの結果からTaskDefを取得
  rake = fetch(:ecs_registered_tasks)["ap-northeast-1"]["rake"]
  raise "Not registered new task" unless rake

  [
    {
      cluster: "app",
      region: "ap-northeast-1",
      task_definition: {
        task_definition_name: "#{rake.family}:#{rake.revision}",
        main_container_name: "rake"
      },
      command: ["db:ridgepole:apply"],
      timeout: 600,
    }
  ]
end

db:migrate -> ridgepole

  • migrateのupとdownがめんどい
  • 特に開発者用ステージング
  • ridgepoleならデプロイ時に実行するだけで、ほぼ収束する
  • エラーが起きたらslackにログを出して、手動で直す
  • productionはdiffがあればリリースを停止させる
  • diffを見てリリース担当者が手動でDDLを発行する

テストとCI

以下を参照。 https://speakerdeck.com/joker1007/dockershi-dai-falsefen-san-rspechuan-jing-falsezuo-rifang


コンテナを真面目に運用するためには、結構色々考えることがある

何かしら参考になれば幸いです