PHPCon 2023 moim okiem
![](/posty/phpcon-2023/phpcon23.jpg)
Jedna z możliwości obsługi długotrwałych procesów w AWS Lambda gdy korzystasz z API Gateway.
Lambda w AWS może pracować do 15 minut. Po tym czasie się wyłącza. 15 minut. Przecież mi wystarczy. Puszczę request na API Gateway i... No właśnie. API GW ma swoje nieprzekraczalne limity. 30 sekund i koniec.
Poniżej postaram się zaprezentować jeden ze sposobów obejścia powyższego problemu.
Jeżeli nie interesuje cię droga do problemu, a samo rozwiązanie, to na końcu wpisu jest gotowiec
Jak wygląda procesowanie danych w lambdzie z API GW?
import { APIGatewayProxyEventV2, Context, Handler } from 'aws-lambda';
const entrypoint: Handler<APIGatewayProxyEventV2, { statusCode: number }> = async (
event,
context,
callback,
) => {
// <magic>
return { statusCode: 200 };
};
export default entrypoint;
Schody zaczynają się, gdy <magic> trwa dłużej niż 30 sekund. Response 5xx, proces przerwany, w logach też za dużo się nie dzieje. Co gorsze, uruchomienie procesu lokalnie działa bez zarzutu. Źródłem problemu jest maksymalny czas zapytania do API Gateway.
Na nic sztuczki w postaci uruchomienia długiego procesu asynchronicznie i wczesny response. Proces umiera z chwilą wysłania odpowiedzi.
Jest kilka możliwości obejścia tego problemu. Puszczenie wiadomości na SQS, dodatkowa lambda, która wykona zadanie, EC2 zamiast API GW. Ale jest jeszcze jedno rozwiązanie, które idealnie nadaje się do małych funkcji. “Samo-wywołanie” lambdy. Proces jest podobny do bazowego:
Dobra, proces jest identyczny, ale <magic> zaczyna się od warunku (to się if
em zaklei).
Minusy takiego rozwiązania? Status-code może będzie kłamać. API GW zwróci 202 zamiast 200, a magia stanie się w tle.
Załóżmy, że nasza lambda nazywa się devnotes-demo-lambda
. Trzeba teraz ustawić na niej odpowiednie uprawnienia. Poniższy fragment kodu zezwala na wywołanie devnotes-demo-lambda
z procesu lambdy.
{
"Statement": [
{
"Action": ["lambda:InvokeFunction"],
"Effect": "Allow",
"Resource": [
"arn:aws:lambda:*:*:function:devnotes-demo-lambda"
]
}
],
"Version": "2012-10-17"
}
Możemy już przejść do kodu. Zacznijmy od zdefiniowania nowego eventu:
type CustomEvent = { eventType: 'DevNotesCustomEvent' };
type HandlerEvent = APIGatewayProxyEventV2 | CustomEvent;
To tego dorzucamy helper, który pozwala wykryć niestandardowe zdarzenia:
const isCustomEvent = (event: HandlerEvent): event is CustomEvent =>
'eventType' in event && event?.eventType === 'DevNotesCustomEvent';
Teraz kosmetyczne zmiany w entrypoint
. Zmieniamy typ eventu i dodajemy obsługę CustomEvent
. W ten sposób, 1 handler jest w stanie obsłużyć 2 różne eventy:
const entrypoint: Handler<HandlerEvent, { statusCode: number }> = async (
event,
context,
callback,
) => {
if (isCustomEvent(event)) {
// handle custom event
return { statusCode: 202 };
}
// handle api event
return { statusCode: 200 };
};
Dobrze, ale to nie koniec zmian. Czas na wywołanie lambdy, czyli to, co rozwiązuje problem timeoutów. Najpierw trigger.
const triggerCustomEventFromApi = async (context: Context) => {
const client = new LambdaClient({
region: process.env?.AWS_REGION || '...',
});
await client.send(
new InvokeCommand({
FunctionName: context.functionName,
Payload: JSON.stringify({
eventType: 'DevNotesCustomEvent',
}),
LogType: 'None',
InvocationType: 'Event',
}),
);
};
Co tu się dzieje? Tworzymy klienta LambdaClient
. Zmienna środowiskowa AWS_REGION
jest ustawiana przez AWS. Następnie wywołujemy lambdę pobierając jej nazwę z kontekstu: FunctionName: context.functionName
. Dzięki temu, niezależnie od regionu czy nazwy funkcji, wywołanie zadziała.
Skoro mamy trigger, czas na obsługę z poziomu API. Gdy otrzymamy request ze ścieżką /triggerCustomEvent
, uruchamiamy trigger i zwracamy status 202 Accepted
. Każda inna ścieżka może być obsłużona niezależnie.
const handleApiEvent: Handler = async (
event,
context,
callback
) => {
if (event.rawPath === '/triggerCustomEvent') {
await triggerCustomEventFromApi(context);
return { statusCode: 202 };
}
// other routes <magic>
// no route matched? 404!
return { statusCode: 404 };
};
Kilka funkcji później nasza implementacja wygląda jak poniżej. Najpierw konfiguracja uprawnień:
{
"Statement": [
{
"Action": ["lambda:InvokeFunction"],
"Effect": "Allow",
"Resource": [
"arn:aws:lambda:*:*:function:devnotes-demo-lambda"
]
}
],
"Version": "2012-10-17"
}
Mają uprawnienia, można aktualizować kod:
import { APIGatewayProxyEventV2, Context, Handler } from 'aws-lambda';
import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda';
type CustomEvent = { eventType: 'DevNotesCustomEvent' };
type HandlerEvent = APIGatewayProxyEventV2 | CustomEvent;
const isCustomEvent = (event: HandlerEvent): event is CustomEvent =>
'eventType' in event && event?.eventType === 'DevNotesCustomEvent';
const triggerCustomEventFromApi = async (context: Context) => {
const client = new LambdaClient({
region: process.env?.AWS_REGION || '...',
});
await client.send(
new InvokeCommand({
FunctionName: context.functionName,
Payload: JSON.stringify({
eventType: 'DevNotesCustomEvent',
}),
LogType: 'None',
InvocationType: 'Event',
}),
);
};
const handleApiEvent: Handler = async (event, context, callback) => {
if (event.rawPath === '/triggerCustomEvent') {
await triggerCustomEventFromApi(context);
return { statusCode: 202 };
}
// other routes' <magic>
// no route matched? 404!
return { statusCode: 404 };
};
const handleCustomEvent: Handler = async (event, context, callback) => {
// <magic>
return { statusCode: 200 };
};
const entrypoint: Handler<HandlerEvent, { statusCode: number }> = async (
event,
context,
callback,
) => {
if (isCustomEvent(event)) {
return handleCustomEvent(event, context, callback);
}
return handleApiEvent(event, context, callback);
};
export default entrypoint;
Jak już wspomniałem, takie rozwiązanie jest przydatne przy małych lambdach. Nada się również, gdy tylko jeden z kilku endpointów wymaga dłuższego procesowania. Jest to o tyle wygodne, że cały kod żyje w jednym miejscu.
Największym minusem wydaje się brak odpowiedzi bezpośrednio po wywołaniu funkcji. Aby się do niej dobrać trzeba albo grzebać w logach. Oczywiście możliwe jest też wysłanie powiadomienia na Slack czy zapis do DynamoDB.
W przypadku większej ilości takich funkcji raczej kierowałbym się w stronę osobnych implementacji dla każdej z nich.