Airflow 환경 Docker compose로 containerization하기

eunseok yang
네이버 플레이스 개발 블로그
12 min readDec 4, 2023

--

안녕하세요, G플레이스데이터개발 팀에 올 하반기 신입으로 합류하게 된 양은석입니다. 비전공자인 저에게는 데이터 엔지니어링(DE)이 생소하고 낯설게 느껴졌는데요, DE의 핵심 도구 중 하나인 Airflow를 이해하게 된 과정과 개발 환경 설정을 통해 팀 생산성에 기여한 경험, 그리고 그 결과를 공유드리고자 이 글을 작성하게 되었습니다.

왼쪽부터 기존 로컬 환경, 새로 구축한 도커 환경, 그리고 쿠버네티스 기반 프로덕션 환경을 도식화했습니다.

이번 과제의 중심 목표는 Airflow 환경을 Docker 기반으로 구축하고, 개발 및 디버깅이 가능하도록 IDE 환경을 설정하는 것입니다.

기존에는 개발 환경과 배포 환경에서 Airflow의 실행 방식이 서로 달라 환경 변수나 설정 값이 주입되는 방식에 차이가 있었고, 그 간극 때문에 “분명 내 로컬에서는 되는데 배포 했더니 문제가 발생하는” 상황을 마주할 위험이 존재했습니다. 뿐만 아니라 각각의 로컬 개발 환경이 Python 가상 환경 도구(e.g. Miniconda)로 관리되고 있었지만 종종 이격이 발생했으며 OS 차이에 따른 문제도 심심찮게 일어났습니다.

하지만 Docker 환경을 이용하면 Local 환경의 변경 영향을 없애고 항상 동일한 형상에서 멱등성있게 테스트를 수행 가능하며, requirements.txt와 같이 Python 환경에 수정 사항이 발생한 경우 변경점에 대해서만 격리된 환경에서 검증 가능합니다. 따라서 K8S로 배포 및 운영되는 프로덕션 환경과 유사하면서 서로 동일한 환경에서 개발 및 테스트가 가능하도록 Airflow를 컨테이너화하는 작업이 필요했습니다.

이제 Airflow가 어떻게 구성되어 있고, 왜 containerization을 위해 Docker compose를 사용하였으며, 개발 환경은 어떤 방식으로 설정했는지 차례대로 살펴보도록 하겠습니다.

Apache Airflow

Apache Airflow는 배치 단위의 작업을 스케줄링 및 모니터링할 수 있는 워크플로 개발 도구입니다. 저희 팀은 테라바이트 단위로 흐르는 Place data를 추출, 변환 및 적재하는 ETL pipeline을 효율적으로 운영하기 위하여 Airflow의 힘을 빌리고 있습니다. Airflow는 최소 작업 단위인 task가 서로 의존성을 가지고 DAG(Directed acyclic graph)를 구성하며 일정 시간마다, 혹은 특정 조건에 의해 트리거될 때마다 실행되는 방식으로 작동합니다. 시작점으로 Airflow의 컴포넌트를 가볍게 짚어보면서 컨테이너를 어떻게 구성할지 밑그림을 그려볼 수 있습니다.

먼저, Scheduler는 이름에서 유추할 수 있듯 DAG의 실행을 스케줄링 및 오케스트레이션하는 컴포넌트입니다. DAG들의 지휘관 역할을 하면서 Metadata database에 여러 정보나 실행 상태를 저장하고, 파이썬 파일 형태로 존재하는 DAG의 디렉토리를 바라보며 일정 시간마다 변경을 감지합니다. 다음으로 Executor는 Scheduler 내부에 존재하면서 task를 실제로 수행하는 Worker를 만들고 명령합니다. 마지막으로 Webserver는 UI를 통해 DAG가 잘 돌고 있는지 모니터링할 수 있도록 도와줍니다.

구성 요소 간의 연결 관계를 파악할 때는, 만약 그 요소가 존재하지 않는다면 무슨 일이 일어날 지 상상해보는 것이 도움이 됩니다.

Airflow architecture overview

이 때 어떤 Executor를 선택하느냐에 따라 Worker는 쓰레드나 프로세스가 될 수도 있고, pod이 될 수도 있기 때문에 컨테이너의 구성 방식이 바뀝니다. 예를 들어 SequentialExecutor는 하나의 작업을 단일 프로세스에서 순차적으로 실행하므로, Scheduler와 Worker의 역할을 수행하는 단일 컨테이너로 충분합니다. 반면, CeleryExecutor는 Celery라는 분산 작업 큐 시스템을 이용하여 다수의 Worker가 작업을 병렬로 처리하므로 Worker가 독립 컨테이너로 존재해야 합니다.

엔지니어는 항상 트레이드오프를 고려해야 하기 때문에 개발 환경과 운영 환경에 어떤 Executor를 사용할지 따져봐야 하는데요, 저희 팀에서는 Airflow의 개발 환경은 구성이 간단한 SequentialExecutor를, 그리고 배포 환경에서는 확장성이 뛰어난 CeleryExecutor를 사용하는 차이가 있었습니다.

Docker compose의 활용

컨테이너 하면 Docker를 가장 먼저 떠올릴 수 있지만 낱개의 Dockerfile로 앞서 설명한 다수의 컴포넌트를 관리하기는 쉽지 않습니다. 배포 환경과 동일하게 CeleryExecutor를 로컬 환경에서 Docker로 구동하려면 서로 다른 서비스를 각각 파일로 정의해야 하기 때문입니다.

대신에 Docker compose라는 도구를 이용하면 단일 yaml 파일에서 컨테이너들과 볼륨, 네트워크를 손쉽게 정의하고 관리할 수 있습니다. 여기서는 Airflow에 포함된 각 컴포넌트들의 의존 관계를 고려하면서 멀티 컨테이너를 효과적으로 다룰 수 있어야 하기에 Docker compose를 채택했습니다.

이 때 컴포넌트 간 의존 관계는 곧 컨테이너가 실행되는 순서를 의미합니다.
1) message queue인 Redis와 database인 PostgreSQL이 준비되고,
2) airflow-init 컨테이너에서 Airflow의 variable과 connection 등 각종 설정을 완료한 후에
3) Scheduler나 Webserver 등 Airflow를 구성하는 컨테이너가 실행되어야 합니다.

만약 database가 준비된 상태가 아닌데 작업을 시작하면 에러가 발생합니다. 이러한 의존관계는 yaml 파일의 depends_on 파라미터로 간단히 설정할 수 있습니다. 또 Docker compose에서 제공하는 문법인 fragment나 extension을 이용하면 yaml 블럭의 재사용성을 높이고 중복을 줄일 수 있습니다.

그리고 배포 환경과 설정을 맞추기 위해 다음을 적용했습니다.

- 배포 환경에서 동작하는 Docker image를 base image로 사용
- country, hadoop platform, phase 등을 환경변수로 받아 실행
- DB connection, logging, Python path 등 설정
- Airflow가 로컬에 존재하는 DAG를 바라보도록 volume mount

위 작업을 위해 Airflow, Docker, Helm 등 기술 습득과, 팀이 그 기술을 어떤 방식으로 사용하고 있는지 맥락 파악이 필요했습니다. 전자는 documentation을 읽고 구글링하면서, 후자는 팀 동료들에게 도움을 요청하면서 주로 어려움을 해결했습니다. 여기까지 마치면 단일 커맨드 docker compose up 으로 Airflow를 로컬 환경에서 Celery executor mode로 실행할 수 있습니다!

Airflow 실행 후 Docker desktop으로 확인한 모습입니다.

Pycharm IDE 환경 설정

저희 팀은 파이썬 개발 환경으로 Pycharm IDE를 사용하고 있기 때문에 컨테이너화한 환경에서 DAG를 개발 및 디버깅할 수 있도록 세팅하는 것이 다음 단계였습니다. 이 과정에서 시행착오를 수 차례 겪었는데 Pycharm에서 제공하는 Docker compose 환경이 일부 제한적이었고, 무엇보다도 발생하는 에러의 원인을 쉽게 발견할 수 없었기 때문입니다.

원래 설계에서는 docker-compose.yaml에서 image를 변수로 두어 실행 시점에 선택할 수 있도록 하여 국가 별로 서로 다른 이미지를 사용하거나 tag가 변경되더라도 쉽게 적용할 수 있었습니다. 하지만 Pycharm에서는 그 자체로 실행 가능한 상태의 yaml 파일만이 Python interpreter로 설정 가능했기 때문에 국가 별로 설정을 분리한 후 latest tag를 바라보도록 대체했습니다. 이미지를 업데이트할 때 최신 이미지를 항상 latest tag에 덮어쓴다면 그 때마다 yaml 파일을 수정하지 않아도 되기 때문이죠.

다음으로 개발 환경을 위한 Executor로 리소스를 많이 소비하는 CeleryExecutor 대신 LocalExecutor로 타협하여 선택했는데요, LocalExecutor는 더 가볍고 빠르게 테스트 및 작업 실행이 가능하면서, 기존 개발 환경이었던 SequentialExecutor와는 다르게 병렬성 확보가 가능하다는 장점 때문이었습니다. 대신에 개발이 완료된 DAG를 배포 환경과 유사하게 실행하고 싶을 때 CeleryExecutor를 이용할 수 있도록 아래와 같이 yaml 파일을 분리해 구성하였습니다.

Local executor와 Celery executor를 위한 yaml을 각각 다른 파일로 두고, 공통 요소를 common yaml에 담았습니다. 빨강, 초록, 파랑 순으로 컨테이너가 실행됩니다.

한편, Pycharm에서 코드를 실행하거나 디버깅하기 위해서는 Run/debug configuration 설정이 필요합니다. 다만 하나의 configuration은 기본적으로 하나의 스크립트를 수행하기 때문에 단일 컨테이너에서 Airflow 설정 초기화 후 곧바로 DAG를 실행하게 하지는 못합니다. 따라서 다음 두 가지 방안을 생각했고, 각각에는 장단점이 존재합니다.

  1. Initializing과 DAG 실행 configuration을 따로 두는 방식

Airflow 설정을 한 번 초기화한 후에 그 값을 재활용하여 DAG를 여러 번 실행할 수 있습니다. 실행 시마다 설정을 다시 하지 않아도 되므로 빠르지만, 초기화가 올바르게 되어 있는지 고려하는 인지 부담이 발생합니다. 또한, configuration의 개수가 증가하고 의존성이 생깁니다.

2. docker compose command로 configuration을 단일화하는 방식

실행 시마다 Airflow 설정 초기화 과정을 거칩니다. Docker compose command의 option을 잘 활용하여 컨테이너가 올바르게 실행 및 종료되도록 해야 합니다.

DAG daily_testing_dataset를 테스트하는 python run configuration

최종적으로는 예시와 같이 두 번째 방안을 기본 값으로 설정했습니다. 위에서부터 Interpreter를 Docker compose로 변경하고, script와 parameter를 Airflow의 경로와 테스트하고자 하는 DAG로 적절히 작성합니다. 다음으로 Docker compose command의 옵션으로는 항상 컨테이너를 재생성하는 — force-recreate과, 테스트가 끝나면 모든 컨테이너가 종료되도록 하는 — exit-code-from 을 추가했습니다. 단, command에 container 명을 명시하지 않으면 — exit-code-from에 의해 종료되지 않음을 유의해야 합니다. 끝으로 unit test나 integration test을 위해 pytest configuration을, Airflow의 실행을 위해 Docker configuration을 각각 설정했습니다.

Pycharm에서 테스트 및 디버깅을 하는 모습입니다.

정리 및 개선점

여기까지 크게 두 가지 결과물을 얻었습니다. 첫 번째로 Airflow의 배포 환경과 유사하게 로컬 환경에서 DAG의 실행을 확인할 수 있습니다. 컨테이너들을 띄우고 Webserver로 동작을 확인하면 되는 것이죠. 두 번째로 이러한 환경에서 Pycharm을 이용해 DAG를 테스트하고, breakpoint를 찍어 디버깅할 수 있게 되었습니다.

이러한 목표를 달성한 후 관리 및 사용 측면에서 소소하게 개선한 부분들이 있습니다. 팀에서는 많은 실행 command를 Makefile target 형태로 관리하고 있는데요, 컨테이너 내에서도 unit/integration test나 Airflow 설정 관련 command를 make로 실행하도록 했습니다. 예를 들어 integration test를 위해 미리 정의된 target command인 make test.integration을 Native Local 환경이나 Docker 환경 양쪽 모두에서 동일하게 실행 가능합니다. 다음으로 Airflow configuration을 로컬 환경에서는 airflow.cfg 파일로, 배포 환경에서는 Helm value로 관리하고 있었는데 이를 모두 Helm value로 통합하여 관리 비용을 줄였습니다. 마지막으로 Run configuration의 naming convention을 만들어 개별 테스트에 대한 configuration이 추가되어도 관리가 쉽도록 정리했습니다.

마치며

업무를 시작할 때만 해도 깜깜했는데 우여곡절 끝에 잘 마무리된 것 같아 기쁩니다. 처음에는 할 수 없을 것 같았지만 생각보다 쉽게 진행된 일도 있었는데요, 반대로 쉬워 보였는데 원인 모를 에러 때문에 하루를 허비하기도 했습니다. 경험 많은 동료들의 도움 덕분에 혼자였으면 오래 걸릴 이슈가 단숨에 해결된 적도 종종 있었습니다. 아는 만큼 보인다는 말이 이만큼 와 닿았던 때가 있었을까요.

그래도 이번에 작은 고개를 한 번 넘어 봤으니 다음 업무도 잘 할 수 있겠다는 자신감을 얻었습니다. 경험치를 쌓았으니 ETL Pipeline을 구성하는 다른 컴포넌트들을 컨테이너화하는 일도 조금 더 손쉽게 할 수 있지 않을까요. 이제 동료들이 결과물을 잘 사용하는 것을 지켜보면서 뿌듯해하면 될 것 같습니다.

긴 글 읽어주셔서 감사합니다. 이만 마칩니다.

--

--