CloudWatch Synthetics Canary on Terraform で Error: Cannot find module エラー

# 課題
CloudWatch Synthetics Canary の設定を Terraform で構築すると次のエラーに遭遇。

# エラー
```
ERROR: Canary error: 
Error: Cannot find module '/opt/nodejs/node_modules/pageLoadBuilderBlueprint'
Require stack:
- /var/task/index.js
- /var/runtime/UserFunction.js
- /var/runtime/index.js Stack: Error: Cannot find module '/opt/nodejs/node_modules/pageLoadBuilderBlueprint'
Require stack:
- /var/task/index.js
- /var/runtime/UserFunction.js
- /var/runtime/index.js
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:902:15)
    at Function.Module._load (internal/modules/cjs/loader.js:746:27)
    at Module.require (internal/modules/cjs/loader.js:974:19)
    at require (internal/modules/cjs/helpers.js:101:18)
    at Runtime.exports.handler (/var/task/index.js:71:30)
    at runMicrotasks (<anonymous>)
    at processTicksAndRejections (internal/process/task_queues.js:95:5)
```

# Terraform コード
```cw_syn.tf
locals {
  cw_syn_target = {
    "target_name" = ""
  }
}

data "archive_file" "cw_syn_function" {
  for_each = local.cw_syn_target

  type        = "zip"
  source_dir  = "src/cw_syn/${each.key}"
  output_path = "dst/cw_syn/${each.key}/layer.zip"
}

resource "aws_synthetics_canary" "cw_syn_canary" {
  for_each = local.cw_syn_target

  artifact_s3_location     = "s3://${aws_s3_bucket.cw_syn.id}/canary/${each.key}"
  execution_role_arn       = aws_iam_role.cw_syn_role.arn
  failure_retention_period = 31
  handler                  = "pageLoadBuilderBlueprint.handler"
  zip_file                 = data.archive_file.cw_syn_function[each.key].output_path
  name                     = each.key
  runtime_version          = "syn-nodejs-puppeteer-3.5"
  success_retention_period = 31
  tags = {
    "blueprint" = "pageload"
  }
  tags_all = {
    "blueprint" = "pageload"
  }

  run_config {
    active_tracing = false
    environment_variables = {
      URL = "https://example.com/"
    }
    #memory_in_mb      = 1000
    timeout_in_seconds = 60
  }

  schedule {
    duration_in_seconds = 0
    expression          = "rate(1 minute)"
  }
}
```

# 解決
`source_dir` 配下は `nodejs/node_modules/function.js` の構造にする

<details><summary>src/cw_syn/target_name/nodejs/node_modules/pageLoadBuilderBlueprint.js</summary>

```src/cw_syn/target_name/nodejs/node_modules/pageLoadBuilderBlueprint.js
const { URL } = require('url');
const synthetics = require('Synthetics');
const log = require('SyntheticsLogger');
const syntheticsConfiguration = synthetics.getConfiguration();
const syntheticsLogHelper = require('SyntheticsLogHelper');

const loadBlueprint = async function () {

    const urls = [process.env.URL];

    // Set screenshot option
    const takeScreenshot = true;

    /* Disabling default step screen shots taken during Synthetics.executeStep() calls
     * Step will be used to publish metrics on time taken to load dom content but
     * Screenshots will be taken outside the executeStep to allow for page to completely load with domcontentloaded
     * You can change it to load, networkidle0, networkidle2 depending on what works best for you.
     */
    syntheticsConfiguration.disableStepScreenshots();
    syntheticsConfiguration.setConfig({
       continueOnStepFailure: true,
       includeRequestHeaders: true, // Enable if headers should be displayed in HAR
       includeResponseHeaders: true, // Enable if headers should be displayed in HAR
       restrictedHeaders: [], // Value of these headers will be redacted from logs and reports
       restrictedUrlParameters: [] // Values of these url parameters will be redacted from logs and reports

    });

    let page = await synthetics.getPage();

    for (const url of urls) {
        await loadUrl(page, url, takeScreenshot);
    }
};

// Reset the page in-between
const resetPage = async function(page) {
    try {
        await page.goto('about:blank',{waitUntil: ['load', 'networkidle0'], timeout: 30000} );
    } catch(ex) {
        synthetics.addExecutionError('Unable to open a blank page ', ex);
    }
}

const loadUrl = async function (page, url, takeScreenshot) {
    let stepName = null;
    let domcontentloaded = false;

    try {
        stepName = new URL(url).hostname;
    } catch (error) {
        const errorString = `Error parsing url: ${url}.  ${error}`;
        log.error(errorString);
        /* If we fail to parse the URL, don't emit a metric with a stepName based on it.
           It may not be a legal CloudWatch metric dimension name and we may not have an alarms
           setup on the malformed URL stepName.  Instead, fail this step which will
           show up in the logs and will fail the overall canary and alarm on the overall canary
           success rate.
        */
        throw error;
    }

    await synthetics.executeStep(stepName, async function () {
        const sanitizedUrl = syntheticsLogHelper.getSanitizedUrl(url);

        /* You can customize the wait condition here. For instance, using 'networkidle2' or 'networkidle0' to load page completely.
           networkidle0: Navigation is successful when the page has had no network requests for half a second. This might never happen if page is constantly loading multiple resources.
           networkidle2: Navigation is successful when the page has no more then 2 network requests for half a second.
           domcontentloaded: It's fired as soon as the page DOM has been loaded, without waiting for resources to finish loading. Can be used and then add explicit await page.waitFor(timeInMs)
        */
        const response = await page.goto(url, { waitUntil: ['domcontentloaded'], timeout: 30000});
        if (response) {
            domcontentloaded = true;
            const status = response.status();
            const statusText = response.statusText();

            logResponseString = `Response from url: ${sanitizedUrl}  Status: ${status}  Status Text: ${statusText}`;

            //If the response status code is not a 2xx success code
            if (response.status() < 200 || response.status() > 299) {
                throw `Failed to load url: ${sanitizedUrl} ${response.status()} ${response.statusText()}`;
            }
        } else {
            const logNoResponseString = `No response returned for url: ${sanitizedUrl}`;
            log.error(logNoResponseString);
            throw new Error(logNoResponseString);
        }
    });

    // Wait for 15 seconds to let page load fully before taking screenshot.
    if (domcontentloaded && takeScreenshot) {
        await page.waitFor(15000);
        await synthetics.takeScreenshot(stepName, 'loaded');
        await resetPage(page);
    }
};

const urls = [];

exports.handler = async () => {
    return await loadBlueprint();
};
```
</details>

# 調査方法
正常に稼働している Synthetics Canary に対して AWS CLI からレイヤーの zip を落として解凍してみた。

```
aws lambda get-layer-version \
--layer-name <layer-name> \
--version-number 1
```

# 一言
なるほどね。