왜 asyncio? 왜? lambda에서 asyncio를?

단순히 생각해보면, lambda에선 invoke도 있고, 굳이 asyncio를 쓸 이유는 없어 보입니다. 병렬 실행이 필요하면 invoke 타입을 event로 해서 던져버리면 되니까요. 하지만 제가 처한 상황은 달랐습니다. 작업 하나하나가 5분 이상 넘어가기 때문에 이 작업 각각은 비동기적으로 실행되어야 하지만, 작업이 끝나는 타이밍에 다른 lambda function을 invoke 해야 하는 상황이었습니다. 앞에서 전처리 한 작업들을 후처리하여 새로운 무언가를 생성해야 하는 일이었죠. 따라서 해당 하위 작업들을 분리하되, asyncio로 각각의 lambda function들을 동기적으로 실행하고 싶었습니다.

상황

하루에 한 번씩 동기화 작업, 그리고 주/월마다 한 번씩 실행되어야 하는 프로그램이 있습니다. 저희 서비스는 MSA기 때문에 해당 서비스를 위해서 서버 인스턴스를 하나 더 생성해야 하는 상황입니다. 하지만 단기적으로만 반짝 사용하는 것에 24/7의 EC2나 beanstalk를 사용하는 것은 매우 비효율적이라고 판단하였고, 람다를 사용하기로 결정한 상태입니다.

처음 생각

처음 생각은 ‘고비용 람다 하나 이용해서 절차실행 하면 되겠다!’ 였습니다. 이 생각이 잘못되었다는 걸 깨닫는 데에는 오랜 시간이 걸리지 않았습니다. 작업 하나에 메모리를 1000MB 이상 사용하는데, 3가지 작업이 진행되어야 하기 때문입니다. 컴퓨팅 파워도 그렇고 시간 제한도 문제였습니다. 3008 메모리 기준으로 15분 타임아웃이 났습니다. 따라서 작업을 람다 단위로 분리하고, 해당 작업들을 비동기로 실행하되, 끝나는 지점을 묶어 줄 무언가가 필요했습니다. 이 아이디어를 먼저 다이어그램으로 그려 본 모습입니다.

lambda diagram. invoked 3 instance by one lambda before step 2

asyncio

asyncio를 사용하면 마냥 쉬울 줄 알았습니다. 의도대로 돌아갈 것이라고 생각했죠. 하지만 예상과는 달랐습니다.

python3.6

파이썬3.6에서는 고수준의 asyncio API 를 제공하지 않습니다. (엄밀히는, 3.7에 비해 강력하지 않습니다.) 따라서 저수준의 event loop를 사용하여야 합니다. 다음 메서드들을 사용했습니다.:

asyncio.loop.run_until_complete
asyncio.ensure_future

python3.7에서는 create_taskrun 을 사용하면 잘 동작할 것입니다.

global loop

3.7에서라면 몰라도, 저는 3.6에서 저수준 event loop를 사용할 때 loop를 닫아 주는 게 버릇이기 때문에. 다음과 같은 코드를 작성했습니다.

def lambda_handler(event, context):
    loop = asyncio.get_event_loop()
    loop.run_until_complete(fun(event))
    loop.close()
    return {
        'statusCode': 200
    }

그리고 이걸 돌려보고 얼마 안 되어서 이상함을 느꼈습니다.

어? 왜 CloudWatch 로그가 찍히질 않지?

저는 lambda를 당연히 docker처럼 격리된 인스턴스에서 파이썬 인터프리터를 따로 올려 실행시킨 후, 종료하는 절차를 밟는 것이라고 생각하고 있었습니다. global loop를 닫았는데 그 뒤로 모든 람다 인스턴스들의 CloudWatch log가 찍히지 않는 것은 너무나 이상했습니다.

cloudwatch lambda log

그래서 다른 테스트용 람다 펑션에 글로벌 이벤트 루프를 생성하는 코드를 작성 후 실행하고 돌려 보았습니다.

def lambda_handler(event, context):
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(asyncio.new_event_loop())
    return {
        'statusCode': 200
    }

그랬더니 로그가 잘 찍히기 시작했고, 한 가지 의문점이 들었습니다.

cloudwatch가 python 인터프리터의 event loop queue에 들어 가 동작하는 건 알겠는데, 이게 왜 다른 람다에게도 영향을 미치는가?

이에 따라 한 가지 가설을 세워 봤습니다.

  1. 모든 람다는 환경을 OS단부터 완벽히 독립시킨 게 아닌, 인터프리터를 공유하면서 실행 환경만 독립시켜서 작동한다.

인터프리터를 공유한다는 이야기는 한 프로세스에서 작업이 종료되어도 인터프리터를 종료하지 않고 다음 실행 시 재사용한다는 뜻이 될 것이고, 그 말은 한 프로세스에서 람다를 여러 번 실행한다는 이야기와 같습니다. 그렇다면 이전 람다가 종료되지 않은 시점에서 다시 한번 람다를 실행시키면 GIL이 걸릴 겁니다. GIL이 걸리는 지 테스트해봅시다. 약 3분의 수행시간이 걸리는 람다를 2회 실행했습니다.

two lambda was created

아하! 인스턴스가 하나 더 생성되는군요! 이제 이해가 됩니다. 람다 인스턴스가 생성되는 조건과도 연관이 깊군요!

정리

위 실험을 통해서 다음과 같은 정보를 얻을 수 있습니다.

결론

cold boot 이후에 warm boot로 실행 된 람다들은 한 인터프리터와 인스턴스를 공유한다. 따라서 전에 실행한 코드가 global event loop를 닫아버렸다면 뒤에 따라오는 lambda들도 cloudwatch가 동작하지 않을 것입니다.