地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Solr】Apache Solrへ入門する

概要

今更ながらApache Solrへ入門してみたのでその辺の情報の取りまとめ記事。

目次

Apache Solrとは

Apache Solrとは、Apacheコミュニティによって開発がされているOSS全文検索エンジンXMLCSVJSONなんかをインポートして使うことができる。実装自体はJavaで動かす際は1.8以上のJavaが必要です。現在は8が最新で7系もサポート期間的な扱いのようです。バイナリは以下からダウンロードできます。

solr.apache.org

ちなみにElasticsearchと同じようにLuceneをバックエンドに使っていてElasticsearchの入門書とか読んでいても登場したりする。(コアな部分は同じなのかぐらいの認識。)。Apache Lucene(アパッチ ルシーン)は、Doug Cuttingによって開発された、Java製の無料のオープンソース検索ライブラリ

openstandia.jp

Elastcisearchとの比較記事はぐぐるとめちゃめちゃHITすることからも分かる通りどっちが主流になるのかを争っていた時代があったというのが伺われますね。今ならElasticsearch一択感がとても強いです。

SolrとElasticsearchを比べてみよう

触ってみる

Dockerを使って環境構築

hub.docker.com

単一のSolrサーバー環境をDockerで立ち上げてみる。本番運用する場合はSolr Cloudなるものを使うらしい。(自動フェイルオーバーとかやる唯一の方法らしくきちんとやるなら一択)

Dockerfile

FROM solr:5.3.1

docker-compose.yml

version: '3'
services:
  solr:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
     - "8983:8983"
    volumes:
     - ./data:/opt/solr/server/solr/mycore
    environment:
      TZ: Asia/Tokyo

起動したら以下をクリックし管理画面を開くことができれば正常となります。メモリをそこそこ食うので手元の環境では最初エラーとなったので非力なマシンで動かす場合はある程度はメモリを開けておく必要があるので注意です。

http://localhost:8983/solr/#/

コアを生成する

Solr Coreは、使用に必要なすべてのSolr構成ファイルを含むLuceneインデックスの実行中のインスタンスです。何をするにもこのコアが中心となります。Elastcisearchでいうindexにあたるという認識です。またRDBスキーマに相当し、コアごとにスキーマ定義やクエリの設定を持つことができます。

www.finddevguides.com

$ docker-compose exec solr bash

# ちなみにcreateには以下のようなオプションがあります。conf_dirはconfをCMSで管理するなら必要なオプションです。
# –c *core_name *    Name of the core you wanted to create
# -p* port_name *  Port at which you want to create the core
# -d* conf_dir*    Configuration directory of the port

$ ./bin/solr create -c mycore

Setup new core instance directory:
/opt/solr/server/solr/mycore

Creating new core 'mycore' using command:
http://localhost:8983/solr/admin/cores?action=CREATE&name=mycore&instanceDir=mycore

{
  "responseHeader":{
    "status":0,
    "QTime":1631},
  "core":"mycore"}

コアを生成するとadmin画面よりCore Adminに上記で作成したコアが表示されます。

http://localhost:8983/solr/#/~cores/mycore

f:id:ryuichi1208:20210328224355p:plain

ちなみにdelete コマンドを使用してこのコアを削除できます。誤って作成した際などはこちらを使用することで対処することが可能です。

サンプルデータを投入する

インストール時についてくるxmlを使ってサンプルデータを先ほど作成したコレクションに追加します。

$ ./bin/post -c mycore example/exampledocs/*.xml
/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java -classpath /opt/solr/dist/solr-core-5.3.1.jar -Dauto=yes -Dc=mycore -Ddata=files org.apache.solr.util.SimplePostTool example/exampledocs/gb18030-example.xml example/exampledocs/hd.xml example/exampledocs/ipod_other.xml example/exampledocs/ipod_video.xml example/exampledocs/manufacturers.xml example/exampledocs/mem.xml example/exampledocs/money.xml example/exampledocs/monitor.xml example/exampledocs/monitor2.xml example/exampledocs/mp500.xml example/exampledocs/sd500.xml example/exampledocs/solr.xml example/exampledocs/utf8-example.xml example/exampledocs/vidcard.xml
SimplePostTool version 5.0.0
Posting files to [base] url http://localhost:8983/solr/mycore/update...
Entering auto mode. File endings considered are xml,json,csv,pdf,doc,docx,ppt,pptx,xls,xlsx,odt,odp,ods,ott,otp,ots,rtf,htm,html,txt,log
POSTing file gb18030-example.xml (application/xml) to [base]
POSTing file hd.xml (application/xml) to [base]
POSTing file ipod_other.xml (application/xml) to [base]
POSTing file ipod_video.xml (application/xml) to [base]
POSTing file manufacturers.xml (application/xml) to [base]
POSTing file mem.xml (application/xml) to [base]
POSTing file money.xml (application/xml) to [base]
POSTing file monitor.xml (application/xml) to [base]
POSTing file monitor2.xml (application/xml) to [base]
POSTing file mp500.xml (application/xml) to [base]
POSTing file sd500.xml (application/xml) to [base]
POSTing file solr.xml (application/xml) to [base]
POSTing file utf8-example.xml (application/xml) to [base]
POSTing file vidcard.xml (application/xml) to [base]
14 files indexed.
COMMITting Solr index changes to http://localhost:8983/solr/mycore/update...
Time spent: 0:00:00.773

上記が成功すると先ほどのCore Amin画面のmaxDocやnumDocsが変わっていることが確認できます。

f:id:ryuichi1208:20210328224708p:plain

検索APIを使う

curlを使って検索APIを実行してみる。Elastcisearchと違ってクエリパラメータだけで完結できるのは個人的にはこっちの方がわかりやすくて好きかもしれない。DSLとか覚えなくてもある程度は高度なクエリもかけそうだしhttpを理解していればだいぶ理解は楽かも。

主要なクエリパラメータ以下となります。

クエリ 概要 備考
q 検索文字列 q=Apache&df=title
wt 出力フォーマット (xml, json, csv, 他)
indent 出力結果にインデントをつける
rows 一度に表示される応答の行数を制御します デフォルト10行
fl クエリ応答に含まれる情報を、指定されたフィールドのリストに制限

hkawabata.github.io

インデントとフィールド指定したクエリを実行。正しく取れていそう。

$ curl 'http://localhost:8983/solr/mycore/select?q=id:apple&rows=10&indent=true'
<?xml version="1.0" encoding="UTF-8"?>
<response>

<lst name="responseHeader">
  <int name="status">0</int>
  <int name="QTime">1</int>
  <lst name="params">
    <str name="q">id:apple</str>
    <str name="indent">true</str>
    <str name="rows">10</str>
    <str name="wd">json</str>
  </lst>
</lst>
<result name="response" numFound="1" start="0">
  <doc>
    <str name="id">apple</str>
    <str name="compName_s">Apple</str>
    <str name="address_s">1 Infinite Way, Cupertino CA</str>
    <long name="_version_">1695484420944822272</long></doc>
</result>
</response>

httpリクエストで完結してくれるのでクライアント言語が特定のライブラリに依存しないのはとても良いですね。pythonのhttpなりでクライアントをさっとかけるのもとても良さそうです。(構築中のテストなんかがやりやすい)

サンプルクエリ
# idを指定して検索
http://localhost:8983/solr/techproducts/select?q=id:SP2514N

# フィールドリストを指定して検索
http://localhost:8983/solr/techproducts/select?q=id:SP2514N&fl=id+name

# 単語検索(ex. q=Apache&df=title)
# フィールド指定検索(ex. q=title:Apache)
# 全文検索(q=*:*)
# 論理演算子・グループ化(ex. q=検索式1 AND (検索式2 OR 検索式3 NOT 検索式4))
# 範囲検索(ex. q=pages:[100 TO *], q=genre:[C TO D])
# ワイルドカード検索(ex. q=title:プログラ?,q=title:*ログラム)
# 正規表現検索(/で囲った部分を正規表現として解釈。ex. q=/[cm]ap/)
# フレーズ検索(複数単語の出現順序を保証。ex. q=title:"Apache Solr")
# あいまい検索(指定した編集距離(0-2)以内の単語に当たれば OK。ex. q=title:プログラム~1)
# 近傍検索(複数単語が指定した距離以内に近さにあれば OK。フレーズ検索と異なり、順序は指定不可。ex. q=title:"Ruby プログラミング"~1)
# 単語の重み付け(ex. q=title:Apache^0.5 OR summary:Solr^2.0)
# 定数スコア(ex. q=title:Apache^=1.0 AND genre:パソコン)

solr.apache.org

スキーマの話

Solrのスキーマの属性は以下のようなものがあります。ちなみにsolrもElasticsearchみたいなスキーマレスとしてデータを扱う機能が4系から入ったらしいです。それ以前は必須だったとのことですが実運用する上でこの辺は必須な設定な気がしてます。

  • name : フィールド名
  • type : 型 (Solr組み込みの型の一覧)
  • indexed : trueの場合、クエリで検索可能なフィールドになる (デフォルトはtrue)
  • stored : trueの場合、クエリの結果に値を含めることができる (デフォルトはtrue)
  • required : trueの場合、POST時の必須項目となる (デフォルトはfalse)
  • multiValued : trueの場合、複数の値を持つことができる (デフォルトはfalse)

スキーマは以下のように http://localhost:8983/solr/test/schema にPOSTすることで定義できます。(ここでは予めtestというコアを作成しておきます)

curl -X POST -H 'Content-type:application/json' --data-binary '{
  "add-field": {
    "name": "url",
    "type": "string",
    "indexed": "true",
    "stored": "true",
    "required": "true",
    "multiValued": "false"
  }
}', http://localhost:8983/solr/test/schema

今回は新規なので追加です。同じschemeを定義することはできないので以下を指定することで削除や更新を行うことができます。

キー 説明
add-field-type 追加
replace-field-type 更新
delete-field-type 削除

テストデータを作成

[
  {
    "name": "url",
    "url": "https://google.com",
    "type": "searchengine",
    "indexed": "true",
    "stored": "true",
    "required": "true",
    "multiValued": "false"
  },
  {
    "name": "url",
    "url": "https://yahoo.co.jp",
    "type": "searchengine",
    "indexed": "true",
    "stored": "true",
    "required": "true",
    "multiValued": "false"
  }
]

以下のコマンドでデータをpostします。

$ curl 'http://localhost:8983/solr/test/update?commit=true&indent=true' --data-binary @test.json -H 'Content-Type: text/json'
{
  "responseHeader":{
    "status":0,
    "QTime":43}}

ちなみにPOSTはcurlを使わずとも管理画面から行うことも可能らしいです。

blog.johtani.info

今回はAPI経由でschemeを定義しましたがxmlを書きreloadすることで書いた定義を反映させることができます。コード ベースでできるのでCI/CDもやりやすいので運用で使うなら多分こっちですかね。(テストとかは書きづらそうなのでクライアントでやる必要がありそう。)

hkawabata.github.io

  <types>
    <fieldType name="タイプ名" class="クラス名" [オプション属性] />
    <fieldType name="test_ja" class="solr.TextField" autoGeneratePhraseQueries="false" positionIncrementGap="100">
      <analyzer>
        <!-- アナライザの定義 -->
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="search" />
        <filter class="solr.JapaneseBaseFormFilterFactory" />
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" >
        ...
      </analyzer>
    </fieldType>
    ...
  </types>

ちなみにパスはこの辺になります。${solr.solr.home}/collection1/conf/schema.xml

* indexed=”true/false”
trueをセットすると、インデックスが作成されそのフィールドで検索およびソートできるようになります。

* stored=”true/false”
trueをセットすると、検索の結果にそのフィールドの値が含まれるようになります。

* multiValued=”true/false”
trueの場合、1つのドキュメント中に複数のフィールドが現れることを示します。

* required=”true/false”
trueの場合、このフィールドが必須であることを示します。required=”true”なフィールドがインデックスの追加時に含まれていない場合、エラーが返ります。

* default=”デフォルト値”
インデックスの追加時のデフォルト値をセットします。

configの書き方

Solrの動作設定ファイルはsolrconfig.xmlとなります。コンテナでやる場合は/opt/solr/server/solr/configsets/basic_configs/conf/solrconfig.xmlこの辺にあります。

qiita.com

f:id:ryuichi1208:20210329001352p:plain

高可用性を実現するためには

solrは可用性を高めるためのソリューションを提供しています。master slaveとSolrCloudという構成があって高可用性を実現するためにはSolr Cloud一択となっているようです。

f:id:ryuichi1208:20210329002606p:plain

参考: techblog.zozo.com

master-slave構成で可用性を高めるにはバッチでインデックスを生成するみたいな構成を取っている場合はmasterの障害中にデータをロストしないようなバッチとする必要があります。なんらなかの永続化する仕組みが間に必要となるので複雑になりそうです。

とはいえSolrCloudの方もzookeeperなど別途で必要になってくるのでこっちもこっちで複雑になりそうです。この辺はノウハウが溜まっている状況でない限りは運用は怖いかもしれないですね。。

backup / restore

既存環境から新環境へインデックスを再構築する方法。公式で提供してくれるのは嬉しいですね。バージョンアップなんかも丁寧に説明してあったりでその辺は参考になりそう。

# backup
$ curl http://localhost:8983/solr/admin/cores?action=CREATESNAPSHOT&core=techproducts&commitName=commit1

# restore
$ curl "http://localhost:8983/solr/core1/replication?command=restore&name=20160216120000"

参考記事

【HTTP】キャッシュあたりを整理してみる

概要

HTTPのキャッシュについて色々調べてみたのでメモ的な意味も兼ねて書いた。

動機としては以下の本を読む上で先に事前知識を入れておきたかったなというのがあります。キャッシュだけで400ページ超え。とても楽しみです。

gihyo.jp

目次

それぞれのヘッダーのもつ意味

Expiresヘッダー

If modified Sinceも送られないため、304レスポンス負荷が発生することもない。

HTTPレスポンスにCatch-Controlのmax-ageディレクティブが含まれる場合は、Expiresは無視されます

blog.redbox.ne.jp

Apacheでやるならこんな感じで設定を書くことで適用することができます。キャッシュ先はユーザのブラウザとなるのでキャッシュヒット時にはユーザとしてはサービスが取りうる最高のレイテンシでサービスを使うことができます。

<ifModule mod_expires.c>
 ExpiresActive On
 ExpiresByType image/png "access plus 1 months"
 ExpiresByType image/jpeg "access plus 1 months"
 ExpiresByType image/gif "access plus 1 months"
 ExpiresByType text/css "access plus 1 months"
</ifModule>

注意点としては

  • RFCガイドラインに違反するので、1年以上先には設定しない。
  • 強制的なキャッシュなので更新の多いページにはなるべく使わない。

Cache-Control

public

レスポンスが通常はキャッシュ可能でなくても、レスポンスをどのキャッシュにも格納することができます。

private

Webサーバから返されるコンテンツがただ一人のユーザのためのものであることを示す。このコンテンツは、複数のユーザが共有されるキャッシュに記録されるべきではないことを表している。

この設定はCDNの設定で以下の流出の事件で有名なやつですね。

engineering.mercari.com

no-cache

「キャッシュを使うな」のように見えるこのヘッダが実際に意味するところは少々ニュアンスが異なる。このヘッダの意味は、いちどキャッシュに記録されたコンテンツは、現在でも有効か否かを本来のWebサーバに問い合わせて確認がとれない限り再利用してはならない、という意味である。

no-store

このヘッダは、Webサーバから返されてくるコンテンツをキャッシュに記録するな、という指示である。

developer.mozilla.org

「no-cache」と「no-store」の違い

no-cacheは、同じURLに対する後続のリクエストへのレスポンスとして、以前返されたレスポンスを使用するには、まずサーバーに問い合わせてレスポンスに変更があったかどうかを確認する必要があることを示します。

no-storeはより単純で、返されたレスポンスのバージョンにかかわらず、ブラウザのキャッシュやすべての中間キャッシュはそのレスポンスを一切格納できません。たとえば、個人の機密データや銀行データが含まれているレスポンスなどです。ユーザーがこのアセットをリクエストするたびに、リクエストがサーバーに送信され、完全なレスポンスが毎回ダウンロードされます。

Last-ModifiedとIf-Modified-Since

Last-Modifiedヘッダーでは、最後に更新された日時をもとにリソースが同じかどうかを判断します。

また、リクエストにIf-None-Matchヘッダーが含まれる場合には、If-Modified-Sinceヘッダーが無視されます。つまり、Last-ModifiedヘッダーとETagヘッダーを併用した場合は、ETagヘッダーが優先されます。

qiita.com

satoyan419.com

EtagヘッダーとIf-None-Match

Etagヘッダーでは、「エンティティタグ」と呼ばれるリソースを特定するために割り当てられる固有の値(文字列)を用いて、リソースの内容が同じかどうかを判断します。

  • Last-Modified オブジェクトが最後に変更された日時を示します。
  • エンティティタグ (ETag) コンテンツの一意の識別子を提供します。

Cache-ControlもExpireもない場合はどうなるの

NginxもApacheも明示しなければ上記のヘッダーは自動ではつきません。自動でつかないなら全てのリクエストはオリジンへ飛ぶのかといういうとそんなこともなくブラウザの実装次第にはなりますが概ね以下のようなキャッシュがブラウザで行われます。

ヒューリスティックなアプローチを使用してキャッシュを利用する。レスポンスの生成時間とLast-modifiedの時間とコンテンツの時間を使って経験則から導き出した時間でキャッシュをする。ブラウザというかhttpクライアントごとに細かくは違うんだろう。

expirationTime = responseTime + freshnessLifetime - currentAge

正確な話はRFCに以下の記述があります。Ageヘッダーがない場合とかその辺はどうやってるのかは定義されていないのでこれもブラウザ依存でしょうか。

tools.ietf.org

ブラウザにキャッシュはさせたいけど都度確認はしてきて欲しいとき

cache-control: public, max-age=0
ETag: "aaaaaa"

max-ageに0を指定する。クライアントは次回リクエスト時にEtagを使ってサーバに更新がないかどうかを問い合わせることでキャッシュが古くならないようにする仕組み。

from service workerって何

f:id:ryuichi1208:20210307123940p:plain

Etagとか使ったブラウザキャッシュはfrom disk cacheとかになるのかなとservice workerもキャッシュを持つらしいことを知った。上はtwitterにアクセスした際の静的ファイルのキャッシュ。

Service Worker とは、Webページとは別にバックグラウンド(別スレッド)で動作するJavascript環境のことでブラウザキャッシュとは違うところに保存されるらしい。

そしてどうやらClear Cacheとかのブラウザキャッシュをクリアするツールだけではこの領域はクリアされないらしい。

chrome.google.com

まとめ

HTTPレイヤでのキャッシュ、主にクライアントキャッシュについて調べてみた。サーバサイドのキャッシュと違ってクライアントの実装依存な部分があったりキャッシュの鮮度を意識したりとTTLだけの制御だけでは足りない世界を垣間見ることができました。

この辺の知識はRedis 6.0だかでリリースされた新機能のクライアントサイドキャッシュの理解にも役立ちそうでよかったです。

【Linux】IOバウンドの処理はどの程度まで並列数を上げればよいかの考察(ちょっと修正版)

概要

IOバウンドについて考える機会があったのでその辺の話についてまとめてみた。ちなみにブロッキングIOしか出てきません。以下の本が大体の参考になってます。

https://www.amazon.co.jp/exec/obidos/ASIN/4774143073/hatena-blog-22/www.amazon.co.jp

忙しい人向け

結論としてはIOバウンド(ネットワーク起因)な処理はコア数関係なく増やせば増やす分だけスループットは向上する。自宅環境においてボトルネックはサーバ側の最大コネクション数になった。

ネットワーク越しのIOはクライアントだけじゃなくサービス提供側が存在して成り立つものなので並列数はその辺のサービス特性なんかも理解しつつ決めていく必要があって一概にどれくらいが良いとは言えるものでは無かった。

前説

どの程度まで並列数を上げるかの検証の前に一旦前提部分の知識を整理

IOバウンドとCPUバウンドについてと対処法方

CPUバウンド

CPUバウンドとはプロセスの進行速度がCPUの速度によって制限されることを意味します。圧縮/解凍や暗号化処理なんかがこれにあてはまります。画像変換処理なんかもこれです。対処法はとてもシンプルでコアの周波数をより高いものにする。または並列化が可能ならば1コアで処理させるのではなく複数コアで処理するようにプログラムを書き換える。

基本的にCPUの性能依存でコア数以上の並列度は意味を持ちません。最適化するには並列度 = コア数にするのが良いぐらいの回答になります。(intelのHTの話なんかも必要な気がしますが今回はスコープ外とします。)

CPUバウンドのアプリケーションでコア数以上の並列数を指定するとコンテキストスイッチがアプリの実行に加えて処理時間としてかかってしまうので効率はよく無いです。OSが使うCPU分を残してアプリはコアをアフィニティで指定することでもしかしたらコア数分の並列どよりも早くなるケースもあるかも知れないです。(割と思いつきなのでちょっと微妙かも)

ちなみにCPU性能律速型アプリケーションのパフォーマンスは動作クロックは性能に比例します。世の中にいるオーバークロックしたりする人たちが使ってるようなベンチマークツールなんかはこの辺が顕著にスコアに影響したりするらしいです。

あと、CPUとメモリ間のやりとりに延滞が発生すれば処理能力も低下しますが、Xeonなどの CPU は、一般市場向けの CPU よりも内部キャッシュの容量を増やす事でレイテンシを改善したりしてたりします。PCとサーバで性能差がわかりにくかったりする原因はこの辺にもあったりします。

I/Oバウンド

プロセスの進行速度がI/Oサブシステムの速度によって制限されることを意味します。ディスクへの操作やネットワークを介したファイルの書き込みなんかが当てはまります。対処法はその条件によって代わりますので後述します。

I/Oバウンドの原因

プロセスの実行中にI/O待ちになるケースとして代表的なのが以下の2つです。

  • HDD/SSDへデータを書き込もうとした
  • ネットワークを介してデータを送信しようとした

前者はディスクIO、後者はネットワークIOなんて言い方をしたりします。今回考えるのは後者のネットワークIOです。

ディスクIOばボトルネックになる場合は以下のような対処法がパッと出てきます。今回は細かくは見ていかないですが調査手段なんかも確立されていたりして比較的理解しやすい分野のはずです。(チューニングになると意味不明なので静観します)

  • ディスクの回転数を高いものにする
  • ディスクが搭載しているキャッシュサイズを大きいものにする
  • RAIDを組んでRead/Write性能を上げる
  • SATA接続からSAS接続などのように上位の接続方法を採用する
  • 構築するファイルシステムをチューニングする

(この辺はデータベースのチューニングとかをやる際に考える必要がある項目だと思いますがマネージドDBが流行ってるので触れることはあんま無いと思うので知識としていつかちゃんと調べたい)

ネットワークIOとは

ネットワークIOとはざっくり何をしているのかというとユーザプロセス的にはsocketという特殊ファイルに対してread/writeをしてるだけです。もちろんユーザ空間の先でカーネルがいろいろ処理をして通信先へデータを書いたりするといった処理を行ってくれます。

細かい話をすると切りが無いですが基本的なTCPのアプリケーションはサーバもクライアントも流れ的に大体こんな感じで通信を行っていきます。

f:id:ryuichi1208:20210215175956p:plain

Socket in Linux (Part 18/24)

TCPクライアントにおけるIOバウンドは図でいうとrecvfrom()の部分です。これはTCPサーバ側が処理を行っていたりデータをディスクから読んでいるみたいなケースですぐにデータを送信できない場合クライアント側はblockされます。

blockが発生する条件をまとめると以下のような場合です。

  • read: クライアント側のrecvバッファにデータが到着していない
  • write: サーバ側のrecvバッファがいっぱいでクライアントのsendバッファがいっぱいになった

処理としては全体的に以下のような流れになります。

システムコール
↓
カーネルモードにコンテキストスイッチ
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放
補足: そもそもプロセスがblockするとは

Linux において、システムコールがブロックするとは、「プロセスが、システムコール呼び出しの延長で待状態(TASK_INTERRUPTIBLE or TASK_UNINTERRUPTIBLE) に遷移し、CPU時間を消費せずにあるイベントが完了するのを待つようになる」、ことを指します。ちなみにこの状態はpsコマンドで見えるSTATと関連します。

www.mas9612.net

パイプやソケットなど、キュー(FIFO)の構造を持つファイルを読み書きしようとした時に、キューが空で読み取れるデータがない場合と、キューが満杯でこれ以上書き込めない場合には、読み書きできる状態になるまでプロセスは待ち状態になります。

キューに新しくデータが到着すると、キューが読み込み可能になります。キューに空きが出来ると、キューは書き込み可能状態になります。この辺の値はnetstatコマンドで見えるRecv-QやSend-Qなんかの値が関連してきます。

access.redhat.com

NICにパケットが到着してからソケットキューにデータが入ってユーザプロセスが受け取るまでの流れは以下です。右のプロセスがread()を発効するもsocket queueにデータが無い場合は左から順にパケットが入ってくるまでブロックします。

f:id:ryuichi1208:20210215220833p:plain

データ到着後はCPUとOS側の処理としてユーザプロセスを起こすようなイメージです。LinuxのCPUスケジューラは書籍や情報も豊富なので学習しやすいと思います。特に以下の方がすごくおすすめです。

https://www.amazon.co.jp/%E8%A9%A6%E3%81%97%E3%81%A6%E7%90%86%E8%A7%A3-Linux%E3%81%AE%E3%81%97%E3%81%8F%E3%81%BF-%E5%AE%9F%E9%A8%93%E3%81%A8%E5%9B%B3%E8%A7%A3%E3%81%A7%E5%AD%A6%E3%81%B6OS%E3%81%A8%E3%83%8F%E3%83%BC%E3%83%89%E3%82%A6%E3%82%A7%E3%82%A2%E3%81%AE%E5%9F%BA%E7%A4%8E%E7%9F%A5%E8%AD%98-%E6%AD%A6%E5%86%85-%E8%A6%9A/dp/477419607Xwww.amazon.co.jp

本題

ここで本題に入ります。CPUバウンドなアプリケーションでは並列度 = コア数が性能的には一番出るって話でしたがIOバウンド(ネットワーク)の場合どの程度まで並列度を上げると良いのかを検証してみます。

検証準備

検証は2コアのマシン。

$ nproc
2

TCPサーバとしてはflaskでhttpリクエストを受けた際にサーバがsleepすると言った擬似的に重い処理を行っているような実装を仕込んでおきます。

@app.route("/abort", methods=["GET"])
def abort():
    exit = request.args.get("exit")
    app.logger.error(exit)
    if not exit:
        os.abort()
    elif exit == "exit":
        sys.exit()
    elif exit == "sleep":
        time.sleep(10)

    return "OK"

以下のエンドポイントへhttpリクエストをすると10秒かかると言ったサーバを予め用意しました。

GET /abort?sleep

検証は以下のスクリプトで並列度を実行パラメータに取るようにしてます。(マルチスレッドモデルでforkのオーバーヘッドは削りたかったですが今回はマルチプロセスモデルをテスト用クライアントとします。)

#!/bin/bash

for i in $(seq $1); do
    curl -o /dev/null -s curl http://${WEBSRV}:30001/abort\?exit\=sleep &
done
wait
1リクエスト1並列

実行は10秒で完了しました。これは当然の結果ですね。

time bash test.sh 1
bash test.sh 1  0.01s user 0.00s system 0% cpu 10.044 total

ちなみにcurlは何でblockされるかというとpoll(2)を使ってソケットを監視しているようでした。poll自体はblockingなのでOSがデータの到着を通知するまではブロックします。この間はCPUを使用することは基本ないです。(ユーザ/カーネルコンテキストスイッチは発生します。)

poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)

pollの流れは以下のようなイメージです。

システムコール
↓
カーネルモードにコンテキストスイッチ
↓
ファイルディスクリプタの準備ができたらユーザモードにコンテキストスイッチ
↓
準備ができたデータグラムに対するシステムコール
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放

(どうでもよいですがepollのような効率的なシステムコールじゃなくてpollで実装してるのは何ででしょう。ファイルディスクリプタの数に制限が無いのに加えて、ファイルディスクリプタの状態変化監視も改善されているとかその辺のメリットはhttpクライアントレベルだと実はないとかですかね。)

Man page of POLL

10リクエスト10並列

こちらの実行も10秒で完了しました。コア数が2ですが基本ブロック処理なのでCPUコア数以上の値を出しても性能が良くなることが分かりました。

$ time bash test.sh 10
0.02s user 0.04s system 0% cpu 10.092 total

前述の通りblock中はCPUを使わないのでこのようなコア数以上の並列度での実行で性能が出ると言った結果になります。

10000リクエスト10000並列

思い切って1000倍の数の並列度を出してみます。これが10秒で完了するのかどうかを確認です。

実行

reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failure

残念クライアント側より先にサーバアプリのmaxclientsに掛かったのかそのレベルの並列度はコネクションが貼れませんでしたw

ちなみにロードアベレージ は3000を超えましたがCPU使用率は90%程度で耐えていました。

load average: 3544.42, 1430.31, 524.07

vmstatの値ではrunningとblockがとてつもない数字になっていました。この状態でもbash入力は受け付けられていたりとサーバ側に余裕はある感じでした。

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
336 1295      0 102984      0 164492    0    0 35540     0 3944 10386 11 81  0  9  0
89 1471      0 104164      0 113340    0    0 24908     0 3657 10493  4 97  0  0  0
1895 2912      0 103464      0  94700    0    0 470285    15 52712 351584  2 98  0  0  0
2835 7384      0 137432      0 118060    0    0 2262545   166 27198 139124  3 91  0  6  0

podのログを見るとサービスメッシュとして稼働しているistioが限界を迎えていたっぽいです。。podに割り当てるリソースを増やせばもしかしたら耐え得るのかもしれないですが今回は「お家k8s上の自作アプリの限界テスト」ではないのでここらで一旦まとめに入ります。

考察とまとめと感想

CPUバウンドな処理はコア数程度にするのが良い

IOバウンドな処理はコア数以上の並列度にすればOSがいい感じにスケジュールしてスループットは上がる。(blockされたプロセスはデータの到着を待つ。データが来たらOSがプロセスに通知して通知されたプロセスが起動するような仕組み)

この辺の並列数は実際に接続元/接続先が固定されたときに数値を算出することが可能になると思います。NICスループットだったりクライアント側のメモリの搭載量、同じ話がサーバ側にも当てはまります。当然ですが外部サービスへの接続では同時接続数は大量にしすぎると攻撃判定されてアクセス禁止なんてことも起こりうる可能性もありそうです。きちんと並列数は管理しつつ処理を要求する必要があると感じました(とても大事)。

パフォーマンスチューニングにゴールは無いは無いのはよく言われる話でここでのチューニング項目は並列数になると思います。外部サービスを利用するならそもそも相手のサービスに負荷を掛けてしまうような動作は避けなければならないですし「単位時間あたりにどれくらいまでリクエスト可能です」みたいな規約があればそれが並列数のボトルネックになります。この辺の感所はあんまないので理解していきたいです。

オマケ: お家k8sの話

サンプルとして作ったアプリですがこれ自体はk8s上でHPAを有効にして動かしてます。今回みたいなスパイクに弱いというのは話には聞いていて「そうなのか」程度の理解でしたがこの機会に実感できたので良かったですw

オマケ: NICの話

10GbEを始め40GbEやインフィ二バンドみたいな高速な通信を可能にする通信規格の場合はIOバウンドは起こり得ないし幸せな世界がやってきそうなものですがそんなことはなくて今度は高速さゆえのCPU割り込みが増えてCPU使用率が高くなる問題があるらしいです。ネットワークは高速化すれば良いって話で済まないのはまさにボトルネックは常に動くを表していて面白いです。

blog.yuuk.io

【Linux】agメモ

# ディレクトリ階層の深さ指定
     --depth NUM          Search up to NUM directories deep (Default: 25)
# (マッチした)ファイル名のみを出力
  -l --files-with-matches Only print filenames that contain matches
                          (dont print the matching lines)
# マッチしなかったファイル名を表示
  -L --files-without-matches
                          Only print filenames that dont contain matches
# ファイル名にマッチしたものを出力
  -g PATTERN              Print filenames matching PATTERN
# 行番号を非表示
     --[no]numbers        Print line numbers. Default is to omit line numbers
                          when searching streams
# 隠しファイルも検索
     --hidden             Search hidden files (obeys .*ignore files)
# 全ファイルを検索(隠しファイル、無視指定ファイル含め検索)

  -u --unrestricted       Search all files (ignore .agignore, .gitignore, etc.;
                          searches binary and hidden files as well)
# 大文字小文字区別
  -s --case-sensitive     Match case sensitively
# 大文字を含んでいれば、大文字小文字区別
  -S --smart-case         Match case insensitively unless PATTERN contains
                          uppercase characters (Enabled by default)
# ファイル名を正規表現指定で制限検索(ex. ag -G '\.(c|h)' pattern)
  -G --file-search-regex  PATTERN Limit search to filenames matching PATTERN