Lambda를 활용한 짧은 주기 배치 구현
1. 개요
배치는 처리해야 할 작업들을 모아서 일괄로 처리하는 작업을 의미합니다.
특정 시간마다 실행할 수 있도록 스케줄링을 하고 배치가 실행되어 한꺼번에 작업을 처리하게 됩니다.
실시간 처리가 아니기 때문에 빠른 응답이 필요하지 않은 서비스가 적합합니다.
본 문서에서는 배치 구성을 하며 겪었던 시행착오와 고민했었던 사항들을 공유합니다.
1.1. 짧은 주기 배치에 대한 고민
주로 Background 에서 주기적으로 수행하는 업무들을 배치 형태로 구성을 합니다.
실행 간격이 1초에 한 번 실행과 같이 짧은 주기로 실행이 되는 경우에는 이 업무가 배치로 구성하는 것이 적합한지 다시 한번 고민해 봐야 합니다.
짧은 주기의 배치는 near realtime 처리를 필요로 하는 업무일 가능성이 큽니다.
이러한 케이스는 배치가 아닌 실시간으로 처리할 수 있는 방안을 고민하는 것이 우선입니다.
kafka와 같은 메시지 큐를 활용하여 이벤트 드리븐으로 데이터 처리를 할 수도 있습니다.
하지만 기존의 배치를 실시간 처리로 변경하려면 데이터를 송신하는 곳의 변경이 필요합니다.
1.1.1. 상시 배치 컨테이너 구성(Batch on EKS)
처음에는 EKS Fargate 환경을 사용하여 배치를 구현했었습니다.
Fargate는 기동시 약 60~90초의 시간이 필요한 제약사항이 있습니다.
모든 배치 Job을 각각의 Fargate 컨테이너로 Job을 실행하는 것은 비효율적이기 때문에 여러 개의 Job을 실행할 수 있는 상시 배치용 컨테이너를 구성했습니다.
이 환경은 Fargate로 구성되지만, Job 실행을 위한 1회성 환경이 아닌 종료되지 않고 계속 유지되는 환경입니다.
짧은 주기로 실행되는 배치 Job을 동일한 Fargate 컨테이너에서 주기적으로 실행되도록 합니다.
Fargate가 유지되므로, Startup 시간은 최초 1회만 필요하며 빠르게 Job을 실행할 수 있습니다.
단, 비용은 일반 Backend 서비스처럼 Fargate가 상시 가동되는 비용이 발생합니다.
1.1.2. Java의 Start시 Resource 과다 사용 문제
배치로 Java를 사용하는 것은 문제가 있었습니다. Job 실행시 각 Job 간의 독립성을 보장하기 위해서 Java 프로세스를 매번 새로 실행을 했습니다.
이로 인해 Java 프로세스 시작시 CPU 사용량 증가로 인해 리소스 낭비가 발생을 했는데요.
Java는 컴파일 단계에서 JVM에서 실행 가능한 바이트코드를 생성 후 런타임에 JVM에서 바이트코드 로드 및 네이티브 실행 코드로 변환하기 때문에 초기에 CPU 리소스를 많이 사용합니다.
이러한 단계는 애플리케이션이 제대로 작동하는 데 필수적이지만 상당한 오버헤드가 발생하고 애플리케이션의 최초 실행이 느려지게 됩니다.
또한 Java의 Spring과 같은 Framework를 사용하면 로드 및 실행에 더 많은 시간이 소요됩니다.
Java는 장시간 사용할 경우 성능에 이익을 볼 수 있는 구조이므로 단기 성 배치에는 불리해질 수 있습니다.
여러 개의 배치 Job이 수시로 시작하고 정지하는 환경에서는 CPU 사용량이 꾸준히 높게 유지가 되는 것을 확인 할 수 있었습니다.
2. Lambda 배치 구성
배치 Job간의 독립성을 보장하면서도 비용을 줄일 수 있는 방안으로 Lambda를 고려하게 되었습니다.
마침 Lambda SnapStart 기능이 서울 리전에서 사용할 수 있게 되어서 짧은 주기 배치를 해결할 수 있을 것 같았습니다.
2.1. Lambda 제약 사항
먼저 배치를 구현하기에 부족한 부분은 없는지 Lambda의 제약 사항을 확인했습니다.
Lambda는 아래와 표와 같이 배포 패키지 크기 및 스토리지, 메모리, 제한 시간(Timeout) 등의 제약 사항이 있습니다.
짧은 주기 배치는 긴 시간 동안 실행을 하지 않기 때문에 제한 시간은 문제가 되지 않았습니다.
또한 대량의 데이터 처리가 필요하지 않기 때문에 메모리도 충분했습니다.
2.2. Lambda 장점
몇 가지 제약 사항을 제외하면 Lambda는 배치로 사용하기에 꽤 훌륭한 서비스입니다.
동시 성 스케일링 보장 및 빠른 실행, 저비용, 관리 부담 절감 등은 Lambda 배치 구성 시 큰 장점입니다.
2.2.1. 동시성 스케일링
대용량 데이터 처리 및 동시에 많은 Job을 처리해야 될 경우에는 급격하게 인스턴스를 늘려서 병렬 처리를 해야 합니다.
Lambda를 사용한다면 자원을 10초당 1,000개씩 늘리는 것이 가능합니다.
별도의 설정 없이 Request 수에 따라서 자동으로 자원이 늘어나고 줄어들기 때문에 편리합니다.
비용도 사용만 만큼만 지불하기 때문에 idle 인스턴스에 대한 고민을 할 필요도 없습니다.
2.2.2. 저비용
Lambda는 실행 시간과 요청 횟수, 메모리 크기에 따라 가격이 결정됩니다.
Lambda는 호출이 발생하고 실행 중인 시간 동안만 과금이 되므로, 사용하지 않는 기간에는 비용이 발생하지 않습니다.
Lambda로 2GB 메모리를 사용하는 1초짜리 배치를 40만건 실행할 경우 약 $13 가 발생합니다.
Fargate 로 0.25 vCPU, 2GB 스펙의 컨테이너 1대를 한 달간 사용을 했다면 약 $478 발생합니다.
Fargate 대비해서 비용을 많이 절감할 수 있는 것을 알 수 있습니다.
2.2.3. 멱등성(Idempotent)
동일한 작업을 몇 번을 수행해도 같은 결과를 받을 수 있을 때 멱등하다고 합니다.
Lambda는 Function as a Service 라고도 불리는 Stateless 서비스입니다.
배치 Job을 Lambda로 실행시 각 Job은 완벽히 분리된 환경에서 동일한 function을 사용하여 처리하므로 서로 간섭이 일어나지 않습니다.
그렇기 때문에 몇 번을 실행해도 동일한 결과를 보장합니다.
각 배치 Job의 독립성을 보장하면서도 언제나 같은 상태를 유지할 수 있다는 것은 큰 장점이었습니다.
2.2.4. 관리 부담 절감
서버가 있을 경우 각종 패치와 버전 업그레이드로 인해서 관리의 부담이 됩니다.
Lambda는 내부에서 주기적으로 패치와 업그레이드가 되므로 개발자는 신경 쓸 필요가 없습니다.
갑작스럽게 트래픽이 증가하더라도 자동으로 자원을 추가 프로비저닝하여 대응을 하고 사용한 만큼만 비용을 부과하므로 관리가 어렵지 않습니다.
개발자는 서버 관리에 대한 부담 없이 코드에만 집중할 수 있습니다.
2.3. Lambda Java 배치
2.3.1. Lambda SnapStart
2022년 AWS re:invent 에서 Lambda SnapStart 기능을 발표했습니다.
SnapStart는 Lambda의 사전 초기화된 스냅샷을 생성하고 Cold Start시 해당 스냅샷을 사용하여 코드를 실행하는 기능입니다.
Java를 배치에 사용할 경우의 큰 단점이었던 초기의 오버헤드를 SnapStart를 통해서 해결할 수 있게 되었습니다.
SnapStart를 사용하면 Cold Start 시간을 줄일 수 있을 뿐만 아니라 Lambda 실행 시간 단축으로 인해 사용 비용도 절감이 됩니다.
Lambda 기반의 Java Application 을 실행하는 경우에는 큰 도움이 될 것이 분명합니다.
특히 Spring Framework 처럼 Bootstrap 단계에서 많은 양의 처리가 필요한 경우에는 SnapStart는 필수적으로 사용을 해야 합니다.
<제약 사항>
- 지원되는 런타임이 Java 11 이상의 AWS 관리형 런타임으로 제한되어 있고,
- 프로비저닝된 동시성, arm64 아키텍처, Amazon EFS 또는 512MB를 초과하는 임시 스토리지는 지원하지 않습니다.(참고)
2.3.2. Lambda에서 Spring Boot 구성하기
Lambda SnapStart 를 사용하면 Spring Boot 을 사용하는 것도 부담 되지 않습니다.
Spring Boot 을 사용하면 개발자들에게 익숙한 코드들을 사용할 수 있기 때문에 개발 생산성이 올라갑니다.
Lambda에서 Spring Boot 을 실행하기 위해서 기존의 Spring Boot 코드들을 그대로 사용할 수 있습니다.
Lambda의 호출을 받을 수 있는 handleRequest Mothod를 포함하여 LambdaHandler Class 를 추가로 구성해 주면 됩니다.
Spring Boot 을 위한 handler 를 전역변수로 선언하고, 정의하는 코드는 Class의 생성자에 추가하였습니다.
public class LambdaHandler implements RequestHandler<AwsProxyRequest, AwsProxyResponse> {
private static SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler;
public LambdaHandler() {
try {
handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(MyApplication.class);
} catch (ContainerInitializationException ex) {
throw new RuntimeException("Unable to start spring boot application", ex);
}
}
@Override
public AwsProxyResponse handleRequest(AwsProxyRequest input, Context context) {
return handler.proxy(input, context);
}
}
2.4. 배치의 DB Connection Pool
Spring에서 많이 사용하는 hikaricp 를 이용하여 DB의 Connection Pool 을 관리하고 있습니다.
Connection Pool은 DB Connection 을 열고 닫을 때 발생하는 CPU, Memory, 네트워크 오버헤드를 줄이기 위해서 Connection 을 열린 상태로 유지하며 재활용하는 기능입니다.
일반적인 Backend Service에서는 Connection Pool 사용이 일반적입니다.
하지만 배치에서는 DB Connection Pool이 정말 필요한지 고민해 봐야 합니다.
2.4.1. Connection Pool 제거
배치는 Immutable 하게 구성하기 위해서 Stateless 상태를 유지하는 것이 좋습니다.
Job 시작 시 다른 요청에서 사용했던 Connection 을 재사용 하는 것은 속도를 빠르게 할 수는 있지만 안정성을 해칠 수 있습니다.
Connection의 상태에 따라서 에러가 발생하기도 합니다.
Lambda는 invoke(요청)가 발생할 때 내부적으로 컨테이너를 실행하고 요청을 처리합니다.
처리가 끝나면 해당 컨테이너를 일정 시간 동안 sleep 상태로 유지했다가 다른 요청이 들어오면 컨테이너를 재사용합니다.
Connection Pool을 유지할 경우에 초기에 생성된 Connection 이 실행 시점에는 유효하지 않은 경우가 발생합니다.
이로 인해 에러가 발생하고, 재처리를 해야 했습니다.
또한 배치 Job 별로 Connection 을 생성하고 제거하지 않는다면, DB에도 큰 부담이 됩니다.
Connection Pool을 배치에서 사용해야 한다면, Job 시작시 Connection 을 생성하고 Job 종료시 제거하는 전략이 적합합니다.
DB 연결이 많지 않다면 과감하게 Connection Pool 을 생략하고, 필요할 때마다 Connection을 연결해서 사용하는 것이 좋습니다.
2.4.2. RDS Proxy
배치에서 DB 호출이 많고, 동시에 많은 Job 이 실행된다면 DB의 connection 을 동시에 요청하게 될 수 있습니다.
이런 경우에는 DB의 Connection 부족으로 인해 장애가 발생할 수 있습니다.
DB를 AWS RDS 또는 AuroraDB를 사용한다면 RDS Proxy를 사용하여 이 문제를 해결할 수 있습니다.
RDS Proxy는 애플리케이션을 대신해서 Connection Pool을 구성하고,
Connection 을 공유하여 DB의 부하를 줄일 수 있을 뿐 아니라 DB 장애에 대한 애플리케이션의 복원력을 높일 수 있습니다.
RDS Proxy 사용 비용은 DB의 용량에 따라서 부과됩니다.
3. Lambda 배치 환경에서 Troubleshooting
3.1. Lambda invoke CLI 호출시 두 번 호출되는 문제
- 현상
별도의 스케줄러에서 AWS CLI를 사용해서 Lambda 배치를 실행하고 있었습니다. 간혹 Lambda가 연속으로 두 번씩 호출되는 로그를 확인했습니다.
Lambda CLI의 리턴 로그상으로는 한번 실행된 것에 대한 로그만 있었기 때문에 Lambda가 어디에서 호출된 것인지 알 수 없었습니다.
로그를 분석해 보니 Lambda 실행 시간이 1분 넘어가는 시점에 두 번째 실행이 되는 것을 발견했습니다.
중복 실행 될 경우에 처음 실행한 lambda는 종료되지 않고, 새로운 lambda가 실행되었습니다.
- 원인
AWS CLI의 기본 timeout은 60초입니다. CLI timeout 이 발생하면 자동으로 CLI 명령이 다시 실행됩니다.
Lambda 배치의 실행 시간이 길어서 CLI timeout 이내에 끝나지 않을 경우 의도치 않게 동시에 여러 개의 Lambda 배치가 실행 됩니다.
- 해결 방법
AWS CLI 명령에 --cli-read-timeout <integer>
옵션을 추가하고 초 단위로 시간을 지정하여 timeout 을 늘릴 수 있습니다.
default read-timeout 은 60초입니다. 0으로 설정할 경우 제한 시간없이 무제한 대기 상태가 됩니다.
lambda의 최대 실행 시간은 15분(900초) 입니다. — cli-read-timeout 시간을 900초 보다 조금 더 크게 지정하면 Lambda의 최대 실행 시간까지 대기하도록 설정할 수 있습니다.
3.2. DB query 실행시 에러 발생. “Connection is not available”
- 현상
DB query 를 실행시키면 일정 시간 동안 대기하다가 끊기는 현상이 가끔씩 발생했습니다.
에러가 발생할 경우에는 일정 시간 동안 계속 동일한 유형의 에러가 발생을 했습니다.시간이 조금 지난 후 에러가 안 나는 시점부터는 계속 성공적으로 배치가 수행되었습니다.
에러가 발생하는 시점에도 DB는 정상이었고, 네트워크에도 문제가 없었습니다.
에러 발생시에는 아래의 로그처럼 몇 가지 유형의 로그가 섞여서 보였습니다.
- java.sql.SQLTransientConnectionException: Kal-Master — Connection is not available, request timed out after 10000ms.
- Failed to validate connection oracle.jdbc.driver.T4CConnection@76c39a0c (Closed Connection). Possibly consider using a shorter maxLifetime value.
- Connection oracle.jdbc.driver.T4CConnection@66a6478d marked as broken because of SQLSTATE(08006), ErrorCode(17002)
- java.sql.SQLRecoverableException: IO Error: Connection reset by peer
- Application exception overridden by rollback exception
- 원인
Hikaricp 를 이용하여 Connection Pool 을 관리하고 있었습니다. idleTimeout 은 default 값인 10분, maxLifetime 은 default 값인 30분이 적용된 상태였습니다.
Lambda 가 실행되고 Lambda instance가 장시간 sleep상태(5분 이상)였다가 Lambda instance가 재 사용될 경우 이런 현상이 발생했습니다.
Connection Pool의 connection이 장시간 idle 상태였다가 다시 연결을 시도하면, 서버측 connection은 이미 close 된 상태이기 때문에 에러가 발생하는 것으로 추측됩니다.
- 해결 방법
hikaricp max-lifetime 설정을 최소 값인 30초로 설정했습니다.
배치에서는 Connection Pool 을 사용하지 않거나 Connection max-lifetime, idle-timeout 등을 최소 시간으로 유지하여 비정상 connection 상태가 되는 것을 막아야 합니다.
RDS Proxy 를 사용할 수 있다면, RDS Proxy 를 적용하여 Connection Pool 관리를 RDS Proxy에서 하는 것이 더욱 효율적일 것입니다.