S3 다운로드 작업시 동기, 비동기, 멀티스레드, 멀티프로세스 성능 비교 (python)
최근 많은 파일들 (버킷 자체로 따지면 천만개 이상, Prefix 나 날짜별로 분리해도 수천~수만 이상)들을 다운로드받고 그것을 tar.gz 으로 아카이브 및 압축을 해서 특정 버킷에 glacier 클래스로 밀어넣어야 하는 요구사항을 처리하게 되었다.
이 때 파일을 다운로드 받아야 아카이브 및 압축을 할 수 있기 때문에 처음에는 aws cli
를 통해 다운로드 받고, tar
명령어를 통해 아카이브해서 밀어넣는 최대한 단순한 방법을 고려해보았다. 하지만 c7g.16xlarge
와 같은 네트워크 성능이 최대 30Gbps(=3750 MB/s) 나오는 인스턴스로도 다운로드 되는 속도는 최대 250MB/s 에 불과했었는데. 다양한 원인이 있겠지만
- 일단 하나의
aws cli
요청 (하지만 내부적으로 스레드 풀을 사용하기때문에 여러 요청을 병렬적으로 하고는 있다)이 EC2 인스턴스 내의 NIC 가 처리 가능한 네트워크 대역폭을 모두 포화시키지는 못한다. - gp3 스토리지를 기본값 (IOPS 3000, 처리량 125MB)으로 생성해서 연결한 경우 Disk 쓰기 속도의 제한으로 인해서 병목현상이 발생하기도 한다.
위 글을 참고하면 S3 ↔︎ EC2 간 성능 향상을 위해서는 “병렬” 적인 작업 부하, 업로드시에는 멀티파트 업로드 설정 최적화, 그리고 가능한 경우 VPC Endpoint 를 사용하라는 등의 내용을 볼 수 있다.
여기서 일단 다른 설정들은 제외하고 “병렬”적인 작업부하와 같은 병렬적인 접근 방식이나, 혹은 비동기 처리등을 통해서 S3 로부터 EC2 까지 데이터를 받아오는데 성능을 올리는 방법을 적용해보고자 테스트 해봤다. 이 때 모든 파일은 “메모리”에 저장해서 SSD 의 쓰기 속도의 제한을 받지 않도록 작업하였다.
접근 방법들
우선 크게 아래와 같은 4개의 접근 방법을 고려해보았다.
- 동기 코드
- 비동기 모듈 기반 코드 (aioboto3, aiobotocore..)
- 멀티스레드 코드 (concurrent.future 에서의 ThreadPoolExectutor)
- 멀티프로세스 코드 (concurrent.future 에서의 ProcessPoolExecutor)
각각의 코드는 아래와 같다.
동기 코드
비동기 코드
멀티 스레드 코드
멀티 프로세스 코드
테스트 방법
$ dd if=/dev/urandom of=50MB.file bs=1M count=50
와 같은 형태로 여러 용량의 더미 데이터를 생성하고.
$ seq 1 1000 > object_ids
과 같은 형태로 버킷의 키로 사용할 문자를 미리 생성한다.
$ time parallel --will-cite -a object_ids -j 10 aws s3 cp 50MB.file s3://YOUT_TEST_BUCKET/{}
형태로 aws s3 cp
명령어를 병렬적으로 (동시에 10개) 수행하는 방식으로 테스트 할 데이터를 업로드한다.
레퍼런스 : https://github.com/aws-samples/maximizing-storage-throughput-and-performance
이렇게 테스트 할 데이터를 버킷에 올리고 동기, 비동기, 멀티 스레드, 멀티 프로세스 코드로 S3 로부터 파일을 다운로드 받는 테스트를 진행해보았다.
테스트 with profiler (viztracer)
각 코드가 실제로 어떤 흐름으로 동작하는지 이해하기 위해서 viztracer
라는 프로파일링 툴을 이용해서 각 코드를 테스트해보았다.
동기 코드
동기 코드의 경우 각 파일 다운로드에 대해서 프로그램의 메인 실행 흐름에서 순차적으로 진행됨을 알 수 있다.
비동기 코드
aioboto3(aiobotocore)를 사용한 코드의 프로파일링 결과다.
aioboto3(aiobotocore)는 aiohttp를 래핑해서 사용하는 패키지로 S3 작업에 대해 비동기 작업을 할 수 있다. 위 스크린샷에서 메인 스레드(이벤트 루프)에서 초기에 여러 파일 다운로드에 대한 Future 객체들을 모두 등록하고 await 동작에 따라서 처리 완료된 Network I/O 작업에 대해 메인스레드에서 비동기적으로 처리하게 된다.
위 스크린샷은 프로그램 실행 초기에 비동기 작업들을 등록하는 작업이다.
이후 처리가 된 HTTP Request 결과에 대해서 메인 스레드에서 준비된 비동기 Task 로부터 데이터를 받는 부분을 확대한 스크린샷이다.
이렇게 메인 스레드에서 Network I/O 작업이 블로킹되지 않고. select / epoll / kqueue 와 같은 비동기 메커니즘을 사용해서 처리된다.
이 작업은 “단일 스레드” 에서 동작하므로 당연하게도 “하나의 CPU 코어” 에만 부하가 발생한다.
멀티 스레드
멀티스레드 (ThreadPoolExecutor) 를 사용하는 경우. 메인 스레드는 제어 흐름만 관리하고 실제 S3로부터 데이터를 다운로드 받는 작업은 worker thread 에서 병렬적으로 수행된다.
이 때 멀티코어 CPU 환경에서는 이 작업이 “여러 CPU” 에서 발생 할 수 있고. 또한 스레드간 작업 전환에는 “컨텍스트 스위칭”을 필요로 하기 때문에 특정 상황에서는 이 컨텍스트 스위칭 오버헤드에 의해서 원하는 성능이 나오지 않을 수 있다.
concurrent.futures.ThreadPoolExecutor
를 사용하게 되어있고. S3 에 관한 설정 중 max_concurrent_requests
옵션의 경우 그 스레드 풀의 최대 스레드 개수를 제한하는 옵션이다. https://github.com/boto/s3transfer/blob/9a168299c932077e665a618bfa5e2d5e39343745/s3transfer/futures.py#L406-L411멀티 프로세스
멀티프로세스 (ProcessPoolExecutor)를 사용하는 경우 스레드가 아닌 “프로세스를” 워커로서 사용하는 방법으로 CPU bound 작업 또한 성능 개선을 노려볼 수 있다. 다만 프로세스별로 별도의 메모리 공간을 가져야 하므로 메모리 사용량이 더 크다는 점도 있기 때문에, 머신의 자원이 널널한 경우에만 사용하는게 좋아보인다.
상황 별 테스트
테스트를 하기 위한 인스턴스로 c7g.16xlarge
EC2 인스턴스를 사용했고. 네트워크 성능은 최대 30Gbps 인 인스턴스다.
1KB, 1MB, 50MB 파일에 대해서 각각 10개, 50개, 100개, 500개, 1000개를 받을 때 소요시간을 체크해보았고. 3GB 단일 파일에 대해서도 소요시간을 체크해보았다.
1KB 파일
방식/개수 | 10 | 50 | 100 | 500 | 1000 |
---|---|---|---|---|---|
동기 | 0.7s | 3.1s | 5s | 30s | 57s |
비동기 | 0.15s | 0.25s | 0.37s | 1.4s | 2.73s |
멀티스레드 | 1.29s | 3.9s | 5.04s | 15.25s | 28.87s |
멀티프로세스 | 0.23s | 0.25s | 0.38s | 0.66s | 6.1s |
전체적으로 동기 방식은 선형적으로 처리 시간이 늘어난다. 비동기의 경우 전체 케이스에 거쳐서 좋은 성능을 보인다.
멀티 스레드 방식의 경우 그렇게 좋은 성능을 내진 않고, 멀티프로세스 방식의 경우 특정 케이스에서는 더 낫지만 어떤 경우 아니기도 하다. (오버헤드에 의한 부분 차이로 느껴짐)
1MB 파일
방식/개수 | 10 | 50 | 100 | 500 | 1000 |
---|---|---|---|---|---|
동기 | 0.75s | 3.3s | 6.1s | 30s | 137s |
비동기 | 0.18s | 0.33s | 0.53s | 1.4s | 4.4s |
멀티스레드 | 1.24s | 4.01s | 5.05s | 15.76s | 29.80s |
멀티프로세스 | 0.24s | 0.27s | 0.39s | 0.82s | 1.34s |
50MB 파일
방식/개수 | 10 | 50 | 100 | 500 | 1000 |
---|---|---|---|---|---|
동기 | 0.70s | 3.18s | 6.22s | 46.45s | 113.07s |
비동기 | 1.21s | 5.01s | 10.30s | 58.33s | 116.80s |
멀티스레드 | 1.85s | 5.97s | 6.23s | 31.71s | 60.66s |
멀티프로세스 | 0.78s | 1.32s | 2.99s | 8.6s | 15.48s |
파일 크기가 큰 경우에. 파일 다운로드 자체 I/O 가 긴 작업이기때문에 “비동기”를 이용해 동시성으로 성능을 끌어올리려고 해도. 오히려 I/O 에 의한 오버헤드가 발생해서 동기식 코드보다 느려지는 경향이 있다. 이럴 때에는 병렬 실행이 오히려 네트워크 대역폭도 잘 쓰고 , 네트워크 I/O에 대한 처리가 더 나아진다.
3GB 파일 1개
동기 : 0.13s
비동기 : 35s
멀티 스레드 : 0.15s
멀티 프로세스 : 0.2s
전반적으로 작은 파일이 여러개 있는 경우에는 비동기 로직이 더 나은 성능을 보인다.
파일 개수가 많고 각 파일이 큰 경우(크다 라는것이 너무 추상적이지만 대충 수십MB 이상인 경우라고 생각 할 수 있다), 혹은 큰 파일(수 GB 이상의 파일) 하나를 받아오는데에는 비동기 로직이 성능이 나빠진다.
아래는 각 테스트 결과에 대한 그래프로 나타낸 자료이다.
높이가 낮을수록 성능이 좋은것 (소요 시간이 짧은 것)이다.
결론
위 테스트 결과를 통해 동기, 비동기, 멀티스레드, 멀티프로세스 어느 하나 “모든”상황에 좋은 성능을 내는 접근 법은 없다라는 결론을 얻었다.
다만 작은 파일이 여러개 있다 → 비동기가 유리하다
파일이 각각이 크기가 크다 → 멀티스레드/프로세스가 유리하다.
그리고 그것과 별개로 S3 Prefix 구조를 잘 짜놨다 → 병렬 실행해서 성능을 끌어 올릴 수 있다.
멀티스레드/멀티프로세스가 성능이 향상되는 케이스가 있지만. 그 대가로 자원을 더 많이 사용하게 된다 (여러 CPU 코어에 한 실행시간을 더 쓰게 됨)
위와 같은 인사이트를 얻을 수 있었다.
결국 모든것이 trade-off 라고 볼 수 있고, 상황에 맞게 끔 적절한 접근 방식을 취해야 하고 결국 어떤 상황에서 퍼포먼스가 가장 나은지를 실제 테스트해보고 결정해야 한다.
번외
s5cmd 라는 툴은 Go 로 작성되어있고 동시성과 병렬성을 모두 가져가서 40Gbps(~4.3GB/s) 정도의 대역폭도 포화 시킬 수 있는 성능을 갖고있다고 한다. Disk 의 I/O 성능이 충분하거나, 그 외 여러 상황에서 직접 코드로 구현하는것보다 나은 성능을 얻을 수 있다.
테스트를 위해 c7g.16xlarge
인스턴스에 Ramdisk 를 80GB 할당하고 테스트해보았다. 램디스크의 읽기/쓰기 성능은 약 5.83GB/s 이므로 s5cmd 로 네트워크를 통해 데이터를 받고 디스크에 쓰더라도 병목현상이 크게 생기지 않을 것으로 판단했다. (fio 명령어를 통해 ramdisk 성능을 EC2 인스턴스 내에서 측정한 결과)
50MB 파일 1000개 (총 50GB)를 다운로드 받는것을 아래와 같은 명령어로 테스트해보았다.
$ time s5cmd cp s3://MY_TEST_BUCKET/test/* /mnt/ramdisk
15초 정도가 소요되었다. 50GB 를 15초만에 다운로드 받았다는것은 네트워크 성능이 약 27.31Gbps 정도였다는것을 의미하고 별다른 설정 없이 s5cm5d
명령어 하나만으로 인스턴스(c7.16xlarge
)의 최대 네트워크 성능정도를 포화시킬 수 있었다.
파이썬 멀티 프로세스 코드를 통해서 똑같이 테스트를 해보았다. (다만 기존 코드와 다르게 램디스크에 동기적으로 파일을 쓰도록 수정해서 테스트 하였다)
이때는 15.48초 정도 소요되었다. s5cmd
와 거의 비슷한 성능을 얻을 수 있었다.
s5cmd
는 동시성 + 병렬성을 모두 반영했다고 하고, 위에서 본 멀티 프로세스 코드 또한 가용한 모든 CPU 를 사용해서 병렬적으로 수행했고. s5cmd
및 멀티프로세스 코드 모두 인스턴스의 최대 네트워크 성능에 가깝게 대역폭을 포화 시킬 수 있었다고 추측된다.
s5cmd
는 큰 노력 없이 가용한 자원을 모두 사용해서 S3 를 이용 할 수 있는것으로 판단된다. 다만 주의할 점은 네트워크 성능과 별개로 파일시스템에 파일을 쓰는 경우 Disk I/O 성능(IOPS) 을 고려해야 할 것이다.