TECH PLAY

フォルシア

フォルシア の技術ブログ

241

これは、FORCIA Advent Calendar 2021の5日目の記事です。 こんにちは。プロダクト部 技術研究所の大沢です。 私が大学生のころ、Nintendo DSのアソビ大全というゲームソフトに定番ゲームの1つとして収録されていた「ラストワン」というゲームがあり、必勝法がないものか、あるとしたらどうにか導けないか?と気になっていた時期がありました。 何週間か考えていましたが、当時はプログラミングも学びたてで、結局必勝法を編み出すことはできませんでした。 つい最近になってそんなゲームがあったことを思い出し、ふと考え直してみたところ、なんと必勝法を編み出すことができました! その
アバター
これは、 FORCIA Advent Calendar 2021 の4日目の記事です。 はじめまして。2021年度新卒入社エンジニアの高嶋です。 外部に公開される記事を書くのは初めてなので緊張しています。 本稿では表題の通りReactを用いて要素の高さを揃える方法についてご紹介します。私自身が最近業務内でハマった内容なのですが、大変勉強になったと感じたため記事にすることにしました。解決策を出すにあたりアドバイスをくださった先輩方ありがとうございました。 やりたかったことは、React, Reduxを用いたWebアプリのとあるページで内部のデータ数によって高さが可変になるような枠A, Bをそろえて表示することです。 問題の画面を簡易的に表すと以下のようになります。 データの数が枠Aと枠Bで同じ時は良いのですが、異なる場合には高さが揃わなくなってしまいます。 要素同士の高さを揃えたいとき、通常はCSSを使用することをまず考えるでしょう。 しかし、今回は高さをそろえたい要素同士を同一のwrapperで包むことができないという事情がありました。 縦方向に構成されたグループに内包されているため、要素高を統一するための横方向のwrapperで該当要素同士を包むことができません。 そのため、ここではReactを利用して要素高の統一を実現することにしました。 案1:useState, useEffect, getElementsByClassNameの合わせ技 案1があるということは案2もあります。初めにお話ししておきますが案1は失敗に終わります。 まず初めに試したのは、 getElementsByClassName メソッドで要素とその要素高を取得し、 useState フックで要素高のうち高い方を記録、それを該当要素の style に指定することで高さを揃える方法です。また、枠内に表示するデータ数はユーザーの操作によって可変である為、 useEffect フックを用いてその都度要素の最大高の計算し適用します。 // 親コンポーネント内 // ~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ // 対象要素の取得 const frameElementArr : HTMLElement [] = []. slice . call ( typeof document !== " undefined " && document . getElementsByClassName ( " frame-class " ) ); // 要素高を記録させるためのstate const [ frameMaxHeight , setFrameMaxHeight ] = React . useState < number | undefined > ( undefined ); React . useEffect (() => { if ( // 枠内に表示するデータ数を比較 data [ frame1 ]. length !== data [ frame2 ]. length ) { // データ数が異なった場合は枠1, 2の要素高の高い方をframeMaxHeightにセット setFrameMaxHeight ( Math . max (... frameElementArr . map ( elment => elment . offsetHeight )) ); } else { // 大きいエレメントに合わせたまま元に戻らなくなってしまうので、データ数が同じである場合は1度リセットする。 setFrameMaxHeight ( undefined ); } }, [ data ]); return ( < frame data = { data [ frame1 ] } // 要素高の最大値を渡す frameMaxHeight = { frameMaxHeight } /> < frame data = { data [ frame2 ] } frameMaxHeight = { frameMaxHeight } /> ); // frameコンポーネント内 // ~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ return ( < div className = "frame-class" style = { // frameMaxHeightがundefind(リセット状態)の時は高さ指定なしとする frameMaxHeight ? { minHeight : ` ${ frameMaxHeight } px` } : { minHeight : " initial " } } > dataを表示(省略) </ div > ) 枠内のデータ数を減らした場合に frameMaxHeight を減らすことができなくなってしまうので、データ数が同一(style指定なしで描画しても高さが揃う)の場合には useEffect 内で frameMaxHeight を undefined に設定して要素高をリセットしています。 frameMaxHeight が undefined の場合には枠要素のminHeight指定を初期値に指定します。 これで一見うまくいったかのように思えたのですが、実は初期描画時は枠A、Bの要素高を揃えることができません。(私の場合、初期描画時はデータ数が同一、ユーザー操作でデータ数が不均一になるような状況だったので初めは気づきませんでした) この部分に着目してください。親コンポーネントの初回レンダリング時に子コンポーネントはまだレンダリングされていません。そのため、子コンポーネント内の要素の参照が取れず要素高を取得できません。 // 対象要素の取得 const frameElementArr : HTMLElement [] = []. slice . call ( typeof document !== " undefined " && document . getElementsByClassName ( " frame-class " ) ); ちなみに、再レンダリング時も同様の状況になるのではと考えたのですが、 HTML DOM 内の HTMLCollection は生きて (live) います。それらは元になった document が変更された時点で自動的に更新されます。 https://developer.mozilla.org/ja/docs/Web/API/HTMLCollection より、親コンポーネントを再レンダリングする際にはすでに前のレンダリングにより枠A, B自体はDOMに存在しており HTMLCollection の取得に成功、子コンポーネントの再レンダリングが走り枠A, Bが再描画されたとしても参照は自動的に更新されるため要素高が取得可能だったのだと考えています。初期描画の場合には getElementsByClassName() の戻り値が undefind だったため、子コンポーネントがレンダリングされても参照が更新されなかったのではないでしょうか。 案2:useState, useEffect, useRefの利用 案1の段階で原因すら分からず困っていたところで、useRefフックを使用した本方法について教えていただきました。 useRef フックについて、要素の操作に利用できるフックだというなんとなくの知識はあったのですが、正直DOMオブジェクトのメソッドを利用する場合との違いが分かっていませんでした。refについて理解する上でも大変参考になったため共有します。 コードとしては以下のようになります。 // 親コンポーネント内 // ~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ - // 対象要素の取得 - const frameElementArr: HTMLElement[] = [].slice.call( - typeof document !== "undefined" && - document.getElementsByClassName("frame-class") - ); + // useRefで参照を持つ + const frame1Ref = React.useRef<HTMLDivElement>(null); + const frame2Ref = React.useRef<HTMLDivElement>(null); // 要素高を記録させるためのstate const [frameMaxHeight, setFrameMaxHeight] = React.useState< number | undefined >(undefined); React.useEffect(() => { if ( // 枠内に表示するデータ数を比較 data[frame1].length !== data[frame2].length ) { // データ数が異なった場合は枠1, 2の要素高の高い方をframeMaxHeightにセット - setFrameMaxHeight( - Math.max(...frameElementArr.map(elment => elment.offsetHeight)) - ); + // frame1Ref.current, frame2Ref.currentが参照可能になったら比較を行う + if (frame1Ref.current && frame2Ref.current) { + setFrameMaxHeight( + Math.max(frame1Ref.current.offsetHeight, frame2Ref.current.offsetHeight) + ); + } } else { // 大きいエレメントに合わせたまま元に戻らなくなってしまうので、データ数が同じである場合は1度リセットする。 setFrameMaxHeight(undefined); } - }, [data]); + }, [ + data, + // refから得られる要素高を依存配列に入れておく + frame1Ref?.current?.offsetHeight, + frame2Ref?.current?.offsetHeight + ]); return ( <frame data = {data[frame1]} // 要素高の最大値を渡す frameMaxHeight = {frameMaxHeight} + frameRef = {frame1Ref} /> <frame data = {data[frame2]} frameMaxHeight = {frameMaxHeight} + frameRef = {frame2Ref} /> ); // frameコンポーネント内 // ~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ return ( <div className="frame-class" + ref={frameRef} style={ // frameMaxHeightがundefind(リセット状態)の時は高さ指定なしとする frameMaxHeight ? { minHeight: `${frameMaxHeight}px` } : { minHeight: "initial" } } > // dataを表示(省略) </div> ); getElementsByClassName メソッドの代わりに useRef フックを利用して要素を参照します。前者の場合、DOM構築済みの要素から参照を取ってきますが、後者は生成したrefを渡してレンダリングが行われるため、要素がレンダリングされた後に参照することが可能になります。 また、初期描画においては要素高が数段に分けて変化していく様だったので、 useEffect の依存変数に ref から参照した要素高も含めています。 今回初めてuseRefフックを利用したのですが、レンダリング状態に左右されず要素に参照を通すことができReact内で要素を操作する際に非常に便利だと感じました。
アバター
これは、FORCIA Advent Calendar 2021の4日目の記事です。 はじめまして。2021年度新卒入社エンジニアの高嶋です。 外部に公開される記事を書くのは初めてなので緊張しています。 本稿では表題の通りReactを用いて要素の高さを揃える方法についてご紹介します。私自身が最近業務内でハマった内容なのですが、大変勉強になったと感じたため記事にすることにしました。解決策を出すにあたりアドバイスをくださった先輩方ありがとうございました。 やりたかったことは、React, Reduxを用いたWebアプリのとあるページで内部のデータ数によって高さが可変になるような枠A, Bをそろ
アバター
これは、 FORCIA Advent Calendar 2021 の3日目の記事です。 どうもこんにちは。 タブをスペースに変換するのを忘れてpsqlに怒られがちな新卒2年目の駆け出しエンジニア吉田です。(Web業界に2年近くもいて「駆け出し」を名乗ってよいものか微妙ですが、強気に主張していきます) 私はここ一年間、大規模旅行アプリの開発に携わってきました。旅行アプリの検索機能は、フォルシアの主力事業であるSpookが最も威力を発揮する領域でもあります。 Spookは「膨大で複雑なデータ」を「高速」に検索するための技術基盤です。(参考: https://www.forcia.com/service/spook/) 参考記事にもある通り、Spookの高速検索は「独自の検索最適化技術」と「これまで培ってきたノウハウ」の合わせ技の上に成り立つものです。技術とノウハウを駆使して高速なDB検索をすることがフォルシアエンジニアの仕事の一つ... なのですが、駆け出しエンジニアの私にはまだノウハウが備わっておらず、自分の実装した部分はデータ量を増やすととても重い、検索に失敗するなどの問題が次々に勃発... これを解決すべく、私はかなりの期間クエリとにらめっこし、修正やチューニングに時間を割くことになりました。 本記事は、そんな私がこの一年間クエリ改善活動を行う中ではまった、SQLの「沼」についてまとめたものになります。ドがつくほどの基本的な内容から笑える(笑えない)ミスまで5選用意しましたので、誰かの何かの参考になれば幸いです。 ※「SQLの」と題していますが、私がアサインされたプロジェクトで利用しているPostgreSQL 12系(以後postgresと記述します)での話となるため、RDBMS全体に拡張できない話が含まれることをご承知おきください。 その1 hash joinだから大丈夫!と思っていたら...? 問題 ある日、いつものようにスロークエリをexplain analyzeしていたところ、どうやらテーブルの結合処理(join)に問題があるらしいことがわかりました。 postgresの結合方式はnested loop join, merge join, hash joinの3種類あり、プランナがその時々に応じて最適な結合方式を選んで実行するのですが、今回のケースではhash joinが選択されているようでした。 原因 hash joinは 1. 内部表の結合キーのハッシュテーブルを作成し 2. 外部表の結合キーのハッシュを突き合わせる という方式で実行されるため、内外それぞれ1回だけテーブルが走査されることになり、インデックスの有無にかかわらずある程度速いはずの結合方式になります。 一方でハッシュテーブルのサイズが利用可能なメモリより大きくなってしまう場合、ディスク書き出しが発生し非常に遅くなる可能性もあります。 今回のケースはまさにこれがスロークエリの原因でした。初期開発時はレコード数が小さく無視できていた非効率な処理が、試験時にまとまったデータが用意されたことで顕在化した形になります。 無駄な結合をなくす、結合前に絞り込んで対象レコード数を減らす、などの対応でこれらは解決できました。 その2 set句を付けたのに反映されない! 問題 SQLにはset句というものがあり、実行時のパラメータを制御できます。 例えばひとつ前の問題でプランナが選択する結合方式の話をしましたが、 例えばクエリの実行前に set enable_nestloop to off; と記述することでnested loop join以外の結合方式を強要する、というような使い方ができます。 何らかの理由でプランナが最適な実行計画を選んでくれない状態になった場合など、応急処置的にset句を付与することはよくあり、私のアプリでも何箇所かに利用されています。 ところがある日、いつものようにスロークエリをexplain analyzeしていたところ、set句を付与したのにもかかわらずそれがクエリに反映されていない、という事態に気付いてしまいました。 原因 結論としては、「set句とset句を付与したいクエリが別のトランザクションで実行されていた」ことが理由でした。 set句には session/local というパラメータが指定でき、それぞれset句を同一セッションまたは同一トランザクションに限定できます。 今回のケースではクエリを実行する前に set local ~ という形でパラメータ制御を付与しており、これが記述されたファイルをpsqlというpostgres公式のクライアント経由で実行していました。 psqlから外部SQLを実行する場合には psql -f (ファイル名) というようなコマンドを実行します。ここで実行されるSQL文は単一トランザクションで実行されないため、SQLファイル先頭のset句とクエリが異なるトランザクションで実行されてしまっていまい結果としてset句の制御がクエリに適用されない、という現象が起きていました。 解決策としては set session とする、クエリを begin; ~ commit; で囲って同一トランザクション内で実行する、psqlの実行オプションに --single-transaction を指定する、など色々あります。 ※完全に余談ですが、「nested loop joinは完全にoffにできない」ことが理由の場合も(まれですが)考えられます。 set enable_nestloop to off; とした場合の挙動は、nested loop joinのcostを極端に大きくすることで選ばれなくする、というもののため、それでも選ばれてしまう可能性はあります(例えばenable_nestloop, enable_hashjoin, enable_mergejoinを全てoffにするとnested loop joinが選択されます)。 その3 どうしてそんなにのろいのか join編 さっきからこの人joinの話しかしてないですね。 問題 ある日、いつものようにスロークエリをexplain analyzeしていたところ、大量のテーブルをjoinする部分に時間がかかっていることがわかりました。 今回のケースでは各テーブルのレコード数はそこまで多くなく、結合した結果が膨れ上がるようには思えないテーブル条件にもかかわらず、条件から考えて不可解な時間がかかっていました。 原因 基本的にjoinの順序はpostgresのプランナが最適なものを選んでくれるため、SQL文を書く際にあまり結合順は意識しません。 しかし、結合順序を計算するコストが高過ぎる場合はSQLの記述順に結合されます(基本的に全探索で最もコストの低い結合順が選ばれるため、計算コストは指数オーダーで増えます)。 この「コストが高すぎる」判定として join_collapse_limit というパラメータが使用されており、最終的に結合順の計算対象となるテーブル数がこの値を超えた場合は結合コストの計算が行われず記述順に結合されます(デフォルトでは8となります)。 今回の対応としては、試しによさげな結合順を試してみたところ想定通りの速度でクエリが返ってくるようになったため、 join_collapse_limit の設定値は変更せずクエリ側の修正のみで解決できました。 その4 どうしてそんなにのろいのか and編 問題 ある日、いつものようにスロークエリをexplain analyzeしていたわけではないのですが、試験中にアプリを操作していたところ、とても簡単な条件なのにレスポンスが時間内に返ってこないという問題が発生していました。 原因 いつものようにスロークエリをexplain analyzeしようと思ってクエリをログに出力してみたのですが、その結果 select (columns) from table where column > 0 and column > 0 and column > 0 and column > 0 and column > 0 and column > 0 and column > 0 and ... というクエリが(アプリのバグによって)発行されていることが判明しました。 今回のケースはアプリのバグを修正して事なきを得たのですが、後々気になっていつものようにスロークエリをexplain analyzeで調べたところ、(当たり前と言われればそうですが)同じ条件であっても記述された回数評価されているようだ、ということがわかりました。 極端すぎる例だったかもしれませんが、条件の重複がクエリパフォーマンスの悪化を招いている可能性はアプリのロジックだけを追っていると意外と気付きにくいのではないか、という点ではよい教訓になりました。 その5 計算できるレコードに絞ったのに計算できないぞ? 問題 ある日、いつものようにスロークエリをexplain analyzeしようと思って遅いAPIにリクエストを投げたところ、500エラーが返ってきました。 このリクエストでは以下のようなクエリを実行していました。 -- Step.1: where句で計算が可能なレコードを絞る with target as ( select id from table where [keisan is dekiru] ) -- Step.2: 計算が可能なレコードに対して実際に料金を計算する ,calc as ( select id ,[keisan] as calc from plan inner join target using (id) ) -- Step.3: Step.1 に Step.2 を結合する select id ,calc from target left join calc using (id) エラーログを確認したところ、最初にwith句で計算可能なレコードに絞ったにもかかわらず「計算できないレコードに対して計算しようとしている」ということがわかりました。 原因 今回のケースの特徴は、異なるCTEで何度も同じテーブルを参照するような作りになっている点です。 postgresのプランナが最もコストの小さい実行計画を作る際、このカラムは同じタイミングで取るのが最も適当だと判断した場合は副問い合わせをまたいだ最適化が行われる可能性があります。 また、postgres 12からはCTEのmaterialize(一時テーブルの実体を生成する処理)がデフォルトでoffとなっており、CTEをまたいだクエリの最適化が行われる可能性があります。 さらに今回の場合、Step.1とStep.2のレコードは一対一対応しているにもかかわらず、Step.3で内部結合していない点も問題でした。 これらの要素が重なった結果、Step.2のpriceを計算する部分が先に評価されてしまい、Step.1の絞り込み対象外のレコードに対して実行された結果エラーとなったことがわかりました。 要するにこの記事を通して私が言いたかったことは クエリチューニングをする際はexplain analyzeをしましょう!ということです。 SQLにはexplainという実行計画を問い合わせる句がありますが、postgresの場合はanalyzeというパラメータを付与することで、実際にクエリを実行した結果の情報も含めて取得できます。 今回の例では紹介しませんでしたが、例えば実行計画で見積もった行数と実際に取得できた行数に乖離がある場合などは、プランナの利用する統計情報が実態に即していないことがスロークエリの原因になっていることがわかったりもします。 十八史略にも「先づexplain analyzeより始めよ」とある通り、クエリ改善における最初の一手はexplain analyzeです。迷ったらexplain analyze。困ったらexplain analyze。何はともあれexplain analyze。 そんなわけで、今日も元気にスロークエリをexplain analyzeしてこようと思います。皆さんもよき検索ライフを!
アバター
これは、FORCIA Advent Calendar 2021の3日目の記事です。 どうもこんにちは。 タブをスペースに変換するのを忘れてpsqlに怒られがちな新卒2年目の駆け出しエンジニア吉田です。(Web業界に2年近くもいて「駆け出し」を名乗ってよいものか微妙ですが、強気に主張していきます) 私はここ一年間、大規模旅行アプリの開発に携わってきました。旅行アプリの検索機能は、フォルシアの主力事業であるSpookが最も威力を発揮する領域でもあります。 Spookは「膨大で複雑なデータ」を「高速」に検索するための技術基盤です。(参考: https://www.forcia.com/tech
アバター
これは、 FORCIA Advent Calendar 2021 の1日目の記事です。 エンジニアの松本( @matsu7874 )です。 FORCIA CUBEには Rustやサマーインターンの記事 を書くことが多いです。 さて、Rustを導入する際、直ちにシステム全体をRustで書き直すのではなく、既存資産を有効活用しながら開発を進められます。 この記事ではFFI(foreign function interface)を使って既に書かれたプログラムを活用しながら、一部をRustに置き換えていく方法について解説します。 特に次の2つのパターンに分けて解説します。 A: C言語やPythonで書かれた一部のモジュール(典型的には速度や安全性が重要な部分)をRustに置き換えたい。 B: 主な実装はRustに変更するが、部分的に別の言語で書かれたモジュールを活用したい。 ディレクトリ構成 作業ディレクトリ直下に下記のディレクトリ・ファイルが置かれているとして以降お読みください。 説明のため、 legendary_c_lib と modest_rs_lib_for_c には、正の整数a,bを受け取り、それらの最大公約数を返すgcd関数を実装しています。 ソースコードは こちら からダウンロードできます。 - legendary_c_lib/ 手が入れられないC言語のライブラリ - legend.c - main_c/ C言語で実装された既存のアプリケーションコード - main.c - main_py/ Pythonで実装された既存のアプリケーションコード - call_c.py - call_rs.py - main_rs/ Rustで新たに実装される legendary_c_lib を呼びだすコード - src/main.rs - build.rs - Cargo.toml - modest_rs_lib_for_c/ Rustで新たに実装される main_c, main_py から呼びだされるコード - src/lib.rs - Cargo.toml パターンA: C言語やPythonからRustを使う C言語から使ってもらえるようにRustを書く まずはC言語で実装されているアプリケーションから、新しくRustで実装するモジュールを使ってもらえるようにしましょう。 端的にいうとRustで実装したコードを共有ライブラリにして、C言語側のビルド時にリンクします。 cargo new --lib modest_rs_lib_for_c から lib.rs に下記のコードを書きます。 // modest_rs_lib_for_c/src/lib.rs #[no_mangle] pub extern "C" fn gcd ( a : u64 , b : u64 ) -> u64 { let mut x = a ; let mut y = b ; if x < y { let t = x ; x = y ; y = t ; } while y > 0 { let t = x % y ; x = y ; y = t ; } x } #[test] fn test_gcd () { assert_eq! ( gcd ( 12 , 4 ), 4 ); assert_eq! ( gcd ( 12 , 3 ), 3 ); assert_eq! ( gcd ( 12 , 7 ), 1 ); assert_eq! ( gcd ( 2 , 70 ), 2 ); } おおよそ普通のRustのコードです。テストコードも普通に書けます。 Rustの世界で完結する場合は関数定義は fn gcd(a: u64, b: u64) -> u64 となりますが、Cから呼びだしたい場合は #[no_mangle] と pub extern "C" をつけます。 こちら(Cと少しのRust - The Embedded Rust Book) に書いてある通りなのですが、コンパイルしたオブジェクトコードをC言語で書かれたプログラムからでも利用できるようにするための宣言です。 続いて Cargo.toml を編集し、 crate-type を cdylib にします。 # modest_rs_lib_for_c/Cargo.toml [lib] crate-type = ["cdylib"] cargo build --release でコンパイルすると target/release/modest_rs_lib_for_c ではなく target/release/libmodest_rs_lib_for_c.so が作られます。 このファイルをリンクすることでC言語側からRust実装の関数を呼びだすことができます。 C言語側の実装を見ていきましょう。 main_c/main.c はどこかにある gcd という関数を呼びだすだけのコードです。 //main_c/main.c #include<stdio.h> // 関数定義のみ int gcd ( int a , int b ); void main (){ printf ( "%d \n " , gcd ( 12 , 8 )); } 例えば ../legendary_c_lib/legend.so に gcd の実装があるとすれば次のコマンドでコンパイル・実行が可能です。 gcc main.c ../legendary_c_lib/legend.so -o main_c.o 実行すれば正しく 4 が表示されます。 ./main_c.o リンクするオブジェクトをRust実装に切り替えましょう。下記のコマンドでコンパイル・実行ができます。 gcc main.c ../modest_rs_lib_for_c/target/release/libmodest_rs_lib_for_c.so -o main_rs.o ./main_rs.o C言語実装のgcd関数を使ったときと同じように 4 と出力されることを確認できると思います。 これでC言語からRustで書かれた関数を呼びだすことができました。 PythonからRustを呼ぶ(ctypesを使ってPython側で面倒を見る) PythonからはCで実装された関数を呼びだすことができ、上記の方法で作った共有ライブラリは同じ方法で、Pythonからも使うことができます。 ctypes --- Pythonのための外部関数ライブラリ -- Python 3.10.0b2 ドキュメント PythonからC言語で実装された関数を実行する例を示します。 # main_py/call_c.py import ctypes legend = ctypes . CDLL ( '../legendary_c_lib/legend.so' ) legend . gcd . argtypes = [ ctypes . c_int , ctypes . c_int ] legend . gcd . restype = ctypes . c_int assert legend . gcd ( 120 , 16 ) == 8 同じようにRustでCから使えるように作った共有ライブラリをPythonから使うことができます。 # main_py/call_rs.py rust = ctypes . CDLL ( '../modest_rs_lib_for_c/target/release/libmodest_rs_lib_for_c.so' ) rust . gcd . argtypes = [ ctypes . c_int , ctypes . c_int ] rust . gcd . restype = ctypes . c_int assert rust . gcd ( 120 , 16 ) == 8 PythonからRustを呼ぶ(PyO3を使ってRust側で面倒を見る) 上記の方法ではPython側で関数の引数や戻り値の型を明示するなど面倒なことがありました。 Python側にあまり手を入れたくない場合Rust側でもう少しカバーすることができます。 PyO3/pyo3: Rust bindings for the Python interpreter ずばりサンプルそのままなので説明は割愛しますが、 pymodule に pyfunction を詰め込んでいく形が分かりやすいと感じました。 maturin を使わない場合は target/release/string_sum.so を target/release/libstring_sum.so にリネームして sys.path に target/release を追加すると import string_sum ができるようになります。 パターンB: Rustから既存資産を使う RustからC言語で実装された関数を呼びだす 続いてRustからC言語で実装された関数を呼びだす方法を説明します。端的に言うと、C言語側でアーカイブライブラリを作成し、rustcでコンパイル時にリンクします。 ※ こちら(RustからCを呼ぶ - Embedded Rust Techniques) で解説されているように rust-lang/rust-bindgen を使ってC言語の実装からRust側のインターフェイスを自動作成する方法もありますが、原理を理解するために自分の手で実装します。 ※ 『実践Rustプログラミング入門』 や Rustと少しのC - The Embedded Rust Book などで cc - crates.io: Rust Package Registry を使って、Rust側のbuild時にC言語側のコンパイルをする方法が紹介されていますが、やはり何が行われているかを理解するために、最低限必要なリンク処理を明示的に実装します。 C言語側でアーカイブファイルを用意します。 gcc -c legend.c -o legend.o ar crs liblegend.a legend.o 続いて cargo new main_rs でクレートを作成し、 main_rs/src/main.rs を編集します。 // main_rs/src/main.rs extern "C" { fn gcd ( a : i32 , b : i32 ) -> i32 ; } fn safe_like_gcd ( a : i32 , b : i32 ) -> i32 { let mut res = 0 ; unsafe { res = gcd ( a , b ); } res } fn main () { let mut res = 0 ; unsafe { res = gcd ( 12 , 8 ); } assert_eq! ( res , 4 ); println! ( "{:?}" , res ); res = safe_like_gcd ( 12 , 8 ); assert_eq! ( res , 4 ); println! ( "{:?}" , res ); } extern "C" のブロック内で定義されるC言語実装の関数はRustコンパイラの検証を受けていないので unsafe ブロックの中でしか使えません。呼びだしごとにunsafeが登場しては不便ですから、 safe_like_gcd のような内部に unsafe を押し込めたラッパー関数を書くことがあります。 ビルド時にリンクをする部分を見ていきましょう。 Cargo.toml でビルドスクリプトを設定します。 [package] build = "build.rs" main_rs/build.rs では liblegend.a をリンクするように下記の記述を追加します。 // main_rs/build.rs fn main (){ println! ( "cargo:rustc-link-search=native=/path/to/legendary_c_lib" ); println! ( "cargo:rustc-link-lib=static=legend" ); } ビルドスクリプトにおいて cargo: で始まる行はCargoを制御するための行であり、今回はリンクしたいオブジェクトの親ディレクトリのパスとリンクしたいライブラリの名前を指定しています。 rustcを直接実行する場合のコマンドで表現すると下記と同じ意味合いです。 rustc src/main.rs -L ../legendary_c_lib -llegend ./main ではCargoでコンパイル・実行してみましょう。 cargo run --release 4 4 gcd , safe_like_gcd それぞれが4を返しており期待通りに動作していることが確認できました。 より詳しく知りたい人は下記の資料をご確認ください。 FFI - The Rustonomicon Build Scripts - The Cargo Book おわりに 本記事ではC言語からRust、PythonからRust、RustからC言語で実装された関数を呼びだす方法について解説し、より詳しい資料へのリンクを提供しました。 これからRustで開発を始める皆様の助けになり、Rustコミュニティが盛り上がっていくことを願います。 今月末に弊社が運営している RustのLT会 Shinjuku.rs #19 @オンライン がございますので、なにかやってみたという方はぜひご参加いただければと思います。 また、明日以降も FORCIA Advent Calendar 2021 の記事が公開されますので、ぜひご覧ください。
アバター
RustでFFIを使う・FFIでRustを使う これは、FORCIA Advent Calendar 2021の1日目の記事です。 エンジニアの松本(@matsu7874)です。 FORCIA CUBEにはRustやサマーインターンの記事を書くことが多いです。 さて、Rustを導入する際、直ちにシステム全体をRustで書き直すのではなく、既存資産を有効活用しながら開発を進められます。 この記事ではFFI(foreign function interface)を使って既に書かれたプログラムを活用しながら、一部をRustに置き換えていく方法について解説します。 特に次の2つのパターンに分
アバター
こんにちは、第2旅行プラットフォーム部エンジニアの力石です。 早いものでもう12月、年々時間の流れが早くなっているような気がします。 さて、12月といえば毎年恒例になりつつあるアドベントカレンダーですね! 過去のアドベントカレンダーはこちらから! 2020年: https://www.forcia.com/blog/advent-calendar2020/ 2019年: https://www.forcia.com/blog/advent-calendar2019/ 2018年: https://www.forcia.com/blog/advent-calendar2018/ アドベントカレンダーとは 元々はキリスト教において、待降節(advent)にクリスマスまでカウントダウンするためのカレンダーで、毎日1つずつ日付の窓を開き、中に入っているお菓子や小さな贈り物を楽しむものが一般的です。 この風習になぞらえて、インターネット上では12月1日からクリスマスまでの25日間、特定のテーマや団体に関するブログ記事を毎日1件ずつ、持ち回りで投稿するお祭りが様々なサイトで開催されています。 FORCIAアドベントカレンダー2021始まります! 明日12月1日より毎日、フォルシア社員計25人が記事を投稿します。エンジニア以外にも、営業や広報の社員も参加し、幅広いテーマの記事を公開していきます! 記事のテーマを少しだけお見せすると、以下の様なものがあります。 TypeScriptでの型パズル SQL高速化 フォルシアの文化について 新しい記事が公開されたら、下記の特集ページに追加していきます。 FORCIA アドベントカレンダー2021 また、更新情報はフォルシアのSNSでも告知しますので、ぜひフォロー&チェックしてくださいね。 Twitter: @forcia_pr Facebook: @forciapr それでは、明日からの更新をお楽しみに!
アバター
今年もこの時期がやってまいりました!FORCIAアドベントカレンダー2021始まります! こんにちは、第2旅行プラットフォーム部エンジニアの力石です。 早いものでもう12月、年々時間の流れが早くなっているような気がします。 さて、12月といえば毎年恒例になりつつあるアドベントカレンダーですね! 過去のアドベントカレンダーはこちらから! 2020年:https://www.forcia.com/blog/advent-calendar2020/ 2019年:https://www.forcia.com/blog/advent-calendar2019/ 2018年:https:
アバター
はじめに 第1旅行プラットフォーム部エンジニアの六車と申します。 大手旅行代理店の検索サイトの構築をメイン業務としつつ、社内のコンテナ・クラウド活用推進活動も行っています。 この記事では、社内コンテナ推進活動の一環で行ったDockerfileの書き方のベストプラクティスのまとめを紹介します。 この記事のゴール:効率的かつ保守性の高いDockerfileの書き方を知る。 Dockerfileとはなんぞや Dockerfileはずばり 「docker imageを作るための設計図」 のようなものです。 Dockerfileはdocker imageを自動構築する際に必要となるファイルで、1image = 1Dockerfileと対応します。 以下がDockerfileの例です。 FROM ubuntu:20.04 COPY . /app RUN make app CMD python /app/app.py DockerfileはFROM, COPY, RUNなどの命令文で構成されています。何のimageをもとに何を実行してどのようなimageを構築するか、といった内容が書かれています。実行時は上から順番に読み込まれます。Dockerfileを見ればimageの構築過程を確認することができます。 Dockerfileのカレントディレクトリで docker build -t <IMAGE:TAG> . とすれば、Dockerfileが読み込まれ、imageが構築されます。例えばforciaというimageを2021というタグを付けて作りたいとき docker build -t forcia:2021 . とします。tagをつけない場合自動で:latestというタグが付与されますが、必ずtagをつけたほうが良いとされています。誰が見てもversionを確認できるためです。 Dockerfileをきちんと書くべき理由 DockerfileはDocker imageの設計図なわけですが、意外と簡単にかけます。localのファイルをCOPYで持ってきたり、RUNで通常の環境構築と同じように実行すれば、とりあえず動かすことができます。 しかしながら、Docker imageを商用環境で利用する際には以下の点を考慮する必要があります。 再現性 buildするタイミングによってimageの中身が変わらないようにする セキュリティ コンテナに不正に侵入された際の影響を限定するために、最小限の権限しか持たないようにする 可搬性 imageサイズはなるべく小さい方がbuild時間、配布時間の短縮になる とりわけ、再現性とセキュリティは重要です。例えば同じDockerfileでも、ある環境、あるタイミングではbuildできなかったりすると困ります。またコンテナに入れたらroot権限を得ることができる状態であるのは危険です。したがってコンテナを商用環境で使用することを考えているならば、Dockerfileを きちんと 書く必要があるわけです。そのためのBest practiceを以下で紹介します。 Best practices for writing Dockerfile 本題です。 フォルシアにおいてのDockerfileのガイドラインとアドバイスを列挙します。このベストプラクティスには、Docker社公式のベストプラクティス、世の中一般的によく言われているもの、フォルシア社内特有のルールが混合しています。ですので絶対的に正しいものとしてではなく、あくまで参考として読んでいただけると幸いです。 とりわけ推奨したいものに★をつけています。 不要な特権を避ける★ ビルドコンテキストについて理解しよう .dockerignore を使ったファイル除外の指定 マルチステージビルドの利用★ 不要なパッケージをインストールしない アプリケーションの分割 ビルドキャッシュの利用 レイヤー数は最小に、並び順も意識 信頼できるベースイメージを使用する★ Linterを使う★ 不要な特権を避ける rootlessコンテナ コンテナでプログラムをroot (UID 0) として実行することはやめましょう。 これは一般のLinux環境と同様ですが、root権限が与えられるとすべてのファイルが丸見え、操作可能となるからです。コンテナ内のプログラムがrootとして実行されていると、コンテナを単に実行するだけで、ホストや他のコンテナがのっとられます。 非常に危険です。 またDocker側で生成したファイルの権限がrootになる問題もあり面倒です。 非root 権限として実行するには、Dockerfile にいくつかの追加ステップが必要になります。以下にそのステップを記載します。 FROM alpine:3.12 *# Create user and set ownership and permissions as required* RUN adduser -D myuser && chown -R myuser /myapp-data *# ... copy application files* USER myuser ENTRYPOINT ["/myapp"] 特定の UID にバインドしない Dockerfile内で一般ユーザーを作成する際に困るのが、コンテナ内の実行ユーザーとホストのユーザーのUID/GIDが一致しない問題です。特に開発環境でローカルのファイルをコンテナにマウントする場合によく発生します。 対策として以下のようにDockerfile内でuid/gidを指定する方法があるのですが、ローカルマシンの環境依存するので良くないです。 FROM ubuntu:20.04 ARG UID=1001 ARG GID=1001 RUN useradd -u $UID -o -m myuser RUN groupmod -g $GID -o myuser ... コンテナ内の実行ユーザーとローカルのユーザーのUID/GIDを合わせるのはコンテナ実行時( docker run 時)にしましょう。以下の記事がとても参考になります。 dockerでvolumeをマウントしたときのファイルのowner問題 ビルドコンテキストについて理解しよう ビルドコンテキストとは docker build 実行時に指定するディレクトリのこと。 例えば docker build -t forcia:2020 . では最後の . がビルドコンテキストです。構築時、ビルドコンテキストとして現在のディレクトリ以下にある 全てのファイルやディレクトリ をDocker deamonに送信してしまいます。ビルドコンテキストに余分なディレクトリ・ファイルがあると、build時に時間がかかる、メモリを消費する原因となります。例えば、ビルドコンテキストに100MBのファイルがあるとimageのサイズが100MBプラスとなってしまいます。このような事態を防ぐためにも Dokcerfile用のディレクトリを作成し、そのディレクトリには無駄なファイルは配置しない ようにすべきです。 .dockerignore を使ったファイル除外の指定 Dockerfile用のディレクトリを作ったがそのディレクトリ内にビルドコンテキストとして含みたくないファイルが存在する、もしくはDockerfileをアプリのソースファイルが配置されているディレクトリと同じにしたいということもあると思います。 そんなときに .dockerignore を用いると、ビルドコンテキストとして無視します。 記述ルールについては こちら マルチステージビルドの利用 マルチステージビルドを利用すると、複数の FROM 命令をDockerfileに記述できます。 Docker 17.05以降で使えます。構築手順(プロセス)の最終段階(ステージ)でイメージをビルドするため、ビルド・キャッシュの効果によってイメージ・レイヤを最小化できます。 FROM golang:1.14 as builder WORKDIR /app COPY . /app/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -installsuffix cgo -o main main.go FROM alpine:3.12 COPY --from=builder /app/main /bin/main COPY . . EXPOSE 8080 CMD ["/bin/main"] マルチステージビルドを使うことで、成果物だけ最終ステージにCOPYすればよいため、build用の中間イメージにおいてはRUNをまとめる必要もなくなり可読性がupします。 参考: https://future-architect.github.io/articles/20210121/ 不要なパッケージをインストールしない 複雑さ、依存関係、ファイル容量、構築回数を減らすために「あったほうがいいだろう」くらいの必要性のパッケージのインストールは避けるべきです。例えば、データベースのコンテナにテキストエディタは必要ありません。軽量のベースイメージを使うのもいいです。 また軽量ベースイメージとしては distroless , alpine などが有名です。(ただし 軽量Dockerイメージに安易にAlpineを使うのはやめたほうがいいという話 というのもあったりします。) Tip: Debian及びUbuntu系では --no-install-recommends をつけると、不必要なパッケージのインストールを防ぐことができます。 FROM ubuntu RUN apt-get update && apt-get -y install python FROM ubuntu RUN apt-get update && apt-get -y install --no-install-recommends python アプリケーションの分割 各コンテナは「一つの役割」のみ持つべきです。 アプリケーションを複数のコンテナに分離すると、水平スケールやコンテナの再利用が行いやすくなります。例えば、ウェブ・アプリケーションのスタックであれば、ウェブ・アプリケーション、データベース、インメモリのキャッシュを分離した状態で管理するために、それぞれが自身のユニークなイメージを持つ、3つのコンテナで構成することができます。 ただし、厳密に「1コンテナ1プロセス」にこだわる必要もありません。どの方法が最善かは都度判断が必要です。コンテナが相互に依存する場合、 Dockerネットワーク の活用も有効です。 ビルドキャッシュの利用 imageの構築は、Dockerfile行の命令順(上から順)にしたがって実施されます。Docker は各命令で既存のイメージにキャッシュがあるかどうか検査します。もしキャッシュあれば、新しい(重複する)イメージを作成するのではなく、再利用します。キャッシュ利用のルールは以下の通り。 FROM のimageが既にある場合、Dockerfileの命令と親イメージから派生した子イメージの一致を確認し、一致するものがなければキャッシュを破棄して構築する。 ADD と COPY 命令はチェックサムのみ比較対象。アクセス時間・更新時間は無関係。それ以外の内容変更があればキャッシュ破棄 構築時、Dockerfileの命令行しか見ない(コンテナ内の比較はない) 例えば、 RUN apt-get -y update の実行が古くてもDockerは判断しない。 もしもキャッシュを一切使わないのであれば、 docker build コマンドで --no-cache=true オプションが使えます。 レイヤ数は最小に、 順番も意識する Docker の古いバージョンでは、確実に性能を出すために、イメージ・レイヤ数の最小化が非常に重要でした。この制限を減らすため、以下の機能が追加されました。 RUN 、 COPY 、 ADD 命令のみレイヤを作成します。他の命令では一時的な中間イメージ(temporary intermediate image)を作成し、ビルド容量(サイズ)の増加はありません。 可能であれば マルチステージビルド を使い、最終イメージの中に必要な成果物のみコピーします。これにより、最終イメージの容量は増えずに、中間構築ステージにツールやデバッグ情報を入れられるようになります。 簡単には、 RUN 、 COPY 、 ADD 命令はなるべく少なくする ことが重要です。 またビルドキャッシュを有効活用するために、レイヤ(命令)の実行順番を意識することが重要です。 FROM ubuntu:20.04 COPY . /usr/local/src # よく差分が生じやすいレイヤ RUN apt-get update RUN apt-get -y install vim 以上のDockerfileのように記述すると、Dockerfileのあるディレクトリに差分があるとそれ以降のレイヤもキャッシュは破棄されてやり直しとなります。 頻繁に変更するレイヤはなるべく後ろの方に書くとビルドキャッシュの有効利用ができ、build時間の短縮につながります。 FROM ubuntu:20.04 RUN apt-get update RUN apt-get -y install vim COPY . /usr/local/src # よく差分が生じやすいレイヤは後ろの方に書く 信頼できるベースイメージを使用する ベースイメージには中身が不明なイメージは使わないようにしてください。 Docker Hubにある公式イメージでも、tagを一意に決めていたとしても、中身は変わりうるので突然動かなくなる場合があります。 よってベースイメージは信頼できるものを使用したほうがよいです。例えば社内のインフラチームが管理していて、定期的にupdateを行っており、中身が明確にわかって、問い合わせもできるようなものです。 またベースイメージとして distroless イメージを使うのもよいかもしれません。distrolessは商用環境に特化したimageでランタイムに必要ないということでシェルすらありません。なので軽量でセキュアです。 Linterを使う Dockerfileを書く際はLint toolを使いましょう。 以下のhadolintとdockleを使うことを推奨します。Linterに怒られながらDockerfileを作ればよいので快適です。 hadolint DockerfileのLinterとして hadolint というものがデファクトスタンダードとして存在します。 対象のDockerfileと同じディレクトリに配置した上で docker run --rm -v "$PWD:/work" -w /work hadolint/hadolint hadolint Dockerfile を実行すればよいです。 例えば以下のDokcerfileを対象としてhadolintを実行してみます。 FROM node:14 RUN apt-get update RUN apt-get install -y python3 WORKDIR /app COPY . . RUN yarn install --production # RUN adduser nodejs && chown -R nodejs /app # USER nodejs CMD node src/index.js ❯ docker run --rm -v "$PWD:/work" -w /work hadolint/hadolint hadolint Dockerfile Dockerfile:3 DL3009 info: Delete the apt-get lists after installing something Dockerfile:4 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>` Dockerfile:4 DL3015 info: Avoid additional packages by specifying `--no-install-recommends` Dockerfile:12 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments このように丁寧にアドバイスしてくれます。 VSCodeのextension もあるそうです。こちらを使えばリアルタイムにエディタがメッセージをだしてくれます。 またhadolintのうちどのルールを無視するかなどの設定を.hadolint.yamlとしてas codeで管理できます。 ignored: - DL3000 # Use absolute WORKDIR. - DL3005 # Do not use apt-get upgrade or dist-upgrade. trustedRegistries: - hoge.dkr.ecr.ap-northeast-1.amazonaws.com # 特定のレジストリのベースイメージしか使えなくする たとえばフォルシアではベースイメージはprivate ECRにある、社内のコンテナチームが管理しているものを使用することを推奨しており、hadolintの ruletrustedRegistries で使えるコンテナレジストリをあえて制限しています。 ❯ hadolint --config ./.hadolint.yaml Dockerfile dockle dockle はビルドしたDockerImageをスキャンして、セキュリティ上の問題が無いかチェックしてくれるツールです。 Dockerfileからbuildしたimageを、dockleでスキャンした結果を以下に示しています。rootユーザでないことや、不必要なファイルを追加していないかなども注意してくれます。 ❯ docker build -t lint:test . ❯ dockle lint:test WARN - CIS-DI-0001: Create a user for the container * Last user should not be root INFO - CIS-DI-0005: Enable Content trust for Docker * export DOCKER_CONTENT_TRUST=1 before docker pull/build INFO - CIS-DI-0006: Add HEALTHCHECK instruction to the container image * not found HEALTHCHECK statement INFO - CIS-DI-0008: Confirm safety of setuid/setgid files * setuid file: urwxr-xr-x usr/bin/chfn * setuid file: urwxr-xr-x usr/bin/newgrp * setgid file: grwxr-xr-x sbin/unix_chkpwd * setuid file: urwxr-xr-x bin/mount * setuid file: urwxr-xr-x usr/bin/gpasswd * setgid file: grwxr-xr-x usr/bin/ssh-agent * setgid file: grwxr-xr-x usr/bin/expiry * setgid file: grwxr-xr-x usr/bin/wall * setuid file: urwxr-xr-x usr/lib/openssh/ssh-keysign * setuid file: urwxr-xr-x bin/su * setuid file: urwxr-xr-x bin/umount * setuid file: urwxr-xr-x bin/ping * setuid file: urwxr-xr-x usr/bin/passwd * setgid file: grwxr-xr-x usr/bin/chage * setuid file: urwxr-xr-x usr/bin/chsh INFO - DKL-LI-0003: Only put necessary files * unnecessary file : app/Dockerfile hadolintと同様に適用しないruleを.dockleignoreファイルで管理することもできます。dockleコマンド実行時のカレントディレクトリ下に配置するだけで適応されます。 # Avoid empty password DKL-LI-0001 # Avoid apt-get upgrade, apk upgrade, dist-upgrade DKL-DI-0003 CIで動かす hadolint, dockleともにCIで動かすこともできます。 Dockerfileの差分があればlintを実行することでよりクリーンなDockerfileが維持されることが期待されます。 以下はGitLab CIでの例です。 stages: - dockerfile_test - docker_image_test docker-hadolint: image: hadolint/hadolint:latest-debian stage: dockerfile_test script: - hadolint Dockerfile rules: - changes: - Dockerfile dockle_test: variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: "" DOCKER_DRIVER: overlay2 services: - docker:dind stage: docker_image_test before_script: - apk -Uuv add bash git curl tar sed grep script: - docker build -t dockle-ci-test:${CI_COMMIT_SHORT_SHA} . - docker save dockle-ci-test:${CI_COMMIT_SHORT_SHA} > dockle-ci-test.tar # 何故かdockle実行時にdokcer.ioを見てしまうので一旦tarに固める - | VERSION=$( curl --silent "<https://api.github.com/repos/goodwithtech/dockle/releases/latest>" | \\ grep '"tag_name":' | \\ sed -E 's/.*"v([^"]+)".*/\\1/' \\ ) && curl -L -o dockle.tar.gz <https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz> && \\ tar zxvf dockle.tar.gz - ./dockle --exit-code 1 --input dockle-ci-test.tar rules: - changes: - Dockerfile おわりに Dockerfileのベストプラクティスを社内向けにまとめたものを公開させていただきました。 Dockerは使うだけならば、各フレームワーク公式がDocker Imageを公開していますので簡単に使うことができます。しかし、特に商用環境で利用することを考慮し、セキュアで再現性を高く、かつ可搬性も上げるためには様々な考慮が必要だよね、という話が社内であがったため今回の記事を書きました。 今回紹介したベストプラクティスは、Docker社公式であるものとフォルシアで特に重視しているものが混ざり合っています。特にLinterの使用はベストプラクティスをAS Codeで管理することができるので定着推進がしやすいため推しているところです。 この記事を読んでくださったかたの一助となりましたら幸いです。 おまけ: 参考になりそうなdocker-compose 社外の参考になりそうなdocker-compose docker-compose awesome compose-spec(公式ドキュメント) Best Practices Around Production Ready Web Apps with Docker Compose 参考 公式ドキュメント https://www.slideshare.net/zembutsu/explaining-best-practices-for-writing-dockerfiles Dockerfileを書くためのベストプラクティス【参考訳】v18.09 Dockerfileのベストプラクティス Top 20 DockerでRUNをまとめた方が良いとは限らない ※サムネイル画像出典 https://icons8.com/
アバター
はじめに 第1旅行プラットフォーム部エンジニアの六車と申します。 大手旅行代理店の検索サイトの構築をメイン業務としつつ、社内のコンテナ・クラウド活用推進活動も行っています。 この記事では、社内コンテナ推進活動の一環で行ったDockerfileの書き方のベストプラクティスのまとめを紹介します。 この記事のゴール:効率的かつ保守性の高いDockerfileの書き方を知る。 Dockerfileとはなんぞや Dockerfileはずばり 「docker imageを作るための設計図」 のようなものです。 Dockerfileはdocker imageを自動構築する際に必要となるファイル
アバター
こんにちは、エンジニアの松本です。この記事はShinjuku.rs #14 において『Rustでロギングってどうすればいいんですか?』というタイトルでデモを中心に発表した内容をブログ記事としてまとめ直したものです。 Rustの標準的なロギングの仕組みとそれを実現するクレート群の使い方を解説します。 ※クレート:他のプログラミング言語でいうところの「ライブラリ」に相当する概念。ある機能を提供する再利用可能なコード群のこと。 ロギングは大切 アプリケーションを運用していくにあたって、様々な場面でアプリケーションの動作を確認したくなることがあります。処理はいつ終了したか、どの処理に時
アバター
FORCIAアドベントカレンダー2020 25日目の記事です。 昨年に引き続きFORCIAアドベントカレンダー最終回を担当します、エンジニアの武田です。 今回は、私が担当しているプロジェクトでgit subtreeを利用することになったため、その紹介をしたいと思います。 git subtreeとは gitリポジトリ内で複数のgitリポジトリの履歴を管理することができる、gitのサブコマンドです。その名前のとおり、メインリポジトリの履歴(main tree)と取り込んだリポジトリの履歴(sub tree)を管理することができます。 git subtreeを利用することになった背景 あるプロジェクトのプライベートなリポジトリで、特定のディレクトリをプロジェクト外の開発者も開発できるようにする必要がありました。一部のディレクトリを別リポジトリとして切り出し、プロジェクト側に取り込むには submodule を利用するケースが多いかと思います。フォルシアの各プロジェクトでも共通モジュールを submodule としてリポジトリに取り込むケースが多いです。 一方で、submodule を取り込んでいるプロジェクト側で submodule も併せて開発していくケースでは以下のような課題がありました。 本体側と submodule を同時に修正する場合、それぞれのリポジトリで commit しなければならない それぞれのリポジトリに push し、本体側では submodule のコミットハッシュを更新する必要があり、手順が多い Merge Request/Pull Request を作成したときに submodule 内の差分を確認できない コードレビュー時にリポジトリを行ったり来たりして確認する必要がある submodule が更新された場合、各開発者が submodule update をしないと submodule が更新されず、アプリケーションが動かない、といったことが起きやすい そこで git subtree を利用してみることにしました。 実際にgit subtreeを使ってみてどうか コードレビューで subtree 側の差分についても確認できるというメリットが非常に大きいと感じています。 また、開発者は subtree である、ということを意識せずに開発を進められるため、取り込んだリポジトリに対して変更を加えていく場合は git subtree を利用した方が快適に開発を進めることができます。 一方、 git subtree に限った話ではありませんが、定期的にリモートの履歴を pull しておかないと競合が発生してつらいことになりそうです。git subtree 自体は素晴らしい仕組みですが、それをどのように利用し、運用していくか、といったところに難しさがあるような気がしました。 そちらについては今後運用を進めていく中で改善していければと考えています。 git subtreeコマンドについて 有名な話ですが、git subtree コマンドはシェルスクリプトで実装されています。 https://github.com/git/git/blob/master/contrib/subtree/git-subtree.sh 引数の処理、関数の呼び出し方、エラー処理、デバッグメッセージの出し方など非常に参考になる書き方が多いのでぜひ軽く眺めてみることをおすすめします。 各コマンドの詳細については git subtree --help で表示される説明が非常にわかりやすいため、詳細は省きますが git subtree add/split したときの動きについて簡単に説明します。 git subtree add/split 基本的に add/split で実現したいことは同じです。「本体リポジトリ」に別のコミット履歴を作り、 add は別のリポジトリをサブディレクトリとして取り込む split はすでにあるリポジトリからサブディレクトリに関する履歴を別ブランチに分離する という違いがあります。add は submodule add と似ていてイメージしやすいですが、split は submodule に対応するコマンドがなく、イメージしにくいかもしれません。split の名前のとおり「分離する」ということを意識すると理解しやすいです。 以下にそれぞれのコマンドを実行した場合のイメージ図を貼ります。 クリスマスということでコミットドットをオーナメントで表現してみました!(powered by gitgraph.js) add/split した場合、完全に別の履歴となって管理されるようになります。add の場合は squash することで余計な履歴が発生しないようにすることも可能です。 # add する場合のコマンド例 $ git subtree add --prefix={addしたいサブディレクトリ} {addしたいリポジトリのURL} main # split する場合のコマンド例 $ git subtree split --prefix={splitしたいサブディレクトリ} --annotate='(subtree) ' --branch subtree --rejoin split の場合、該当のサブディレクトリに対して変更を加えたコミットのみ、別のコミット履歴として切り出して分離してくれます。 過去のすべてのコミットをチェックすることになるため、split コマンドの実行に時間がかかるのですが、 --rejoin をつけることで subtree のコミットハッシュ、subtree のディレクトリがどこにあるか、といった情報がメインブランチに記録されます。 以降、git subtree split を実行する場合、このコミットより前の履歴を見なくする、といった工夫がされているため、コマンド実行時間が短縮されます。split に時間がかかる場合は定期的に --rejoin を実行すると良さそうです。 さいごに 非常に単純な仕組みを使ってこれだけ便利な機能が、1,000行に満たないシェルスクリプトで実装されていることに衝撃を受けました。外部のリポジトリを取り込みつつ、取り込んだリポジトリも開発していきたい、というケースでは git subtree の利用を検討してみてください。 FORCIAアドベントカレンダー2020も本日で終了となります。2021年が皆様にとって良い年になることを祈っています。メリークリスマス!良いお年を!
アバター
FORCIAアドベントカレンダー2020 25日目の記事です。 昨年に引き続きFORCIAアドベントカレンダー最終回を担当します、エンジニアの武田です。 今回は、私が担当しているプロジェクトでgit subtreeを利用することになったため、その紹介をしたいと思います。 git subtreeとは gitリポジトリ内で複数のgitリポジトリの履歴を管理することができる、gitのサブコマンドです。その名前のとおり、メインリポジトリの履歴(main tree)と取り込んだリポジトリの履歴(sub tree)を管理することができます。 git subtreeを利用することになった背景
アバター
本記事は Kubernetes3 Advent Calendar 2020 の 24 日目の記事です。 こんにちは。旅行プラットフォーム部エンジニアの小孫です。 昨年のアドベントカレンダーの記事 で、来年はk8sを本番環境で利用してアドベントカレンダーで書くぞと決意表明しましたが、なんとか今年中に有言実行できました。弊社の「 Masstery 」というクラウド型のデータクレンジングサービスのデータ変換のバッチ処理を、Fargate for EKSでk8sのJobとして動かしています。 k8s化に取り組んだ経緯 昨年k8sに触れてみて、本番環境で利用してみたいと考えたものの、私が普段担当しているアプリは自社サービスではなくインフラも弊社持ちではないため、実績がないままいきなりk8sを導入するのは難しい状態でした。 そんなときに、今年ローンチした弊社の新サービス「Masstery」のチームと話をする機会があり、k8sについて話しました。ちょうどMassteryでも将来のユーザー数の増加に備えて、多数のバッチ処理を同時並行で実行できるようにしておく必要がありました。 Massteryのデータ変換バッチでは商品データのフォーマット統一やカテゴリ情報の付与等を行っていますが、バッチ処理が実行されるタイミングはユーザー次第です。そのため通常のサーバーだと必要十分なリソースの調整が難しく、コストパフォーマンスが悪くなってしまいます。 そこで、AWSのFargate for EKSを利用することにしました。 Fargate はAWSが提供するサーバーレスのコンテナ実行基盤です。通常k8sを利用するには、Podを動かすためのワーカーノード(AWSではEC2)を用意する必要がありますが、Fargateではその必要がなくPod実行時に自動でワーカーノードが用意されます。起動に数分ほどかかるものの、必要な時に必要なだけのリソースを利用できるので、今回のようなバッチ処理にぴったりのサービスです。 k8s化にあたって行ったこと バッチ処理をFargate for EKSで動かすにあたって、大きく分けて以下の4つの対応を行いました。 コンテナイメージの作成 マニフェストやkubectlをラップしたスクリプトの作成 EKSクラスターの構築 実際に動かしてみての課題の対応 一般的にk8sクラスターの構築はインフラエンジニアだけが担当することが多いと思いますが、弊社ではアプリエンジニアとインフラエンジニアの垣根が低く、今回の私のようにアプリエンジニアであっても興味があればインフラ寄りのタスクを担当することがあります。 k8sについては昨年から本を読んだり遊びで動かしたりして、ある程度の予備知識はありましたが、今年 CKAD の合格を目指して勉強したおかげで、実践的な知識を得ることができました。AWSの知識と経験が浅くEKSクラスターの構築では苦労しましたが、インフラエンジニアと協力し、 社内で一足早くk8sを商用利用 したアプリの担当者にもアドバイスをもらいながら構築できました。 ここからは、k8s化の過程で工夫したことや困ったことをいくつか紹介します。 コンテナイメージの軽量化 バッチ処理をk8sで動かすにあたって、まずはコンテナイメージの軽量化から始めました。 こちら の記事にもあるように、Fargateではイメージのキャッシュが使えず毎回ECRからイメージをプルするため、イメージサイズがPodの起動時間に大きく影響します。 バッチ処理ではPythonイメージを使っていますが、 こちら の記事を参考に、pipでインストールしたライブラリをマルチステージビルドで実行用のコンテナにCOPYするようDockerfileを変更しました。これによりビルド時のみ必要で実行時には不要なファイルを省くことができ、イメージサイズを減らせました。Pythonのマルチステージビルドは他にも方法があるようですが、今のところこの方法で問題なく動いています。 # ビルド用コンテナ FROM python:3.x-buster as build-stage WORKDIR /tmp COPY requirements.txt /tmp RUN pip3 install -r requirements.txt # 実行用コンテナ FROM python:3.x-slim-buster # ビルド用のコンテナから、pipでインストールしたライブラリをコピー COPY --from=build-stage /usr/local/lib/python3.x/site-packages /usr/local/lib/python3.x/site-packages COPY --from=build-stage /usr/local/bin /usr/local/bin また、 Docker公式のベストプラクティス にも書かれていますが、apt-getでのライブラリのインストール後に、パッケージキャッシュをクリーンにし /var/lib/apt/lists を削除することでイメージサイズを減らしました。(apt-get updateの実行により、パッケージのinstall時に参照するインデックスファイルがダウンロードされるディレクトリが /var/lib/apt/lists です。) # 必要なライブラリをインストール後、キャッシュを削除 RUN apt-get update \ && apt-get install -y hoge fuga piyo\ && apt-get clean \ && rm -rf /var/lib/apt/lists/* その他、不要にインストールしていたモジュールを削除することでコンテナイメージを軽量化し、運用上支障のないPod起動時間に短縮できました。 EFSの利用で実行時間が延びた原因 コンテナを利用する際には永続データをどう扱うかを考える必要があります。 当初はバッチ処理の変換前後のデータや、データ変換に必要なファイルはS3に置いて、バッチ処理の前後にPodからS3にアクセスする予定でした。ただ、Fargateの一時ボリュームは20GBであり、データ変換で用いる機械学習モデルや自然言語処理の辞書まで持つには心許ない容量です。 しかしちょうど今年8月に Fargate for EKSでもEFSをサポート するという朗報があり、早速Fargate for EKSでEFSを利用することにしました。FargateのPodにEFSをマウントすることで、バッチ処理自体のロジックを変更することなく永続データを扱えるようになり、容量も気にせずに済むようになりました。 ところが実際にFargateでバッチ処理を実行してみると、なぜか特定の処理に通常の10倍ほど時間がかかるようになってしまいました。Kubernetesダッシュボードで確認するとCPUやメモリはボトルネックとなっておらず、I/Oの問題かなと思いつつもなかなか原因がわかりませんでした。 試しにEFSに置いているあるファイルをPod起動時にコンテナ内にコピーして、バッチ処理ではそちらを参照するように変更してみると、処理時間が通常になりました。このファイルに対して頻繁にI/Oが発生するロジックになっていたため、EBSのようなローカルストレージよりレイテンシが大きいEFSでは処理に時間がかかってしまっていたようでした。 Fargate for EKSはEFSのサポートにより利用の幅が大きく広がりましたが、EFSの利用にあたっては今回のように頻繁にI/Oが発生しないようなロジックの工夫が必要だということもわかりました。 また今回は利用しなかったS3ですが、 整合性が強力になった (書き込みの直後に読み取りができるようになった)ことが先日発表されたので、ストレージの選択肢の一つとして今後利用できる場面がさらに増えそうです。 kubectl waitコマンドでPodの監視 監視については、以前からPrometheusを利用してリソースやログの監視を行っていました。バッチのログはPodにマウントしているEFSに書き出すようになっているので、grok_exporterを動かしているEC2でログを監視するようにしました。 また、Job実行後にPodのステータスがReadyになったかどうかはPrometheusでは監視できなかったので、Job実行時に kubectl waitコマンド を実行して、その結果をJob実行をキックしているWebアプリのログに出力するようにしました。このログもgrok_exporterで監視しているので、何らかの理由でPodが起動できなければ検知されるようになっています。 さらにJobやPodの異常終了についても、Job実行時にkubectl waitコマンドでJobの正常終了のタイムアウトを設定することで監視しています。 なお、kubectl waitコマンドはExperimental(実験的)な機能です。たしかに、タイムアウトを2時間に設定したのに結果が返ってくるのが2時間半後ということがありました。現時点では使える場面が限られますが、時間に厳密さを求めないのであれば使いやすい機能だと思います。今回はこれで十分でしたが、ワークフロー処理のように状態変化を即座に検知する必要がある場合には、 社内でモジュールを開発して対応 しています。 完了したJobの削除 バッチ処理ではk8sのJobリソースを使っています。k8sのJobとそれに紐づくPodは、 完了後も自動で削除されずに残り続ける 仕様 です。 これは完了後のPodでログを確認できるようにするためですが、Podが残り続けるとFargateの課金の対象となってしまいます。完了後のリソースを一定時間後に削除する TTL controller という仕組みも存在しますが、現時点ではEKSでは利用できないものです。 そこで こちら の記事を参考に、EKS管理用のEC2からcronで定期的に完了したJobを削除するようにしました。 $ kubectl delete job $(kubectl get job -o=jsonpath='{.items[?(@.status.succeeded==1)].metadata.name}') また、異常終了したJobについては調査を行う時間を考えて、1日に1回、開始後1日以上経っているJobの削除を行うようにしました。 $ kubectl delete job $(kubectl get jobs | awk '$4 ~ /d/' | awk '{print $1}')` 最後に 一年前は本当にk8sを本番運用まで持っていけるのか?というのが正直な気持ちでしたが、一つずつステップを踏んでいくことで実現できました。開発環境でのコンテナ利用にとどまらず、本番環境でk8sを利用することで、アプリ運用の可能性が大きく広がることを実感しました。来年も新しい技術に挑戦していきたいです!
アバター
FORCIAアドベントカレンダー2020 23日目の記事です。 こんにちは。アドベントカレンダー23日目の記事を担当します、エンジニアの澤田です。 昨年は Template Haskell を使ってメタプログラミングをやってみた という記事を書き、Haskell を勉強しつつ関数型言語に触れてみました。 その中で、関数型言語は並列処理との親和性が高いということを知ったので、また、違う言語に触れてみようと思い、今回は Erlang で並列プログラミングをやってみます! なお、Erlang のバージョンは Erlang/OTP 22 を使用しています。 並列プログラミングと Erlang 並列処理には大きく「マルチプロセス」と「マルチスレッド」があり、両者には主に以下のような違いがあります。 マルチプロセス: 各プロセスが固有のメモリ空間を持つ。基本的にプロセス同士が干渉することがなく安全に処理を行えるが、プロセス生成やプロセス間でのデータのやり取りのオーバーヘッドが高い。 マルチスレッド: スレッド間でメモリを共有する。メモリを共有するため処理が高速だが、共有メモリにアクセスする際の排他制御など、処理が複雑になる傾向がある。 Erlangの特徴として、並列処理を強力にサポートしている点が挙げられます。 マルチプロセスは安全だけど処理が重いというデメリットがありますが、Erlang のプロセスは非常に軽量です。 これは Erlang のプロセスが、OSのプロセスとは異なり、Erlang VM上で生成・管理されているためです。 OSのプロセスとは異なるものの、プロセスの特性(固有のメモリ空間を持つなど) は OSのプロセスと同じで安全に扱うことができ、プロセス間の通信も Erlangのメッセージング機構によって高速かつ簡単に行うことができます。すごいですね。 それでは、実際にプロセスを生成してみましょう! プロセスを生成してメッセージを送る プロセスの生成は組み込み関数(BIF: Built-in function) の spawn/3 (※) を使用して、以下のように記述します。 ※この 3 は引数の数を表しています。Erlangでは引数の数が異なると、同じ名前でも別の関数として扱われます。 spawn(モジュール名, 関数名, 引数リスト) プロセスを生成するにはモジュールが必要なので、簡単なモジュールを書いてみます。 -module(echo). -export([receive_message/0]). receive_message() -> receive {Pid, Message} -> io:format('~w~n', [Message]), Pid ! ok end. 上記のコードを、モジュール名と同じファイル名 echo.erl で保存し、保存したディレクトリで erl コマンドを実行して Erlangシェルを起動します。 Erlangシェル上で関数 c(echo). を実行するとコンパイルされて、 echo モジュールと、 -export 属性の指定によって公開された関数が外から使えるようになります。 ここで、receive節に到達するとメッセージを受け取るまで待機することになります。 メッセージを受け取るとその中の各節でパターンマッチが行われて、マッチした場合にその節の本体が実行されます。 そして io:format('~w~n', [Message]) のところで受け取ったメッセージを出力しています( ~ は制御シーケンスで、 ~w には変数 Message の中身が入り、 ~n には改行が入ります)。 Erlangシェルで、以下の通り spawn関数を実行するとプロセスが生成され、プロセス識別子が返されます。 そして返された結果( <0.80.0> ) を変数 P1 に結合させます。 1> P1 = spawn(echo, receive_message, []). <0.80.0> それでは、プロセスにメッセージを送ってみましょう! メッセージを送るには以下のように記述します。 プロセス識別子 ! メッセージ プロセス P1 にメッセージを送ります。 2> P1 ! {self(), hoge}. hoge {<0.78.0>,hoge} おお、送ったメッセージ hoge と、評価された式の結果( {<0.78.0>,hoge} ) が返ってきましたね! もう一度送ってみましょう。 3> P1 ! {self(), piyo}. {<0.78.0>,piyo} おや・・・最後に評価された式の結果( {<0.78.0>,piyo} ) は表示されますが、送ったメッセージ piyo が表示されません・・・。 プロセスは生きているのでしょうか? 確認してみます。 4> is_process_alive(P1). false なんと・・・ false が返ってきたので、既に存在していないことがわかりました。 生成したプロセスは、一通り処理が完了すると終了してしまうのです。 プロセスを継続するにはどうすれば良いでしょうか? 再帰でプロセスを存続させる プロセスを存続させるには再帰を使います。 先のモジュールを少し書き換えて、receive節内の最後で自分自身を実行します。 -module(echo). -export([receive_message/0]). receive_message() -> receive {Pid, Message} -> io:format('~w~n', [Message]), Pid ! ok, receive_message() end. では、もう一度やってみましょう。 1> P2 = spawn(echo, receive_message, []). <0.80.0> 2> P2 ! {self(), hoge}. hoge {<0.78.0>,hoge} 3> P2 ! {self(), piyo}. piyo {<0.78.0>,piyo} 今度は上手くいきましたね! プロセスを並列で動作させる それでは、いよいよプロセスを並列で動作させてみましょう。 並列動作を確認しやすくするため、指定した回数分、1秒間隔でメッセージを送る関数 send_message/3 を追加して使うことにします。 -module(echo). -export([receive_message/0, send_message/3]). receive_message() -> receive {Pid, Message} -> io:format('~w~n', [Message]), Pid ! ok, receive_message() end. send_message(_, _, 0) -> ok; send_message(Pid, Message, N) -> Pid ! {self(), Message}, receive _ -> ok end, timer:sleep(1000), send_message(Pid, Message, N - 1). まず、メッセージを受け取って表示するプロセスを生成しておきます。 1> P3 = spawn(echo, receive_message, []). <0.80.0> そしてプロセスを3つ生成しつつ、非同期でメッセージを送ります。 2> spawn(echo, send_message, [P3, hoge, 5]), 2> spawn(echo, send_message, [P3, piyo, 5]), 2> spawn(echo, send_message, [P3, foobar, 5]). hoge <0.84.0> piyo foobar hoge piyo foobar hoge piyo foobar hoge piyo foobar hoge piyo foobar 無事並列で実行することができました! さいごに 個人的にすぐに理解できなかったのが self() を実行して得られるプロセス識別子( <0.78.0> ) が一体何なのか? ということでした。 Erlangシェルで実行している場合、これはErlangシェル自身のプロセス識別子なんですね。 シェル自身にメッセージを送って、処理されなかったメッセージを flush/0 関数で取り出してみます。 1> self() ! hoge. 2> self() ! piyo. 3> flush(). Shell got hoge Shell got piyo 面白いですね :)
アバター
FORCIAアドベントカレンダー2020 23日目の記事です。 こんにちは。アドベントカレンダー23日目の記事を担当します、エンジニアの澤田です。 昨年は Template Haskell を使ってメタプログラミングをやってみた という記事を書き、Haskell を勉強しつつ関数型言語に触れてみました。 その中で、関数型言語は並列処理との親和性が高いということを知ったので、また、違う言語に触れてみようと思い、今回は Erlang で並列プログラミングをやってみます! なお、Erlang のバージョンは Erlang/OTP 22 を使用しています。 並列プログラミングと Erlang
アバター
本記事は Next.js Advent Calendar 2020 の 22 日目の記事です。 こんにちは。旅行プラットフォーム部エンジニアの東川です。 フォルシアではフロントエンドフレームワークとして Next.js を使用していますが、2020年は Next.js にとって激動の年であったといえます。 この 1 年間でバージョンは 9.1 から 10.0 に上がり、SSG(Static Site Generation), ISG(Incremental Static Generation)などの新機能が次々に追加されました。 10 月 27 日に Next.js のカンファレンス Next.js Conf の開催と同時に Next.js バージョン 10.0 が発表されました。 国際化に対応したルーティング、Next.js Analytics, Next.js Commerce, React17 対応など数多くの新機能とバージョンアップが発表されましたが、next/image はその リリースノート の中でも一番上で取り上げられています(Next.js Analytics については山門がこのアドベントカレンダーで 紹介記事 を書いているのでぜひご覧ください)。 簡単に言えば、next/image とは 画像サイズと拡張子をデバイスとブラウザに応じて最適な形で出し分けてくれる React コンポーネントのことです。 この記事では next/image の基礎的な使い方と仕組み、コンポーネントの引数の解説をしたいと思います。 画像の最適化は重要だが手間がかかる Next.js Conf の Keynote で指摘されているように、画像ファイルはウェブページ全体のバイト数の半分を占めます。 最適化されていない画像の送受信や描画はページ表示の遅れにつながり、UX(ユーザーエクスペリエンス)の悪化につながります。 フォルシアでは EC サイトの構築を多くやってきていますが、EC サイトのように多くの商品画像を表示する必要があるウェブサイトの場合、この問題は特に重要です。 Next.js Conf の講演 Why Images Hurt App Performance & How the New Next.js Image Component Can Help や next/image の RFC では、最適化されていない画像とは何かと、その UX への悪影響について以下の点が指摘されています。 画像サイズ: 通信されるの画像サイズと実際に表示される画像サイズがあっていない 例えば、スマホの画面に 100×100pixel の画像を <img> タグで表示したいとします。このとき、大きすぎるサイズの画像 500×500pixel の画像を送ってしまうと、無駄な通信が発生して画面描画が遅れます。 また、 <img> タグに width, height が設定されていない場合、画像の表示前後で DOM の配置が変化する可能性があります。 これらは、google が提唱する core web vitals の一部、LCP(Largest Contentful Paint, 簡単に言えばファーストビューが表示されるまでの時間です) や CLS(Cumulative Layout Shift, 簡単に言えば描画までに起こった画面レイアウトの変化量) の悪化につながり、全体的な UX の悪化につながります。 特にスマホの場合は、処理スペックに限りがある一方で viewport(画面の表示領域) が小さいため、最適な画像サイズを送ることは特に重要になるといえます。 拡張子: 軽量な拡張子の画像が使われていない モダンな拡張子、例えば webp は jpeg, png に比べて 30%程度軽量です。従って、jpeg, png 画像をこれらの拡張子に置き換えることで通信量の削減ができます。 タイミング: viewport 外の画像を読み込んでいる 初期描画でページ内のすべての画像を読み込むと、表示にかかる時間が不必要に伸びてしまいます。 速度と表示を両立させるためには、初期描画では viewport 内(ブラウザの表示領域)の画像のみを、それ以外の画像は viewport が近づいたタイミングで順次読み込みます(遅延ローディングと呼ばれます)。 上記の課題は有名な対応法が知られています。 例えば 画像サイズの最適化に関しては、 <img> タグで srcset を設定すれば、ブラウザが複数の画像から最適なサイズの画像を読み込ませるようにすることができますし、拡張子の最適化は jpeg, png を片っ端から webp に変換すれば対応できます。 遅延ローディングに関しても intersection observer を使った実装などがよく知られています。 Next.js に限っても next-optimized-images などの画像の最適化をしてくれるライブラリが知られていました。 しかしながら、画像の最適化はウェブ全体を見ると十分に浸透しているとは言えません(上のリリースノートによれば、99.7%の画像は webp のようなモダンな拡張子が使用されていないそうです)。 これには以下の理由が考えられます。 ブラウザ間の差異を考慮する必要がある 上述のモダンな拡張子 webp などは、一部のブラウザではサポートされていないため、これらのブラウザのサポートと画像拡張子の最適化を同時にしようとすると、ブラウザを見て返却する拡張子を変化させる必要があります。 また、遅延ローディングには様々な実装方法が知られていますが、実装方法によってはうまく機能しないブラウザなどもあり注意が必要です。 また、ブラウザによって画像サイズを出し分ける場合、素朴には各画像に対して srcset と各サイズの準備をする必要があり、実装の手間がかかります。 外部サーバーから画像を取得する場合、画像の最適化とキャッシュ機能を担う中継サーバーが必要になる 外部サーバーから取得した画像を最適化する場合、外部サーバーから画像を取得し、画像の最適化をしてブラウザに返却するような中間のサーバーが必要になります(往々にして、このようなサーバーは最適化された画像を保持するキャッシュとしての役割も持ちます)。 画像の最適化に真剣に取り組もうとすると、これらの課題をクリアしながら開発する必要があり、そのコストは決して少なくありませんでした。 これらの解決策として登場したのが next/image です。 next/image の概要と基本的な使い方 また、以下の例は 公式の example を下敷きにしています。動作確認は以下の条件で行いました。 Next.js: canary(2020/12/14時点), Chrome: 78, Firefox: 83, Internet Exploler: 11 例として、以下の画像ファイル( river.jpg )を考えます。 next/image の導入は非常に簡単であり、 タグを React コンポーネント に置き換えるだけで画像サイズと拡張子の適切な出し分けができるようになります。 付属ライブラリのインストールなども不要です。 // Case A. 通常の画像コンポーネント const UnoptimizedImage = () => ( <img src="{`/river.jpg`}" width="{360}" height="{240}" /> ); // Case B. next/iamgeを使った画像コンポーネント import Image from "next/image"; const OptimizedImage = () => ( <img // <img="" />を <img />に置き換えるだけ!! src={`/river.jpg`} width={360} height={240} /> ); では、これがどのように画像を最適化してくれるのかを見ていきましょう。 Case A, B の画像を横に並べて比較してみたものが下です。 当然期待されることですが、 <img> を使った場合も <Image> コンポーネントを使った場合も同じ画像が表示されます。 一方で、画像取得のリクエストや生成される DOM 要素は大きく異なり、 <Image> コンポーネントを使った場合 public/下には何の最適化もしていない画像を配置したにもかかわらず、最適な画像サイズの画像が最適な拡張子で遅延読み込みされるようになっています。 上の例だと、元々 resource size 2.7MB の jpeg ファイルが返却されていたのが 24kB の webp に変換され、resource size は元々の 1%程度まで小さくなっています。 <Image> コンポーネントから生成される DOM 要素は下のようであり、 <img> に加えて <img> をラップするような DOM 要素が生成されます。 // Case A. 通常の画像コンポーネントから生成されるDOM要素 <img src="/river.jpg" width="360" height="240" /> // Case B. Imageコンポーネントから生成されるDOM要素 <!-- レイアウトを整える用のラッパーDOM要素 --> <div style=" display: inline-block; max-width: 100%; overflow: hidden; position: relative; box-sizing: border-box; margin: 0; " > <div style="box-sizing: border-box; display: block; max-width: 100%"> <img style="max-width: 100%; display: block" alt="" aria-hidden="true" role="presentation" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYwIiBoZWlnaHQ9IjI0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2ZXJzaW9uPSIxLjEiLz4=" /> </div> <!-- 画像のDOM要素 --> <!-- srcが/_next/image/下のパスに置き換わり、decoding, srcsetが設定されている ! --> <img src="/_next/image?url=%2Friver.jpg&w=1080&q=75" decoding="async" style=" visibility: visible; position: absolute; inset: 0px; box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%; " srcset=" /_next/image?url=%2Friver.jpg&w=384&q=75 1x, /_next/image?url=%2Friver.jpg&w=750&q=75 2x, /_next/image?url=%2Friver.jpg&w=1080&q=75 3x " /> </div> ソース を見るとわかるように <Image> コンポーネントは以下のような DOM 要素を生成します。 // packages/next/client/image.tsx // <Image>コンポーネントで生成されるDOM要素 <div style={wrapperStyle}> {sizerStyle ? ( <div style={sizerStyle}> {sizerSvg ? ( <img style={{ maxWidth: "100%", display: "block" }} alt="" aria-hidden={true} role="presentation" src={`data:image/svg+xml;base64,${toBase64(sizerSvg)}`} /> ) : null} </div> ) : null} <img {...rest} {...imgAttributes} decoding="async" className={className} ref={setRef} style={imgStyle} /> </div> <Image> コンポーネントにおける画像取得の仕組みは大まかに以下のようです。 生成された DOM 要素で decoding=async が設定されているため、画像のデコード処理が非同期的にバックグラウンド処理されるようになります。 <Image> コンポーネントはデフォルトで遅延ローディングになっており、対象画像が viewport に近づくとローディングが始まります。内部的には intersectionObserver を使って対象画像との相対位置を監視しています( ソースの該当箇所 )。 上の生成された DOM 要素 を見ると分かるように、 <Image> コンポーネントから生成された <img> タグには srcset が設定されています。これにより、ブラウザは srcset で設定された、w(width)と x(ピクセル密度)が異なる 3 つの画像からブラウザ幅に応じて最適なものを選んでリクエストします。例えば、 PC 画面の場合だと <Image> コンポーネントに設定された width=360 に最も近い画像幅 w=384 をもつ x=1 のケースが選ばれ、リクエスト /_next/image?url=%2Friver.jpg&w=384&q=75 が送られます。 /_next/image は next のビルド時にできる画像サーバーです。リクエストを受けた画像サーバーは、リクエスト元のブラウザとクエリパラメターから画像サイズと拡張子を適切なものに変換し、ブラウザに返却します。上の例だと、Chrome は webp 対応しているため、webp で width=384, height=254 の画像ファイルをクライアントに返却します。一点注意するべき点は、 <Image> による画像の変換はビルド時ではなくランタイムで行われるということです。これにより画像のレスポンス時間は長くなるものの、画像数の増加によるビルド時間の増大を防ぐことができます。 <Image> を使うことにより、画像のサイズ・拡張子・読み込みタイミングの最適化という冒頭で上げた3つの問題が解消されていることがわかります。 また、この記事では詳しく説明しませんが /_next/image にできる画像サーバーにはキャッシュ機能があり、変換された webp 画像が .next/cache/images/ 下に保持され、同じリクエストに対しては webp への変換なしでクライアントに返却される、ブラウザに画像がある場合は /_next/image でバリデーションした後に 304 コードを返却することでブラウザのキャッシュを利用するようにする、などのことをしています。 <Image> はブラウザに応じて、適切に拡張子を選んでくれます。下は各ブラウザの devtool のネットワークを調べたものですが、 webp に対応している Chrome, Firefox に対しては webp が、webp 非対応の IE に対しては jpeg がそのまま返却されていることがわかります。 コンポーネントのオプション 公式ドキュメント で紹介されているように、 <Image> コンポーネントには豊富なオプション引数が存在します。 // <Image>コンポーネントの引数 <Image src={`/river.jpg`} // ソースファイル, string width={420} // 表示幅, number height={280} // 表示高さ, number quality={75} // 画質, number priority={false} // 表示の優先度, boolean loading={"lazy"} // 遅延ロードするかどうか, "lazy" | "eager" unoptimized={false} // 最適化するかどうか, boolean layout={"fixed"} // レイアウト, "fill" | "fixed" | "intrinsic" | "responsive" objectFit={"contain"} // layout='fill'の場合のobject-fit objectPosition={"50% 50%;"} // layout='fill'の場合のobject-position /> これらのほかに、例えば画像の alt 属性など <img> タグに設定できる属性は <Image> コンポーネントの props として設定することができます。 但し、 style, srcSet, decoding は例外で、設定したとしても <Image> コンポーネントの内の <img> タグの props を設定する際に上書きされてしまいます(上のソースコードを参照ください)。 必須引数 src : 画像のソースファイルです。 型: string public/下を参照するときは通常の <img> タグと同様に /path/to/image/below/publicDir/img.png のように設定します width : 画像の幅です 型: number 下で説明するように、 layout='fill' の時以外は必須です height : 画像の高さです 型: number 下で説明するように、 layout='fill' の時以外は必須です width や height は通常の <img> タグと異なり、必須の引数です。 width や height の設定されていない画像は CLS の悪化を引き起こしますが、 <Image> コンポーネントでは開発者が自然とそれを避けられるように設計されていることがわかります。 priority, loading: 表示タイミング・表示の優先度についての任意引数 priority : preload するかどうかのフラグです 型: boolean デフォルト値: false true の場合は、ページ遷移時に preload されます。 loading : 遅延ローディングをするかどうかのフラグです 型: "lazy" | "eager" デフォルト値: lazy loading=lazy の場合は viewport から計算された値でローディングを開始し、 loading=eager の場合は viewport の位置にかかわらず、ページ遷移した時にローディングを開始します。 上で説明したようにデフォルト設定では画像が遅延ロードされますが、遅延ロードが有効でないケースもあります。 例えば、サイズが大きくローディングに時間がかかる画像やトップページのヒーローイメージのようにファーストビューですぐに表示したい画像などです。 これらのケースでは preload=true や loading='eager' の設定が有効です。 unoptimized, quality: 最適化の有無と画質についての引数 unoptimized : 最適化するかどうかのフラグです。 型: boolean デフォルト値: false unoptimized=true の場合、生成される html では <img src='/river.jpg'> のようになり、srcset も設定されません。このため、 _next/image にリクエストはされず、最適化された画像がクライアントに返却されることもありません。 quality : 画質 型: number (1~100 の数値) デフォルト値: 75 quality を変化させると next の画像サーバー /_next/image/ へのリクエストのクエリパラメター q が変化します。下の例だと quality を 1(最低値), 75(デフォルト値), 100(最高値)とした時のスクリーンショットです。resource size はそれぞれ 3.6kB, 24kB, 73kB でした。この例だと q=1 の場合は画質の荒さが気になりますが、 q=75 は q=100 とほとんど遜色なく置き換えても問題ないように感じられます。 quality によって画像サイズが劇的に変化するため、このオプションは背景画像などサイズが大きくローディング時間を短縮したい状況で使えそうです。 unoptimized=true と設定するべき状況として、RFC では next/image に対応していない loader の画像を取得する場合などが挙げられています(対応している loader の一覧については 公式ドキュメント を参照してください)。 layout, objectFit, objectPosition: 画像の幅と高さなどのレイアウトについての引数 <Image> コンポーネントでは画像のレイアウトに対しても豊富なオプションが提供されています。 layout : viewport を変更した時のレイアウトを表します 型: "fill" | "fixed" | "intrinsic" | "responsive" デフォルト値: intrinsic layout='fixed' : viewport の幅によらず、設定された width, height の画像を表示します。 layout='intrinsic' : width が viewport 幅よりも小さい場合は viewport 幅に合わせて小さくなりますが、画像の幅が viewport 幅よりも大きい場合は width の値に設定されます。 layout='responsive' : viewport 幅に依存して画像幅が変化します。 layout='intrinsic' の場合と異なり、画像の幅が viewport 幅よりも大きい場合は viewport 幅に合わせて画像幅が増加します。 layout='fill' : 親の DOM 要素の height, width に合わせて画像の幅と高さが設定されます。 objectFit , objectPosition : layout='fill' と同時に使用され、親の DOM 要素内での相対値を表すオプション object-fit , object-position の値を設定します。 下の画像は layout を "fixed", "intrinsic", "responsive" の3つのケースに対して、画像幅 width が viewport 幅よりも小さい場合と大きい場合でどのように表示されるかを比較したものです。 デフォルトでは "intrinsic" が適用されており、画像が viewport からはみ出ないようになります。 layout="fixed" は企業ロゴなど常に一定の大きさを保ちたいものに対して使用するのが良さそうです。 背景画像など viewport 幅に合わせて表示したい画像に対しては layout="responsive" が有効です(css の background-image で画像を指定することも可能ですが、next/image の RFC でも指摘されているようにパフォーマンスの悪化が懸念されます)。 <h2>layout: 'fixed'</h2> <Image src={`/river.png`} width={360} height={240} // viewportの幅によらず一定の画像幅を保つ layout={"fixed"} /> <h2>layout: 'intrinsic'</h2> <Image src={`/river.png`} width={360} height={240} // widthより小さいviewport幅の場合はviewport幅に合わせてスケール // widthより大きいviewport幅の場合はwidthに設定 layout={"intrinsic"} /> <h2>layout: 'responsive'</h2> <Image src={`/river.png`} width={360} height={240} // viewport幅に合わせてスケール layout={"responsive"} /> まとめ next/image で行われている画像の最適化は、拡張子とサイズの最適化、遅延ローディングと一つ一つを見るとシンプルです。 しかし、これらをブラウザやデバイスの差異を吸収しながら自前ですべて実装しようとするとコストもかかり、バグも生じやすくなります。 next/image を使用することでこの強力な最適化をほとんど zero config で実装でき、通常の <img> からの置き換えがしやすく非常に開発者にやさしい設計となっています。 また、width や height が必須の引数になっていたり、 layout="responsive" のオプションが提供されていたりと、パフォーマンスの悪化を招くような実装を自然に避けることができるように、注意深く設計されていることがわかります。 上で解説したように豊富なオプション引数があり、対象画像と要件に合わせて画像の表示タイミングやレイアウトを柔軟に設定できることも魅力の一つです。 新しく Next.js のアプリケーションを作るのであれば next/image を使わない手はないといってよいでしょう。 この記事の執筆中に Vercel が 40 億円の資金調達をした というニュースが入ってきました。 2021 年も Next.js の進化から目が離せませんね。
アバター
FORCIAアドベントカレンダー2020 21日目の記事です。 PostgreSQLのユーザー定義関数をRustで実装する話です。 こんにちは、エンジニアの松本です。主な業務としてインメモリデータベースをRustで実装しています。 フォルシアではPostgreSQLを使っており、C言語で 拡張 も書いていますが、Rustを使って書けるようになると環境構築やテストがしやすくなって嬉しいです。本記事ではRustで関数を実装するとPostgreSQLから使えるようにラップしてくれる zombodb/pgx というクレートを紹介します。 C言語実装との比較実験を行い、遜色ない速度で実行できることを確認しました。 環境構築 環境はUbuntu 20.04.1 LTS (Focal Fossa)で行います。 PostgreSQL13.1を 公式の手順 でインストールしました。加えて sudo ln -s /usr/local/pgsql-13.1 /usr/local/pgsql とシンボリックリンクを張り、 export PATH=/usr/local/pgsql/bin:$PATH としてパスを通している状態です。 何をやるか SQLでは扱いにくい処理を行うときにユーザー定義関数を書くことが多いです。 ループを含むような処理の例として、コラッツ予想で知られている「整数nについて偶数ならば n = n/2 、奇数ならば n = 3*n+1 とする」という手順を 「 n == 1 となるまで繰り返すときの回数」を返すような関数を作ります。 C言語で書く場合 まずはC言語での実装を示します。詳細は ドキュメント を参照して下さい。 # collatz.c #include "fmgr.h" #include "postgres.h" // `int32`, `int64` は `postgres.h` 内で定義されている。 PG_MODULE_MAGIC; PG_FUNCTION_INFO_V1(collatz_c); Datum collatz_c(PG_FUNCTION_ARGS); Datum collatz_c(PG_FUNCTION_ARGS) { int32 arg = PG_GETARG_INT32(0); // 第1引数をint32として取得する int64 n = arg; int32 count = 0; while (n > 1) { if (n % 2 == 0) { n /= 2; } else { n = 3 * n + 1; } count += 1; } PG_RETURN_INT32(count); // countをint32として返却する } コンパイルを行い、 $ gcc -shared -O2 -Wall -fpic -I/usr/local/pgsql/include/server collatz.c -o collatz_c.so $ sudo mv collatz_c.so /usr/local/pgsql/lib/ # CREATE or REPLACE FUNCTION collatz_c(int4) RETURNS int4 AS 'collatz_c.so', 'collatz_c' LANGUAGE C IMMUTABLE STRICT; CREATE FUNCTION # select collatz_c(12); collatz_c ----------- 9 12, 6, 3, 10, 5, 16, 8, 4, 2, 1 と遷移するので出力 9 が正しいことが確認できます。 pgxを使ってRustで書く場合 本題である zombodb/pgx を紹介します。PostgreSQL 10~13に対応しています。 cargo install cargo-pgx でサブコマンドをインストールします。 cargo pgx init を実行すると、pgxの検証用にPostgreSQL 10~13の各バージョンがインストールされます。ご飯が食べられるくらいには時間がかかります。 cargo pgx collatz として、ボイラーテンプレートからプロジェクトを作成します。 src/lib.rs に処理を実装します。 # lib.rs use pgx::*; pg_module_magic!(); #[pg_extern(immutable)] fn collatz_strict(arg: i32) -> i32 { if arg 1 { n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 }; debug_assert!(n >= 1); count += 1; } count } #[pg_extern(immutable)] fn collatz(arg: Option ) -> i32 { match arg { Some(arg) => { if arg 1 { n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 }; debug_assert!(n >= 1); count += 1; } count } None => { panic!("The function 'collatz' got a null, expected the arg is a positive integer.") } } } #[test] fn test_collatz() { assert_eq!(0, collatz_strict(1)); assert_eq!(1, collatz_strict(2)); // 2,1 assert_eq!(7, collatz_strict(3)); // 3,10,5,16,8,4,2,1 assert_eq!(2, collatz_strict(4)); // 4,2,1 assert_eq!(5, collatz_strict(5)); // 5,16,8,4,2,1 assert_eq!(8, collatz_strict(6)); // 6,3,10,5,16,8,4,2,1 assert_eq!(16, collatz_strict(7)); // 7,22,11,34,17,52,26,13,40,20,10,5,...,1 } #[test] #[should_panic] fn test_collatz_panic() { collatz_strict(0); } cargo test で動作確認をすることができます。同じファイルに手軽にテストを書き、標準のパッケージマネージャから実行できる点はRustの長所の一つだと感じます。 cargo pgx package でリリースビルドを行うと、 target/release 以下に必要なファイル群が作成されます。 $ tree target/release/collatz-pg13/usr/local/pgsql-13.1/ target/release/collatz-pg13/usr/local/pgsql-13.1/ ├── lib │ └── collatz.so └── share └── extension ├── collatz--1.0.sql └── collatz.control collatz--1.0.sql を確認すると、 collatz_strict には STRICT をつけて宣言していることが確認できます。引数に Option 型が含まれない場合は自動で STRICT をつけた宣言が作成されるようになっています。 -- collatz--1.0.sql CREATE OR REPLACE FUNCTION "collatz_strict"("arg" integer) RETURNS integer STRICT IMMUTABLE LANGUAGE c AS 'MODULE_PATHNAME', 'collatz_strict_wrapper'; CREATE OR REPLACE FUNCTION "collatz"("arg" integer) RETURNS integer IMMUTABLE LANGUAGE c AS 'MODULE_PATHNAME', 'collatz_wrapper'; 必要なファイルを移動し、extensionとして登録します。 $ sudo mv target/release/collatz-pg13/usr/local/pgsql-13.1/lib/collatz.so /usr/local/pgsql/lib/ $ sudo mv target/release/collatz-pg13/usr/local/pgsql-13.1/share/extension/collatz* /usr/local/pgsql/share/extension/ -- create extension collatz; CREATE EXTENSION select collatz(12); collatz --------- 9 速度比較 作成したそれぞれの関数について100万回実行時の速度を検証します。 -- テスト用テーブルを作成 # create table numbers as (select generate_series(1,1000000) as num); SELECT 1000000 Time: 1210.538 ms -- 結果が等しいことを確認 # select * from (select num, collatz(num) as rust, collatz_c(num) as c from numbers)s where rust!=c ; num | rust | c -----+------+--- (0 rows) -- 速度検証用のコマンド # select sum(collatz_strict(num)) from numbers ; sum ----------- 131434424 (1 row) # select sum(collatz_c(num)) from numbers ; sum ----------- 131434424 (1 row) 各関数について3回実行したところ下記の結果となりました。 関数 1回目[ms] 2回目[ms] 3回目[ms] C( collatz_c ) 464.390 475.266 465.974 Rust( collatz_strict ) 455.088 449.032 461.443 Rust( collatz ) 446.275 442.574 451.141 あくまで私の環境での実測値になりますが、Rust実装の方がC実装よりも高速に処理されることが確認できました。環境構築やテストの利便性を考えればRustに移行したほうがよいと考えられます。 まとめ 本記事ではPostgreSQLのユーザー定義関数をpgxを使ってRustで実装しました。C言語実装の関数と比べて遅くないことを確認しました。 本記事では紹介しきれませんでしたが、pgxには一通り必要な機能が揃っているように感じました。新しい関数は当然Rustで書くよねという時代が来るかもしれません。 フォルシアではPostgreSQLのパフォーマンス改善に強いエンジニアを募集しています。
アバター
本記事はNext.js Advent Calendar 2020の 22 日目の記事です。 ! 2020年12月時点の情報です Next.js 13(2022年10月リリース)で next/image は大幅に刷新され、本記事で解説している layout, objectFit, objectPosition などのプロパティは廃止されました。また、バージョンの違いにより本記事の記載内容と公式ドキュメントの内容が一部異なっている場合があります。 こんにちは。旅行プラットフォーム部エンジニアの東川です。 フォルシアではフロントエンドフレームワークとして Next.js を使用しています
アバター