STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Ruby3.0でRactorを入れた理由、M:Nスレッドの制限。深掘りRubyKaigi 2022 with ko1 & kateinoigakukun 文字起こしレポートvol.2

2022年10月5日に『深掘りRubyKaigi 2022 with ko1 & kateinoigakukun~ RubyKaigiどうでした&RubyのWASI/並列どうなるの? ~』を開催しました。イベントでお話した内容を3部作でお届けします。こちらはvol.2です。

ko1の発表「Making *MaNy* threads on Ruby」をざっくり

fujimura:では、笹田さんのパートに移ろうと思います。笹田さん、どうぞよろしくお願いいたします。

ko1:よろしくお願いします。

fujimura:最初にRubyKaigiのトーク内容のざっくりまとめと、感想及びその後の反響をお願いします。

ko1:ざっくりしたまとめで言うと、RubyにM:Nスレッドを入れたいという話を発表しました。M:Nスレッドとは何かというと、ユーザーレベルスレッドライブラリと、カーネルスレッドのよさをハイブリッドにしたスレッドシステム、並行処理システムが昔から知られており、難しいと言われていました。でも最近の言語を見ると、例えばGoではよく使われていて、goroutine楽しいとみんなもよく使っている。そういう便利なものがあるんだったら、Rubyでも使えるようにしましょうというのが今回の話でした。反響は、何かできるといいねと聞いた気がします。

fujimura:ありがとうございます。素人質問コーナーをやらせていただこうと思うんですが、並行性がコンカレンシーで、並列性がパラレリズムだと思うんですけど、一般的にどう違うんでしょうか?Rubyだと、どういう区分になってるのかを教えていただきたいです。

ko1:みんながそう使っているわけではなく、われわれはこう使っているという話ですが、パラレル、並列にプログラムを動かすときには、CPUが2個、4個とか、複数あって、その複数のCPUでプログラムを同時に動かす、物理的に同時に動かすというものになります。

並行処理は物理的に同時に動かしてもいいし、例えば時分割であれをやった後に、これをやってというのをユーザーから見えないような感じで、適当に入れ替えながら動かすみたいなやり方。どういうやり方であっても、とにかくN個のタスクがあったら、そのN個のタスクがいつか終わっている、そういうものが並行になります。

Ruby2.7までは、Rubyのスレッドといえば並行に動くプログラムの実行単位でした。なので、例えばN個のスレッドを作っても、基本的にはCPUの数が増えても、あまり同時には動かないところがありました。なのでRubyのスレッドは並行に動くけれども、並列には動かないものでした。それに対してRactorをRuby3.0に入れて、Ractorが異なれば、異なるスレッドは並列に動くのが、Rubyにおける並行と並列の歴史になります。

fujimura:ありがとうございます。もうひとつ素人質問なんですが、Ractorで並列なプログラムを書くと、本当に何でもパラレルに動くんでしょうか?

ko1:パラレルに動かしていいものは、動くと思います。データ構造を複数のRactorから共有するようなものをもし作ったら、それはきちんと同期しなきゃいけない。クラスってRactor間で共有するんですね。そうするとあるクラスを触っているときに、ほかのRactorがそのクラスをいじろうとすると、何もしないとデータ構造がぐちゃぐちゃになってしまう。いわゆるデータレースとか、レースコンディションとか、そういうものが発生してしまうので、そういうことがないようにロックをしなきゃいけない。それを同期するという話なんだけど、そういうのがあると、ちょっと待ってろって感じで、同時に並列には動かないです。

fujimura:ありがとうございます。これで視聴者のみなさまも、並列と並行についてはばっちりになったと思います。

Ruby3.0でRactorを入れた理由

fujimura:RubyKaigiの発表を経た上で、今後の課題はどこなのかを教えてください。

ko1:まず実装が完全ではないので、ちゃんと使えるようにするのが、まず直近の課題になっています。RubyKaigiで発表するために、とにかくやっつけでもいいからと、わーっと作ったところがあります。例えばRailsは、多分まだまだ動かないことが多いとか、あとLinuxの2.なんぼっていう、誰も使ってないと思うんですけど、それ以降の機能を使ってるので、古いLinuxだと動かないとか。その辺をない機能を使っているときには動くようにする、例えばFreeBSDとか、macOSでちゃんと動くようにするみたいなのがあるかと思います。

そういうのをやっていると、多分3.2には間に合わないので、3.2がリリースする頃に大体できている状態にして、3.3に向けてマージしましょうと持っていけるといいなと思っています。

fujimura:ありがとうございます。ちなみにRailsってどこら辺が壊れた、及び壊れそうって、もう当たりがついてたりするんですか?

ko1:いや、そういう意味では、実際に動かしていないのでわからないんです。けど、とにかくこの手の処理って想定外のあるタイミングで動かすと変なことが起こりがちなんですよね。それをつぶしていくのが、しんどそうだなって。普通に動かしていたら動くんだけど、シグナルが入ったら動かないとか、コントロールしてると何かおかしなことが起こるみたいな。

shyouhei:うろ覚えなんですけど、昔の記憶で言うと、SQLiteバインディングって確かグローバル変数をめっちゃ使っていて、SQLiteをマルチスレッドから動かそうと思うと、結構難しいみたいになってたような気がする。うろ覚えなんで、違うかもしれないけど。Railsだとデータベースが必要なので、そういったところが不可避かもなって今思いました。

ko1:そうですね。Ractor対応でいうと、既にSQLiteが使えない可能性は十分にあると思うので、SQLiteへのアクセスはC Extensionのほうで同期する必要があると思います。つまりひとつのRactorがSQLiteの処理をしているときには、ほかの処理が動かない、ほかのRactorが使えないようにする、そういうことが必要になります。

fujimura:ありがとうございます。パラレリズムがあるプログラムのデバッグが本当に難しいっていうのは、僕も1回すごく苦しんだことがあるんですけど。あれって本当にもうどうにもなんないんですかね。どうやっても難しいんですかね?

ko1:人類はこれに対して、3つのアプローチを研究しています。1つは、デバッガ、デバッグ環境を頑張る。並行並列処理のデバッグ一般の話ですが、何が難しいって、2回目に同じことをやっても再現しないんですよね。それを再現するためにいろんな研究があって、OSのこのパラメーターは前に動かした時と同じにするとか、タイミングもあわせるとか。だからOS、ランタイム、IO、全部再現するための仕組みみたいなものが、10年ぐらい前に研究では流行っていました。

それからThread Sanitizerって、いくつかの言語処理系でありますよね。さっきの同期の話ですけども、同期を忘れていると起きる問題、デッドロックしちゃう問題がある。こういう場合にはロックを取ってなきゃいけないっていうのを先に計算しておいて、ここロックを取ってなきゃいけないのに、このリソースに対してロックを取らないでアクセスしてるっていうのを検証する。そんな感じでコンパイラやランタイムのチェックをいい感じにする方法で、デバッグ環境をよくするっていうのがひとつ。

最後に、言語に制限を入れる方法。例えば、Erlangみたいにすべてがイミュータブルだと、並列並行の嫌なところってだいぶ消えるんですよね。デッドロックとかライブロックは残るんで、頑張らなきゃいけないんですけど、いつの間にか変わっていたとか。このタイミングでRead Writeが錯綜するとエラーになるとか。Readしかできないプログラムにおいては起きないので、そういうふうに言語から変えてしまおうっていうのがひとつ。で、Erlangは、昔からの話で言うと電話交換機のような、エラーが起きてはいけないところで使われてきていて、いかにバグを排除するかっていうところで得られた知見なのかなと感じています。

fujimura:ありがとうございます。

ko1:もうちょっと言うと、Ractorはそっちを目指してるんですね。RactorってほかのRactorから見ると、すごい制限された状態しか見れないんだけども、下手にスレッド間でオブジェクトが共有できて、ほかのスレッドがRead Writeしてるところに、別のスレッドがRead Writeすることが起こり得るんです。そういう可能性を減らすことで、並列並行処理のデバッグのしやすさを増やそうというのが、Ruby3.0で入れたRactorの目論見でした。

それでまあ、別のつらみが出てくるんですけど(笑)Erlangみたいに全部イミュータブルにしましょうっていうと、多分暴動が起こるんですね。これからローカル変数は、インスタンス変数はread onlyですって言われたら、Rubyじゃなくなっちゃいますね。なので、いい感じにこっちはある範囲ではミュータブルで、ある範囲ではイミュータブルに見えるものを作りたかったっていうのが、Ractorの目論見ではありました。

fujimura:ありがとうございます。質問を1個いただいています。
「並列処理の実行状況を可視化できる方法ってあるんでしょうか?」

ko1:あると思います。あんまり作ってないんですけれども、例えばほかの言語でもよく使われてるビジュアライザーみたいなもの、プロファイリングを取る仕組みとビジュアライザーをする仕組みをやればいいかなと思います。

並列処理、並行処理に関しては、今Shopifyの人が単一のRactor、いわゆる普通のRubyに対して、スレッドがどういうタイミングで動いているか、このスレッドはあんまり動いてないぞっていうのを見つけるためのビジュアライザーみたいなものを作っていらっしゃいますね。

fujimura:ありがとうございます。並列並行でこの言語はうまくいってるな、これはいただきたいなみたいなところがある言語って、Ruby以外だとどういう言語になるんですか?

ko1:Ruby以外だと、全部イミュータブルだといろいろやれることがあってよさそうですよね。Erlangだと80年代のセンスで作られた言語なので、それを現代風にしたElixirは、やっぱり使いやすそうだなと。

kateinoigakukun:質問してもいいですか?

ko1:どうぞ。

kateinoigakukun:例えばRustだと、sendabilityだったり、そういう概念があると思うんですけど、そういうスレッドをまたいだときの特性を表明するかたちでイミュータビリティ、イミュータブルではないけど、何かしらできることがあったりしないですか?

ko1:もちろん型による解決もあり得るかと思います。が、私はRustをよく知らないんだけど(笑)、十分に型で共有データ構造を表現できるのか、簡単なところは多分できると思うんですよね。ここは所有権を持ってないとRead Writeできないっていうのを制限するみたいなのはできると思います。が、ある共有のデータ構造に対して、複数の並行処理実体がRead Writeするプログラムがあったとした場合に、それが型で表現できるのか、unsafe使わないとうまくいかないのかは、ちょっと興味がありますね。知らないんで、わからないんですけど。

kateinoigakukun:僕もあんまり詳しくはないんですけど、そういうunsafeを使わないといけないとか、本当にクリティカルなロックを取る部分だったり、そういう下回りの部分っていうのは、アプリケーションを書くようなユーザーは書かなくて。ライブラリ作成者が頑張ってセンダブルにしたものを合成して、センダブルな新しい型を作るっていうスタイルだと思うんですよね。

ko1:気にしなきゃいけない範囲を十分に狭めるから、人間の複雑さが爆発しないみたいな。それはあり得ると思います。

kateinoigakukun:ありがとうございます。

fujimura:イミュータブルだと思っていた言語の標準ライブラリをのぞいてみると、unsafeってめちゃくちゃ書いてあるの見たことある(笑)。Haskellで見たやつでしたね。ちょっと質問を回してみようと思います。

Reactorをよくするため

hogelog:コメントを読むとjoker1007さんから、「並行並列の問題にかかわらず、イミュータブルなことが保証されているオブジェクトは欲しいことが結構ある」とか、Shiaさんも「支持する層がいるかもですよ」とコメントがあります。

僕もふと思うのは、イミュータブルな文字列にするために.freezeってつけなきゃいけなくて、一生懸命freezeつけて回ってる人とかいたよなって思って。なので意外と、イミュータブルなものをRubyの中でやりやすくするのは欲しい人がいるのかもなと思いました。

ko1:そうですね。全部はできないんだけれども、ユーザーが簡単にできるようにする、できるように指定するのは、考えられるかもしれません。それに関して2つ、Rubyには機能が入ったのと、これから入るものがあります。

Ruby3.0で、定数に入るオブジェクトを全部freezeする仕組みが実は入ってるんですよ。なので、それを使うと今の話は、frozen-string-literalよりも厳しいものを使うことはできるかと思います。それからRuby3.2からは、Dataクラスが入る予定です。それはStructのイミュータブル版っていわれていて、セッターのないStructが多分一番わかりやすいと思うんですけど、それがこれから入る予定になっています。

hogelog:ありがとうございます。並列並行みたいなところを思うと、Rubyでもマルチプロセスのプログラムを書くことってよくあると思います。マルチプロセスであっても、コピーオンライトが走って、メモリの大部分は共有できて動く。しかもRactorじゃなくても、並列に動く、コアを使い切ることができるんですけど、それでもスレッドでやりたくなる? 実際RailsでもPumaがあって、Unicornがあるわけですけど、どれぐらいからスレッドを使いたくなるのか、その数字の肌感ってあったりしますか?

ko1:forkでよければ、fork使っていいんじゃないですかっていうのが私の立場です。そのうえで性能に問題があるとか、プロセスで扱うのはちょっと大変なので、一プロセスの中にあったほうが情報が見やすいとか、管理情報を集約しやすいというのがあると思います。あとコピーオンライトでメモリ共有するといっても限界があるので、共有するという仕組みに関しては、やっぱりスレッドのほうが性能的なメリットがあるのかなと思います。一応私の立場を表明しておくと、スレッドはあんまり使うべきじゃないと思っているので。

一同:(笑)

ko1:スレッド良くなるよっていうふうには、発表では言ってるんだけど、これはRactorがよくなるために作ってるって言ってなかったのかな。Ractorをよくするのが目標なので、RactorがIOとか、ちょっぱやになるのが目標になっているので、スレッドはなるべく使わないほうがいいんじゃないですかねっていうのは、併せて言っておきます。

hogelog:僕らはスレッドを使っていたりするわけですけども、ちょっと危ない橋を渡っているのかどうか(笑)

ko1:時々事故りますよね。

hogelog:そうですね。難しいよなって。Pumaとか、Sidekiqとかを使ってることもあるので、なかなか難しいです(笑)。

ko1:大抵うまくいくものが、いかないんですよね。いかないというか、99%大丈夫だから、テストでも引っかかないし、気づけないんだけども、1年に1回まずい問題が起こるみたいなのが入っていて、その1年に1回がセキュリティリスクのある問題だったら、やばいですよねっていう。

hogelog:楽天的なことを言うと、joker1007さんもコメントに書いてますけど、PumaとかSidekiqって、今だとスレッド数は、人間が何となく10スレッド、何スレッドぐらいかな、コア数にみたいなことをやっている。M:Nスレッドが超理想的に動くとしたら、PumaやSidekiqのスレッド数をすごいたくさんの数字にしても、裏でM:Nスレッドの仕組みがうまく動いて、たくさんのワーカーが動く感じになるんですかね。

ko1:そうですね。Ractorを十分にサポートすれば、M:Nで効果が出てくるという話になると思いますが、Sidekiqの上で動かすプログラム、ActiveRecordでアクセスできないと話にならないと思うんですけど、多分ActiveRecordをRactor対応させるのはすごい大変だと思うんで、だいぶ先ですよね。

なのでRactorに関しては、実はあんまり楽観できないというか、遠い目標だと思っていて。小さなプログラム、小さなちょっとしたワーカーですね。独立した何かみたいなのをちょっと外に出す、並列に動かすみたいなのには使いやすいのかなと思って、そういうところから、ちょっとずつ使っていくといいんじゃないかなと思っています。で、一応言っておくと、Ractorを使わないと1:Nスレッド、いわゆるグリーンスレッドっていわれるものになるので、性能的なメリットはあまりないです。細かい話をすると、状況によってはネイティブスレッドをたくさん使うようにもなるんで、Ruby1.8であったような、本当の1:Nスレッドではないんですけれども、コンセプトとしては1:Nスレッドになりますね。ただのグリーンスレッドですね。

hogelog:なるほど。そうなんですね。ありがとうございます。

OSのスレッドと言語処理のスレッド

shyouhei:僕が最初聞こうかなと思ってたのは、笹田さんがいつも、fork使えばいいじゃんと言うにもかかわらず、今回はM:Nスレッドを持ってきたんで、どういうモチベーションでこの機能を新しく作り込んだのかを聞きたかったんだけど、それに関しては、愛用しているRactorを速くしたいみたいな話だったのかなと思いました。

ko1:それもあるんだけど、そもそも例えば100万とか作ると、ネイティブスレッドで作れないので、100万Ractorを作ることを考える。なので、最終的にはM:Nスレッドにするしかなかったっていう感じですね。

shyouhei:M:Nスレッド、結構難しいじゃないですか。かつ、ずっと昔のSolarisとかは、やろうとしたけど結局うまくいかなくって、1:1に戻しましたみたいな話もあったと思うんですよね。それは何で難しいかっていうと、スケジューラーがカーネルの中にあって、それとは別にユーザーランドのスケジューラーもあって、協調させて動かすのが難しいという話でした。今回はそれを頑張ったという話なのか、それとも僕が今話したのは90年代から2000年代初頭ぐらいの話なので、20年くらい経って理論的な進捗があったという話なのかがちょっと気になりました。どうですか?

ko1:OSのM:Nスレッドがぽしゃった、例えばFreeBSDにおけるKSE*1みたいなのをやらなくなったのは、M:Nスレッドにするための労力が1:1スレッドで頑張る労力に合わなくなった。1:1スレッドのほうがシンプルだし、バグもなくて簡単で、頑張れば頑張るほど速くなる。で、M:Nスレッドをちゃんと対応しようとすると、手がたくさんかかってしまう問題があった。で、2つのスケジューラーを上手に協調させるのが難しかった。

shyouhei:例えばアフィニティだったり、プライオリティだったりを正しく動かすのは超難しかったっていう印象。

ko1:そこはあまりないかな。そういう意味だと、言語処理系が提供するスレッドやgoroutineはOSが提供する、もしくはptheadが提供するスレッドよりも、機能を諦めていい部分が結構あると思うんですよね。できないから、できない。例えばGoでは、こういうことはできないけども、それはGoでできることをみてやること、仕様を決めることができる。その辺をこのOSが提供するやつでいうのは「いや、うちではこれは仕様ではこう書いてあるけど、できないんですよ」って言いづらかったのはあるだろうなって。

一同:(笑)

ko1:だから取捨選択がしやすかった。で、Goなどの言語処理系では、最終的に困ったら、もう1:1スレッドのネイティブスレッドに丸投げすることができるんですよね。言語というか、アプリケーションレイヤーでスレッドライブラリを作ると。そういう割り切りがしやすいので、言語レベルでM:Nスレッドがはやってるのは、そういう理由なのかなと思います。多分そんなこと考えてないと思うんですけど。

shyouhei:(笑)

ko1:きっとできなかったら、そのとき考えるみたいな感じで、ちょっとずつ性能を上げていく感じになるんじゃないかな。RubyにおけるM:Nスレッドに関しても、困ったらもう1:1スレッド、ネイティブスレッドに丸投げするっていう実装になるので、そこはもうそんな感じになっちゃうんじゃないかと思います。

M:Nスレッドの制限

kateinoigakukun:M:Nスレッドだとできないけど、ネイティブスレッド、1:Nスレッドだと普通にできるみたいな話は、それはどこのどういう制約があるんですか?

ko1:ユーザーレベルでスケジューラーを作るときに使えるものって、ほとんどIOしかないんですよね。その切り替えるタイミングはIOと言ってもタイムスライスぐらいしかない。IOにおいてもハードディスクが壊れていた。で、ハードディスクが壊れていて、アクセスすると5秒かかるみたいなことも時々あるじゃないですか。何か壊れてるなって感じで、がちゃがちゃっていって、何か起こるかなと思ったら、5秒後に返ってくるみたいな。

kateinoigakukun:嫌だな。

ko1:それってユーザーレベルでは検知できないんですよね。これ読めますか、すぐ返ってきますかって聞くんだけど、返ってきますって言うんですよ、OSは。

kateinoigakukun:(笑)

ko1:だけど読んでみたら、5秒かかる、もしくはもう無限に待つみたいな。それって、もうユーザーレベルスレッドでは無理なんですね。そういうときには、goroutineなり、今回作ってるM:Nだと、もう無理だってネイティブスレッドをもう一個作っちゃうわけですね。そんな感じで、スケジューリングに対する制限はいろいろあって、できることしかやらないっていうアプローチをしている感じです。

ほかの制限として、よくあるのがスレッドローカルストレージですね。ネイティブスレッドに依存する機能は、使えなくなります。すごい単純な話だと、gettid(2)っていうネイティブスレッドのIDを返してくれるLinuxシステムコールがあります。Rubyもそれを間接的に使う仕組みがありますが、M:Nだとネイティブスレッドを切り替えながらになるのでその結果は安定しないんですね。このタイミングで呼んだら1が返って、このタイミングで呼んだら3が返ってくるみたいな、そういうことが起こる。それは使えない機能ですよね。というわけで、ネイティブスレッドに依存した機能っていうのは、もちろん使えなくなります。

kateinoigakukun:なるほど。そういう環境下で、Ractorローカルなストレージっていうのは、どうやって実装されているんですか?

ko1:Ractorローカルなストレージは作るのが簡単で、現在の動かしてるコンテキストから、現在のどのスレッドで動かしてるかっていうデータ構造へのアクセスは、できるようになっています。で、どのスレッドで動かしているかがわかれば、どのRactorで動かすかがわかるので、要するにRactorがスレッドを管理して、スレッドが実行コンテキストを管理してっていうふうな感じになる。そうすると現在動かしている実行コンテキストの属するRactorが取れるんですね。そうするとそのRactorに属するデータ構造から、Ractorローカルストレージみたいなことはできるので、そこは特に難しい話はないです。

kateinoigakukun:そうですね、確かに。OSレベルでスレッドをM:Nにするのは厳しい、割に合わないという話だったと思うんです。

ko1:推測ですね、これは(笑)。

kateinoigakukun:え(笑)?

ko1:推測です。作ってる人に聞いた話ではないので。

kateinoigakukun:例えば現状のモデルのスレッドへの逃げ道を残しつつ、pthreadのインターフェースとはまた別のインターフェースとして、M:NをOSが提供するみたいなことはあり得るんですか?

ko1:十分あり得る話で、HPC分野では、そういう実装が提案されていたりします。それからpthreadレベルでも、それをやろうと思えばできるんですね。さっき言ったIOで、できる限りではコンテキストスイッチするけれども、そうじゃないときには、もうネイティブスレッドに逃げることをやればできると思うので、少なくとも90年代にはそういう提案がありました。Solarisとか、FreeBSD以外でもありましたが、今はやってないのは正直、1:1で作ったpthreadが十分速いんじゃないかっていう気もします。

kateinoigakukun:(笑)

ko1:それ以上やるんだったら、スレッド、いわゆるpthreadみたいなヘビーウェイトなスペックなんだけども、そうではなくてErlangのプロセスとかgoroutineみたいな、用途に特化した小回りの、手軽なデータ構造にしていくしかないのかなと思っていて。

更に性能を出すんだったら、そこにおけるスケジューリングも、言語とか、アプリケーションレベルで書き換えるんじゃないですかね。何か適したものを用意する必要があって、そうするとあんまりシステム標準で、そういうのが1個ぼーんってあるメリットがないのかもしれないなというのは推測です。

kateinoigakukun:なるほど。

ko1:ただ昔、生成が遅いって聞いたことがあって、私もpthreadの生成が遅いから、M:NやったらきっとRubyのスレッド生成速くなるんだろうなと思ったんですけど、実際にやってみると、スレッドを作るのが超速くて。グラフ書いたら、ほら、100倍速くなったでしょとか言えるかなと思ってやってみたら、そんなに速くならなくて。

kateinoigakukun:(笑)

ko1:2倍、3倍ぐらいしか速くならなくて、2、3倍だったら、もう安定したもの使えばいいじゃんっていうのは、選択肢としてはあり得ますよね。実際に、何十万並列、何十万並行処理をやる人がそんなにいないので、あまりまだ気になってないっていう話かもしれないですね。

kateinoigakukun:だから困った人が頑張って実装しなさいという。

ko1:うん。で、Rustではライブラリレベルでやるし、goroutineだったら、goroutine使ってる限りは、こういう十分にうまくいくものがある。ただGoにも、アプリケーションに特化したスケジューラーのほうが、やっぱ究極的には速いから、そっちを作る仕組みを入れましょうみたいな話もあると聞きました。どこに落ち着くんだろうなっていうのは面白そうですね。

kateinoigakukun:ありがとうございます。

fujimura:タイムテーブル的には、このあたりで質疑応答にいってるとこなんすけども、事実上、質疑応答タイムに入っている感じなので、一旦笹田さん、ありがとうございました。

ko1:ありがとうございます。(完)


vol.3に続きます。

深堀りRubyKaigi 2022 文字起こしレポート一覧

記事の感想をお待ちしております!#fukabori_rubykaigi_2022 をつけてツイートしていただけると嬉しいです。

 

イベントの感想まとめ▼

 

STORES Product Blogの更新はTwitter @storesinc_tech でお知らせしています。ぜひフォローしてください!