Асинхронная лямбда-функция: возврат обещания или отправка responseURL не завершает вызов пользовательского ресурса CloudFormation

#javascript #node.js #asynchronous #aws-lambda #aws-serverless

#javascript #node.js #асинхронный #aws-lambda #aws-бессерверный

Вопрос:

У меня есть лямбда-функция, вызываемая как пользовательский ресурс через шаблон CloudFormation. Он создает / удаляет экземпляры AWS Connect. Вызовы API работают нормально, но, похоже, я не могу завершить вызов пользовательского ресурса, поэтому последним блоком CF остается CREATE_IN_PROGRESS. Независимо от того, что я возвращаю из асинхронной функции, она просто не завершит выполнение CF с успехом.

Я могу успешно использовать неасинхронный обработчик, как в https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/walkthrough-custom-resources-lambda-lookup-amiids.html но мне нужно выполнить несколько вызовов API и дождаться завершения, отсюда и необходимость в асинхронном обработчике.

Ниже приведен код в его простейшей форме, хотя я пробовал практически все, включая использование обратного вызова и контекста (т.е. exports.handler = функция async(событие, контекст, обратный вызов) {…}), оба из которых должны быть ненужными с обработчиком async. Я пытался использовать cfn-response для прямой отправки ответа, который, похоже, игнорируется асинхронными обработчиками. Я пытался напрямую возвращать обещания с ожиданием перед ними и без него, пытался возвращать переменные, содержащие различные ResponseStatus и responseData, похоже, ничего не работает.

 Transform: 'AWS::Serverless-2016-10-31'
Parameters:
  IdentityManagementType:
    Description: The type of identity management for your Amazon Connect users.
    Type: String
    AllowedValues: ["SAML", "CONNECT_MANAGED", "EXISTING_DIRECTORY"]
    Default: "SAML"
  InboundCallsEnabled:
    Description: Whether your contact center handles incoming contacts.
    Type: String
    AllowedValues: [true, false]
    Default: true
  InstanceAlias:
    Description: The name for your instance.
    Type: String
    MaxLength: 62
  OutboundCallsEnabled:
    Description: Whether your contact center allows outbound calls.
    Type: String
    AllowedValues: [true, false]
    Default: true
  DirectoryId:
    Description: Optional. The identifier for the directory, if using this type of Identity Management.
    Type: String
  ClientToken:
    Description: Optional. The idempotency token. Used for concurrent deployments
    Type: String
    MaxLength: 500
  Region:
    Description: Region to place the AWS Connect Instance
    Type: String
    Default: us-east-1
#Handler for optional values
Conditions:
  HasClientToken: !Not
    - !Equals
      - ""
      - !Ref ClientToken
  HasDirectoryId: !Not
    - !Equals
      - ""
      - !Ref DirectoryId

Resources:
  CreateConnectInstance:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-AWSConnectInstance"
      Handler: index.handler
      Runtime: nodejs12.x
      Description: Invoke a function to create an AWS Connect instance.
      MemorySize: 128
      Timeout: 30
      Role: !GetAtt LambdaExecutionRole.Arn
      Layers:
        - !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:layer:node_sdk:1"
      Environment:
        Variables:
          IdentityManagementType:
            Ref: IdentityManagementType
          InboundCallsEnabled:
            Ref: InboundCallsEnabled
          InstanceAlias:
            Ref: InstanceAlias
          OutboundCallsEnabled:
            Ref: OutboundCallsEnabled
          Region:
            Ref: Region
          #Optional Values
          ClientToken: !If
            - HasClientToken
            - !Ref ClientToken
            - !Ref "AWS::NoValue"
          DirectoryId: !If
            - HasClientToken
            - !Ref ClientToken
            - !Ref "AWS::NoValue"
      InlineCode: |
        var aws = require("aws-sdk");
        exports.handler = async function(event) {
            console.log("REQUEST RECEIVED:n"   JSON.stringify(event));
            var connect = new aws.Connect({region: event.ResourceProperties.Region});
            var isInboundCallsEnabled = (process.env.InboundCallsEnabled == 'true');
            var isOutboundCallsEnabled = (process.env.OutboundCallsEnabled == 'true');
            var createInstanceParams = {
                InboundCallsEnabled: isInboundCallsEnabled,
                OutboundCallsEnabled: isOutboundCallsEnabled,
                IdentityManagementType: process.env.IdentityManagementType,
                ClientToken: process.env.ClientToken,
                DirectoryId: process.env.DirectoryId,
                InstanceAlias: process.env.InstanceAlias
            };

            // Create AWS Connect instance using specified parameters
            if (event.RequestType == "Create") {
                return await connect.createInstance(createInstanceParams).promise();
                // I can store this in a variable and read the contents fine, but...
                // returning the promise does not terminate execution
            }
        };


  InvokeCreateConnectInstance:
    Type: Custom::CreateConnectInstance
    Properties:
      ServiceToken: !GetAtt CreateConnectInstance.Arn
      Region: !Ref "AWS::Region"
 

Документ, приведенный в https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html явно указано, что вы должны иметь возможность возвращать await ApiCall.promise() непосредственно из любой асинхронной функции, именно то, что я пытаюсь сделать, например

 const s3 = new AWS.S3()

exports.handler = async function(event) {
  return s3.listBuckets().promise()
}
 

Почему я не могу вернуться из своей асинхронной функции? Снова вызовы API работают, экземпляры Connect создаются и удаляются (хотя я опустил код удаления для краткости), но CF просто зависает часами и часами, пока в конечном итоге не скажет «Пользовательский ресурс не удалось стабилизировать в ожидаемое время»

Вот встроенный код сам по себе для удобства чтения:

         exports.handler = async function(event) {
            console.log("REQUEST RECEIVED:n"   JSON.stringify(event));
            var connect = new aws.Connect({region: event.ResourceProperties.Region});
            var isInboundCallsEnabled = (process.env.InboundCallsEnabled == 'true');
            var isOutboundCallsEnabled = (process.env.OutboundCallsEnabled == 'true');
            var createInstanceParams = {
                InboundCallsEnabled: isInboundCallsEnabled,
                OutboundCallsEnabled: isOutboundCallsEnabled,
                IdentityManagementType: process.env.IdentityManagementType,
                ClientToken: process.env.ClientToken,
                DirectoryId: process.env.DirectoryId,
                InstanceAlias: process.env.InstanceAlias
            };

            // Create AWS Connect instance using specified parameters
            if (event.RequestType == "Create") {
                return await connect.createInstance(createInstanceParams).promise();
                // I can store this in a variable and read the contents fine, but...
                // returning the promise does not terminate CF execution
            }
          };
 

ОБНОВЛЕНИЕ: я реализовал метод sendResponse точно так, как показано в примере поиска AMI (первая ссылка), и отправляю точно правильную структуру для ответа, она даже включает вновь созданный идентификатор экземпляра подключения в поле данных:

 {
    "Status": "SUCCESS",
    "Reason": "See the details in CloudWatch Log Stream: 2020/12/23/[$LATEST]6fef3553870b4fba90479a37b4360cee",
    "PhysicalResourceId": "2020/12/23/[$LATEST]6fef3553870b4fba90479a37b4360cee",
    "StackId": "arn:aws:cloudformation:us-east-1:642608065726:stack/cr12/1105a290-4534-11eb-a6de-0a8534d05dcd",
    "RequestId": "2f7c3d9e-941f-402c-b739-d2d965288cfe",
    "LogicalResourceId": "InvokeCreateConnectInstance",
    "Data": {
        "InstanceId": "2ca7aa49-9b20-4feb-8073-5f23d63e4cbc"
    }
}
 

И ВСЕ ЖЕ пользовательский ресурс просто не закроется в CloudFormation. Я просто не понимаю, почему это происходит, когда я возвращаю вышеуказанное в event.responseURL. Это похоже на указание асинхронного обработчика, который полностью нарушает пользовательский обработчик ресурсов и предотвращает его закрытие.

ОБНОВЛЕНИЕ: Когда я вручную СКРУЧИВАЮ приведенный выше ответ непосредственно в event.responseUrl, ресурс CF регистрирует успех! WTF… Я отправляю точно такой же ответ, который отправляет лямбда-функция, и он принимает его от CURL, но не от моей лямбда-функции.

ОБНОВЛЕНИЕ: последний код, включая sendResponse и т. Д

 var aws = require("aws-sdk");
exports.handler = async function(event, context, callback) {
    console.log("REQUEST RECEIVED:n"   JSON.stringify(event));
    var connect = new aws.Connect({region: event.ResourceProperties.Region});
    var isInboundCallsEnabled = (process.env.InboundCallsEnabled == 'true');
    var isOutboundCallsEnabled = (process.env.OutboundCallsEnabled == 'true');
    var createInstanceParams = {
        InboundCallsEnabled: isInboundCallsEnabled,
        OutboundCallsEnabled: isOutboundCallsEnabled,
        IdentityManagementType: process.env.IdentityManagementType,
        ClientToken: process.env.ClientToken,
        DirectoryId: process.env.DirectoryId,
        InstanceAlias: process.env.InstanceAlias
    };
    var responseStatus;
    var responseData = {};

    // Create Connect instance
    if (event.RequestType == "Create") {
        try {
            var createInstanceRequest = await connect.createInstance(createInstanceParams).promise();
            responseStatus = "SUCCESS";
            responseData = {"InstanceId": createInstanceRequest.Id};
        } catch (err) {
            responseStatus = "FAILED";
            responseData = {Error: "CreateInstance failed"};
            console.log(responseData.Error   ":n", err);
        }
        sendResponse(event, context, responseStatus, responseData);
        return;
    }

    // Look up the ID and call deleteInstance.
    if (event.RequestType == "Delete") {
        var instanceId;
        var listInstanceRequest = await connect.listInstances({}).promise();
        listInstanceRequest.InstanceSummaryList.forEach(instance => {
            if (instance.InstanceAlias == createInstanceParams.InstanceAlias) {
                instanceId = instance.Id;
            }
        });
        if (instanceId !== undefined) {
            try {
                var deleteInstanceRequest = await connect.deleteInstance({"InstanceId": instanceId}).promise();
                responseStatus = "SUCCESS";
                responseData = {"InstanceId": instanceId};
            } catch (err) {
                responseStatus = "FAILED";
                responseData = {Error: "DeleteInstance call failed"};
                console.log(responseData.Error   ":n", err);
            }
        } else {
            responseStatus = "FAILED";
            responseData = {Error: "DeleteInstance failed; no match found"};
            console.log(responseData.Error);
        }
        sendResponse(event, context, responseStatus, responseData);
        return;
    }
};

// Send response to the pre-signed S3 URL 
function sendResponse(event, context, responseStatus, responseData) {
    var responseBody = JSON.stringify({
        Status: responseStatus,
        Reason: "CloudWatch Log Stream: "   context.logStreamName,
        PhysicalResourceId: context.logStreamName,
        StackId: event.StackId,
        RequestId: event.RequestId,
        LogicalResourceId: event.LogicalResourceId,
        Data: responseData
    });
    console.log("RESPONSE BODY:n", responseBody);
    var https = require("https");
    var url = require("url");
    var parsedUrl = url.parse(event.ResponseURL);
    var options = {
        hostname: parsedUrl.hostname,
        port: 443,
        path: parsedUrl.path,
        method: "PUT",
        headers: {
            "content-type": "",
            "content-length": responseBody.length
        }
    };
    console.log("SENDING RESPONSE...n");
    var request = https.request(options, function(response) {
        console.log("STATUS: "   response.statusCode);
        console.log("HEADERS: "   JSON.stringify(response.headers));
        // Tell AWS Lambda that the function execution is done  
        context.done();
    });
    request.on("error", function(error) {
        console.log("sendResponse Error:"   error);
        // Tell AWS Lambda that the function execution is done  
        context.done();
    });
    // write data to request body
    request.write(responseBody);
    request.end();
}
 

Занимаюсь этим уже два дня: (

PS в журналах «ТЕЛО ОТВЕТА» отображается так, как ожидалось, как я скопировал выше, и в журнале отображается «ОТПРАВЛЯЮЩИЙ ОТВЕТ», но не попадает в «СТАТУС: » и «ЗАГОЛОВКИ: » часть запроса.https() вызов, что заставляет меня думать, что что-то с асинхронным вмешательствомпри этом вызове … IDK

Ответ №1:

Это было действительно сложно, но в конце концов я во всем разобрался. Мне пришлось сделать функцию sendResponse асинхронной, добавив к ней обещание, ожидая этого обещания и возвращая его. Это позволило мне в конечном итоге вызвать «return await sendResponse(событие, контекст, ResponseStatus, responseData);» и, наконец, все работает, операции создания и удаления выполнены успешно, и пользовательский ресурс CloudFormation завершается, как ожидалось. Фух. Публикую код здесь в надежде, что другие извлекут из него пользу.

 var aws = require("aws-sdk");
exports.handler = async function(event, context, callback) {
    console.log("REQUEST RECEIVED:n"   JSON.stringify(event));
    var connect = new aws.Connect({region: event.ResourceProperties.Region});
    var isInboundCallsEnabled = (process.env.InboundCallsEnabled == 'true');
    var isOutboundCallsEnabled = (process.env.OutboundCallsEnabled == 'true');
    var createInstanceParams = {
        InboundCallsEnabled: isInboundCallsEnabled,
        OutboundCallsEnabled: isOutboundCallsEnabled,
        IdentityManagementType: process.env.IdentityManagementType,
        ClientToken: process.env.ClientToken,
        DirectoryId: process.env.DirectoryId,
        InstanceAlias: process.env.InstanceAlias
    };
    var responseStatus;
    var responseData = {};
    if (event.RequestType == "Create") {
        try {
            var createInstanceRequest = await connect.createInstance(createInstanceParams).promise();
            responseStatus = "SUCCESS";
            responseData = {"InstanceId": createInstanceRequest.Id};
        } catch (err) {
            responseStatus = "FAILED";
            responseData = {Error: "CreateInstance failed"};
            console.log(responseData.Error   ":n", err);
        }
        return await sendResponse(event, context, responseStatus, responseData);
    }

    if (event.RequestType == "Delete") {
        var instanceId;
        var listInstanceRequest = await connect.listInstances({}).promise();
        listInstanceRequest.InstanceSummaryList.forEach(instance => {
            if (instance.InstanceAlias == createInstanceParams.InstanceAlias) {
                instanceId = instance.Id;
            }
        });
        if (instanceId !== undefined) {
            try {
                var deleteInstanceRequest = await connect.deleteInstance({"InstanceId": instanceId}).promise();
                responseStatus = "SUCCESS";
                responseData = {"InstanceId": instanceId};
            } catch (err) {
                responseStatus = "FAILED";
                responseData = {Error: "DeleteInstance call failed"};
                console.log(responseData.Error   ":n", err);
            }
        } else {
            responseStatus = "FAILED";
            responseData = {Error: "DeleteInstance failed; no match found"};
            console.log(responseData.Error);
        }
        return await sendResponse(event, context, responseStatus, responseData);
    }
};

async function sendResponse(event, context, responseStatus, responseData) {
    let responsePromise = new Promise((resolve, reject) => {
        var responseBody = JSON.stringify({
            Status: responseStatus,
            Reason: "CloudWatch Log Stream: "   context.logStreamName,
            PhysicalResourceId: context.logStreamName,
            StackId: event.StackId,
            RequestId: event.RequestId,
            LogicalResourceId: event.LogicalResourceId,
            Data: responseData
        });
        console.log("RESPONSE BODY:n", responseBody);
        var https = require("https");
        var url = require("url");
        var parsedUrl = url.parse(event.ResponseURL);
        var options = {
            hostname: parsedUrl.hostname,
            port: 443,
            path: parsedUrl.path,
            method: "PUT",
            headers: {
                "content-type": "",
                "content-length": responseBody.length
            }
        };
        console.log("SENDING RESPONSE...n");
        var request = https.request(options, function(response) {
            console.log("STATUS: "   response.statusCode);
            console.log("HEADERS: "   JSON.stringify(response.headers));
            resolve(JSON.parse(responseBody));
            context.done();
        });
        request.on("error", function(error) {
            console.log("sendResponse Error:"   error);
            reject(error);
            context.done();
        });
        request.write(responseBody);
        request.end();
    });
    return await responsePromise;
}
 

Комментарии:

1. IMHO AWS следует опубликовать документацию о том, как возвращать запросы из пользовательских ресурсов с помощью асинхронных обработчиков, поскольку в настоящее время она охватывает только синхронные случаи. Я определенно буду использовать это в качестве модели для будущих пользовательских ресурсов, требующих асинхронности.

2. Спасибо за предоставление этого полного примера и вашего мыслительного процесса по его проработке.

3. Это очень странно. Похоже, что AWS неправильно реализовал cfn-response запуск запроса HTTPS без ожидания ответа. Почему это работает для лямбд синхронизации, а не для асинхронных лямбд, остается загадкой, но, возможно, асинхронные лямбды завершаются раньше, чем лямбды синхронизации, что предотвращает отправку запроса HTTPS? Было бы здорово, если бы кто-нибудь из AWS мог исследовать, прокомментировать и исправить их реализацию cfn-response и связанную документацию

4. Спасибо за этот ответ, удивлен, что документация AWS настолько плоха.

Ответ №2:

Этот ответ является вариантом ответа OP для тех, кто использует опцию «ZipFile» в свойстве «Code» ресурса AWS :: Lambda::Function в CloudFormation. Преимущество подхода ZipFile заключается в том, что в дополнение к разрешению лямбда-кода, встроенного в шаблон CF, он также автоматически связывает «cfn-response.js » функция очень похожа на «async function sendResponse» в ответе OP. С пониманием, полученным из ответа OP относительно обещанного ответа (спасибо, я застрял и был озадачен), вот как я включил функцию cfn-response в качестве ожидаемого обещания для передачи сигнала CF после завершения моих асинхронных вызовов AWS API (для краткости опущен):

 CreateSnapshotFunction:
    Type: AWS::Lambda::Function
    Properties:
        Runtime: nodejs12.x
        Handler: index.handler
        Timeout: 900 # 15 mins
        Code:
            ZipFile: !Sub |
                const resp = require('cfn-response');
                const aws = require('aws-sdk');
                const cf = new aws.CloudFormation({apiVersion: '2010-05-15'});
                const rds = new aws.RDS({apiVersion: '2014-10-31'});

                exports.handler = async function(evt, ctx) {
                    if (evt.RequestType == "Create") {
                        try {
                            // Query the given CF stack, determine its database
                            // identifier, create a snapshot of the database,
                            // and await an "available" status for the snapshot
                            let stack = await getStack(stackNameSrc);
                            let srcSnap = await createSnapshot(stack);
                            let pollFn = () => describeSnapshot(srcSnap.DBSnapshot.DBSnapshotIdentifier);
                            let continueFn = snap => snap.DBSnapshots[0].Status !== 'available';
                            await poll(pollFn, continueFn, 10, 89); // timeout after 14 min, 50 sec

                            // Send response to CF
                            await send(evt, ctx, resp.SUCCESS, {
                                SnapshotId: srcSnap.DBSnapshot.DBSnapshotIdentifier,
                                UpgradeRequired: upgradeRequired
                            });
                        } catch(err) {
                            await send(evt, ctx, resp.FAILED, { ErrorMessage: err } );
                        }
                    } else {
                        // Send success to CF for delete and update requests
                        await send(evt, ctx, resp.SUCCESS, {});
                    }
                };

                function send(evt, ctx, status, data) {
                    return new Promise(() => { resp.send(evt, ctx, status, data) });
                }