はじめに

こんにちは、2024 年 8 月より入社し、データインテグレーションエンジニアをしています Roki です。

弊社では元々、Mobile Device Management(以下 MDM)を活用していましたが、以下のような課題がありました。

  • 管理が煩雑であったため、MDM情報と資産の貸し借りといった使用状況を紐付けて一元管理し、紛失や不正転売の防止を強化したい
  • 既存の管理ページのユーザビリティが低く、ページのレスポンス速度にも課題があった

こうした課題を解決すべく、この度、オープンソースの資産管理システムである Snipe-IT(スナイプ・アイティー) を導入し、刷新することとなりました。本記事では、Snipe-IT と AWS を活用した MDM との統合アーキテクチャを紹介し、その設計思想や技術的な工夫について説明します。

Snipe-IT のホーム画面
Snipe-IT のホーム画面

端末管理プラットフォーム

弊社では主に iOS と Android OS を搭載のモバイル端末を扱っており、各 OS ごとの端末管理プラットフォームを活用することでセキュリティの向上やキッティングの効率化を図っています。

Apple Business Manager

Apple Business Manager (以下 ABM) は、Apple ID の管理からデバイスの自動登録、App の一括購入配布まで、Apple エコシステムにおける法人向け管理を網羅的にカバーするプラットフォームです。企業内の Apple 製品管理を体系化し、人的ミスを排除することで、運用工数・セキュリティリスクの低減に直結します。

弊社では、この ABM を Jamf Pro と連携させて活用しています。Jamf Pro は Apple 製品に特化した MDM ソリューションであり、ABM との統合により、端末の自動 MDM 登録、自動構成プロファイルの適用、アプリの自動配布といった一連の処理を、完全自動化する構成を実現しています。このように、ABM + Jamf の連携は、単なる登録支援ではなく、「エンタープライズモビリティの自律的制御基盤」として運用されており、資産管理(Snipe-IT)と整合性を保っています。

Android Enterprise

Android Enterprise は、Google が定義する Android OS における企業向け端末管理のベストプラクティス群であり、デバイスの整合性・セキュリティを担保しながら、管理者による一元制御を実現するための基盤です。なかでも「Zero-touch enrollment(ZTE)」は、端末の初期構成を完全に自動化し、ユーザーの操作を最小限に抑えたデプロイメントフローを構築する鍵となります。弊社では LINC Biz emm と連携した構成により、以下のような ZTE ベースの管理体制を確立しています。

アーキテクチャ概要

本システムでは、上記端末管理プラットフォームを活用の上、MDM との連携を行うことで、企業内のデバイス情報を一元管理する仕組みを提供します。

MDM 連携システムの概要図
MDM 連携システムの概要図

これは、以下の主要コンポーネントで構成されています。

  1. AWS Lambda:イベント駆動型のサーバーレス関数として MDM との連携を担う
  2. Amazon S3:MDM から取得したデバイス情報の一時保存や、長時間処理の際のデータ受け渡しに使用
  3. Amazon CloudWatch:ログ管理およびアラート通知に活用
  4. API Gateway:MDM からのリクエストを統制
  5. Snipe-IT:デバイス管理のための外部システムとして統合
  6. Snowflake:デバイスデータ分析に利用

以下は、システム全体のフローです。

  1. 起動
    • MDM からの Webhook イベントから API Gateway を経由して
    • Event Bridge による定期実行
  2. MDM からのデバイス情報取得
  3. デバイス情報の処理
    • MDM から取得したデバイス情報を解析し、適切なフォーマットに変換
    • 必要に応じて、データを S3 に保存し、後続の Lambda 実行で利用可能に
  4. 処理時間超過の対策(再帰的呼び出し)
    • AWS Lambda の処理時間が 15 分に達する前に、現在の処理状態を S3 に保存
    • Lambda 自身を再度呼び出し、処理を継続
  5. データの登録・更新
    • 解析済みデータを Snipe-IT に送信し、モデル、デバイスの登録やステータス更新を実施
    • ログを CloudWatch に送信し、監視・分析を可能に

その他、子会社を含む全従業員のデータを元に定期的に Snipe-IT へユーザ登録/更新を行っています。

技術的な工夫

AWS Lambda の 15 分制限を超える再帰的処理

AWS Lambda は 1 回の実行につき最大 15 分までの処理時間が許容されています。しかし、大量のデバイス情報を処理する場合、15 分以内に完了しないケースが発生します。そのため、本システムでは再帰的自己呼び出しを活用し、継続的な処理を実現しています。具体的には、以下のような流れで実装されています。

  • Lambda の処理開始時に、現在のタスクの進捗状況を記録
  • 時間のかかるタスクの要所要所で、残りのタスクが 15 分以内に完了しないと判断された場合、処理途中のデータを Amazon S3 に保存
  • 自身を再度呼び出しして、保存したデータを基に処理を継続

この設計により、実質的に処理時間を無制限に拡張することが可能となります。

データの受け渡しにおける Base64 エンコードの活用

あらゆる外部 API へのリクエストに対し、一度失敗したとしても 2 度即座にリトライ、それでもダメならば 10 分後にリトライ、さらにダメならば 1 時間後にリトライ、さらにさらにダメならば失敗とする仕様要件があり、1 時間後にリトライまで進んだ場合はまず確実に Lambda がタイムアウトしてしまいますので再帰的に Lambda を呼び出す必要があり、その際に、各リクエストに基づいた適切な sleep 時間を次の Lambda へ渡す必要があります。Snipe-IT へのモデルの作成やデバイスの登録ではその対象デバイス分連続で API を叩く必要があるため、Too Many Requests として弾かれるときがしばしばあり、決して珍しい現象ではなく対応が必須でした。
そこで、まずタイムアウト後は再起動した Lambda の実行内で、該当のリクエストと全く同一のリクエストを送信する場合、下記の W 秒間待ってから行う設計としました。$$W=\max\left(\text{本来待ちたかった時間(秒)}-\text{待った時間(秒)}-\text{AWS Lambda の残り実行時間(秒)}-n\text{(秒)},0\right)$$ここで n は任意の自然数です。これを実現するためには下記のデータを次の Lambda へ完全に引き継ぐ必要があります。

  • 途中まで取得したデバイス情報
  • 未処理のデバイスリスト
  • リトライ回数の管理
  • スリープ時間の制御

今回は、リクエスト全体を Base64 エンコードしてそれをキーとし、これらの情報全体を値として保持させ次回の Lambda 呼び出しのペイロードとして送信することで、残リトライ回数や残スリープ時間を引き継ぐこととしました。下記がそのエンコード部分のおおよその実装です。

from base64 import b64encode

class RetryRequest(metaclass=ABCSingletonMeta):
    # 略...
    def _key_from_req_params(self, req_params: dict[str, object]) -> str:
        return b64encode(
            str([(key, req_params[key]) for key in sorted(req_params)]).encode()
        ).decode("ascii")
    # 略...

これを用いて、下記のようなデータ更新用の関数を実装しています。

from collections.abc import Callable, Iterable
from requests.models import Response

type RequestMethodType = Callable[[dict[str, object]], Response]

class RetryRequest(metaclass=ABCSingletonMeta):
        # 略...
        def _update_sleep_method(
        self,
        method: RequestMethodType,
        req_params: dict[str, object],
        sleep_seconds: int,
        next_plan_index: int,
    ) -> None:
        continue_data = (
            self._retry_method_data if self._retry_method_data is not None else {}
        )
        continue_data.update(
            {
                method.__qualname__: continue_data.get(method.__qualname__, {})
                | {
                    self._key_from_req_params(req_params): {
                        "sleep_seconds": sleep_seconds,
                        "next_plan_index": next_plan_index,
                    }
                }
            }
        )
        self._rec_s3.update_task(
            self._retry_method.__qualname__,
            InvokeLambdaData(continue_data=continue_data, unprocessed={}),
        )

その上で下記のようにデータを引き出し、前回実行時のリクエストに関する状態データの受け渡しをしています。

class RetryRequest(metaclass=ABCSingletonMeta):
        # 略...
    def _retry_method(
        self,
        method: RequestMethodType,
        req_params: dict[str, object],
        json_decode: bool = True,
    ) -> dict[str, object]:
        # 略...
        if (
            self._retry_method_data is not None
            and self._retry_method_data.get(method_name, {}).get(
                self._key_from_req_params(req_params)
            )
            is not None
        ):
            sleep_seconds = self._retry_method_data[method_name][
                self._key_from_req_params(req_params)
            ]["sleep_seconds"]
            next_plan_index = self._retry_method_data[method_name][
                self._key_from_req_params(req_params)
            ]["next_plan_index"]
        # 略...

デバイス間等価性の検証と MDM との統合

デバイスの登録・更新処理を実現する上で重要となるのが、デバイス間の等価性の検証です。一般的に、この検証ではシリアル番号や IMEI を用いてデバイスの同一性を確認することが考えられます。しかし、実際にはシリアル番号が重複するケースがあり得ます。また、IMEI についても、デュアル SIM 搭載機などでは変更される可能性があるため、デバイス固有の識別子としては十分ではありませんでした。

そこで、本システムでは 「型番とシリアル番号の組み合わせ」 を用いてデバイスの等価性を保証することとしました。これにより、以下のような異常を検出し、適切に対応できる仕組みを導入しています。

  • 異なるデバイスにも関わらず、同じ資産タグ(Snipe-IT 上でユニークな値)が設定される
  • 同じデバイスにも関わらず、異なる資産タグが設定される

このような不整合が発生した場合にはアラートを発し、適宜対応できるようにしました。さらに、各 MDM から取得するデータのフォーマットが異なる場合にも対応できるよう、抽象化されたデータ変換インタフェースを設計しました。

class DeviceKeyInterface(metaclass=ABCMeta):
    def __init__(self, device_detail: dict) -> None:
        self._model_number = self._parse_model_number(device_detail)
        self._serial = self._parse_serial(device_detail)

    @abstractmethod
    def _parse_model_number(self, dict) -> str:
        pass

    @abstractmethod
    def _parse_serial(self, dict) -> str:
        pass

このインタフェースにより、MDM ごとに異なるフィールド名の違いを吸収し、一貫したデバイス情報として処理できるようにしています。

まとめ

本記事では、AWS Lambda を活用した MDM 連携システムの設計について解説しました。特に、AWS Lambda の 15 分制限を超えるための再帰的処理の工夫や Base64 を用いたデータの受け渡し、MDM との統合設計について説明しました。

クラウドを活用した IT 資産管理の一例として、本プロジェクトの知見が参考になれば幸いです。