🎄

悩めるエンジニアに贈る!障害と故障のキホン

2024/12/25に公開

この記事は「レバテック開発部 Advent Calendar 2024」の最終日の記事です!
昨日は sotaro8181 さんの記事でした!

はじめに

🤶🎅🎄メリークリスマス🎄🎅🤶

レバテック開発部で入社1年目の山川です!

普段はレバテックプラットフォームの開発を担当しています。以前は、Scala と Akka を使用した分散システムの開発や、C# と Unity を使ったゲーム開発を行なっていました。特にアーキテクチャ設計や関数型プログラミング、Domain-Driven Design に興味を持っています。

この記事では、障害や故障に関する私の考え方を紹介します。
システム障害に悩んでいる方のお役に立てれば嬉しいです。

想定読者

  • サービスの障害を減らす方法に関心がある方
  • システムアーキテクチャ設計に興味がある方

書かないこと

次の内容はこの記事で扱いません。ご興味がある方は別途資料や記事を参照ください。

  • RetryCircuitBreaker などの設計パターンについて
  • ログ出力について
  • 異常系(エラーレスポンスとタイムアウト)の緻密なハンドリング方法

障害と故障とは

この記事では、「障害(Fault)」と「故障(Failure)」を次のように定義します:

  • 障害(Fault): システムの機能が低下または一時的に失われた状態
  • 故障(Failure): システムが機能しなくなる状態

例えば、アプリケーションから見ると、データベースが利用不可となることは「障害」と呼び、その結果としてアプリケーションが機能を提供できなくなることを「故障」と呼びます。また、データベースから見ると、ディスクの読み書き失敗は「障害」と呼び、データベース自体が機能を提供できなくなることは「故障」と呼びます。

障害(Fault)と故障(Failure)は文脈によって異なる解釈をされることがあります。興味がある方は こちらの記事 もご覧ください!

障害と故障を考える

ユーザからのお問い合わせを受け付ける API (Inquiry API と呼ぶ) を開発するという架空のシナリオをとおして、障害と故障を考えていきます。Inquiry API では次の3つの要件を実現します:

  • 要件1: 問い合わせ内容を保存する
  • 要件2: 受付完了メールを送信する
  • 要件3: 分析データを登録する

これらの要件を実現するなかで、私が考える障害と故障の扱い方について紹介します。次のようなシンプルなメソッド handleInquiryRequest で Inquiry API を提供する想定で進めます。サンプルコードには、弊社で広く採用している TypeScript を使用し、エラーハンドリングは伝統的な try catch を使います。

// ユーザからの問い合わせリクエスト
interface InquiryRequest {
  email: string; // ユーザのメールアドレス
  content: string; // 問い合わせ内容
}
// 問い合わせAPIが返すレスポンス
type InquiryResponse = "Success" | "ServerError";

// 問い合わせAPIの処理を実装するメソッド
async function handleInquiryRequest(req: InquiryRequest): Promise<InquiryResponse> {
  // このメソッドに処理を実装していく。
}

要件1: 問い合わせ内容を保存する

最初に「問い合わせ内容をデータベースに保存する」要件を実現します。
Inquiry API の実装は次のようになります:

// 問い合わせ内容をデータベースに保存する。保存に失敗した場合は例外をスローする。
declare async function saveInquiry(email: string, content: string): Promise<void>;

async function handleInquiryRequest(req: InquiryRequest): Promise<InquiryResponse> {
  try {
    await saveInquiry(req.email, req.content);
  } catch (error) {
    return "ServerError";
  }
  return "Success";
}

データベースで障害が発生した場合、Inquiry API は問い合わせ内容をデータベースに保存できず、ユーザにエラーを返します。言い替えると、データベースで障害が発生した場合、Inquiry API は故障します。

ここでは、この実装が十分な可用性を実現しているという前提で話題を進めます[1]

要件2: 受付完了メールを送信する

次に、「問い合わせを受け付けたことを知らせるメール(受付完了メールと呼ぶ)をユーザに送信する」要件を実現します。メール送信処理は、任意の外部サービスを利用すると仮定します。

受付完了メールの送信処理と問い合わせ内容の保存処理、どちらを先に実施する方が良いでしょうか?それぞれの処理順序を変えた場合に、障害が発生した時の挙動を考えてみます。主な障害は次の2つです:

  • データベース障害: ネットワーク接続の問題でデータベースと一時的に通信できなくなり、問い合わせ内容を保存できない状態が発生します。
  • メール送信障害: ネットワーク接続の問題でメールサービスと一時的に通信できなかったり、メールサービス自体が障害により利用不可となったりすることで、メールを送信できない状態が発生します。

問い合わせ内容を保存した後に受付完了メールを送信する実装

問い合わせ内容を保存した後、受付完了メールを送信してみましょう。
Inquiry API は次のような実装になります:

// 宛先(toAddress)にタイトル(title)のメールを送信する。送信に失敗した場合は例外をスローする。
declare async function sendEmail(toAddress: string, title: string): Promise<void>;

async function handleInquiryRequest(req: InquiryRequest): Promise<InquiryResponse> {
  try {
    await saveInquiry(req.email, req.content);
    await sendEmail(req.email, "お問い合わせを受け付けました");
  } catch (error) {
    return "ServerError";
  }
  return "Success";
}

データベースで障害が発生した場合、Inquiry API は、問い合わせ内容をデータベースに保存せず、受付完了メールも送信せず、ユーザにはエラーを返します。この動作は問題ありません。最初の実装と変わらず、データベースで障害が発生すると Inquiry API は故障します。

メール送信で障害が発生した場合はどうでしょうか。Inquiry API は、問い合わせ内容をデータベースに保存しますが、受付完了メールを送信せず、ユーザにエラーを返します。この動作を問題とするかは状況に依ります。

多くの場合、エラーを受け取ったユーザは、受付完了メールも受信できないため、同じ内容でリクエストを再送信します。Inquiry API がこの再送を正常に処理できると、データベースに保存した問い合わせ内容は重複しますが、ユーザからは Inquiry API が正常に動作しているように見えます。このような重複を排除する場合は、問い合わせ内容の保存処理とメール送信処理をアトミックに実行する必要があります。このアトミックな操作を実現する方法の1つとして、Transactional Outbox という設計パターンがあります。

今回はこのような問い合わせ内容の重複を許容できるとして話題を進めます。

問い合わせ内容を保存する前に受付完了メールを送信する実装

処理順序を逆にして、問い合わせ内容を保存する前に、受付完了メールを送信してみましょう。
Inquiry API は次のような実装になります:

async function handleInquiryRequest(req: InquiryRequest): Promise<InquiryResponse> {
  try {
    await sendEmail(req.email, "お問い合わせを受け付けました");
    await saveInquiry(req.email, req.content);
  } catch (error) {
    return "ServerError";
  }
  return "Success";
}

メール送信で障害が発生した場合、Inquiry API は、問い合わせ内容をデータベースに保存せず、受付完了メールも送信せず、ユーザにはエラーを返します。この動作は問題ありません。

データベースで障害が発生した場合はどうでしょうか。Inquiry API は、問い合わせ内容をデータベースに保存しませんが、受付完了メールを送信して、ユーザにはエラーを返します。この場合、問い合わせ内容を保存できていないにも関わらず、受付完了メールをユーザに送信しています。ほとんどの場合、この動作は受け入れらないでしょう。

個人的な考え

処理順序はシステムの信頼性を確保するための重要な要素です。どちらの順序を選択しても、メール送信時に障害が発生する可能性があるため、最初の実装に比べて可用性は低下します。しかし、問い合わせ内容を保存した後に受付完了メールを送信する方が、より信頼性の高いシステムを実現できると考えます。

要件3: 分析データを登録する

最後に、「分析データを登録する」要件を実現します。問い合わせリクエストは認証済みのユーザから送られることがあると想定します。この要件では、このような認証済みユーザから問い合わせがあった場合、そのユーザの問い合わせ時点での属性をユーザサービスから取得して、行動記録として分析基盤サービスに登録します。以降は、この一連の処理を「分析データの登録」と呼ぶこととします。

分析データの登録が失敗するとリクエストが失敗する実装

まずは次のような実装を考えてみます:

interface InquiryRequest {
  email: string;
  content: string;
  // 認証済みユーザからのリクエストの場合はユーザIDが設定される。
  userId: string | undefined;
}

interface UserAttributes {
  // 様々なユーザ属性が定義されていると想定する。
}

// ユーザ(userId)の属性をユーザサービスから取得する。取得に失敗した場合は例外をスローする。
declare async function fetchUserAttributes(userId: string): Promise<UserAttributes>;

// ユーザの行動記録を分析基盤サービスに登録する。登録に失敗した場合は例外をスローする。
declare async function sendUserActivity(userId: string, payload: any): Promise<void>;

async function handleInquiryRequest(req: InquiryRequest): Promise<InquiryResponse> {
  try {
    await saveInquiry(req.email, req.content);
    await sendEmail(req.email, "お問い合わせを受け付けました");

    // 分析データを登録する。
    if (req.userId != null) {
        const userAttributes = await fetchUserAttributes(req.userId);
        await sendUserActivity(req.userId, { type: 'inquiry', attributes: userAttributes });
    }
  } catch (error) {
    return "ServerError";
  }
  return "Success";
}

Inquiry API は新たに2つのサービス(ユーザサービスと分析基盤サービス)とやり取りするため、これらのサービスで障害が発生した場合、システムがどのように振る舞うかを考えてみます。これらのサービスでは次のような障害が発生します:

  • ユーザサービス障害: ネットワーク接続の問題でユーザサービスと一時的に通信できなかったり、ユーザサービス自体が障害により利用不可となったりすることで、ユーザ属性を取得できない状態が発生します。
  • 分析基盤サービス障害: ネットワーク接続の問題で分析基盤サービスと一時的に通信できなかったり、分析基盤サービス自体が障害により利用不可となったりすることで、行動記録を登録できない状態が発生します。

ユーザサービスで障害が発生した場合、Inquiry API は、問い合わせ内容をデータベースに保存し、受付完了メールを送信しますが、ユーザサービスからのユーザ属性取得に失敗するため、ユーザにはエラーを返します。ユーザは受付完了メールを受信しているにもかかわらず、エラーを受け取り、Inquiry API が故障しているように見えます。分析基盤サービスで障害が発生した場合も同様の議論ができ、ユーザからは Inquiry API が故障しているように見えます。

分析データの登録が失敗してもユーザに成功レスポンスを返す実装

受付完了メールの送信処理を最後に実行することも1つの選択肢ですが、ここでは別のアプローチを検討します。分析データの登録が問い合わせの受付に比べると重要度が低い場合、障害発生時には実行しなくても問題ないという仕様にすることができます。

分析データの登録が失敗してもユーザに成功レスポンスを返す実装は次のようになります:

async function handleInquiryRequest(req: InquiryRequest): Promise<InquiryResponse> {
  try {
    await saveInquiry(req.email, req.content);
    await sendEmail(req.email, "お問い合わせを受け付けました");
  } catch (error) {
    return "ServerError";
  }

  // 分析データを登録する。
  if (req.userId != null) {
    try {
      const userAttributes = await fetchUserAttributes(req.userId);
      await sendUserActivity(req.userId, { type: 'inquiry', attributes: userAttributes });
    } catch (error) {
      // 監視ツールなどで解析しやすくするため、
      // エラーが発生した場合は、WARN ログを出力する。
      logger.warn({ error, message: "分析データの登録に失敗しました"});
    }
  }

  return "Success";
}

この実装では、ユーザサービスや分析基盤サービスで障害が発生した場合でも、問い合わせ内容をデータベースに保存して受付完了メールを送信できれば、ユーザに成功レスポンスを返すことができます。Inquiry API は全ての機能を完全には提供できませんが、問い合わせを受け付ける機能は依然として提供し続けることができます[2]

個人的な考え

障害発生時にどの機能を実行しないかを決定することは、システムの信頼性を確保するための重要な要素です。例えば、障害発生時に分析データの登録を実行しないことで、システムの可用性が向上する可能性があります。その実装を採用するためには、「問い合わせを受ける機能が分析データの登録よりも重要である」と関係者間で合意が得ることが重要です。一方、両者が同等に重要である場合には、分析データの登録を非同期で実行するなど、より複雑ではありますが、可用性の高いシステムアーキテクチャを検討することも可能です。

おわりに

お読みいただきありがとうございました!完全に障害を無くすことは難しいですが、障害が発生してもシステムが故障しにくい設計をすることは可能です。この記事が少しでも読者のみなさまの助けになれば嬉しいです。

本記事で レバテック開発部 Advent Calendar 2024 は完走となります。
それでは、よいお年を!

脚注
  1. 目標とする可用性が達成できない場合、より可用性の高いデータベースや代替技術を検討することもあります。 ↩︎

  2. 縮退運転と呼ばれます。 ↩︎

レバテック開発部

Discussion