TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

こんにちは、ECプラットフォーム部の権守です。普段はID基盤やAPI Gatewayの開発を行い、ZOZOTOWNのリプレイスに携わっています。 本記事では、ID基盤で開発・導入したMySQL実行計画の簡易検査を行うツールを紹介します。 ツール開発の経緯 RDBにおけるテーブル設計は利用するクエリに応じて適切なインデックスを設定するなど専門的な知識を必要とし、設計できる人が限られてきます。しかし、アプリケーション上で利用されるクエリは機能の追加・改修に伴って日々変化していくため、それら全てに目を通し、漏れなく適切な設計することは困難です。そこで、専門的な知識がなくても設計に問題がないかの簡易的な検査を行えるツールを開発し、CIに組み込むことで自動的に問題を検出できるようにしました。 ツール開発のアプローチ ID基盤ではDBMSとしてAmazon Aurora MySQLを使用しています。そのため、ツールはMySQL向けのものとして開発しました。また、 Amazon Aurora MySQL 2.0がMySQL 5.7.12によるバグ修正までを取り込んでいる 背景から、動作検証もMySQL 5.7.12で行っています。 スロークエリログを用いたアプローチ スロークエリログ を監視することで、テーブル設計の不備を検知できる場合があります。MySQLのスロークエリログはクエリの所要時間が設定した値を超えている、または行参照にインデックスを使用していない場合にそのクエリを出力できます。 前者は、レコード数が増加し、クエリの所要時間が増えてきて初めて検知できるため、パフォーマンスの劣化を未然に防ぐには手間がかかります。具体的には、テスト環境に本番同等のスペックと十分なレコード数を用意し、検証する必要があります。 一方、後者の場合は、有用な情報ではありますが、問題を検知できないパターンがあります。次のような定義のテーブルとクエリを例に考えてみます。 CREATE TABLE `events` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR ( 32 ) NOT NULL , `from_date` DATE NOT NULL , ` to_date ` DATE NOT NULL , PRIMARY KEY(`id`), INDEX `idx_events_from_date_to_date`(`from_date`, ` to_date `) ); SELECT `id` FROM `events` WHERE ` to_date ` < " 2021-04-01 " ; この場合、MySQLのオプティマイザは idx_events_from_date_to_date インデックスを使用する可能性がありますが、その場合のスキャン範囲はインデックス全体となります。なぜなら、このインデックスは下に示す表のように from_date を優先する順序でインデックスツリーが構成されているため、 to_date に関する比較は全体を見る必要があるからです。スロークエリログはこのようなスキャン範囲が全体に及ぶような場合であってもインデックスを使用したということでログには出力されません。 from_date to_date 2020-04-01 2021-08-01 2020-08-01 2021-03-31 2020-10-01 2021-04-30 2020-12-01 2021-02-28 EXPLAINステートメントを用いたアプローチ EXPLAINステートメントを用いることで、クエリの実行計画に関する情報を知ることができます。実行計画とは、クエリが実行されるに当たり、どのようにテーブルのスキャンや結合、ソートなどが行われるかを示したものです。これを見ることでインデックスの使用の有無を知ることができ、どこにインデックスを追加するとパフォーマンスを改善できるかといったことが分かります。 このアプローチを採用するには次の課題があります。 検査対象のクエリの管理 EXPLAINステートメントの出力結果を元に、設計に不備がないかを判断するロジックの開発 スロークエリログを使ったアプローチであれば、クエリの実行に伴って自動的にログが出力されるため、検査対象を別途取得する必要がありません。一方、こちらのアプローチでは明示的にEXPLAINステートメントにクエリを与える必要があるため、そのクエリを管理しなければいけません。また、EXPLAINステートメントの結果は独自の出力フォーマットであるため、そのままでは前提知識のない人が見ても、結果に問題があるのかの判断ができません。 しかし、これらの課題を解決できれば、前述したスロークエリログを用いたアプローチでは対応できなかったパターンにも対応できるようになります。そのため、今回はこちらのアプローチを採用しました。 検査対象のクエリの管理 EXPLAINステートメントの対象とするクエリを管理する必要がありますが、そのための設定ファイルを作ってしまうと、アプリケーションコード中に記述されているものと二重管理になってしまいます。その結果、クエリを追加・変更するような機能の改修が入った際に設定ファイルの更新も必要になってしまい、漏れなく管理することは困難です。そのため、二重管理しないで済む方法を検討しました。 検査対象の取得 シンプルに考えると「アプリケーションコードを解析して抽出する方法」があります。しかし、プレースホルダーを含むクエリの場合には、クエリに与える引数の動的解析が必要となり実装が複雑になります。そこで、MySQLの 一般クエリログ に注目しました。一般クエリログはMySQLサーバーが受け取ったSQLステートメントを記録したものです。今回はテストコードを実行した際に出力される一般クエリログを用いて、検査対象のクエリを取得するようにしました。 検査対象の選別 テスト実行によって出力された一般クエリログを利用することで、アプリケーション上で利用されるクエリを取得できます。しかし、それらのクエリの中には検査対象外としたいクエリが含まれることもあります。例えば、書き込み操作が多いテーブルでは、インデックスを意図的に追加していない場合もあります。そのような場合を考慮し、除外するクエリを判別するためのアノテーションをアプリケーションコード中に与えることを考えました。なお、詳細は後述しますが、アプリケーションコードにはGo言語を利用しています。 // @mydctr:skip q := "SELECT * FROM `users` WHERE `name` = ?" 上記のコードのコメント部分がアノテーションに相当し、このクエリを検査対象外とすることを示しています。 mydctr は開発したツールの名称で、アノテーションのプレフィックスとして記述しています。 EXPLAINステートメントの出力結果の判断ロジック まず、EXPLAINステートメントの 出力フォーマット の理解が必要です。今回は type カラムと Extra カラムに注目しました。 type カラムはテーブルへのレコードアクセスをどのように行っているかを、 Extra カラムはオプティマイザがどのような戦略を選択したかなどの追加情報を示しています。 今回のツールでは、検査によって以下の2つのパターンを検出したいと考えました。 検索条件の解決に適切なインデックスがなく、テーブル全体もしくはインデックスツリー全体のスキャンが行われるパターン ソートをインデックスのみで解決できず、クイックソートが行われるパターン 1つの目のパターンを判断するには、まず、 type カラムの値に注目します。 type カラムが ALL の場合にはテーブル全体を、 index の場合にはインデックスツリー全体をスキャンしていることが分かります。しかし、これだけで判断すると WHERE 句を利用していないクエリでもテーブル全体、またはインデックスツリー全体をスキャンするため、警告が出力されてしまいます。そこで、 Extra カラムに Using where の値が含まれているかどうかも合わせて確認する必要があります。 Using where があれば行がフェッチされた後に、 WHERE 句によって絞り込まれていることが分かります。そのため、 WHERE 句を利用していないクエリを対象から除外できます。 2つ目のパターンを判断するには、 Extra カラムの値に Using filesort の値が含まれているかどうかを確認することで判断できます。 ツールの実装とCIへの組み込み ツールの具体的な実装方法を紹介します。 開発言語にはID基盤の技術スタックと同じGo言語を採用しました。採用した理由は、ID基盤を構成するGoプログラムからアノテーションを抽出する際に、 go/parser パッケージを用いることで簡単にGoプログラムを構文解析できるからです。 一般クエリログからのクエリ取得 まず、一般クエリログのフォーマットを簡単に説明します。 ログファイルには以下のように各行に操作時刻、接続ID、コマンド、コマンド引数が順に記録されています。 2021-05-24T07:36:41.773817+09:00 1 Query SELECT `name` FROM `users` WHERE `id` = 1 プリペアドステートメントを利用した場合は次のように出力されます。 2021-05-24T07:38:02.260023+09:00 1 Prepare SELECT `name` FROM `users` WHERE `id` = ? 2021-05-24T07:38:02.260696+09:00 1 Execute SELECT `name` FROM `users` WHERE `id` = 1 2021-05-24T07:38:02.262475+09:00 1 Close stmt また、接続時には次のようにログが記録されます。 2021-05-24T07:37:41.808776+09:00 3 Connect user@host on db using TCP/IP 一般クエリログにはシステムデータベースの作成といった初期化のクエリも含まれます。テスト実行中に出力されたクエリだけを取得するために、初期化のクエリをスキップします。初期化のクエリはrootユーザーで実行されるため、テスト内で利用されるユーザーによる接続ログまでスキップすることで、初期化のクエリを取り除けます。 テスト実行中のログにも検査に利用しないログが多く含まれています。ここから検査に利用するクエリを抽出するにはコマンドが Query もしくは Execute であるログのクエリだけに絞ればよいので、その条件を表す正規表現を用います。 具体的な実装の例を以下に示します。 func extractQueries(user string , generalLog io.Reader ) ([] string , error ) { re, e := regexp.Compile( ` (Execute|Query)\t(.+)$` ) if e != nil { return nil , e } queries := [] string {} scanner := bufio.NewScanner(generalLog) skipped := false stopper := fmt.Sprintf( "Connect \t %v@" , user) for scanner.Scan() { line := scanner.Text() if !skipped && !strings.Contains(line, stopper) { continue } skipped = true matches := re.FindStringSubmatch(line) if len (matches) > 1 { queries = append (queries, matches[ 2 ]) } } if e := scanner.Err(); e != nil { return nil , e } return queries, nil } Goプログラム中のアノテーションの抽出 ID基盤ではGo言語を用いて開発しているため、Goプログラム中のアノテーションの抽出を実装しました。 まず、検査対象とするアプリケーション配下のGoファイルのファイルパス一覧を取得します。次に、各Goファイルからコメント部分を取得するために構文解析し、取得したコメントを正規表現でアノテーションのフォーマットに一致するコメントだけに絞り込みます。アノテーションコメントを絞り込んだ後は、コメントが掛かっている変数への代入文を取得し、代入しているSQLのクエリ文字列を抽出します。コメントに書かれたアノテーションの種類毎にクエリの一覧を作成し、最終的にJSON形式で出力します。 例えば、アプリケーションコード中に次のような記述があるとします。 // @mydctr:skip q1 := "SELECT * FROM `users` WHERE `name` = ?" // @mydctr:skip q2 := "SELECT * FROM `users` WHERE `age` > ?" この場合、次のようなJSONが出力されます。 { " skip ": [ " SELECT * FROM `users` WHERE `name` = ? "," SELECT * FROM `users` WHERE `age` > ? " ]} 具体的な実装は以下の通りです。 type annotation string const ( annotationSkip annotation = "skip" annotationTodo annotation = "todo" annotationAll annotation = "all" annotationFilesort annotation = "filesort" annotationTemporary annotation = "temporary" annotationPrefix = "@mydctr:" ) var annotationExpression = regexp.MustCompile(annotationPrefix + "(.+)" ) func NewAnnotation(a string ) (annotation, error ) { switch annotation(a) { case annotationAll, annotationSkip, annotationTodo, annotationFilesort, annotationTemporary: return annotation(a), nil } return "" , errors.New( "invalid annotation" ) } func ExtractAnnotatedQueries(dir string ) ([] byte , error ) { annotatedQueries := map [annotation][] string {} // 指定したディレクトリ配下のファイル・ディレクトリに対する処理 e := filepath.Walk(dir, func (path string , info os.FileInfo, e error ) error { if e != nil { return e } // Goファイルのみに処理を限定 if !strings.HasSuffix(info.Name(), ".go" ) { return nil } queries, e := extractAnnotatedQueries(path) if e != nil { return e } for k, v := range queries { annotatedQueries[k] = append (annotatedQueries[k], v...) } return nil }) if e != nil { return nil , e } return json.Marshal(annotatedQueries) } func extractAnnotatedQueries(filename string ) ( map [annotation][] string , error ) { annotatedQueries := map [annotation][] string {} fset := token.NewFileSet() // Goファイルを構文解析 f, e := parser.ParseFile(fset, filename, nil , parser.ParseComments) if e != nil { return nil , e } // コメントとその対象の関連付けを取得 commentMap := ast.NewCommentMap(fset, f, f.Comments) for node, commentGroups := range commentMap { annotationComment := func () string { for _, commentGroup := range commentGroups { for _, comment := range commentGroup.List { // 正規表現を用いてアノテーションコメントのみを取得 matches := annotationExpression.FindStringSubmatch(comment.Text) if len (matches) <= 1 { continue } return matches[ 1 ] } } return "" }() if annotationComment == "" { continue } // コメントに記述されたアノテーションの検証 annotation, e := NewAnnotation(annotationComment) if e != nil { return nil , e } // コメントの対象が代入文であることをチェック if stmt, ok := node.(*ast.AssignStmt); ok { // 複数代入の場合には右辺も複数になる for _, expr := range stmt.Rhs { // 右辺に文字列の結合が含まれる場合にも対応 joined, e := joinStringExpression(expr) if e != nil { return nil , e } annotatedQueries[annotation] = append (annotatedQueries[annotation], joined) } } } return annotatedQueries, nil } func joinStringExpression(expr ast.Expr) ( string , error ) { switch expression := expr.( type ) { case *ast.BinaryExpr: // 文字列結合の場合には再帰的に呼び出し if expression.Op != token.ADD { return "" , errors.New( "contains not add operator" ) } x, e := joinStringExpression(expression.X) if e != nil { return "" , e } y, e := joinStringExpression(expression.Y) if e != nil { return "" , e } return x + y, nil case *ast.BasicLit: if expression.Kind != token.STRING { return "" , errors.New( "contains not string literal" ) } fset := token.NewFileSet() // \nなどにも対応するためにEvalを利用 evaluated, e := types.Eval(fset, nil , token.NoPos, expression.Value) if e != nil { return "" , e } return constant.StringVal(evaluated.Value), nil } return "" , errors.New( "contains not supported expression" ) } EXPLAINステートメント結果の解析 EXPLAINステートメントを実行する前に一般クエリログから抽出したクエリに対し、アノテーションの存在有無を確認します。Goプログラムから抽出したクエリはプレースホルダーを含むものなので、実行された具体的な値を伴うクエリと比較する際には正規表現における任意の文字列を表す表記に置き換えて比較します。 例えば、次のようなアノテーションが抽出されたとします。 { " skip ": [ " SELECT * FROM `users` WHERE `name` = ? " ]} この場合、次のような正規表現に置き換えて比較されます。 "SELECT * FROM `users` WHERE `name` = .+" 実装は以下の通りです。 var annotatedQueries map [annotation][] string e := json.NewDecoder(annotationReader).Decode(&annotatedQueries) if e != nil { return e } // プレースホルダーを含むクエリを正規表現に変換 annotationPatterns := map [annotation][]*regexp.Regexp{} for a, queries := range annotatedQueries { patterns := []*regexp.Regexp{} for _, q := range queries { pattern, e := regexp.Compile(strings.ReplaceAll(regexp.QuoteMeta(q), `\?` , ".+" )) if e != nil { return e } patterns = append (patterns, pattern) } annotationPatterns[a] = patterns } func findAnnotations(query string , patternMap map [annotation][]*regexp.Regexp) map [annotation] struct {} { annotations := map [annotation] struct {}{} for a, patterns := range patternMap { found := func (query string , patterns []*regexp.Regexp) bool { for _, pattern := range patterns { matched := pattern.MatchString(query) if matched { return true } } return false }(query, patterns) if found { annotations[a] = struct {}{} } } return annotations } skip アノテーション、もしくは todo アノテーションが存在した場合にはクエリを検査対象外とします。それ以外の場合にEXPLAINステートメントを実行していきます。 todo アノテーションは修正予定のあるものに、 skip アノテーションはテストコード内でのみ利用されるクエリなどの修正予定がないものに使う想定です。 EXPLAINステートメントの出力結果を元に、修正を要する可能性があるものに対して警告を出力します。本記事の執筆時点では警告の種類は次のものを実装しています。 全体のスキャン ファイルソート 一時テーブル 「EXPLAINステートメントの出力結果の判断ロジック」の章で挙げたパターンに加え、一時テーブルの使用に関しても警告を出力するようにしました。 全体のスキャンに関する警告は適切なインデックスが設定されておらず、テーブル全体もしくはインデックスツリー全体のスキャンが行われる場合に出力されます。 ここで注意すべき点は、テーブル内のデータに依存してEXPLAINステートメントの出力結果が変わることです。例えば、適切にインデックスが設定されており、インデックスの範囲検索が有効となる条件文を含むクエリがあるとします。この場合、通常は条件に一致する範囲のみをスキャンするのでインデックスツリー全体のスキャンとはなりません。しかし、条件の値が全件を取得するような値であった場合、EXPLAINステートメントの出力結果はインデックスツリー全体のスキャンを表すこととなり、警告が出力されてしまいます。 この問題を回避するには、実際のクエリ実行時に格納されているデータと近い傾向のデータをダミーデータとして検査実行前にテーブルへ挿入しておくことが必要です。また、テーブル内のレコード数が数件しかない場合には、オプティマイザがインデックスを使うよりテーブル全体をスキャンした方が速いと判断する場合もあります。これに関してはダミーデータをある程度入れておくことで誤った警告が出力されることを回避できます。実装上、本当に全体のスキャンが必要な場合にはツールにそれを知らせるために all アノテーションを使います。これにより、この警告の対象外にできます。 ファイルソートの警告はソート時にインデックスを利用できずソート処理が実行される場合に出力されます。ソートのためのインデックスをあえて追加しない場合には、 filesort アノテーションを使うことでこの警告の対象外にできます。 一時テーブルの警告はクエリの実行中に一時テーブルを必要とする場合に出力されます。一時テーブルはその大きさが小さい場合はメモリ上に作成されますが、大きくなった場合にはディスク上に作成されます。ディスク上へテーブル作成するコストを考慮すると、一時テーブルを必要としている場合には何らかの対応を求められる可能性が高いです。 具体的な対応としては、クエリの改善やメモリ上に作成する一時テーブルの最大サイズを設定する tmp_table_size 変数の調整が必要になる場合があります。この警告は実装の容易さに対して、有用な情報であると判断したので、追加で実装しました。 集計用のクエリなど一時テーブルの利用を許容する場合には、 temporary アノテーションを使うことでこの警告の対象外にできます。 実装は以下の通りです。 type DSN struct { User string Password string Host string Port int DB string } type explainResult struct { id int selectType sql.NullString table sql.NullString partitions sql.NullString joinType sql.NullString possibleKeys sql.NullString key sql.NullString keyLen sql.NullInt32 ref sql.NullString rows sql.NullInt64 filtered sql.NullFloat64 extra sql.NullString } func Examine(generalLog io.Reader , annotationReader io.Reader , dsn DSN) error { // ... プレースホルダーを含むクエリの正規表現変換処理 db, e := sql.Open( "mysql" , fmt.Sprintf( "%v:%v@tcp(%v:%d)/%v" , dsn.User, dsn.Password, dsn.Host, dsn.Port, dsn.DB)) if e != nil { return e } defer db.Close() queries, e := extractQueries(dsn.User, generalLog) if e != nil { return e } for _, q := range queries { // 検査対象をSELECT、UPDATE、DELETEに限定 if !strings.HasPrefix(q, "SELECT" ) && !strings.HasPrefix(q, "UPDATE" ) && !strings.HasPrefix(q, "DELETE" ) { continue } annotations := findAnnotations(q, annotationPatterns) if _, ok := annotations[annotationSkip]; ok { continue } if _, ok := annotations[annotationTodo]; ok { continue } // EXPLAINステートメントの実行 rows, e := db.Query( "EXPLAIN " + q) if e != nil { panic (e) } defer rows.Close() // EXPLAINステートメントの出力結果を元に警告を作成 warnings := [] string {} results := []explainResult{} for rows.Next() { var r explainResult e = rows.Scan(&r.id, &r.selectType, &r.table, &r.partitions, &r.joinType, &r.possibleKeys, &r.key, &r.keyLen, &r.ref, &r.rows, &r.filtered, &r.extra) if e != nil { panic (e) } if r.joinType.Valid && (r.joinType.String == "ALL" || r.joinType.String == "index" ) && r.extra.Valid && strings.Contains(r.extra.String, "Using where" ) { if _, ok := annotations[annotationAll]; !ok { warnings = append (warnings, "絞り込みに必要なインデックスが不足している可能性があります" ) } } if strings.Contains(r.extra.String, "Using filesort" ) { if _, ok := annotations[annotationFilesort]; !ok { warnings = append (warnings, "インデックスが用いられていないソート処理が行われています" ) } } if strings.Contains(r.extra.String, "Using temporary" ) { if _, ok := annotations[annotationTemporary]; !ok { warnings = append (warnings, "クエリの実行に一時テーブルを必要としています" ) } } results = append (results, r) } if e := rows.Close(); e != nil { panic (e) } if e := rows.Err(); e != nil { panic (e) } if len (warnings) == 0 { continue } // 警告がある場合に、クエリとEXPLAINステートメントの出力結果を伴って出力 fmt.Println(q) table := tablewriter.NewWriter(os.Stdout) table.SetHeader([] string { "id" , "select_type" , "table" , "partitions" , "type" , "possible_keys" , "key" , "key_len" , "ref" , "rows" , "filtered" , "extra" }) for _, r := range results { table.Append([] string { strconv.Itoa(r.id), mapFromNullString(r.selectType), mapFromNullString(r.table), mapFromNullString(r.partitions), mapFromNullString(r.joinType), mapFromNullString(r.possibleKeys), mapFromNullString(r.key), mapFromNullInt32(r.keyLen), mapFromNullString(r.ref), mapFromNullInt64(r.rows), mapFromNullFloat64(r.filtered), mapFromNullString(r.extra), }) } table.Render() for _, warning := range warnings { fmt.Println(warning) } fmt.Println( "" ) } return nil } func mapFromNullString(s sql.NullString) string { if !s.Valid { return "NULL" } return s.String } func mapFromNullInt32(s sql.NullInt32) string { if !s.Valid { return "NULL" } return strconv.Itoa( int (s.Int32)) } func mapFromNullInt64(s sql.NullInt64) string { if !s.Valid { return "NULL" } return strconv.Itoa( int (s.Int64)) } func mapFromNullFloat64(s sql.NullFloat64) string { if !s.Valid { return "NULL" } return fmt.Sprintf( "%f" , s.Float64) } CIへの組み込み CIへの組み込みは次のような操作で実現できます。 テストコード実行 テスト用データベース内のデータクリア ダミーデータの挿入 アノテーション抽出 検査実行 既存テストは、プルリクエストの作成・更新・マージのタイミングで実行するようしています。上記の操作は既存テストに追加する形で実行されるように組み込んだので、同様にプルリクエストの作成・更新・マージのタイミングで実行されます。 アノテーションの抽出は、現状はGo言語にしか対応していないため、他の言語によって実装されたアプリケーションで利用する場合は 4. のステップを省略しています。ただし、アノテーションはシンプルなJSONで表現されているため、手動で管理することによって検査ツールにアノテーションを与えることは可能です。 まとめ MySQLの実行計画を簡易的に検査するためのツールを開発し、CIに組み込みました。それによってリリース前に問題のある実行計画をある程度把握できるようになりました。 今後の改善点は、一般クエリログに同様のクエリが存在した場合に同じ警告を重複して出力しないようにまとめあげる点などが挙げられます。また、開発して間もないので運用を通して改善した後にOSSとして公開できればと考えています。 最後に、ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは。ブランドソリューション開発部 プロダクト開発チームの杉田です。 Fulfillment by ZOZO (以下、FBZ)が提供するAPIシステムの開発・運用を担当しています。 本記事では、サーバーレスアーキテクチャを採用しているFBZのAPIを例に、Datadog APMを使った分散トレーシングの導入手順と運用する際のポイントを紹介します。 「サーバーレスアーキテクチャを採用しているけど分散トレーシングを導入していない」という方や、「既にDatadogは活用しているけどAPMの機能は使っていない」という方に読んでいただけると幸いです。 FBZにおけるサービス監視 FBZでは、CloudWatchメトリクスやAWS Lambda、API Gatewayのログを解析し、PagerDutyやDatadogなどの外部サービスに連携して監視をしています。最近では、Lambda Destinationsの活用や頻度ベースによるアラート通知を実施したことで、サービス監視に要していた運用工数の削減を実現しました。 運用改善のためにアラート最適化を行った事例を過去記事で紹介しているので、併せて御覧ください。 techblog.zozo.com サーバーレス監視の課題 アラート通知の最適化により、サービス監視を効率的に行える環境を構築できました。その結果、APIにリクエストが送られてからレスポンスを返すまで、以下のような処理の流れの構成になっています。 しかし、この処理の流れを起因とする課題も残っていました。サーバーレスアーキテクチャでは、複数のサービスを組み合わせて構築するため、1サービスだけのログや個々のメトリクスだけではボトルネックの特定は困難です。特に、FBZ APIでレスポンスの遅延やタイムアウトが発生した場合、AWS LambdaやAPI Gatewayなど複数ログをまたがって確認する必要があり、調査しづらいという課題がありました。 分散トレーシングの導入 前述の課題を解決するために、Datadog社が提供している Datadog APM というサービスを導入しました。分散トレーシングを実現するためのツールはいくつかありましたが、以下の理由でDatadog APMの採用に至りました。 Serverless Frameworkのインテグレーションが存在した 複数のAWSアカウントによる運用をしていたため、各アカウントの情報を一元管理してシームレスにサービス監視できる環境を作りたかった 既にログ監視でDatadogを使っており、社内にDatadogに関するノウハウがあった Datadog APMのセットアップ 本章では、Datadog APMのセットアップ手順を紹介します。 Datadogのマニュアル に従ってセットアップを行います。 AWSインテグレーションのインストール AWSインテグレーション を設定することで、DatadogがCloudWatchからLambdaメトリクスを取り込むことができるようになります。 インテグレーションのインストールを行う過程で、 Datadog Forwarder と呼ばれるLambdaが作成されます。このDatadog ForwarderがAWSの各種サービスの情報をDatadogに対して送信します。 なお、 こちらのページ からもDatadog Forwarderのインストールを行えますが、AWSインテグレーションのインストールで既に作成されている場合はスキップしてください。 Datadog Serverless Pluginの設定 FBZのAPIは Serverless Framework を使って開発しています。DatadogがServerless Framework向けのPluginである Datadog Serverless Plugin を提供しているので、それを利用します。このプラグインはメトリクス、トレース、ログをDatadogに送信するLambdaレイヤーを作成します。 Datadog Serverless Pluginのセットアップは、 こちら を参考にして実施します。 下記3つの手順によって、Datadog Serverless Pluginのインストールと設定が可能です。 # Datadog Serverless Pluginのインストール $ yarn add --dev serverless-plugin-datadog # serverless.yml # プラグインを追加 plugins : - serverless-plugin-datadog # serverless.yml # セクションを追加 custom : datadog : addExtension : true apiKey : # Your Datadog API Key goes here. その他、プラグインに関する詳細は DatadogのGitHubリポジトリ にまとめられています。 以下に示す設定例は、上記リポジトリに記載されているものです。ログレベルやタグの付与に関する設定が可能です。 # serverless.yml # パラメータの例 custom : datadog : flushMetricsToLogs : true apiKey : "{Datadog_API_Key}" apiKMSKey : "{Encrypted_Datadog_API_Key}" addLayers : true logLevel : "info" enableXrayTracing : false enableDDTracing : true forwarderArn : arn:aws:lambda:us-east-1:000000000000:function:datadog-forwarder enableTags : true injectLogContext : true exclude : - dd-excluded-function 以上の設定で、分散トレーシングができるようになります。 セットアップ時の注意点 実際にDatadog APMをセットアップしていく中で、いくつか注意すべき点を発見しました。本章では、その注意点を説明します。 リージョンの統一 AWSインテグレーションのインストール時には、CloudFormationを使ってスタックを作成します。その際、リージョンがデフォルトでは us-east-1 となっているため、必要に応じて監視対象のLambda関数と同じリージョンに変更する必要があります。この設定を間違えると、DatadogとAWSの連携ができなくなるので、再度セットアップをやり直すことになります。 ログとトレースの接続 アプリケーションから出力されるログとトレースした情報を接続するには、以下の2つの作業が必要となります。 アプリケーション側 ログにトレースIDを挿入 Datadog側 パイプラインの設定 トレースIDの挿入 Datadog Serverless Pluginのオプションで injectLogContext: true とすると、ログにトレースIDやスパンIDが自動で挿入されます。しかし、CloudWatchに出力しているログフォーマットを独自に設定している場合、それらの設定が上書かれて異なるログフォーマットとなってしまいます。 FBZのAPIではログの可読性向上を目的として、LambdaのログフォーマットをLTSV形式の独自フォーマットに変更しています。そのため、 こちら を参考にし、手動で分散トレースに必要なログを設定しました。 # FBZで利用しているLTSV形式のログフォーマット FORMAT = ( # 省略 ' \t datadog:[dd.trace_id=%(dd.trace_id)s dd.span_id=%(dd.span_id)s]' # 接続のために追加 # 省略 ) パイプラインの設定 前述のように、ログの設定をしただけではトレースとの接続はできません。手動で設定したログを、Datadogの共通形式にするために、ログのデータ構造を変換する必要があります。 こちら を参考に設定しました。 AWSインテグレーションをインストールした際に自動作成されたパイプラインは編集できないので、クローンして新たなパイプラインを作成します。 作成したパイプラインの中から、 Grok Parser: Parsing Lambda logs という名前のプロセッサーを選択して編集します。 Log samplesの5つの項目のいずれかにログのサンプルをセットし、マッチするかを確認しながらパースのルールを書いていきます。この際にヘルパールールを用いることで目的ごとに名付けができます。 # LTSV形式をパースするルール例 # Sample Rule sample_rule %{datadog_trace} # Helper Rules datadog_trace datadog:(\[dd.trace_id=%{word:dd.trace_id} dd.span_id=%{word:dd.span_id}\]) この設定が完了すると、ログとトレースの接続が実現されます。 運用上でのポイント 次に、実際に運用する中で得られたポイントとなる点を紹介します。 タグの活用 Datadogでは env service version の3つのタグが予約済みタグとして利用されています。公式ドキュメントでは、タグを使うことで以下の3つが可能となると書かれています。 バージョンでフィルタリングされたトレースおよびコンテナメトリクスでデプロイへの影響を特定する 一貫性のあるタグを使用して、トレース、メトリクス、ログ間をシームレスに移動する Datadogアプリ内で統一された方法で環境またはバージョンに基づいてサービスデータを表示する 公式ドキュメント より引用 FBZでも、タグを使ってフィルタすることで検索性が向上したり、APIやBatchといった処理系ごとの処理時間の傾向などを確認できるようになりました。 タグの自動付与 Datadog Serverless Pluginをインストールすると、 serverless.yml に定義している値からLambdaに対して自動的にタグ付けをしてくれます。プラグインの機能を使うと各Lambdaで重複するタグを書かなくてもよくなるので、定義ファイルへの記述を減らすことができます。 # serverless.yml service : service-name provider : name : aws stage : prod # Lambdaのenvタグに「prod」が付与される plugins : - serverless-plugin-datadog functions : hello : handler : handler.hello # この関数は、プラグインによって上記で構成されたサービスレベルのタグを継承する # tags: #env: provider.stageの値が自動付与される #service: serviceの値が自動付与される world : # この関数は、タグを上書きする handler : handler.users tags : env : "<ENV>" service : "<SERVICE>" タグに関する詳細は 公式ドキュメント を御覧ください。 まとめ Datadog APMのServerless Frameworkへの導入方法から運用する際のポイントを紹介しました。 サーバーレスアーキテクチャで運用している場合、内部でいくつものサービスを経由して処理が進みます。そのため、サービス間の依存関係やパフォーマンス面で問題が発生した場合、調査が困難になることが多いです。その課題の解決のために、Datadog APMを導入することで既存のコードへの修正は最小限に抑えながら、分散トレーシングを実現できました。 分散トレーシングを実現できたことで、上図のようにタイムアウトが発生した際のボトルネックの箇所の特定が容易になり、各サービスのレイテンシを可視化できるようになりました。 さいごに ZOZOテクノロジーズでは、サーバーレスアーキテクチャやAWSのマネージドサービスを活用しサービスを成長させていきたい仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。ZOZOアプリ部の遠藤と林です。 日本時間の6月8日から12日にかけて開催され今年の WWDC21 も、昨年と同様にオンライン開催でした。 FaceTimeの新機能であるSharePlayや、プライバシーが更に強化されたiCloud+、アプリ開発を一元管理できるようになったXcode 13など、新機能から開発環境周りまで幅広い発表が目白押しでした。 本記事ではオンライン開催が2年目になったWWDC21に対し、弊社のエンジニアが昨年の経験を活かしてどのように臨んだのか、また開催期間中の活動内容をお伝えします。今年3月に10年ぶりにリニューアルしたZOZOTOWNアプリとZOZOGLASSに対するデザインフィードバックも可能な範囲で紹介しますので、是非最後までご覧ください。 WWDCの概要 WWDC(Worldwide Developers Conference)は、Appleが年に1度開催している開発者向けのカンファレンスです。iOS、iPadOS、macOS、watchOS、tvOSのアップデートをはじめ、開発環境周りの新機能などが発表されます。また、例年通りの各種セッションやLabsに加え、今年は新たに「Digital Lounges」と「Challenges」が追加されました。それらの新しい体験を加えた形でWWDCを楽しむことができます。 昨年の経験を活かしたオンライン参加の工夫 オンライン開催のWWDCへの参加も2回目ということで、昨年の経験を活かしてより効果的な参加方法を模索しました。その結果、開催日までの事前準備や、開催期間中の情報共有も昨年よりもスムーズに行えました。ここでは、そこで期間中の働き方や、実施した事前準備や情報共有の工夫点を紹介します。 開催期間中の働き方 弊社では海外カンファレンスへの参加が推奨・サポートされており、オンライン開催でも業務の一環として参加できます。 1-on-1 Developer Labs (以下、ラボ)へ参加することもあり、今年も昨年同様、希望したメンバーは業務調整を事前に行った上で現地時間に合わせた勤務時間として参加しました。昨年の内容は以下の記事をご覧ください。 techblog.zozo.com 現地時間に合わせて参加するメンバーは、以下の点を考慮してスケジュールを組みました。 現地時間に合わせた勤務時間の調整 日本時間2:00〜11:00を勤務時間とする 休日出勤を利用した勤務日調整 最終日が日本では土曜日なので、該当の6月12日を休日出勤として、振替休日を取得する 事前準備と情報共有にMiroを活用 昨年もラボやセッションへ参加するための準備・共有は実施していました。質問したい内容をスプレッドシートで管理し、各自が参加するセッションをSlackで共有していました。しかし、昨年のやり方では情報が分散してしまうことと、情報が流れてしまうことが課題として挙がりました。そこで、今年は Miro を使用した情報の一元管理を実施しました。 Miroのボードには、ラボでの質問だけではなく、後日社内に向けて共有すべき情報を書き込めるようにしました。 WWDC21の全期間を終え、Miroにはたくさんの情報が集約されました。以下の画像は開催前と開催後のMiroのボードを比較したものです。開催後の一番大きい枠は、ラボに関する内容で、11個の質問がまとめてあります。 WWDC21開催前 WWDC21開催後 事前準備やセッション、ラボで聞いた内容はMiroのマインドマップのテンプレートを使用して整理しました。要素を簡単に増やせ、その内容を繋げることで、あとから見たときに関連性を把握しやすくまとめることができました。 どのようにまとめたのか、その一部を紹介します。以下はラボで質問した内容をまとめた一例です。 予想ビンゴ! 毎年WWDCの開催前には、どのような発表がされるのかを予想し、SNS上に多数投稿されます。今年は、社内でそれを実施しました。何が発表されるのかをビンゴ形式で予想するようにし、楽しむ要素をプラスして実施しました。 ビンゴの結果を発表します。 下図の赤色の内容が正解したものです。残念ながらビンゴは成立しませんでした。しかし、「Scribbleの日本語対応」などいくつか予想が当たっている項目もありました。「iPadでXcodeが動く」についてズバリ当たりはしませんでしたが、「Swift PlaygroundsからApp Storeへアプリを公開できるようになった」ということで当たりと判定しました。 「ビンゴに書いた内容が当たるかな」とKeynoteで内容が発表される度にドキドキして楽しみながら参加できました。来年はビンゴが成立できるよう、リベンジしたいと思います。 WWDCの新しい楽しみ方 今年のWWDCには新しく2つの要素が追加されました。追加されたこれらの要素に参加したので、その内容を紹介します。 Digital Lounges まず1つ目は「Digital Lounges」です。こちらは、Slack上にデベロッパーツール、SwiftUI、アクセシビリティ、機械学習についてのチャンネルが用意され、そこで質問ができる仕組みでした。質問できる内容は限られていますが、ラボに行かなくてもSlack上でAppleのエンジニアとデザイナーに気軽にリアルタイムで質問できます。 そして、各チャンネルでは質問だけではなく、「Trivia Night」というクイズ大会などのイベントも開催されていました。Trivia Nightを通してAppleの歴史を知ることができ、楽しむことができるコンテンツでした。 Challenges 2つ目は「Challenges」です。Challengesというタイトルから推測できる通り、問題にチャレンジして達成できたら、その内容をApple Developer ForumsやDigital Loungesに共有して楽しむことができます。 出題された問題に、「Throwback with SwiftUI」というものがありました。これは、1984-2013年の範囲からランダムに指定された年に対し、「その年のUIっぽいもの」をSwiftUIで作るという問題です。 チャレンジしたメンバーは1984年を引いて、その年の有名なCMを再現していました。 出題される問題には関連するセッションのリンクも付いています。セッションを見るだけではなく、実際に手を動かして問題を解く体験もセットででき、とても楽しく参加できました。出題される問題はどれも面白く、毎日追加されるので、問題の追加をワクワクしながら過ごすこともできました。 Labs & Sessions この章では、WWDC21へ参加した社員が、それぞれ参加したラボやセッションの内容を紹介します。 Design Lab × ZOZOTOWN 最初の報告はZOZOアプリ部の林がお送りします。 WWDC21では、姿を消していたDesign Labが1年ぶりに復活しました。AppleのデザイナーにリニューアルしたZOZOTOWNアプリのフィードバックを頂いたので、その一部を紹介します。 ラージタイトルに関するフィードバック ZOZOTOWNアプリのリニューアル時にラージタイトルを導入しました。しかし、「お気に入り」ページのような上部にタブがある場合、標準のNavigationではラージタイトルを対応できないため独自で実装しました。 Human Interface Guidelines にも、このパターンに関する記載がないので、Appleのデザイナーに質問してみました。そして、「タブを切り替える際、ユーザーの気が散らないようにナビゲーションバーの状態を維持すべきだ」というフィードバックを頂きました。今後、UI改善をする際の参考にしたいと思います。 ダークモードに関するフィードバック ダークモードが普及していく中で、リニューアル後の白を基調としたZOZOTOWNアプリはダークモード対応の必要性が高くなってきます。その将来を見据えて、ダークモード対応の注意点を確認してきました。商品画像についてはダークモード用の画像を用意しなくてもいいということや、WebViewともバランスよくダークモードに合わせるべきなど、ダークモード対応について貴重なアドバイスを頂きました。 全体へのフィードバック 全体へのフィードバックとして、「ZOZOTOWNアプリは洗練されていて一貫性があるクリーンなECアプリであり、個人的にも気に入った」という嬉しいコメントを頂きました。今回の大規模なリニューアルに携わったメンバーとして、このようなコメントを頂くことができ、とてもやりがいを感じました。 Design Lab × ZOZOGLASS こんにちは、ZOZOアプリ部の松井です。ZOZOTOWNでは、自宅にいながら簡単にフェイスカラー計測ができる 「ZOZOGLASS(ゾゾグラス)」 を3月18日にリリースしました。このZOZOGLASSを使った一連の計測フローを、より使いやすいUIにするために、Appleのデザイナーに意見を聞いてみました。 計測フローをお見せしながら説明したところ、「一連の流れに筋が通っており、とても使いやすい」と言って頂けました。「手順やコンテンツについてきちんと説明がされているし、改善点がないくらい。私も使ってみたい!」という嬉しい感想を頂けました。「強いて改善点を挙げるならば、UXの流れの中で表示されるコンテンツが、何のために表示されているのか説明があれば分かりやすい」というフィードバックを頂けました。 全体を通し、コンテンツを見たユーザー自身が何をすれば良いか理解できること、コンテンツの表示理由が明確であることを意識しているように感じました。 Design Labに過去10回以上参加している同僚によると、ほとんどの場合はフィードバックが止まらなく、時間が足りなくなるようです。ところが、それに該当せず、非常に好評だったZOZOGLASSのUI/UX。まだお使いでない方は、是非Appleのデザイナーも認めるUI/UXを堪能してみてください。 Object CaptureはECにおけるARの利活用を加速させるか? ARやVRといったXR領域に注力している @ikkou です。ここ数年のWWDCではAugmented Reality(AR)関連の発表も続いているため、特にAR関連セッションを注視しています。 AR関連で特に注目すべきは 2D写真から3Dモデルを生成 する「Object Capture」です。これはMacでフォトグラメトリを実現するものです。既にLiDARが搭載されたiPad/iPhoneを使ったものもありますが、こちらは スキャンから3Dモデルを生成 する、似て非なるものです。 私は会期中にApple M1チップが搭載された私物のMacを購入し、 #WWDC21Challenges のお題でもある Object Captureを試しました 。その結果、ラフに撮影したにも関わらず、思いのほか綺麗なUSDZファイルが生成されて驚きました。 これまで、フォトグラメトリには「 RealityCapture 」を始めとする「有償」アプリケーションの利用が一般的でした。しかし、今回発表されたObject Captureは 動作するMacの機種に制限がある ものの「無償」です。 RealityCaptureは建造物のような広い範囲を対象とする「広域フォトグラメトリ」にも対応しています。対してObject Captureは広域フォトグラメトリ向きではなく、必ずしも比較すべき対象ではありません。それでも特定の用途に限っては、ECにおけるARの利活用の課題として挙げられる「3Dモデルの生成」を容易にします。 ARKitによってARが身近なものになり、Object CaptureによってARで映し出す3Dモデルの生成が容易になりました。今後「ARで商品を隅々まで眺めてから購入するという買い物体験」が今まで以上に加速することは想像に難くありません。非常に楽しみです。 自前実装で悩んだ日々にさようなら、UISheetPresentationControllerで頑張らないハーフモーダル こんにちは、 でらけん です。WEAR部のiOSチームで日々頑張っています。WEARアプリでは、類似画像の検索画面など、既にいくつかの画面でハーフモーダルを取り入れてきました。そのため、今回のWWDC21で、私は「 Customize and resize sheets in UIKit 」に釘付けでした。 実際に サンプルコード で動作を試してみたので、そこから得られた知見を紹介します。 リリースアプリで実装しているハーフモーダルは、モーダルに見立てたViewを1つ用意し、次の機能を組み合わせて動作させています。 UIPanGestureRecognizer パンジェスチャーによる移動量の監視 UIView.transform 移動量をViewの拡大・縮小へ反映 UIView.animate ジェスチャーを止めた際のViewの拡大・縮小のアニメーション UIGestureRecognizerDelegate モーダル内のCollectionViewのスクロールとジェスチャーを同時に認識させる スクロールを用いたモーダルの拡大・縮小 iOS 15からは、 UISheetPresentationController を使用することで、1.〜3. を自前で実装することなく、ハーフモーダルを実現することが可能になりました。4. は、スクロールのタイミングで animateChanges(_:) を使って拡大させることが可能です。こちらを使用することで、スクロールに限らずボタンをタップしたら拡大させるなど、様々なアクションと紐づけることができそうです。 まだ、一部のみを試した段階ではありますが、スワイプの制御を意識する必要がなくなるだけでも非常に負担が軽減されたと感じます。 dyld3時代でも「Static Frameworkをマージする」手法は有効性を持つか ZOZOアプリ部のげんです。ラボ(C, C++, Obj-C, compiler, analyzer, debugger, and linker lab)に参加し、「Dynamic Frameworkをstaticにビルドしてマージする手法」がdyld3時代となった現在でも有効性をもつのか、をAppleのエンジニアに確認しました。 結論は「少し効果あり」でした。 同時に「Xcodeからアプリをロードする場合はdyld2が使われる」ということと、Umbrella Frameworkに対して私はAppleのエンジニアと異なる認識を持っていることも分かりました。AppleがUmbrella Frameworkで指し示すものは「Frameworkの中にFrameworkが入っているもの」であり、「Static Frameworkをマージしたもの」ではないとのことでした。 そして、「Framework周りはストア審査の際に見られるので注意した方が良い」というアドバイスも頂きました。リジェクトされる可能性を考えると、確かに事前に注意した方が良さそうです。 テストでキーボード入力をいい感じにしたい テックリードの @banjun です。InternationalizationとClockKitとCamera Captureのラボに行ってきました。そのうちのひとつをご紹介します。 ZOZOTOWNのテストケースでは KIF を使ってタップやキーボード入力をシミュレートしていました。そのときのキーボードは目的の言語のものである必要があります。今まではテストコードということもありプライベートなAPIを活用していたのですが、最近は失敗することもあったため、ラボで聞いて解決を図ることにしました。 ラボでは、より安定しそうな公開APIの手法をいくつか提案して頂きました。例えば .asciiCapable をセットする、 insertText: で入れる、ペーストしてしまう、などです。それぞれの不利な点もあるそうですが、必要なシミュレートの粒度に応じて適切なものを選択できます。これで UIなんとかImpl クラスを直接触らなくて済む日が来るかもしれません。 まとめ 以上、WWDC21の参加レポートでした。 カンファレンスのオンライン開催が当たり前の時代になっていることを実感できたWWDCでしたね。「Digital Lounges」や「Challenges」など新しい仕組みを導入して、Appleはオンライン開催でも開発者がより情報をキャッチアップできるような試みを実施していました。弊社も昨年のオンライン参加の経験を活かして、Miroなどの活用で情報共有がよりスムーズにできました。 WWDC21の最後に、Appleから「2年連続でオンライン開催を実施してみたがどうだったか」「オフラインイベントに参加したいのか」のアンケートがありました。みなさんはいかがですか? さいごに ZOZOテクノロジーズでは、一緒にモダンなサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、WEAR部 運用改善チームの三浦です。普段は WEAR の運用改善を行っていますが、最近は新規プロジェクトの開発にも携わっています。 本記事では、WEARのS3への画像アップロード機能をインフラ・バックエンド両面からリプレイスを行い、パフォーマンスの向上と安全かつ効率的に運用保守を行えるよう改善をした事例を紹介します。 背景 現在取り組んでいる新規プロジェクトで、WEARの外部連携用APIを通してWEARへコーデ投稿をできる機能を作ることになりました。WEARのコーデ画像はAmazon S3で管理しており、今回作成するコーデ投稿機能でもWEARのバケットに対して画像をアップロードする必要があります。しかし、現状の画像アップロードの仕組みには様々な課題がありました。 その仕組みと課題の概要を説明します。 現状の画像アップロード機能の仕組み WEARの現状の画像アップロードの仕組みは以下の通りです。 WEARのアプリからAPIを呼び出し、WEARサーバーに画像アップロード APIからS3バケットへ画像をアップロード 画像がバケットへアップロードされたのをトリガーにAWS Lambdaが起動 Lambdaがアップロードされた画像のリサイズを行い、サイズ毎にバケットへ保存 画像の参照は、「現在使用しているCDN」から「Amazon CloudFront」を経由して行います。 課題 現状の画像アップロードの仕組みではインフラ、バックエンドそれぞれ下記のような課題を抱えていました。 インフラ インフラリソースの設定がコード管理されていない 開発環境用のS3バケットがないため、本番のバケットでテストをしなければならない Lambdaの画像のリサイズ処理が遅い 複数種類にリサイズして保存しているため、S3の利用料がかさむ バックエンド APIを経由してS3バケットへ画像をアップロードするので時間がかかる APIのリプレイスはRailsで行うが、このままリプレイスするとPumaのスレッドを長時間占有したり、メモリの使用量が増えるなどパフォーマンスに影響を及ぼす Railsでリプレイスを行う背景は 過去記事 で説明しているので、併せてご覧ください。 techblog.zozo.com これらの課題をクリアし、画像アップロード機能のパフォーマンスの向上と運用効率を改善するため、インフラ、バックエンド両面からリプレイスを行うことにしました。 実現したいこと 最終的なゴールは、画像アップロードのリプレイスをすることにより、前述の保守・開発・パフォーマンスの課題を解決することです。このリプレイスは4つのフェーズに分けて行うことを計画しています。 本記事ではフェーズ1とフェーズ2で予定している構想を紹介し、今回対応したフェーズ1で工夫した点や得られた効果を紹介します。 フェーズ1 フェーズ1ではCDNを切り替えます。なお、その理由は後述します。 具体的な手順は以下の通りです。 既存とは別に新しいS3バケットを用意する 画像のアップロードは最新のアプリでは新バケットに向けて行う ユーザーがWEARのアプリをアップデートするまでは新旧バケット両方への画像アップロードが発生することになる 最新のアプリの画像のアップロードはPresigned URLを発行してクライアントから直接新バケットへ画像をアップロードする 旧環境で使用しているCDNを現在使用しているCDNからAkamaiへ切り替える アップロードした画像のURLはDBに保存しているため、画像参照時はそのURLを基に新旧どちらのバケットに保存されているかを判断し画像をダウンロードします。 このフェーズではCDNの切り替えを行いますが、DNSのレコードを修正することで既存の画像URLは変更せず今まで通りアクセスできるようにしています。 フェーズ2 フェーズ1の手順を終え、すべてのWEARユーザーのアプリのバージョンが最新になると、旧環境のバケットには新規のファイル追加がなくなります。それを踏まえ、フェーズ2では下記の手順を実施します。 旧バケットにある画像をすべて新バケットに移行する DBに保存している旧バケットの画像のURLを新バケットのURLに変更する 新環境にAkamaiを導入する なお、後続するフェーズ3〜4では画像リサイズ機能のリプレイスや、不要になった旧環境のリソースを削除する予定です。 次章では、フェーズ1で実際に行ったこと内容をインフラ・バックエンドの両面から紹介します。 フェーズ1の実施内容と効果 本章では、フェーズ1で実際に行った内容とそのポイント、そこで得られた効果を紹介します。 インフラのリプレイス 既存とは別のS3バケットを用意する 既存の仕組みでは、インフラリソースの設定がコード管理されていない課題がありました。そのため、既存のS3バケット上で修正を加えていくのはリスクがあると判断しました。 そこで、新しくAWS環境を用意し、S3バケットを新規で用意しました。 新バケットでは、AWS CloudFormationを用いることでインフラをテンプレート(YAMLファイル)管理できるようにしました。また、CloudFrontの Origin Access Identity を使用し、S3バケットへのアクセスをCloudFront経由に限定しました。これにより、外部から新バケットに対してダイレクトURLでアクセスできないように制限しています。さらに、新バケットでは開発環境と本番環境を分離することで、開発環境でのテストも本番へ影響を与えることなく安全にできます。 旧環境のCDNをAkamaiへ切り替える 旧環境で使用していたCDNをAkamaiに変更します。その理由は、Akamaiの方が全体の配信単価が安くなる点と、 Image Manager を活用することでLambdaを使用しなくても画像のリサイズが可能な点です。フェーズ1ではImage Managerの導入は行いませんが、リサイズ機能のリプレイスを行うフェーズ3で導入予定です。 バックエンドのリプレイス 画像をアップロードするためのURLは、S3のPresigned URL機能を利用して発行します。画像のアップロードはAPI経由ではなく、このURLを使うことでクライアントから直接アップロードできるようにします。 ここで使用するPresigned URLは、署名をクエリ文字として含むURLを発行する機能です。この機能を利用することで、認証情報を保持する必要がなく、期間限定で指定したS3バケットに対して操作が可能です。 WEARでも、クライアントで認証情報を保持することを避けたいので、このPresigned URLを使用した画像アップロード機能を実装しています。 Presigned URLの詳しい内容は 公式サイト をご覧ください。 リプレイス前後の画像アップロードのフロー比較 Presigned URLの導入により、画像アップロードのフローがどのように変化するのかを説明します。 これまでの方法では、クライアントのアプリから画像データを一度APIへ渡していました。その後、APIは受け取った画像データをS3バケットへアップロードします。 一方、Presigned URLを導入すると下記のフローに変わります。クライアントはS3へアクセスを行うために、APIに対してPresigned URLの発行を要求します。そして、APIはS3へ署名情報を渡し、Presigned URLを発行してクライアントへ返却します。その結果、クライアントは受け取ったPresgiend URLに対して画像データを渡すことで、APIを介さずにS3バケットへ直接画像をアップロードすることが可能です。 Presigned URLの発行方法 ここでは、Presigned URLの発行方法に触れておきます。 前述の通り、Presigned URLはAPIで発行します。今回はRubyを利用しているので、AWS公式のGemである AWS SDK for Ruby(Version 3) で実装しています。なお、他の開発言語のSDKは 公式サイト に記載されています。 次に、実装時のポイントを紹介します。 認証情報は、Amazon ECSコンテナにアタッチされたIAMロールを使用する Presigned URLを発行する時点で、アップロード先のバケット名とオブジェクトキーを指定する URLの有効期限は expired_in で秒単位で設定可能だが、セキュリティを考慮し、デフォルトより短い時間に設定し、画像アップロードの度にPresigned URLを発行する デフォルトは900秒(15分)、最大1週間まで期限を設定できる なお、オプションの詳細は 公式リファレンス にも記載されています。 上記の内容を踏まえたソースコードのサンプルです。 client = Aws::S3::Client.new(profile: 'default') resource = Aws::S3::Resource.new(client: client) resource.bucket(#{bucket_name}).object(#{object_key}).presigned_url(:put, expires_in: 60) # => https://xxx.amazonaws.com/yyy.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=aaa&X-Amz-Date=bbb&X-Amz-Expires=60&X-Amz-SignedHeaders=ccc&X-Amz-Security-Token=ddd&X-Amz-Signature=eee クライアントは、API側で発行されたこのPresigned URLに対して画像を送る事で、S3バケットへ画像をアップロード可能です。 導入効果 フェーズ1のリプレイスを実施し、以下の課題を解決できました。 インフラリソースの設定をCloudFormationでコード管理することにより、変更履歴の確認や複製が容易になった 開発環境用と本番環境のバケットを分離することにより、開発が安全に行えるようになった Presigned URLを使った画像アップロードを行うことにより、サーバーサイドへの負荷を削減とパフォーマンスの向上を実現できた まとめ 本記事では、WEARの画像アップロード機能のリプレイスのフェーズ1において、インフラの構成変更やPresigned URLを使用した画像アップロードの仕組みを紹介しました。今後は、フェーズ2では旧バケットから新バケットへの画像データの移行、フェーズ3以降では画像のリサイズのリプレイスを引き続き行っていく予定です。 さいごに 運用改善チームではWEARの過去の負債から目を背けず、正攻法で解決案を議論し、運用改善に取り組んでいます。そのため、コミュニケーションと技術力を活かしながら一緒に会社を盛り上げてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
こんにちは。EC基盤本部 SRE部の渡邉です。去年の今頃はリモートワークによる運動不足を解消するために毎朝ロードバイクで走っていたのですが、3か月目に突入したころ急に飽きてしまいました。継続することの大切さを痛感しています。 さて、以前公開した記事でも Splunkを導入した話 について書きました。今回はSplunkをもっと活用していくために、効率的なサーチ方法やダッシュボード作成のTIPSを紹介します。 メトリクスのダッシュボード作成TIPS あらかじめ、よく使うサーチやメトリクスのダッシュボードを作成しておくと、都度SPL(サーチ処理言語)を書く手間が省けます。しかし、1枚のダッシュボードに漠然とパネルを作ってしまうと、動作が重くなったり視認性が悪くなってしまいがちです。今回はメトリクスのダッシュボードを作る際に行ったちょっとした工夫を紹介します。 Timechartに別要素を追加する Splunkでは、メトリクスを収集するために mstats を使います。 以下の例では平均値と95Percentileでデータを抽出しています。 | mstats avg (_value) p95(_value) prestats= true WHERE metric_name= " Processor.%_Processor_Time " AND " index " = " em_metrics " AND (host=WEB*) AND `sai_metrics_indexes` span=1m | timechart avg (_value) AS " 平均値 " p95(_value) AS p95 span=1m BY host | rename " 平均値: WEB " AS " CPU(平均値) " | rename " p95: WEB " AS " CPU(p95) " | fields - _span* このTimechartに appendcols を使うことで、他の要素を埋め込むことができます。 | appendcols[ search index=main host=WEB* | timechart span=1m count ] | mstats avg (_value) p95(_value) prestats= true WHERE metric_name= " Processor.%_Processor_Time " AND " index " = " em_metrics " AND (host=WEB*) AND `sai_metrics_indexes` span=1m | timechart avg (_value) AS " 平均値 " p95(_value) AS p95 span=1m BY host | rename " 平均値: WEB " AS " CPU(平均値) " | rename " p95: WEB " AS " CPU(p95) " | fields - _span* | appendcols[ search index =main host=WEB* | timechart span=1m sum ( count ) AS アクセス数] しかし、SPLを記述しただけではグラフには描画されません。そのため、 Format Visualization から Chart Overlay のタブを選択し、追加したいデータをセットしていきます。 この設定を入れることで、アクセス数のデータも一緒に表示可能です。下図のように、アクセス増加に応じてCPU USAGEが上昇している状況を把握できます。他にもエラーカウントを組み合わせても良いでしょう。 個人的な見解ですが、2つ以上の appendcols はグラフ描画に時間がかかってしまう恐れがあるため、1つまでにしておくのが無難です。 CPU USAGEをインスタンス別で把握する インスタンスを数百台以上の規模で管理していると、特定のインスタンスだけが高負荷状態に陥っている場合があります。そういった状況を把握するために、CPU USAGEを5段階でレベル分けして表示するダッシュボードを作りました。紹介する例では、95Percentileを取得した結果を表示させています。パネルごとに色の設定が可能なため、現状でどの程度の負荷状況に分布されているのか一目で把握できます。 | mstats p95(_value) prestats= true WHERE metric_name= " Processor.%_Processor_Time " AND " index " = " em_metrics " AND " host " = " WEB* " AND `sai_metrics_indexes` span=1m BY host | bin _time span=1m |stats p95(_value) AS avg_cpu BY host | eval Description= case (avg_cpu<= 30 , " Clear " ,avg_cpu<= 50 , " Attention " ,avg_cpu<= 70 , " Warning " ,avg_cpu<= 85 , " Problem " ,avg_cpu<= 100 , " Critical " ) | stats count BY Description | eval range= case (Description= " Clear " , " low " , Description= " Attention " , " guarded " , Description= " Warning " , " elevated " , Description= " Problem " , " high " , Description= " Critical " , " severe " ) こちらは Visualization を Single Value に設定し、 Trellis Layout を有効化します。分割の単位はSPLで定義したDescriptionを使います。 以上で、それぞれのCPU USAGEに対するカウント数を表示するダッシュボードができました。パネルの構成は以下の通りにしました。 Clear(緑):0〜30% Attention(青):30〜50% Warning(黄):50〜70% Problem(橙):70〜85% Critical(赤):85〜100% Criticalまで負荷が上昇しているインスタンスが9台存在しているようです。ここではどのインスタンスなのかを特定できるサーチもあると便利だと感じたため、次の連携部分で説明していきます。 ダッシュボードを連携させる では、次にダッシュボードを連携させていきます。 今回は CPU Level のパネルをクリックすると詳細のダッシュボードへと遷移し、該当するインスタンスをリスト表示させます。そのために、連携させたいダッシュボードに対してパラメータを設定していきます。調査する 時間範囲 と 選択したCPU Level をトリガーとするため、パラメータは以下の3つを使用します。これを Drilldown Editor で設定します。 項目 パラメータ名 トークン名 開始時刻 form.time_token.earliest $earliest$ 終了時刻 form.time_token.latest $latest$ CPU Level form.cpulevel $trellis.value$ このトリガーを受け、遷移先のダッシュボードではトリガーを受け取るパラメータを設定が必要です。 Add Input から Text と Time を設置します。 続いて、先程設置したTextフォームを以下のように編集します。 Token には選択した CPU Level を渡すため、このように入力します。 そして、サーチ文を以下のように用意します。 | mstats p95(_value) prestats= true WHERE metric_name= " Processor.%_Processor_Time " AND " index " = " em_metrics " AND " host " = " WEB* " AND `sai_metrics_indexes` span=1m BY host | bin _time span=1m | stats p95(_value) AS avg_cpu BY host | eval Description= case (avg_cpu<= 30 , " Clear " ,avg_cpu<= 50 , " Attention " ,avg_cpu<= 70 , " Warning " ,avg_cpu<= 85 , " Problem " ,avg_cpu<= 100 , " Critical " ) | eval range= case (Description= " Clear " , " low " , Description= " Attention " , " guarded " , Description= " Warning " , " elevated " , Description= " Problem " , " high " , Description= " Critical " , " severe " ) | WHERE Description= " $cpulevel$ " | rename avg_cpu AS " CPUUSAGE " | fields host CPUUSAGE なお、先程設定した Token は、サーチ文では以下のように指定します。 | WHERE Description="$cpulevel$" そして、下図が完成したダッシュボードです。ダッシュボード内から、別のダッシュボードに遷移させることで、さらに詳細を調査するといった使い方が可能です。 サーチに要する時間を短縮させる工夫 Splunkは、取り込んだデータを元に目的のフィールドを抽出するなど、作り込み可能な点が最大の特徴です。しかし、膨大な量のログからデータを抽出する際には、かなりの時間を要する可能性もあります。例えば、過去のレスポンスタイムをTimechartで表示したい場合に、対象の期間が長ければ長いほどサーチにかかる時間も比例して増加します。 Search Modeによる実行時間の差 基本的には Verbose Mode でサーチを実行するケースが多いでしょう。しかし、 Fast Mode に変更するだけで実行時間の改善を見込める場合があります。実際、実行時間にどのくらいの影響を与えるのか確認していきます。 実験に使用したサーチ文は以下の通りです。これを使い、過去24時間分のデータを取得してみます。 index =main sourcetype= " ms:iis:auto " host=WEB* | eval response_time_msec=(response_time/ 1000 ) | timechart avg (response_time_msec) AS " レスポンス(平均値) " perc95(response_time_msec) AS " レスポンス(p95) " span=1m まず、 Verbose Mode でサーチを実行してみます。開始から7:50経過した段階では、24%しか進行していません。 同様に Fast Mode でサーチを実行してみます。すると、半分程度の3:27で完了しました。 このように、取得したいフィールドデータが限定されている場合には Fast Mode を使うことで時間を短縮できます。しかし、複数のパネルが設置されているダッシュボードの場合には、結果が返ってくるまでに時間を要することもありえます。 また、複数人で重いダッシュボードを開いてしまうとサーチヘッドやインデクサーといったSplunkのコンポーネントが悲鳴をあげるでしょう。 Summary Indexを使ってみる 前述の課題を解決するために、 Summary Index を利用することをお勧めします。ライセンスやコストには影響がないため、使用することによるデメリットは基本的にありません。 Summary Index の設定をしていきます。 Index name は分かりやすいものにしましょう。そして、 Max raw data size は0GBにします。次に設定する Searchble time と Archive Retention Period は今回の例ではいずれも3年としています。 入力が完了し、保存をすると数分でIndexが作成されます。 次に、新しく作成したIndexにサーチ結果を蓄積させていきます。 まず、実行させるサーチ文を書きます。レスポンスタイムを取得するサーチ文の最後に | collect index=response_data" を追加してください。このサーチ対象時間は今回の例では過去60分としています。 index =main sourcetype= " ms:iis:auto " host=WEB* | eval response_time_msec=(response_time/ 1000 ) | timechart avg (response_time_msec) AS " レスポンス(平均値) " perc95(response_time_msec) AS " レスポンス(p95) " span=1m | collect index =response_data 作成したサーチ文の実行後、 Save As で Report に保存します。ここでも名前は分かりやすいものにします。 作成したレポートを定期実行させることで、データの蓄積が自動で行われます。定期実行をするために、 Edit Schedule から実行間隔を登録します。 サーチ時間を過去60分としているので、毎時0分になったら実行するように設定します。サーチにかかる時間が数秒から10秒程度であれば、毎分稼働させることも可能です。 スケジュール登録が完了したら、実際にどのようにデータが保存されているか確認してみましょう。 index =response_data Index のみを指定してサーチを実行することで、データの格納形式を確認できます。 項目名がそのままフィールド名として格納されているので、これを利用してTimechartにしてみます。 ここでは、過去24時間でTimechartを描画します。 index =response_data | timechart span=1m avg (レスポンス(p95)) AS レスポンス(p95) avg (レスポンス(平均値)) AS レスポンス(平均値) これを実行すると、過去24時間のサーチが1秒で完了しました。 このように、取得する項目があらかじめ決まっていて、値だけが欲しい場合には Summary Index を駆使したダッシュボードを活用することで、どれだけパネルが増えても、ストレスなく表示させることができます。現在、ZOZOTOWNのサービス監視で利用しているダッシュボードのパネルの大半で Summary Index を利用しています。 さいごに Splunkを導入して1年半が経過し、様々なチームでSplunkの活用が進んでいます。サーチ結果で異常検知したらSlackにアラートを通知をするなど、運用改善にも貢献できています。 また、 Splunk Synthethic Monitoring や Splunk Real User Monitoring などの新製品がリリースされているので、機会があれば試してみたいと思います。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは。 ZOZO研究所 の後藤です。普段はZOZOTOWNの推薦システムの開発や社内で利用するための機械学習システムの開発に携わっています。 本記事では、近年目覚ましい進展を見せている画像ベースの仮想試着の研究を紹介し、実用化を考える際に解決すべき課題とアプローチの考察も併せて紹介します。 目次 目次 はじめに 画像ベースの仮想試着の課題 モデルアーキテクチャの課題 性能の課題 データセットの課題 課題へのアプローチ VITON M2E-TON FiNet O-VITON まとめ モデルアーキテクチャの課題 性能の課題 データセットの課題 最後に 参考 はじめに コロナ禍の状況も相まってECでの買い物需要が高まっています。普段使いしている消耗品であれば気軽に購入できますが、ZOZOTOWNで扱うようなファッション商材に関しては、実際に店舗で試着をして着用イメージを確認してから購入する方も多いでしょう。 しかし、気になった商品の着用イメージを仮想的にでも確認できれば、実際に店舗で試着をしてから購入している方々に対し、オンラインでの購入の後押しになるのかもしれません。 (引用:VITON-HD) 例えば、上の画像はVITON-HD 1 という仮想試着システムの出力例です。1番左の参照画像(白いTシャツの人物の画像)に対して、右側4枚の画像は、各画像の左下に掲載している商品画像を実際に着ているかのように当てはめています。元の画像では二の腕部分や胸元までTシャツで隠れていますが、右側の画像ではシステムによってその部分が補完されています。 オンライン上の衣服画像と自分自身の写真をこのように合成できたら、具体的な着用イメージが湧き、購入の意思決定の後押しになるでしょう。 このような着せ替え技術を売りにしているAIスタートアップも続々と登場しており、最近ではウォルマートがZeekitを買収したことがニュースになりました 2 。 歴史的には3Dの身体モデルに服を着せる仮想試着の方が先行しています。しかし、本記事では比較的低コストで手に入れられる画像データを使った仮想試着の技術に注目します。そして、実用化を検討する際に直面する課題を列挙し、近年登場したアプローチとの対応関係をまとめます。この記事を通して、仮想試着の技術に興味を持っていただければ幸いです。 画像ベースの仮想試着の課題 本章では、GANを使った先駆的な研究を紹介し、実用化にむけて解決しておくべき課題を挙げます。 任意の商品画像を仮想的に着用する技術の先駆的な例はJetchevらによるConditional Analogy GAN(CAGAN) 3 です。彼らはヨーロッパにECサイトを展開しているZalandoの研究部門、Zalando Researchの研究者で、ファッションECの課題を研究しています。 (引用:Conditional Analogy GAN) この研究では、上図の左側に掲載しているようなEncoder-Decoder型のGeneratorを、GANのアーキテクチャで学習させます。 Generatorへの入力として、3種類の画像を使います。商品 (赤色のパーカー)を着用した人物画像 と商品画像 のペア、着せたい商品 (青色のTシャツ)に関する商品画像 の3種類です。これらの画像を受け取って、Generatorは顔やポーズなどの人物の個性を保存したまま商品 に着せ替えた画像 を生成します。数式だと、 と表現できます。 Discriminatorは人物画像と商品画像のペアを受け取り、正しいペア をReal、 や のように作られたペアをFakeとして学習させます。 これらを正しく見分けられるように学習しながら、GeneratorがよりRealに近いデータを生成できるようフィードバックを与えます。 (引用:Conditional Analogy GAN) Generatorの学習には、GAN Lossによるリアルな画像の生成だけでなく、ピクセルレベルで正確に着せ替えを実行させる方針が必要です。 ここで問題になるのが、教師データです。 と同じ人物・ポーズの商品 の着用画像を大量に用意することは難しいため、Generatorの出力に対してピクセルレベルの教師データを与えることはできません。その点を著者らは、Cycle GANのアイデアを取り入れ、上手く解決しています。商品 に着せ替えた画像を再度Generatorに入力し、商品 に着せ替え直した画像 を生成させます。 と をピクセルレベルで比較しGeneratorに誤差を返しています。 この研究の実験結果の一部を示します。 (引用:Conditional Analogy GAN) 上図では、ある人物画像に様々なタイプの商品を着せています。商品の色や形など大まかな特徴は転写できており、大まかな雰囲気を知りたい程度であれば、これで十分かもしれません。しかし、より細かく見ていくと、商品のテクスチャやロゴが崩れている例や、ネック部分が保持されていない例があります。 (引用:Conditional Analogy GAN) 上図では、同じ商品を様々な人物に着せています。人物のサイズやポーズの違いにはきちんと対応できているようです。しかし、顔が崩れたりボトムスやバッグの色が変わったりと、注目している部分と関係のない部分が保持できないという問題があります。 この研究が登場した2017年当時、このような条件付き生成モデルによる服の着せ替えは衝撃的で、我々も再現実装を行いました。しかし、現実的なシチュエーションに対応するためには、いくつかのギャップを乗り越えなければならいことがわかりました。そこで得られた課題を紹介します。 モデルアーキテクチャの課題 CAGANでは、学習時と推論時のどちらにも人物画像に対応する商品画像が必要になります。人物画像と商品画像のペアのデータの収集はアノテーションのコストがかかります。そして、自分自身の写真に任意の商品を着せたい場合、自身が着ている衣服の商品画像を別途用意する手間がかかります。 また、この研究の実験はトップスカテゴリに限定されています。実際にはボトムスやアウター、シューズの着せ替えも試してみたいでしょう。着せ替え領域の指定はモデル内で暗黙的に行われるため、モデルは商品カテゴリ毎に用意したほうが良いでしょう。複数の商品を同時に着用したい場合は、商品の数だけ推論する必要があります。 性能の課題 出力結果を観察すると、顔やポーズが崩れているという個性の保持の問題と、縞模様が崩れていたりロゴやフォントが潰れるといった転写の性能の問題があることがわかります。 論文に示されていない例として、以下のパターンが考えられます。 肌の露出が大きく変わる場合(長袖からノースリーブへの着せ替えなど) シルエットが大きく変わる場合(パンツからスカートへの着せ替えなど) 肌の描写やシルエットの変更はGeneratorの負担を増やすことになるため、上手くいかない可能性があります。 商品にはサイズのバリエーションがあり、どのサイズを選ぶかによって見た目は変化します。生成結果は、一見それらしい着せ替えを実現していますが、サイズバリエーションは反映されていません。生成結果は、実際の商品サイズを反映したものであるべきです。 データセットの課題 学習に使われるデータのほとんどはファッションモデルが商品を着用しているものです。細身で頭身の高い傾向の人物しか含まれていません。実際に推論したいデータには、さらに多様な体型の人物が含まれているはずです。 また、モノトーンの背景・商品が映えるような環境光やポーズといった、商品のカタログ的なシチュエーションが設定されています。推論時には屋外で撮られた、背景・環境光・ポーズがより複雑なデータを扱うことになります。 このように、実用の際には学習時と推論時で入力データの質の違いが問題になり得ます。 CAGANの登場以降、これらの課題を解決するための様々なアプローチが提案されました。次章では、そのような研究の概要をいくつか紹介します。 課題へのアプローチ 本章の内容は専門性が上がるため、まずはじめに各研究の特徴を表にまとめておきます。 ※表を拡大表示 全体的に人物・商品のペア画像が不要となる代わりに、Human Parsingやポーズ推定の前処理が必要になってきている傾向があります。 前述のモデルアーキテクチャと性能面の課題に関して、レイアウトの生成と見た目の生成という二段構えのアプローチを取ることで大きな改善が見られています。しかし、商品サイズの違いを反映するモデルはまだ見たことがありません。 ここで挙げた手法以外にも多くの研究があるので、興味を持っていただけた方は、参考文献の引用から関連する研究を調査すると良いでしょう。 VITON (引用:VITON) VITON 4 はファッション関連の面白いタスクをたびたび提案するXintong Hanらによる研究です。VITONは着せ替えのプロセスを、衣服領域のマスクの生成と、衣服領域に合うように歪ませた商品画像のテクスチャの貼り付けの二段階に分けて行います。CAGANの課題だった、推論時に人物画像 とその人物が着ている商品画像 のペアが必要であった点や、個性の保持・テクスチャの転写の性能を改善しています。 (引用:VITON) 1段目のEncoder-Decoder Generatorは、入力データとして人物画像そのものではなく、ポーズ・身体形状・顔と髪の領域の組み合わせという、衣服の情報を削ぎ落としたものを使います。これに商品画像を加え、元の人物画像を復元するという戦略で学習させます。CAGANでは、人物画像 のどの部分が衣服領域であるかを商品画像 との関係から推測する必要がありました。一方、VITONでは前処理の段階で、確実に人物画像から衣服の情報を取り除いているため、これを商品画像で条件付けることにより、元の人物画像を復元するだけで着せ替えが実現します。この工夫により、ピクセルレベルの教師あり学習をさせることが可能なため、Cycle GANの構造が不要になります。 1段目の出力は、復元された人物画像と衣服領域のマスクです。衣服領域のマスクは、商品画像をどのように歪めたら人物画像にフィットするのかの情報を持っています。この情報を使い、Thin Plate Spline Transformationで商品画像を人物に合わせて歪めたものを生成します。 2段目のRefinement Networkは、1段目で得られた人物画像と衣服領域に合わせて歪めた商品画像を入力とし、衣服領域のテクスチャの合成を行います。テクスチャをGeneratorで生成するのではなく、元の商品のテクスチャを貼り付けるというイメージなので、商品のテクスチャが綺麗に反映されやすくなっています。 M2E-TON (引用:M2E-TON) Model2Everyone Try-On Network(M2E-TON) 5 は、商品画像を使わず、人物が着用している商品を任意の人物に転写するアーキテクチャを提案しています。CAGANやVITONは人物と商品のペアが必要でしたが、人物画像だけを使うという点で学習データの収集コストを大幅に減らしています。このアーキテクチャは、人物から人物への着せ替えの工程を以下の3つのEncoder-Decoder型のGeneratorで実現します。 Pose Alignment Network (PAN) Texture Refinement Network (TRN) Fitting Network (FTN) また、補助情報として、3Dの人体表面を推定するDense Poseモデルの推論結果を追加します。上図では、緑色の矢印で示されています。モデル人物画像のDense Poseとターゲット人物画像のDense Poseの人体表面を対応付けて歪ませると、ひび割れたようなテクスチャ が得られます。PANはモデル人物画像とひび割れたテクスチャを使ってモデル人物画像のポーズをターゲット人物のポーズに合わせるGeneratorです。この出力 はぼんやりとしており、テクスチャの詳細部分が失われているため、元のひび割れたテクスチャ と合成することで細部の表現を取り戻します。しかし、ひび割れ部分のエッジ部分が目立つのでTRNで綺麗に修復します。端的に言えば、二度のEncoder-Decoderでモデルのポーズを無理やりターゲットのポーズに変形して画像のあらを修復したのです。 (引用:M2E-TON) 上述のPAN・TRN・FTNの学習は、CAGANと同様の理由によりピクセルレベルの教師データを与えるのが困難です。M2E-TONはこの課題をUnpaired Training(上図左)とPaired Training(上図右)の2つの学習戦略を合わせることで対応します。 Unpaired Trainingは、服装やポーズに対応関係のないモデルとターゲットの情報を入力し、ポーズで条件付けたGANの損失関数を使って学習を進めます。しかし、この学習戦略で評価されるのは、出力と入力のポーズの一致具合と出力が本物の画像に近いかどうかだけです。衣服部分のテクスチャが正確に反映されているかどうかを評価する方針を別に与える必要があります。その対策としてPaired Trainingを行います。Paired Trainingは、ECサイトで手に入りやすい同じ人物が同じ服を着て異なるポーズを取っている画像ペアを使います。GANの損失関数に加えて入力 と出力 のピクセルレベルの誤差を損失関数として導入します。 (引用:M2E-TON) Paired Trainingを行うことにより、Generatorにポーズの違いだけでなくテクスチャの細部にまで転写できるような学習方針を取らせることができます。上図の4番目の画像と6番目の画像はPaired Trainingの有無の差を表しています。 ボトムスの着せ替えは論文中に一例のみだったので、上手くいかなかった可能性があります。アーキテクチャ的にはマルチカテゴリに対応できそうですが、解決できなかった課題が残っている可能性があります。 FiNet (引用:FiNet) FiNet 6 はVITONと同じ著者の研究です。人物に指定の商品を着せるタスクではなく、着せ替えたい領域を欠損させて、欠損を修復することで様々な着用イメージを生成するというタスクを提案しています。さらに、適当な衣服を埋めて修復するのではなく、欠損させた部分以外の文脈をきちんと反映させた上で修復するため、ファッションとして調和した生成結果が得られるように工夫されています。 FiNetはShape GenerationとAppearance Generationの2段階で、画像を修復します。 (引用:FiNet) Shape Generationは元の人物の顔領域とポーズ、欠損させたレイアウト、欠損させた部分をどのように補うかを示すShape Codeを入力とし、元のレイアウトを復元します。学習時はShape Codeとして元の衣服領域(Input Shape)を符号化したものを使います。そして、推論時は欠損させた部分以外の衣服画像(Contextual Garments)を符号化してGeneratorに与えます。学習時と推論時で利用する情報が異なる点は、両者が同じ分布から生成されるようにEncoderを学習することで対応しています。 (引用:FiNet) Appearance Generationも、Shape Generationと同じアーキテクチャで元の人物画像を復元します。 (引用:FiNet) 上図は実際の推論結果を示しています。レイアウトの生成と外見の生成をトップス(左)、ボトムス(右)に関して行っています。Shape CodeとAppearance Codeをサンプリングすることで、全体として調和した外見を数多く生成することに成功しています。 ここまでに紹介した研究は、明示的には複数カテゴリへの対応はしていませんでした。一方、FiNetはトップスやボトムスだけでなく、シューズやハットまで対応できており、汎用性の高さが伺えます。 FiNetは変更を加えたい部分をマスクして、その領域を埋めることで様々な外見を生成できる手法です。さらに、マスクした部分以外の文脈情報を考慮して全体として調和した結果を生成するため、その保証はないですが、ファッションセンスの高いものになっているというファッションらしいタスクを解いています。ただし、このタスクは指定の衣服を着せるというこれまでに紹介したタスクと異なります。 (引用:FiNet) 著者らは、メインのタスクではないとしながらも、上図のように任意の衣服が着せられるかを実験しています。上図にはReference、Input、Transferの3種類の画像が掲載されています。Referenceの人物が着用している衣服をInputの人物に着せた結果がTransferです。ReferenceからShape CodeとAppearance Codeを生成し、欠損させた画像(Input)と合わせて渡すことで、任意の衣服を着せることが可能であり、しかも上手くいくようです。このようにFiNetは任意の衣服を着せるタスクにも利用でき汎用性を持っています。 O-VITON (引用:O-VITON) O-VITON 7 はAmazon Lab126の著者らがCVPR2020で発表した研究です。FiNetと同様に、Shape Generation(上図の緑色部分)とAppearance Generation(上図の青色部分)を順に実施して着せ替えを実現します。 他のモデルと大きく異なる点は、複数のカテゴリの商品を同時に着せることができるところです。入力データは人物が着用している画像を領域分割したものです。そして、入力として使う人物画像の各衣服領域の特徴マップを、着せたい衣服の特徴マップと差し替えてレイアウト生成モジュールや外見生成モジュールに入力します。どちらのモジュールも学習時は着せ替え自体を行わず、特徴マップから元の画像を復元することにフォーカスしています。なお、複数の商品を着せる能力自体は汎化性能によるものです。 また、損失関数として、Shape Generatorはレイアウトのピクセルレベルの誤差とレイアウトのShape Feature Mapで条件付けたGAN Lossを用います。Appearance Generatorはレイアウトで条件付けたGAN Lossと、生成物と元データのFeature Matchingを用います。 (引用:O-VITON) FiNetではShape GenerationとAppearance Generationの2つで終わりでした。しかし、O-VITONはさらに見た目を改善するために、推論時にAppearance Generatorに個別のファインチューニング(Online Optimization)をかけます。 この段階では、リファレンス画像のレイアウトと、衣服を着せたい人物のレイアウト(クエリ)を入力します。リファレンス画像の復元結果はFeature Matchingを、クエリへの着せ替え結果はDiscriminatorの損失を評価して学習します。リファレンス画像の衣服がクエリレイアウトにきちんと転写されるまで最適化をかけます。 (引用:O-VITON) Online Optimizationを取り入れた場合(一番右の列)、単に二段階の過程で生成した場合(右から2番めの列)に比べて、元の商品のテクスチャが正確に反映できています。 (引用:O-VITON) O-VITONは学習時には複数の商品を着せての評価はしていません。しかし、クエリ画像の各カテゴリの特徴マップをReference Garmentsの特徴マップに差し替えることで、複数のカテゴリの商品をクエリの人物に対して着せることができています。本来解いているタスクではないのに、このようなことができるということは、モデルの汎化性能が高いということを示しています。 まとめ ※表を拡大表示 モデルアーキテクチャの課題 CAGANは学習時と推論時の両方で、人物画像に対応する商品画像が必要でした。一方で、CAGAN以降の研究では、Human Parsingやポーズの推論結果を利用することで制約が緩和されています。また、M2E-TON・FiNet・O-VITONのように人物画像のみで学習・推論が可能なモデルも登場しています。 また、FiNetやO-VITONはレイアウトを明示的に扱うことにより、単一のモデルで複数のカテゴリの着せ替えに対応可能となり、高い柔軟性を示しています。 性能の課題 着せ替えの出力をGeneratorに任せると顔やポーズ、テクスチャが崩れるといった問題がありました。その点に関しては、VITONのように事前に切り出しておき、それを後続のタスクに渡して微修正する方法が有効な場面が多いでしょう。Generatorに任せる場合は、O-VITONのように推論のたびにチューニングし直し、レアなパターンやロゴを正確に転写させる方法が良さそうです。他の手法の仕上げ段階でも取り入れられる汎用的なアイデアです。 肌の露出やシルエットの変化は、FiNetやO-VITONのようにレイアウトを編集するモジュールを取り入れることで対応できそうです。 しかし、実際の商品サイズを考慮する点に関しては、サイズ情報を扱う方法は模索されていません。今後の課題になるでしょう。 データセットの課題 推論時の多様なシチュエーションへの対応は、多くの研究で検証できていません。今回紹介した論文では唯一、VITONが屋外の複雑な環境の写真に対しての推論結果を掲載しています。 (引用:VITON) 対象のポーズと商品がシンプルな左の2つの画像は、若干服が浮いて見えますが貼り付け自体は上手くできているように感じます。しかし、中央の画像は肌の露出が増えており、腕の形や色の生成に不自然さが残ります。そして、右の画像では、テクスチャの転写に失敗しています。 下記のブログでは、着せ替えモデルを以下の4パターンでテストしています。 www.kdnuggets.com Replication of the authors’ results on the original data and our preprocessing models (Simple). Application of custom clothes to default images of a person (Medium). Application of default clothes to custom images of a person (Difficult). Application of custom clothes to custom images of a person (Very difficult). 上述のVITONの例は綺麗な商品画像とカスタムの写真を使っているので 3. のDifficultに相当します。このブログでは、カスタムの商品画像としてCGや絵を渡してみたりと、チャレンジングな状況でのテストも実施しています。自前で用意したデータに対する実用性を測る上で、重要なテスト項目です。 最後に ZOZO研究所では、機械学習の社会実装を推し進めることのできるMLエンジニアを募集しています。今回紹介したVirtual Try-Onのタスク以外にも、検索、推薦、画像認識の技術など幅広い分野で研究や開発を進めていけるメンバーを募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co hrmos.co hrmos.co 参考 Choi, Seunghwan, et al. "VITON-HD: High-Resolution Virtual Try-On via Misalignment-Aware Normalization." arXiv preprint arXiv:2103.16874. 2021. ↩ Sarah Perez, 「ウォルマートがAIやコンピュータービジョンを駆使したバーチャル試着のZeekitを買収」 ,TechCrunch. 閲覧日:2021年6月9日. ↩ Jetchev, Nikolay, and Urs Bergmann. "The conditional analogy gan: Swapping fashion articles on people images." Proceedings of the IEEE International Conference on Computer Vision Workshops. 2017. ↩ Han, Xintong, et al. "Viton: An image-based virtual try-on network." Proceedings of the IEEE conference on computer vision and pattern recognition. 2018. ↩ Wu, Zhonghua, et al. "M2e-try on net: Fashion from model to everyone." Proceedings of the 27th ACM International Conference on Multimedia. 2019. ↩ Han, Xintong, et al. "Compatible and diverse fashion image inpainting." arXiv preprint arXiv:1902.01096. 2019. ↩ Neuberger, Assaf, et al. "Image based virtual try-on network from unpaired data." Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition. 2020. ↩
アバター
はじめまして、ZOZO研究所 福岡の家富です。画像検索システムのインフラ、機械学習まわりを担当しています。 今回は、t検定におけるサンプルサイズが与える影響を解説します。 目次 目次 t検定の使われ方 t検定 t検定の問題点 論文手法 実際の購入金額データに対する考察 まとめ さいごに t検定の使われ方 近年、施策が有効かどうかをデータを元に統計的に判断していこう、という話を聞くことが増えてきました。 経済学の流行においても、統計的な指標を重要視する流れが強まってきています。例えば、貧困対策にお金をどの程度どのような用途で支給するのが良いか、といった議論で利用されることも多くなってきています。 www.amazon.co.jp Web業界においても、サイトの変更や施策の有効性をA/Bテストなどを実施し、統計的に判断していく流れが主流になってきています。なお、本記事では以下の文献を「A/Bテスト本」と呼びます。 www.amazon.co.jp そして、このようなA/Bテストから得られた統計情報に対し、それが有効か否かを判断する方法の1つにt検定があります。 t検定 例として、次のような各ユーザーの一週間の購入金額のデータを考えます。 2つの施策AとBがあり、それぞれの施策に対し、以下のような購入者の購入金額のデータが得られたとします。 施策Aの購入者のデータ: 同様に施策Bの購入者のデータ: ここで、n、mはそれぞれ施策A、Bの購入者数を表し、 、 はそれぞれのユーザーの購入金額とします。 この時に、「施策Aの分布と施策Bの分布を比べて、施策Aの方が良いと言えるのか?」というのが一般的に考えたい問題かと思います。 一番最初に思いつく方法は、それぞれの平均値 、 を見る方法です。 ならば施策Aの方が良いと考えます。 ここで問題になるのは、「たまたま施策Aの平均の方が少し良かっただけなのでは?」という可能性を捨てきれない点です。非常に小さな差しかなければ、「たまたまじゃないのか」という気になります。では、「どれほどの差があれば十分施策Aの方が良いと言えるのか?」というの疑問が湧いてきます。検定を使えば、この疑問に答えることができます。 t検定の場合、次のような論法をとります。 まず、施策A・Bのどちらのデータも、ある潜在的な確率分布から独立にサンプリングされたものと考えます。なお、この見方は実際のデータに対してよく仮定されるものです。次に「施策Aの潜在的な分布の平均値」と「施策Bの潜在的な分布の平均値」は等しいと仮定します。そして、以下の式を計算します。 この、 は以下で定義されるものです。 「ある仮定のもと(詳細は後述します)」で、この値は自由度m+n–2のstudentのt分布に従うことが証明されています。正確には、ウェルチのt検定なので自由度が多少異なりますが、m=nの時はほぼ同様の自由度となります。 en.wikipedia.org そのため、t分布の表を見ることで、上記で得られたt値が実際にどの程度の確率で生じたものなのかがわかります。 例として、m=n=121の場合、つまり該当のt分布の自由度が240となる場合を考えてみます。計算されたt値が1.392であったとします。これは自由度240のt分布において上位5%となる点は1.651なので、そこには含まれません。一方、上位10%でのt分布の値は1.285です。そのため、上記の値は5〜10%の間で生じる値ということになります。 検定でよく採用される5%を基準とした場合、上記の値は起こりうる確率の範囲と捉えることができ、特に矛盾はないと考えます。そして「施策Aの潜在的な分布の平均値」と「施策Bの潜在的な分布の平均値」が等しいことに問題はないと解釈します。 また、10%を基準とした場合には、上記の値は起こりうる確率の範囲外にあると考えられます。すなわち、「施策Aの潜在的な分布の平均値」と「施策Bの潜在的な分布の平均値」は等しくない、施策Aは有意に有効であると考えられます。 上述のように、2つのデータの違いを評価することに使われます。 この「破られることを期待される仮定を置く」という論法が若干わかりにくくしている部分であり、時々誤解を生んでいることもあります。このような論法が使われる背景には、たくさん施策し数値的に特に目立った施策だけを拾っていこう、という発想があります。 t検定の問題点 前章でt検定の使用方法を説明しました。実際に使用する際には、t値がt分布に従うかどうかがポイントになります。 「ある仮定のもと」と前章で述べましたが、ここではその点をさらに見ていきます。t値がt分布に収束するには、以下のように定義された値 がt分布に従う必要があります。 なお、 は「潜在的な分布の平均値パラメータ」であり、データから推測はできますが、一般のデータからは得られないことに注意が必要です。 次に、 がt分布に従うための条件を見るために、以下のように分解します。 なお、 は「潜在的な分布の標準偏差パラメータ」であり、データから推測はできますが、一般のデータからは得られないことに注意が必要です。 ここでは、 が中心極限定理より「nが十分大きい時に」正規分布に収束します。また、 が「nが十分大きい時に」自由度n-1のカイ二乗分布に収束します。このことから、 は「nが十分大きい時に」自由度n-1のt分布に従うと言えます。なお、nが十分大きい時なので、nとn-1の差はほぼ考えなくても良いです。 この時「nが十分大きい時に」という表現が、実際にどれくらいの大きさならば誤差を無視できるのかが問題となります。 「A/Bテスト本」では、元の分布の歪度( )が大きい場合の問題を指摘しています。なお、 は以下のように定義されます。 なお、 は分布の期待値、 で定義される分布の分散を表します。歪度が大きい場合、中心極限定理による近似度があまり良くありません。そのため、t検定をする際にはサンプルサイズを大きく取る必要があると主張しています。 「A/Bテスト本」では、サンプルサイズnは 以上必要だと主張しており、歪度が10以上となるとかなりのサンプルサイズが必要だと主張しています。さらに、この主張がどこからくるのかについて調べていくと「How Large Does n Have to Be for Z and t Intervals?, Dennis D. Boos and Jacqueline M. Hughes-Oliver」という論文によるものだとわかりました。 次章では、上記論文の内容を紹介していきます。 論文手法 「How Large Does n Have to Be for Z and t Intervals?, Dennis D. Boos and Jacqueline M. Hughes-Oliver」(以下「本論文」と呼ぶ)ではまず、歪度と中心極限定理の関係について述べています。 1次元の実数上の確率分布に対し、正規分布との関係を述べた定理としてGram–Charlier A seriesがあります。この定理を に対して適用したものがEdgeworth seriesです。 en.wikipedia.org この定理により、一般の確率分布の累積分布関数をn -1/2 の級数展開として得ることができます。そして、n -1/2 以降の項はn -1 ,n -3/2 と続きます。サンプルサイズnが大きくなる場合、n -1 以降は収束が速いため、n -1/2 の項のみに注目すれば良いことがわかります。ここで、n -1/2 の項の係数をみると歪度が出てくるため、収束の速度に歪度が重要なパラメータとして関わっていることもわかります。 また、中心極限定理の収束速度を表す定理の1つであるBerry-Esseenの定理においても、やはり収束誤差を で抑えられると言われています。 en.wikipedia.org 上記の知見を元にし、本論文では「上位5%を表す閾値0.05でのt値に対し、 だけずれる。なお、cは分布に依らない定数。」と仮定します。一般にこの閾値は「 」のように で表すことが多いため、本記事でもこの表記を使用します。 次に、先程登場したcを求める方法について述べます。本論文では様々な既存の分布から実際にサンプリングを行い、歪度とズレに関して線形回帰をすることによって求めています。よく知られた解析的な確率分布を用いているので、 のような潜在的なパラメータをシミュレーション結果からではなく、実際に計算できるので、結果としてt値を計算することが可能となります。 本論文では以下の分布を使用しています。 ガンマ分布 = 1, k=1, 1.78, 4, 16としたもの ja.wikipedia.org ワイブル分布 scale parameter=1, shape parameter= 1.2, 1.6, 2.2としたもの en.wikipedia.org 論文「An Asymptotically Distribution-Free Test for Symmetry versus Asymmetry」による3パターンのオリジナル分布 lam1, lam2, lam3, lam4 = 0, 1.0, 1.4, 0.25 # 7 lam1, lam2, lam3, lam4 = 0, 1.0, 0.000070, 0.10 # 8 lam1, lam2, lam3, lam4 = 0, -1.0, -0.1, -0.18 # 12 上記の分布に対し、本論文においてはズレ(miss)を次のように計算します。 まず、各分布に対して30サンプル抽出した時のt値を10,000セット用意します。なお、今回の場合だと300,000サンプル合計抽出します。 次に、用意した10,000個のt値を大きい順に並べ替え、大きい方からt値を調べていきます。正確な自由度30のt分布においては5%に相当するt値=1.697と比較していき、それを下回るのが何番目になるかをチェックします。例えば、上位から125番目が該当した場合、上位5%とのずれは0.05 - 125/10000 = 0.0375となります。 このように様々な歪度の分布からのt値を計算し、実際のズレのデータを取得します。論文では、さらに小さい方からのt値も見ていき、左側検定の場合のズレも計算しています。 以下はズレを取得する処理のPythonの疑似コードです。 t_list = [] for s_id in range ( 10000 ): np.random.seed(s_id) xs = [] for _ in range ( 30 ): # scale parameter=1, shape parameter=1 のgamma分布からのサンプリング a = np.random.gamma( 1 , 1 ) xs.append(a) t_list.append( get_t(xs) ) # t値を計算して、リストに登録 t_list.sort() for i,t in enumerate (t_list): # left if t > - 1.697 : left_percentile = i/ 10000 break for i,t in enumerate ( reversed (t_list)): # right if t < 1.697 : right_percentile = i/ 10000 break print ( 0.05 - left_percentile, 0.05 - right_percentile) 本論文では、左側のズレが0.116、右側のズレが-0.067という結果が掲載されています。一方で、私が行った実験結果では左側のズレが0.11186489、右側のズレが-0.03688539という結果が出ています。 最後に、この結果を用いて必要なサンプルサイズを求めます。本論文ではズレ(miss)が =0.01以下となるようにサンプルサイズnを決めます。 これをnの式に直すと、 となります。 先程の実験結果から、値の大きい左側を使うと以下の結果が算出されます。 本論文結果: 私の実験結果: 誤差を0.005以下にすると考えた場合、係数は本論文では538.24、私の実験結果では500.55となります。「A/Bテスト本」で述べられていた355という係数は得られませんでしたが、数値のオーダーはほぼ同等のものが得られたと考えられます。 実際の購入金額データに対する考察 冒頭で紹介した「各ユーザーの一週間の購入金額のデータ」に対して歪度を計算してみました。歪度は700.43となり、精度を得るために必要なサンプルサイズはn=61,389,051.39と算出されます。これは現実的ではない値です。 「A/Bテスト本」ではこのような場合、ある閾値以上の部分は落とすという方法を推奨しています。今回は10万円以上のユーザーは、別データに分けました。そうすることで、10万円未満のデータの分布は歪度が2.750となり、現実的な数値となりました。 なお、10万円以上のデータの分布はこれにより歪度が35.637となり、だいぶ小さい値になりました。扱いやすくなりましたが、まだ工夫が必要な値です。さらに細かく層を分けて対応するなどの工夫が必要でしょう。 まとめ 本記事では、t検定におけるサンプルサイズの考察を紹介しました。 実際にt検定の理論を深堀りした際には、Edgeworth seriesなどは意外と文献が見つけにくく、苦労しました。しかし、自ら数値実験をすることで実際の数値とのズレ具合など、知見を得ることができました。また、実データは想像以上に歪みの大きいデータが多いため、そのまま適用とはいかないケースがかなりあることも実感しました。 さいごに ZOZOテクノロジーズではZOZO研究所のMLエンジニアを募集しています。本記事に興味を持っていただけた方は、ぜひご応募ください。 hrmos.co
アバター
こんにちは。ZOZO研究所の山﨑です。 ZOZO研究所では、検索/推薦技術をメインテーマとした論文読み会を進めてきました。週に1回の頻度で発表担当者が読んできた論文の内容を共有し、その内容を参加者で議論します。 本記事では、その会で発表された論文のサマリーを紹介します。 目次 目次 検索/推薦技術に関する論文読み会 発表論文とその概要 SIGIR [SIGIR 2005] Relevance Weighting for Query Independent Evidence [SIGIR 2010] Temporal Diversity in Recommender System [SIGIR 2017] On Application of Learning to Rank for E-Commerce Search [SIGIR 2018] Should I Follow the Crowd? A Probabilistic Analysis of the Effectiveness of Popularity in Recommender Systems [SIGIR-workshop eCom 2018] Towards Practical Visual Search Engine within Elasticsearch [SIGIR 2020] Models Versus Satisfaction: Towards a Better Understanding of Evaluation Metrics [SIGIR 2020] Studying Product Competition Using Representation Learning [SIGIR 2020] Understanding Echo Chambers in E-commerce Recommender Systems [SIGIR 2020] Cascade or Recency: Constructing Better Evaluation Metrics for Session Search KDD [KDD 2012] Summarization-based Mining Bipartite Graphs [KDD 2019] Applying Deep Learning To Airbnb Search [KDD 2020] Embedding-based Retrieval in Facebook Search [KDD 2020] Controllable Multi-Interest Framework for Recommendation [KDD 2020] On Sampled Metrics for Item Recommendation [KDD 2020] Personalized Image Retrieval with Sparse Graph Representation Learning [KDD 2020] Managing Diversity in Airbnb Search [KDD-workshop 2020] Lessons Learned Addressing Dataset Bias in Model-Based Candidate Generation at Twitter TheWebConf (旧WWW) [WWW 2020] NERO: A Neural Rule Grounding Framework for Label-Efficient Relation Extraction [WWW 2020] The Difference Between a Click and a Cart-Add: Learning Interaction-Specific Embeddings RecSys [RecSys 2018] Calibrated recommendations [RecSys 2019] A Pareto-Eficient Algorithm for Multiple Objective Optimization in E-Commerce Recommendation その他 [WSDM 2010] Anatomy of the Long Tail: Ordinary People with Extraordinary Tastes [VLDB 2013] Supporting Keyword Search in Product Database: A Probabilistic Approach [ACL-short 2018] ‘Lighter’ Can Still Be Dark: Modeling Comparative Color Descriptions [ECIR 2020] From MAXSCORE to Block-Max Wand: The Story of How Lucene Significantly Improved Query Evaluation Performance [MLSys 2020] Understanding the Downstream Instability of Word Embeddings [COMPUTER GRAPHICS Forum 2020] Interactive Optimization of Generative Image Modelling usingSequential Subspace Search and Content-based Guidance まとめ おわりに 検索/推薦技術に関する論文読み会 ZOZO研究所では主に検索/推薦技術に関する論文読み会を進めてきました。論文読み会とは週に1度、1人が読んできた論文を発表し、その内容を議論する場です。 論文の選択基準は特に指定せず発表者に一任していましたが、 SIGIR や KDD といったトップカンファレンスの論文が人気でした。 これまでに約30本の検索/推薦技術を中心とした幅広い分野の論文が社内で共有され、実際のプロダクト開発にも活かされています。 論文読み会はチームに閉じた形式ではなく、この取り組みに賛同する社員であれば誰でも参加できる形式を取りました。その結果、10〜20名程度が参加し、活発な議論が生まれました。 次章では、発表された論文のサマリーを紹介します。 発表論文とその概要 本章で掲載している画像は、特別な記載が無い限り全て原著論文より引用しています。 SIGIR [SIGIR 2005] Relevance Weighting for Query Independent Evidence どんなもの? クエリに依存しない特徴を、クエリに関連するスコア(BM25など)に活用してランキングを調整する方法を提案した。 こちらは、 Elasticsearch 7.0のrank_featureの開発の基となった論文 である。 先行研究と比べてどこがすごい? クエリに依存しない静的な特徴をBM25と組み合わせる手法の提案と、その手法がどの程度成功したかを測定した。 技術や手法のキモはどこ? 静的な特徴との組み合わせを3つの関数で実験した。 静的な特徴とクエリに関連するスコアの組み合わせに単純な独立性を仮定すると、うまく動作しないケースがあった。そのため、FLOEという手法を用いて解決した。 どうやって有効だと検証した? ベースライン手法のBM25と静的な特徴の組み合わせの手法を比較した。 TRECのデータを使用して、MAPが改善した。 [SIGIR 2010] Temporal Diversity in Recommender System どんなもの? レコメンドシステムにおける推薦アイテムの時間的な多様性を研究した。 同じ商品が繰り返し推薦され続けるシステムは時間的な多様性が低い、というイメージ。 また、精度を大幅に減少させること無く、多様性を最大化するレコメンド手法を提案した。 先行研究と比べてどこがすごい? レコメンドにおける推薦アイテムの時間的な多様性の重要性をアンケート調査によって明らかにした。 技術や手法のキモはどこ? レコメンドの多様性を促進するために、レコメンドモデルを時間経過に伴って切り替えた。 どうやって有効だと検証した? 5週間の継続的なアンケート調査によって、レコメンドシステムの多様性がアイテムのレーティングに影響を与えることを発見した。 また、Netflixのデータセットを使って複数のレコメンド手法に対する多様性の評価・比較をした。 [SIGIR 2017] On Application of Learning to Rank for E-Commerce Search どんなもの? ECサイト検索でランキング学習モデルを活用する際の効果的な特徴量とRelevancy(クリック率・カート追加率など)について調査した。 また、クラウドソーシングを用いた結果がモデルに活用できるかを調査した。 先行研究と比べてどこがすごい? ECサイト検索において網羅的にランキング学習モデルの良し悪しを測った研究は、これまで存在しなかった。 ECサイト検索にランキング学習モデルを導入する際の特徴量やRelevancyについて実践的な知見を展開した。 技術や手法のキモはどこ? ECサイト検索に有効な特徴量・モデル・Relevancyを評価する実験設計を行った。 どうやって有効だと検証した? 実際の企業データを用いて検索結果のnDCGを測定する実験を行い、以下のことが分かった。 LambdaMARTがモデルとして精度が良い。 ECサイト検索の評価をクラウドソーシングで実現することは困難である。 Relevancyは注文率が良い。 [SIGIR 2018] Should I Follow the Crowd? A Probabilistic Analysis of the Effectiveness of Popularity in Recommender Systems どんなもの? アイテムの人気度(多くのpositiveな反応があるアイテム)はレコメンドシステム構築において取り除くべきバイアスかどうかを検証した。 先行研究と比べてどこがすごい? これまで、レコメンドシステムで人気度を「回避すべきバイアス」として扱うべきかの議論があった。 本研究ではレコメンドシステムにおいて、人気度が効果的となる条件とその逆の条件を特定した。 技術や手法のキモはどこ? 人気度の有効性は以下の3変数の相互作用に依存することを発見した。 適合性 : アイテムが多くのユーザーに好まれやすいか 発見性 : 好みのアイテムを発見しやすいか 評価するユーザーの判断 : 好みのアイテムを評価しやすいか その上で、人気度がレコメンドにおいて効果的な条件とその逆の条件を特定した。 どうやって有効だと検証した? クラウドソーシングで構築した独自のデータセットを用いて実験を行い、理論的に導いた結論が観測結果とどの程度一致するか検証した。 一般的なデータセットからバイアスを除去し、バイアスがある状況との精度の違いを示した。 レコメンドにおいて多くの場合、平均評価が評価数よりも効果的であることを発見した。 [SIGIR-workshop eCom 2018] Towards Practical Visual Search Engine within Elasticsearch どんなもの? Elasticsearch上で画像検索を実装し、その手法を説明した。 先行研究と比べてどこがすごい? より近いベクトルがより多くの文字列トークンを共有するように、画像特徴ベクトルを文字列トークンのグループにエンコードした。 技術や手法のキモはどこ? 画像特徴ベクトルをそのまま使うのではなく、転置インデックスに適した文字列の形にエンコードして検索可能な状態にした。 どうやって有効だと検証した? Jet.comの約50万枚・1536次元の画像から、1,000件をランダムに検索したときの精度と速度を既存手法と比較し、改善されていることを示した。 [SIGIR 2020] Models Versus Satisfaction: Towards a Better Understanding of Evaluation Metrics どんなもの? ユーザーの行動データに対して最適化された評価指標が、ユーザー満足度の推定においても同様に機能するかどうかを調査した。 先行研究と比べてどこがすごい? 検索システムの評価指標の妥当性を、ユーザーの行動の予測の正確性とユーザーの満足度の2つの側面で整合性があるかを調査した。 また、データセットも独自にフィールドスタディを行って作成した。 技術や手法のキモはどこ? C/W/Lフレームワーク を用い、代表的な評価指標に対してユーザーモデルの精度とユーザー満足度との相関を調査した。 どうやって有効だと検証した? 独自で作成したデータセットと公開されている検索行動データセットを用いた。 ユーザーのクリック行動に合わせて最適化された評価指標は、ユーザー満足度の情報でキャリブレーションされたメトリクスと同等の性能を発揮できることが分かった。 [SIGIR 2020] Studying Product Competition Using Representation Learning どんなもの? 数百万商品のEC市場で、商品レベルの競合関係を購買情報を用いて分析した。 先行研究と比べてどこがすごい? 商品情報をEmbeddingすることにより、製品数で計算コストが線形に増大しないモデルを作成した。 技術や手法のキモはどこ? 商品情報はWord2vecと同様の手法でEmbeddingした。 競合関係は代替品と補完品を定量的に区別して分析した。 どうやって有効だと検証した? 公開されたベンチマークで、既存手法と比較し、HitRateなどの指標で改善されていることを示した。 [SIGIR 2020] Understanding Echo Chambers in E-commerce Recommender Systems どんなもの? ECサイトのレコメンドでも エコーチェンバー効果 が発生しているかを分析し、クリックに関しては傾向があることを観測した。 先行研究と比べてどこがすごい? 人工的に作成された環境ではなく、大規模なECサイトの実データを用いて初めてエコーチェンバー効果を評価した。 技術や手法のキモはどこ? ユーザーのクラスターの状態の時系列変化を分析することで、エコーチェンバーを観測した。 どうやって有効だと検証した? Alibaba Taobaoのデータを用いて分析し、クリックに関してはエコーチェンバー効果の傾向が見られた。 購入に関してはその効果が緩やかになっているという結果も分かった。 [SIGIR 2020] Cascade or Recency: Constructing Better Evaluation Metrics for Session Search どんなもの? カスケード仮説と親近効果の両方を考慮したセッションベースの検索指標(RSMs)を提案した。 カスケード仮説 :順位の低い検索結果はユーザーの注目度が低いため、評価時には小さな重みを割り当てる方が良い 親近効果 :ユーザーが同セッション内の後段で発行したクエリに、より大きな重みを割り当てる方が良い 先行研究と比べてどこがすごい? 親近効果を考慮した検索指標を初めて提案した。 検索のユーザー満足度を測るデータを作成し公開した。 技術や手法のキモはどこ? 親近効果を「セッション内最後のクエリとその時に発行されたクエリの距離」として定義し、既存の評価のフレームワークに組み込んだ。 どうやって有効だと検証した? 独自のユーザーの検索行動データセットを作成し、既存のセッションベースメトリクスと比較することで、ユーザー満足度との相関関係を明らかにした。 KDD [KDD 2012] Summarization-based Mining Bipartite Graphs どんなもの? 二部グラフから真の関係情報を抽出し、その情報を利用して複数のタスクを解いた。 先行研究と比べてどこがすごい? 結果の解釈がシンプルかつ容易である上に、リンク予測問題やクラスタリングのタスクを精度良く解くことができた。 技術や手法のキモはどこ? 二部グラフに対し、両タイプのノードを同時にクラスタリングした上で、クラスタ同士の関係性を可視化した。 クラスタリングはMDLを用いてエッジの追加削除を選択した。 どうやって有効だと検証した? 人工データと一般のデータを用い、リンク予測問題とクラスタリングのタスクを従来手法よりも良い結果で解いて有用性を示した。 また、クラスタ同士の可視化も従来手法よりも容易に解釈が可能となった。 [KDD 2019] Applying Deep Learning To Airbnb Search どんなもの? Airbnbがディープラーニング(DL)を検索ランキングへ適用するために行ったことと、それまでの歴史をまとめた。 下図のようにDLで成功するまでの長い道のりが記されている。 先行研究と比べてどこがすごい? Airbnbの中での検索ランキングモデルの歴史と失敗した取り組みまで紹介されている。 技術や手法のキモはどこ? 段階的にランキングモデルをどのように変更して改善したかが書かれている。 ハンドメイドのスコアリング GBDTとFMをNNの入力にしたモデル DNNを適用 どうやって有効だと検証した? 実際にオフラインとオンラインでテストを実施して精度向上を確認した。 [KDD 2020] Embedding-based Retrieval in Facebook Search どんなもの? Facebookは従来Boolean Matchの検索だったが、Embedding-basedな検索にした。 その際に改善させたアーキテクチャなどを紹介している。 先行研究と比べてどこがすごい? Facebookが抱えていた、テキスト情報だけでは検索がうまく行われない問題を解決した。 Embedding-basedな検索にする際、テキスト情報と付加情報(位置情報やユーザー情報)をうまく組み合わせた。 技術や手法のキモはどこ? テキスト情報と負荷情報を組み合わせたEmbedding-basedな検索システムを開発した。 損失関数にTriplet lossを使用しており、その際のHard Negative Samplingについても検索という観点から考察した。 どうやって有効だと検証した? 実際にオンラインでA/Bテストを行い、有効であることを検証した。 [KDD 2020] Controllable Multi-Interest Framework for Recommendation どんなもの? ユーザーの商品のクリックのシーケンス情報から興味情報に応じた推薦結果を出力する。 その結果の多様性をコントロール可能なフレームワークを提案した。 先行研究と比べてどこがすごい? 複数の関心を抽出できる研究は存在していたが、それと同時に出力結果の多様性をコントロールできるようにした。 技術や手法のキモはどこ? 下図のように複数の興味抽出の機構と、得られた興味のベクトルに似たアイテムを多様性を考慮して選択する機構を構築した。 どうやって有効だと検証した? Amazon BooksやTaobaoの商品データを用いて、既存手法に比べてRecall/nDCG/HitRateが改善されていることを示した。 [KDD 2020] On Sampled Metrics for Item Recommendation どんなもの? Recall/Precision/nDCGといった評価指標をサンプリングで計算すると推定値にバイアスが乗る可能性を指摘した。 また、そのバイアスを補正する方法を提案した。 先行研究と比べてどこがすごい? 多くのレコメンドアルゴリズムの論文が採用している評価指標の危険性を指摘した。 技術や手法のキモはどこ? 複数のデータセットで、サンプリングによって評価指標にバイアスと不安定性が存在することを示した。 どうやって有効だと検証した? レコメンドアルゴリズムの出力結果をサンプリングされた方法で計算し、サンプリングを使用しない手法と比較して強いバイアスが存在することを示した。 バイアス補正の方法も、同様の実験で動作することを示した。 [KDD 2020] Personalized Image Retrieval with Sparse Graph Representation Learning どんなもの? Adobe Stock での画像検索のパーソナライズを改善した。 先行研究と比べてどこがすごい? 画像検索のパーソナライズの精度を、画像間の類似度を用いて疎なユーザーと画像間の関係を補強したグラフ構造に対してのGCNを用いて向上した。 技術や手法のキモはどこ? GCNを用いて画像埋め込みにユーザー行動情報を反映した。 疎なユーザーと画像間の関係を画像の類似度の情報を用いることで補強した。 どうやって有効だと検証した? Adobe Stockデータでクリックされるのポジションの平均値とRecallが改善されていることを示した。 [KDD 2020] Managing Diversity in Airbnb Search どんなもの? AirbnbではDLのランキングモデルとは別に多様性のモデルを開発しており、その取り組みの歴史をまとめた。 先行研究と比べてどこがすごい? Airbnbの多様性の手法について、ヒューリスティックベースの手法からDNNに至るまでの手法をまとめている。 技術や手法のキモはどこ? 多様性の評価尺度を、Mean Listing RelevanceやLocation Diversityなど様々な独自指標で定義して実験している。 モデルはTwo Towerで、かつ各Embedding間での距離を遠ざけている。 どうやって有効だと検証した? オフライン評価では、通常の検索の評価指標であるnDCGと多様性の両方が改善するモデルを採用した。 オンライン評価ではnDCGやCV(予約数など)を計測し、多様性改善によって過去数年で最大の改善が実現されていることを示した。 [KDD-workshop 2020] Lessons Learned Addressing Dataset Bias in Model-Based Candidate Generation at Twitter どんなもの? 2段階のレコメンドシステムにおいて、1段階目のCandidate Generation(以下CG)に影響するデータセットのバイアスについて調査した。 また、ランダムランブリングでバイアスを軽減する方法を示した。 先行研究と比べてどこがすごい? 従来のバイアス除去の技術ではCGにおいて効果が薄かった。 本手法ではCGに効果的なバイアス軽減方法を提案した。 技術や手法のキモはどこ? CG後にも2段階目でのランカー学習のために負例対象を残す必要があるため、Implicit Negativeな結果を負例として扱う。 さらに負例のサンプリングを行うことで、明らかな負例を少しだけ残しておく。 結果として、少量の明らかな負例・正例に近い負例・正例から構成された候補集合の作成を見込むことができる。 どうやって有効だと検証した? オフラインでは上記手法を使用してROC-AUCを計測して改善することを確かめた。 オンラインではTwitterでテストを行い、Fine-tuningしたモデルでコンバージョン(お気に入り登録・リツイート数)が改善されていることを示した。 TheWebConf (旧WWW) [WWW 2020] NERO: A Neural Rule Grounding Framework for Label-Efficient Relation Extraction どんなもの? 関係抽出のタスク(下図参照)において、アノテーションされたルールにマッチしない文章のラベリングルールも学習することで精度(F1値)向上を実現した。 先行研究と比べてどこがすごい? 文章単位でのラベルを人手でアノテーションすること無く、ラベル付けのルールを学習できる。 できるだけ少ない労力で精度向上できるフレームワークを提案した。 技術や手法のキモはどこ? 下図(D)のようにハードマッチしない文章に擬似ラベルを割り当てて学習する。 どうやって有効だと検証した? 文章とアノテーションされた関係のデータセットに対してF値で評価したところ、従来手法に比べて改善されていることを示した。 また、10分の1程度のアノテーションの時間で従来と同程度の精度を達成した。 [WWW 2020] The Difference Between a Click and a Cart-Add: Learning Interaction-Specific Embeddings どんなもの? ユーザーへの商品のレコメンドの精度を向上した。 ユーザーがある商品をクリックをした後とカートに商品を追加した後にレコメンドする商品は、それぞれ異なることが望ましいという仮説に則ってモデル化した。 先行研究と比べてどこがすごい? 過去に閲覧などのアクションを起こした商品の情報だけではなく、そのアクション情報も組み合わせた埋め込み表現を使用した。 技術や手法のキモはどこ? アクションとアイテムのペアを系列とみなして学習した。 (l1, click), (l2, click), ...というような系列を学習する(l1、l2はアイテムを表す)。 どうやって有効だと検証した? オフライン評価は Etsy の1年間のログを用いて、過去に購入されたアイテムをどの程度捕捉できているかという指標で比較し、改善されていることを示した。 オンライン評価は実際に7日間A/Bテストを行い、CV率が向上していることを示した。 RecSys [RecSys 2018] Calibrated recommendations どんなもの? オフライン評価で精度に最適化されたモデルが、ユーザーの関心のない分野をレコメンドしてしまう問題がある。 映画のジャンルなどのカテゴリに偏りがあるレコメンドシステムを、キャリブレーションすることで上記問題を解決する新しいリランキング手法を提案した。 先行研究と比べてどこがすごい? ジャンルの多様性を考慮した、レコメンドシステムの出力を後処理するシンプルな手法を提案した。 技術や手法のキモはどこ? アンバランスなレコメンドを補正するための劣モジュラ性を持つキャリブレーションメトリックを提案した。 どうやって有効だと検証した? MovieLensのデータを対象に、レコメンドシステムにジャンルの多様性を補完する仕組みを導入した。 その結果、Recallは減少するが多様性が改善されることを観察した。 [RecSys 2019] A Pareto-Eficient Algorithm for Multiple Objective Optimization in E-Commerce Recommendation どんなもの? トレードオフの関係にある複数の目的関数(例:GMV vs. CTR)のランキング学習に対して、パレート効率的な解を算出するフレームワークを提案した。 先行研究と比べてどこがすごい? 多目的なランキング学習に対して、理論的にパレート効率性の保証のあるアルゴリズムのフレームワークを考案した。 先行研究ではルールベースかヒューリスティックな探索のため、パレート効率性の保証がない。 実際のECサイトのインプレッション・クリック・購入などの情報から構成されるデータを作成して公開した。 技術や手法のキモはどこ? モデルパラメータと重みを交互に最適化することで、理論的に保証のある解が得られる。 どうやって有効だと検証した? 独自に作成したオープンなECのデータ(EC-REC)を用いて、GMVとCTRの2つの目的関数で実験して有効性を検証した。 その他 [WSDM 2010] Anatomy of the Long Tail: Ordinary People with Extraordinary Tastes どんなもの? アイテムを人気度順にソートしたとき、テール部分に属するアイテムの重要性を示した。 ニッチな商品は一部のユーザーしか見ないのではなく、多くのユーザーがニッチな商品を少しずつ調べている、ということを示した。 先行研究と比べてどこがすごい? 先行研究では、ニッチな商品は一部のユーザーにしか見られないという仮定が強かった。 多くのユーザーがトップのアイテムもテールのアイテムも両方欲している、という仮説を立ててこれが正しいことを証明した。 技術や手法のキモはどこ? ユーザー満足度・ユーザーのEccentricity(どれだけニッチな商品を見ているか)を定義した。 テール商品が売上に関連することを示すユーザー行動をモデル化した。 どうやって有効だと検証した? ユーザー満足度・ユーザーのEccentricityを定義し、従来の仮説だとNetflixやYahoo! Searchの実データの結果に合わないことを示した。 テールの商品を補完することで、間接的に売上が向上することを理論的に検証した。 [VLDB 2013] Supporting Keyword Search in Product Database: A Probabilistic Approach どんなもの? 「安価なゲーム用PC」のようなキーワード検索を用いて、構造化された製品情報に対する検索を最適化する方法を研究した。 先行研究と比べてどこがすごい? 「安価」といった曖昧なユーザーの嗜好にマッチする製品のランキングを作るためのブラックボックスでない確率モデルを初めて提案した。 確率モデルを用いたキーワード検索以外のアプリケーションも提案した。 技術や手法のキモはどこ? ユーザーの検索クエリと商品のスペックのギャップを解決する新たな確率モデルを提案した。 また、そのモデルをユーザーレビューや過去の行動ログを使って改善した。 どうやって有効だと検証した? Best BuyとWalmartの商品データとレビューと検索のログデータを用いて、検索結果のnDCGを評価して従来手法よりも改善されていることを示した。 [ACL-short 2018] ‘Lighter’ Can Still Be Dark: Modeling Comparative Color Descriptions どんなもの? 色の比較級(darkerなど)は元の色が無いとRGBで表すことができないが、基準の色と比較級のみを与えることでRGB空間での方向を表した。 先行研究と比べてどこがすごい? 基準となる色をベースとして比較級をRGBのベクトルで表現した。 直接的な色の比較級の情報が必要なく、基準の色とその比較級があれば良い。 技術や手法のキモはどこ? 元の色と比較級を与えると、元の色を起点にRGB中で色の変化を表すベクトルを出力する。 どうやって有効だと検証した? 色の変化を 色差 とコサイン類似度を用いて評価した。 [ECIR 2020] From MAXSCORE to Block-Max Wand: The Story of How Lucene Significantly Improved Query Evaluation Performance どんなもの? Block-Max WAND がLucene 8に実装されるまでの道のりと性能測定をまとめた。 先行研究と比べてどこがすごい? アカデミックなアルゴリズムを利用者が既に存在するライブラリに実装した。 技術や手法のキモはどこ? Luceneのインデックスのフォーマットやスコア計算処理を既存のインデックスに影響が出ないように変更した。 どうやって有効だと検証した? ClueWebのデータセットを用いて計算時間が改善することを確認した。 [MLSys 2020] Understanding the Downstream Instability of Word Embeddings どんなもの? モデルをどの程度の頻度で更新するのが良いかをモデルの学習の不安定さから解釈しようとした。 単語埋め込みの安定性とメモリのトレードオフを示し、不安定性のための新たな尺度を提案した。 先行研究と比べてどこがすごい? 上流のモデルの固有値の不安定性(EIS)を測ることで、下流のモデルの不安定性にも強い相関があることを示した。 また、安定性とメモリのトレードオフはナレッジグラフの埋め込みにも適用できることを示した。 (上記画像は slide より引用) 技術や手法のキモはどこ? 下流のモデルの不安定性にも強い相関がある上流のモデルの固有値の不安定性(EIS)を提案した。 また、メモリのパラメータが下流モデルの不安定性に影響を受けることを示した。 どうやって有効だと検証した? メモリのパラメータを変更することで不安定性を改善することを示した。 ナレッジグラフの埋め込みについても同様に改善することを示した。 [COMPUTER GRAPHICS Forum 2020] Interactive Optimization of Generative Image Modelling usingSequential Subspace Search and Content-based Guidance どんなもの? 学習済みのGANとインタラクティブな入力を用いて目的の画像を生成するシステムを開発した。 本システムでは以下のようなツールなど(画像編集ツールなども可能)を用いて、ユーザーは自由に重みを選択できる。 その重みに応じた画像を表現できる。 先行研究と比べてどこがすごい? 既存のインタラクティブなシステムでは、独自のアーキテクチャや追加のデータが必要だった。 本システムは任意のモデルに追加のデータやアーキテクチャ無しで自分の意図を表現できる。 技術や手法のキモはどこ? ユーザーの入力に基づいたベクトルをサンプリングして、高次元空間を効率よく逐次最適化できる。 どうやって有効だと検証した? 本手法を様々な生成画像モデリングアプリケーションで実験し、既存手法のiGANよりも優れた性能を示した。 まとめ 論文読み会では多くの方に集まっていただき、多くの有益な情報を共有できました。 最後に、本記事でご紹介した論文のアブストラクトのWord Cloudを作成してみました。 「search」「recommendation」以外にも「user」や「model」という文字も大きく表示されていますね。 本記事では、ZOZO研究所が検索や推薦技術で取り組んでいるアカデミックな調査の概要をお伝えしました。本記事を通して、ご興味のある論文を見つけた際には是非とも原著論文を読むことをお勧めします。 おわりに ZOZO研究所では検索エンジニア・MLエンジニア・サーバサイドエンジニアのメンバーを募集しております。今回紹介した検索/推薦技術に興味ある方はもちろん、幅広い分野で一緒に研究や開発を進めていけるメンバーも募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co hrmos.co https://hrmos.co/pages/zozo/jobs/0000029 hrmos.co
アバター
はじめに こんにちは。メディアプラットフォーム本部 WEAR部 WEAR-SREの長尾です。 WEAR は2013年にリリースされ、現在8年目のサービスです。そして、2004年にリリースされた当時のZOZOTOWNと同じアーキテクチャを採用しているため、比較的古いシステム構成で稼働しています。本記事では、そのWEARのWebアプリケーション刷新とクラウド移行で実践している、Fastlyを活用したパスベースルーティングによる段階移行の取り組みを紹介します。 WEARをリプレイスする理由 WEARのWebアプリケーションは、データセンターでオンプレミス(以下、オンプレ)上で稼働しています。また、DBはSQL Serverを利用しています。長年このアーキテクチャで成長を続けてきましたが、今後さらに成長を加速させていくためには以下の3点を実現する必要があります。その実現に向け、2年前からリプレイスに着手しています。 開発スピードの加速化 コストの削減 人材の増強 経緯に関する考え方はZOZOTOWNのシステムと同様なため、以下のスライドもご覧ください。 speakerdeck.com リプレイス後のシステムは、クラウドはAWSを採用し、ALB、ECS、Fargate、Railsアプリケーションをベースにした構成です。 Fargate x Railsアプリケーションの詳細は、 id:takanamito さんの記事で紹介されているので、合わせてご覧ください。 techblog.zozo.com リプレイスにおける課題と解決策 WEARは日々新しい機能追加や機能改善をしているサービスです。そのため、新機能を取り込みつつ、全く新しいアプリケーションを作って切り替える、ビックバンアプローチは難しいと考えました。 そこで、パス単位でオンプレ環境とAWS環境に適宜ルーティングする機能(パスベースのルーティング)を用意し、パス単位でAWS側に機能を作成して徐々に切り替えていくアプローチをとることにしました。 パスベースのルーティングを実現する方法として、CloudFrontとFastlyを比較しました。その結果、これから紹介する2つの理由により、Fastlyのほうが導入の難易度が低く、保守性も高いと判断し、Fastlyを採用しました。 1. DNSの移行が不要 WEARのホストはZone Apexである「wear.jp」を利用しています。wear.jpのDNSはAWS外で管理されており、CloudFrontを利用するためには事前にDNSをRoute 53に移設する必要があります。一方で、FastlyはAnycast Addressが払い出され、それをAレコードに利用できるため、DNSを移行しなくてもZone Apexの設定が可能です。 詳しくは 公式ドキュメント をご参照ください。 2. 設定反映が速い Fastlyは設定の反映が高速で、ロールバックが必要になったとしてもすばやく対応できます。WebのトラフィックをすべてFastlyで受けるため、トラブルが発生した際の影響を最小限にするためにも、最短でロールバックできることは大きなメリットだと考えました。 サービス 設定反映までの時間 参考リンク Fastly 5秒程度 Fastly network map | Fastly CloudFront 5分程度 AWS Blog Fastlyを利用したパスベースのルーティング設定 まずはじめに、Fastlyについて少し触れておきます。一般的なFastlyのイメージはCDN(Content Delivery Network)だと思いますが、VCLを使った柔軟な設定ができるという特徴があります。下図のように「あらかじめ定義したバックエンドに対し、条件に合わせてルーティングを設定する」という用途にも適しています。 パスベースのルーティング設定例 以下のサンプルは、Edge Dictionariesとtable.contains関数を利用したパスベースのルーティング設定例です。 詳細については公式のドキュメントをご確認ください。 docs.fastly.com まずバックエンド(オリジンサーバ)を作成します。 backend backend1 { .between_bytes_timeout = 10s; .connect_timeout = 1s; .dynamic = true; .first_byte_timeout = 15s; .host = "backend1のHost名"; .max_connections = 600; .port = "443"; .share_key = "xxxxxxxxxxxxxx"; .ssl = true; .ssl_cert_hostname = "Fastly側のHost名"; .ssl_check_cert = always; .ssl_sni_hostname = "Fastly側のHost名"; .probe = { .expected_response = 200; .initial = 3; .interval = 5s; .request = "HEAD /healthcheck HTTP/1.1" "Host: xxxxx.jp" "Connection: close" "User-Agent: Varnish/fastly (healthcheck)"; .threshold = 3; .timeout = 32.767s; .window = 5; } } backend backend2 { .between_bytes_timeout = 10s; .connect_timeout = 1s; .first_byte_timeout = 15s; .host = "backend2のHost名"; .max_connections = 600; .port = "80"; .share_key = "xxxxxxxxxxxxxx"; .probe = { .expected_response = 200; .initial = 3; .interval = 5s; .request = "HEAD /healthcheck HTTP/1.1" "Host: xxxxx.jp" "Connection: close" "User-Agent: Varnish/fastly (healthcheck)"; .threshold = 3; .timeout = 32.767s; .window = 5; } }∂ 次に Edge Dictionaries を使用して、パスを登録していきます。 table path_routing_dict { "/path1": "aws", } table.contains関数 を利用して、Edge Dictionariesに登録されているパスとURLパスが一致するかを判定します。 if (table.contains(path_routing_dict,req.url.path)) { set req.backend = backend1; } else { set req.backend = backend2; } Edge Dictionariesを使った方法は、シンプルな記述ができるというメリットがありますが、パスが完全一致する場合にしか使かえないという制約がありました。そのため、「/path1/以下はすべてbackend1にルーティングする」という場合は、前方一致のif文を記述しています。 if (req.url.path ~ "^/path1/") { set req.backend = backend1; } else { set req.backend = backend2; } 導入時に発生した課題と解決策 次に、Fastlyをクライアント(ブラウザなど)とバックエンドの間に挟むことで発生した課題とその解決策をご紹介します。 発生した課題 弊社がオンプレ環境で利用しているロードバランサは、歴史的経緯でパーシステンス機能を有効にしており、以下のようなロジックで動いています。 これにより、同一クライアントのリクエストは同一アプリケーションサーバに転送する仕組みを実現しています。 パスベースのルーティング構成ではクライアントとロードバランサの間にFastlyが挟まっています。そのため、ロードバランサは下図のように、Fastly EdgeとのTCPコネクション(Keep-Alive)からサーバ割り当てを判定します。 クライアントは毎回同じFastly Edgeを経由する保証が無いため、リクエスト毎に違うアプリケーションサーバを割り当てられてしまうという事象が発生しました。これにより、既存システムの仕様により一部の機能が正常に動作しなくなることが判明しました。 解決策 解決方法は2つ考えられました。 ロードバランサの判定ロジックを変更する FastlyでKeep-Aliveを無効化する 検討の結果、ロードバランサで対応する場合は影響範囲の確認と動作検証に時間を要すると考え、FastlyでKeep-Aliveを無効化する方針を採用することにしました。 【設定例1】コネクションをクローズさせる設定 sub vcl_miss { # 省略 set bereq.http.connection = "close"; } sub vcl_pass { # 省略 set bereq.http.connection = "close"; } sub vcl_pipe { # 省略 set bereq.http.connection = "close"; } Keep-Aliveを無効化すると、リクエストは下図のように転送されるため、発生していた課題を解決できました。 判定ロジックにより、リクエストがすべて下図の赤枠部分に該当するようになります。 【設定例2】1Edge当たりのコネクション最大数(max_connections)の設定 前述の設定により課題は解決したのですが、新しい懸念も発生しました。この構成では1リクエストごとに新規セッションを確立するため、コネクション数が枯渇するリスクがあります。また、TCPコネクションを確立するためのオーバーヘッドがレスポンスタイムに影響します。この点は、システムの計測とチューニングを行った結果、現状問題は発生していません。 backend backend1 { #省略 .max_connections = 1000; } 今回紹介した課題と解決策はほんの一例です。既存環境に新しいリソースを組み込むことから、想定外の挙動は発生するものだと考え、柔軟に対応していく必要があります。そのため入念な検証を実施することをお勧めします。 Fastlyの運用 実際に、どのようにFastlyを運用しているのかを簡単に紹介します。 リリースの仕組み Fastlyの設定はすべてTerraformで作成しており、そのコードはGitHubで管理しています。CI/CDはCircleCIを利用しています。mainブランチへマージしたタイミングでステージング環境に反映し、リリースタグを切ったタイミングで本番環境へ反映するように自動化しています。CI/CDに関する詳細な説明は、後日別の記事で紹介したいと思います。 監視の仕組み 監視は、FastlyのメトリクスとログをDatadogに取り込んで実現しています。 コストの観点から、Datadog Logsのログ保存期間は2週間にしており、それ以前のものはS3へ転送することで長期保存も実現しています。過去ログの検索はS3のログをAthenaで検索する運用にしています。 次に、監視している項目を一部ご紹介します。 503エラー 503エラーをレスポンスのメッセージごとにカウントし、それぞれ対策を実施していきます。 503エラーの原因については、公式のドキュメントに詳しく記載されていますのでご参照ください。 docs.fastly.com 同一IPからのリクエスト数 同一IPから大量にリクエストが来ていないかを監視しています。一定の閾値を超えたIPに対し、攻撃かクローラーアクセスかを判定し、必要なものはブロックする運用をしています。 まとめ オンプレ環境とAWS環境を共存させながら、パス単位で徐々にクラウド移行していくアプローチについて、Fastlyを利用した事例をご紹介しました。今後はFastlyを利用したサイト高速化へのアプローチや、DoS防御などのセキュリティ向上への取り組みに関しても別の記事でご紹介できればと思います。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
はじめに SRE部 ECプラットフォームSREチームの小林 ( @akitok_ ) です。 ZOZOTOWNでは、マイクロサービス間通信におけるトラフィック制御のために、 Istio によるサービスメッシュを導入しています。本記事ではZOZOTOWNのマイクロサービスプラットフォーム基盤(以下、プラットフォーム基盤)において、Istioをいかにプロダクションレディな状態で本番に投入していったか、その取り組みを紹介します。 なお、Istioによるサービスメッシュを導入した背景については、以下の記事で紹介しています。 techblog.zozo.com はじめに What is Istio? Istioをプロダクションレディにするまでに直面した3つの課題 どのようにリソース消費量を見積もるか Data Planeサイジング Envoyプロキシのチューニング 負荷試験 Istioベンチマーク試験 サービス単体負荷試験 サービス結合負荷試験 Control Planeサイジング 何を監視するか メトリクス監視 Data Planeメトリクス Control Planeメトリクス 分散トレーシング どのように可観測性を向上させるか まとめ 終わりに What is Istio? Istioは、マイクロサービスの複雑性を解決する一手段である「サービスメッシュ」を実現するためのフレームワークです。サービスメッシュは、マイクロサービスの実装においてビジネスロジックに集中できることを目指して生まれた手法です。 具体的にはサービス間の通信制御をサービスごとに実装させるのではなく、すべてプロキシ経由の通信とし、ルーティングや認証などのプロキシ設定を全体に伝搬させます。プロキシ経由でサービス間に網状の構成を取ることから、サービスメッシュと呼ばれています。 Istioは以下の特徴を持ちます。 Google、IBM、Lyftの3社共同開発により、2017年5月にOSS化されたサービスメッシュフレームワーク KubernetesのPodにプロキシ(Envoy)をサイドカーコンテナとして注入させることで、サービスのコード変更を伴わずにサービスメッシュの実現が可能 アーキテクチャは以下の通りです。 Istioのアーキテクチャは、Data PlaneとControl Planeに分割して考えることができ、それぞれ以下の特徴を持ちます。 Data Plane サイドカーとして注入されるEnvoyプロキシ(正確にはEnvoyプロキシの拡張)のコンテナから構成される このプロキシがマイクロサービス間の通信を仲介・制御する Control Plane Envoyプロキシコンテナのサービスへの注入や設定伝搬を司る Istioをプロダクションレディにするまでに直面した3つの課題 ZOZOTOWNでは、これからも持続的に成長を続けていくことを目的とし、現在レガシーシステムのリプレイスを進めています。ZOZOTOWNのリプレイス戦略については、以下のスライドをご覧ください。 speakerdeck.com その一環で、モノリシックアーキテクチャから、マイクロサービスアーキテクチャへの移行も行われています。そして、マイクロサービス化が進むにつれ、プラットフォーム基盤上で稼働する各サービス間通信に複雑性が生まれていました。 そこで、この課題を解決していくために、昨年度末にIstioの導入を推進しました。その際に、Istioをプロダクションレディな状態で導入していくために、以下3つの大きな課題に直面しました。 どのようにリソース消費量を見積もるか 何を監視するか どのように可観測性を向上させるか 本記事では、それぞれどのように検討・対処を進めていったかをご紹介します。 どのようにリソース消費量を見積もるか Istioのリソース消費量を見積もり、適切なキャパシティプランニングを行う必要があります。そのためには、アーキテクチャに基づき、Data PlaneとControl Planeをそれぞれ分けて考慮する必要があります。 Data Planeサイジング Istioの 公式ドキュメント によれば、Data Plane(Envoy)のパフォーマンスについて、以下のようにレポートされています。 Envoyプロキシは、プロキシを通過するリクエストにおいて、1000リクエスト/秒あたり0.35vCPUと40MBメモリを使用する Envoyプロキシは、90パーセンタイルで、レイテンシに2.65ミリ秒を追加する 上記の数値は以下の前提で行われた負荷テストによる結果です。 Istio 1.10を使用する サービスメッシュが1000個のサービスと2000個のEnvoyプロキシ(サイドカーコンテナ)で構成される サービスメッシュ全体で1秒あたり70000回のリクエストがある 実際にはData Planeのパフォーマンスは、以下にあるような要素にも依存し、変動します。 クライアント接続数 目標リクエストレート リクエストサイズとレスポンスサイズ プロキシワーカースレッド数 プロトコル CPUコア数 これらの要因により、レイテンシやスループット、EnvoyプロキシのCPUやメモリのリソース消費量は変化します。そのため、Istioの公式ドキュメントを参考にしながらも、実際に負荷試験を行い、実環境で計測することが非常に重要です。 Envoyプロキシのチューニング 負荷試験の説明を進める前に、まずData Planeのチューニングポイントである、Envoyプロキシのチューニングについて説明します。 Envoyプロキシの resource 設定は、Envoyプロキシを注入するリソースに対し spec.template.metadata.annotations の指定を追加することで、チューニング可能です。 resource設定に関するannotationは以下の通りです。 annotation 説明 sidecar.istio.io/proxyCPU EnvoyプロキシのCPU Requestを指定する sidecar.istio.io/proxyCPULimit EnvoyプロキシのCPU Limitを指定する sidecar.istio.io/proxyMemory EnvoyプロキシのMemory Requestを指定する sidecar.istio.io/proxyMemoryLimit EnvoyプロキシのMemory Limitを指定する 以下の例は、Deploymentリソースに注入するEnvoyプロキシのCPU Limitを500m、Memory Limitを512Miに指定する例です。 apiVersion : apps/v1 kind : Deployment metadata : name : test-api spec : template : metadata : annotations : sidecar.istio.io/proxyCPULimit : 500m sidecar.istio.io/proxyMemoryLimit : 512Mi その他のannotationでの設定は、 公式リファレンス をご参照ください。 本記事で説明する負荷試験では、試験結果を見ながら、このannotationによるチューニングを繰り返し行いました。 負荷試験 プラットフォーム基盤では、以下の3つの負荷試験フェーズに分け、パフォーマンス測定を行い、チューニング精度を上げていくようにしました。 Istioベンチマーク試験 サービス単体負荷試験 サービス結合負荷試験 また、プラットフォーム基盤でのIstioの導入は、BFF(Backends For Frontends)を実現するZOZO Aggregation APIがファーストターゲットとなりました。以下に示す負荷試験イメージは、このAPIの負荷試験を対象として記しています。 ZOZOTOWNのBFFへの取り組みについては、以下の記事をご参照ください。 techblog.zozo.com Istioベンチマーク試験 Istioベンチマーク負荷試験は、以下の構成で実施しました。 この構成では、実際のマイクロサービスをData Planeに置くのではなく、 Fortio という負荷試験クライアントのPodにEnvoyプロキシを注入し、Data Planeに組み込んでいます。FortioがEnvoyプロキシ経由でコールするバックエンドサービスは、 httpbin というモックを水平スケールさせた状態で稼働させています。この状態でクライアントからcurlコマンドでHTTPリクエストを実行し、Fortio経由でEnvoyプロキシに負荷をかけ、検証しました。 この試験は、各マイクロサービスに注入するEnvoyプロキシの初期リソース(CPU、Memory)サイジングに役立ちました。また、マイクロサービスのリソースサイジングだけでなく、Istioのバージョンアップにおけるパフォーマンスの変化を確認できる環境としても役立っています。 サービス単体負荷試験 サービス単体負荷試験は、以下の構成で実施しました。 この構成では、試験対象であるマイクロサービスのPodにEnvoyプロキシコンテナを注入し、連携先である他サービスは、 Nginx を用いて静的コンテンツを返すWebサービスモックを用意しました。さらに、負荷試験はIstioベンチマーク試験とは異なり、本番環境へのリクエストを想定したテストシナリオを作成し、 Gatling を負荷試験クライアントとして活用しています。 複雑なサービスメッシュ構成において一気にすべてのサービスを接続し、想定したパフォーマンスが出ない、あるいはエラーが頻発するというような事象が発生した場合、問題切り分けが非常に困難になります。被疑箇所は、以下のように分割して考える必要があります。 接続元サービス 接続元サービスのEnvoyプロキシ 接続先サービスのEnvoyプロキシ 接続先サービス そこでサービス単体試験環境を用意し、接続元サービスと接続元サービスのEnvoyプロキシのチューニングを完了させた上で、実際のマイクロサービスを連携させた負荷試験のフェーズに進むことが重要と考えました。 サービス結合負荷試験 サービス結合負荷試験は、以下の構成で実施しました。 この構成では、連携する他サービスも含め、本番環境と同等の環境を用意しています。 この試験結果が期待通りでない場合は、単体負荷試験と比較しながら切り分けを行うことで、マイクロサービス間での課題整理をスムーズに進めることができました。 Control Planeサイジング Control Planeを構成するIstiodコンポーネントのパフォーマンスは、以下の要素に依存し、変動します。 Deploymentの変更頻度 Configurationの変更頻度 Istiodに接続するEnvoyプロキシ数 またIstiodは水平にスケール可能なので、CPU使用率などをトリガーとしてKubernetesの HPA(Horizontal Pod Autoscaler) 設定で、オートスケールさせると良いです。 なお、本記事ではIstioの構築には深く触れていませんが、プラットフォーム基盤では Istio Operator を活用した構築をしています。Istio OperatorによりHPAの設定は自動生成され、IstiodのCPU使用率が80%に到達したら、オートスケールするようにしています。 何を監視するか プラットフォーム基盤における運用監視には、 Amazon CloudWatch と Datadog を採用しています。特に今回、既にDatadogで取得している各サービスの監視対象メトリクスなども合わせてダッシュボード化していくことも考慮し、Istioに関するメトリクスもDatadogで取得する方針としました。 DatadogにおけるIstioインテグレーションについては、 公式ドキュメント をご参照ください。 メトリクス監視 監視対象のメトリクスについても、Data PlaneとControl Planeに分けて考慮しました。 Data Planeメトリクス プラットフォーム基盤上に稼働している各マイクロサービスは、Datadog APMを活用し、マイクロサービス単位でのメトリクス収集・監視は十分に実施できている状況でした。そのため、Data Planeの監視は、個々のマイクロサービスに着目するのではなく、Data Plane全体のエラーレートを監視するのが良いと考えました。 そこで、以下の2つのメトリクスを用いて、 エラー数 / リクエスト数 = エラーレート で算出した値を監視することにしました。 メトリクス 説明 trace.envoy.proxy.hits Envoyプロキシが受け付けたリクエスト数 trace.envoy.proxy.errors Envoyプロキシが受け付けたリクエストのエラー数 Control Planeメトリクス 前述の通り、プラットフォーム基盤ではIstio Operatorを用いた構築をしています。これによりControl Planeは、Istio Operatorのマニフェストファイルに基づき、自動運用されます。 例えば、Control PlaneのPod障害などがあった場合には自動で再起動され、Podのリソース消費が大きい場合にはオートスケールされるなど、回復性および拡張性をもった構成になっています。そのため、Control Planeの単純なインフラメトリクスの変化ではなく、Control Planeが正しい挙動をしていない以下のような状態を捕捉すべきと考えました。 何らかの原因でEnvoyプロキシの注入に失敗している 何らかの原因でEnvoyプロキシへの設定伝搬に失敗している それぞれ以下のメトリクスを監視することで、捕捉できます。 メトリクス 説明 istio.sidecar_injection.failure_total Envoyプロキシの注入に失敗した回数 istio.galley.validation.failed Envoyプロキシへの設定伝搬に失敗した回数 分散トレーシング プラットフォーム基盤ではIstioサービスメッシュの導入により、マイクロサービス間の通信が透過的にルーティングされ、複雑性が増しています。あるサービスのレイテンシ遅延の原因を調査したい場合に、1つのリクエスト起点で発生する複数のマイクロサービス呼び出しをすべてトレースし、どこで何が起きているのか特定するのは至難の業です。分散トレーシングは、まさにこれらのリクエストを追跡するための技術です。Istio Data Planeの分散トレーシングについては、Istioの公式ドキュメントで、 Zipkin や Jaegar 、 Lightstep を活用した方法が紹介されています。 前述の通りプラットフォーム基盤では、Datadog APMを活用し、各マイクロサービスの分散トレーシング情報を既に収集していました。そこで、Envoyプロキシを通過した通信も同様にDatadog APMを活用し、各マイクロサービスの通信とIstio Data Planeの通信を一気通貫でトレースできるようにしました。 Datadogを活用したIstio Data Planeのトレーシング情報の収集については、 公式リファレンス をご参照ください。 以下は、実際の各マイクロサービスとEnvoyプロキシを通過する通信を含むトレーシング情報の図です。 この図の「えんじ色」の部分が、Envoyプロキシのトレーシングを示しています。他のサービスからの呼び出し関係などを含め、一気通貫したトレースが容易になっています。 どのように可観測性を向上させるか ここまでは、Istioの監視メトリクスについて、いくつか紹介してきました。 一方で、他にも常に監視対象とする必要はないものの、運用状態として可観測性を高く保っておきたいメトリクスもありました。それらはDatadog Dashboardを使い、一箇所に情報を収集・可視化し、Istioサービスメッシュの健康状態の把握を分かりやすくしています。 以下が実際のダッシュボードです。 IstioのDatadog Dashboardの作成にあたっては、 公式ドキュメント と ブログ が非常に参考になりました。 プラットフォーム基盤上のマイクロサービスで、パフォーマンス劣化やエラーなどが観測された際に、特にウォッチしているグラフは以下のものです。 グラフ 説明 Request count by destination 宛先ごとのリクエスト数 Top request destination リクエスト数上位の宛先 Average request latency by destination 宛先ごとの平均レイテンシ Top latency destination レイテンシ上位の宛先 Request count by resource マイクロサービスごとのリクエスト数 Top request resource リクエスト数上位のマイクロサービス Error count by resource マイクロサービスごとのエラー数 Top error resource エラー数上位のマイクロサービス 例えば、サービスメッシュ全体のエラーレートが高騰した際に、特定のマイクロサービスに集中して発生している問題なのか、サービスメッシュ全体での問題なのか切り分ける必要があります。その際には、Top request resouceとTop error resourceのグラフを参考にしています。 具体的には、プラットフォーム基盤全体で発生している問題であれば、Top request resourceとTop error resourceのランキングは相関した動きになるはずです。一方で、特定のサービスに起因する場合は必ずしも相関せず、特定のサービスのみ大量にエラー発生している状態が読み取れるでしょう。 このように特定メトリクスを監視するだけでなく、Dashboardなどを活用した可観測性の向上も継続的に実施することが、サービスメッシュを拡大していく上では非常に重要です。 まとめ 本記事では、Istioサービスメッシュをプロダクションレディな状態で、ZOZOTOWNプラットフォーム基盤に導入してきた取り組みを紹介しました。Istioは今後ますますマイクロサービス全体に利用を拡大し、さらなる可観測性の向上や、サーキットブレーカーなどの高度な機能も取り入れていく予定です。また新たな知見が得られたら、紹介したいと思います。 終わりに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
こんにちは、計測プラットフォーム本部バックエンド部の髙木( @TAKAyuki_atkwsk )です。普段は ZOZOMAT や ZOZOGLASS などの計測技術に関する開発・運用に携わっています。ちなみにZOZOGLASSを使って肌の色を計測したところ、私のパーソナルカラーはブルーベース・冬と診断されました。 さて、本記事ではZOZOMATシステムで利用されていたNetwork Load BalancerをApplication Load Balancerに移行した事例をご紹介します。 ZOZOMATのシステム構成(2020年当時)に関しては、こちらの記事で詳しく説明されていますので合わせてご覧ください。 techblog.zozo.com 移行の背景 ZOZOTOWNアプリやZOZOTOWNシステムからZOZOMATシステムに対するリクエストの負荷分散のためにNetwork Load Balancer(以下、NLB)を利用していました。これは、ZOZOTOWNアプリからのリクエストがgRPCを利用することと、当時Application Load Balancer(以下、ALB)ではエンドツーエンドのHTTP/2対応がされていなかったことに拠ります。1年近くNLBを利用していましたが、2020年11月に エンドツーエンドのHTTP/2およびgRPCがALBにてサポートされた ことを受け、NLBからALBに移行しようということになりました。 NLBを利用していたときの課題 NLBがALPN対応していないため、TCPリスナーで構成しターゲットにはEnvoyを配置してTLS終端とALPNの役割を担う構成にしていました。このため、TLS証明書はAWS Certificate Manager(ACM)を利用できず、自前で購入・管理する必要がありました。環境を複数用意したい場合、その都度証明書を購入するかワイルドカード証明書を購入するか、という観点でも悩みどころとなっていました。 また、NLBではTCPリスナーだとアクセスログを取得できないことやレスポンスタイムなどのメトリクスを取得できないことも運用にあたっては少し不便でした。実際にはターゲットに配置しているEnvoyのアクセスログを利用することで補っていました。ALBではこれらの悩みが解決されますが、急激なトラフィック増加が見込まれる際には事前に暖機申請をして備えることが求められます。今回の移行時にこの点が懸念としてありましたが私たちのユースケースでは特に問題ありませんでした。 移行に関する課題 移行はシステムを停止させることなく行いたいという要望がありましたが、知見が少なかったことと、このような移行作業の経験もなかったため、注意深く検討する必要がありました。そのため、不明確だった以下の点の調査・検証を行いました。 ALB経由でgRPCリクエストを受け付けられるか 複数のIngressをグループ化して単一のALBでルーティングできるか ExternalDNSを利用したDNSレコード変更ができるか ロールバックできるか 本記事の後半では、そのなかでも複数のIngressをグループ化して単一のALBでルーティングできるか、ExternalDNSを利用したDNSレコード変更ができるか、の2点を紹介します。 移行方法 ZOZOMATシステムに紐付くドメインに対し、Route 53上でエイリアスレコードの値をNLBエンドポイントからALBエンドポイントへ切り替える方法で移行しました。NLB関連のリソースを残し、ALB関連のリソースを追加した状態で切り替えるため、切り替え後に不具合が起きた場合、すぐに切り戻しが可能な構成です。以下に切り替え前の構成図と切り替え時の構成図を示します。 構成図にはElastic Kubernetes Service(EKS)のクラスターや、Kubernetesのリソースも含めています。NLBやALB、そしてRoute 53のレコードがKubernetesリソースによって管理されるためです。 次に切り替えのために準備したものを紹介していきます。 ACMでのTLS証明書作成 ALBを利用する構成では ACMで発行したTLS証明書を使うことができる ので、あらかじめ発行しておきました。あらかじめ発行しておく理由は、後ほど触れるIngressリソースを定義する際に証明書のARNを参照するためです。 AWS Load Balancer Controllerのインストール AWS Load Balancer Controller をEKSクラスターにインストールします。これにより、Ingressリソースを追加すると連動してALBが作成されるようになります。ちなみに、v2.1.0からgRPCワークロードに対応していて、事前検証中にこのバージョンがリリースされ、良いタイミングで利用できました。 Kubernetesリソースの追加 今回追加したのはIngressリソースと、既存のServiceとは別のServiceリソースです。 Ingressは、AWS Load Balancer Controllerでアノテーションを設定していくことでALBの振る舞いを変えることができます。設定可能なアノテーションは以下のドキュメントを参照ください。 kubernetes-sigs.github.io ZOZOMATシステムでは、ZOZOTOWNアプリからはgRPCで、ZOZOTOWNサーバーからはHTTP/1.1(REST API)としてリクエストされることを考慮しなければなりませんでした。そのため、ALBを配置しても両方のプロトコルでリクエストを受けられるようにしておく必要があります。ALBでこれを実現するには、ターゲットグループを用意し、リスナーのルールによってそれぞれのターゲットグループにルーティングする方法を利用します。また、Ingressリソースの定義で実現できるかどうかも調査・検証しました。次章でご紹介します。 次に、Serviceリソースについて見ていきます。既存の構成では、LoadBalancerタイプのものを利用してNLBとして外部からアクセスする経路を作っていました。切り替え後の構成では、Ingressを利用してALBとして外部からアクセスする経路になるため、LoadBalancerタイプのServiceである必要はなくなります。そのため、新たにClusterIPタイプのServiceを用意してIngressと関連付けることにしました。 EnvoyのHTTPリスナーの作成 ロードバランサーのターゲットに配置されるEnvoyは、既存構成ではHTTPS用のリスナーのみ定義し、TLS終端やALPNを設定していました。切り替え後の構成では、ALBでこれらの役割を担うため、EnvoyにHTTP用のリスナーを追加しました。TLSの設定やポート番号以外は同じ定義です。 検証したこと 次に、今回検証した内容から2つのトピックをピックアップして紹介します。 検証1:複数のIngressをグループ化して単一のALBでルーティングできるか AWS Load Balancer Controllerに Ingress Group という仕組みがあり、これを利用すると複数のIngressを単一のALBに統合できます。これを設定すると、ALBではリスナールールおよびターゲットグループとして現れます。ZOZOMATシステムで実現したいgRPC、HTTP/1.1両プロトコルの設定をグルーピングする例を以下に示します。 # gRPC用のIngress apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : zozomat-ingress-grpc namespace : default annotations : kubernetes.io/ingress.class : alb # グルーピングするIngress間で共通の値を使う alb.ingress.kubernetes.io/group.name : zozomat-ingress alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/target-type : ip alb.ingress.kubernetes.io/backend-protocol-version : GRPC # ... spec : rules : - http : paths : - path : /foo.BarService/* backend : serviceName : envoy-service servicePort : 80 # ... --- # HTTP/1.1用のIngress apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : zozomat-ingress-http1 namespace : default annotations : kubernetes.io/ingress.class : alb # グルーピングするIngress間で共通の値を使う alb.ingress.kubernetes.io/group.name : zozomat-ingress alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/target-type : ip alb.ingress.kubernetes.io/backend-protocol-version : HTTP1 # ... spec : rules : - http : paths : - path : /foo/* backend : serviceName : envoy-service servicePort : 80 # ... このマニフェストを適用することで、単一のALBに対して複数のターゲットグループが関連付けられることを確認できました。 検証2:ExternalDNSを利用したDNSレコード変更ができるか ZOZOMATシステムでは ExternalDNS を利用しており、Kubernetesリソースを介してRoute 53のDNSレコードを制御する仕組みです。しかし、ExternalDNSに関して、既にService経由でDNSレコードが設定されている場合、Ingress経由で同じドメインのDNSレコードに対して操作が行えるかどうかが切り替える際に不確かな点でした。 LoadBalancerタイプのServiceに対してDNSレコードを作成するには external-dns.alpha.kubernetes.io/hostname アノテーションを設定します。詳しくは こちらのドキュメント に記載されています。 設定例を以下に示します。このマニフェストを適用するとNLBのエンドポイントをターゲットとする api.example.com のエイリアスレコードが作成されます。 apiVersion : v1 kind : Service metadata : name : envoy annotations : # 説明用のドメイン external-dns.alpha.kubernetes.io/hostname : api.example.com service.beta.kubernetes.io/aws-load-balancer-type : "nlb" service.beta.kubernetes.io/aws-load-balancer-internal : "false" spec : type : LoadBalancer # ... 続いて、この状態でIngressリソースを作成した場合、既存のエイリアスレコードに変更が発生するのかを確認していきます。 こちらのドキュメント を見ると、「Ingressの spec.rules[].host を設定するとDNSレコードが作成される」と書いてあります。 設定例を以下に示します。 apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : zozomat-ingress-grpc namespace : default annotations : kubernetes.io/ingress.class : alb # あらかじめACMで発行したTLS証明書のARN alb.ingress.kubernetes.io/certificate-arn : arn:aws:acm:<region>:<account-id>:certificate/xxxxxx # ... spec : rules : # Serviceのアノテーションで設定したドメインと同じ - host : api.example.com http : paths : - path : /foo.BarService/* backend : serviceName : envoy-service servicePort : 80 # ... これを適用したところ、既存のエイリアスレコードに変更はありませんでした。 内部の挙動を理解しておきたかったので、ExternalDNSのログを見ました。以下に示すように、DNSレコードに反映する候補となるIngressリソースを検知はしているようですが、変更は行われなかったと見て取れます。 これに関連する処理のテストコード を見てみると、DNSレコードに反映する候補がいくつかある場合は、既存のDNSレコードと同じ値が含まれていれば変更しない挙動になっています。このことから、Serviceリソースのアノテーションで設定したドメインをhostとするIngressリソースを追加してもDNSレコードに影響を及ぼさないことが分かりました。 # ExternalDNSのログから抜粋(ドメイン名やhostedzoneを一部改編) # Serviceに関連するログ time="2020-12-02T09:11:59Z" level=debug msg="Endpoints generated from service: default/envoy: [api.example.com 0 IN CNAME xxx.elb.ap-northeast-1.amazonaws.com []]" # ここからIngressに関連するログ time="2020-12-02T09:11:59Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-http1: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:11:59Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-grpc: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:11:59Z" level=debug msg="Removing duplicate endpoint api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []" # 特定のhostedzoneについては全レコードが最新の状態であるというログ # つまりapi.example.comのAレコード(エイリアスレコード)はNLBのエンドポイントに設定されたまま time="2020-12-02T09:11:59Z" level=debug msg="Considering zone: /hostedzone/ZZZZZZ (domain: example.com.)" time="2020-12-02T09:11:59Z" level=info msg="All records are already up to date" さらに、この状態からNLBと紐付くLoadBalancerタイプのServiceのアノテーションを削除するとどうなるのかを検証しました。その結果、エイリアスレコードのターゲットがNLBのエンドポイントからALBのものに切り替わりました。 作業時のログを以下に示します。DNSレコードに反映する候補としてServiceリソースは検知されなくなり、これによってIngressリソースの値が同じドメインのエイリアスレコードに反映されています。 以上の調査・検証で切り替え方が分かったので、最後にある程度の負荷を掛けながら今までの作業を試しました。特にリクエストが失敗することなくDNSレコードの値を切り替えることができました。 # ExternalDNSのログから抜粋(ドメイン名やhostedzoneを一部改編) # Serviceに関しては検知されなくなった time="2020-12-02T09:18:02Z" level=debug msg="No endpoints could be generated from service default/envoy" # ここからIngressに関連するログ time="2020-12-02T09:18:02Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-http1: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:18:02Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-grpc: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:18:02Z" level=debug msg="Removing duplicate endpoint api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []" # 先ほどとは違いレコードの変更が行われたログが記録されている time="2020-12-02T09:18:03Z" level=debug msg="Considering zone: /hostedzone/ZZZZZZ (domain: example.com.)" time="2020-12-02T09:18:03Z" level=debug msg="Adding api.example.com. to zone example.com. [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=debug msg="Adding api.example.com. to zone example.com. [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=info msg="Desired change: UPSERT api.example.com A [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=info msg="Desired change: UPSERT api.example.com TXT [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=info msg="2 record(s) in zone example.com. [Id: /hostedzone/ZZZZZZ] were successfully updated" ここまでの一連の流れを図示したものを以下に示します。 さいごに 本記事で紹介した事前の準備や調査・検証により、本番環境での移行作業を滞りなく、かつシステムを停止せずに行うことができました。ALBに切り替えた後はTLS証明書の更新作業から開放され、新しい環境が必要になった際もACMで証明書を発行できるので、手軽に環境を構築できるようになりました。 これは個人的な副産物なのですが、一連の調査でAWS Load Balancer ControllerやExternalDNSのドキュメントおよびソースコードを読む機会がありました。少しずつ理解をしていくうちに、どちらのコンポーネントもあるリソースの状態をチェックして別のリソースをあるべき状態にするパターンなんだなと分かりました。さらに調べるとKubernetesの カスタムコントローラー という概念であることを知りました。作業に入る前はExternalDNSというものが「なんか良い感じにDNSレコードの制御をやってくれている」くらいの認識でしたが、その中身や周辺知識を知る良い機会になりました。 最後に、計測プラットフォーム本部バックエンド部では、サーバーエンジニア、SREエンジニア、それぞれでファッションにおける計測にまつわる課題解決を共に進めてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 www.wantedly.com www.wantedly.com
アバター
はじめに こんにちは。SRE部MLOpsチームの中山( @civitaspo )です。みなさんはGWをどのように過ごされたでしょうか。私は実家に子どもたちを預けて夫婦でゆっくりする時間にしました。こんなに気軽に実家を頼りにできるのも 全国在宅勤務制度 のおかげで、実家がある福岡に住めているからです。「この会社に入って良かったなぁ」としみじみとした気持ちでGW明けの絶望と対峙しております。 現在、MLOpsチームでは増加するML案件への対応をスケールさせるため、 Kubeflow を使ったMLOps基盤構築を進めています。本記事ではその基盤構築に至る背景とKubeflowの構築方法、および現在分かっている課題を共有します。 目次 はじめに 目次 MLOpsチームを取り巻く状況 MLOps基盤の要件 MLOps基盤技術としてのKubeflow Kubeflowの構築 ドキュメント通りにKubeflowを構築する Kubeflowを要件に合わせて構築する Config Connector用ManifestをTerraform化 Manifest群をKustomizeで参照しPatchを当てる 適切なNode Poolに配置する 運用課題 Istioが古い kubernetes-sigs/applicationが異常な量のログを出力する Kubeflow PipelinesとKubernetesの不整合 最後に MLOpsチームを取り巻く状況 冒頭で「増加するML案件への対応をスケールさせるため」と述べましたが、まずはその背景を私たちのチームが直面している状況を踏まえて説明します。 MLOpsチームは2019年4月に発足しました。当初はZOZO研究所がML機能開発を担当していたものの、プロダクションにその機能をリリースできていない課題がありました。その課題を解決するために、プロトタイプからプロダクションレベルへの引き上げをミッションとして発足したのがMLOpsチームです。このミッションは2年経った現在も変わっていません 1 。 ミッションは変わっていませんが、周囲を取り巻く環境は変わりました。なぜなら、着実にML機能のリリースを重ね、社内からの信頼度が高まってきているためです。これについて、もう少し深掘りして説明します。 私たちはチームの中長期目標として3つのフェーズを定めていました。 Phase1: ML機能を1つ、プロダクションに出す Phase2: ML機能を複数、プロダクションに出す Phase3: ML機能の量産体制を整える Phase1ではML機能を1つのプロダクションに出すことが目標でした。これは、私たちがレベルの高いインフラ、つまり技術選定が妥当である、安定している、十分に高速であるインフラを構築可能であると示すことで、MLOpsチームに対する社内からの信頼を獲得するための目標でした。同時に技術的なプレゼンスを高め、社外に対して発信することも目標に含んでいました。その最初のML機能が画像検索でした。 techblog.zozo.com Phase2ではML機能を複数のプロダクションに出すことが目標でした。ここでは、Phase1で実践したレベルの高いインフラ構築、およびそれを用いたML機能のプロダクションリリースの再現性を示すことが重要でした。全ての事例は載せられないので、検索パーソナライズと推薦の代表的な2例を紹介します。 techblog.zozo.com techblog.zozo.com そして、現在はPhase3の目標である、ML機能の量産体制の整備に取り組んでいます。Phase2までの取り組みで社内からの信頼を確実なものとしました。そのため、ML機能をリリースする案件も以前より増加しています。社外に対する技術的なプレゼンス向上も、実際に優秀な人材の採用に繋げられています。 しかし、案件の増加スピードに対して人材の増加が追いつかなくなる未来も見え始めています。そのため、ML機能のリリースをスケールさせるような基盤構築を進めています。この基盤が本記事で「MLOps基盤」と呼んでいるものです。複数のML機能をリリースしたことで、ML機能をプロダクションへリリースするために必要な共通要素・デザインパターンが分かってきました。その経験を元にMLエンジニアと協力して要件整理・検証を進めています。 MLOps基盤の要件 構築を進めているMLOps基盤の説明の前に、私たちの考えるMLOps基盤とは何かを説明します。 私たちの考えるMLOps基盤とは以下の要件を満たすものです。 運用中の予測モデル(ワークフロー)を一元管理できること モデル作成の際に環境構築が容易であること 実験段階からプロダクションへの移行が容易であること 車輪の再発明をしないような仕組みであること(= 似たようなモデル開発をしない) モデルサービングが可能であること 以前にAI Platform Pipelinesを取り上げた記事でも言及した内容ですが、改めて本記事でも説明します。 techblog.zozo.com まず、「運用中の予測モデル(ワークフロー)を一元管理できる」必要があります。少人数で多数のML機能をリリースするためにはプロジェクト間・環境間の差分を極力排除し、構築・運用が共通化されていなければなりません。 同様の理由で「モデル作成の際に環境構築が容易である」ことも重要です。プロジェクト・環境が異なっても同じ方法で実験を開始できれば、その分だけMLエンジニアはモデル開発に集中できます。 さらに「実験段階からプロダクションへの移行が容易である」ことも必須です。実験段階のコードとプロダクションのコードが大幅に異なる場合、実験時と同じ結果を得られる保証がありません。そのため、再度検証が必要となり、大きな工数が必要となります。 「車輪の再発明をしないような仕組みである」ことはエンジニアなら当然考えることですが、MLOpsの文脈では過去の実験を再現可能であることが重要です。過去の実験をカタログのように扱い、新たなML機能をリリースする際にも過去の実験を参考・流用できる状態にしておく必要があります。 最後の「モデルサービングが可能である」ことは、モデルを構築すればそのままサービングが可能であることを求めています。モデルを構築しても別途サービング用のコードを書く必要がある場合、実装工数が必要となる他、学習時にオフライン評価で使用した推論結果とサービング時の推論結果が一致していることを保証する必要もあります。そのため、モデルサービングをフレームワークレベルでサポートし、MLエンジニアはモデル作成に専念できる状態を目指しています。 MLOps基盤技術としてのKubeflow KubeflowはMLに必要な全てのワークロードを Kubernetes 上で実現するツールキットです。Kubeflowそのものに関しては 先ほど紹介したAI Platform Pipelinesを取り上げた記事 で説明しているので割愛します。Kubeflowに関する知見は既に社内で溜まりつつあり、またMLOps基盤としての要件を十分に満たす機能を持っていたため採用を決めました。 特に[Kubeflow Pipelines]( https://www.kubeflow.org/docs/components/pipelines/overview/pipelines-overview/ )の非常に高い実験管理機能は魅力的でした。Kubeflow Pipelinesはワークフローエンジンとして内部で[Argo Workflows]( https://argoproj.github.io/projects/argo )を利用しています。Argo Workflowsではワークフローのタスク1つ1つがPodとなっているため、元データと使用するイメージに変更が無ければ多くのケースで何度でも同じ挙動を再現できます。Kubeflow Pipelinesではワークフローの実行ごとに、実行時メタデータだけでなくワークフローの定義自体も含めて保存しているため、過去の実行を容易に再現できます。 また、Kubeflowは[マルチテナンシーをサポート]( https://www.kubeflow.org/docs/components/multi-tenancy/ )しており、単一のKubeflowで複数のプロジェクトを管理できます。Kubeflow内部で[Profile]( https://www.kubeflow.org/docs/components/multi-tenancy/design/ )という単位で権限を管理できる機能を持っており、プロジェクト間で厳密な権限管理を行いつつ、Kubeflowという基盤に実験を集約することが可能です。1つの基盤を運用すれば良いので運用工数も大幅に削減できます。 そのため、MLOps基盤を構築する最初の目標としてマルチテナンシーが有効化されたKubeflowを構築し、Kubeflow Pipelinesを利用できる状態を目指しました。前置きが長くなりましたが、本記事ではこの目標を達成するためにKubeflowを構築した際に得られた知見、課題を共有します。 # なぜAI Platform Pipelinesを使わないのか Kubeflow構築の説明をする前に、なぜ AI Platform Pipelines を使わなかったか触れておきます。MLOpsチームは Google Cloud Platform(以下、GCP) を使っているため、Kubeflow Pipelinesの代わりにGCPのマネージドサービスであるAI Platform Pipelinesを利用することも検討しました。しかし、複数の観点から採用を見送りました。 まず、AI Platform Pipelinesは1つのプロジェクトを作成する毎に1つの Google Kubernetes Engine(以下、GKE) が構築されてしまう点です。 AI Platform Pipelinesは1つのGKEクラスタに複数構築することができない ので、プロジェクトを増やす毎にGKEを構築する必要があります。GKEが増えれば増えるほど、GKEのバージョンアップ、監査ログ取得ツール Falco などの共通コンポーネントのインストール、などのクラスタ管理コストが増えてしまうため、運用がスケールしないと判断しました。 また、AI Platform Pipelinesの内部で保持するワークフローのデータなどをGKEに依存せず永続化するためには Cloud SQL を利用することになります。しかし、これに関してもプロジェクト増加毎に1インスタンス必要となりコスト面で許容できませんでした。Cloud SQLを使用しない場合は、GKE上に StatefulSet として MySQL がデプロイされ、 Persistent Volume に依存する構成となります。つまり、Zoneに依存する構成となり耐障害性が低くなってしまいます。 そして、一番課題と感じた点は利用者側でGKEにApplyされたManifestを直接書き換えても強制的に巻き戻ってしまう点です。問題発生時にManifestを修正することで問題解決できず、サポートケースを上げて解決することになるため、問題解決までのリードタイムが長くなってしまいます。 これらの理由によりMLOps基盤としてAI Platform Pipelinesの採用を見送りました 2 。 Kubeflowの構築 さて、Kubeflowを構築する話に移っていきます。なお、今回構築したKubeflowは v1.2.0 で、GKE 1.18.16-gke.502を使用しています。 ドキュメント通りにKubeflowを構築する 最初に Kubeflowの公式ドキュメント に沿ってKubeflow構築を進めました。このドキュメントに従うと、以下のように構成管理用GKEクラスタを使用して構築を進めることになります。 構成管理用GKEクラスタでは Config Connector を有効化しています。Config ConnectorはKubernetesを介してGCPのリソース操作を可能にするGKEアドオンです。このアドオンをインストールするとKubernetesにGCPのリソースを定義するための Custom Resource Definitions が使用可能になります。例えば、以下のようなManifestをApplyすると sample-gcp-project というGCP Projectに kubeflow-admin という名称の Service Account が定義されます。 apiVersion : iam.cnrm.cloud.google.com/v1beta1 kind : IAMServiceAccount metadata : name : kubeflow-admin namespace : sample-gcp-project labels : kf-name : kubeflow spec : displayName : kubeflow admin service account Kubeflowの公式ドキュメントでは、以下の手順でKubeflowを構築します。 ①Config Connectorを有効化した構成管理用GKEクラスタを構築する ②Config Connectorを使用してGCPのリソースを作成する ③構築したGKE上へKubeflowに必要なManifest群をApplyする これらの手順がMakefileに記述されており、 make apply で構築が完了するようになっています。 この手法は構成管理用GKEクラスタを構築する必要があるという点もさることながら、以下のような問題がありました。 既に構成管理に使用している Terraform と役割が競合してしまう 既にManifest管理に使用している Kustomize を使用できない Config Connectorで作成されるGCPリソースが私たちのインフラ要件を満たさない 3 依存コンポーネントとしてMySQLや MinIO を利用するためPersistent Volumeに依存してしまう これらの問題を解決しつつKubeflowを構築できるよう、次に示すような方法で構築しました。 Kubeflowを要件に合わせて構築する 私たちの環境に合わせた運用が可能になるよう、以下の方針でKubeflow構築を進めることにしました。 Kubeflowの公式ドキュメントに沿ってManifest群の生成まで進める Config Connectorで定義されたGCPリソースはTerraformで管理可能なように移植する 出力したManifest群はKustomizeで必要なファイルのみ参照するようにし、必要に応じてPatchを当てる まず、Kubeflowの公式ドキュメントに沿ってManifest群を生成します。 $ kpt pkg get https://github.com/kubeflow/gcp-blueprints.git/kubeflow@v1. 2 . 0 kubeflow $ cd kubeflow $ make get-pkg 上記コマンドでManifest群生成に必要なファイルを準備し、可能な限り私たちのインフラ要件に合うように一部のファイルを修正します。 $ vim Makefile 55c55 < kpt cfg set ./instance gke.private false --- > kpt cfg set ./instance gke.private true # VPCネイティブクラスタで構築するため 57c57 < kpt cfg set ./instance mgmt-ctxt <YOUR_MANAGEMENT_CTXT> --- > kpt cfg set ./instance mgmt-ctxt null # 構成管理用GKEクラスタは利用しないため 59,62c59,62 < kpt cfg set ./upstream/manifests/gcp name <YOUR_KF_NAME> < kpt cfg set ./upstream/manifests/gcp gcloud.core.project <PROJECT_TO_DEPLOY_IN> < kpt cfg set ./upstream/manifests/gcp gcloud.compute.zone <ZONE> < kpt cfg set ./upstream/manifests/gcp location <REGION OR ZONE> --- > kpt cfg set ./upstream/manifests/gcp name kubeflow > kpt cfg set ./upstream/manifests/gcp gcloud.core.project sample-gcp-project > kpt cfg set ./upstream/manifests/gcp gcloud.compute.zone asia-northeast1 > kpt cfg set ./upstream/manifests/gcp location asia-northeast1 65,66c65,66 < kpt cfg set ./upstream/manifests/stacks/gcp name <YOUR_KF_NAME> < kpt cfg set ./upstream/manifests/stacks/gcp gcloud.core.project <PROJECT_TO_DEPLOY_IN> --- > kpt cfg set ./upstream/manifests/stacks/gcp name kubeflow > kpt cfg set ./upstream/manifests/stacks/gcp gcloud.core.project sample-gcp-project 68,71c68,71 < kpt cfg set ./instance name <YOUR_KF_NAME> < kpt cfg set ./instance location <YOUR_REGION or ZONE> < kpt cfg set ./instance gcloud.core.project <YOUR PROJECT> < kpt cfg set ./instance email <YOUR_EMAIL_ADDRESS> --- > kpt cfg set ./instance name kubeflow > kpt cfg set ./instance location asia-northeast1 > kpt cfg set ./instance gcloud.core.project sample-gcp-project > kpt cfg set ./instance email takahiro.nakayama@example.com $ vim instance/gcp_config/kustomization.yaml 13a14,16 > - ../../upstream/manifests/gcp/v2/privateGKE/ > patchesStrategicMerge: > - ../../upstream/manifests/gcp/v2/privateGKE/cluster-private-patch.yaml 修正が完了したらManifest群を生成します。 $ make set-values $ make clean-build $ make hydrate これにより .build ディレクトリ以下に大量のManifest群が生成されます。 $ find .build -type f | head -n10 .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_certificates.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_challenges.acme.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_orders.acme.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_issuers.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_certificaterequests.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_clusterissuers.cert-manager.io.yaml .build/iap-ingress/networking.gke.io_v1beta1_managedcertificate_gke-certificate.yaml .build/iap-ingress/v1_configmap_ingress-bootstrap-config.yaml .build/iap-ingress/rbac.istio.io_v1alpha1_clusterrbacconfig_default.yaml .build/iap-ingress/cloud.google.com_v1beta1_backendconfig_iap-backendconfig.yaml $ find .build -type f | wc -l 505 これら全てのファイルを気合で読み進め、Terraform化、Kustomize化を進めます。 Config Connector用ManifestをTerraform化 ここまでの手順でConfig Connector向けのManifestも出力されるので、Terraform管理可能な定義に変換していきます。Config Connector向けのManifestは .build/gcp_config 以下のファイル群です。 これらファイル群で定義されているGCPリソースは以下の通りです。 Virtual Private Cloud Static IP Persistent Disk Firewall rules Cloud Router Cloud NAT Cloud DNS Cloud IAM GKE 各種APIの有効化 GKEを構築済みである場合、Virtual Private CloudやCloud NATなどは既に存在しているはずなので、リソース作成の要不要は定義を読んで判断する必要があります。私たちの場合はFirewall rulesとCloud IAM以外は不要でした。 Manifest群をKustomizeで参照しPatchを当てる 生成したManifest群をKustomizeで参照するために、以下のようなディレクトリ構成をとることにしました。 . ├── generated │   └── kubeflow │      └── .build │         ├── application │         ├── cert-manager │         ├── cert-manager-crds │         ├── cert-manager-kube-system-resources │         ├── cloud-endpoints │         ├── gcp_config │         ├── iap-ingress │         ├── istio │         ├── knative │         ├── kubeflow-apps │         ├── kubeflow-issuer │         ├── metacontroller │         └── namespaces ├── base # generatedを参照する │   ├── application │   ├── cert-manager │   ├── cert-manager-leaderelection │   ├── cluster-resources │   ├── falco │   ├── iap-ingress │   ├── istio │   ├── knative │   ├── kubeflow-apps │   │   ├── argo │   │   ├── centraldashboard │   │   ├── jupyter-web-app │   │   ├── katib │   │   ├── kfserving │   │   ├── metadata │   │   ├── minio │   │   ├── ml-pipeline │   │   ├── notebook-controller │   │   ├── poddefaults │   │   ├── profiles │   │   ├── pytorch │   │   └── tfjob │   ├── kubeflow-issuer │   ├── kubeflow-istio │   ├── metacontroller │   └── nvidia-driver-installer ├── dev # baseを参照する ├── stg # baseを参照する └── prd # baseを参照する generated/kubeflow/.build 以下のディレクトリに先ほど生成したManifest群が格納されています。生成したManifestへは直接変更を加えず base 以下のディレクトリに格納する kustomization.yaml から参照、Patchを加えます。 例えば、 base/kubeflow-apps/argo/kustomization.yaml で記述されているArgo Workflowsの設定は以下のようになります。 apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : kubeflow resources : - ../../../generated/kubeflow/.build/kubeflow-apps/apiextensions.k8s.io_v1beta1_customresourcedefinition_workflows.argoproj.io.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/app.k8s.io_v1beta1_application_argo.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/apps_v1_deployment_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/apps_v1_deployment_workflow-controller.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/networking.istio.io_v1alpha3_virtualservice_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrole_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrole_argo.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrolebinding_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrolebinding_argo.yaml # NOTE : We use the configMapGenerator instead of these files. # - ../../../generated/kubeflow/.build/kubeflow-apps/v1_configmap_workflow-controller-configmap.yaml # - ../../../generated/kubeflow/.build/kubeflow-apps/v1_configmap_workflow-controller-parameters.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/v1_service_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/v1_serviceaccount_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/v1_serviceaccount_argo.yaml configMapGenerator : # ref. https://github.com/argoproj/argo/blob/v2.3.0/docs/workflow-controller-configmap.yaml - name : argo-workflow-controller-config files : - config=config/workflow-controller.yaml configurations : - varReference.yaml vars : - name : ARGO_WORKFLOW_CONTROLLER_CONFIGMAP_NAME objref : kind : ConfigMap name : argo-workflow-controller-config apiVersion : v1 fieldref : fieldpath : metadata.name patchesStrategicMerge : - apps_v1_deployment_workflow-controller.yaml - v1_serviceaccount_argo.yaml このような定義をKubeflowに含まれる全てのコンポーネントに行っていきます。そして、各環境用ディレクトリから base を参照する構成です。 先ほど課題に挙げていたPersistent Volumeへの依存も base でPatchを当てることで解消しました。 MySQLを MySQL for Cloud SQL へ変更 MinIOを MinIO GCS Gateway へ変更 Kubeflowが公式に用意している kubeflow/manifests というリポジトリには様々なパターンへ対応するためのManifestが格納されています。そこに、 MySQL for Cloud SQLやMinIO GCS Gatewayを利用するパターン も用意されていました 4 。 適切なNode Poolに配置する ここまでの内容でKubeflowの構築が完了しました。構築に関する知見共有の最後に Node Pool の構成について触れておきます。 MLOps基盤ではKubeflowのController系Podを載せるNode Poolと、ワークフローのPodを載せるNode Poolを別々に管理する方針にしています。 KubeflowのController系Podを載せるNode Poolは、用途毎に占有のNode Poolを作成しました。用途以外のPodが配置されないように taint を設定し、占有対象のPodが配置されるように tolerations と nodeAffinity を設定します。 以下のようなPatchを定義し、 kustomization.yaml でPatchを当てます。 # dedicated-node-pool-patch.yaml - op : add path : /spec/template/spec/affinity value : nodeAffinity : requiredDuringSchedulingIgnoredDuringExecution : nodeSelectorTerms : - matchExpressions : - key : cloud.google.com/gke-nodepool operator : In values : - kubeflow - op : add path : /spec/template/spec/tolerations value : - key : dedicated operator : Equal value : kubeflow effect : NoSchedule # kustomization.yaml apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : kubeflow resources : - <...snip...> patches : - target : kind : StatefulSet path : dedicated-node-patch.yaml - target : kind : Deployment path : dedicated-node-patch.yaml 一方で、ワークフローのPodを載せるNode Poolは占有のNode Poolを作っていません。 Node Auto Provisioning でワークロード毎にNode Poolを自動でプロビジョニングするようにしています。 なお、Node Auto ProvisioningはGKEのアドオンの1つです。このアドオンを有効化するとScheduleされたPodの Resource Request 、 nodeAffinity や labelSelector 、 taint と tolerations から最適な設定のNode Poolが自動で作成されます。MLOps基盤として利用者のワークフローがどれだけのリソースを必要とするのか事前に把握するのは困難であるため、Node Auto ProvisioningでオンデマンドにNode Poolが作成される構成としました。 Node Auto Provisioningの良いところは、 GPU のプロビジョニングもサポートしているところです。利用者が必要なタイミングで何の相談も無くGPUが利用できる状態を作ることができます。 最終的には以下のようなNode Poolができています。 nap- から始まるNode PoolがNode Auto Provisioningによって生成されたNode Poolです。 運用課題 ここからは運用課題をいくつか紹介します。 Istioが古い Kubeflowで利用される Istio はv1.4です。Istioの最新バージョンはv1.9ですので非常に古いです。 また、v1.5で これまでマイクロサービスとして存在していたコンポーネント群がistiodに統合される大きなアーキテクチャ変更 がありました。そのため、現状のv1.4からバージョンが上がらないことに大きな危惧を感じています 5 。このGKEクラスタ上でサービングを始める前に解消されるべき課題です。 実は、Argo Workflowsも非常に古いバージョンである2.3.0(最新は3.0.0)を使用しています。Argo Workflowsに関しては、Kubeflow Pipelinesが依存しているのみなので、Istioほど大きな危惧は抱いていません。しかし、Kubeflowの依存コンポーネントがバージョンアップできない問題は今後も頭を悩ませ続けそうです。 kubernetes-sigs/applicationが異常な量のログを出力する kubernetes-sigs/application はアプリケーションを構成する全てのコンポーネントを束ねて扱えるCustom Resource Definitionsを提供するプロジェクトです。Kubernetesで定義可能な Deployment などの単位ではアプリケーション全体を管理できないという課題から作られたようです。kubernetes-sigs/applicationはKubeflowの依存コンポーネントですが、構築直後からデフォルトで非常に大量のログを出力するようになっています。 私が構築したときは秒間1000件以上のログを出力していました。GKE上でこの量のログが出力されると Cloud Logging のコストが高額になってしまいます。この問題はKubeflow側でも認識されていて、Issue( Stackdriver Logs are very expensive for kubeflow - kubeflow/gcp-blueprints#184 )になっています。その、 kubeflow/gcp-blueprints#184 ではkubernetes-sigs/applicationのログを全て /dev/null に捨てるという豪快なアプローチで解決が図られています。しかし、私たちはkubernetes-sigs/applicationを削除することにしました。なぜなら、kubernetes-sigs/applicationが存在しなければ動かないコンポーネントがKubeflowに存在しないからです。 Kubeflow PipelinesとKubernetesの不整合 Kubeflow Pipelinesは自身のDBに持つ状態を正として扱います。 一方、Kubernetes上の状態がKubeflow Pipelinesの持つ情報と異なっていても、Kubernetes上の状態を修正しません。また、Kubeflow PipelinesのUIからはDBに格納されている情報が表示されるのみで、その不整合状態を確認できません。そのため、Kubeflow Pipelinesの持つ情報とKubernetes上の状態との差異が発生すると、実際の状態を誤認してしまいます。 そして、この不整合状態は比較的高い確率で起こることが確認できています。原因が不明なものもあるため、確実に原因が分かっている2つのケースを紹介します。 1つ目はKubeflow Pipelinesによって作成されたObjectを削除するケースです。このケースは手動運用が禁じられている本番環境では起きえないので深く考える必要はありません。 もう1つは OwnerReference によって親Objectと共にObjectが削除されてしまうケースです。分かりにくいと思うので図を用いて説明します。 Kubeflow PipelinesではSchedule実行のために Recurring Run という機能があります。Recurring Runは時間になったら Run という機能でワークフローを実行します。Recurring RunとRunはKubernetes上でOwnerReferenceによって親子関係ができています。そのため、Runを実行中にRecurring Runを削除した場合、Runも一緒に削除されてしまいます。 しかしながら、Kubeflow Pipelines上で明示的に削除が行われたわけではないため、Kubeflow PipelinesのUIではRunは実行中ステータスのままになってしまうのです。 非常に危険な問題なので、Kubeflow Pipelinesの保持する状態とKubernetes上の状態を比較、監視する仕組みを導入しようと思っています。 最後に 本記事では現在構築中のMLOps基盤を紹介しました。記事内で取り上げた課題は解決に向けて絶賛取り組んでいるところです。特にIstioの最新化は急ピッチで進めています。また、インフラ部分だけではなく、MLOps基盤としてMLエンジニアをサポートする機能強化を実施していきたいと思っています。折を見て記事を書きますので期待して待っていて頂けると嬉しい限りです。 本記事に載せた内容以外にも様々な観点で機能を検証追加しています。絶賛構築中なので、関心を持たれた方は1度お話しをさせてもらえるとありがたいです。是非助けてください! ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/ tech.zozo.com https://hrmos.co/pages/zozo/jobs/0000031 hrmos.co ミッションや文化に関する詳しい説明は前リーダーである @sonots さんが 『ZOZO MLOps のチームリーディングとSRE(Engineering)』 で語っています。興味のある方はご覧ください。 ↩ 2021-05-19にAI PlatformがVertex AIという名前となりPipelinesからGKE依存がなくなった ので再検討の余地があります。 ↩ 例えば私たちは 『GCP Shared VPCを利用した全社共通ネットワークの運用におけるDedicated Interconnect利用設定の最適化手法』 で説明したように Shared VPC を使用しています。そのためサービスプロジェクト側からホストプロジェクト側のFirewall rulesを操作することを認めていません。 ↩ 実は私たちのMySQL for Cloud SQLは Private Service Access を構成しています。そのため Cloud SQL Auth Proxy を使う構成では無く、単に接続情報を変更するだけで済みました。 ↩ issue は存在しています。 ↩
アバター
はじめに こんにちは。マイグレーションチームの藤本です。 この記事では、 先日のリニューアル に伴って導入したBackends For Frontends(以下、BFF)で、Redisを使ったキャッシュの事例をご紹介します。キャッシュを導入する際に起きる問題とその回避策について、サーバーサイドのアプリケーションで行った対策をもとに紹介していきます。 ZOZOTOWNリニューアルとBFF ZOZOTOWNで導入したBFFは、複数のAPIのレスポンスをフロントエンドが必要とする形式に集約して返却することを主な目的としています。これまでの実績から、大規模セール時のアクセス数は通常時の何倍にもなることがわかっており、BFFもそれに耐えられるパフォーマンスが必要です。 しかし、BFFに来たすべてのアクセスをそのままAPIに流すと、パフォーマンスに影響する恐れが出てきました。そのため、APIからのレスポンスの一部をキャッシュとして保存しています。このキャッシュの仕組みにRedisを利用しています。 BFFを導入した経緯や構成、ZOZOTOWNにおけるBFFの目的についての解説は、こちらの記事をご覧ください。 techblog.zozo.com techblog.zozo.com キャッシュ利用時の注意点 レスポンスの一部をキャッシュとして保存しても、無期限に持ち続けて良いわけではありません。ZOZOTOWNでは商品やショップなどの情報は常に更新されているため、キャッシュを一定期間で破棄して最新の情報を再取得する必要があります。 アクセス数が少ない場合はそれほど問題にはなりませんが、ECサイトのように常に大量のアクセスがある場合は、キャッシュが破棄されたタイミングでAPIにも同時に大量のアクセスが発生してしまいます。 この現象は一般的に Cache Stampede(キャッシュスタンピード) 、 Dog piling(ドッグパイル) などの名称で呼ばれています。 この記事では キャッシュスタンピード と呼びます。 キャッシュスタンピードの回避 ひとたびキャッシュスタンピードが起きると、APIの負荷が増えることによるパフォーマンス低下、データベースの処理遅延、最悪の場合はサイト全体の遅延や停止などにつながる可能性があります。 これを回避するための方法として代表的なものが3つあります。 別プロセスで事前にキャッシュを生成する(事前作成) 期限切れ前に一定の確率で期限を更新する(期限更新) 裏側のAPIへアクセスするプロセスを絞る(ロック) それぞれの方法のメリットとデメリットを見ていきます。 代表的な回避方法の比較 事前作成 1つ目の「別プロセスで事前にキャッシュを生成する」方法は、キャッシュの書き込みと読み取りをそれぞれ別のアプリケーションとして作るので、処理がシンプルになります。そして、キャッシュが期限切れになる前に新しいデータを準備できるため、キャッシュヒット率を上げられることがメリットです。 デメリットは、管理対象のアプリケーションが増えるため運用保守のコストが増加する点と、ユーザーの検索条件を予測できないので事前に最適なキャッシュを生成しづらい点です。 期限更新 2つ目の「期限切れ前に一定の確率でキャッシュを更新する」方法のメリットは、1つ目と同様に事前にキャッシュを生成するので期限切れになる心配が少ないことです。 デメリットはどれくらい前から更新し始めるか、確率はいくらにするのかといった値を、運用開始後にも定期的に見直す必要が出てくる点です。 ロック 3つ目の「裏側のAPIへアクセスするプロセスを絞る」は、簡単に言えばロックを取得する方法です。メリットは管理対象のアプリケーションは増やさず、数値の調整などの運用時の調整もあまり必要としないため、3つの方法の中で最も運用時のコストが抑えられることです。 デメリットは、事前に新しいデータを準備できないため一時的にキャッシュ切れが発生することや、長時間のロックはできないのでキャッシュ生成にかけられる時間が短いことです。 キャッシュの期限切れ 運用コスト 事前作成 少ない 高い 期限更新 少ない ほどほど ロック 多い 低い ロックを選択 リニューアルの開発を進めていく中で、上記の3つの方法を比較検討していました。その際の制約として、以下の2点がありました。 別プロセスでキャッシュを生成する仕組みが無いので追加開発が必要となる 日付が変わる時など、固定でキャッシュを破棄するタイミングがある まず、別プロセスでキャッシュを生成する方法は、現在のZOZOTOWNでは仕組みが存在せず、追加開発が必要でした。リニューアルの開発を進めている途中でキャッシュの導入が決まったため、スケジュールの都合で見送ることになりました。 また、ZOZOTOWNでは頻繁に日付や特定の時間を過ぎたタイミングで商品の販売開始や終了が発生したり、価格やクーポンなどの情報の変更が発生したりします。例えば、23時59分に生成したキャッシュが、1分後の24時00分には使えなくなってしまうことが起こりえます。期限を更新したキャッシュが無駄になってしまうことは避けたいと考えました。 今回は残る選択肢として、「裏側のAPIへアクセスするプロセスを絞る」方法を採用しました。 SETNXコマンド 前述の通り「裏側のAPIへアクセスするプロセスを絞る」ために、ロックを取得します。 BFFは多数のサーバーで稼働しているので、内部でロックを制御しても意味がありません。そこでRedisを使ってロックを制御します。 RedisにはSETNXという便利なコマンドが存在します。 redis.io SETNXコマンドの特徴は次の通りです。 通常のSETコマンドと同様に、key-valueのペアで登録できる 既にvalueが登録済みのkeyを指定すると、上書きできず失敗となる 成功したら1、失敗したら0が返ってくる 今回はこのSETNXコマンドの特徴を利用し、成功時のみ裏側のAPIへのアクセスを許可、失敗時はAPIへのアクセスを許可しないという制御をしています。こうすることで複数のサーバーで動作しているアプリケーションでもロックが可能になります。 この制御を Spring Framework の RedisTemplate を使った場合、以下のコードのように記述できます。 setIfAbsent() がRedisのSETNXコマンドに対応しています。 public boolean lock(String value, long ttl){ String key = lockKey(); Boolean result = redisTemplate .opsForValue() .setIfAbsent(key, value, ttl, TimeUnit.MILLISECONDS); return result != null && result; } SETNXコマンドでロックが取得できなかったプロセスは、キャッシュもなく最新の情報も取得できない状況にあるため、そのままではレスポンスを返せなくなってしまいます。この状況はできるだけ回避したいので、ロックを取得したプロセスが新しいキャッシュを登録するのを待つようにしています。 Redlockアルゴリズム ロックを取得するプロセスを厳密に1つに絞るならば、本来はRedlockアルゴリズムを用いないといけません。詳しい説明はこちらの公式ページ、または翻訳ページに記載があります。 redis.io redis-documentasion-japanese.readthedocs.io 簡単に説明すると、以下のような考え方です。 ロック取得後に対象のMasterノードが落ちると、ロックが失われて別のプロセスからもう一度ロックが可能になってしまう すべてのMasterノードに対してロックを試行して、過半数が取得できたらロック成功とみなす 今回のリニューアルでロックを必要とした場面は、商品やショップなどの情報を取得するためであり、データベースに保存されたデータを更新するためではありません。そのため、トランザクションのような厳密さは必要ないと判断して、Redlockは使わずにSETNXコマンドを使用しています。 重要なデータを更新するなどの厳密さが求められる場面では、Redlockアルゴリズムを用いるか、ミドルウェアのトランザクション機能を使うほうが良いです。 効果 以下のグラフは、負荷試験を行っている際に、裏側のとあるAPIへのアクセス状況をDatadogでグラフ化したものの抜粋です。 Before After 何も対策していない場合は、定期的に15 req/sec流れていましたが、対策後は5 req/secと、約1/3に抑えることができました。 まとめ Redisを使ったキャッシュへの取り組みと注意点、その回避方法を本記事ではご紹介しました。対策を施すことでAPIへの負荷も減り、現在は想定していたパフォーマンスを維持できています。 しかしながら、今後はよりアクセス数も増加し、それぞれのユーザーに合わせたおすすめの表示など、検索パターンや表示される商品のバリエーションが増加します。そうなると、キャッシュの意味が薄れてしまい、パフォーマンスの低下につながってしまいます。 ZOZOTOWNのリプレイスを進めつつも、既に置き換えが済んでいるAPIのパフォーマンス改善もあわせて考える必要が出てきています。 さいごに ZOZOTOWNのリプレイスはまだまだ道半ばの段階です。新規機能の追加とパフォーマンスの維持を両立させながら前へ進む必要があるため、考えることがたくさんあります。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
アバター
はじめに 2020年新卒入社で、現在ZOZOWEB部所属の 武井 です。ZOZOTOWNのWebフロントエンド開発を担当しています。私は入社以来オフィスに2度しか出社したことがありませんが、そのうちの1度は スタッフインタビュー記事 の撮影のときでした。アートがたくさんある素敵なオフィスですが、それ以降出社できていません。まさか新卒1年目からフルリモート勤務をすると思っていませんでしたが、先輩スタッフが仕組み作りをしてくださっていたおかげで快適に働けています。 さて、本題です。ZOZOTOWNではタイムセール、ショップ限定クーポン、抽選プレゼントなどのキャンペーンを期間限定で実施しています。このキャンペーンをより際立たせるためにキャンペーンページを作成し、ホーム画面やメルマガなどを通じてお客様にお届けしています。しかし、このキャンペーンページの作成が必要になった場合、エンジニアが都度実装しており、5日程度の開発工数が発生していました。そこで、このページ作成をビジネスサイドのキャンペーン担当者ができるよう、ノーコード化するシステムを構築しました。本記事では、そこで得たヘッドレスCMSであるmicroCMSとReactに関する知見を紹介します。 はじめに 背景と課題 解決方法 microCMS(ヘッドレスCMS)の導入 microCMSを用いた管理画面とAPIの構築 キャンペーンページ表示コンテンツのReactコンポーネント化 プレビューURLの発行とプレビューページの実装 キャンペーンページのリリース 効果 まとめ 最後に 背景と課題 ZOZOTOWNのキャンペーンはパンツやシューズなどの商品カテゴリーから、特定の出店ブランドを強調するものまでさまざまな切り口でお届けしています。ZOZOTOWNは多数の商品を取り扱っているため、カテゴリーやブランドを特定し厳選した商品をお客様に提案することで、よりお買い物を楽しめるようにすることがキャンペーンの狙いです。以下にキャンペーンページのイメージを記します。 アパレル商材は訴求時期が重なるという特徴を持っています。そのため、キャンペーンを短期間に複数開催するということも珍しくありません。理想としては、こうしたキャンペーンを毎日実施し、お客様に日替わりでさまざまな切り口の商品を紹介するサイトにしたいと考えています。また、ZOZOTOWNは先日リニューアルし、コスメ専門モールの ZOZOCOSME や、ラグジュアリー&デザイナーズゾーンの ZOZOVILLA がオープンしました。このリニューアルに際し、「シューズ」「コスメ」といったカテゴリーをタブ化しました。これにより、今後はカテゴリー別でキャンペーンをたくさん行う予定です。 しかし、キャンペーンページ作成はエンジニアが個別にマークアップコーディングしています。したがって、毎日続けてキャンペーンページを公開できないのが現実です。 そこで、キャンペーンページ作成時に必要となるエンジニアの作業工数を削減する方法を検討しました。その結果、ビジネスサイドのキャンペーン担当者がコンテンツを編集し、キャンペーンページをノーコードで作成できるシステムを構築することをゴールとしました。 当初はZOZOTOWNの社内サイト管理システムを拡張する形で実現できないか検討を進めました。しかし、リニューアルを進める大規模プロジェクトが並行して走っており、管理システム及びバックエンドの開発リソース確保が難しいという背景がありました。そのため、今回はバックエンドシステムに一切改修を入れずにフロントエンド側だけで完結するシステムを設計しています。 解決方法 早速ですが、まず今回採用したシステム全体図を以下に示します。 以降、この構成にした理由を説明していきます。なお、図中の(1)(2)(3)の番号を説明内で利用するので、上図を適宜参照してください。 microCMS(ヘッドレスCMS)の導入 ノーコード化を実現するために、まずは非エンジニアが操作する管理画面を作成する必要があると考えました。今回の用途のみの管理画面を内製するのは合理的でないため、コンテンツ管理システム、いわゆるCMSの1つである microCMS を導入しました。 CMSといえば、WordPressを思い浮かべる方が多いでしょう。しかし、ZOZOTOWNで導入する場合はヘッドレスCMSが適当だと考えました。「ヘッドレスCMSがそもそも何か」については以下のmicroCMS公式ブログがわかりやすいのでご覧ください。 blog.microcms.io 今回、ヘッドレスCMSを選んだ理由は zozo.jp のWebサーバー上で、このキャンペーンページを動作させたかったためです。キャンペーンページは静的ページではなく、お客様のお気に入り、商品のパーソナライズなども行います。この機能をWordPressなどの別サーバーに構築した場合、バックエンドの開発工数の発生が予想されます。その点、APIで連携するヘッドレスCMSであれば、現在利用しているテクノロジーを変えることなく部分的にCMS機能を使うことが可能です。また、CSSやJavaScriptの実行環境もこれまで通りに維持できるため、フロントエンド資産の流用、連携も可能です。 なお、ヘッドレスCMSはmicroCMS以外にも、 Contentful や Strapi など多数存在します。また、WordPressもプラグインなどを駆使してヘッドレスCMSとして利用できます。しかし、今回のCMSのニーズを以下のように整理したところ、すべて満たすものはmicroCMSのみでした。 コンテンツはエンジニアではないビジネスサイドの担当者が編集するので、管理画面でメタタグなども含めたほぼすべてのコンテンツを編集可能にしたい パターン化されたコンテンツを並び替えたり、複数設定できるようにしたい 編集結果を本番と同じ見た目で確認できるプレビューページを用意したい プレビューページはリリースするキャンペーンページと同じ環境で動作させたい インフラの構築や運用作業を不要にしたい システム利用者の役割ごとに適切なコンテンツの編集権限管理がしたい 日本語対応したい 加えて、microCMSはお客様ニーズの勘所を抑えている機能を続々とリリースしている印象がありました。また、公開されているロードマップも便利そうな機能が並んでいました。このことから、今後も改善され続けるだろうという期待を込めて、microCMSを選定した側面もあります。microCMSのブログで、過去の新機能リリースをみていただけるとお分かりいただけるかもしれません。 blog.microcms.io 以上がmicroCMSを導入した理由です。 microCMSを用いた管理画面とAPIの構築 次に、システム全体図の(1)でmicroCMSが実現している管理画面とAPIの構築について説明します。 microCMSではコンテンツ入力項目の最小単位をフィールドと呼びます。フィールドには次のような種類のデータ形式 1 が設定できます。 これらのフィールドを組み合わせて管理画面を構築していきます。今回作成するキャンペーンページのコンテンツモデルは先述の通り、コンテンツを並び替えたり、複数設定できる柔軟なものである必要があります。microCMSには繰り返しフィールドとカスタムフィールドと呼ばれる機能があり、それらを利用することで実現可能です。 この機能についてはmicroCMSのブログ、「microCMSのカスタムフィールドを使ってランディングページを作ろう」で詳しく解説されています。また、サービスの作成や管理画面の操作方法などの基本的な解説は、公式ブログや microCMS の公式ドキュメント に委ね、省略します。実際にドキュメントを読みながら操作したところ、つまずく点は特にありませんでした。 blog.microcms.io document.microcms.io 最終的にキャンペーンページのコンテンツモデル(APIスキーマ)の設定は以下の形式になります。 また、上記の設定から構築される管理画面は以下の通りです。 この管理画面上でそれぞれのコンテンツを入力すると、JSON APIエンドポイントが作成されます。 例えば、本記事の冒頭で挙げたキャンペーンページのイメージの場合、以下のJSONを返します。 { " id ": " sample_campaign ", // UUIDが発行される。手動で変更も可能 " createdAt ": " 2020-12-31T15:00:00.000Z ", // microCMSの時刻表記形式は ISO8601 " updatedAt ": " 2020-12-31T15:00:00.000Z ", " publishedAt ": " 2020-12-31T15:00:00.000Z ", " revisedAt ": " 2020-12-31T15:00:00.000Z ", " managedTitle ": " (テスト作成中)キャンペーンページサンプル ", " displays ": [ // 「フィールドを追加」をクリックするとカスタムフィールドを選ぶモーダルが掲出する。コンテンツを選べば入力フォームが現れ編集ができる。(繰り返しフィールド) { " fieldId ": " mainVisual ", // カスタムフィールドには設定したIDが自動付与される " title ": " Brand DAY ", " lead ": " Brand の 2日間限定のクーポン&タイムセール開催中! ", " backgroundImagePC ": { " url ": " https://images.microcms-assets.io/assets/**/pc_mv.jpg ", " height ": 1000 , " width ": 2560 } , " backgroundImageSP ": { " url ": " https://images.microcms-assets.io/assets/**/sp_mv.jpg ", " height ": 750 , " width ": 750 } } , { " fieldId ": " favoriteGoods ", " allShopID ": 0 , // お客様のお気に入り商品を取得する内部APIのリクエストに必要なパラメーターショップID " menShopID ": 1 , // お客様の性別が判別される場合は別のショップIDを設定できるように設定 " womenShopID ": 2 , " kidsShopID ": 3 , } , { " fieldId ": " searchMenu ", " title ": " 人気カテゴリー ", " searchMenuItems ": [ { " fieldId ": " searchMenuItem ", " title ": " Tシャツ ", " url ": " /search/xxxx " } , { " fieldId ": " searchMenuItem ", " title ": " ボトムス ", " url ": " /search/xxxx " } , ... ] } , { " fieldId ": " goodsCatalog ", " tagID ": 0 , // キャンペーンごとにtagID社内管理ツールを用いて商品をタグで紐付けをすることができる。内部WebAPIのリクエストパラメーターに用いる " isCoupon ": true , // クーポン商品のみ絞り込むかのフラグ。内部WebAPIのリクエストパラメーターに用いる " title ": " スペシャルクーポン ", " subTitle ": " 最大¥1,000分のクーポン発行中 ", " url ": "/ search / xxxx " // 「すべてをアイテムをみる」の遷移先のリンク } , ] , " meta ": { // HTMLのメタタグ関連の設定 " fieldId ": " meta ", " title ": " メタタイトル ", " description ": " メタ詳細 " } , " campaignDate ": { // キャンペーンの期間設定、これに応じてページの公開・非公開を制御する " fieldId ": " campaignDate ", " startDate ": " 2020-12-31T15:00:00.000Z ", " endDate ": " 2021-01-01T15:00:00.000Z " } } このようにCMSのコンテンツはすべてJSON APIで取得できるので、さまざなプログラミング言語や環境から呼び出すことができます。主に利用するのはコンテンツIDからコンテンツ情報を取得するAPIと、エンドポイントのコンテンツすべてを配列で取得するAPIの2つです。読み込みだけではなく書き込みも可能なので、他システムとの連携も柔軟にできるでしょう。より詳細は以下のAPIドキュメントを参照ください。 document.microcms.io キャンペーンページ表示コンテンツのReactコンポーネント化 次に、APIのレスポンスの中でも、ページに表示させるコンテンツ部分を説明します。先ほどのJSONの中にある displays という配列に注目してください。 " displays ": [ { " fieldId ": " mainVisual ", ... } , { " fieldId ": " favoriteGoods ", ... } , { " fieldId ": " searchMenu ", ... } , { " fieldId ": " goodsCatalog ", ... } ] この displays は 繰り返しフィールド を利用しています。この機能で表示コンテンツを並び替えたり、複数設定できるような操作を可能としています。フィールドとUIコンポーネントを1対1で対応させ、これらを組み合わせることでページを作成します。 ここで紹介している mainVisual , favoriteGoods , searchMenu , goodsCatalog の他にも計14点のコンポーネントを定義しました。これらのフィールドを組み合わせることで多様なキャンペーンページの作成が可能です。 ソースコード上では、これらをReactコンポーネントで定義しています。また、フィールドはTypeScriptで型定義しているので、型安全にコンポーネントを管理できます。例えば、 mainVisual フィールドであれば、以下のようなReactコンポーネントと型定義をセットで記述します。 // microCMSで定義できるフィールドを定義 interface MicroCMSField { text: string textArea: string image: { url: string height: string width: string } ... } interface MicroCMSCustomField < T , U > { fieldId: T } & Partial < U > // microCMSで定義したカスタムフィールドのIDを定義 const CUSTOM_FIELD = { mainVisual: 'mainVisual' , ... } as const type MainVisualField = MicroCMSCustomField < typeof CUSTOM_FIELD.mainVisual , { title: MicroCMSField [ 'text' ] lead: MicroCMSField [ 'textArea' ] backgroundImagePC: MicroCMSField [ 'image' ] backgroundImageSP: MicroCMSField [ 'image' ] } > interface Props { field: MainVisualField device: Device } import React , { FC } from 'react' export const MainVisual: FC < Props > = ( { field , device } ) => { const { title lead backgroundImagePC backgroundImageSP } = field const isPC = device === 'pc' return ( < section > < Title > { title } < /Title > < Lead > { lead } < /Lead > { isPC ? ( < BackgroundImagePC src = { backgroundImagePC } / > ) : ( < BackgroundImageSP src = { backgroundImageSP } / > ) } < /section > ) } また、 mainVisual のフィールドはmicroCMSの設定画面では以下のように定義しています。 このフィールド変更時に型定義も変更するようにしています。イメージとしてはRDBのスキーマ更新に近いかもしれません。フィールドとコンポーネント定義を対応させることで、フィールド変更時、ソースコードに不整合が発生しないか型検証が可能です。この検証により、型の不整合を未然に防ぎ、コンポーネントを安全に改修できました。 システム全体図の(1)microCMSの説明は以上です。 プレビューURLの発行とプレビューページの実装 次にCMS上の編集結果を確認するプレビューページについて説明します。 システム全体図の(2)プレビューページの部分です。microCMSのメニューに「画面プレビュー」というボタンがあります。このボタンを押した際の遷移先URLをどのような形式で発行するかを、以下のように設定できます。 コンテンツ編集者はボタンからプレビューページに遷移できます。 遷移先のプレビューページは関係者のみが閲覧可能なパイロット環境に構築しています。このパイロット環境は zozo.jp 本番環境と同じデータベースに接続しています。そのため、この環境で内部APIをリクエストすれば、本番と同じ商品データを取得ができます。これはキャンペーンで紹介したい実商品データなどを取得してプレビューしたかった事情もあります。 これにより、ビジネスサイドのキャンペーン担当者は公開されるページと商品情報なども含めて全く同一の見た目でプレビュー確認できる状態が実現できました。 プレビューページと公開されるページの見た目は同じです。しかし、実装の中身は異なります。プレビューページでは閲覧の度にmicroCMSのAPIをリクエストし、そのレスポンスからDOMを生成しています。いわゆるCSR(クライアントサイドレンダリング)と呼ばれるレンダリング手法です。一方、公開されるページは、静的なマークアップに変換してリリースする形を取りました。以降、この理由を説明します。 Webページのレンダリングに関する説明は、以下の記事が参考になります。 developers.google.com この記事でも、以下のようにお客様体験を良くしたい場合はSSRか静的レンダリングが推奨されています。 2 かいつまんで言うと、私たちは開発者が完全なリハイドレーションの上で、サーバーレンダリングまたは静的レンダリングを検討することを勧めるでしょう。 また、お客様体験の向上以外にも、CSRを採用しない理由に通信コストの問題があります。CSRではmicroCMSに都度データフェッチをするため、データ転送が発生します。microCMSはデータ転送量に応じた従量課金制なため、コストの観点からもCSRは望ましくありませんでした。 では、本番にリリースするページをどのように静的なマークアップに変換しているのかを次節で説明します。 キャンペーンページのリリース 本番にリリースするページについて説明します。システム全体図の(3)に該当します。 ヘッドレスCMSを用いたシステムを構築する場合、リリースするページは Next.js や Gatsby などの Jamstack に対応したフレームワークを導入し、SSGやISR 3 の機能を利用するのが一般的なセオリーと言われています。 しかし、ZOZOTOWNではテンプレートエンジンのようなミドルウェア 4 でマークアップを記述しており、今回はその記法に対応させる必要がありました。したがって、今回はこのようなフレームワークを導入せず、Reactの ReactDOMServer.renderToStaticMarkup を駆使して静的なマークアップに変換するCLIツールをNode.jsで実装しました。 5 このツールを社内ではjsx2markupと呼んでいます。jsx2markupにより、プレビューページとリリースページはレンダリング手法が異なっていても、同じJSXのコードベースを用いることが可能になります。 jsx2markupは、以下のようなコマンドを叩くことでリリース用のマークアップファイルを生成し、リリースします。 6 ts-node --files -r tsconfig-paths/register ./jsx2markup --endpointName= " microCMSのエンドポイント名 " --contentID= " microCMSの管理画面で設定したコンテンツID " jsx2markupの詳細は省きますが、先ほど言及したテンプレート記法に変換する処理など、ZOZOTOWNの環境に対応させるためのさまざまな処理をしています。加えて、画像URLをmicroCMSのものからZOZOTOWNで普段利用している画像サーバーのURLに変更する処理なども行っています。 この変換は以下のような関数で記述しました。 import path from "path" ; const getURLBasename = ( url: string ) : string => path.basename (new URL ( url ) .pathname ); // APIのレスポンスの型をジェネリクスで渡す export const transformFromCMSImages = < T >( { contents , targetDirectory , } : { contents: T ; targetDirectory: string ; } ) : T => { const microCMSImageUrlRegex = /https?:\/\/images.microcms-assets.io[-_.!~*\\'()a-zA-Z0-9;\\/?:\\@&=+\\$,%#\u3000-\u30FE\u4E00-\u9FA0\uFF01-\uFFE3]+/gm ; const stringContents = JSON .stringify ( contents ); const matches = stringContents.match ( microCMSImageUrlRegex ) || [] ; const microCMSImageUrls = Array . from( matches ); return JSON .parse ( microCMSImageUrls.reduce ( ( acc , _ , i ) => acc.replace ( microCMSImageUrls [ i ] , ` ${ targetDirectory } / ${ getURLBasename(microCMSImageUrls[i]) } ` ), stringContents ) ); } ; この処理は、画像をmicroCMSによってアップロードされたものではなく、社内で利用している画像サーバーにホスティングしたかったため必要でした。このようなニーズは、ZOZOTOWN以外でもあり得る要求だと思うので、参考になれば幸いです。 リリースは、基本的にはエンジニアがコマンドを叩くだけです。一手間かかりますが、静的なマークアップに変換することで別のメリットも得られました。 そのメリットとは、マークアップに追加の変更を加えることができる点です。なぜならば、少しのデザイン変更を加えることで、多種多様な出店ブランドの世界観やキャンペーンの訴求力を高められることがあるからです。こうしたケースへの対応も、完全に自動化してしまうと対応が困難になります。しかし、静的なマークアップに変換してしまえば、変換後のマークアップに変更を加えてリリースすることで対応可能です。そのため、リリースはあえて自動化していません。 以上で構築した環境の説明は終わります。 効果 ソフトウェアだけ作っても運用がうまくされなければ意味がありません。このキャンペーンページの場合もオペレーションを含めて考えなければいけませんでした。 そのため、スムーズな運用ができるようにビジネスサイドと定期的に議論し、CMSのマニュアル作成やキャンペーンの実施フローなどもこれを機に見直しました。 その結果、4月のシステム導入から、本記事を公開した5月14日に至るまでに、8個のページをリリースできました。これは昨年比で2倍の数です。特にゴールデンウィーク中は毎日キャンペーンを実施し、5個のページをリリースできました。 これまでの仕組みでは短期間に複数のページをマークアップすることが困難でしたが、その課題を解決でき、有用なシステムを作れたと手応えを感じています。 また、エンジニアではないビジネスサイドの担当者でも、CMS上で編集しながらプレビュー確認が行えるようになったため、デザイナーの工数削減やページの手戻りが発生しづらくなったという効果もありました。 今後もシステムや業務フローを洗練させ、キャンペーン数を増やし、お客様にお買い物を楽しんでいただけるようなサービスにしていきます。 まとめ 今回はZOZOTOWNのキャンペーンページ作成をキャンペーン担当者ができるよう、ノーコード化するシステムの構築手法、そこで得たmicroCMSやReactに関する知見や効果について紹介しました。 ZOZOTOWNは、JavaScriptに関しては ES5 , jQuery から React , TypeScript に移行中です。今後はさらにフロントエンドのWebサーバーのリプレイスも予定しています。 今回のシステムも、そのリプレイスを見据え、サーバー移行しやすいように設計しました。リプレイスで本システムにも変化がありましたら、またご紹介します。 最後に 私はZOZOTOWNを担当するエンジニアになって1年が経過しましたが、その独特なシステムにいまだ衝撃を受ける毎日です。 約16年前に誕生し、凄まじい勢いで成長してきたZOZOTOWNは、その当時としては優れたミドルウェアや技術で構築されており、現在の規模までスケールさせた先輩スタッフには尊敬の念しかありません。 しかし、これらの技術は進化の激しいソフトウェア開発の世界では、現在ではいわゆる技術的負債と呼ばれるものとなり、開発速度を鈍化させる要因の1つになっていることは否めません。今回作ったシステムも、その負債の制約がなければ、もっと別のやり方があったでしょう。しかし、これを私はネガティブには捉えていません。なぜならば、ZOZOTOWNは技術的な改善により、まだまだ伸び代があるということを意味しているからです。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。 corp.zozo.com また、ZOZOTOWNのフロントエンドエンジニアに興味がある方は 私 や 森口 にDMをいただいても構いません。よろしくお願いします! 記事を執筆した2021年5月時点の情報です。また、料金プランによって選択できるデータ形式は異なります。 ↩ Webは進化し続けるので2021年現在でも同じ結論になるとは限りません。しかし、検証コストやサーバーに改修を入れられない都合上、プレビューページと実際にお客様に届けるページのレンダリング手法を別にすることは、システムの構想段階から想定していました。 ↩ Jamstack, SSG, ISRについてはご存知ない方は こちらの記事 が参考になります。 ↩ Rubyにおける Haml 、JavaScriptにおける Pug のようなもので、VBScriptで利用できるマークアップ記法です。 ↩ 余談ですが、Reactはフレームワークというよりシンプルなライブラリであろうとする思想があるため、このような小回りが効くが強力なAPIがある点は素晴らしいと改めて思いました。 ↩ tsconfig-paths/register を用いることで、このスクリプトとクライアントサイドのTypeScriptの設定ファイルを共通化できます。この説明のために、本記事ではあえてコマンド上に露出させていますが、実際はnpm scriptsでエイリアスを当てています。 ↩
アバター
こんにちは、MA部でエンジニアをしている田島です。 以前に弊社の塩崎が「Amazon AuroraのデータをリアルタイムにGoogle BigQueryに連携してみた」という発表を行いました。 こちらの発表では、Amazon Aurora MySQLのデータをGoogle BigQueryへリアルタイムにデータ連携する方法を紹介しています。リアルタイムデータ連携を実現するために、Aurora MySQLをレプリケーションソースとしてGoogle Cloud SQLへレプリケーションします。そして、BigQueryのFederated Query機能を利用してリアルタイムにデータを参照できるようにしています。 本記事ではその中の、Aurora MySQLからCloud SQLへのレプリケーション部分にフォーカスします。Aurora MySQLがマネージドサービスだからこそ発生する大きな注意ポイントを2つ紹介します。 以降、以下の2つの注意ポイントを説明します。 Aurora MySQLにおけるバイナリログの保持期間の設定 Aurora MySQLのCollation 目次 目次 リアルタイムデータ連携基盤 リアルタイムデータ連携基盤の全体構成 復習「MySQLのレプリケーション」 バイナリログとリレーログ バイナリログのローテーション GTID レプリケーションの確認方法 Aurora MySQLからCloud SQLへのレプリケーション手順 1. Aurora MySQLの設定 2.〜3. Cloud SQLの構築 Aurora MySQLからCloud SQLへのレプリケーション構築における注意すべき2つのポイント (1)Aurora MySQLにおけるバイナリログの保持期間の設定 発生した問題 結論 再現実験 実験準備 ログの解読 実験結果 回避方法 (2)Aurora MySQLのCollation MySQLのCollation 発生した問題 調査(1)メインDBのCollationの設定確認 調査(2)binlogの確認 調査(3)Charset ID #255の確認 調査(4)クライアント側でCollationを指定して再現実験 調査(5)session.collation_connectionが255にセットされた原因の調査 結論 回避方法 まとめ リアルタイムデータ連携基盤 冒頭でも紹介しましたが、弊社のデータ基盤の一部では、Aurora MySQLのデータをGoogle BigQueryへリアルタイムにデータ連携をしてます。以前の発表内容をご覧になられていない方のために、改めてその概要を紹介します。発表または発表資料をご覧になられた方は読み飛ばして頂いて問題ありません。 リアルタイムデータ連携基盤の全体構成 以下がAurora MySQLからGoogle BigQueryへリアルタイムデータ連携をするための全体構成です。 前述の通り、Aurora MySQLをレプリケーションソースとしCloud SQLへレプリケーションします。これにより、Aurora MySQLのデータがリアルタイムにCloud SQLへ連携されます。そして、BigQueryからCloud SQLへFederated Queryという機能を利用することでCloud SQLのデータをBigQueryから参照できます。 以上のようにしてAurora MySQLのデータをBigQueryからリアルタイムに参照することを可能としました。 その他の構成の特徴として、AWS-GCP間のインターナル通信を実現するために、弊社のオンプレ環境を挟み専用線を用いた通信を利用しています。また、冗長構成等も行っています。そのような全体構成の詳細は、改めて別の記事で紹介する予定です。 復習「MySQLのレプリケーション」 まずはMySQLのレプリケーションについて復習します。ここでは、レプリケーションされるサーバーを「メインDB」、レプリケーションするサーバーを「レプリカDB」と呼ぶこととします。本記事では、Aurora MySQLからGoogle Cloud SQLへのレプリケーションにおいて必要となる部分のみを抜粋して説明します。より細かくレプリケーションについて学びたい場合は以下の記事が非常に参考になります。 qiita.com バイナリログとリレーログ MySQLのレプリケーションはバイナリログと呼ばれるものを利用して実現します。 メインDBのすべての変更はバイナリログに保存されます。それらのバイナリログに書かれた変更点を別のDBで再現することにより、メインDBで行われた変更を追随できます。 続いて、保存されたバイナリログをレプリカDBに送ります。メインDBから送られてきたバイナリログをレプリカDBではリレーログと呼びます。レプリカDBはリレーログに書かれた変更点を取り込むことによってメインDBで起こった変更に追随します。こうすることにより、レプリカDBはメインDBと同期が取れた状態となります。 バイナリログのローテーション バイナリログのファイル名は以下のように「 プレフィックス.インデックス 」の形式を取ります。自前でMySQLを運用している場合は「 --log-bin-index[=file_name] 」オプションを利用することでファイル名のプレフィックスを変更できます。Amazon RDSを利用している場合は以下のような「MySQL-bin-changelog」がプレフィックスに利用されます。 mysql-bin-changelog.000001 また、バイナリログは以下のコマンドを利用することで確認可能です。実際に検証用のAurora MySQLで実行した結果が以下の通りです。 mysql> SHOW BINARY LOGS ; +----------------------------+-----------+ | Log_name | File_size | +----------------------------+-----------+ | mysql-bin-changelog. 003382 | 7335768 | | mysql-bin-changelog. 003383 | 194 | | mysql-bin-changelog. 003384 | 17682649 | +----------------------------+-----------+ ここからわかるように、MySQL-bin-changelogのインデックスが「003382」から始まっており、「000001〜003381」が存在していません。これはバイナリログがローテーションされ、古いバイナリログが定期的に消されていることによります。 なお、MySQLにおいてバイナリログがローテーションされるタイミングは以下のように定義されています。 DB再起動時 max_binlog_sizeを超えたとき RDSの場合はフェイルオーバー時も また、バイナリログは以下のオプションを設定することで、定期的に削除できます。 expire_logs_days オプションの詳細に関しては以下のドキュメントをご参照ください。 dev.mysql.com また、Aurora MySQLでは、以下の例のような特別なパラメータを利用することでバイナリログの削除タイミングを設定可能です。 CALL mysql.rds_set_configuration( 'binlog retention hours' , 24 ); こちらも詳しくは以下のドキュメントをご参照ください。 aws.amazon.com このように、バイナリログファイルはローテーションされ、古いログが削除されます。そのため、初めてレプリケーションを行う場合、バイナリログを利用するだけではメインDBの状態をレプリカDBに再現することはできません。 そこで、最初にメインDBのダンプを取得し、レプリカでそれをロードします。これによりバイナリログが存在する期間までの状態をレプリカで再現できます。あとは、その続きからバイナリログを利用して変更に追随することでメインDBの状態を再現できます。 GTID 先程の説明で「メインDBのダンプを取得し、レプリカでそれをロード」し「その続きからバイナリログを利用して変更に追随することでメインDBの状態を再現できます」と紹介しました。 しかし、どのように「その続きから」をMySQLは判断するのでしょうか。MySQLがどこまでの変更が反映されたのかを判断するためのIDとしてGTIDというものが存在します。 GTIDは以下のような形式のIDです。 b340ea24-7307-34f8-afac-7cabb90c910e:1 b340ea24-7307-34f8-afac-7cabb90c910e:2 b340ea24-7307-34f8-afac-7cabb90c910e:3 以下のコマンドを利用することで人間が読める形でバイナリログを取得できます。 SET @@SESSION.GTID_NEXT= 'b340ea24-7307-34f8-afac-7cabb90c910e:38'/*!*/; とGTIDが利用されていることがわかります。 mysqlbinlog --read-from-remote-server --host=host_name --port=3306 --user username --password= ' xxxxxxxxxxx ' -v mysql-bin-changelog. 000001 > binlog. 000001 $ cat binlog. 000001 /*! 50530 SET @@ SESSION.PSEUDO_SLAVE_MODE = 1 */; /*! 50003 SET @ OLD_COMPLETION_TYPE =@@ COMPLETION_TYPE,COMPLETION_TYPE = 0 */; DELIMITER /*!*/; # at 4 #210121 6:03:16 server id 1678319257 end_log_pos 123 CRC32 0x828ddba9 Start: binlog v 4, server v 5.7.12-log created 210121 6:03:16 at startup ROLLBACK/*!*/; BINLOG ' JBkJYA+ZHglkdwAAAHsAAAAAAAQANS43LjEyLWxvZwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAkGQlgEzgNAAgAEgAEBAQEEgAAXwAEGggAAAAICAgCAAAACgoKKioAEjQA AanbjYI= ' /*!*/; # at 123 #210121 6:03:16 server id 1678319257 end_log_pos 194 CRC32 0x00f6490d Previous-GTIDs # b340ea24-7307-34f8-afac-7cabb90c910e:1-37 # at 194 #210209 9:50:29 server id 1678319257 end_log_pos 259 CRC32 0x90246c84 GTID last_committed=0 sequence_number=1 rbr_only=no SET @@ SESSION.GTID_NEXT = ' b340ea24-7307-34f8-afac-7cabb90c910e:38 ' /*!*/; # at 259 #210209 9:50:29 server id 1678319257 end_log_pos 331 CRC32 0x6ef666ab Query thread_id=14 exec_time=0 error_code=0 SET TIMESTAMP = 1612864229 /*!*/; SET @@ session.pseudo_thread_id = 14 /*!*/; SET @@ session.foreign_key_checks = 1 , @@ session.sql_auto_is_null = 0 , @@ session.unique_checks = 1 , @@ session.autocommit = 1 /*!*/; SET @@ session.sql_mode = 0 /*!*/; SET @@ session.auto_increment_increment = 1 , @@ session.auto_increment_offset = 1 /*!*/; /*!\C utf8 *//*!*/; SET @@ session.character_set_client = 33,@@session.collation_connection = 33,@@session.collation_server = 8 /*!*/; SET @@ session.lc_time_names = 0 /*!*/; SET @@ session.collation_database =DEFAULT/*!*/; ここではGTIDを利用したレプリケーションを紹介しましたが、GTIDを使わない方法も存在します。また、GTIDはMySQL 5.6以上のバージョンでないと利用できません。今回はGTIDを利用したレプリケーションを前提とするため詳細は省略します。 レプリケーションの確認方法 レプリケーションの状態を確認するためには以下のコマンドを利用します。 SHOW SLAVE STATUS; 以下に示すのは、Aurora MySQLからレプリケーションした先のレプリカDBであるCloud SQLにおいて実行した結果です。レプリケーションが正常に動作している場合、以下のような出力となります。 mysql> SHOW SLAVE STATUS \G *************************** 1 . row *************************** Slave_IO_State: Waiting for master to send event Master_Host: main.host.name Master_User: main_host_user Master_Port: 3306 Connect_Retry: 60 Master_Log_File: mysql-bin-changelog. 000011 Read_Master_Log_Pos: 42199452 Relay_Log_File: relay-log. 000006 Relay_Log_Pos: 963 Relay_Master_Log_File: mysql-bin-changelog. 000011 Slave_IO_Running: Yes Slave_SQL_Running: Yes Replicate_Do_DB: Replicate_Ignore_DB: Replicate_Do_Table: Replicate_Ignore_Table: Replicate_Wild_Do_Table: Replicate_Wild_Ignore_Table: mysql.% Last_Errno: 0 Last_Error: Skip_Counter: 0 Exec_Master_Log_Pos: 42199452 Relay_Log_Space: 1455 Until_Condition: None Until_Log_File: Until_Log_Pos: 0 Master_SSL_Allowed: No Master_SSL_CA_File: Master_SSL_CA_Path: Master_SSL_Cert: Master_SSL_Cipher: Master_SSL_Key: Seconds_Behind_Master: 0 Master_SSL_Verify_Server_Cert: No Last_IO_Errno: 0 Last_IO_Error: Last_SQL_Errno: 0 Last_SQL_Error: Replicate_Ignore_Server_Ids: Master_Server_Id: 1678319257 Master_UUID: a9168178-6d44 -3109 -b13c-06fde20ea6cf Master_Info_File: mysql.slave_master_info SQL_Delay: 0 SQL_Remaining_Delay: NULL Slave_SQL_Running_State: Slave has read all relay log; waiting for more updates Master_Retry_Count: 86400 Master_Bind: Last_IO_Error_Timestamp: Last_SQL_Error_Timestamp: Master_SSL_Crl: Master_SSL_Crlpath: Retrieved_Gtid_Set: a9168178-6d44 -3109 -b13c-06fde20ea6cf: 228240-228242 Executed_Gtid_Set: a9168178-6d44 -3109 -b13c-06fde20ea6cf: 1-228242 Auto_Position: 1 Replicate_Rewrite_DB: Channel_Name: Master_TLS_Version: Master_public_key_path: Get_master_public_key: 0 Network_Namespace: 1 row in set ( 0.15 sec) 出力結果で、以下のように Slave_IO_Running と Slave_SQL_Running がYesになっていれば、正常にレプリケーションが動作していることがわかります。 Slave_IO_Running: Yes Slave_SQL_Running: Yes また、バイナリログやリレーログに関するパラメータが表示されていることもわかります。 Master_Log_File: mysql-bin-changelog.000011 Read_Master_Log_Pos: 42199452 Relay_Log_File: relay-log.000006 Relay_Log_Pos: 963 Relay_Master_Log_File: mysql-bin-changelog.000011 GTIDに関するパラメータも表示されていて以下のような意味があります。 Retrieved_Gtid_Set: メインDBから受けっとって保持しているトランザクション。上記の場合 GTID 228240 〜 228242を保持している。1 〜 228239 を含むファイルは既に削除済み。 Executed_Gtid_Set: レプリカDBで既に実行したトランザクションを表す。上記の場合GTID 1 〜 228242 までレプリカDBに反映済み。 一方で、レプリケーションでエラー等が発生した場合は以下のような出力となります。 mysql> SHOW SLAVE STATUS \G *************************** 1 . row *************************** Slave_IO_State: Master_Host: main.host.name Master_User: main_host_user Master_Port: 3306 Connect_Retry: 60 Master_Log_File: Read_Master_Log_Pos: 4 Relay_Log_File: relay-log. 000002 Relay_Log_Pos: 4 Relay_Master_Log_File: Slave_IO_Running: No Slave_SQL_Running: Yes Replicate_Do_DB: Replicate_Ignore_DB: Replicate_Do_Table: Replicate_Ignore_Table: Replicate_Wild_Do_Table: Replicate_Wild_Ignore_Table: mysql.% Last_Errno: 0 Last_Error: Skip_Counter: 0 Exec_Master_Log_Pos: 0 Relay_Log_Space: 331 Until_Condition: None Until_Log_File: Until_Log_Pos: 0 Master_SSL_Allowed: No Master_SSL_CA_File: Master_SSL_CA_Path: Master_SSL_Cert: Master_SSL_Cipher: Master_SSL_Key: Seconds_Behind_Master: 0 Master_SSL_Verify_Server_Cert: No Last_IO_Errno: 1236 Last_IO_Error: Got fatal error 1236 from master when reading data from binary log: 'The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION = 1, but the master has purged binary logs containing GTIDs that the slave requires.' Last_SQL_Errno: 0 Last_SQL_Error: Replicate_Ignore_Server_Ids: Master_Server_Id: 1678319257 Master_UUID: a9168178-6d44 -3109 -b13c-06fde20ea6cf Master_Info_File: mysql.slave_master_info SQL_Delay: 0 SQL_Remaining_Delay: NULL Slave_SQL_Running_State: Slave has read all relay log; waiting for more updates Master_Retry_Count: 86400 Master_Bind: Last_IO_Error_Timestamp: 210209 10 : 52 : 51 Last_SQL_Error_Timestamp: Master_SSL_Crl: Master_SSL_Crlpath: Retrieved_Gtid_Set: Executed_Gtid_Set: a9168178-6d44 -3109 -b13c-06fde20ea6cf: 1-36 Auto_Position: 1 Replicate_Rewrite_DB: Channel_Name: Master_TLS_Version: 1 row in set ( 0.15 sec) Slave_IO_Running: No となり、レプリケーションが止まっていることがわかります。また、直前に発生したエラーは Last_IO_Error で確認できます。 Aurora MySQLからCloud SQLへのレプリケーション手順 以下の記事で、Aurora MySQLからCloud SQLへインターナル通信にてレプリケーションする方法をまとめています。 qiita.com 本記事ではこれらのうち、レプリケーションに関する部分のみを抜粋して改めて紹介します。 以下がレプリケーション構築の手順です。 Aurora MySQLの設定 Aurora MySQLの初期ダンプ Cloud SQLのレプリカ構築 Cloud SQLにおいてダンプしたデータをロード Cloud SQLからAurora MySQLに対してレプリケーションを開始 上記の手順において、一部補足説明をします。 1. Aurora MySQLの設定 まずはじめに、外部へのレプリケーションが可能となるようにAurora MySQLの設定をします。以下のドキュメントにその手順が紹介されています。 docs.aws.amazon.com また、以下のドキュメントに記載のある設定により、GTIDベースのレプリケーションを行うことができます。 docs.aws.amazon.com 2.〜3. Cloud SQLの構築 GCPにて以下のドキュメントに記載されている手順に従いCloud SQLを構築します。Cloud SQLにおけるレプリケーションではMySQLの初期ダンプは手動で行う方法と、自動で行う方法と両方用意されています。また、レプリカを構築するとダンプのロードからレプリケーションの開始まで自動で行ってくれます。 cloud.google.com Aurora MySQLからCloud SQLへのレプリケーション構築における注意すべき2つのポイント それではやっと本編です。Aurora MySQLからCloud SQLへレプリケーションする際の注意点を2つ紹介します。 (1)Aurora MySQLにおけるバイナリログの保持期間の設定 1つ目はAurora MySQLにおけるバイナリログの保持期間の設定の挙動についてです。 発生した問題 前述の通り、Aurora MySQLからCloud SQLへレプリケーションするには、初期ダンプを行い、そのデータをロードする必要があります。そのため、ダンプ開始からダンプのロード完了まで対象のバイナリログが残っていないと、ダンプ完了後に続きからレプリケーションを開始できなくなってしまいます。よって、バイナリログは「ダンプ時間 + ロード時間」だけ削除されずに残っている必要があります。そこで、実際の作業ではダンプ開始からダンプロード完了までに12時間くらいかかると予想し、バイナリログ保持期間を1日として作業しました。以下がその設定です。 CALL mysql.rds_set_configuration( 'binlog retention hours' , 24 ); 実際に試したところ、想定通りダンプの取得からダンプのロード完了まで合わせて12時間で完了し、設定したバイナリログの保持期間に収めることができました。しかし、初期ダンプロード後にレプリケーションを開始すると以下のエラーが発生しました。 mysql> SHOW SLAVE STATUS \G *************************** 1 . row *************************** Slave_IO_State: Master_Host: main.host.name Master_User: main_host_user Master_Port: 3306 Connect_Retry: 60 Master_Log_File: Read_Master_Log_Pos: 4 Relay_Log_File: relay-log. 000002 Relay_Log_Pos: 4 Relay_Master_Log_File: Slave_IO_Running: No Slave_SQL_Running: Yes Replicate_Do_DB: Replicate_Ignore_DB: Replicate_Do_Table: Replicate_Ignore_Table: Replicate_Wild_Do_Table: Replicate_Wild_Ignore_Table: mysql.% Last_Errno: 0 Last_Error: Skip_Counter: 0 Exec_Master_Log_Pos: 0 Relay_Log_Space: 331 Until_Condition: None Until_Log_File: Until_Log_Pos: 0 Master_SSL_Allowed: No Master_SSL_CA_File: Master_SSL_CA_Path: Master_SSL_Cert: Master_SSL_Cipher: Master_SSL_Key: Seconds_Behind_Master: 0 Master_SSL_Verify_Server_Cert: No Last_IO_Errno: 1236 Last_IO_Error: Got fatal error 1236 from master when reading data from binary log: 'The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION = 1, but the master has purged binary logs containing GTIDs that the slave requires.' Last_SQL_Errno: 0 Last_SQL_Error: Replicate_Ignore_Server_Ids: Master_Server_Id: 117581704 Master_UUID: b340ea24 -7307 -34f8-afac-7cabb90c910e Master_Info_File: mysql.slave_master_info SQL_Delay: 0 SQL_Remaining_Delay: NULL Slave_SQL_Running_State: Slave has read all relay log; waiting for more updates Master_Retry_Count: 86400 Master_Bind: Last_IO_Error_Timestamp: 210208 14 : 36 : 58 Last_SQL_Error_Timestamp: Master_SSL_Crl: Master_SSL_Crlpath: Retrieved_Gtid_Set: Executed_Gtid_Set: b340ea24 -7307 -34f8-afac-7cabb90c910e: 1-1024678 Auto_Position: 1 Replicate_Rewrite_DB: Channel_Name: Master_TLS_Version: 1 row in set ( 0.16 sec) 上記のエラーは「対象のGTIDを含むバイナリログが見つからない」といったエラーでした。実際にバイナリログを確認すると、既に対象のGTIDを含むはずのバイナリログファイルがローテートされ削除されてしまっていました。なぜ、設定したバイナリログ保持期間よりも短い時間でバイナリログが消えていたのでしょうか。 結論 AWSの仕様を改めて確認すると以下のようにドキュメントに記載されています。 aws.amazon.com この仕様を読んだ際には、最新のバイナリログ書き込みから、設定した保持期間分のバイナリログが保持されると考えていました。しかし、実際にはバイナリログの保持期間は「ログのファイルが生成されたタイミング」から指定した期間バイナリログが保持されるということが確認できました。そのため、対象バイナリログファイルがローテートされたタイミングで既にファイル作成時から保持期間を過ぎていた場合、即座にファイルが削除されてしまいます。 再現実験 実際にその挙動を実験で再現させました。 実験準備 まず初めに、3時間でbinlogが削除されるように設定します。 CALL mysql.rds_set_configuration( 'binlog retention hours' , 3 ); 続いて以下のようなスクリプトを作成しました。 定期的にデータをinsertするスクリプト while true do sleep 1 mysql -h host_name -u root -Dtest -pxxxxx -e " insert into user values (0, 'xxx'); " done 15秒ごとにGTIDの状態を取得するスクリプト while true do date mysql -h host_name -u root -Dtest -pxxxxx -e " SHOW GLOBAL VARIABLES LIKE '%gtid%'; " mysql -h host_name -u root -Dtest -pxxxxx -e " SHOW MASTER STATUS \\ G " mysql -h host_name -u root -Dtest -pxxxxx -e " SHOW BINARY LOGS \\ G " sleep 15 done 以上のスクリプトを数時間実行し、そのログを解析します。 ログの解読 上記スクリプトによって出力されるログのうち、見るべきログを説明します。 gtid_executed 最後の数字が最新のトランザクション(GTID)になっている(実行済みトランザクション) gtid_purged binlogから既に削除済みのトランザクション(GTID) これを見ることで削除されてしまったトランザクションログがわかる Log_name binlogのファイル名でトランザクションログが書き出されるファイル これを見ることでローテーションされたbinlogファイルが確認できる 実験結果 実験の結果を順に紹介します。 上記スクリプトを「Tue Feb 9 14:28:33 UTC」から実行開始 Tue Feb 9 14:28:33 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-9172 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-37 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000007 Position: 2393564 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-9172 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000007 File_size: 2393564 10時間後の「Wed Feb 10 00:28:42 UTC 2021」 Wed Feb 10 00:28:42 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-43018 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-37 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000007 Position: 11261216 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-43018 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000007 File_size: 11261216 トランザクション単位でbinlogが削除されるのであれば「gtid_purged」の値が変わっているはずです。しかし、実際には変わっていないことがわかります。よって、binlog保持期間を過ぎてもログは削除されていないことがわかりました。 「Wed Feb 10 04:08:45 UTC 2021」 binlogをローテートさせるため、 メインDBのフェイルオーバーを実施します。 「Wed Feb 10 04:18:45 UTC 2021」 Wed Feb 10 04:18:45 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-55967 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000008 Position: 169446 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-55967 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 169446 ログローテートされてから数十秒後に000007のbinlogが削除されました。ここから、ローテート済みバイナリログファイルでないと削除されないことがわかります。 「Wed Feb 10 05:02:55 UTC 2021」 再度フェイルオーバーを行い、バイナリログをローテートさせます。 「Wed Feb 10 05:27:57 UTC 2021」 Wed Feb 10 05:27:57 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-59887 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000009 Position: 370400 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-59887 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 826280 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 370400 フェイルオーバーから数十秒経過しても古いバイナリログ000008が削除されていないことから、保持期間は何らかの形で働いていることがわかります。 「Wed Feb 10 05:30:29 UTC 2021」 再現性を確認するために、再度フェイルオーバーを実施します。 Wed Feb 10 06:30:55 UTC 2021 Wed Feb 10 06:30:55 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-63482 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000010 Position: 914836 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-63482 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 826280 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 3. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 914836 フェイルオーバーから数十秒経過しても000008、000009は削除されないことを確認しました。 「Wed Feb 10 07:10:26 UTC 2021」 Wed Feb 10 07:10:11 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-65733 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000010 Position: 1504598 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-65733 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 826280 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 3. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1504598 Wed Feb 10 07:10:26 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-65747 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-58474 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000010 Position: 1508266 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-65747 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1508266 このタイミングで000008が削除されました。「Wed Feb 10 04:08:45 UTC 2021」にbinlogが生成されたので、約3時間くらい経過しています。これはバイナリログ保持期間とほぼ同じタイミングです。 「Wed Feb 10 08:05:36 UTC 2021」 Wed Feb 10 08:05:21 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-68879 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-58474 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000011 Position: 447428 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-68879 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1881616 *************************** 3. row *************************** Log_name: mysql-bin-changelog.000011 File_size: 447428 Wed Feb 10 08:05:36 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-68893 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-59991 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000011 Position: 451358 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-68894 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1881616 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000011 File_size: 451358 このタイミングで000009が削除されました。「Wed Feb 10 05:02:55 UTC 2021」から約3時間経過しているので、やはりバイナリログ保持期間とほぼ同じタイミングです。 以上の実験結果により、実際にバイナリログファイルが作成されてから指定した保持期間後に対象のバイナリログがローテートされている場合にのみ削除されることが確認・再現できました。 回避方法 上記の結果からわかるように、バイナリログファイルがログローテートされる前に保持期間を過ぎてしまった場合、ログローテートされたタイミングで即座にファイルが削除されてしまいます。 そこで、自分たちのシステムではどれくらいの期間で max_binlog_size に到達するのかを知る必要があります。これがわかったら「ダンプ取得 + ダンプロード」時間と「 max_binlog_size に到達する」時間を比較し、ログ保持期間をその大きい方の値以上に設定することでログの消失を防ぐことができます。 (2)Aurora MySQLのCollation 2つ目の注意点はAurora MySQLのCollationについてです。 MySQLのCollation MySQLはCharsetの他にCollationを指定できます。Collationは日本語では照合順序のことで文字の並び順のことです。これにより、どちらの文字の方が大きいのか小さいのかを判定します。また、文字を区別する際の役割も担っており、大文字と小文字「A」と「a」や濁点と半濁点「ば」と「ぱ」を同じ文字として認識するのかといったことはCollationによって異なります。 発生した問題 (1)の問題が解決し無事レプリケーションに成功した後、以下のエラーが発生しました。 Error 'Character set '#255' is not a compiled character set and is not specified in the '/usr/share/mysql/charsets/Index.xml' file' on query. Default database: 'database_name'. Query: 'create index idx_xxxx_yyyy on xxxx ( xxxx, yyyy)' このエラーを調べたところ Charset ID #255 がCloud SQLには存在していないというエラーでした。これは CHARACTER_SET_NAME=utf8_mb4 、 COLLATION_NAME=utf8mb4_0900_ai_ci という組み合わせでした。そして、 utf8mb4_0900_ai_ci はMySQL 8.0から導入されたことがわかりました。 調査(1)メインDBのCollationの設定確認 メインDBであるAurora MySQLではバージョン5.7を利用しているため、Collation utf8mb4_0900_ai_ci は使われないと考えていました。実際に確認すると以下のCollationしか使われていませんでした。 utf8mb4_bin utf8_general_ci これらは以下のコマンドで調査しました。 -- データベースのCollationの確認 mysql> SHOW VARIABLES LIKE 'collation_server' ; +----------------------+-------------------+ | Variable_name | Value | +----------------------+-------------------+ | collation_connection | latin1_swedish_ci | | collation_database | utf8mb4_bin | | collation_server | utf8mb4_bin | +----------------------+-------------------+ -- テーブルごとのCollationの確認 mysql> SHOW TABLE STATUS FROM データベース名 \G *************************** 1 . row *************************** Name: xxxxx Engine: InnoDB Version: 10 Row_format: Dynamic Rows : 27 Avg_row_length : 606 Data_length: 16384 Max_data_length: 0 Index_length: 16384 Data_free: 0 Auto_increment : NULL Create_time: 2021-02-12 07 : 34 : 57 Update_time: 2021-04-05 06 : 24 : 30 Check_time: NULL Collation: utf8mb4_bin Checksum : NULL Create_options: Comment : (略) -- カラムごとのCollationの確認 mysql> SELECT table_name, column_name, collation_name FROM columns WHERE table_schema= 'データベース名' AND collation_name IS NOT NULL ; +-----------------------+-----------------+-----------------+ | table_name | column_name | collation_name | +-----------------------+-----------------+-----------------+ | xxxxx | xxxxxxx | utf8mb4_bin | | yyyyy | yyyyyyy | utf8_general_ci | (略) +-----------------------+-----------------+-----------------+ 調査(2)binlogの確認 続いて、実際にどのようなbinlogが生成されたことで、このエラーが発生したのかを確認します。対象のbinlogを確認すると以下のような怪しい部分が見つかりました。 @@session.charset_client 並びに @@session.collation_connection が 255 にセットされていることが確認できます。 # at 80217401 #210212 1:17:30 server id 117581704 end_log_pos 80217607 CRC32 0x682b2d36 Query thread_id=79799 exec_time=910 error_code=0 SET TIMESTAMP=1613092650.534322/*!*/; SET @@session.sql_mode=0/*!*/; /*!\C utf8mb4 *//*!*/; SET @@session.character_set_client=255,@@session.collation_connection=255,@@session.collation_server=46/*!*/; create index xxxxxxxxxxxxxxx on xxxxxxxxxxxx (xxxxxx, yyyyyy) /*!*/; 調査(3)Charset ID #255の確認 実際にメインDBで Charset ID #255 が使われていることがわかったため、Aurora MySQLにおける Charset ID #255 がどのようになっているかを確認しました。以下のコマンドで確認できます。 mysql> SELECT * FROM information_schema.collations WHERE id = 255 ; +--------------------+--------------------+-----+------------+-------------+---------+ | COLLATION_NAME | CHARACTER_SET_NAME | ID | IS_DEFAULT | IS_COMPILED | SORTLEN | +--------------------+--------------------+-----+------------+-------------+---------+ | utf8mb4_0900_ai_ci | utf8mb4 | 255 | | Yes | 0 | +--------------------+--------------------+-----+------------+-------------+---------+ 1 row in set ( 0.00 sec) Charset ID #255 が存在し、Collationは utf8mb4_0900_ai_ci となっています。 utf8mb4_0900_ai_ci は、MySQL 8.0から導入されたCollationでした。しかし、Aurora MySQLでは5.7バージョンにおいて先行してこのCollationが使えるとわかりました。また、Cloud SQLにおいて同じように確認すると、 Charset ID #255 が存在していないことがわかります。 mysql> SELECT * FROM information_schema.collations WHERE id = 255 ; Empty set ( 0.15 sec) 調査(4)クライアント側でCollationを指定して再現実験 調査をすすめると、MySQLでは以下のコマンドでクライアント側から利用するCollationを指定可能であることがわかりました。 SET collation_connection = utf8mb4_0900_ai_ci; そこで、検証環境のメインDB側でCollationを指定した場合と指定しなかった場合の挙動を確認します。書き込み処理は実際に本番環境でエラーが発生したインデックスの作成をしています。 mysql> SHOW VARIABLES LIKE 'collation%' ; +----------------------+-------------------+ | Variable_name | Value | +----------------------+-------------------+ | collation_connection | utf8_general_ci | | collation_database | latin1_swedish_ci | | collation_server | latin1_swedish_ci | +----------------------+-------------------+ 3 rows in set ( 0.00 sec) mysql> create index idx_user_id on user (id); Query OK, 0 rows affected ( 0.61 sec) Records: 0 Duplicates: 0 Warnings: 0 mysql> SET collation_connection = utf8mb4_0900_ai_ci; Query OK, 0 rows affected ( 0.00 sec) mysql> SHOW VARIABLES LIKE 'collation%' ; +----------------------+--------------------+ | Variable_name | Value | +----------------------+--------------------+ | collation_connection | utf8mb4_0900_ai_ci | | collation_database | latin1_swedish_ci | | collation_server | latin1_swedish_ci | +----------------------+--------------------+ 3 rows in set ( 0.00 sec) mysql> create index idx_user_name on user (name); すると、以下のようなバイナリログが出力されました。 Collation指定前 # at 42198999 #210215 2:14:50 server id 1678319257 end_log_pos 42199110 CRC32 0x37819efd Query thread_id=194567 exec_time=1 error_code=0 SET TIMESTAMP=1613355290/*!*/; create index idx_user_id on user (id) /*!*/; Collation指定後 # at 42199175 #210215 2:15:16 server id 1678319257 end_log_pos 42199290 CRC32 0x328a35c9 Query thread_id=194567 exec_time=0 error_code=0 SET TIMESTAMP=1613355316/*!*/; /*!\C utf8 *//*!*/; SET @@session.character_set_client=33,@@session.collation_connection=255,@@session.collation_server=8/*!*/; create index idx_user_name on user (name) /*!*/ 以上の結果から、確かにバイナリログに @@session.collation_connection=255 がセットされていることがわかります。また、レプリケーションも同様のエラーが発生し、事象の再現に成功しました。 調査(5)session.collation_connectionが255にセットされた原因の調査 ここまでの調査により、クライアント側でCollationをセットすることで、バイナリログにもその変更が反映されることがわかりました。しかし、わざわざCollationをクライアント側でセットしていませんでした。クライアント側のCollationのデフォルト値について調査したところ、ロケールを以下のように設定した環境においてMySQL 8.0クライアントを利用するとデフォルトで utf8mb4_0900_ai_ci のCollationが使われるとわかりました。 export LANG= ja_JP.UTF-8;localedef -f UTF-8 -i ja_JP ja_JP.utf8 mysql> SHOW VARIABLES LIKE 'collation%' ; +----------------------+--------------------+ | Variable_name | Value | +----------------------+--------------------+ | collation_connection | utf8mb4_0900_ai_ci | | collation_database | utf8mb4_bin | | collation_server | utf8mb4_bin | +----------------------+--------------------+ この状態で書き込みを行うと、実際にコマンドからCollationをセットしたときと同様のバイナリログが出力されました。また、Collationの詳細な挙動は以下のドキュメントをご参照ください。 dev.mysql.com 結論 以上の調査結果から、Collation utf8mb4_0900_ai_ci が利用できるAurora MySQL環境において、Collationを指定して書き込みを行うことでバイナリログに以下が書き出されることがわかりました。 @@session.collation_connection=255 そして、これをCloud SQL側で読み取ると対象のCollationが存在しないためエラーが発生します。また、Collationを意図的に指定しなくても、MySQL 8.0クライアントを利用しロケールを適切にセットするとデフォルトで utf8mb4_0900_ai_ci が利用されることもわかりました。 回避方法 回避方法として、クライアント側で必ずCollationを確認することは可能ですが、意図せずCollationの設定にミスがあるとそのタイミングでレプリケーションが止まってしまいます。そのため、システム側で解決する必要があります。 最初にCloud SQLへ対象のCollationを追加することを考えました。しかし、Cloud SQLの特性上Collationの追加はできないことがわかりました。 そこで、Cloud SQL側のMySQLのバージョンを8.0とすることで回避しました。Collation以外の部分で非互換な部分はありますが、現在の運用方法では特にバージョン違いが原因による問題は発生していません。実際にこのような構成を採用する場合は事前に使っている機能に互換性があるか確認することをおすすめします。 まとめ 本記事では、Auroara MySQLからCloud SQLへのレプリケーションにおいて注意すべき点を2点紹介しました。 弊社では、エラーを1つずつ調査し、妥協せずに解決まで導き出せるエンジニアを募集しています。興味がありましたら以下のリンクからご応募ください。 tech.zozo.com
アバター
はじめに ブランドソリューション開発部プロダクト開発チームの木目沢です。 Fulfillment by ZOZO (以下、FBZ)で提供しているAPIの開発に携わっています。以前「 FBZにおけるドメイン駆動設計(DDD)とサーバーレスアーキテクチャを組み合わせた設計戦術 」という記事を公開しました。そこでは、AWS Lambdaを中心としたサーバーレスアーキテクチャを採用していること、ドメイン駆動設計でAWSのサービス処理とビジネス処理を分離していることをご紹介しました。 FBZはリリース前の設定時にはJavaも検討していました。しかし、結果として採用を見送ることにしました。その理由とリリースから4年が経過した今、改めてJavaに関して調査した結果を本記事ではご紹介します。 JavaではなくPythonを選択した理由 FBZの設計をしていた当時、Lambdaで使用可能な言語は、Node.js、Python、Javaの3つでした。FBZは最終的にPythonを選択し実装されていますが、設計の途中までは以下の理由からJavaを最有力候補として考えていました。 ビジネスロジックが複雑でドメイン駆動で設計していくことを前提としていたため、型のある言語が必要だった Scalaの実装経験を持つメンバーが多く、Javaへの親和性が高かった AWS Lambdaで使用できる言語かつ、Scala同様のJVM言語であるJavaが一番扱いやすい言語だった しかし、サーバーレスアーキテクチャを検討していくなかで、以下の理由からJavaを採用することは難しいと感じるようになってきました。 AWS Lambdaはハンドラーが呼ばれる度に起動される Javaの場合、起動が許容できないほど遅い 例えば、Pythonだと起動から終了まで0.3秒程のAWS Lambdaの処理が、Javaだと約7秒かかる デプロイパッケージサイズが圧縮済みで50MB、解凍して250MBという制約がある JavaでSpring Frameworkを使うだけでこの制約を超えてしまう この2点の制約からJavaの採用を見送り、型ヒントが利用できるPythonを採用する方針にしました。 AWS Lambdaの進化 FBZリリースから4年が経ち、AWS Lambdaも進化しました。 特に以下の機能追加は「JavaでAWS Lambdaを実装できる」と十分に思わせてくれるものでした。 Lambdaレイヤー AWS Lambda本体が使用するライブラリ群を「Lambdaレイヤー」という別レイヤーで管理することが可能になりました。これにより、Springなどのライブラリを含めない状態でAWS Lambdaを利用でき、圧縮後の50MBの制約を気にする必要がなくなりました。 ただし、「AWS Lambda本体 + Lambdaレイヤーの合計が250MBの制約」は残っているため、その点は引き続き考慮する必要があります。 Lambdaカスタムランタイム あらかじめAWS側で用意されているランタイムはJava、Python、Node.js、Rubyです。しかし、それ以外のランタイムも利用できるようになりました。 AWS LambdaでJavaを利用する際のフレームワーク比較・検討 前述の2つの新機能を活かし、今回はベータ版のものも含めた3つのフレームワークでAWS Lambdaの実装を試してみました。 Spring Cloud Function フレームワークのライブラリをLambdaレイヤーに配置する Micronaut & GraalVM ネイティブアプリにコンパイルし、Lambdaカスタムランタイムを利用して動作させる Spring Native ベータ版 Spring Cloud Function Spring Cloud Function はAWS LambdaをサポートしたSpring Frameworkです。Spring MVCのようなコントローラーの代わりに、 java.util.function.Function を実装したクラスがAWS Lambdaのハンドラーとして動作します。実装の詳細は ドキュメント をご確認ください。これまでのSpring Frameworkの機能も利用できるので、Springに慣れたチームであれば容易に実装できるフレームワークです。 ところが、最低限必要なライブラリを追加するだけでjarファイルが20MB超えてしまいます。しかし、それをLambdaレイヤーを利用し、ライブラリと本体を分離して配置することで解消可能です。 Gradleのマルチプロジェクト機能を利用すると子プロジェクトを作成できます。そのため、プロダクト本体の子プロジェクトとライブラリの子プロジェクトを作成します。そして、本体側はライブラリ側を参照するように依存関係を設定するとうまく両者を管理できます。本体側でライブラリ側を参照する際には compileOnly としておくことで、ビルドファイルから除外されるので便利です。 以下はLambdaレイヤー側の build.gradle の例です。 dependencies { implementation( "org.springframework.cloud:spring-cloud-function-adapter-aws" ) implementation( "org.springframework.cloud:spring-cloud-function-web" ) implementation( "org.springframework.boot:spring-boot-starter-validation" ) implementation( "org.springframework.boot:spring-boot-starter-web" ) implementation( "com.amazonaws:aws-lambda-java-events" ) implementation( "com.amazonaws:aws-lambda-java-core" ) implementation( "com.amazonaws:aws-lambda-java-log4j" ) // other implementation } 次に、プロダクト側の build.gradle の例です。 dependencies { compileOnly project( ":layers" ) // other implementation } Lambdaレイヤーとプロダクト側のプロジェクトをそれぞれビルドし、AWS Lambdaにアップロードします。この方法で、容量の問題をある程度解決できます。 ただし、この状況では実行速度の問題がまだ残っています。それを解決する選択肢として、Lambdaカスタムランタイムを利用した新しい解決策であるMicronaut & GraalVMを紹介します。 Micronaut & GraalVM Micronaut(マイクロノート) はSpring Framework同様、フルスタックのフレームワークです。 これまでのフレームワークはDIなどにリフレクションを使用していました。リフレクションは動的にクラスやフィールドにアクセスする技術ですが、それはJavaがJavaバイトコードにコンパイルされ、JVM上で動作することを活かしたものです。しかし、Javaの起動が遅い原因の1つがこのリフレクションの処理でもあるため、Micronautではリフレクションを使用しないように設計されています。 さらに、リフレクションを使用しないため、JVMにこだわる必要もなくなり、 GraalVM を利用してネイティブアプリにコンパイルできます。実装の詳細は AWS Lambdaに焦点を合わせた公式ガイド をご確認ください。 MicronautのGradleプラグインがビルド時に圧縮まで自動的に行ってくれます。そのため、その圧縮ファイルをアップロードするだけで設定が完了できます。 アップロードする際には、以下の画像のようにカスタムアプリとして登録します。 MicronautのGradleプラグインはbootstrapファイルも内包してくれますので、「ユーザー独自のブートストラップを提供する」を選択してください。 AWS Lambda作成画面 MicronautのGradleプラグインを使うとbuildNativeLambdaタスクが追加されます。このタスクを実行することで、ネイティブアプリにビルドできます。以下は build.gradle の例です。 plugins { id( "io.micronaut.application" ) version "1.3.3" } micronaut { processing { incremental( true ) annotations( "micronaut_sample.*" ) } version = "2.3.0" runtime "lambda" } dependencies { compileOnly( "org.graalvm.nativeimage:svm" ) implementation( "io.micronaut:micronaut-validation" ) implementation( "io.micronaut:micronaut-runtime" ) implementation( "io.micronaut.aws:micronaut-function-aws" ) implementation( "io.micronaut.aws:micronaut-function-aws-custom-runtime" ) // other implementation } Micronautでネイティブアプリ化をすることで、AWS Lambdaの実行速度はかなり改善されます。しかし、今やSpring Framework以外のフレームワークを利用することに抵抗があるチームも多いかと思います。 そんな中、3月に Spring Nativeのベータ版が発表 されました。ベータ版ではありますが、Spring Nativeの検証も実施しました。 Spring Native(ベータ版) Spring NativeもMicronautと同様に、GraalVMを利用してコンパイルされます。一度JVMにコンパイルした後、AOTコンパイル(Ahead-Of-Time・事前コンパイル)する仕組みです。 Gradleプラグインも用意されています。Spring Cloud Functionの設定にプラグインを追加し、下記のように設定するだけで利用可能になります。これにより、bootBuildImageタスクが追加されます。 plugins { id "org.springframework.experimental.aot" version "0.9.1" } bootBuildImage { builder = "paketobuildpacks/builder:tiny" environment = [ "BP_NATIVE_IMAGE" : "true" ] } このGradleプラグインはDockerでビルドされるので、このプラグインを使う場合はAmazon ECRにpushして参照する必要があります。 実行可能ファイルに変換するGradleプラグインは現時点で用意されておらず、Mavenを利用する必要があります。詳細はリファレンスの 2.2 Getting started with native image Maven plugin を参考にしてください。 また、Mavenのプラグインはbootstrapファイルの用意までは実施してくれないため、自作する必要があります。 実行結果の比較・検討 各フレームワークで、起動から任意の文字列を100回ループして終了するまでの時間を計測し、比較してみました。 Spring Cloud Function Micronaut & GraalVM Spring Native 約7秒 約0.6秒 約0.6秒 冒頭でJavaを諦めた理由として、起動が遅いと述べました。Spring Cloud Functionではその認識通り、約7秒の時間を要していました。Spring Cloud Functionを単体で使う場合は、Lambda関数の暖機をおこなう Serverless WarmUp Plugin などを利用しないと実用的ではありません。 一方、Lambdaカスタムランタイムを利用してMicronautやSpring Nativeを利用することで圧倒的に所要時間が短くなりました。 MicronautやSpring Nativeの起動時間は大きく短縮できますが、ネイティブアプリにコンパイルする時間がかかります。Micronautで4分30秒程かかりました。 これを致命的と判断するかどうかで、評価が分かれてきそうです。AWS Lambdaの場合、アクセスされるたびにアプリケーションが起動されるので、起動時間の速度が非常に重要です。その点でネイティブアプリにコンパイルできるフレームワークは重宝されます。 まとめ AWS Lambdaの進化と、フレームワークの進化によって選択肢が広がっていることを実感できました。これは、アーキテクチャの検討の際にも選択肢が増え、理想のアプリケーションを実現させる武器になります。 ブランドソリューション開発部では、今後はJavaを使ったWebアプリの開発をしていくことも検討しています。システムによっては、FBZでのサーバーレスアーキテクチャの開発経験を活かして、Java + サーバーレスアーキテクチャを組み込むのも良いでしょう。 ブランドソリューション開発部では、サーバーレスアーキテクチャやドメイン駆動設計など、テクノロジーを活用しサービスを成長させたい仲間を募集中です。ご興味ある方は こちら からぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは、推薦基盤部の与謝です。ECサイトにおけるユーザの購買率向上を目指し、レコメンデーションエンジンを研究・開発しています。最近ではディープラーニングが様々な分野で飛躍的な成果を収め始めています。そのため、レコメンデーション分野でも研究が進み、精度向上に貢献し始めています。本記事では、ディープニューラルネットワーク時代のレコメンド技術について紹介します。 目次 はじめに 目次 パーソナライズレコメンドとは 深層学習より前の推薦手法 協調フィルタリング Matrix Factorization SVD(Singular Value Decomposition) Factorization Machine 深層学習を使った推薦手法 ニューラルネットワーク推薦手法に対する警鐘 Recboleプロジェクト Recboleプロジェクトを用いた各アルゴリズムの検証 General Recommendationに分類されるアルゴリズム GMF(Generalized Matrix Factorization) NCF(Neural Collaborative Filtering) NeuMF(Neural Matrix Factorization) Graph Recommendationに分類されるアルゴリズム NGCF(Neural Graph Collaborative Filtering) LightGCN(Light Graph Convolutional Network) DGCF(Disentangled Graph Collaborative Filtering) Knowledge Aware Recommendationに分類されるアルゴリズム RippleNet CKE(Collaborative Knowledge Base Embedding for Recommender Systems) Sequential Recommendationに分類されるアルゴリズム RNNRecommender Item2Vec BERT4Rec 深層学習×推薦のまとめ 短いライフサイクルで推薦を可能にするための計算時間を短縮する工夫 スパースマトリックスによるデータ量削減 Cython GPU 最後に パーソナライズレコメンドとは レコメンドエンジンとは、ECサイトやWebサイト上で、ユーザにおすすめの商品やコンテンツを表示するためのシステムです。 「新着順」や「人気順」などの汎化されたレコメンドもありますが、個々のユーザに パーソナライズ することによって、閲覧や購買を促進します。ユーザの閲覧履歴や購入履歴などから関連性のある商品やコンテンツ情報を表示させることで、サイト運営側は売り上げや閲覧数を増加させ、ユーザはより自分の気にいる商品やコンテンツを発見しやすくなります。 次章以降、このパーソナライズレコメンドについて掘り下げていきます。 深層学習より前の推薦手法 まずは深層学習より前のレコメンデーションをいくつか見ていきましょう。 協調フィルタリング Aという商品を閲覧・購入した人はBという商品も閲覧・購入した人が多いため、Aという商品を閲覧・購入した人にはBという商品を薦める。といったように、 協調フィルタリング はWebのアクセス履歴やユーザの行動履歴に基づいて商品をレコメンドする手法です。この手法は、商品情報などのコンテンツ情報の必要がない、という点がポイントです。協調フィルタリングでは、ユーザ同士またはアイテム同士のコサイン類似度を計算し、レコメンドを行います。 (引用: 協調フィルタリング入門 ) Matrix Factorization Matrix Factorization は、協調フィルタリングに対する次元削減によって、より良いレコメンドを行います。協調フィルタリングの場合、ユーザやアイテムの数が増えるとそれだけ次元が増えてしまい、計算が困難になります。これが次元の呪いと呼ばれる問題です。そのため、このような高次元のデータを扱うために、高次元データの特徴をできるだけ保持したままデータを低次元データに変換します。これが次元削減です。2008年に行われた推薦システムのコンテスト、 Netflix Prize で最も成果を上げたモデルの1つです。 (引用: Simple Matrix Factorization example on the Movielens dataset using Pyspark ) SVD(Singular Value Decomposition) SVD は特異値分解によって次元削減する手法です。特異値分解は行列の低ランク近似や擬似逆行列の計算などに使われ、特異値を成分とした対角行列を生成します。 (引用: Recommender Systems with Python — Part III: Collaborative Filtering (Singular Value Decomposition) ) Factorization Machine Factorization Machine(FM) は、Matrix Factorizationを使いやすく進化させ、より精度の高いレコメンドエンジンを作成できます。Matrix Factorizationでは、ユーザとアイテムの情報しか扱えなかったため、性別、年齢などをレコメンドエンジンの作成に用いる事ができませんでした。Factorization Machineは、それ以外の情報も扱えるため、性別、年齢を考慮できます。さらに特徴量の間で影響を与えあう相互作用(Interaction)を考慮できるので、相関関係がある特徴量も扱えます。 (引用: On Factorization Models ) 深層学習を使った推薦手法 次に深層学習を使ったレコメンデーションをいくつか見ていきましょう。 ニューラルネットワーク推薦手法に対する警鐘 RecSys 2019と2020の2年連続で、推薦システムの公平なベンチマークに向けた調査と提案に関する論文が発表されました。RecSysとは推薦システムに関するACM主催の国際学会で、この分野ではトップカンファレンスです。 Are We Really Making Much Progress? A Worrying Analysis of Recent Neural Recommendation Approaches Are We Evaluating Rigorously? Benchmarking Recommendation for Reproducible Evaluation and Fair Comparison 上記の論文では、近年流行しているニューラルネットワークベースの推薦手法に対し、有効性やロバスト性(あらゆるデータセットに対して安定したパフォーマンスを出しているか)を検証しました。論文では、多くのDNNベースの推薦手法では旧来の協調フィルタリングやBPRFM手法に勝つことができないと主張しており、DNNベースの推薦手法に疑問符が付きました。 Recboleプロジェクト レコメンドの研究コミュニティでは、レコメンデーションアルゴリズムのオープンソース実装の標準化に対する関心が高まりつつあります。包括的かつ効率的なレコメンダーシステムライブラリとして、 Recbole プロジェクトが発足されました。PyTorchを元に開発され、研究者が推奨モデルを再現・開発する支援をします。本ライブラリの特徴は 公式ページ には以下のように書かれています。 一般的で拡張可能なデータ構造 さまざまな推奨データセットのフォーマットと使用法を統一するために、一般的で拡張可能なデータ構造を設計する 包括的なベンチマークモデルとデータセット 65の一般的に使用される推奨アルゴリズムを実装し、28の推奨データセットのフォーマットされたコピーを提供する 効率的なGPUアクセラレーションによる実行 ライブラリの効率を高めるために、GPU環境で多くの調整された戦略を設計する 広範で標準的な評価プロトコル レコメンデーションアルゴリズムをテストおよび比較するために、一般的に使用される一連の評価プロトコルまたは設定をサポートする Recboleプロジェクトを用いた各アルゴリズムの検証 一般的に使用される推薦データセットの1つである MovieLens 100K Dataset に対し、Recboleに用意されている推薦アルゴリズムを実行し、実行時間やパフォーマンスを計測しました。なお、計測不能なものは除いており、数値は参考値としてご覧ください。 Model Step 秒数 recall@10 mrr@10 ndcg@10 hit@10 precision@10 BPR 53 28.66 0.2358 0.4711 0.2801 0.7646 0.1882 ConvNCF 18 68.74 0.1035 0.2341 0.1235 0.5111 0.0941 DGCF 83 417.9 0.2421 0.4787 0.2862 0.7773 0.1916 DMF 45 52.47 0.226 0.4149 0.2521 0.7678 0.1783 FISM 33 72.4 0.2237 0.4573 0.2689 0.7519 0.1777 GCMC 15 31.97 0.1835 0.3841 0.2136 0.6872 0.1419 ItemKNN 0 2.25 0.247 0.4623 0.2834 0.7847 0.1931 LightGCN 136 92.4 0.2467 0.4838 0.2895 0.7826 0.1949 LINE 85 56.52 0.2025 0.3875 0.2284 0.7243 0.1601 NAIS 18 126.54 0.2389 0.4586 0.2764 0.7741 0.1894 NeuMF 35 40.08 0.238 0.4567 0.2768 0.7678 0.191 NGCF 74 64.83 0.2476 0.4978 0.2983 0.7869 0.1994 Pop 0 1.624 0.0289 0.1244 0.0558 0.2694 0.0492 SpectralCF 26 22.44 0.1133 0.2686 0.1363 0.5578 0.1014 CFKG 13 12.88 0.1109 0.2664 0.1368 0.5408 0.1027 CKE 88 84.38 0.243 0.4813 0.2863 0.7752 0.1954 KGCN 39 51.26 0.2159 0.4401 0.2566 0.7476 0.1749 KGNNLS 39 72.5 0.2159 0.4401 0.2566 0.7476 0.1749 KTUP 60 82.16 0.1716 0.3374 0.2006 0.6808 0.1503 MKR 64 145.2 0.194 0.4016 0.2315 0.7041 0.1601 RippleNet 27 258.06 0.198 0.3873 0.2281 0.7094 0.1642 BERT4Rec 29 584.83 0.1113 0.0335 0.0513 0.1113 0.0111 FDSA 43 1130.45 0.1273 0.0403 0.0601 0.1273 0.0127 FOSSIL 41 42.06 0.1007 0.032 0.0476 0.1007 0.0101 FPMC 23 13.65 0.0838 0.0244 0.0382 0.0838 0.0084 GCSAN 17 3490.54 0.1241 0.0398 0.0591 0.1241 0.0124 GRU4Rec 33 103.96 0.1304 0.0483 0.0671 0.1304 0.013 GRU4RecF 24 160.15 0.1463 0.0473 0.07 0.1463 0.0146 HGN 11 11.76 0.0339 0.0136 0.0182 0.0339 0.0034 HRM 27 69.26 0.0997 0.0305 0.0465 0.0997 0.01 NARM 40 158.06 0.1347 0.0431 0.0642 0.1347 0.0135 NPE 42 28.43 0.0626 0.0136 0.0247 0.0626 0.0063 RepeatNet 30 1792.03 0.193 0.0734 0.101 0.193 0.0193 SASRec 24 317.61 0.1251 0.0389 0.0588 0.1251 0.0125 SASRecF 35 496.47 0.1283 0.0406 0.0606 0.1283 0.0128 SHAN 23 80.48 0.105 0.0356 0.0516 0.105 0.0105 STAMP 34 47.94 0.105 0.0452 0.0657 0.1347 0.0135 TransRec 17 14.73 0.07 0.0178 0.0297 0.07 0.007 これらのレコメンドは以下の4種類に大別できます。 General Recommendation Graph Recommendation Knowledge Aware Recommendation Sequential Recommendation 特に、NGCFやLightGCNといったGraph Recommendationは、ItemKNNやBPRなどの旧来手法を超える精度を出しています。 また、ZOZOTOWNではレコメンドを活用するシーンが多く存在します。類似アイテムの推薦にはKnowledge Aware Recommendation。ユーザの短期のクリック予測にはSequential Recommendation。クーポン配信にはGraph Recommendation。各機能との相性を考慮し、最適なレコメンドアルゴリズムを選択する必要があります。 次の章では、これら4種類のレコメンダーシステムの特徴を紹介します。 General Recommendationに分類されるアルゴリズム GMF(Generalized Matrix Factorization) GMFはユーザとアイテムのembeddingをelement-wiseにかけたものから、マトリックスの中身を推定する線形モデルです。通常のMatrix Factorizationと同じモデルです。 NCF(Neural Collaborative Filtering) NCF はユーザとアイテムのembeddingを結合したものから多層パーセプトロンを用いてマトリックスの中身を推定する非線形モデルです。Matrix Factorizationの内積表現の部分をニューラルネットワークベースの関数に置き換えて学習します。 (引用: 論文リンク ) NeuMF(Neural Matrix Factorization) NeuMF ではGMFとNCFの出力を結合してマトリックスの中身の推定します。線形モデルと非線形モデルの組み合わせです。 (引用: 論文リンク ) Graph Recommendationに分類されるアルゴリズム グラフ構造は、ノード(頂点)とエッジ(辺)で構成されるデータ型を表します。GNN(Graph Neural Networks)は、グラフ構造を加味しながら各ノードをembeddingします。そして、レコメンド領域でのグラフ構造では、ユーザノードとアイテムノードからなる2部グラフを考えます。2部グラフとは、頂点集合を2つに分割して各部分の頂点は互いに隣接しないようにできるグラフのことです。 グラフベースレコメンドでは、GCN(Graph Convolutional Network)によってノードのembeddingを行います。CNNでは画像データは上下左右斜めの8方向から情報の畳み込み処理を行っているのに対し、GCNでは対象ノードの周辺あるいはグラフ全体の情報から畳み込みを行います。 (引用: 繋がりを可視化するグラフ理論入門 、 グラフ理論 (2) 、 介入効果推定の方法 ) NGCF(Neural Graph Collaborative Filtering) NGCF では、ユーザとアイテムのembeddingにグラフ畳み込み処理を行うことで、明示的にユーザとアイテムの交互作用を考慮します。 (引用: 論文リンク ) LightGCN(Light Graph Convolutional Network) LightGCN では、先のNGCF加えてグラフ上の特徴を平準化することによって計算量を抑えます。これによりGCNの最も重要なコンポーネントである近隣集約のみを考慮しています。 (引用: 論文リンク ) DGCF(Disentangled Graph Collaborative Filtering) DGCF では、ユーザとアイテムの間にインテント層を用意します。これによりユーザがアイテムを購入した潜在的な意図を表現し、購買理由を説明可能にします。 (引用: 論文リンク ) Knowledge Aware Recommendationに分類されるアルゴリズム ナレッジグラフは、柔軟かつ双方向的に事実「エンティティ」を格納する脳のような構造化データベースで、エンティティの相互にリンクされた意味付けを自由に表現します。レコメンド領域のナレッジグラフでは、エンティティとしてユーザとアイテムの他に、アイテムのジャンルやユーザの年齢層/性別などの各特徴量も表現可能です。 さらにナレッジグラフは意味ネットワークとして構築されるため、エンティティ間のセマンティック類似性(意味の類似性)を計算し、各アイテムの類似商品を推薦します。また、ナレッジグラフでは推薦理由を可視化し、データのスパース性問題を解決します。 (引用: A Survey on Knowledge Graph-Based Recommender Systems ) RippleNet RippleNet は、ナレッジグラフのリンクに沿ってユーザの潜在的な関心を自動的かつ反復的に拡張することにより、知識エンティティのデータセットに対するユーザの好みをembeddingします。ユーザによってアクティブ化された複数の波紋に従って、過去にクリックされたアイテムは候補アイテムに関するユーザの嗜好分布を形成し、最終的なクリック確率を予測します。 (引用: 論文リンク ) CKE(Collaborative Knowledge Base Embedding for Recommender Systems) CKE では、以下の3つを用いてアイテムのembeddingを取得します。 ナレッジグラフを用いたembedding(structural knowledge) 画像解析によって得られたembedding(visual knowledge) テキスト解析によって得られたembedding(textual knowledge) (引用: 論文リンク ) Sequential Recommendationに分類されるアルゴリズム 通常のGeneral Recommendationでは、ユーザがアイテムを消費する順序は考慮せず、長期予測としてユーザが最終的に消費するアイテムを予測します。シーケンシャルレコメンドの場合は、ユーザがアイテムを消費した順序に基づいて推薦を行い、ユーザが次に買いそうなアイテムを予測します。 シーケンシャルレコメンドの領域は、自然言語処理の分野と強い相関があります。自然言語処理では、単語の並ぶ方向からID化した単語をembeddingします。シーケンシャルレコメンドでも、ユーザの商品への消費履歴からアイテム情報をembeddingしていきます。ほとんどのシーケンシャルモデルは、自然言語モデルを元に作られています。 RNNRecommender RNNRecommender は、ユーザの1Session分の短期の行動(クリック履歴)から時系列データを取得し、RNNによってユーザのembeddingを行う手法です。RNNの層にGRUやLSTMを使用したり、双方向モデルを使用したりと、様々なアーキテクチャが存在します。 (引用: Sequential Recommendation with User Memory Networks ) Item2Vec Item2Vec は、自然言語処理で文脈から単語のembedding処理を行うWord2Vecを応用し、ユーザの行動履歴からアイテムのembeddingを行います。 (引用: Item2Vec-based Approach to a Recommender System ) BERT4Rec BERT4Rec の「BERT」はBidirectional Encoder Representations from Transformersの略です。2018年10月にGoogleのJacob Devlinらの論文で発表された自然言語処理モデルです。「AIが人間を超えた」と言わしめるほどのブレークスルーをもたらし、多様なタスクにおいて当時の最高スコアを叩き出しています。このBERTモデルをレコメンドに応用したものがBERT4Recです。 (引用: 論文リンク ) なお、自然言語処理の領域では、BERTの他にもALBERTやXLNetやGPT-xなど様々なモデルが登場しているため、それらを生かしたレコメンドモデルも試していきたいです。 深層学習×推薦のまとめ ここまで、ディープニューラルネットワーク時代のレコメンド技術の動向について4種類にわたり紹介してきました。一口にディープラーニングといっても、様々な分野の技術がレコメンドに生かされていることをご理解いただけたでしょう。ここで紹介した内容以外にも、強化学習を用いたレコメンド、深層学習を使わないレコメンドなどもあります。各分野のレコメンド技術に加え、特徴量エンジニアリング、ハイパーパラメータチューニングなど、さらなる精度向上を目指していきたいです。 短いライフサイクルで推薦を可能にするための計算時間を短縮する工夫 コラムとして本章では、推薦における計算時間を短縮するためのテクニックを紹介します。ZOZOTOWNでは、毎日100万以上のアクティブユーザと、100万のアイテム数を取り扱っています。ファッションアイテムは商品のライフサイクルが短いため、推薦結果を出すまでの計算時間は重要です。 スパースマトリックスによるデータ量削減 数百万のアクティブユーザと、数百万のアイテムのマトリックスデータを扱おうとすると、数TBのメモリが必要になります。そのようなサーバーを用意するのも大変ですが、用意できたとしてもインフラコストが格段に高いため、レコメンドによる売り上げ増加を食い潰してしまいます。 そのため、データ量を削減する工夫として、スパースマトリックス(疎行列)をインプットとして用います。レコメンドのインプット成分のほとんどがゼロであるため、疎行列の非零要素だけを工夫してうまく格納することにより大次元の問題を扱うことが容易になり、比較的少ない手間でベクトルと行列の積を計算できます。 ZOZOTOWNの場合、スパースマトリックスを用いることでインプットのデータ量を約10万分の1に削減できます。 Cython Pythonはインタプリタ言語であるため、処理速度は遅い部類に属します。そこで、コンパイラ言語であるC/C++に変換することにより高速化しようというのがCythonです。Factorization Machineモデルでは、Cythonベースのライブラリを用いることで、高速化を実現しています。ZOZOTOWNのリアルデータでも10分強でモデルの作成を終えることができます。 GPU embeddingの計算は、TensorFlowやPyTorchを用いてテンソルの処理をGPUで行います。NumPyに比べてテンソル計算は100倍以上、GPUを用いるとさらに3倍早くなる印象です。 最後に ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
はじめに こんにちは。EC基盤本部SRE部プラットフォームSREの 三神 です。 2021年3月18日、ZOZOTOWNは大規模なリニューアルをしました。その中でも、コスメ専門モールの ZOZOCOSME と、ラグジュアリー&デザイナーズゾーンの ZOZOVILLA を同時にオープンし、多くの反響をいただきました。 今回のリニューアルではBackends For Frontends(以下、BFF)にあたるZOZO Aggregation APIを構築しています。本記事ではZOZOTOWNが抱えていた課題とBFFアーキテクチャを採用した理由、またZOZO Aggregation API構築時に発生した課題と解決法についてご紹介します。 ZOZO Aggregation APIのサービスメッシュについてはこちらの記事でご紹介していますので合わせてご覧ください。 techblog.zozo.com BFFとは BFFはアーキテクチャ設計パターンの1つです。フロントエンドのリクエストに応じて各種のAPIコールをしたり、バックエンドから取得した内容を加工してフロントエンドに返却したりするフロントエンド専用のサーバーを用意するアーキテクチャ設計パターンです。 より詳細な役割は こちら を御覧ください。 ZOZOTOWNにおけるBFFの役割 今回のZOZOTOWNリニューアルにおける大きな特徴の1つに「パーソナライズ」が挙げられます。 我々は、ZOZOTOWNにおいてユーザーと商品のより良い出会いを提供したいと考えています。そのためには画面に表示する情報も画一的なものではなく、ユーザーの趣味嗜好に合わせた商品情報をお届けする必要があります。 これを実現するために、リニューアルしたZOZOTOWNではユーザーが登録している情報から適切な商品情報を提供する機能を実装する事にしました。さらに、商品情報を提供する際にはユーザーが利用しているクライアントに合わせて表示方法を変更する事でより良い体験を提供する事も重視しました。 これらの要件を満たすために、BFFであるZOZO Aggregation APIが各種処理を実施しています。ZOZO Aggregation APIでは各バックエンドから取得した情報をモジュールという単位で管理します。モジュールは性別や年齢、お気に入りブランドといった情報によって取得する内容が異なっており、複数のモジュールからパーソナライズする処理をZOZO Aggregation APIにて行っています。接続したクライアント毎にモジュールの数も調整しており、各クライアントのUIに合わせて最適な形でレスポンスを返す処理もZOZO Aggregation APIが担当しています。 なぜBFFを採用したのか? 下記の記事でも紹介している通り、昨年よりZOZOTOWNのリプレイスの一環でシステムのマイクロサービス化を進めています。 認証機能 や検索機能といった様々なシステムのマイクロサービス化を進めており、今後もその範囲は拡大していく予定です。 techblog.zozo.com リニューアル後のZOZOTOWNではパーソナライズ機能を強化しているので、クライアントが必要とするデータの種類が増えました。そして、マイクロサービス化が進む事で、クライアントが必要なデータを取得するために多数のマイクロサービスへリクエストする必要性が出てきます。この様な背景から下記の課題が出てきました。 クライアントが接続するマイクロサービスの増加により、各マイクロサービスのAPI仕様とクライアント実装が複雑になる 各APIにアクセスを行うため、クライアント・サーバー間の通信量が増加する リプレイスの進捗に応じてバックエンドAPIの粒度や提供データの内容に修正が発生した場合、各クライアントが要件に追従する必要がある 上記課題の解決手段としてBFFアーキテクチャを採用する事にしました。 BFFの存在により、下図のようにフロントエンドはBFFにのみリクエストを送る事になり、通信量の肥大化を防ぐ事ができます。クライアントの実装も接続先はBFFのみとなるのでシンプルにできます。また、バックエンドAPIに修正があった際もBFFにてその対応が吸収できるので、各クライアントでの対応は不要です。 これら踏まえ、各バックエンドの情報を集約/整形してフロントエンドに返す処理をするBFFをZOZO Aggregation APIとして実装する事にしました。 ZOZOTOWNにおけるBFFアーキテクチャ ZOZO Aggregation APIはZOZO API Gateway(以下、API Gateway)の配下に設置する設計で構築しています。 ZOZOTOWNではAPI Gatewayパターンのアーキテクチャを採用しており、認証認可やカナリアリリース機能を備える高機能な内製API Gatewayを軸にしたシステム構成となっています。BFFであるZOZO Aggregation APIはAPI Gateway配下の1マイクロサービスとして設置しており、リクエストはAPI Gatewayを経由してルーティングされます。 なお、API Gatewayに関しては以下の記事でご紹介していますので合わせてご覧ください。 techblog.zozo.com 次に、ZOZO Aggregation APIをリリースする上で発生した課題を2点ご紹介します。 BFFによるキャッシュの一元化 リニューアル後のシステム要件を整理する中でZOZO Aggregation APIにおけるバックエンドの最大負荷が想定以上に多い事が判明しました。 リニューアル後のトップページでは、パーソナライズを強化するためにバックエンドAPIへのリクエストがリニューアル前に比べて増加していました。それに加え、セール等のイベント時は通常時と比較して圧倒的に負荷が高くなるため、スパイクを考慮する必要もあります。 リニューアル後のセール時に発生するスパイクをシミュレーションすると、既存の各バックエンドの規模では負荷に耐えられない事がわかりました。バックエンドが高負荷状態になり、ZOZO Aggregation APIのレスポンスが遅延した場合、トップページ生成時間が長くなるのでユーザー体験を著しく損なう可能性があります。 この問題の対策として、各バックエンドの増設もしくはキャッシュの導入を検討しました。前者の増設による対策の場合、すべてのバックエンドをイベント毎に増設する必要があり、そのための工数や維持費用が膨れ上がり現実的な解決策とは言えませんでした。そこで、後者のキャッシュによる解決策を中心に検討を進めていきました。 トップページにおけるアクセス増が原因なので、AkamaiやFastlyといったCDNにてキャッシュする事による負荷軽減を模索しました。しかし、パーソナライズを実現するにあたり、多種多様なモジュール内容と組み合わせを想定しているため、ユーザーごとに表示される内容に差異が多い仕様でした。したがって、ZOZO Aggregation APIにて集約した後のページをキャッシュするCDNの様な方式は、本サービスにおける負荷対策としてあまり効果的ではないと判断しました。 そこで、モジュール単位でキャッシュする案を検討しました。ページを生成する前のモジュール単位であれば、同一条件下でのレスポンスデータ生成においてキャッシュが利用できます。そのため、モジュール単位でキャッシュするシステム構成に変更しました。ZOZO Aggregation APIと各バックエンドの間に ElastiCacheによるRedis を利用する事で各モジュールをキャッシュする仕組みを構築しました。 BFFの存在のおかげで、キャッシュ利用に関する実装はBFF内で完結させる事ができました。BFFが無ければ各フロントエンドでキャッシュに関する実装が必要となり、開発工数が大きく増加していました。 下図はキャッシュが無いタイミングのアプリケーショントレーシングの結果です。ZOZO Aggregation APIが各バックエンドに対して大量にリクエストしている事がわかります。 そして、次に示す図はキャッシュがある場合のアプリケーショントレーシングの結果です。ZOZO Aggregation APIが各モジュールのキャッシュを取得しているため、バックエンドに対する負荷が下がっている事がわかります。また、レイテンシも200msから40msと短縮されており、レスポンス速度も大幅に改善している事がわかります。 キャッシュを非常に活用できている状態になったのですが、運用上の課題が一点残りました。 ZOZOTOWNでは毎日お得なクーポンを発行しており、利用できるクーポンが毎日午前0時に切り替わります。ZOZO Aggregation APIではクーポン情報も保有しているので、午前0時のタイミングで強制的にバックエンドから最新情報を取得する仕様となり、キャッシュスタンピード状態になる可能性がありました。この課題に関しても、記事を執筆予定なので、是非ご期待ください。 BFFにおけるサービス可用性の考慮 BFFはフロントエンドからリクエストを受けるため、BFFに障害が発生した場合はサービス障害に直結しやすい傾向にあります。ZOZO Aggregation APIもトップページを生成するために利用されるので、障害時はZOZOTOWNのサービス自体に影響します。ところが、BFFは複数のバックエンドと通信するアーキテクチャである事から、いずれかのバックエンドにて障害が発生した際に、その影響を受けてしまう懸念がありました。サービスとしての可用性を担保するにはこの課題の対策が必要です。 そこで、ZOZO Aggregation APIでは、特定のモジュールが取得できない場合は取得済みモジュールのみでレスポンスを行う仕様にしました。タイムアウトとリトライ制御をバックエンド毎に設定しておき、バックエンドが期間内にレスポンスを返さない場合は、その他のバックエンドから取得できたモジュールのみでレスポンスを返します。 ZOZO Aggregation APIと各バックエンド間の通信におけるタイムアウトとリトライ制御は、Istioのトラフィック制御機能で実現しています。ZOZOTOWNマイクロサービスプラットフォームにおけるIstioの活用については こちら の記事で紹介しているので、是非ご覧ください。 apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : zozo-test-api-vs spec : hosts : - zozo-test-api-vs.zozo-test.svc.cluster.local http : - route : - destination : host : zozo-test-api-vs.zozo-test.svc.cluster.local subset : zozo-test-api weight : 100 retries : attempts : 1 perTryTimeout : 8s retryOn : 5xx,connect-failure timeout : 9s 下図で示す通り、アプリケーショントレーシングでも特定のモジュールがタイムアウトした場合、ZOZO Aggregation API自身は取得できたモジュールのみで返却している事がわかります。 なお、現在はタイムアウトとリトライ制御のみですが、バックエンドとの通信のさらなる回復性向上のために サーキットブレーカー の採用を検討しています。 まとめ 今回はマイクロサービス化が進むZOZOTOWNにおけるBFFの有効性と構築時に発生した課題2点を紹介しました。BFFを採用した事でAPI実装やクライアント実装がシンプルになり、効率的なキャッシュ実装や通信量削減などの様々なメリットを実感しています。 今後もBFFを活用して様々な機能を追加し、適切なマイクロサービス環境の運用を目指していきます。新たな知見が得られた際はまたご紹介します。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。 tech.zozo.com
アバター
こんにちは。アーキテクト部の廣瀬です。 弊社ではサービスの一部にSQL Serverを使用しており、BigQuery上のデータ基盤へテーブルを連携しています。連携の仕組みは非常によくできているものの、データ不整合や遅延が発生し得るという課題を抱えていました。しかし、SQL Serverのスナップショット分離レベルを導入することでそれらを解決できました。本記事では、抱えていた課題および解決までの流れと、スナップショット分離レベルを導入する際に気を付ける点を紹介します。 データ基盤連携の方法と課題 データ基盤との連携方法は、日次連携とリアルタイム連携の2種類です。それぞれの連携方法と抱えていた課題について説明します。 日次連携 1日1回、SQL Server専用の一括コピーツールである「bcp」を使用してテーブル全体のデータを取得する連携方法です。データ取得時のSQLのイメージは以下の通りです。 SELECT #{columns} FROM #{@tablename} WITH (NOLOCK) この方法では、テーブルサイズの大きさに応じてデータ取得にかかる時間も長くなります。SQL Serverにおけるデフォルトのトランザクション分離レベルは「READ COMMITTED」です。そのため、ユーザー操作によって発行される更新クエリをブロックしてしまう懸念があり、それを避けるために「WITH(NOLOCK)」を付与しています。 この「WITH(NOLOCK)」ヒントをつけるとトランザクション分離レベルが「READ UNCOMMITTED」になります。この分離レベルではダーティリードを許可するため、データの読み取り中にページ分割が起こると、データの欠損や重複などの不整合につながります。データ基盤はアプリのPUSH配信にも使われているため、重複を避けるための工夫を配信側で実装する手間や、データ欠損による機会損失が発生していました。なお、「WITH(NOLOCK)」ヒントとページ分割の関係性については こちらの記事 で詳しく解説されています。 このように「READ COMMITTED」でも「READ UNCOMMITTED」でも、それぞれに懸念がありました。しかし、どちらかを受け入れるしかないため、ユーザー操作への悪影響を避けることを優先して「READ UNCOMMITTED」分離レベルを採用していました。 リアルタイム連携 約1分に1回、弊社で開発したリアルタイムデータ連携の仕組みを使い、直近で更新のあった差分データのみを取得する連携方法です。なお、リアルタイムデータ連携基盤に関する詳しい内容については、下記の記事をご参照ください。 techblog.zozo.com 上記記事で紹介しているデータ取得時のSQLのイメージは以下の通りです。 SELECT a.SYS_CHANGE_OPERATION AS changetrack_type, a.SYS_CHANGE_VERSION AS changetrack_ver, #{columns} FROM CHANGETABLE(CHANGES #{@tablename}, @前回更新したバージョン) AS a LEFT OUTER JOIN #{@tablename} ON a.#{@primary_key} = b.#{@primary_key} この方法では、差分データのみを取得するため、データの取得が高速に完了します。そのため、データ取得クエリが他のクエリを長時間ブロックする懸念はほぼありません。したがって、「WITH(NOLOCK)」ヒントをつけずに「READ COMMITTED」分離レベルでクエリ実行しています。 しかし、該当テーブルへ長時間の更新クエリが実行されている状況だと、逆にデータ取得クエリがブロックされてデータの同期遅延が発生することがありました。ブロックされて待ち続けた場合にロックの状況が悪化しないよう、クエリ実行時にロックのタイムアウト設定を入れたり、インターバルを60秒と長めにとるという工夫もしています。 連携方法と課題のまとめ ここまでの説明をまとめると、以下の通りです。 連携の方法は日次とリアルタイムの2種類が存在 日次連携では「WITH(NOLOCK)」付きで「READ UNCOMMITTED」分離レベルでクエリを実行 課題:ダーティリードを許可しているため、データの欠損や重複などの不整合が起こり得る リアルタイムデータ連携では「READ COMMITTED」分離レベルでクエリを実行 課題:他のクエリが更新クエリを長時間実行中だと、ブロッキングによりデータの同期遅延が起こり得る 以降では、これらの課題をどのように解決したか、順に説明します。 トランザクション分離レベルの検討 今回の課題を解決するには、「READ UNCOMMITTED」分離レベルを使用せずに、他の更新処理によって連携クエリがブロックされない状況を作る必要があります。そのためにはトランザクション分離レベルを変更する必要があります。 まず、SQL Serverのトランザクション分離レベルについて簡単に説明します。 SQL Serverには、5つのトランザクション分離レベルが用意されています。 READ UNCOMMITTED READ COMMITTED REPEATABLE READ SNAPSHOT SERIALIZABLE デフォルトの分離レベルは「READ COMMITTED」であり、これは変更できません。 トランザクション単位での分離レベルは個別に指定可能で、未指定時はデフォルトの分離レベルとなります。 分離レベルの指定は以下のクエリで実行可能です。 SET TRANSACTION ISOLATION LEVEL <分離レベル名> また、分離レベルではないものの、「READ COMMITTED」の挙動を変化させるデータベースオプション「READ_COMMITTED_SNAPSHOT」(READ COMMITTED SNAPSHOT ISOLATION : RCSI) も存在します。 このオプションをONにすると、データ更新時にコミット済みのレコード(トランザクション内で変更する前の状態のデータ)がtempdbへと書き込まれるようになります。そしてSELECTクエリを実行した際は、必要に応じてtempdbに格納されたコミット済みデータを読み取ることで、ロック無しで整合性のとれたデータを取得できます。 このオプションのON/OFFも考慮すると、分離レベルは以下の6つに分類できます。 READ UNCOMMITTED READ COMMITTED(READ_COMMITTED_SNAPSHOT OFF) READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) REPEATABLE READ SNAPSHOT SERIALIZABLE この中で「READ UNCOMMITTED」分離レベルを使用せずに、他の更新処理によってSELECTクエリがブロックされない状況を作るには、 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) SNAPSHOT のどちらかの分離レベル(+オプション)を設定する必要があります。 そのため、この2種類の選択肢について比較検討を実施しました。 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) vs. SNAPSHOT どの時点のデータを読み取るか READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) 各ステートメント(SELECT文)を発行したタイミングで、コミットされていたデータ SNAPSHOT トランザクションを開始したタイミングでコミットされていたデータ 同一リソースへの書き込みが競合した場合の挙動 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) ブロッキングが発生 SNAPSHOT トランザクションの開始後、他のクエリによって変更されたデータに対して変更をコミットしようとすると、ロールバックされエラーとなる(詳細は ドキュメント を参照) 読み取りの挙動が変化する範囲 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) オプションをONにした時点ですべてのセッションが影響を受け、コミット済みデータだけをロック無しで読み取るようになる 既存アプリケーションへの影響がある(読み取り処理とデータ更新処理との競合がなくなる) SNAPSHOT SNAPSHOT分離レベルを指定したセッションのみが影響を受ける 比較検討の結果 今回課題を抱えているのは読み取りのみのワークロードです。したがって、書き込みの競合を考慮する必要はありません。また、何年も運用されているDBのため「READ_COMMITTED_SNAPSHOT」オプションをONにすると既存のアプリケーションの挙動に予期せぬ変化が生じる懸念もありました。一方で、SNAPSHOT分離レベルの場合は明示的に分離レベルを指定したセッションのみが影響を受けるため、既存アプリケーションの挙動は一切変化しません。 以上の考察を踏まえ、最終的にSNAPSHOT分離レベルを導入することにしました。 SNAPSHOT分離レベルの導入 SNAPSHOT分離レベルに切り替えるためには、該当セッションで以下のクエリを実行します。 SET TRANSACTION ISOLATION LEVEL SNAPSHOT ただし、データベースオプション「ALLOW_SNAPSHOT_ISOLATION」が有効化されている必要があります。 ALTER DATABASE <データベース名> SET ALLOW_SNAPSHOT_ISOLATION ON このオプションを運用中の本番環境に適用する際には注意点があるので紹介します。 導入時の注意点 「ALLOW_SNAPSHOT_ISOLATION」の有効化はオンラインで実施可能です。 ただし、「ALTER DATABASEを実行する前に開始されたトランザクション」が存在する限り、ALTER文の実行は完了しません。「ENABLE_VERSIONING」という待ち事象で待ち続けることになります。 なお、「ALTER DATABASEを実行した後に新たに開始されたトランザクション」についてはALTER文の実行を妨げることはありません。 ドキュメント には以下の記載があります。 ALLOW_SNAPSHOT_ISOLATION を新しい状態に (ON から OFF へ、または OFF から ON へ) 設定した場合、ALTER DATABASE は、データベース内にあるすべての既存のトランザクションがコミットされるまで、呼び出し元に制御を返しません。 データベースが既に ALTER DATABASE ステートメントで指定した状態にある場合には、制御は呼び出し元に直ちに返されます。 実際に弊社の環境で導入した際は、瞬時に完了したDBもあれば、完了まで90秒程度かかったDBもありました。 基本的にこのALTER文が他のクエリをブロックすることは無い認識ですが、万一の事態に備え、ALTER文の実行中は常に sys.dm_exec_requests を使い、実行中のクエリでブロッキングが発生していないかを監視することをおすすめします。 導入後の注意点 導入後は、データの書き込みが発生する度にtempdbにコミット済みのレコード情報が書き込まれるようになるため、tempdbの負荷が上昇します。 この性質を念頭において、パフォーマンスモニタの以下のメトリクスで目立った変化が無いかを確認します。 CPUの高騰がみられないか Processor¥% Processor Time 同時実行性の低下はみられないか SQLServer:Statistics¥Batch Requests/sec SQLServer:General Statistics¥Processes blocked 「行のバージョン管理」関連メトリクスで気になる変化はないか SQL Server:Transactions¥Free Space in tempdb(KB) SQL Server:Transactions¥Version Store Size(KB) SQLServerTransactions¥Version Cleanup rate (KB/s) SQL Server:Transactions¥Version Generation rate (KB/s) tempdbのディスク負荷は問題ないか Physical Disk (tempdbのドライブ)¥Disk Read Bytes/Sec Physical Disk (tempdbのドライブ)¥Disk Write Bytes/Sec Physical Disk (tempdbのドライブ)¥Current Disk Queue Length 行のバージョン管理に使用するtempdbの領域は、定期的に自動でクリーンアップされます。領域サイズが増え続けずに、定期的に減少するタイミング(行のバージョン管理のクリーンアップ)があること必ず確認します。 あわせて、sys.dm_exec_requestsを使って、リアルタイムでクエリの同時実行性についても確認しておくとより安心です。基本的には、上記内容に気を付けつつ導入および導入後の評価を実施すれば、安心してSNAPSHOT分離レベルを使用できるかと思います。 監視項目 「ALLOW_SNAPSHOT_ISOLATION」を有効化した後は、以下の2点は必ず監視しましょう。 tempdbの容量逼迫の検知 環境によっては、大量のデータ更新などの理由でtempdbの空き容量の枯渇が懸念されます。90%を超えたらアラートを発報するなど、検知できる仕組みを用意しておきましょう。 長時間開きっぱなしのトランザクションの検知 行のバージョン管理のクリーンアップについて、 ドキュメント に以下の記載があります。 バージョンストアに格納されているバージョンは、行のバージョン管理に基づく分離レベルで実行されるトランザクションで必要な限り保持されます。 SQL Server データベース エンジンにより、必要なトランザクション シーケンス番号の中で最も小さい番号が追跡され、それよりもトランザクション シーケンス番号が小さい行のバージョンは定期的にすべて削除されます。 つまり、開きっぱなしのトランザクションがあると、そのトランザクションより後に開始されたトランザクションによってtempdbに書き込まれたデータはいつまでもクリーンアップされません。この状況になるとtempdbの容量逼迫につながる懸念があるため、例えば以下のようなクエリを定期的に実行してレコードが取得できた場合は通知する仕組みを用意しておきます。 -- 60分以上開きっぱなしのトランザクションを検知 SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED SELECT TOP ( 1 ) ' transaction_time_min: ' + isnull( cast (datediff(minute, transaction_begin_time, getdate()) AS VARCHAR ( max )), '' ) + ' / session_id: ' + isnull( cast (es.session_id AS VARCHAR ( max )), '' ) + ' / host_name: ' + isnull( cast (host_name AS VARCHAR ( max )), '' ) + ' / program_name: ' + isnull( cast (program_name AS VARCHAR ( max )), '' ) + ' / status: ' + isnull( cast (es.STATUS AS VARCHAR ( max )), '' ) + ' / last_request_end_time: ' + isnull( cast (last_request_end_time AS VARCHAR ( max )), '' ) + ' / text: ' + isnull( cast (TEXT AS VARCHAR ( max )), '' ) AS result FROM sys.dm_tran_session_transactions ts JOIN sys.dm_exec_sessions es ON es.session_id = ts.session_id JOIN sys.dm_tran_active_transactions at ON at.transaction_Id = ts.transaction_id LEFT JOIN sys.dm_exec_requests der ON es.session_id = der.session_id OUTER APPLY sys.dm_exec_sql_text(sql_handle) AS dest WHERE datediff(minute, transaction_begin_time, getdate()) > 60 ORDER BY datediff(minute, transaction_begin_time, getdate()) DESC 導入効果 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化し、データ基盤への連携クエリだけ「SNAPSHOT」分離レベルを使用することで、抱えていた以下の課題を解決できました。 日次連携:WITH(NOLOCK)つきのクエリを実行することによるデータ不整合 ブロッキングの懸念が無くなったため、WITH(NOLOCK)を外すことができた リアルタイム連携:「READ COMMITTED」分離レベルでクエリを実行する際に他のクエリにブロックされる ブロックされることが無くなったため、遅延が発生しなくなりデータ基盤への連携が安定した ブロックすることも無くなったため、同期のインターバルを短く設定してより早く連携できるようになった また、既存のアプリケーションの挙動については一切変化しないため、予期せぬ不具合が発生することも避けることができました。 導入後に起きた問題 ほとんどのDBは上記内容でスムーズに導入できましたが、一部のDBでは導入後に問題が発生して切り戻しました。発生した問題点と、策定した解決方法を紹介します。 トランザクションログのバックアップサイズが急激に肥大 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化したあとに特定のDBだけ、トランザクションログファイルのバックサイズが約100倍に肥大しました。tempdbへの書き込みが増加することは認識していましたが、ユーザーDBのトランザクションログファイルがここまで急激に肥大することは考慮できていませんでした。 ログ肥大の原因調査 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化する前後のバックアップログファイルをテーブルにダンプして解析しました。 まず、以下のクエリでログファイルをテーブルにINSERTします。 SELECT * INTO tran_log_dump FROM sys.fn_dump_dblog( NULL , NULL , NULL , 1 , N ' C:\***\backup_log_file.trn ' , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL ) 次に、INSERTしたテーブルをOperation(INSERT/DELETEなど)、Context(HEAP/CLUSTEREDなど)、AllocUnitId(テーブル)単位で集計し、合計のトランザクションログサイズが大きい順に表示しました。 SELECT * , SUM (cnt) OVER () AS sum_cnt , SUM (sum_log_record_length) OVER () AS sum_all_log_record_length , SUM (sum_log_reserve) OVER () AS sum_all_log_reserve FROM ( SELECT Operation ,Context ,AllocUnitId , COUNT (*) AS cnt , SUM ( CAST ([ Log Record Length ] AS BIGINT)) AS sum_log_record_length --単位:byte , SUM ( CAST ([ Log Reserve] AS BIGINT)) AS sum_log_reserve --単位:byte FROM tran_log_dump WITH (NOLOCK) GROUP BY Operation ,Context ,AllocUnitId ) AS A ORDER BY sum_log_record_length DESC 上の図は、「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化した後のトランザクションログです。図のように、1位だけログサイズも、ログの件数も突出していました。有効化前のトランザクションログと比較すると、LOP_MODIFY_ROW(行のUPDATE)の出現回数が約2500倍になっていました。 どのテーブルへのUPDATEが大量に行われているのか確認するために、AllocUnitIdを使ってテーブル名を解決しました。 SELECT allocation_unit_id, object_name(object_id) FROM sys.allocation_units WITH (NOLOCK) JOIN sys.partitions WITH (NOLOCK) ON container_id = hobt_id WHERE allocation_unit_id IN ( 12 * * * * 34 ) その後、該当のテーブルのUPDATE回数をdm_db_index_operational_statsを使って確認しました。 SELECT * FROM sys.dm_db_index_operational_stats(db_id( ' DatabaseName ' ), NULL , NULL , NULL ) WHERE object_id = object_id( ' TableName ' ) AND database_id = db_id( ' DatabaseName ' ) 図のように、UPDATE回数が約6兆回と、INSERTやDELETEといった他の操作と比べても突出して大きな値となっていました。このテーブルへUPDATEしている処理はどういったものがあるのかキャッシュから確認したところ、5分に1回のペースで定期的に該当テーブルを更新しているバッチ処理がありました。この処理のクエリは、該当テーブルのほぼ全レコードに対してUPDATEを実行していましたが、大半のレコードは同じ値でUPDATEされていることも分かりました。そのため、同じ値でカラムをUPDATEしたときの挙動について、「ALLOW_SNAPSHOT_ISOLATION」オプションの有効化前後で比較を実施しました。 「ALLOW_SNAPSHOT_ISOLATION」オプションの有効化前後でのトランザクションログの比較 以下の検証用のクエリは、同一のテーブルを同じレコード数、同じ値でUPDATEした際のトランザクションログの中身を確認するクエリです。「ALLOW_SNAPSHOT_ISOLATION」オプションの無効化時と有効化時の結果をそれぞれ確認できます。 SET NOCOUNT ON GO DROP TABLE IF EXISTS UpdateTest GO CREATE TABLE UpdateTest ( C1 INT PRIMARY KEY CLUSTERED ,C2 INT ,C3 INT ,C4 INT ,C5 INT ) GO -- 10000レコード、ランダムな値でINSERT DECLARE @cnt INT = 1 BEGIN TRAN WHILE (@cnt <= 10000 ) BEGIN INSERT INTO UpdateTest VALUES (@cnt, RAND() * 100 , RAND() * 100 , RAND() * 100 , RAND() * 100 ) SET @cnt += 1 END COMMIT TRAN; GO -- ログバックアップによりトランザクションログを切り捨てる CHECKPOINT BACKUP DATABASE TEST TO DISK = N ' NUL ' CHECKPOINT BACKUP LOG TEST TO DISK = N ' NUL ' GO -- トランザクションログの中身をチェック(この時点では空っぽのはず) SELECT * FROM sys.fn_dblog( NULL , NULL ) WHERE AllocUnitName LIKE ' %UpdateTest% ' GO -- 「ALLOW_SNAPSHOT_ISOLATION」オプション無効化 ALTER DATABASE TEST SET ALLOW_SNAPSHOT_ISOLATION OFF GO -- C1=1のカラムの存在チェック SELECT * FROM UpdateTest WHERE C1 = 1 GO -- 同じ値でカラムをUPDATE UPDATE UpdateTest SET C2 = C2 WHERE C1 = 1 GO 10 -- トランザクションログの中身をチェック(ここの結果を比較したい) SELECT * FROM sys.fn_dblog( NULL , NULL ) WHERE AllocUnitName LIKE ' %UpdateTest% ' -- 「ALLOW_SNAPSHOT_ISOLATION」オプション有効化 ALTER DATABASE TEST SET ALLOW_SNAPSHOT_ISOLATION ON GO -- 同じ値でカラムをUPDATE UPDATE UpdateTest SET C2 = C2 WHERE C1 = 1 GO 10 -- トランザクションログの中身をチェック(ここの結果を比較したい) SELECT * FROM sys.fn_dblog( NULL , NULL ) WHERE AllocUnitName LIKE ' %UpdateTest% ' 上記クエリの実行結果は以下のようになりました。「ALLOW_SNAPSHOT_ISOLATION」オプションの無効化時(上段)は、同じ値でUPDATEした場合はトランザクションログに書き込まれていません。一方で、有効化時(下段)は同じ値でUPDATEしてもトランザクションログに書き込みが行われるようになっていました。 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化すると、各レコードにバージョン情報のタイムスタンプを保持するようになります。今回の実験における挙動の違いは、有効化時はタイムスタンプだけが更新され、その更新情報がトランザクションログに書き込まれたものと推測されます。 次に、その推測通りの挙動になっているかを確認しました。「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化した状態で、同じ値でUPDATEする前後のデータページの中身を確認します。 DBCC TRACEON( 3604 ) DBCC PAGE (N ' TEST ' , 1 , 2875640 , 3 ) WITH TABLERESULTS DBCC TRACEOFF( 3604 ) GO UPDATE UpdateTest SET C2 = C2 WHERE C1= 1 GO 10 DBCC TRACEON( 3604 ) DBCC PAGE (N ' TEST ' , 1 , 2875640 , 3 ) WITH TABLERESULTS DBCC TRACEOFF( 3604 ) 推測通り、UPDATEの前後で行のバージョン情報である、「Transaction Timestamp」が更新されていることが確認できました。 したがって、ログ肥大の原因は「トランザクションログへの書き込みの挙動がオプション有効化によって変化し、同じ値で大量のレコードを更新している処理がログへ書き込まれるようになったため」と判断できます。 対応策 問題となったバッチ処理では、ほとんどのレコードは同じ値でUPDATEされているため、変化があったレコードだけを更新する差分更新に処理を修正することでログ肥大を抑えられると考えられます。リリースに向けて現在対応中です。 まとめ 本記事では、SQL Serverからデータ基盤へとデータを連携する際に抱えていた課題について説明し、スナップショット分離レベルを導入することで課題を解決するまでの流れ(分離レベルの選定、導入前後の注意点、導入後の問題点)を紹介しました。同じような課題を抱えている方の参考になれば幸いです。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
アバター
こんにちは、DATA-SREチームの塩崎です。最近気になるニュースは「ネコがマタタビを好む理由が蚊を避けるためだった 1 」です。 さて、皆さんはデータ基盤で集計した結果をどのようにして確認していますか。LookerやPower BIなどのBIツールを使って綺麗なダッシュボードを作成している方も多いかと思います。しかし、全員が毎日確認すべき数値はSlackなどの全員が日常的に目にする場所へ掲げたいです。本記事ではBigQueryとSlackを連携させる機能をノーコードで作成する方法を紹介します。 従来手法 BigQueryで集計した結果をSlackに通知するためにはGoogle Apps Script(以下、GAS)を用いるやり方が現在では主流です。GASの文法はJavaScriptとほぼ同じであり、普段分析をメインで担当している人たちには馴染みの薄い言語です。また、Cloud FunctionsとCloud Schedulerを組み合わせて定期的に集計結果をSlackへ通知できますが、これも同様に分析メインな人たちにとっては難易度が高いです。 そのため、Slack通知するためのBotの作成と運用をエンジニアに依頼するという業務フローを採っている組織もあるかと思います。この工数が非常に大きいわけではありませんが、可能ならばエンジニアリソースを使わずにSlackへの通知を実現させたいです。 提案手法 今回提案する手法の全体図を以下に示します。 BigQuery→Google Sheetsの連携にはConnected Sheetsを使い、Google Sheets→Slackへの連携にはSlack Workflow Builderを使います。Google Sheetsを仲介させることで、SQLのみで集計結果をSlackに通知することが実現できます。 Connected Sheets Connected SheetsはBigQueryとGoogle Sheetsを繋ぐ機能です。BigQueryに対してクエリを実行した結果をGoogle Sheetsに挿入したり、Google Sheetsにおけるピボットテーブルを自動的にSQLに変換したりできます。今回はクエリの実行結果をGoogle Sheetsへ挿入するために使用しています。 cloud.google.com support.google.com Slack Workflow Builder Slack Workflow Builderは定型的なプロセスをワークフロー化して、Slackで実行するための機能です。デフォルトの状態では、メッセージの送信やフォームの表示などしかできませんが、サードパーティ製のアプリを導入すると外部サービスと連携できます。 slack.com 今回は以下のアプリを使ってGoogle Sheetsとの連携をします。 slack.com 手順 それでは、実際にやってみましょう。今回はお題として「毎朝10時にBigQueryのログを確認し、昨日の利用費が多い人Top3を通知する」を実現させます。 BigQueryでのジョブの実行履歴は、 INFORMATION_SCHEMA の JOBS_BY_ORGANIZATION から取得します。 cloud.google.com 完成したクエリを以下に示します。第1列に行番号を入れているのは、Google SheetsとSlackを連携させる時に必要なためです。 select row_number() over ( order by sum (total_bytes_billed) desc ) as row_num, user_email, cast ( sum (total_bytes_billed) / 1024 / 1024 / 1024 / 1024 * 5 as int64) as total_cost_in_usd from `region-us`.INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION where date (creation_time, ' Asia/Tokyo ' ) = current_date ( ' Asia/Tokyo ' ) - 1 and reservation_id is null group by user_email order by total_cost_in_usd desc BigQueryとGoogle Sheetsの連携 まずはBigQueryとGoogle Sheetsを連携させます。 メニューバーから「Data」→「Data connectors」→「Connect to BigQuery」を選択します。 課金プロジェクトの選択画面が表示されるので、適当なプロジェクトを選択したあとに「Write Custom Query」を選択してクエリエディタを開きます。ここに先程のクエリを入力して、「Connect」を選択します。 すると、クエリを実行した結果がGoogle Sheetsに挿入されます。 次に、「Refresh options」から定期実行の設定をします。実行時刻を詳細に指定できず、4時間程度の幅の中から選ぶ必要があります。今回はSlackへの通知時刻が10時なので、それ以前の時間帯であればどの時間でも大丈夫です。 最後に「Extract」ボタンを選択して、別シートへ結果の書き出しを行います。Data Connectorで自動的に作成されたシートは直接Slackに連携できないので、一旦通常のシートへの書き出しが必要です。 Google SheetsからSlackへの連携 次にSlackへ連携させます。Google SheetsとSlack Workflow Builderを連携させるためには以下のアプリが必要なので、予めSlackのワークスペースにインストールする必要があります。 slack.com Slack Workflow Builderを起動し、新規のワークフローを作成します。トリガーは「Scheduled date & time」に設定し、毎日AM 10:00に起動するように設定します。 ここから、Google SheetsとSlack Workflow Builderを連携するための設定を入れていきます。「Add Step」を選択して、Google Sheetsからデータを取得するStepを追加します。 「Select a spreadsheet row」を選択します。もし、この時にGoogle Sheets関連のStepが見つからない場合は「Google Sheets for Workflow Builder」のインストールが必要です。 このStepの設定は以下のようにします。「Sheet」はData Connectorが自動的に作成したシートではなく、Extractをして生成したシートにする必要があります。このStepは「Choose a column to search」に設定した列の値が「Define a cell value to find」になっている行をシートから読み取ります。この例ではrow_numが1の列を読み取ることになるので、前述したクエリと併せると、BigQueryの課金額が1番多い人の情報を読み取っています。 同様に「Add Step」であと2つのStepを作成します。「Define a cell value to find」をそれぞれ2と3の値にする以外は、1つ目と同じ設定のStepにします。これにより、BigQueryの課金額が2番目と3番目に多い人の情報を読み取ります。 最後に、取得したデータをSlackに投稿するためのStepを作成します。「Add Step」から「Send a message」を選択します。 この時に「Insert a variable」をクリックすると以前のStepで読み取った値を参照できます。同じ名前が3つずつあり少し分かりにくいですが、上のものから順に1番目、2番目、3番目のStepで読み取った値を表しています。 これらの変数を埋め込み、メッセージを整えていきます。最終的には以下のようなメッセージが出来上がりました。 あとは、このWorkflowをPublishすれば毎日定期的にBigQueryの高額課金者を通知するBotが完成します。実際に動作している様子を以下に示します。 メリット・デメリット 従来のGASを使ったやり方に対する、今回の手法のメリット・デメリットをまとめます。 メリット メリットとして挙げられるのは、SQLだけを知っていればOKという点です。 GASを使う手法で必要だったJavaScriptに関する知識が今回の手法では不要になります。そのため、エンジニアの工数を消費することなく、集計結果の定期的な通知機能を実現できます。このような通知機能は1回作ったらおしまいになることは少なく、プロダクトの成長に併せて確認すべき数値が変わることもしばしばあります。最近では非エンジニアでもSQLを書ける人材が多いので、SQLさえ知っていればOKである仕組みにすると継続的にエンジニアの工数を削減できます。 デメリット 一方で、デメリットもあります。デメリットは大きく分けると2つあり、柔軟性と信頼性が劣るという点です。 まず柔軟性が劣る点について説明します。GASを使ってSlackに連携する場合はSlackのIncoming Webhook機能を使っているケースが多いかと思います。Incoming Webhookで送信するメッセージはBlock Kitに対応しているためリッチな通知ができます。今回の手法でも多少のメッセージの装飾はできますが、標準的なメッセージで可能なものに限ります。 api.slack.com また、クエリの実行時刻についても柔軟性が劣っています。Connected Sheetsの仕様により、クエリの実行時刻は最大で4時間の誤差が生じることを考慮に入れる必要があります。 さらに、通知の頻度についても柔軟性が劣ります。Slack Workflow BuilderとConnected Sheets両方の仕様により、日次よりも高い頻度では通知できません。 次に信頼性が劣る点について説明します。Slack WorkflowがGoogle Sheetsから値を読み取る時に、BigQueryで実行されているクエリの完了を待ち合わせることができません。クエリの実行が完了していない場合は前日分の集計結果をSlackに通知されてしまいます。そのため、クエリの実行タイミングとSlack Workflowの起動タイミングの間に十分なバッファを用意する必要があります。 また、クエリの実行中にエラーが発生したことを検知する方法がありません。そのため、「通知が来ない」ことによってしかエラーの検知ができません。 これらのデメリットは今後Connected SheetsやSlack Workflowの機能が充実することで解消される可能性があるので、今後に期待したいです。 まとめ BigQueryで集計をした結果を定期的にSlackに通知する機能をノーコードで作ることができました。GASで作成する場合に比べると柔軟性や信頼性では劣りますが、エンジニアの工数を使わずに通知が実現可能という点が大きなメリットです。簡単な通知Botならば非エンジニアでも作れるようになるので、データ基盤を社内の多くの職種に解放してデータ活用を更にすすめることに貢献できる機能です。 最後に ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com 【プレスリリース】ネコのマタタビ反応の謎を解明!~マタタビ反応はネコが蚊を忌避するための行動だった~ ↩
アバター