#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) });
}