NTT東日本の自治体クラウドソリューション

mackerel-container-agent が自動退役に失敗するので、Lambda 関数で確実に退役させる

こんにちは、保坂です。

NTT東日本ではクラウド導入・運用サービスという、お客さまのクラウド移行・利用を支援するサービスを展開しております。

このサービスでは、お客さまのクラウドリソースの監視に株式会社はてな社の Mackerel というサービスを利用しています。

Mackerel(マカレル): 始めやすくて奥深い、可観測性プラットフォーム

Mackerel は非常に完成度が高く重宝しているサービスであるのですが、ECS / EKS などの監視を行う際に利用する mackerel-container-agent で登録されたホストが自動退役に失敗し、監視対象が既に存在しないにも関わらず Mackerel 上に残り続けてしまう…という事象に悩んでおりました。

このコラムでは Mackerel ホストの自動退役に失敗する原因やその再現と、Lambda 関数を利用して確実に自動退役させる仕組みの実装例をご紹介します。

NTT東日本では、AWSなどクラウドに関するお役立ち情報をメールマガジンにて発信していますので、ぜひこちらからご登録ください。

【情シス担当者・経営者向け】NTT東日本がおすすめするクラウド導入を成功させるためのお役立ちマニュアル 資料ダウンロードフォームはこちら

1. mackerel-container-agent を利用した監視と自動退役に失敗する状況

mackerel-container-agentは Amazon ECS や Kubernetes などを監視する際に利用し、以下の様な特徴を持っています。

  • コンテナオーケストレーションプラットフォームを対象とした専用の監視エージェントです
  • Dockerイメージで提供します
  • タスク(ECS)やPod(Kubernetes)を監視します
  • タスク/Podのサイドカーとして実行します
  • タスク/Podをホストとして扱い、サービス、ロールを割り当てることが可能です
  • エージェント終了時に自動退役します

コンテナを監視する - Mackerel ヘルプ

この mackerel-container-agent を利用して監視しているタスク / Pod が終了する際、mackerel-container-agent は Mackerel に対して自動的に退役処理を実施し、Mackerel のホストとしても退役(Mackerel の監視対象外とすること)させる事ができます。

mackerel-container-agent による退役処理

この時ネットワークの問題や mackerel-container-agent が退役処理を実施する前にタスク / Pod が停止されるなどによって、Mackerel のホストとして退役することができずタスク / Pod が存在しないにも関わらず残り続ける、という状況が発生いたします。

ホストが自動退役されない

NTT東日本では、AWSなどクラウドに関するお役立ち情報をメールマガジンにて発信していますので、ぜひこちらからご登録ください。

2. 自動退役に失敗する事象の再現

2-1. 再現方法

まずは mackerel-container-agent を利用した監視において、自動退役に失敗する事象を再現してみます。

再現方法は以下としました。

  • 2AZ にタスクをデプロイする ECS Cluster / Serivce を作成
  • 片方の AZ の VPC にひもづく NACL において HTTPS 通信をブロック
  • Service 設定で必要なタスク数を 2 → 0 としタスクを終了させる
自動退役に失敗する事象の再現方法

2-2. 再現結果

結果は画像の通りで、ECS タスクは終了しているのにも関わらず NACL で退役処理をブロックしたホストは Mackerel 上に残ったままとなる状態を再現できました。

事象再現前の Mackerel 上のホスト
事象再現後の Mackerel 上のホスト
事象再現後の ECS タスク状況

NTT東日本では、AWSなどクラウドに関するお役立ち情報をメールマガジンにて発信していますので、ぜひこちらからご登録ください。

3. Lambda関数を利用した確実に退役させる実装サンプル

Mackerel のドキュメントや「自動退役に失敗する事象の再現」からも分かるように、mackerel-container-agent 自身が退役処理を実施するのが本事象の根本的な原因となります。

そのため mackerel-container-agent 以外、今回は Lambda 関数から 退役処理を実施することで確実に Mackerel 上から該当のホストを退役させます。

具体的には以下となります。

  • EventBridge ルールを使用し ECS からの ECS Task State Change 且つ lastStatus: STOPPED なイベントをトリガー
  • Lambda 関数にて上記イベントから Mackerel 上に該当するホストを取得、そのホストに対し退役処理を実施

3-1. 実装サンプル

Lambda 関数は EventBridge より受信したイベントを元に、以下の処理を実施して退役処理を行う実装としました。

  • EventBridge より受信したイベントから「taskId」を取得
  • Mackerel よりホスト名が「taskId」であるホスト情報を取得
    • mackerel-container-agent によって正常に退役されていれば、ホスト情報の取得は出来ない
  • Mackerel に対し取得したホストの退役処理を実施

実装サンプルは SAM として以下に記載しておりますので、参考にしていただければ幸いです。

template.yml

AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: "MackerelECSTaskHostRetirer"

Parameters:
  TargetECSClusterArn:
    Description: "The ARN of the target ECS Cluster"
    Type: "String"
  
  TargetECSServiceName:
    Description: "The name of the target ECS Service"
    Type: "String"
  
  MackerelApiKey:
    Description: "API key to the Mackerel organizaton"
    NoEcho: true
    Type: "String"

Resources:
  MackerelApiKeySecret:  # Mackerel API キー格納用 Secret
    Type: "AWS::SecretsManager::Secret"
    Properties:
      Description: "Mackerel オーガニゼーションの API キー"
      Name: "mackerel-organization-api-key"
      SecretString: !Sub "{\"apiKey\": \"${MackerelApiKey}\"}"

  MackerelECSTaskHostRetirer:  # ECS タスク終了時に該当の Mackerel ホストを退役させる Lambda 関数のサンプル
    Type: "AWS::Serverless::Function"
    Properties:
      Description: "ECS タスク終了時に該当の Mackerel ホストを退役させる Lambda 関数のサンプル"
      Environment:
        Variables:
          MACKEREL_API_KEY_SECRET: !Ref "MackerelApiKeySecret"
      Events:
        ECSTaskStopped:
          Type: "EventBridgeRule"
          Properties:
            Pattern:
              source:
                - "aws.ecs"
              detail-type:
                - "ECS Task State Change"
              detail:
                clusterArn:
                  - !Ref "TargetECSClusterArn"
                lastStatus:
                  - "STOPPED"
                group:
                  - !Sub "service:${TargetECSServiceName}"
      FunctionName: "mackerel-ecs-task-host-retirer"
      Handler: "lambda_function.lambda_handler"
      MemorySize: 128
      PackageType: "Zip"
      Policies:
        - "AWSLambdaBasicExecutionRole"
        - Statement:
            - Effect: "Allow"
              Action: "secretsmanager:GetSecretValue"
              Resource:
                - !Ref "MackerelApiKeySecret"
      Timeout: 10
      Runtime: "python3.13"

lambda_function.py

import os
import json
from urllib.error import HTTPError
from urllib.request import Request, urlopen

import boto3


class MackerelHostNotFoundError(Exception):
    """Mackerel上に指定したホストが存在しない場合の例外

    Attributes
    ----------
    host_name : str | None
        存在しなかったホストのホスト名
    host_id : str | None
        存在しなかったホストのホストID
    """

    def __init__(self, host_name: str | None = None, host_id: str | None = None) -> None:
        self.host_name: str | None = host_name
        self.host_id: str | None = host_id

        if host_name:
            message = f"Mackerel上にホスト名:{host_name}を持つホストが見つかりません"
        elif host_id:
            message = f"Mackerel上にホストID:{host_id}を持つホストが見つかりません"
        else:
            message = "Mackerel上に指定したホストが見つかりません"

        super().__init__(message)


class MackerelHostNameDuplicateError(Exception):
    """Mackerel上に指定したホスト名を持つホストが複数ある場合の例外

    Attributes
    ----------
    host_name : str
        同じホスト名を持つホストが複数あったホスト名
    hosts : list[dict]
        ホスト情報
    """

    def __init__(self, host_name: str, hosts: list[dict]) -> None:
        self.host_name: str = host_name
        self.hosts: list[dict] = hosts
        super().__init__(f"Mackerel上にホスト名:{host_name}を持つホストが{len(hosts)}ホスト存在します")


class ECSTaskHostRetirer:
    """ECSタスク毎ホストのタスク終了時に退役APIを実行するクラス

    Attributes
    ----------
    BASE_URL : str
        Mackerel API のベース URL
    """

    BASE_URL: str = "https://api.mackerelio.com"

    def __init__(self, event: dict) -> None:
        """Initializer

        Parameters
        ----------
        event: dict
            lambda_handlerに渡されるevent
        """
        self._event: dict = event
        self._mackerel_api_key: str = self._get_mackerel_api_key()
    
    def _get_mackerel_api_key(self) -> str:
        """Secrets ManagerからMackerel APIキーを取得する

        Returns
        -------
        str
            取得したMackerel APIキー
        """
        client = boto3.client("secretsmanager")
        response: dict = client.get_secret_value(SecretId=os.environ["MACKEREL_API_KEY_SECRET"])
        return json.loads(response["SecretString"])["apiKey"]
    
    def _get_http_headers(self) -> dict[str, str]:
        """Mackerel APIリクエスト用のHTTPヘッダーを生成する

        Returns
        -------
        dict[str, str]
            APIキーとContent-Typeを含むHTTPヘッダー
        """
        return {
            "X-Api-Key": self._mackerel_api_key,
            "Content-Type": "application/json"
        }

    def execute(self) -> None:
        """ECSタスクホスト退役処理のライフサイクルフック

        Raises
        ------
        MackerelHostNameDuplicateError
            ホスト名が重複している場合
        """
        task_name: str = self._get_task_name()

        try:
            host_id: str = self._get_host_id(task_name)
            self._retire_host(host_id)
            print(f"ECSタスク {task_name} を退役させました")
        except MackerelHostNotFoundError:
            print(f"ECSタスク {task_name} は 'mackerel-container-agent' により退役済みです")

    def _get_task_name(self) -> str:
        """EventBridgeからのECSタスク終了イベントからタスクIDを取得する

        Returns
        -------
        str
            タスクID
        """
        task_arn: str = self._event["resources"][0]
        return task_arn.split("/")[-1]

    def _get_host_id(self, host_name: str) -> str:
        """Mackerelから指定したホスト名のホストIDを取得する

        Parameters
        ----------
        host_name : str
            ホスト名

        Returns
        -------
        str
            ホストID

        Raises
        ------
        MackerelHostNotFoundError
            ホストが存在しない場合
        MackerelHostNameDuplicateError
            ホスト名が重複している場合
        """
        url: str = f"{self.BASE_URL}/api/v0/hosts?name={host_name}"

        req: Request = Request(url, headers=self._get_http_headers(), method="GET")

        with urlopen(req) as res:
            result: str = res.read().decode("utf-8")

        hosts: list[dict] = json.loads(result)["hosts"]

        if not hosts:
            raise MackerelHostNotFoundError(host_name=host_name)
        if len(hosts) > 1:
            raise MackerelHostNameDuplicateError(host_name, hosts)

        return hosts[0]["id"]

    def _retire_host(self, host_id: str) -> None:
        """Mackerelから指定したホストIDのホストを退役させる

        Parameters
        ----------
        host_id : str
            ホストID

        Raises
        ------
        MackerelHostNotFoundError
            ホストIDが存在しない場合
        HTTPError
            その他のHTTPエラー
        """
        url: str = f"{self.BASE_URL}/api/v0/hosts/{host_id}/retire"
        data: bytes = json.dumps({}).encode("utf-8")

        req: Request = Request(url, data=data, headers=self._get_http_headers(), method="POST")

        try:
            with urlopen(req) as res:
                _ = res.read().decode("utf-8")
        except HTTPError as e:
            if e.code == 404:
                raise MackerelHostNotFoundError(host_id=host_id)
            raise e


def lambda_handler(event: dict, context: object) -> None:
    """AWS Lambdaハンドラー

    Parameters
    ----------
    event : dict
        イベントデータ
    context : object
        Lambdaコンテキスト
    """
    print(json.dumps(event))
    ECSTaskHostRetirer(event).execute()

3-2. 動作確認

確実に退役させる Lambda 関数をデプロイしたら、「自動退役に失敗する事象の再現」と同じ要領で事象を再現させます。

以下から mackerel-container-agent による退役処理を NACL でブロックした場合も Mackerel 上から退役できている事が確認できます。

事象再現前の Mackerel 上のホスト
事象再現後の Mackerel 上のホスト

また Lambda 関数のログからも以下を確認することができました。

  • NACL で退役処理をブロックしていない方のホストは mackerel-container-agent によって正常に退役できており、Lambda 関数実行時に該当のホストが Mackere 上に存在しない
  • NACL で退役処理をブロックした方のホストは mackerel-container-agent によって退役できておらず、Lambda 関数によって退役させられた
Lambda 関数の動作ログ

NTT東日本では、AWSなどクラウドに関するお役立ち情報をメールマガジンにて発信していますので、ぜひこちらからご登録ください。

4. さいごに

本コラムでは mackerel-container-agent による自動退役が失敗する事象とその原因、対策の実装サンプルをご紹介しました。

タスク / Pod はオートスケーリングなどによってタスク数が変動するものだと思いますし、スケールアウト→スケールインを繰り返す内に本事象によって、存在しないタスクを監視する Mackerel ホストがいつの間にか発生していると余計なコストが発生してしまうことにもなります。

mackerel-container-agent 以外から退役処理を実施し、タスク / Pod 終了時に確実に退役させることでそういった余計なコストの発生も抑えることができます。

本コラムが誰かの参考になりましたら幸いです。

NTT東日本では経験豊かなエンジニアが、AWSの構築保守からネットワーク設計を含めエンドツーエンドでのソリューションを提供しております。ぜひお気軽にお問い合わせください。

  • Amazon Web Services(AWS)および記載するすべてのAmazonのサービス名は、米国その他の諸国における、Amazon.com, Inc.またはその関連会社の商標です。
  • Mackerelは、株式会社はてなの商標です。

ページ上部へ戻る

相談無料!プロが中立的にアドバイスいたします

クラウド・AWS・Azureでお困りの方はお気軽にご相談ください。