hiroaki's blog

技術系を中心に気になったこととかいろいろと。

AWS サービスを使って社内業務システムと連携する

はじめに

​ ​この記事は Recruit Engineers Advent Calendar 2019 の 13 日目のエントリーです。

adventar.org

​自分が担当しているプロダクトで社内の外部システムと API 連携する機会がありました。どうやって連携するのが構築や運用が楽になるだろうと検討した結果、AWS が提供しているサービスをフル活用して実現するのがよいのではと考え実際に構築運用してみました。 このブログでは具体的にどのような構成や仕組みで連携したのかを簡単に紹介していきたいと思います。

背景

自分が担当しているプロダクトは 2018 年に新規で公開したサービスなのですが、おかげさまで利用していただいているお客様がどんどん増えておりとても好調な状態です。このサービスとは別にリクルート社内が持っている別の管理システムがあるのですが、今のプロダクトを更に拡大するためにそのシステムと連携してユーザーの使い勝手を良くしていこう、という計画が去年の末頃からでてきました。

社内で使われている業務システムなので、下記のような要件が求められます。

  • 対向のシステムは社内業務の必須ツールになっている
  • そのため、日中帯の業務時間帯は高いサービスレベルが求められる
  • 僕らのサービスでのアクションは(ほぼ)リアルタイムで外部システムに連携する必要がある
  • もし何かトラブルがあったときのために修正は速ければ速いほうが良い(リリース不要で対応できるのがベスト)

上記の理由から AWS が提供している S3、SQS、Lambda を使用することで稼働しているシステムとは疎結合でリアルタイムに連携できる仕組みが構築できるのでは?と考え実際に構築してみました。

全体構成図

まず前提として、我々のサービスは Amazon Elastic Container Service(ECS) 上で Docker コンテナ形式で稼働しています。

f:id:hiro14aki:20191213143135p:plain

​今回は連携する対向システムの特性上、

  • ​​​サービスレベルを上げる必要がある
  • 正常動作の担保が最優先

という点からリクエスト送信用と外部システムからの受信用でアプリケーションコンテナを分けて構成しました。内部のアクションやアクセスしている DB は同じですが、コンテナを分けることで我々のサービス本体のデプロイライフサイクルとは完全に分離して外部システム用のコンテナを運用できるようになっています。

デプロイ構成

コンテナ自体は分かれており完全な別アプリケーションとして稼働していますが 2 つのコンテナは同じリポジトリ内で管理しています。コンテナが分かれているとはいえ同じサービスですしお互いに共通の DB も参照しています。同じリポジトリ内で管理することでお互いのアプリケーションのソースコードの乖離をできるだけ発生しないようにしています。

具体的には下記のような感じで Git のリポジトリ直下に、

を作成して管理しています。(一般的なモノレポ構成に近いはず。) お互いに共有するリソース(データモデルやメールの生成ロジックなど)は common というディレクトリを参照するようにしています。

.
├── README.md
├── api ← アプリケーション本体のソースコードディレクトリ
│   └── Dockerfile
├── batch
├── external ← 外部連携用アプリケーションのソースコードディレクトリ
│   └── Dockerfile
├── common ← どちらのアプリケーションからも共通で使うものたち
├── docker-compose.ci.yml
├── docker-compose.yml
├── drone
├── erd
├── flyway
├── frontend
├── openresty
├── resttest
├── spec
└── static_contents

※ 実際の構成とは一部、構成を省略、ネーミングを変更しています。

アプリケーションのデプロイには drone.io を使用しています。 drone.ioGithub と hook API で連携しており、特定のブランチに指定したフォーマットで tag を push すること、それぞれ対応したディレクトリの Dockerfile をベースにコンテナのビルドとデプロイが動くようになっています。

f:id:hiro14aki:20191213143517p:plain

外部システムへのリクエスト方法の検討

リクエスト方法は様々な方法がありますが、今回の構成ではアプリケーションとは分離させつつ、問題発生時にトレースがしやすくなることを想定して、「S3、SQS、Lambda を経由して外部システムの API を叩く」という方法を採用しました。具体的な処理フローとしては下記のとおりです。

  1. 自サービス内でユーザーがアクションすると連携するデータをオブジェクト形式で S3 に保存する
  2. S3 のイベント連携で SQS にイベントをプッシュする
  3. SQS にキューが積まれたタイミングで Lambda を発火する
  4. Lambda が SQS のイベント内容から S3 に配置されたオブジェクトのロケーションを取得する
  5. Lambda で S3 から取得した連携データのオブジェクトをパースして外部システムへ API リクエストを投げる
  6. リクエストが正常に完了したら S3 の対象のオブジェクトを削除する

f:id:hiro14aki:20191213143612p:plain

この方式だとリクエストする内容が S3 に保存されます。仮に 5 のタスクの API リクエストがエラーとなった場合、対象のイベントが Dead Letter Queue(DLQ)にプッシュされ、S3 にファイルが残ったままになります。DLQ から復旧も可能ですが S3 にファイルが残るので、例えば長期休暇中に何か問題が発生していた場合や外部システムの都合で単純なリトライが難しい場合でも後から S3 のオブジェクトを確認することができるのでトレースが楽になります。

外部システム連携の詳細

S3 から SQS へのイベント連携

S3 の プロパティ -> イベント から SQS への連携を設定します。今回は ファイルが置かれたとき にイベントを発動するので PUT, POST を有効にしておきます。また サフィックス.json を指定しておくことで、仮に異なる形式のファイルが配置されてしまったときに誤ってイベントが発動してしまう、といったケースを防ぐようにしています。

f:id:hiro14aki:20191213150553p:plain

SQS から Lambda のトリガーを設定

Lambda の起動トリガーとして SQS を追加します。今回の連携ではお互いのシステム間で「社内の調整ごとのような順序性を担保した連携」が必要だったため Lambda を同時実行することはせずにバッチサイズを 1 にして SQS にイベントがプッシュされたタイミングで 1 つずつ処理されるようにしています。

f:id:hiro14aki:20191213143736p:plain

システム連携する Lambda を作成

今回は Lambda は Node.js 10.x 系を使用しました。チームで特に言語は縛っていないのですが、担当しているプロダクトは SPA で構成されておりチームメンバーも全員 React / Redux は書けるのでメンテナンスは問題ないだろうというのと、一番は自分が好きだったという理由で Node.js にしています。

実際には下記のようなコードで API リクエストを送信しています。

// indes.js(一部省略)

const AWS = require("aws-sdk");
const request = require("request-promise");
AWS.config.update({ region: "ap-northeast-1" });
const s3 = new AWS.S3({ apiVersion: "2006-03-01" });

const BASE_URL = process.env.API_BASE_URL;
const ACCESS_TOKEN = process.env.API_ACCESS_TOKEN;
const QUERY_PARAMETER = `accessToken=${ACCESS_TOKEN}`;

const parseSQSEvent = event => {
  if (event.Records.length === 0) throw "SQS event resource not found.";
  const s3Event = JSON.parse(event.Records[0].body);
  if (s3Event.Records.length === 0) throw "S3 event resource not fount.";
  return s3Event.Records[0];
};

// "Content-type": "application/json で送信される。
const postWithJson = async values => {
  // API 送信処理
};

// "Content-type" : "multipart/form-data" 形式で送信される。
const postWithForm = async params => {
  // API 送信処理
};

exports.handler = async (event, context, callback) => {
  // SQS のイベントから S3 の情報を取得する
  const s3Detail = parseSQSEvent(event);
  const s3Params = {
    Bucket: s3Detail.s3.bucket.name,
    Key: s3Detail.s3.object.key
  };

  try {
    // SQS のイベントをもとに S3 から対象のオブジェクトを取得
    const targetObject = await s3
        .getObject(s3Params)
        .promise()
        .catch(err => {
          throw "Get object from s3 is failed.";
        });

    // オブジェクト内の実際に送信するデータをパース
    const targetValue = JSON.parse(targetObject.Body.toString());

    // タイプによって送信フォーマットが異なるため呼び出すメソッドを変える
    if (targetValue.action === "JSON") {
      await postWithJson(targetValue.value);
    } else if (targetValue.action === "FORM") {
      await postWithForm(targetValue.value);
    }
  } catch (error) {
    throw "External system request failed.";
  }

  // 正常終了のため S3 から対象のオブジェクトを削除する
  await s3
      .deleteObject(s3Params)
      .promise()
      .catch(err => {
        console.log("Delete object from s3 is failed.");
      });

  return {
    statusCode: 200,
    body: JSON.stringify("Execution completed successfully.")
  };
};

Terraform でインフラ環境を構築

これらの構成は Terraform を使用して管理、構築しています。 基本的には下記のブログの内容をベースにしているのですが、一点だけつまずいたポイントを紹介します。

recruit-tech.co.jp

Node.js で作成した Lambda をアップする場合、外部ライブラリを使用しているためそれらも一緒に zip でパッケージングする必要があります。Terraform 的には何かインフラを構築するわけではないが「事前に npm install してからパッケージングをし Lambda へアップロードする」動きをさせたいと考えていたため、Terraform の null_resource を使用して実現しようとしていました。

www.terraform.io

しかし null_resource を使用した場合、後続のタスクから連携している depends_on が正常に動作しなくなるという現象が発生したため、空ファイルを作るという一連の構築のフローに関係のないダミーのタスクが存在しています。(Terraform v0.11.13 を使用していたので最新版では問題ないかもしれません)

# main.tf(一部抜粋)

# 本来ローカルファイルを作成する必要性は全く無いが、depends_on を動作させるために使用している
resource "local_file" "main" {
  filename      = "${path.module}/src/terraform.txt"
  content       = "created by terraform on ${timestamp()}"

  provisioner "local-exec" {
    command = "npm i --production --prefix=${path.module}/src"
  }
}

data "archive_file" "zip_lambda" {
  depends_on  = ["local_file.main"]
  type        = "zip"
  source_dir  = "${path.module}/src"
  output_path = "${path.module}/actionLinker.zip"
}

resource "aws_lambda_function" "main" {
  depends_on                     = ["data.archive_file.zip_lambda"]
  runtime                        = "nodejs10.x"
  :
  以下省略

システム連携の一時停止機能

連携している社内システムですが、当然向こうの開発サイクルによるリリースや定期メンテナンスによるシステム停止が発生します。この間に我々のシステム内でアクションした処理を無邪気に連携してしまうと、連携先のシステムが落ちている状態のため、DLQ に連携失敗のキューがたまり続けてしまいます。

これを防ぐために、「SQS から連携用の Lambda を実行するトリガー自体を ON/OFF する Lambda」を別で作成して CloudWatch Eventscron 形式でシステム連携を一時停止する運用にしています。

実際のコードは下記のような感じで作成しています。

// index.js(一部抜粋)

// AWS SDK で Lambda の状態を更新する
const getExecuteFunctionName = async param => {
  return await lambda.listEventSourceMappings(param).promise();
};
const updateFunctionEvent = async param => {
  return await lambda.updateEventSourceMapping(param).promise();
};

exports.handler = async event => {
  // システム連携用 Lambda の情報を取得
  const baseParam = {
    FunctionName: functionName
  };
  let currentFunctionInfo = {};
  try {
    currentFunctionInfo = await getExecuteFunctionName(baseParam);
  } catch (e) {
    throw "Error getting function parameters";
  }

  // システム連携用 Lambda のイベント連携状態を更新
  const updateParam = Object.assign(baseParam, {
    UUID: currentFunctionInfo.EventSourceMappings[0].UUID,
    Enabled: false // イベントを ON にするときは true にする
  });

  try {
    await updateFunctionEvent(updateParam);
  } catch (e) {
    throw "Error updating function parameters";
  }
  :
  以下省略

今回やってみての感想

アプリケーションと分離して AWS サービスのみで連携できて非常にメンテナンスが楽になりました。この辺りの仕組を自分たちで実装しようとするとバッチ環境を作らなきゃいけなかったり連携基盤自体もメンテナンスしていく必要が出てきてしまいますが、そこを AWS にお任せできるというのはかなりメンテナンスコストを削減できて良いですね。

ただ、ログの管理については今後の課題の一つかなと思っています。使用するサービスが分かれているため、今は何か問題が起こったときにそれぞれのログを見にいかなきゃいけない状態です。あとはローカル環境でこれと同じ環境を再現するのが手間がかかってしまうのもなんとかしたいですね。(開発中のトラブルシューティングがつらかった。)

この辺りは AWS SAM CLI でローカル環境を構築したり、X-Ray で横断的にログをトレースしたりなど、更に便利になるようにいろいろと検討していけたらなと思っています。

追伸

Lambda のバッチサイズを 1 にして順序性を保つと説明していましたが、厳密には SQS の標準キューを使用している以上、確率は低くはなりますがイベントが発生するタイミングによってはイベントの逆転は発生してしまいます。本来であれば FIFO キューを使いたかったのですが、こちらは S3 からのイベント連携に対応していないとのこと。。

今回は要件的に順序の逆転がそこまで発生しないだろうという前提のもと、仮に SQS で仮に順序が逆転しても業務的に問題ない方式でお互いのアプリケーションを機能実装することでそこをカバーしました。早く FIFO キューで S3 のイベント連携できるようになってほしいですね。