エブリーエンジニアブログ エブリーエンジニアブログ

IAP, IABレシートとユーザー状態の管理について

DELISH KITCHENの定期購読

こんにちは、DELISH KITCHEN開発部でバックエンド開発を担当している南です。 主にDELISH KITCHENのプレミアムユーザー向けの機能の開発を行っております。

DELISH KITCHENでは、人気順検索、プレミアムレシピ(ダイエット、ヘルスケア、美容・健康、作りおき)、 プレミアム献立など、さまざまな機能を提供するプレミアムサービスという定期購読(サブスクリプション)商品を販売しております。 プレミアムサービスは、おもにiOSやAndroidのプラットフォーム上で管理、販売されておりDELISH KITCHENアプリ内から購入できます。

ここではiOSの課金をIAP(In-App-Purchase), Androidの課金をIAB(In-App-Billing)と呼んで区別したいと思います。

IAPとIABとDELISH KITCHEN

iOSやAndroidのプラットフォームに対してAPIを実行することでそれぞれ課金状態を表したレシートを取得できます。 IAPレシートもIABレシートも課金状態を表現するものという点では共通しているのですが、 表現の仕方がことなるためDELISH KITCHENのサーバー側で違いを吸収する必要があります。

IAPとIABの課金状態について

IAPもIABもレシートが表現する課金状態はほぼ同じですが、IABにのみ一時停止という状態があります。

課金状態名と、それがDELISH KITCHENにおいて、どのような状態かを説明した表です。

AndroidのIABレシート

IABレシートの構造はシンプルで、現時点の購読状態のみ返します。 購読が更新されれば、expiryTimeMillis の日時が増加し、支払いに関して変化がおきたら paymentState の値が変化します。

{
    "kind": "xxxxxxx",
    "startTimeMillis": 1111111111111,
    "expiryTimeMillis": 2222222222222,
    "autoRenewing": true,
    "priceCurrencyCode": "JPY",
    "priceAmountMicros": 480000000,
    "countryCode": "JP",
    "developerPayload": "",
    "paymentState": 1,
    "cancelReason": 0,
    "userCancellationTimeMillis": 0,
    "orderId": "GPA.0000-0000-0000-0000",
    "linkedPurchaseToken": "",
    "purchaseType": 0
}

IABレシートとユーザー状態

レシートの値からユーザーの課金状態を把握する必要があります。 IABは返す情報がシンプルで、情報と状態を結びつける資料も整備されているので判別することが簡単です。 一方で履歴のような過去の情報が一切ないため、状態の変遷をAPIから知る方法がありません。

Google Play の課金システム > 定期購入を販売する

(*) 一時停止状態とはユーザーがPlayストアの定期購読一覧から指定した期間だけ購読を中断して、期間がすぎたら再び自動再開する仕組みです。

AppleStoreのIAPレシート

一方でIAPレシートは、IABレシートと比べると構造が複雑で情報も多めです。

latest_receipt_infoには定期購読商品の購入履歴が含まれています。 履歴の1つ1つには、「どんな商品を購入したか?」、「何時購入したか?」、「何時期限切れになるか?」といった変化しない情報が含まれています。 (例外として返金キャンセルが発生すると履歴の値が変化します)

また状況に応じて刻々と値が変わるpending_renewal_infoという項目があります。 pending_renewal_infoからは「次回の更新で購入する予定の情報」、「定期購読を継続するか否か」、「期限切れになった理由」、といった状況に応じて変化する情報が含まれています。

{
    ...
    "latest_receipt_info": [
        {
            "quantity": "1",
            "product_id": "delishkitchen",
            "transaction_id": "111111111111111",
            "original_transaction_id": "111111111111111",
            "purchase_date_ms": "1629307052000",
            "purchase_date": "2022-02-01 07:00:00 Etc/GMT",
            "purchase_date_pst": "2021-02-01 00:00:00 America/Los_Angeles",
            "original_purchase_date_ms": "1643698800000",
            "expires_date_ms": "1646118000000",
            "expires_date": "2021-03-01 07:00:00 Etc/GMT",
            "expires_date_pst": "2021-03-01 00:00:00 America/Los_Angeles",
            "cancellation_date_ms": "0",
            "cancellation_date": 0,
            "cancellation_date_pst": 0,
            "web_order_line_item_id": "333333333333333",
            "is_trial_period": "true",
            "is_in_intro_offer_period": "false",
            "promotional_offer_id": ""
        },
        {
            "quantity": "1",
            "product_id": "delishkitchen",
            "transaction_id": "222222222222222",
            "original_transaction_id": "111111111111111",
            "purchase_date_ms": "1646118000000",
            "purchase_date": "2022-03-01 07:00:00 Etc/GMT",
            "purchase_date_pst": "2021-03-01 00:00:00 America/Los_Angeles",
            "original_purchase_date_ms": "1629307053000",
            "expires_date_ms": "1648796400000",
            "expires_date": "2022-04-01 07:00:00 Etc/GMT",
            "expires_date_pst": "2021-04-01 00:00:00 America/Los_Angeles",
            "cancellation_date_ms": "0",
            "cancellation_date": 0,
            "cancellation_date_pst": 0,
            "web_order_line_item_id": "444444444444444",
            "is_trial_period": "false",
            "is_in_intro_offer_period": "false",
            "promotional_offer_id": ""
        }
    ],
    "pending_renewal_info": [
        {
            "expiration_intent": "",
            "auto_renew_product_id": "delishkitchen",
            "original_transaction_id": "111111111111111",
            "is_in_billing_retry_period": "",
            "product_id": "delishkitchen",
            "auto_renew_status": "1"
        }
    ]
}

IAPレシートとユーザー状態

IAPレシートは情報が多めですが、レシートからユーザーの状態を把握する際は以下の情報を用いています。

  • latest_receipt_infoの最新のレシート
    • is_trial_period: 無料トライアルか否か
    • expires_date_ms: 有効期限の日時
    • cancellation_date_ms: 返金した日時
  • pending_renewal_info
    1. auto_renew_status: 購読を継続するか否かを表す。
    2. expiration_intent: レシートが期限切れになった理由を表す。期限内は常に空
    3. is_in_billing_retry_period: 支払いリトライ中か否かを表す。ExpirationIntent=2以外のときは空
    4. grace_period_expiration_date: 猶予期間の期限
    5. auto_renew_product_id: 次回の更新に購入するプロダクトID

課金状態と注意点

猶予期間保留中(支払いリトライ状態)一時停止の扱いは気をつけないといけません。

猶予期間

期限が切れたユーザーを引き続き課金状態として扱うため、IABではexpiryTimeMillisの日時が自動的に伸びます。 一方IAPでは、latest_receipt_infoのexpires_date_msは伸びません。代わりに、grace_period_expiration_date_msに猶予期間の日時が入ります。

保留中(支払いリトライ状態)

猶予期間後も支払いに失敗しつづけている状態です。 期限切れになり無料ユーザー状態となりますが、一定期間(デフォルトでiOSは60日間、Androidは30日間)支払いをリトライし続けます。 リトライによって支払いが成功すると購読状態に戻りますが、一定期間以上失敗し続けると、プラットフォームが自動的に解約状態にしてリトライをやめます。 また支払いリトライ期間中に解約することもできます。

一時停止

IAB特有のユーザー状態です。こちらも一度期限が切れるため、一見解約したように見えます。 しかし指定した期間をすぎると何事も無かったかのように購読を再開するため、一時停止状態中だと判別できていないと解約したユーザーが再び戻ってきたかのように見えてしまいます。 また定期購読一時停止中に解約することもできます。

定期購読と状態管理

Choosing a Receipt Validation Technique

こちらで述べられている通り定期購読状態を適切に扱うにはサーバーで購読状態を管理し、同期する必要があります。 ですが、これだけでは下記のようなユーザーの行動の変遷を追うことはできません。 ユーザー状態をと経緯を正確に判断するためにも、レシートの履歴をサービスのサーバー側で保存することも大切です。

  • ユーザーが猶予期間中になっていたか?
  • 保留中から戻ったのか?、それともキャンセルしたのか?
  • 一時停止から戻ったのか?、それともキャンセルしたのか?

まとめ

定期購読の難しいところでも述べられておりますが、 一見単純そうにみえる定期購読ですが、正しくやろうとすると実は面倒なことが多いです。 またIAP、IABの仕様追加にも追従していく必要がありサーバー側の保守コストがかかります。

ですがDELISH KITCHENのプレミアム機能を多くの方に提供し続けるためにも定期購読の管理・アップデートを続けていきたいと思っております。

参考資料

  1. Google Play の課金システム > 定期購入を販売する
  2. App Store Receipt Data Types
  3. Choosing a Receipt Validation Technique
  4. Question About Ios Receipt Fields Addition on July 19 2017
  5. App Store の In-App Purchase の Grace Period対応
  6. アップルはApp Storeのサブスク期限切れに「猶予期間」を導入
  7. Engineering Subscriptions(WWDC 2018)
  8. Auto Renewing Subscriptions for iOS Apps

Next.js + useForm/zod で楽をする管理サイト作り

f:id:siukaido:20220325141415p:plain

こんにちは。TIMELINE開発部の齊藤です。好きなエディタはEmacsです。社内の一部エンジニアからは珍獣扱いされてますが、Emacsは最強のエディタなので20年近く愛用しています1

さて、皆様は日頃のサービス運用に、社内向けの管理サイトなどを作っているかと思われますが、弊社でもご多分に漏れず管理サイトを用意して、日々の運用を行なっております。

この管理サイトの出来不出来によっては、運用コストも大きく変わったりするので、案外重要なものだったりするのですが、作るのは正直めんどくさいです。

ユーザさんにお見せするサイトと異なり、MAUは一桁ぐらいですし、いいものを作っても誰かに誉められることも少ないので、正直めんどくさいです(大事なことなのでry

なので、めんどうなことは少しでも楽をしつつ、それでいて運用事故/コストを少しでも減らせられるようにがんばっていたりします。

序文

私が所属しているTIMELINE開発部では、「au payマーケット」アプリで提供している「ライブTV」の運用/開発などを主に担当しています。

コロナ禍でライブコマースがEC市場で再燃。複数のプラットフォームで配信できる『TIMELINE』ならではの強みとは

元々、弊社ではCHECKというライブコマースサービスを運用していました。

ライブTVはその時の資産を利用しており、管理サイトなども数年前のリリース時に私が作ったのがそのまま利用されていたりします。

当時は何も考えなしに作ってたのですが、TIMELINEに合流して改めて見ると、あちらこちらでめんどくせーってところが散見されたため、そこらへんを修正していったあれやこれやを紹介させていただきます。

ビルドの自前管理がめんどくさい

webpackを用いてビルドしているのですが、元々はそのコンフィグファイルを自前管理していました。

当時は「 grunt に比べてなんて楽なんだろう...」と感動して使っていたのですが、久々に昔書いたコンフィグファイルを眺めていると、「こんなん管理するとかムリー!!」っていう感情に溢れてしまうほどめんどくさいものでした。

そこで、MAMADAYSで採用していた Next.js へ全面的に載せ替えることにしました。

Next.js の利点として SSR/SSGによるものがよく挙げられますが、管理サイトのフレームワークとして採用する利点はそこではなく、「ビルド管理をNext.js に任せられる。また、それに伴う各種恩恵(後述)に与れる」ところです。

もちろん載せ替える手間はあります。しかし、今後発生するであろうめんどくささと比べたら微々たるものですし、元々が React で書いていたので最低限の修正だけで載せ替えることができました。

これにより next build するだけでビルドしてくれます。コンフィグを自前管理しなくてもいい2だなんて、神!

さらには、開発時のホットリロードも独自で書いてたのですが、それすらも next dev するだけでやってくれます。至れり尽くせりすぎる!

開発効率は格段に上がり、細かい修正とかへのストレスもだいぶ軽減されました。

ルーティングがめんどくさい

元々は react-router-dom を使って、下記のような方法でルーティングを行っていました。

import { HashRouter, Switch, Route } from 'react-router-dom';
import Index from './containers/Index';
import Hoge from './containers/Hoge';

const routes = [
{
    path: '/',
    exact: true,
    component: Index,
  },
  {
    path: '/hoges,
    exact: true,
    component: Hoge,
  },
];

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Switch>
          {routes.map(({ path, exact, component }) => (
            <Route key={path} path={path} exact={exact} component={component} />
          ))}
          <Route render={(props) => <NoMatch {...props} /> } />
        </Switch>
      </HashRouter>
    );
  }
}

教科書どおりな react-router-dom の使い方ですし、NestedRouteing などの考えはものすごく良いのですが、管理サイトでそこまでやるメリットが思いつきませんでした。

むしろ、これだとページを追加するたびに routes を修正しないといけないし、path とファイル名が一致しないケースがあったりと、めんどくささが満載です。

ですが、Next.js に載せ替えたため、pages 以下にファイルを置くだけで、ルーティングをよしなにしてくれます。

だいぶめんどくささが軽減されましたし、path とファイル名が一致してるだけで、ものすごく気分が楽になります。

コンポーネント名を覚えるのがめんどくさい

JavaScript は型がなく厳密な書き方をせずとも動くので楽っちゃ楽なのですが、型がないがゆえに補完をうまくしてくれません。

そのため、ファイル名や位置とかをある程度、脳内メモリに格納した上で開発しないといけないわけですが、私も本厄を迎える歳となってしまい細かい記憶力に心配がでてくるようになってきました。

TypeScript であれば、最強のエディタであるEmacsがよしなに補完してくれる3し、import とかも気にせずに済むんですが...。

という悩みも、Next.js に載せ替えたことにより、tsconfig.json を置くだけでTypeScript化は完了です。

無事に加齢による衰えをシステムでフォローしてくれようになり、高齢化対策も万全です!

デザインがめんどくさい

cssが苦手です。書きたくないです。レスポンシブデザインとかになると、めんどくささに溢れてます。

なので、管理サイトは基本的に Bootstrap を使ってデザインしてます。

ただそれでもクラス名を覚えるのがめんどくさいので、 ReactBootstrap を使ってます。

もちろんReactBootstrapのコンポーネント名とかもEmacsが補完してくれます4

また、どうしてもcssを書かなきゃいけない個所であっても、Next.js が Sass に対応してくれるので、生でcssを書くよりも格段に楽ができます。

バリデーションがめんどくさい

元々は下記のように各項目ごとにバリデーションを書いていました。

const [form, setForm] = useState({ name: '', state: 0 });

const isValidName = () => {
  if (form.name.length < 5) {
    return false;
  }
  if (form.name.length > 10) {
    return false;
  }
  return true;
};

const isValidState = () => {
  if ([0, 1].includes(form.state)) {
    return false;
  }
  return true;
};

const onSubmit = (e) => {
  e.preventDefault();

  if (!isValidName() || !isValidState()) {
    // エラーハンドリング
    return;
  }
  // 正常処理
};

まー、めんどくさい。しかも、漏れも生じまくる。入稿してくれる方の職人芸もありつつの、事故回避でした。

そこで zod を用いることにしました。 zodに関しては uttkさんのこの記事 が秀逸です。めちゃくちゃ参考にしました。

上記の例をzodを使って書き直すと

const schema = z.object({
  name: z.string().min(5).max(10),
  state: z.union([z.literal(0), z.literal(1)]),
});

const [form, setForm] = useState({ name: '', state: 0 });

const onSubmit = (e) => {
  e.preventDefault();

  try {
    f = schema.parse(form);
  } catch (e) {
    // エラーハンドリング
    return;
  }
  // 正常処理
};

意識するのはzodの定義のみで、非常にわかりやすくなりました。

しかも、zodからTypeScriptの型も吐き出せます

type IForm = z.infer<typeof schema>;

// IForm = type {
//   name: string,
//   state: 0 | 1,
// }

なので、一石二鳥!使わない手はないです。

フォームがめんどくさい

運用の大部分を占めるのが入稿作業だと思います。

これも元々はお見せするのも恥ずかしいレベルのオレオレフォームを作っていたのですが、useForm を使うようにしました。

zodと組み合わせるとすげー便利ですし、すっきりさせることができました。

const methods = useForm<IForm>({
  resolver: zodResolver(schema),
  defaultValues: {
    name: '',
    state: 0,
  },
});

const onSubmit: SubmitHanlder<IForm> = (data) => {
  // 正常処理
};

跋文

こんな感じで手を抜けるところは抜いて、それでいて運用コストを少しでも下げられるような改善を日々行っています。

また、手を抜くことにより、理解する箇所を極限まで減らしていくことにもつながるので、普段フロントエンドを書いていないエンジニアでも、運用サイトの更新ができるという面もあったりします。


  1. Emacsは エディタではなくOSである という方もおられますが、ここではエディタとして扱わせていただきます

  2. もちろん細かい設定をいじりたいときは修正する必要がありますが、そうだとしても格段に管理が楽になりました

  3. だいたいのエディタでやってくれます…

  4. だいたいのエディタでやってくれます…

swagとecho-swaggerを使ったSwagger UIでの開発談

はじめに

こんにちはMAMADAYSバックエンドチームのrymiyamotoです。最近エルデンリングを遊び倒しています。

MAMADAYSではアプリとWebで利用しているAPI(golang)の仕様をドキュメント化するためにSwaggerを利用しています。

導入をしてから3年以上経過したため、APIの開発運用を進める中で出てきた課題点への施策を綴っていこうと思います。

そもそもSwaggerとは?

SwaggerはOpenAPIというRESTful APIの仕様を記述するためのフォーマットを使用したツールで、仕様が文章化されることで開発者や関係者での認識が取りやすくなります。

動作環境

MAMADAYSではSwaggerの利用にあたって以下のツールを使っています。

ツール名 用途 バージョン
swag ドキュメントの自動生成 v1.8.0
echo-swagger Swagger UIの表示で利用(ドキュメントの可視化) v1.3.0

Swaggerをそのまま使う分にはyamlを表記するだけですが、MAMADAYSではドキュメントを自動生成するための swag を使っています。 swag では定義したstructの型に合わせてドキュメントを生成するのでyamlを直接手で変更する必要がなく楽です。

また生成されたドキュメントのままだと視覚的に分かりにくいため、Swagger UIを表示できるように echo-swagger を利用しています。

以下MAMADAYSので表記に合わせた簡易的な例です。 (goのバージョンは1.17.8です)

package main

import (
    "net/http"

    _ "github.com/rymiyamoto/swagger-test/docs"

    "github.com/labstack/echo/v4"
    echoSwagger "github.com/swaggo/echo-swagger"
)

type (
    Response struct {
        Int64  int64  `json:"int64"`
        String string `json:"string"`
        World  *Item  `json:"world"`
    }

    Item struct {
        Text string `json:"text"`
    }
)

// @title         example
// @version       1.0
// @license.name  rymiyamoto
// @BasePath      /
func main() {
    e := echo.New()

    e.GET("/swagger/*", echoSwagger.WrapHandler)
    e.GET("/", hello)

    e.Logger.Fatal(e.Start(":1323"))
}

// hello godoc
// @Summary  Hello World !
// @ID       HelloWorldIndex
// @Tags     HelloWorld
// @Produce  json
// @Success  200  {object}  Response
// @Router   / [get]
func hello(c echo.Context) error {
    return c.JSON(http.StatusOK, &Response{
        Int64:  1,
        String: "example",
        World: &Item{
            Text: "hello world !",
        },
    })
}

※go.modとgo.sumは省略しています

$ go install github.com/swaggo/swag/cmd/swag@v1.8.0
$ swag init
$ go mod tidy
$ go run main.go

NULL許容の値を表現する

sql.NullStringsql.NullInt64 などのNULL値を含むデータをそのまま使うことができないため swaggertype:"XXX" で対象のキーに表現したい型を定義するかと思います。

しかしこのままだとNULL許容であるかどうかがわかりません。

方法としては2種類あるので紹介します。

descriptionを追加する

対象のキーにコメントとして書くことでdescriptionが追加でき、ここでNULL許容であるかどうかを表現してます。

Hello struct {
    NullInt64  sql.NullInt64  `json:"null_int64" swaggertype:"integer"` // nullable
    NullString sql.NullString `json:"null_string" swaggertype:"string"` // nullable
}

extensionsで任意の追加情報を付与する(echo-swagger v1.3.0では非対応)

extensions:"x-XXX"で任意の追加情報を付与することが可能です。

NULL許容を表現するにあたっては extensions:"x-nullable" で指定することにします。

Add extension info to struct field

Hello struct {
    NullInt64  sql.NullInt64  `json:"null_int64" swaggertype:"integer" extensions:"x-nullable"`
    NullString sql.NullString `json:"null_string" swaggertype:"string" extensions:"x-nullable"`
}

ただし echo-swagger(v1.3.0) 上では表示できないため、出力されたyamlをSuwagger Editor上で確認する人向けです。

(表示がうまくいかないのは、依存packageである swaggo/files の内部で保持しているファイルが古そうです)

同一リソース名を扱う

同一リポジトリ内でAppやWeb・Dashboard等でAPIを作成している場合、リソース名が重複します。

このとき swag 側で全体のパスを含めた構造体名に変更してくれますが、その表記が長く冗長になってしまいます。

単純に1つしかSwaggerを利用しなければ気にすることはありませんが、表記が長くならないようにそれぞれprefixを足して見通しを良くしています。

// DashboardHoge 内部向けDashboard用
DashboardHoge struct {
    Text    string    `json:"text"`
    StartAT time.Time `json:"start_at"`
    EndAT   time.Time `json:"end_at"`
}

// Hoge App用
Hoge struct {
    Text string `json:"text"`
}

structでリクエストのbodyを表現する

bodyを扱う場合に各APIのコメントに以下のように記載すればよいです。

// post godoc
// @Summary  Hello World !
// @ID       HelloWorldPost
// @Tags     HelloWorld
// @Produce  json
// @Param    title        body      string  true   "タイトル"
// @Param    description  body      string  false  "説明"
// @Success  200          {object}  Response
// @Router   / [post]
func post(c echo.Context) error {
    // ...
}

しかしこの状態だと

  • パラメーターが増えると定義が面倒
  • リクエストの定義とコントローラーの定義を別階層で管理していると抜け漏れが発生しやすくなる
  • 同一のリクエストを使いまわしていると修正が冗長になってしまう

となってしまいます。

その対策として、bodyには定義しているformのstructを渡すようにしています。

こうすることで、form部分の修正のみで対応するAPIのbodyも一括で変更できるため管理が簡単になります。

// post godoc
// @Summary  Hello World !
// @ID       HelloWorldPost
// @Tags     HelloWorld
// @Produce  json
// @Param    body  body      Form     true  "request body"
// @Success  200   {object}  Response
// @Router   / [post]
func post(c echo.Context) error {
    // ...
}

Form struct {
    Title       string `json:"title"`       // require
    Description string `json:"description"` // option
}

終わりに

swag を使い続けて3年も経過すると色々と気になるところが出てくるので、利用ルールの制定多くなってきました。

理想を言えばその部分も定義できればより汎用性が出そうです。

しかし適期的にアップデート内容を確認してきましたが、少しずつOpen API 3.0の記法も使えるようになってきているのでしばらくは使っていこうと思います。

皆様良きSwaggerライフを!

ECS Fargate を検証するために ECS Exec を使用した話

f:id:hueless:20220302185515p:plain

tl;dr

  • Fargate ではホストが隠蔽されていて、EC2 のように SSH でコンソールに入って検証することができない
  • ECS Exec は十分に SSH の代用となる
  • ECS Exec の導入に必要なことはこのセクションを参照

DELISH KITCHEN on ECS

弊社では DELISH KITCHEN というサービスを運用しており、主なアプリケーションサーバは ECS の上に構築しています。

ECS には EC2 によるものと、Fargate によるものの2つの環境が存在しますが、現時点ではほぼ全てのアプリケーションサーバが EC2 の環境です。

ECS は、十分な機能を備えながらシンプルで柔軟に運用できる優れたコンテナオーケストレーションサービスです。

しかしながら、EC2 環境においては常にホストとなる EC2 インスタンスをメンテナンスする必要があります。

それほど頻繁に発生する作業ではありませんが、定期的に AMI を更新しなければなりませんし、ECS 以外にホストで動作させているものがあれば、新しい AMI でも同様に動作するかの検証が必要になります。

また、EC2 の定期メンテナンスを行なっても、基本的にサービスへの影響がないことも、どのタイミングでメンテナンスを行うべきかの判断を難しくさせます。

同じだけ人員と工数をかけるのであれば、サービスにとってプラスになるタスクを優先したいというのは当然の考えです。

必然的にこのようなタスクは後回しになりますが、何らかの理由で(多くの場合はセキュリティに起因します)本当に作業が必要になった時には、それまでに積み重なっていた問題を一度に解決しなくてはいけないような状況に陥ることすらあります。

DELISH KITCHEN on Fargate

Fargate とは、ECS 上でコンテナのホストとなる EC2 の管理を不要にし、ECS タスクの管理に集中することができるサービスです。

DELISH KITCHEN で Fargate を採用できれば、前述したようなインスタンスの管理に必要なタスクが削減でき、よりサービスの開発に注力できるようになります。

これは 2022年 エブリーの開発組織の抱負 にある 事業にこだわる開発組織 にもマッチしており、導入に向けて検証を進めることになりました。

検証を進めていく中で、一番ネックになったのはホストに SSH ができないことです。

従来の EC2 環境ではホストに SSH 後、docker exec 経由でコンテナの内部の調査ができたのですが、Fargate ではホストも docker も隠蔽されており、通常はアクセスすることができません。

最初は原始的にアプリケーション自体に printf デバッグを仕込んだりしていたのですが、あまりに効率が悪いため、ECS Exec を導入しました。

ECS Exec の評価

ECS Exec では以下のようなコマンドで shell を実行できます

aws ecs execute-command --cluster "$CLUSTER" --task "$TASK_ID" --container "$CONTAINER_NAME" --interactive --command sh

shell だけ使うならほとんど SSH と使い勝手は変わりません。強いて言うならタイムアウトが 20 分とやや短いのと、その設定がグローバルでしか設定できないところが使い勝手が悪いでしょうか。

scp でファイルの送受信ができないことも困るタイミングがありますが、s3 を経由するのが一般的な解決法のようです。

port forwarding は ECS Exec では提供されませんが、大元の SSM では提供されています。 targetに ecs:{CLUSTER}_{TASK_ID}_{CONTAINER_NAME} を指定すれば Fargate で起動しているタスクへのセッションが開始できます。

例えばコンテナ内の nginx に port forwarding するなら

aws ssm start-session \
    --target "ecs:${CLUSTER}_${TASK_ID}_${CONTAINER_NAME}" \
    --document-name AWS-StartPortForwardingSession \
    --parameters '{"portNumber":["80"], "localPortNumber":["$LOCAL_PORT"]}'

のようにします。

Fargate への道

このように、多少の制限はあるものの、Fargate でも従来の使い勝手に近い形でアプリケーションの調査ができることが確認できました。

現在は引き続き Fargate の評価と移行準備を行なっており、次回があればその話をできれば良いと考えています。

また、ECS Exec を有効化するのにいくつか戸惑った点があったため、最後にドキュメントと共に補足としてまとめておきました。参考になれば幸いです。

ECS Exec の有効化

ECS Exec を利用するためには、事前にいくつかの準備が必要になります。 公式のドキュメント の通りにすれば良いのですが、セッションマネージャープラグインのインストールを見落としやすいため、注意が必要です。

こちらのドキュメントではプラグインのインストールに関するリンク先が英語のみですが、こちらにほぼ同様のものの日本語版があります。

以下にドキュメントの各セクションについて補足の説明を記載します。

ECS Exec を使用するための考慮事項

このセクションでは特に以下の点に注意すれば良いでしょう

  • ECS Exec は linux でしか動作しない
  • 既存のタスクは変更できない(設定後に新しく配置されたタスクから有効になる)
  • コマンドは root からのものになる
  • デフォルトのセッションタイムアウトは 20 分

ECS Exec を使用するための前提条件

aws cli は余程古いバージョンを使っていない限り問題にならないでしょう。

前述の通り、プラグインのインストールが必要になります。aws cli のセットアップ後、ドキュメントに従って各自の環境に合わせたプラグインをインストールしましょう。

Fargate のバージョンも、最近作られたサービスなら問題にならないと思いますが、2021/03/19 より以前に作られたものの場合、注意が必要です。

ECS Exec の有効化と使用

最低限 IAM の設定とサービス(ないしタスク)の設定をすれば ECS Exec を使用できるようになります。 対象がサービスの場合、設定だけでなく、タスクの再配置が必要になります。ウェブコンソールからの場合、"サービスの更新"から"新しいデプロイの強制"を有効にすると設定を変えずにタスクを再配置することができます。

現時点ではウェブコンソールからはサービスやタスクについて、ECS Execを有効にするための機能は提供されていません。ドキュメントに従い、--enable-execute-command 引数を使って aws cli から設定する必要があります。

aws ecs execute-command で SessionManagerPlugin is not found というエラーが出る場合、セッションマネージャープラグインが認識されていません。初回の設定後、シェルやマシンの再起動などが必要な場合があります。

Elasticsearchをゼロダウンタイムで再起動する

タイトル画像 - Elasticsearchをゼロダウンタイムで再起動する

こんにちは。MAMADAYSバックエンドチームのsa9sha9です。最近Diablo3にハマりました。

MAMADAYSでは検索基盤としてElasticsearch(以下ES)を利用していますが、時たま再起動を実施したいケースがあります。

本記事では、ゼロダウンタイムでのESの再起動を実現するための注意点を実際のフローに沿ってまとめたいと思います。

MAMADAYSのアーキテクチャについては以前のTechBlogをご参照ください。

tech.every.tv

おことわり

本記事でご紹介する手順については必ずしもご自身の環境とマッチするか保証しかねます。 バージョンごとの差異については、しっかりと公式ドキュメントにてご確認ください。

安直に再起動ができない理由

ESを利用する場合には複数台のクラスタ構成にするのが常かと思いますが、ESクラスタを安直に再起動してしまうとダウンタイムが発生してしまいます。

検索機能が主機能なサービスの場合には致命的な障害となってしまうでしょう。

MAMADAYSでは3台のノードでクラスタを構成していますが、それぞれのノードを1台ずつ再起動することでゼロダウンタイムでの再起動を目指します。

再起動の準備体操

1. ヘルスチェック

必ずはじめにヘルスチェックを行いましょう。

ここでステータスが yellow / red だった場合は必ず settings や shards の状態を確認し、 green ステータスにしてから再起動に臨みましょう。

  GET _cat/health

  // 結果
  epoch      timestamp cluster             status node.total node.data shards pri relo init unassign pending_tasks max_task_wait_time active_shards_percent
  1645172581 08:23:01  mamadays-es-cluster green           3         3    xxx xxx    0    0        0             0                  -                100.0%

2. ノード一覧の確認

現時点でどのノードが master node になっているかを確認しましょう。

そして再起動する際には、 master node は最後に再起動しましょう。

というのも master node が停止した場合、別のノードが master node に成り代わるわけですが、最初に master node を停止すると master node の移動が最低でも2回行われてしまいます。

master node の再起動を最後に行うことで master node の移動は必ず1回になるので、余計な移動を避けられます。

  GET _cat/nodes?v

  // 結果
  ip             heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
  192.168.1.95            37          88   1    0.11    0.07     0.02 dilm      -      ip-192-168-1-95
  192.168.2.238           27          89   1    0.00    0.00     0.00 dilm      -      ip-192-168-2-238
  192.168.3.72            24          89   1    0.04    0.01     0.00 dilm      *      ip-192-168-3-72

3. インデックスの状態を確認

再起動後にデータの欠落がないか確認するため、 docs.count を控えておきましょう。

  GET _cat/indices/index_01?v

  // 結果
  health status index    uuid                   pri rep docs.count docs.deleted store.size pri.store.size
  green  open   index_01 xxxxxxxxxxxxxxxxxxxxxx   3   1          x            0      x.xmb        xxx.xkb

4. 自動アロケーション機能を無効化

ESは replica shards の存在が確認できないと、別ノードに新たな replica shards が作成されます。ノードの再起動をかけた際に一時的に replica shards が確認できなくなるためこの処理が実行されます。

ただし、すぐに複製を開始するわけではなく、デフォルトでは1分間待機してから複製を開始します。

再起動だけなのでほぼ1分以内にノードは復旧するはずですが、何らかの理由で1分を超えてしまうと不要な複製処理が行われ膨大なI/Oが発生してしまうため、作業中は自動複製を停止しておきましょう。

ただし、primaries を指定して primary shards を他のノードへ再配置することは許可しておきましょう。

  PUT _cluster/settings
  {
    "persistent": {
      "cluster.routing.allocation.enable": "primaries"
    }
  }

  // 結果
  {
    "acknowledged" : true,
    "persistent" : {
      "cluster" : {
        "routing" : {
          "allocation" : {
            "enable" : "primaries"
          }
        }
      }
    },
    "transient" : { }
  }

5. 機械学習機能が有効になっている場合は停止

もし機械学習機能を使っているなら、一時的に停止しましょう。

MAMADAYSでは使っていないのでこの手順はスキップします。


これで再起動準備は整いました!

いざ、再起動

1. 各ノードに入ってプロセスを再起動

  sudo systemctl restart elasticsearch.service

必須ではありませんが、この時に他の生きているESノードに1sごとに _cat/shards APIでシャード状態を確認すると、 primary shard の再配置の動きが肌で感じられてとても良いです。

2. (1つ目のノードの再起動を行った後に) 試しにヘルスチェック

1つ目のノードを再起動したタイミングでヘルスチェックを行うと、ステータスが yellow になっているかと思います。

これは replica shards が一時的に切り離されたことによるもので、検索機能に影響はありません。

  GET _cat/health

  // 結果
  epoch      timestamp cluster             status node.total node.data shards pri relo init unassign pending_tasks max_task_wait_time active_shards_percent
  1645170501 07:48:21  mamadays-es-cluster yellow          3         3    xxx xxx    0    0      xxx             0                  -                 66.7%

3. (全ノードの再起動を行った後に) ノードとインデックスの確認

master nodeの位置が変わっているはずです。

  GET _cat/nodes?v

  // 結果
  ip             heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
  192.168.1.95            37          88   1    0.11    0.07     0.02 dilm      *      ip-192-168-1-95
  192.168.2.238           27          89   1    0.00    0.00     0.00 dilm      -      ip-192-168-2-238
  192.168.3.72            24          89   1    0.04    0.01     0.00 dilm      -      ip-192-168-3-72
  GET _cat/indices/index_01?v

  // 結果
  health status index    uuid                   pri rep docs.count docs.deleted store.size pri.store.size
  green  open   index_01 xxxxxxxxxxxxxxxxxxxxxx   3   1          x            0      x.xmb        xxx.xkb

4. 後処理

自動アロケーション機能を有効化

  PUT _cluster/settings
  {
    "persistent": {
      "cluster.routing.allocation.enable": null
    }
  }

  // 結果
  {
    "acknowledged" : true,
    "persistent" : { },
    "transient" : { }
  }

5. (必要なら) 機械学習機能を有効化

MAMADAYSでは使っていないのでスキップ。

6. 各方面のチェック

WebやAppから検索機能が使えるかどうかなどを確認しましょう。


これにて、ESクラスタの再起動は完了です。

困った点

ロードバランサーが再起動中のESノードにも疎通させてしまい一定の確率で接続できなくなる

MAMADAYSではESの前面にロードバランサーを置いているのですが、ロードバランサーはESノードの死活状態を即時に検知しないため、ESノードの再起動の如何にかかわらず一定の確率で疎通させてしまいます。

ESノードの再起動中はもちろん応答ができないためエラーを返してしまい、結果としてダウンタイムが発生することになります。

そのため、ロードバランサーのターゲットグループから再起動させるESノードを予め除外し、再起動中は疎通させないようにしておく必要があります。

kibanaのアクセス先のESノードが落ちると、その間だけ状態確認ができなくなる

再起動中にも _nodes APIなどで状態を確認したい場合は、curl で他のESノードのAPIを呼びましょう。

理想はkibanaが自動で障害検知して他のESノードへ接続してくれると良いんですが、どうにもそれができなかったので今回はやむなく上記の方法で対処しました。

Sniffingがそういった機能を有しているらしいのですが、うまく動作せず今回は見送りました。詳しい方がいればぜひ入社して欲しいです。

切り戻しについて

万が一何らかの障害が発生して、インデックスデータなどを失ってしまった場合に備えてsnapshotを取っておきましょう。

本記事では詳しい説明は省略しますが、バージョン違いによる互換性などは必ず確認することをお勧めします。

Snapshot and restore | Elasticsearch Guide [8.0] | Elastic

最後に

本来はこのようなトイルは自動化すべきなのですが、本件が緊急対応ということもあって手動で行うことになりました。

今後は、誰でも簡単かつ迅速かつ安全に実施できるようにAnsible化を行おうと考えています。

参考文献

https://www.elastic.co/guide/en/elasticsearch/reference/current/restart-cluster.html#restart-cluster-rolling