booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

機能リリースに干渉しない定期的なパッケージ更新のやり方

アイキャッチ

目次

はじめに

はじめまして、株式会社ブックリスタのプロダクト開発部に所属している伊藤です。
弊社開発のコミックアプリ、「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」のバックエンドエンジニアとして保守・開発を担当しています。

コミックROLLYは2023年8月1日にローンチしたアプリです。弊社のwebtoon制作スタジオである「booklistaSTUDIO」の作品をはじめ、さまざまなコミック作品を閲覧できます。ご興味ある方は是非下記のリンクからダウンロードいただけると嬉しいです。

https://rolly.jp/

この記事の目的

「コミックROLLY」のプロジェクトでは以下2点の課題を抱えていました。 - オープンソースパッケージを定期的にアップデート確認したい - パッケージアップデートのデプロイを機能リリースに干渉せず実行したい

この記事では課題にどう対応していったかをお話しさせていただきます。

コミックROLLYのシステムの構成について

まずコミックROLLYのシステム構成について説明します。

コミックROLLYのシステムは以下の3つで構成され、日々保守・開発しています。

  • モバイルアプリ
  • アプリに情報を渡すAPI
  • アプリに出すコミック情報を管理するCMS

上記3つのシステムは使用言語、機能などオープンソースのものを使用しており、使用バージョンはyarnなどのパッケージマネージャーを通して管理しています。

パッケージ更新の悩み

上記の通り、オープンソースのソフトウェアを使用しているので、使用する機能は継続的にアップデートされて行きます。これらのアップデート反映を長期間放置すると、セキュリティの問題があったり重大なバグの修正を見落としたりするので、こちらもなるべく最新のアップデート状態を保つ必要があります。

ただし、コミックROLLYのプロジェクトがパッケージを随時アップデートしていくにあたっては以下2つの課題があります。

1つ目は「パッケージ更新の検知と内容確認が出来ない」ことです。
ローンチ前はパッケージマネージャーから直接アップデートで解決していました。ですがこの方法だとアップデート内容がどの程度影響するかがわかりません。ローンチ直前やローンチ以降アップデートの検知と変更・更新内容は確認してからアップデートするかを判断したいので、方法を再度検討する必要がありました。

2つ目は「機能リリースとパッケージアップデートリリースは同時に行えない」ことです。
コミックROLLYはソースをGitでバージョン管理しているので、パッケージのアップデートも同様にGitで管理したいです。ですが、普段の機能開発とパッケージアップデートを同じバージョンで管理すると、バグが発生したときに開発した機能自体の問題なのか、更新されたパッケージの問題なのかの切り分けが困難になります。

Renovate導入とブランチの分岐運用で解決

私たちは、上記の問題をRenovateの使用と、パッケージ更新用のブランチを用意することで解決しました。

Renovateとは
https://github.com/renovatebot/renovate

パッケージアップデートを自動で検出してくれるシステムです。GitHubなどのプラットフォームから使用できて、ボットがアップデートを検知、自動で更新用のプルリクエストを作成してくれます。 依存関係のあるパッケージも合わせてアップデート検知と更新をしてくれるので、悩みで提示した「①検知が必要なバージョンアップの数が多すぎる」はRenovateの使用で解決できます。


このRenovateで更新用プルリクエストのマージ先を指定できるので、パッケージ更新用のブランチを機能開発用のブランチと別で用意して管理することにしました。ブランチを分けることで、パッケージ更新のみのリリースのスケジュールを開発のリリースと別対応することが可能になり、悩みの「②機能リリースとパッケージアップデートリリースは同時に行えない」が解決します。

Renovateの設定と運用

以下に実際のパッケージ更新とリリースの解決方法の詳細を記載していきます。

①パッケージ更新検知の自動化

Renovateを使用してパッケージの更新の検知、パッケージ更新した状態のPR作成を自動で行うよう設定します。

私たちはGitHubでコード管理しているので、Renovateで用意されているGithub Actionsでの自動検知・更新のアプリを導入していきます。 登録対象のアプリは以下です。

https://github.com/apps/renovate

この設定時点では、すでに社内でRenovateを導入しているチームがあり、GitHubの組織アカウント単位で導入済みでした。なので組織アカウントの管理者に使用を要求して許可をいただくことですぐに使用可能になりました。

GitHubにRenovateのアプリを導入できたら、以下画像のように自動でRenovateの設定ファイル作成のプルリクエストが作成されるので、ここから各種設定をしていきます。

Renovateの設定用プルリクエスト

https://github.com/marketplace/renovate から画像を引用しています。

設定値はプルリクエストの実施スケジュールやレビュワー、アサイニーの指定ができますが、コミックROLLYでは以下のように設定しています。

{
  "extends": ["config:base", "schedule:nonOfficeHours"],
  "timezone": "Asia/Tokyo",
  "baseBranches": [{マージ元のブランチ名を設定}],
  "labels": ["renovate"],
  "automerge": false,
  "assignees": [{担当者のGitHubアカウント名を設定}],
  "assigneesSampleSize": 1,
  "prHourlyLimit": 5,
  "prConcurrentLimit": 100,
  "ignorePaths": [{renovateの自動更新対象から除外したいファイルのパスを設定}]
}

この設定のプルリクエストをマージすると、以降はこちらが設定したスケジュールをもとに自動でパッケージ更新したプルリクエストを作成してくれます。 私たちのチームではassigneeに指定された人が主担当になるルールがあるのでassigneesを指定していますが、reviewersの指定も可能です。

②パッケージ更新用のブランチを用意する

Renovateの設定が終わったので、パッケージ更新用のブランチを設定していきます。
コミックROLLYではGitflowモデルを基準に以下のブランチで運用しています。

運用するブランチ名 役割
feature 機能追加用ブランチ
機能追加するためにdevelopブランチから切られる
develop 開発用ブランチ
機能の追加・改修が継続的にマージされる
staging リリース前確認用ブランチ
Gitflowでいうreleaseブランチだが、随時確認できるようにプロダクト環境と同じ状態にして確認用環境のために常駐させる
main プロダクトリリースブランチ
Gitflowでいうmasterブランチ

パッケージ更新は機能開発と違うバージョンで管理したいので、stagingブランチから更新用ブランチ(名称:update)を作成します。
このブランチをRenovateでbaseBranchesに設定することで、パッケージ更新のマージ先がupdateブランチになり、機能開発とパッケージ更新の管理が別々に出来ます。

③パッケージ更新と機能改善のリリーススケジュールを分ける

ブランチを分けたことで関心が分離できたので、それぞれのブランチで更新・リリースが実行できます。
ただし、リリースを分けたことで、以下の問題が発生してしまいます。 - 機能開発で追加したパッケージが更新用ブランチにないので更新対象にならない - パッケージ更新が一方で行われているので、機能開発側のパッケージが古いままになる

私たちのチームでは以下の画像のようにブランチ運用をして解決しています。

パッケージ更新のリリース

updateブランチにまとめられたパッケージ更新をリリース管理用ブランチ(stagingブランチ)にマージし動作検証を実施・完了後、mainブランチにマージしてリリースします。 リリース後はstagingブランチの内容をdevelopとupdateブランチにそれぞれマージすることで、updateブランチがdevelopと同期されます。

機能開発のリリース

developブランチにまとめられた開発機能をstagingブランチにマージ、検証後、mainにマージしてリリースします。
リリース後はstagingブランチの内容をdevelopとupdateブランチにそれぞれマージすることで、updateブランチがdevelopと同期されます。

上記のようにパッケージ更新・機能追加を別々にリリース、その内容をdevelop、updateブランチにそれぞれマージすることで先ほどの課題を解決できました。

まとめ

このようにパッケージ更新の自動検知と機能開発とパッケージ更新を分けることで、パッケージを最新の状態に保つことが出来ました。 同じような悩みを抱えている方の参考に少しでもなれば幸いです。

DynamoDB を使用した API ページネーションの実現

アイキャッチ

こんにちは。私は株式会社ブックリスタのプロダクト開発部の姚と申します。
現在、コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)のバックエンド開発を担当しています。
この記事では、DynamoDB を使用した API ページネーションの作成方法を紹介します。

はじめに

なぜ DynamoDB を使用するのか

DynamoDB は大量の非構造化データの処理に適しており、スケーラビリティと柔軟性に優れています。
データの性質や成長の見込みによっては、DynamoDB が最適な選択肢になることがあります。

ページネーション方式

一般的なデータベースのページネーションは、主にオフセット型とカーソル型の 2 種類が存在します。

オフセット型

オフセット型は、指定した offset から limit 件のデータを取得する方法です。RDBMS では一般的によく使われていますが、DynamoDB ではサポートされていません。

GET /items?limit=<limit>&offset=<offset>

リクエストした limit と offset の値から、次のページを取得する場合の offset の値が決まるため、レスポンスにはページングに関する情報を含める必要がありません。

{
  "items": [
    ...
  ]
}

カーソル型(本記事で紹介する方法)

カーソル型は、直前のページの最後のデータと limit 件数を指定して、次のページを取得する方法です。
本記事ではカーソル型ページネーションの実現方式を紹介します。

GET /items?limit=<limit>&lastKey=<lastKey>

レスポンスには、取得したデータと次のページのための lastKey を返します。

{
  "items": [
    ...
  ],
  "lastKey": "xxx"
}


カーソル型のページネーションはオフセット型と比べて、以下のメリットとデメリットがあります。

メリット

  • 重複・不足データが発生しない: 連続してデータを取得する際、データの増減が発生しても、データの重複や漏れが発生しません
  • パフォーマンス向上: カーソル方式を採用しているため、後のページに移動してもレスポンス速度が低下しません

デメリット

  • 任意のページに直接移動できません
    • クライアント側が移動したページのカーソル情報を保持すれば、擬似的なページ移動が可能です
  • クエリのソートで使用されるカラムに、インデックスを貼る必要があります
  • 実装が少し複雑になります

前提知識

DynamoDB のインデックス

DynamoDB テーブル内のデータのクエリを行う場合、インデックス(プライマリキー もしくは セカンダリインデックス)を指定することで、効率的にデータを取得できます。

プライマリキー

テーブルを作成する際に、テーブルのプライマリキーを指定する必要があります。
プライマリキーは以下 2 種類あります。

  • パーティションキー
  • パーティションキーとソートキーの組み合わせ

セカンダリインデックス

プライマリキー以外のインデックスを定義する場合、セカンダリインデックスを作成する必要があります。
セカンダリインデックスはパーティションキーとソートキーの組み合わせの形ですが、以下 2 種類あります。

  • グローバルセカンダリインデックス
    • パーティションキーとソートキーがテーブルと異なるインデックス
  • ローカルセカンダリインデックス
    • パーティションキーはテーブルと同じですが、ソートキーが異なるインデックス

本記事では、パーティションキーとソートキーの組み合わせを使用した場合のページネーションを紹介します。

インデックスとソートキーについて、もっと詳しく知りたい人は以下のドキュメントを参照してください。

DynamoDB テーブルクエリのページネーション

1MB の制限

DynamoDB の クエリー操作では、最大 1MB のデータしか取得できません。

クエリー結果が 1MB 以上の場合、1MB 以内の結果セットだけを返して、クエリー結果に LastEvaluatedKey という要素が返されます。
後続のデータを取得するには、LastEvaluatedKey を次のクエリーの ExclusiveStartKey として使用する必要があります。
これを繰り返して、一度に 1 ページずつ結果を取り出せます。
クエリー結果に LastEvaluatedKey がない場合、これ以上取得する項目がないことを示します。

limit の指定

ほとんどの場合、1 ページを表示するために DB から取得するデータは 1MB 以下であるため、暗黙なページネーションをそのまま利用できません。
そのため、意図的にページネーションしたい場合には、テーブルクエリーのオプレーションに Limit のパラメータを使用して、1 ページのデータ件数を制限できます。

DynamoDB のテーブルクエリー結果のページ分割について、もっと詳細をしたい場合は以下のドキュメントを参照してください。

実現

API インターフェースの設計

DynamoDB のテーブルクエリーを使用する場合、以下の情報が必要です。

limit: number;
lastEvaluatedKey: {
  partitionKey: string;
  sortKey: string;
}

これを API で受け取る場合、LastEvaluatedKey がオブジェクト構造であるため、GET リクエストの URL に直接指定できない問題があります。

素直に設計した場合

最初に考えられる設計は、LastEvaluatedKeyの属性を分解し、それを GET リクエストのクエリパラメーターに指定する方法です。

リクエスト

  • limit: 必須
  • partitionKey: 任意
  • sortKey: 任意
GET /items?limit=<limit>&partitionKey=<partitionKey>&sortKey=<sortKey>

レスポンス

{
  "items": [
    ...
  ],
  "lastEvaluatedKey": {
    "partitionKey": "xxx",
    "sortKey": "yyy"
  }
}

しかし、この設計には以下の問題があります。

  • クエリーのパラメータが多くなるため、API のリクエストが複雑になります。
  • DynamoDB のキー構造のキー構造は内部的な情報なので、可能な限り露出させたくありません。

これらの問題を解決するために、以下のように設計を改善しました。

改善された設計

改善された設計は、 LastEvaluatedKey を Base64 エンコードした文字列を GET リクエストのクエリパラメーターに指定する方法です。

必要に応じて、エンコードした文字列をさらに暗号化できます。

リクエスト

  • limit: 必須
  • lastKey: 任意(Base64 エンコードした文字列)
GET /items?limit=<limit>&lastKey=<base64_encoded_lastEvaluatedKey>

レスポンス

{
  "items": [
    ...
  ],
  "lastKey": "base64_encoded_lastEvaluatedKey"
}

この設計には以下のメリットがあります。

  • API のインタフェースがシンプルになりました。
  • クライアント側も、DynamoDB のキー構造を意識する必要がなくなります。

ただし、Base64 エンコードは JSON オブジェクトをエンコードするためのものではないため、LastEvaluatedKey をエンコードする前に、JSON 文字列に変換する必要があります。

リクエスト仕方

以下に、提案する設計に基づいたリクエストの方法を示します。

初回のリクエスト

初回のリクエストでは、lastKeyパラメーターは指定しません。

GET /items?limit=<limit>

2 回目以降のリクエスト

2 回目以降のリクエストでは、前回のレスポンスで得られた lastKey を指定します。

GET /items?limit=<limit>&lastKey=<base64_encoded_lastEvaluatedKey>

最終ページの判定

レスポンスの lastKey がない場合、最終ページと判定し、次のページを取得する必要がないです。

処理のシーケンス図

シーケンス図

注意点

DynamoDB のテーブルクエリーの特性ですが、指定した limit 件数までデータがある場合、LastEvaluatedKey を返します。
例えば、limit が 10 件で、テーブルに 10 件のデータがある場合、LastEvaluatedKey を返します。
そのため、ちょうど最後の 1 件まで返した時、LastKey があるが、次のページを取得するとデータがないという状況が発生します。
クライアント側で、この状況を判定する必要があります。

まとめ

DynamoDB でサポートされているカーソル型のページネーションは、大規模データを扱い、頻繁にデータを書き込み又は無限スクロールでデータを表示する場合など、効率よくデータを取得できます。
しかしオフセット型と違って、ページネーションのパラメーター設定と実装が少し複雑になります。
今回は LastEvaluatedKey の Base64 化と暗号化によって、クライアントからリクエストが行いやすい形に実装ができました。
DynamoDB を採用してサービスの開発する際に、ご参考になれば幸いです。

iOS17の新機能「スタンバイ」を推し活アプリに導入してみた話

アイキャッチ

株式会社ブックリスタ プロダクト開発部の酒井です。

2023年9月18日にiOS17が正式リリースされました。
メッセージやAirDropが強化されたり、ウィジェット上でボタン操作などができる「インタラクティブウィジェット」が実装されるなど、いくつかの新機能が追加されました。
その中でも「スタンバイ」という新機能に着目し、弊社で開発している推し活アプリ「Oshibana」に実装してみることにしました。

過去にはiOS16の新機能「ロック画面ウィジェット」に関する記事も執筆していますので、そちらもご一読ください。

iOS16の新機能「ロック画面ウィジェット」を推し活アプリに導入してみた話
https://techblog.booklista.co.jp/entry/2022/09/13/110000

スタンバイについて

スタンバイとは、スマホを横向きに置くことでウィジェットを常時表示させておくことのできる機能のことです。
ウィジェットは小サイズのものが2つ配置できるようになっており、カレンダーウィジェットを表示させれば卓上カレンダーのようになり、時計ウィジェットを表示させれば置き時計のような役割を担うことができます。
ウィジェットは小サイズであれば種類を問わず表示させることができ、もちろんOshibanaで実装されているウィジェットも全て配置できます。

スタンバイ1

使い方は以下の通りです。

  • 設定アプリからスタンバイを選択し、「スタンバイ」をONにする
  • 充電中のスマホをロック状態にし、横向きに立てる(スマホスタンドがあると一番良いです)
  • しばらくするとスタンバイの画面になる
  • 表示されているウィジェット部分を長押しすると、ウィジェットを変更できる
  • ウィジェット変更画面の左上の+ボタンを押すことでウィジェットを追加できる

スタンバイ2
スタンバイ3

スタンバイの実装方法

スタンバイの実装方法ですが、実は特別なコーディングを行う必要はありません。
iOS17のSDKをサポートしているXcode15以降でビルドすれば使用可能になります。

ただし、iOS17ではウィジェットのViewの仕様にいくつか変更点があり、Xcode15でビルドすると必然的に仕様変更の影響を受けます。
今回スタンバイを使用するにあたり、iOS16以下の端末でOshibanaのウィジェットが今まで通り動くようにするため、いくつかソースを修正する必要がありました。
※Xcode14以前でビルドしていればiOS16の互換性でウィジェットが動きますが、スタンバイにアプリが表示されないため、Xcode15へのバージョンアップが必須です。

まず、ウィジェットがiPhoneのホーム画面以外の様々な場所へ配置できるようになったことで、ウィジェットの背景を各画面の背景に合わせる指定が必要になりました。
よって、ビューの背景に関する定義であるcontainerBackground(_:for:))を追記する必要があります。

今回は特に背景の指定はないため、以下のように記述します。

var body: some WidgetConfiguration {
    return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
        return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
            .containerBackground(for: .widget) {
                EmptyView()
            }
    }
}

これの指定がないと、ビルドは通るのですが、以下のようなエラーがウィジェット上に表示されて動かないという事象が起こります。

スタンバイ4

更に、iOS17からウィジェットにマージンが追加され、元々のレイアウトが崩れてしまうという事象も発生します。

スタンバイ5

余分なマージンを取り除くには、以下のようにcontentMarginsDisabled())を指定し、デフォルトのコンテンツマージンを無効にする必要があります。

var body: some WidgetConfiguration {
    return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
        return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
    }
    .contentMarginsDisabled()
}

これらはiOS17固有の事象のため、#available(iOS 17.0, *)でiOS16以前と動きを分ける必要があります。

修正したソースの全体像は以下の通りです。

struct oshibana_widgetSmall: Widget {
    let kind: String = "oshibana_widget_small"
    var body: some WidgetConfiguration {
        if #available(iOS 17.0, *) {
            return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
                return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
                    .containerBackground(for: .widget) {
                        EmptyView()
                    }
            }
            .configurationDisplayName("小サイズ")
            .description("設置したいウィジェットを選択しましょう")
            .supportedFamilies([.systemSmall])
            .contentMarginsDisabled()
        } else {
            return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
                return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
            }
            .configurationDisplayName("小サイズ")
            .description("設置したいウィジェットを選択しましょう")
            .supportedFamilies([.systemSmall])
        }
    }
}

これらに関してはWWDC2023のBring widgets to new placesというセッションでも詳しく解説されています。
https://developer.apple.com/videos/play/wwdc2023/10027/

感想

今回Oshibanaにスタンバイを実装してみましたが、思った以上に簡単に追加できました。
Oshibanaは推し活アプリであるため、常に推しに関するウィジェットが画面に表示されていることの需要も高いと感じています。
Appleとしても、iPhoneのホーム画面以外にもウィジェットを表示させる対応を実施し続けていることから、ユーザーがより長くウィジェットに触れることを重要視していることがうかがえます。
今後も様々な方法でユーザーに情報を通達する手段が確立されていくと思われるので、新しい機能が発表されたら積極的に活用していけるよう心がけたいです。

新規プロジェクトをスクラム開発でローンチまで完了しておもうこと

アイキャッチ

株式会社ブックリスタ プロダクト開発部の藤井です。 現在、2023年8月1日から始まった「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」というサービスのスクラムマスターをしております。

今回は新サービスプロジェクトを立ち上げるにあたって、スクラム開発を採用した話をさせていただきます。

近年ソフトウェア開発の現場でスクラムを採用するケースをよく聞きます。

これから採用する方・採用してるけど暗中模索してる方の参考になればと考えています。

あと、このやり方は専門のひとにはスクラムじゃないと言われるものではあるかなと自覚もしているので、その様な場合、改善の意見を私まで指摘を届けていただけたら嬉しいです。

なぜスクラムを選んだのか

今回新規プロジェクトを始めるに当たって開発手法も特にきまっておらず、 課題としては次の様なことが挙げられました。

  • 初めてのZEROからプロジェクトで具体的なイメージが決まりきっていない
  • 開発期間が約一年程度しかない

この状態では完全な要件定義は難しく、暫定のまま進めてユーザーテストを実施した後に再度要望を吸い上げて修正する期間を取ることも難しいと考えました。

そのため以下の様な効果が得られることを期待してスクラム開発をとり入れてみようとなりました。

  • 短期間で細かな要求・要望に対してのリリース可能な成果物を作成可能
  • 進捗状況の把握がしやすい
  • 要求・要望が変わった場合に柔軟に対応ができる

スクラムイベントと期間について

今回私達は水曜日を始まりとした2週間スプリントで実施しました。

水曜日始まりにしたのは「月曜日と金曜日は休みな事も多いので余裕ある方が良いよね」といった考えによるものです。

参考にどんなスケジュールだったのかもあげておきます。

時間割

スクラムイベントについては参考書等に記載があるのでここでは細かく記載しません。

スクラム導入して良かったこと

スプリント完了タイミングで常に現在の完成品を見せられる

これはベストプラクティスだったなと感じました。

特にスプリント毎に機能を見せることができるため、徐々にシステムが完成していくことで業務側からのフィードバックが即時受け取れる状態を作れたのがよかったです。

実際に触って動かせる成果物があることで、「必要だった物」とのギャップを即時に確認でき新たな要望として積み上げることが容易でした。

成果物に対して褒めていただける反響もたくさんありモチベーションアップにも繋がりました。

また、ローンチ直前に外部テストベンダーを使ったアドホックテストも実施したのですがテストベンダーさんからもバグ数が少ないという評価を頂くことができました。

すべての開発サイクルに関わることができる

スクラムのスプリントでは、要件定義、設計、実装、テスト、リリースなどの一連の工程をメンバーが実行していくことになります。

そのため、経験の少ないメンバーも自ずとやったことのない工程を体験していくことになりました。

そういった経験を積むことでメンバーから「今まではサービスとしてどうすべきかをあまり考えたことがなかったが、考えることができた」という発言をもらった際はとても嬉しくなりました。

また、リファインメントでも最初期は発言するメンバーが固定化されており、どうしても経験が浅いメンバーの発言が少ない傾向にありましたが、徐々にその傾向も少なくなってきました。

発言の少なかったメンバーが率先して発言したり、質問をしてくれる様になっていきました。

スクラム導入して見えた課題

全体像の把握とスケジュール管理が難しい

そもそも開発したいことの全量が決まっていなかった初期は、プロジェクト全体としてどれくらい進んでいるのかどうかなどは把握できていませんでした。

また、全量が出てもローンチまでの期間が決まっているので開発したいもの全てが入っているプロダクトバックログのみではローンチまでの作業のみを絞り込んで管理できませんでした。

その結果、別途管理表を作成しローンチまでに消化すべきポイントのみに焦点を当てて管理をしていましたが、ローンチまでに必須なものとそうじゃないものの区別がメンバーには把握できずらいものになっていました。

数値のみの消化をメンバーに共有していたこともあり、ローンチまでに完了が本当にされるのかメンバーに不安を与えるとともにスプリント以外の期限を意識させてしまいました。

ただ実際プロジェクトローンチはお尻があるものなので、この辺りどうしたらよかったかは今後のために考えていかないとなと思っています。

スプリントレビューにステークホルダーが参加できない

これはエンジニアリングだけの問題ではないのですが、ステークホルダーの方達が専任ではないためスクラムイベント関連に出席ができてませんでした。

結果、定期的にスプリントレビューのタイミングで複数のスプリントをまとめた総合レビューを挟むことになりました。

当然そこで初めて触る方も出てくるので、色々な意見が出てきて他の優先事項を処理するため後回しになってしまった改修も発生してしまいました。

ここはスクラムを行う時にステークホルダーを含めて定期的な時間をとってもらう様にすべきであるという事を強く学べました。

チーム内、コミュニケーションの難しさ

よくあることですが、今回のチームは今まで存在していたチームではなく他チームから集められたかつ、ここ数年でエンジニアを積極採用していたのもあり特に社歴の長い人が少ない状態です。

ちなみにこの時私は入社して半年たったくらいでした。つまり、皆さんほぼ初見。

どんな人なのか、どんなスキルを持っているのかは不明の状態でした。

そこでスクラムイベントとは別に問題を解決するための会議や知識共有を目的とした会議の設定をしました。

これらの会議自体は機能しているのですが、まだまだチームとして自然な状態にはなっていないかなと感じています。

この問題は徐々に改善していっていますが、これぞといった解決策が取れていない状態です。

リファインメントがすごく難しい

リファインメントは大きな問題がたくさんありました。

分割する大きさがまちまち

プロダクトバックログアイテムがより小さく詳細になるように分割および定義していくことが必要だと考えています。

その中で、透明度が高い要望は小さく詳細なチケットができるのですが、不透明な案件は分解できずに大きなチケットのまま実施することになってしまいました。

その結果、大きなチケットに関してはスプリント期間に完了しなかったり、実作業時間が見積より小さくなることが頻発しました。

評価がどうしても絶対評価になりがち

事前にストーリーポイントで見積をし、見積数値は相対見積で求めることを決めていましたが、経験上から絶対見積をしてしまうことが多かったです。

「絶対見積=自分ならこれぐらいでできる」というものになるため、作業する人が固定化されてしまったりすることもありました。

発言者の偏りが生じる

経験豊富なメンバーが主導して話すことがおおく、控えめなメンバーの意見が埋もれがちになっていました。

空気的にも経験者が言うなら特にないと言った雰囲気もでており、質問などの発言も初期の頃は少なかったと記憶しております。

会話が発散してしまう

チーム全員のスキルレベルが同じ状態ではないので、リファインメント対象の要件を話している際にどこまですべきかどこまで考えなければいけないかよくブレていました。

考えることや話あうこと自体は間違いではないですが、1時間のリファインメント時間で1つの要件しか見積できないということがよく発生していました。

改善について

これらの課題は解消してきているものもあれば、まだ継続して発生している課題もあります。

現在は「会話が発散してしまう」に対してスケジュールに載せているリファインメント時間以外にも個人で事前にチェックする時間を設けています。

そうすることで、一度は目を通して方向性を考えることで発散しすぎる事を解消できるのではと考えています。

ローンチが完了してみて

この度2023年8月1日にAndroid、iOSに向けてコミックROLLYは無事ローンチされました。

大体開発プロジェクト自体が2022年4月から開始、エンジニアが2022年6月ごろから徐々に集まり始めたことを考えても約1年ぐらいでの開発期間で開発できたのは驚きでした。

特に感じたことは今までのプロジェクトより終盤に向けての絶望感がかなり少なかったです。

なぜなのかと振り返ってみると、やはり常にできた成果物を第三者が確認できる状態を作れていたことが大きいのかなと。

ウォーターフォール型で開発してる場合は、開発終盤になっても第三者に見てもらうことは基本なく最終のユーザーテストや受け入れテストに入った段階で初めて意見の収集ができます。

そのため、規模が大きい場合はフィードバックによる修正の大きさがそのままプロジェクトの遅れにつながることが多く怖かったのですが今回はその心配がそこまでなかったからでした。

重ねてになりますが、1つの事例として迷えるプロジェクト管理者さんに届けば幸いです。

Reader StoreでInfrastructure as Codeしてみた

アイキャッチ

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている饂飩です。

今回は、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のOS EOL対応の一環として行った、「Infrastructure as Code(以降IaC)」についてお伝えします。

IaC化検討の背景

  1. Elastic Beanstalk platforms based on Amazon Linux AMI (AL1)のEOL
    https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2022-07-18-linux-al1-retire.html

  2. Amazon Linux AMI (2018.03) のEOL
    https://aws.amazon.com/jp/amazon-linux-ami/faqs/

この2つのEOLを対応する上で最大の課題として、以下が存在していました。

  • 設計書含む過去資料がない
    手順書もないため、再構築すらできない

  • 構築担当した者が既に離任してしまっている
    既存で動いているものが正で誰も把握できていない状態になっていた

  • 手順書があるものの、荒く、環境再現できない
    そもそも手順が複雑であった

結果、一から新規構築するとなるため将来性を考え、これまで困難だった環境複製や同様のものを再構築できるようにこれを機にIaC化する運びとなりました。

IaCとは

IaC (Infrastructure as Code) は、手動のプロセスではなく、コードを使用してインフラストラクチャの管理とプロビジョニングを行うことを言います。

引用:https://www.redhat.com/ja/topics/automation/what-is-infrastructure-as-code-iac

簡単に説明すると、AWSなどのクラウド環境やOS・ミドルウェアの構築・管理・運用を私のようなインフラエンジニアが手作業で行なうのではなく、コードで管理することです。

IaCを何で行うか

まずは、IaC化を実現する上でツールを何にするかを検討しました。
今回のターゲットとしてはAWS周りがメインだったので主流としては以下があります。

  • CloudFormation
    AWSが提供している、プロビジョニングにおける自動化および構成管理のためのサービスです。
    JSONまたはYAML形式でリソースの設定を記述し、リソースの作成や設定の変更ができます。
    https://aws.amazon.com/jp/cloudformation/
  • CDK
    AWSが提供している、プログラミング言語(Python/Java等)を使用してアプリケーションをモデル化し、AWS CloudFormation を通じてプロビジョニングするツールです。
    https://aws.amazon.com/jp/cdk/
  • Terraform
    HashiCorp社によって提供されているオープンソースのツールです。
    クラウドのみならずSaaS/PaaS、オンプレミス(VMwareなど)との連携にも対応しています。
    https://www.terraform.io/

どれでも可能ではありましたが、対応期間も迫ってきていたこともあり、私が前職でも利用していたTerraformを採用しました。
なお、OS・ミドルウェアをコード化する主流なツールとしては、AnsibleやChefがあります。
Reader StoreではAnsibleでOS・ミドルウェアはコード化されていました。

IaC化までの流れ

今回私が対応した流れとしては以下となります。

コード管理

IaC化するにあたり必須になるコード管理は、アプリケーション開発で利用しているコード管理ツールにインフラ用のリポジトリーを準備しました。

設計

これが一番大変でした。
まず既存で動いているAWSリソースから次期構成図を作成しました。
そこから各種リソースで既存流用するもの、新規構築するものを選定していきました。
既存の命名規則が不明だったので、リソース命名規則も定義しました。
各種リソース作成のソースはできるだけ1モジュール1リソースにし、実際にリソース作成するモジュールは機能単位でソースをラッピングするような形をとりました。
環境単位で変わるものは環境変数を利用する方針にして、環境変数ファイルは環境単位で環境ファイルとして準備しました。
またAWSアカウントを本番と検証開発で分離するという案件も並行して実施することになったのでアカウント単位の環境変数もファイル化。
ディレクトリー構成も悩みましたが以下のような構成にしました。

├── <account>.tfvars          # <-- AWSアカウントの共通環境変数ファイル
├── <environment>             # <-- 環境ごとの機能リソース集格納
|      └─ <environment>.tfvars # <-- 環境個別の環境変数ファイル
|      └─ <function>           # <-- 機能毎のリソース作成用モジュール格納。モジュールはsourceをラッピング
└── <modules>                 # <-- リソース作成モジュールソース集格納
        └─ <source>             # <-- リソース作成モジュールソース格納
            └─ <prebuild>         # <-- 対象リソースで事前に作成しておく必要があるモジュールソース格納
            └─ <postbuild>        # <-- 対象リソースで事後に作成する必要があるモジュールソース格納

コーディング

Terraformコードをコーディングしていきます。
検証で事前構築したリソースがあればTerraformerを使って既存リソースからコードを書き出すことも可能でした。
環境変数化など手直しが必要になりますがベース作りには有効でした。

コードテスト

コードテストはterraform planを実施しコードに問題がないか、作成するリソースの予想結果を確認します。
今回はコードテスト+コード実行してテスト構築し意図したリソースが構築できるかの評価も実施しました。
1環境をベース環境として必要なコード全てテスト用に利用しReader Store全機能分のリソースをコード化していきました。

インフラ構築

ベース環境のテスト構築ができたので、環境単位でディレクトリーを準備し環境に応じた環境変数ファイルを準備しました。
あとは構築していく環境単位でterraform applyを順次実施していく形をとりました。
手作業でやると工数はそれなりにかかりますが、コード実行だけなので構築工数はだいぶ削減されました。

具体的な例でEC2構築を少しだけ紹介します。

functionコードサンプル

locals {
  configs = merge(var.acccount_configs, var.environment_configs)
}

module "batch_instance" {
  source = "../../../modules/components/ec2-single"

  configs = local.configs
  instance_configs = {
    service                       = "batch"
    instance_type                 = "t3.medium"
    root_block_device_volume_size = 30
    vpc_security_group_ids        = [local.configs.batch.security_group.id]
    private_ip                    = "${local.configs.batch.private_ip}"
  }
}

module "batch_postbuild" {
  source = "../../../modules/batch/postbuild"

  configs = local.configs
  batch_configs = {
    instance_role                  = module.batch_instance.instance_role
    instance                       = module.batch_instance.instance
    extra_block_device_volume_size = 100
    dynamodb_table_name  = "dynamo_table"
  }
}

moduleコードサンプル

#
#IAM Role
#
resource "aws_iam_role" "instance_role" {
  name               = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path               = "/"
  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
POLICY
}

resource "aws_iam_instance_profile" "instance_profile" {
  name = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path = "/"
  role = "${var.configs.environment_name}-${var.instance_configs.service}-role"
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::${var.configs.aws_account_id}:policy/${var.configs.common.log_bucket_policy.name}"
}

resource "aws_iam_role_policy_attachment" "attach_ssm_managed_instance_policy" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}


#
#EC2
#
resource "aws_instance" "instance" {
  ami                         = var.configs.ec2.plain_ami
  availability_zone           = "${var.configs.region}${var.configs.main_az}" # e.g. "ap-northeast-1a"
  ebs_optimized               = true
  instance_type               = var.instance_configs.instance_type
  monitoring                  = false
  key_name                    = var.configs.ec2.key_name
  subnet_id                   = var.configs.subnets["${var.configs.main_az}_private"].id
  vpc_security_group_ids      = var.instance_configs.vpc_security_group_ids
  associate_public_ip_address = false
  source_dest_check           = true
  iam_instance_profile        = aws_iam_instance_profile.instance_profile.name
  private_ip                  = var.instance_configs.private_ip


  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.instance_configs.root_block_device_volume_size
    delete_on_termination = true
  }

  tags = {
    "Name" = "${var.configs.environment_name}-${var.instance_configs.service}"
    "AutoStopStart" = "true"
  }
}

postbuildコードサンプル

#
# IAM Policyの追加
#

resource "aws_iam_policy" "batch_policy" {
  name        = "${var.configs.environment_name}-batch-policy"
  path        = "/"
  description = "for batch instances."
  policy      = <<POLICY
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": ["arn:aws:s3:::codepipeline-${var.configs.region}-*/*",
                         "arn:aws:s3:::codepipeline-${var.configs.region}-*"]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}", 
                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}/*"

                        ]
        },
        {
            "Effect": "Allow",
            "Action": "dynamodb:GetItem",
            "Resource": "arn:aws:dynamodb:${var.configs.region}:${var.configs.aws_account_id}:table/${var.batch_configs.dynamodb_mediainfo_table_name}"
        },
        {
            "Sid":"",
            "Effect":"Allow",
            "Action":[
                "logs:PutLogEvents",
                "logs:CreateLogStream"
            ],
            "Resource":"arn:aws:logs:${var.configs.region}:${var.configs.aws_account_id}:log-group:${var.configs.environment_name}/batch:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "SQS:ChangeMessageVisibility",
                "SQS:DeleteMessage",
                "SQS:ReceiveMessage",
                "SQS:SendMessage"
            ],
            "Resource": "arn:aws:sqs:${var.configs.region}:${var.configs.aws_account_id}:voucher-customer-linking-${var.configs.environment_name}"
        }
    ]
}
POLICY
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = var.batch_configs.instance_role.name
  policy_arn = aws_iam_policy.batch_policy.arn
}


resource "aws_ebs_volume" "extra_volume" {
  availability_zone = "${var.configs.region}${var.configs.main_az}"
  size              = var.batch_configs.extra_block_device_volume_size
  type              = "gp3"
}

resource "aws_volume_attachment" "ebs_attach" {
  device_name = "/dev/xvdf"
  volume_id   = aws_ebs_volume.extra_volume.id
  instance_id = var.batch_configs.instance.id
}


#
# CodeBuild
#

# バイナリビルド用
resource "aws_codebuild_project" "batch_code_build" {
  artifacts {
    encryption_disabled    = false
    name                   = "${var.configs.environment_name}-batch-build"
    override_artifact_name = false
    packaging              = "NONE"
    type                   = "CODEPIPELINE"
  }

  badge_enabled = false
  build_timeout = 60

  cache {
    type = "NO_CACHE"
  }

  concurrent_build_limit = 1
  description            = "for build batch"
  encryption_key         = "arn:aws:kms:${var.configs.region}:${var.configs.aws_account_id}:alias/aws/s3"



  environment {
    compute_type = "BUILD_GENERAL1_MEDIUM"

    image                       = "${var.configs.aws_account_id}.dkr.ecr.${var.configs.region}.amazonaws.com/java-gradle:5.0"
    image_pull_credentials_type = "SERVICE_ROLE"
    privileged_mode             = false
    type                        = "LINUX_CONTAINER"
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "CodeBuild"
      status      = "ENABLED"
      stream_name = "${var.configs.environment_name}-batch-build-log"
    }

    s3_logs {
      encryption_disabled = false
      status              = "DISABLED"
    }
  }

  name           = "${var.configs.environment_name}-batch-build"
  queued_timeout = 480
  service_role   = "arn:aws:iam::${var.configs.aws_account_id}:role/${var.configs.environment_name}-build-role"

  source {
    buildspec           = "container/batch/buildspec.yml"
    git_clone_depth     = 0
    insecure_ssl        = false
    report_build_status = false
    type                = "CODEPIPELINE"
  }
}

#
# CodeDeploy
#

resource "aws_codedeploy_app" "batch_deploy" {
  compute_platform = "Server"
  name             = "${var.configs.environment_name}-batch-deploy"
}


resource "aws_codedeploy_deployment_group" "batch_deploy_group" {
  app_name              = aws_codedeploy_app.batch_deploy.name
  deployment_group_name = "${var.configs.environment_name}-batch-deploy-group"
  service_role_arn      = var.configs.ci.code_deploy_role_arn

  ec2_tag_set {
    ec2_tag_filter {
      type  = "KEY_AND_VALUE"
      key   = "Name"
      value = "${var.configs.environment_name}-batch"
    }
  }

  trigger_configuration {
    trigger_events     = ["DeploymentFailure"]
    trigger_name       = "failure-notice-trigger"
    trigger_target_arn = var.configs.sns.notice_topic_arn
  }

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  alarm_configuration {
    alarms  = ["${var.configs.environment_name}-batch-deploy-alarm"]
    enabled = true
  }
}


#
# CodePipeline
#

data "template_file" "codebuild_environment_data" {
  template = file("${path.module}/templates/environment-variables.json")
  vars = {
    environment_short_name = var.configs.environment_short_name,
    environment_name       = var.configs.environment_name
  }
}


resource "aws_codepipeline" "batch_pipeline" {
  artifact_store {
    location = "codepipeline-${var.configs.region}-524718723338"
    type     = "S3"
  }

  name     = "${var.configs.environment_name}-batch-pipeline"
  role_arn = "arn:aws:iam::${var.configs.aws_account_id}:role/service-role/common-code-pipeline-role"

  stage {
    action {
      category = "Source"

      configuration = {
        BranchName           = var.configs.batch.branch_name
        ConnectionArn        = var.configs.ci.connection_arn
        FullRepositoryId     = var.configs.ci.full_repository_id
        OutputArtifactFormat = "CODE_ZIP"
      }

      name             = "Source"
      namespace        = "SourceVariables"
      output_artifacts = ["SourceArtifact"]
      owner            = "AWS"
      provider         = "CodeStarSourceConnection"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Source"
  }

  stage {
    action {
      category = "Build"
      configuration = {
        EnvironmentVariables = data.template_file.codebuild_environment_data.rendered
        ProjectName          = aws_codebuild_project.batch_code_build.name
      }

      input_artifacts  = ["SourceArtifact"]
      name             = "Build"
      namespace        = "BuildVariables"
      output_artifacts = ["BuildArtifact"]
      owner            = "AWS"
      provider         = "CodeBuild"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Build"
  }

  stage {
    action {
      category = "Deploy"

      configuration = {
        ApplicationName     = aws_codedeploy_app.batch_deploy.name
        DeploymentGroupName = "${var.configs.environment_name}-batch-deploy-group"
      }

      input_artifacts = ["BuildArtifact"]
      name            = "Deploy"
      namespace       = "DeployVariables"
      owner           = "AWS"
      provider        = "CodeDeploy"
      region          = var.configs.region
      run_order       = 1
      version         = 1
    }

    name = "Deploy"
  }
}


構築コマンドは以下になります。

  • terraform init実行
    tfファイルを格納しているフォルダーを初期化させます。
    フォルダー内直下のtfファイルが読み込まれ、必要なpluginがインターネットからフォルダーにダウンロードされます。
Initializing modules...
- batch_instance in ../../../modules/components/ec2-single
- batch_postbuild in ../../../modules/batch/postbuild

Initializing the backend...

Initializing provider plugins...
(略)

Terraform has been successfully initialized!


  • terraform plan実行
    構築前にどのようなリソースが作成(変更)されるかを事前に確認できます。
    基本的にはmodulesにコーティングしてあるリソースしか表示されませんが、念の為確認しておきます。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

--- 

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run
"terraform apply" now.


  • terraform apply実行
    Enter a value:が表示され止まるので、yesを入力してEnterキーを押下すると、リソースの構築が開始されます。
    構築進捗状況が出力されるので数分ほど待ち、Apply complete!が出力されれば構築が完了しています。
    Apply complete!の後の文言は、terraform plan時の出力結果と一緒になります。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: <yes>

module.batch_instance.aws_iam_instance_profile.instance_profile: Creating...
module.batch_instance.aws_iam_role.instance_role: Creating...

(略)

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.


terraform apply中にインスタンスIDが表示されるので、AWSコンソールのEC2画面にてインスタンスIDでフィルターし、該当するインスタンスが表示され作成されているか確認します。
EC2

インフラテスト

テストの内容としては、コードから構築されたリソースがちゃんと作成できているか、リソース設定が環境変数で指定した内容になっているか、リソース間疎通確認などを実施しました。

アプリケーション実装評価

ここからは実際にアプリケーションを作成した環境に乗せてみて画面が確認できるかを確認しました。
CI/CDテストもここで実施しました。

システムテスト

このテストはAurora1からAurora3へアップグレードした際に、全機能テスト項目をまとめていたためそちらを実施しました。
またシステムリプレイスのため性能負荷試験も実施し問題がないかも確認しました。

直面した課題・反省点

Beanstalk EOLとOS EOLでモジュール配置のディレクトリー構成を変えてしまった

ディレクトリー構成を決めたもののその配下まで詳細に決めきれてなかったため、Beanstalk EOL時とOS EOL時でモジュール配置を安易に変えてしまいました。
そのため先行リリースしたBeanstalk用のモジュール群が動作しない状態に陥ってしまいました。
結果Beanstalk用とOS用でECSリソースモジュールは分離した状態になりました。

Terraformローカル実行前提だったのでMac M1(ARM)考慮できてなかった

Terraform運用まで検討しておらずローカル実行をするようにしていました。
当初ARM環境がなかったため参画してきたメンバーがTerraform実行環境を整備したときに発覚しました。
結果としてTerraform実行環境をAWS上に準備しTerraform実行はその環境で実行する運用としました。

tfstateを外部参照できるように考慮すればよかった

tfstate(Terraformがリソースの現在の状態を管理するファイル)も、コード管理する必要がありました。
terraform実行後にマージするしかなく、コード修正しながら構築可能な状態となってしまいました。
結果ブランチのままterraform実行することになってしまっていてプルリクエストが構築後になってしまいました。
今後の検討でtfstateはS3保存できるような形にする予定です。

モジュールは1つにつき1リソース作成するものにできなかった

設計でできるだけ1モジュール1リソースとしていたものの関連するリソースを含む形になってしまいました。
構築だけを考えれば現在のままでも問題はないですが、運用を考えると他リソースまで変更される可能性もあるため徹底すればよかったなということが反省点です。
運用に関しては今後対応していく中で少しずつ改善していく予定です。

まとめ

今回EOL対応におけるIaC化についてお話ししました。

本EOL対応は私が入社してまもない頃から今まで対応してきた内容にもなります。
他にもEOL対応の中で実施した内容はあるのでどこかでまたお話しできたらと思っています。

Flutter開発におけるアプリ内課金で注意すべき点

アイキャッチ

自己紹介

はじめまして。株式会社ブックリスタ プロダクト開発部の横山です。

現在はモバイルアプリエンジニアとしてコミックアプリ「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」の開発を行なっています。

コミックROLLYとは

2023年8月1日にリリースされたiOS/Android向けのコミックアプリです。
フルカラーの縦読みコミック「webtoon」をはじめ、電子コミックをスマートフォンやタブレットで読むことができます。

コミックROLLY

https://rolly.jp/

はじめに

ここではFlutter開発において、iOS/Androidのアプリ内課金に対応したことについて話します。
その中でも、特に必要だったAndroid独自の対応について注目して話します。

アプリ内課金(消耗型)のシステム構成

アプリ内課金は、1回購入する度に消費する消耗型の課金アイテムを使用します。
システム構成については、アプリ、ストア(AppStoreおよびGooglePlayストア)、独自サーバーのよくある構成を取ります。
独自サーバーでは、ストア購入後のレシート検証および、課金アイテムの対価を付与する処理を実施します。

AppleのStoreKitのドキュメントにあるシステム構成図は以下のとおりです。

システム構成図

https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase

in_app_purchaseパッケージの導入

FlutterでiOS/Androidのアプリ内課金を共通的に実装するために、in_app_purchaseパッケージを使用しました。

https://pub.dev/packages/in_app_purchase

以下は、上記ページに記載されているサンプルコードの抜粋です。

消耗型課金アイテムを購入する

課金アイテムに対して、購入処理を開始します。

final ProductDetails productDetails = ... // Saved earlier from queryProductDetails().
final PurchaseParam purchaseParam = PurchaseParam(productDetails: productDetails);
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam);

購入処理の更新をlistenする

上記の購入処理に対し、購入成功やキャンセル、エラーなどのステータスを受け取れるため、対応した処理を書くことができます。

Future<void> _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
  for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
    if (purchaseDetails.status == PurchaseStatus.pending) {
      showPendingUI();
    } else {
      if (purchaseDetails.status == PurchaseStatus.error) {
        handleError(purchaseDetails.error!);
      } else if (purchaseDetails.status == PurchaseStatus.purchased ||
        purchaseDetails.status == PurchaseStatus.restored) {
        final bool valid = await _verifyPurchase(purchaseDetails);
        if (valid) {
          unawaited(deliverProduct(purchaseDetails));
        } else {
          _handleInvalidPurchase(purchaseDetails);
          return;
        }
      }
      if (Platform.isAndroid) {
        if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
          final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
          await androidAddition.consumePurchase(purchaseDetails);
        }
      }
      if (purchaseDetails.pendingCompletePurchase) {
        await _inAppPurchase.completePurchase(purchaseDetails);
      }
    }
  }
}

注意点

ストア購入成功後のサーバー処理の失敗を考慮したリトライ

課金アイテムの購入成功を受けて、独自サーバーでの課金アイテムの対価を付与することになりますが、付与が完了しなかった場合に備えてリトライできるような設計が必要です。

例えば、ネットワークエラーや、ユーザーがアプリを終了させてしまった場合などがあります。

ネットワークエラーであれば、その場でダイアログを表示してリトライを促すことができます。
しかし、アプリを終了した場合は、次回起動時にリトライできるような仕組みが必要です。

リトライ時に、保留状態になった課金アイテムは、別途取得できます。 但しiOSとAndroidとでは取得方法が異なることに注意です。

iOS

SKPaymentQueueWrapperを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_storekit パッケージが必要)

https://pub.dev/packages/in_app_purchase_storekit

final paymentQueueWrapper = SKPaymentQueueWrapper();
final transactions = await paymentQueueWrapper.transactions();
for (final transaction in transactions) {
    // 独自サーバーへの購入処理
    await deliverProduct(transaction);
    // 購入トランザクション終了
    await paymentQueueWrapper.finishTransaction(transaction);
}

Android

InAppPurchaseAndroidPlatformAdditionを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_android パッケージが必要)

https://pub.dev/packages/in_app_purchase_android

final androidAddition = InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final response = await androidAddition.queryPastPurchases();
for (final purchaseDetails in response.pastPurchases) {
    if (purchaseDetails.pendingCompletePurchase) {
        // 独自サーバーへの購入処理
        await deliverProduct(purchaseDetails);
        // 購入トランザクション終了
        await InAppPurchase.instance.completePurchase(purchaseDetails);
    }
}

尚、購入処理の更新をlistenするコード において、独自サーバーでの購入処理が失敗して未完了だった場合を考えます。
その場合、そのまま_inAppPurchase.completePurchase()を呼ぶと、購入トランザクションが終了してしまい、上記のリトライもできなくなってしまいます。
そのため、独自サーバーでの購入処理が失敗した場合は呼ばないように制御する必要があります。

逆に、独自サーバーでの購入処理が成功した場合には、リトライ時にもきちんと購入トランザクションを終了しましょう。

更にAndroidで注意すべき点

Androidでは購入アイテムの消費を明示的にする

先程の 消耗型課金アイテムを購入するコードbuyConsumable()において、第2引数のautoConsumeがデフォルトtrueになっています。

Future<bool> buyConsumable({
  required PurchaseParam purchaseParam,
  bool autoConsume = true,
})

しかし、Androidでは先程の 購入処理の更新をlistenするコード でもあったとおり、以下のとおり明示的にconsumePurchase()を呼ぶ必要があります。

if (Platform.isAndroid) {
  if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
    final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
    await androidAddition.consumePurchase(purchaseDetails);
  }
}

そのため、buyConsumable()においては、autoConsumeを使用せずに、以下のとおりiOSとAndroidで分けましょう。

final bool _kAutoConsume = Platform.isIOS || true;
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam,
 autoConsume: _kAutoConsume);

これをしないと、購入が成功した場合でも消費が正常に完了せずに、同じ課金アイテムを購入する際に「このアイテムはすでに所有しています」とエラーが出てしまう場合があります。

consumePurchaseでエラーが返る場合に備えてリトライ処理を追加する

前述のconsumePurchase()の対応を入れても、テスト中に端末によっては「このアイテムはすでに所有しています」とエラー表示されてしまう場合がありました。

それは、購入処理中に機内モードでオフラインにするなどの動作をした後、リトライのテストをした場合に限ります。

同じ条件において様々なOSバージョンで検証したところ、 Android10以下で発生し、Android11以上では発生しないという差がありました。

また、Android10以下でも、オンラインに復帰してからある程度時間が経っていた場合、もしくはDebugモードだった場合では発生しないこともありました。

Android10以下でエラーの詳細を確認したところ、リトライ時のconsumePurchase()において、SERVICE_UNAVAILABLE(エラーコード 2)が返っていることが分かりました。

デベロッパーサイトのリファレンスによると、 このエラーは一時的なもので、再試行で解決する類のエラーということが分かりました。

https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode

したがって、解決策として、 consumePurchase()が失敗した場合、非同期でconsumePurchase()のリトライを実施するようにした方がよいでしょう。

リトライ方法については、以下デベロッパーサイトのページを参考になります。

https://developer.android.com/google/play/billing/errors?hl=ja

さいごに

in_app_purchaseパッケージを活用して、iOS/Androidそれぞれのアプリ内課金を共通的に書くことができました。
しかし、それと同時にOS固有の処理が必要な部分もあり、その処理についての理解も必要だということも分かりました。
今回は消耗型の課金アイテムについて触れましたが、自動更新サブスクリプションであれば更に複雑度も増すことでしょう。
自動更新サブスクリプションの対応機会がありましたら、また注意点をシェアできればと考えています。
この記事が、これからFlutterにてアプリ内課金を対応される方の参考になれば幸いです。

ショートマンガ創作支援サービスに画像生成 AI(DALL·E 2)を導入した話

アイキャッチ

初めまして。

株式会社ブックリスタで、ショートマンガ創作支援サービス(YOMcoma)のフロントエンド開発を担当している前田と申します。

今回 YOMcoma に、DALL·E 2 を導入しましたので経緯も含めてお話させて頂きます。

最近はYOMComaコミック機能がリリースされました。

YOMcomaコミック機能とは、投稿したマンガを紙のコミックとして販売できる機能です。

アプリからご購入できますので、ぜひ一度見ていただけると嬉しいです。

目次

DALL·E 2 とは

今回導入させていただいた DALL·E 2 について、簡単に説明させていただきます。

昨今、様々な生成 AI が誕生していますが、その中でも注目度が高い画像を生成する AI です。

画像生成が注目され始めたのは Nvidia の研究者によって開発された StyleGAN が登場した 2019 年頃です。

そのクオリティの高さから、AI による画像生成が注目されていきました。

そんな中、2021 年 1 月、AI の研究組織である OpenAI が 2021 年 DALL·E を発表し、その後 2022 年 4 月に DALL·E 2 を発表しました。

テキストを入力するだけで、その内容に近い画像が作られることで大きな反響を呼びました。

そして 2022 年 11 月に DALL·E 2 の API が開発者向けに提供されました。

スクリーンショット (DALL·E 2 によって作成された様々なタッチの画像)

導入経緯

YOMcoma は、作品を読んだ際に閲覧者は「いいね」や「シェア」ができます。

その際、投稿者はお礼メッセージと合わせて画像も追加できます。

ただし、この画像は投稿者が YOMcoma のために作る必要がありました。

この画像を AI によって作成できたら、投稿者の負担が少し減るのではないかと思いました。

そこで、DALL·E 2 の導入を提案し実装させていただくことになりました。

DALL·E 2 の API について

DALL·E 2 の API には、下記のパラメータが用意されています。

curl https://api.openai.com/v1/images/generations \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "prompt": "A cute baby sea otter",
    "n": 2,
    "size": "1024x1024",
    "response_format": "b64_json",
    "user": "user123456"
  }'
名前 説明
prompt 生成したい画像のテキスト説明です。最大長は1000文字です。
n 生成する画像の数です。1から10の間で指定します。デフォルトは1です。
size 生成される画像のサイズです。256x256、512x512、または1024x1024のいずれかを指定します。デフォルトは1024x1024です。
response_format 生成された画像の返される形式です。urlまたはb64_jsonのいずれかを指定します。デフォルトはurlです。
user エンドユーザーを表す一意の識別子です。これにより、OpenAIが監視し、乱用を検出するのに役立ちます。

画像生成の API を利用するに辺り大きく 2 つの点に注意する必要があります。

  • prompt の記載は英語ではなくてはなりません。
  • 公序良俗に反する使い方をするとエラーになり、最悪アカウントを停止される可能性があります。

ただし、こういった注意点を投稿者が理解しながら作るにはハードルが高いと思いました。

なので、こちら側で公序良俗に反しない prompt のパターンを用意し、投稿者に選んでもらうようにしました。

下記がその選択をする UI になります。

そして「描いて欲しいイメージのキーワード」と「イメージのスタイル」を選択します。

最後に生成ボタン(🤖)のアイコンを押す事により、選択した画像を生成できます。

予め用意されたパターンを選ぶだけなので、英語で入力する必要もなくなります。

prompt について

prompt を送信する際に、対象物をどのように書かせるかを具体的に書くとよりイメージに近い画像を生成出来ます。

例えば、単純に「sky」と prompt を送信した場合、下記のようなリアルな絵になります。

これを例えば「sky, oil painting」と入力すると、油絵の空になりました。

ゲームのような空を描かせたい場合は「sky, digital art, cyberpunk」と入力するとゲームのワンシーンのような絵が作られました。

このように「対象物, 対象物の条件, 対象物の条件...」と描いていくとよりイメージに近い画像が作れますので、ぜひお試し下さい。

また他の画像生成 AI にも同じ prompt 「sky, digital art, cyberpunk」を送信し比較してみたので、ぜひ参考にして下さい。

Midjourney

Stable Diffusion

Novel AI

Adobe Firefly

ユーザーからの反響

画像生成を使ったユーザーから「今後、もし自分の絵柄を反映した画像が生成できるようになったら、非常に魅力的だと思いました」などのフィードバックが来ておりました。

今後も投稿者の反応やご意見をもとに、創作に役立てるような仕組み作りをしていきます。

まとめ

会社としてトレンドの AI をこうやって積極的に導入できた事は、非常にプラスだと思いました。

2022 年の末は ChatGPT が話題になり、2023 年の 2 月には ChatGPT Plus が登場し、今年は AI 元年になるとも言われています。

実は今回 DALL·E の話だけでしたが、その GPT 使用した機能も追加しております。

こちらも次回書ければと思っています。

YOMcoma は 0→1 で作っており、様々な機能を試しに導入し、ユーザーからのフィードバックを得て日々改善を繰り返しています。

今後ともご支援賜りますようお願い申し上げます。

以上、読んでいただきありがとうございました。