CloudWatch SyntheticsでGUIワークフローで再試行処理を実装してみた

CloudWatch SyntheticsでGUIワークフローで再試行処理を実装してみた

Clock Icon2025.02.13

こんにちは。たかやまです。

CloudWatch Syntheticsを利用することで、ログイン処理を検証するようなGUIワークフローを実装できます。

ただし、ログイン処理中にネットワークエラーやリクエスト負荷が発生した場合に処理の途中でログインに失敗することがあります。

基本的にリトライすることでログインすることができる場合には監視の中でもログイン処理を再試行したいことがあるかと思います。

今回は、CloudWatch Syntheticsを使用してGUIワークフローで再試行処理を実装したので、その内容をお伝えします。

さきにまとめ

  • Canaryスクリプト内でリトライ処理を実装する
    • リトライ時はセッションクリア関数などを用意し、前回の処理の影響を受けない状態で実行する
  • CloudWatch AlarmはステップごとのSuccessPercentを監視対象とする

やってみる

今回使用したコードはこちらです。

https://github.com/nyankotaro/blog-cloudwatch-synthetics-example

CloudWatch Synthetics Canary

今回はサンプルとしてAWSコンソールにログインする処理を実装したCanaryスクリプトのコードを利用します。

index.js
const synthetics = require('Synthetics');
const log = require('SyntheticsLogger');
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

// Secrets Managerクライアントの設定
const region = process.env.REGION || 'ap-northeast-1';
const secretsManager = new SecretsManagerClient({ region });

// 検証用に環境変数からシークレットを取得(本番環境ではSecrets Managerを使用する)
const secret = {
  accountId: 'your-account-id',
  username: 'your-username',
  password: 'your-password',
};

// セッションクリア関数
const clearSession = async (page) => {
  try {
    if (page.isClosed()) {
      log.error('Page is already closed');
      return;
    }
    await page.deleteCookie(...(await page.cookies()));
    const client = await page.target().createCDPSession();
    await client.send('Network.clearBrowserCache');
    await client.send('Network.clearBrowserCookies');
    log.info('Session cleared successfully');
  } catch (error) {
    log.error(`Failed to clear session: ${error.message}`);
    throw error;
  }
};

// メイン処理関数
const executeFlow = async (page) => {
  const url = 'https://console.aws.amazon.com';
  const accountId = secret.accountId;

  synthetics.getConfiguration().setConfig({
    includeRequestHeaders: true,
    includeResponseHeaders: true,
    restrictedHeaders: [],
    restrictedUrlParameters: [],
  });

  // AWS コンソールにアクセス
  await synthetics.executeStep('navigateToAwsConsole', async () => {
    const response = await page.goto(url, {
      waitUntil: ['load', 'networkidle0'],
      timeout: 10000,
    });
    if (response.status() !== 200) {
      throw new Error(`Failed to load AWS Console. Status: ${response.status()}`);
    }
  });

  // ログインフォームの入力
  await synthetics.executeStep('inputAccountId', async () => {
    await page.waitForSelector('#account', { timeout: 5000 });
    await page.type('#account', accountId);
  });

  await synthetics.executeStep('inputUsername', async () => {
    await page.waitForSelector('#username', { timeout: 5000 });
    await page.type('#username', secret.username);
  });

  await synthetics.executeStep('inputPassword', async () => {
    await page.waitForSelector('#password', { timeout: 5000 });
    await page.type('#password', secret.password);
  });

  await synthetics.executeStep('clickSignIn', async () => {
    await page.waitForSelector('#signin_button', { timeout: 5000 });
    await page.click('#signin_button');
  });

  await synthetics.executeStep('verifyLogin', async () => {
    await page.waitForSelector('#console-nav-footer', { timeout: 10000 });
  });

  await synthetics.executeStep('verifyConsoleHome', async () => {
    await page.waitForFunction(
      () => document.body && document.body.innerText.includes('Console Home'),
      { timeout: 5000 }
    );
  });

  // ログアウト処理
  await synthetics.executeStep('openUserMenu', async () => {
    await page.waitForSelector('[data-testid="more-menu__awsc-nav-account-menu-button"]', { timeout: 10000 });
    await page.click('[data-testid="more-menu__awsc-nav-account-menu-button"]');

    // メニューが展開されるのを待つ
    await page.waitForFunction(() => {
      const button = document.querySelector('[data-testid="more-menu__awsc-nav-account-menu-button"]');
      return button && button.getAttribute('aria-expanded') === 'true';
    }, { timeout: 5000 });
  });

  await synthetics.executeStep('clickSignOut', async () => {
    await page.waitForSelector('[data-testid="aws-console-signout"]', { timeout: 5000 });
    await page.click('[data-testid="aws-console-signout"]');
  });

};

// リトライ処理を含むメイン関数
const flowBuilderBlueprint = async () => {
  const maxRetries = 2;
  const retryDelay = 5000;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      log.info(`Attempt ${attempt} of ${maxRetries}`);
      const page = await synthetics.getPage();
      await clearSession(page);
      await executeFlow(page);
      return;
    } catch (error) {
      log.error(`Attempt ${attempt} failed: ${error.message}`);
      if (attempt === maxRetries) throw error;
      log.info(`Retrying in ${retryDelay / 1000} seconds...`);
      await new Promise((resolve) => setTimeout(resolve, retryDelay));
    }
  }
};
exports.handler = async () => flowBuilderBlueprint();

ポイントとしては、リトライ処理の実装とセッションクリア関数の実装しています。

セッションクリア関数(clearSession)は、再試行するにあたり各試行の前にブラウザのセッション状態をクリーンな状態にリセットする役割になります。

const clearSession = async (page) => {
  try {
    if (page.isClosed()) {
      log.error('Page is already closed');
      return;
    }
    await page.deleteCookie(...(await page.cookies()));
    const client = await page.target().createCDPSession();
    await client.send('Network.clearBrowserCache');
    await client.send('Network.clearBrowserCookies');
    log.info('Session cleared successfully');
  } catch (error) {
    log.error(`Failed to clear session: ${error.message}`);
    throw error;
  }
};

この関数では以下の処理を行っています:

  • 既存のCookieを全て削除
  • ブラウザのキャッシュをクリア
  • ブラウザのCookieをクリア

これにより、各試行が前回の実行時のログイン状態の影響を受けることなく、クリーンな状態で実行されるようにしています。

実際のリトライ処理はメイン関数(flowBuilderBlueprint)で実装しています。

const flowBuilderBlueprint = async () => {
  const maxRetries = 2;
  const retryDelay = 5000;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      log.info(`Attempt ${attempt} of ${maxRetries}`);
      const page = await synthetics.getPage();
      await clearSession(page);
      await executeFlow(page);
      return;
    } catch (error) {
      log.error(`Attempt ${attempt} failed: ${error.message}`);
      if (attempt === maxRetries) throw error;
      log.info(`Retrying in ${retryDelay / 1000} seconds...`);
      await new Promise((resolve) => setTimeout(resolve, retryDelay));
    }
  }
};

この実装により、一時的なネットワークエラーや負荷によりログインに失敗した場合でも、再試行するようになります。

CloudWatch Alarm

次にCanaryスクリプトの監視メトリクスを設定します。

CloudWatch Syntheticsの監視メトリクスはSynthetics全体のメトリクスと個別のCanaryメトリクスが追加されます。
また、executeStep() または executeHttpStep() を利用している場合にはさらにステップごとのメトリクスが追加されます。

CleanShot 2025-02-13 at 09.56.25.png

Synthetics ライブラリの executeStep() または executeHttpStep() メソッドのいずれかを使用する Canary は、各ステップのディメンション CanaryName および StepName を使用して SuccessPercent および Duration メトリクスも発行します。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_metrics.html

CloudWatch AlarmのSyntheticsの全体メトリクスのSuccessPercentを監視対象にすると一部のステップでも失敗するとCanaryスクリプト全体で失敗とみなされてしまいます。

CleanShot 2025-02-13 at 15.20.08.png

そのため、再試行処理での監視メトリクスはステップごとに作成されるSuccessPercentを監視対象とします。
ここではステップの最後に実行されるclickSignOutのSuccessPercentを監視対象とします。

CleanShot 2025-02-13 at 14.23.26@2x.png

また、clickSignOutステップに到達できなかった場合は該当メトリクスが生成されないため、Alarmの欠落データが発生した場合の処理としてはしきい値超過として扱います。

動作確認

試しにverifyConsoleHomeステップで意図的に失敗させてみます。

index.js
const synthetics = require('Synthetics');
const log = require('SyntheticsLogger');
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

// Secrets Managerクライアントの設定
const region = process.env.REGION || 'ap-northeast-1';
const secretsManager = new SecretsManagerClient({ region });

// 検証用に環境変数からシークレットを取得(本番環境ではSecrets Managerを使用する)
const secret = {
  accountId: 'your-account-id',
  username: 'your-username',
  password: 'your-password',
};

// セッションクリア関数
const clearSession = async (page) => {
  try {
    if (page.isClosed()) {
      log.error('Page is already closed');
      return;
    }
    await page.deleteCookie(...(await page.cookies()));
    const client = await page.target().createCDPSession();
    await client.send('Network.clearBrowserCache');
    await client.send('Network.clearBrowserCookies');
    log.info('Session cleared successfully');
  } catch (error) {
    log.error(`Failed to clear session: ${error.message}`);
    throw error;
  }
};

// メイン処理関数
const executeFlow = async (page) => {
  const url = 'https://console.aws.amazon.com';
  const accountId = secret.accountId;

  synthetics.getConfiguration().setConfig({
    includeRequestHeaders: true,
    includeResponseHeaders: true,
    restrictedHeaders: [],
    restrictedUrlParameters: [],
  });

  // AWS コンソールにアクセス
  await synthetics.executeStep('navigateToAwsConsole', async () => {
    const response = await page.goto(url, {
      waitUntil: ['load', 'networkidle0'],
      timeout: 10000,
    });
    if (response.status() !== 200) {
      throw new Error(`Failed to load AWS Console. Status: ${response.status()}`);
    }
  });

  // ログインフォームの入力
  await synthetics.executeStep('inputAccountId', async () => {
    await page.waitForSelector('#account', { timeout: 5000 });
    await page.type('#account', accountId);
  });

  await synthetics.executeStep('inputUsername', async () => {
    await page.waitForSelector('#username', { timeout: 5000 });
    await page.type('#username', secret.username);
  });

  await synthetics.executeStep('inputPassword', async () => {
    await page.waitForSelector('#password', { timeout: 5000 });
    await page.type('#password', secret.password);
  });

  await synthetics.executeStep('clickSignIn', async () => {
    await page.waitForSelector('#signin_button', { timeout: 5000 });
    await page.click('#signin_button');
  });

  await synthetics.executeStep('verifyLogin', async () => {
    await page.waitForSelector('#console-nav-footer', { timeout: 10000 });
  });

+ await synthetics.executeStep('verifyConsoleHome', async () => {
+   // 1回目の試行では意図的に失敗させる
+   if (!global.retryCount) {
+     global.retryCount = 0;
+   }
+ 
+   if (global.retryCount === 0) {
+     log.info('First attempt - forcing failure');
+     global.retryCount++;
+     throw new Error('First attempt failed intentionally');
+   }
+ 
+   // 2回目以降は正常に実行
+   await page.waitForFunction(
+     () => document.body && document.body.innerText.includes('Console Home'),
+     { timeout: 5000 }
+   );
+   log.info('Second attempt - success');
+ });

  // ログアウト処理
  await synthetics.executeStep('openUserMenu', async () => {
    await page.waitForSelector('[data-testid="more-menu__awsc-nav-account-menu-button"]', { timeout: 10000 });
    await page.click('[data-testid="more-menu__awsc-nav-account-menu-button"]');

    // メニューが展開されるのを待つ
    await page.waitForFunction(() => {
      const button = document.querySelector('[data-testid="more-menu__awsc-nav-account-menu-button"]');
      return button && button.getAttribute('aria-expanded') === 'true';
    }, { timeout: 5000 });
  });

  await synthetics.executeStep('clickSignOut', async () => {
    await page.waitForSelector('[data-testid="aws-console-signout"]', { timeout: 5000 });
    await page.click('[data-testid="aws-console-signout"]');
  });

};

// リトライ処理を含むメイン関数
const flowBuilderBlueprint = async () => {
  const maxRetries = 2;
  const retryDelay = 5000;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      log.info(`Attempt ${attempt} of ${maxRetries}`);
      const page = await synthetics.getPage();
      await clearSession(page);
      await executeFlow(page);
      return;
    } catch (error) {
      log.error(`Attempt ${attempt} failed: ${error.message}`);
      if (attempt === maxRetries) throw error;
      log.info(`Retrying in ${retryDelay / 1000} seconds...`);
      await new Promise((resolve) => setTimeout(resolve, retryDelay));
    }
  }
};
exports.handler = async () => flowBuilderBlueprint();

途中の処理の verifyConsoleHome ステップで失敗したあと、リトライ処理で再試行し clickSignOut まで到達していることが確認できます。
(このときCanaryとしては失敗として記録されます。)

CleanShot 2025-02-13 at 20.00.55-1.png

ステップ別メトリクスの clickSignOut でもSuccessPercentが記録されていることが確認できます。

CleanShot 2025-02-13 at 20.04.37.png

CloudWatch Alarm上でもSuccessPercentが記録され無事ステータスがOKになっていることが確認できます。

CleanShot 2025-02-13 at 20.06.54.png

最後に

CloudWatch SyntheticsではGUIワークフローで再試行処理を実装できます。

当初の監視ではCanary全体でのSuccessPercentを監視対象としていたため、一部のステップで失敗した場合にCanaryスクリプト全体で失敗とみなされてしまいました。

こちらのステップごとのSuccessPercentを監視対象とすることで、一部のステップで失敗した場合でも監視を行うことができます。

CloudWatch Syntheticsで再試行処理を実装したい場合には参考にしてみてください。

この記事が誰かのお役に立てれば幸いです。

以上、たかやま(@nyan_kotaroo)でした。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.