← 학습 카테고리

Learn

Airflow

151개 모듈 · 현재 135번째

Airflow 모듈 135/151 airflow-learn-135

충돌하는 Python 의존성 처리 - Virtualenv/ExternalPython/Docker/K8s Pod 비교

Apache Airflow Official Documentation (in-repo snapshot) — Apache Software Foundation best-practices.rst - "Handling conflicting/complex Python dependencies" ~ "Using multiple Docker Images and Celery Queues" 섹션 (약 954-1198행)

이 모듈을 다 읽으면

  • constraints 메커니즘이 사전 정의된 오퍼레이터를 쓸 때 의존성 충돌 위험을 어떻게 낮추는지 설명할 수 있다
  • PythonVirtualenvOperator와 ExternalPythonOperator의 트레이드오프(설정 시점, 오버헤드, 보안)를 비교해 설명할 수 있다
  • DockerOperator/KubernetesPodOperator가 앞의 두 오퍼레이터 대비 제공하는 격리 수준과 그 대가(오버헤드, 이미지 준비 비용)를 설명할 수 있다
  • 네 가지 전략 중 개발 반복(iteration)과 프로덕션 배포 각각에 어떤 전략이 적합한지 판단할 수 있다

Airflow는 기본적으로 하나의 Python 환경을 공유하므로, 태스크마다 요구하는 의존성이 충돌할 수 있다. 사전 정의된 오퍼레이터는 커뮤니티의 constraints 메커니즘 덕에 충돌 위험이 낮지만, TaskFlow나 커스텀 오퍼레이터를 많이 쓸수록 충돌 가능성이 커진다. 이를 해결하는 네 가지 전략 - PythonVirtualenvOperator(동적 venv), ExternalPythonOperator(사전 준비된 venv), DockerOperator/KubernetesPodOperator(컨테이너 격리), 다중 Docker 이미지+Celery Queue -는 설정 유연성과 오버헤드·보안·운영 복잡도 사이에서 서로 다른 트레이드오프를 가진다.

문제의 배경 - 단일 Python 환경과 constraints 메커니즘

Airflow는 많은 Python 의존성을 가지며, 때로는 이 Airflow 의존성이 태스크 코드가 기대하는 의존성과 충돌한다. 기본적으로 Airflow 환경은 단일 Python 의존성 집합과 단일 Python 환경이므로, 어떤 태스크가 다른 태스크와 다른 의존성을 요구해 서로 충돌하는 경우가 흔히 생길 수 있다.

사전 정의된 Airflow Operator로 외부 서비스와 통신한다면 선택의 여지가 많지 않지만, 보통 그런 오퍼레이터들은 기본 Airflow 의존성과 충돌하지 않는 의존성을 갖는다. Airflow는 constraints 메커니즘을 사용하는데, 이는 커뮤니티가 (모든 커뮤니티 프로바이더를 포함해) 충돌 없이 설치할 수 있음을 보장하는 '고정된' 의존성 집합을 제공한다는 뜻이다. 다만 프로바이더는 독립적으로 업그레이드할 수 있고 그 constraints가 이를 제한하지 않으므로 충돌 가능성이 낮아진다(그래도 테스트는 필요하다). 따라서 사전 정의된 오퍼레이터를 쓸 때는 의존성 충돌 문제를 거의 겪지 않을 가능성이 높다.

하지만 TaskFlow API를 사용해 대부분의 오퍼레이터를 커스텀 Python 코드로 작성하는 '현대적인' 방식으로 접근하거나, 커스텀 오퍼레이터를 직접 작성하려 한다면, 그 커스텀 코드의 의존성이 Airflow 의존성과 충돌하거나 여러 커스텀 오퍼레이터의 의존성끼리 충돌하는 지점에 이를 수 있다.

이를 완화하는 여러 전략이 있으며, 커스텀 오퍼레이터에서 의존성 충돌을 다루는 것은 어렵지만 :class:`~airflow.providers.standard.operators.python.PythonVirtualenvOperator`나 :class:`~airflow.providers.standard.operators.python.ExternalPythonOperator`(또는 TaskFlow의 ``@task.virtualenv``/``@task.external_python`` 데코레이터)를 쓰면 상대적으로 쉬워진다. 구현이 쉬운 전략부터 Airflow 배포에 변화가 필요한 전략까지 순서대로 살펴본다.

핵심 포인트

  • Airflow는 기본적으로 단일 Python 환경을 공유하므로 태스크 간 의존성 충돌이 발생할 수 있다
  • 사전 정의된 프로바이더 오퍼레이터는 커뮤니티의 constraints 메커니즘 덕분에 충돌 위험이 낮다
  • TaskFlow/커스텀 오퍼레이터를 많이 쓸수록 Airflow 의존성 또는 서로 다른 커스텀 코드 간 의존성 충돌 가능성이 커진다

PythonVirtualenvOperator - 태스크마다 동적으로 venv 생성

가장 쉽고 대신 가장 제약이 큰 전략이다. ``PythonVirtualenvOperator``는 Python 콜러블이 실행될 virtualenv를 동적으로 만들어준다. TaskFlow 방식에서는 ``@task.virtualenv`` 데코레이터로 콜러블을 감싸는 방식이 권장된다. 각 태스크는 태스크가 실행될 때마다 동적으로 생성되는 독립적인 venv를 가질 수 있고, 그 태스크 실행에 필요한 세부 요구사항(requirements)을 지정할 수 있다.

오퍼레이터가 대신 처리해주는 것: 환경에 맞는 venv 생성, Python 콜러블 직렬화 후 venv 인터프리터로 전달, 실행 및 결과 회수(지정 시 XCom으로 push).

이점: venv를 미리 준비할 필요가 없다(태스크 실행 전 동적으로 생성되고 끝나면 제거됨) - Airflow 의존성에 virtualenv 패키지만 있으면 됨; 같은 워커에서 서로 다른 의존성 집합의 태스크를 실행할 수 있어 메모리 자원이 재사용됨(단, venv 생성의 CPU 오버헤드는 아래 참고); 대규모 설치에서는 Dag 작성자가 누군가에게 venv 생성을 요청할 필요 없이 virtualenv 의존성만 있으면 스스로 환경을 정의/수정 가능; Local venv든 Docker든 Kubernetes든 배포 요구사항에 변화가 없다; Dag 작성자로서 컨테이너/Kubernetes에 대한 추가 지식 없이 Python 요구사항만 알면 된다.

한계와 오버헤드: Python 콜러블이 직렬화 가능해야 한다 - 표준 ``pickle``로 직렬화 안 되는 객체가 많고 ``dill`` 라이브러리로 일부는 완화되지만 모든 제약을 해결하진 못한다; Airflow 환경에 없는 의존성은 모두 콜러블 내부에서 로컬 import해야 하며 Dag의 top-level 코드는 그 라이브러리를 import/use하면 안 된다; venv는 같은 운영체제 안에서 실행되므로 시스템 레벨 의존성(``apt``/``yum`` 패키지)은 충돌 없이 공존할 수 없고 Python 의존성만 독립적으로 설치 가능하다; 태스크마다 venv를 처음부터 재생성하므로 CPU·네트워킹·경과 시간 오버헤드가 든다; 워커가 PyPI 또는 사설 저장소에 접근할 수 있어야 한다; venv의 동적 생성은 저장소 접근 불가나 네트워킹 이슈 같은 일시적 장애에 취약하다; 의존성이 너무 동적이 되기 쉬워 설치한 의존성이 업그레이드되고 그 전이 의존성도 독립적으로 업그레이드되면서, 누군가 새 버전을 릴리스했다는 이유만으로 태스크가 멈추거나 서플라이체인 공격의 피해자가 될 수 있다; 태스크는 서로 다른 환경에서 실행되는 것만으로 격리되므로, 같은 워커에서 실행되는 연속 태스크들이 파일 생성/수정 등을 통해 서로 간섭할 여지는 여전히 남는다.

핵심 포인트

  • PythonVirtualenvOperator(@task.virtualenv)는 태스크 실행 시마다 venv를 동적으로 생성·삭제하며, 태스크별로 다른 requirements를 지정할 수 있다
  • 장점: 사전 준비 불필요, 워커 자원 재사용, 배포 방식(Local/Docker/K8s) 변경 불필요, 컨테이너 지식 불필요
  • 단점: 콜러블이 pickle/dill로 직렬화 가능해야 함, 시스템 레벨 의존성 격리 불가, 매 실행마다 venv 재생성 오버헤드, PyPI 접근 필요, 서플라이체인 공격 위험, 같은 워커 내 태스크 간 파일 시스템 레벨 간섭 가능성은 여전함

ExternalPythonOperator - 사전 준비된 불변 환경

.. versionadded:: 2.4

조금 더 손이 가지만 오버헤드·보안·안정성 문제가 훨씬 적은 전략은 :class:`~airflow.providers.standard.operators.python.ExternalPythonOperator`를 쓰는 것이다. TaskFlow 방식에서는 ``@task.external_python`` 데코레이터로 사용할 수 있다. 다만 사전에 준비된 불변(immutable) Python 환경이 필요하다 - ``PythonVirtualenvOperator``와 달리 이 기존 환경에는 새 의존성을 추가할 수 없다. 필요한 모든 의존성은 미리 그 환경에 추가되어 있어야 하고, Airflow가 분산 환경에서 동작한다면 모든 워커에서 사용 가능해야 한다.

이렇게 하면 virtual 환경을 재생성하는 오버헤드와 문제를 피할 수 있지만, 환경을 Airflow 설치와 함께 미리 준비하고 배포해야 한다. 보통 Airflow 설치를 관리하는 사람(대개 Dag 작성자와는 다른 DevOps/시스템 관리자)이 관여해야 한다.

이런 가상 환경은 여러 방식으로 준비할 수 있다 - LocalExecutor라면 스케줄러가 실행되는 머신에 설치하면 되고, 분산 Celery virtualenv 설치라면 여러 머신에 걸쳐 venv를 설치하는 파이프라인이 있어야 하며, Docker 이미지(예: Kubernetes 경유)를 쓴다면 커스텀 이미지 빌드 파이프라인에 venv 생성을 추가해야 한다.

이점: 태스크 실행 시 설정 오버헤드가 없다(태스크 시작 시 venv가 이미 준비되어 있음); 같은 워커에서 서로 다른 의존성 집합의 태스크를 실행할 수 있어 모든 자원이 재사용됨; 워커가 PyPI/사설 저장소에 접근할 필요가 없어 네트워킹발 일시적 오류 가능성이 낮음; 관리자와 보안팀이 의존성을 사전 검증할 수 있어 예상치 못한 코드가 동적으로 추가되지 않음(보안·안정성에 유리); Docker/Kubernetes로 전환할 필요 없이 배포에 대한 영향이 제한적임; Dag 작성자는 컨테이너/Kubernetes 지식 없이 Python 요구사항만 알면 된다.

단점: 가상 환경을 미리 준비해야 하므로 즉석에서 바꿀 수 없고, 새 요구사항 추가·변경에는 최소한 Airflow 재배포가 필요해 새 버전 작업 시 반복 주기가 길어질 수 있다; 콜러블이 pickle(또는 dill)로 직렬화 가능해야 한다는 제약은 동일하다; Airflow 환경에 없는 의존성은 콜러블 내부에서 로컬 import해야 하고 top-level에서 import/use하면 안 된다; 같은 운영체제에서 실행되므로 시스템 레벨 의존성 충돌 문제는 여전히 해결되지 않는다; 같은 워커에서 실행되는 태스크 간 파일 시스템 수준 간섭 가능성도 여전하다.

``PythonVirtualenvOperator``와 ``ExternalPythonOperator``는 서로 대응 관계에 있다고 볼 수 있다 - 개발 단계에서는 ``PythonVirtualenvOperator``(``@task.virtualenv``)로 의존성을 반복(iterate)하며 개발하다가, 반복이 끝나면 DevOps/시스템 관리팀이 사전 존재 venv에 새 의존성을 배포한 뒤 프로덕션에서는 ``ExternalPythonOperator``(``@task.external_python``)로 전환하는 식이다. 필요하면 언제든 데코레이터를 다시 바꿔 ``PythonVirtualenvOperator``로 동적 개발을 계속할 수 있다.

핵심 포인트

  • ExternalPythonOperator(@task.external_python, Airflow 2.4+)는 사전에 준비된 불변 venv를 사용하며 런타임에 새 의존성을 추가할 수 없다
  • 장점: 실행 시 오버헤드 없음, 워커의 PyPI 접근 불필요, 의존성을 사전 검증할 수 있어 보안/안정성이 높음
  • 단점: 의존성 변경마다 재배포가 필요해 반복 속도가 느림, 여전히 pickle/dill 직렬화 제약과 시스템 레벨 의존성 공유 문제가 남는다
  • 일반적인 흐름은 개발 중에는 PythonVirtualenvOperator로 반복하다가, 프로덕션 전환 시 사전 준비된 환경의 ExternalPythonOperator로 바꾸는 것이다

DockerOperator / KubernetesPodOperator - 완전한 프로세스 격리

또 다른 전략은 :class:`~airflow.providers.docker.operators.docker.DockerOperator`나 :class:`~airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator`를 쓰는 것이다. 이들은 Airflow가 Docker 엔진이나 Kubernetes 클러스터에 접근할 수 있어야 한다. Python 오퍼레이터들과 마찬가지로 TaskFlow 데코레이터(``@task.docker``, ``@task.kubernetes``)를 써서 콜러블을 실행할 수도 있다.

다만 훨씬 더 복잡하다 - 이 접근을 쓰려면 Docker/Kubernetes Pod가 어떻게 동작하는지 이해해야 하지만, 태스크가 서로 완전히 격리되고 Python 코드에 국한되지 않고 어떤 프로그래밍 언어로든 태스크를 작성할 수 있다. 의존성도(시스템 레벨 의존성을 포함해) Airflow 의존성과 완전히 독립적이므로, 태스크가 매우 다른 환경을 요구한다면 이 방법이 적절하다.

.. versionadded:: 2.2

Airflow 2.2부터 ``@task.docker`` 데코레이터로 ``DockerOperator``를 사용해 함수를 실행할 수 있다.

.. versionadded:: 2.4

(원문에는 이 버전 태그 아래 본문도 "Airflow 2.2부터"라고 기술되어 있으나, 이는 원본 문서의 표기 오류로 보인다 - ``versionadded`` 지시자 기준으로는 ``@task.kubernetes`` 데코레이터가 Airflow 2.4에서 추가되었다.) ``@task.kubernetes`` 데코레이터로 ``KubernetesPodOperator``를 사용해 함수를 실행할 수 있다.

이점: Python과 시스템 레벨 의존성 모두 서로 다른 집합으로, 심지어 완전히 다른 언어나 다른 프로세서 아키텍처(x86 vs arm)로 작성된 태스크를 실행할 수 있다; 컨테이너의 최적화와 불변성을 그대로 누리며, 비슷하지만 서로 다른 여러 환경이 있을 때 이미지의 캐시된 레이어를 효과적으로 재사용할 수 있도록 최적화된다; 의존성을 관리자와 보안팀이 사전 검증할 수 있어 예상치 못한 코드가 동적으로 추가되지 않는다(보안·안정성에 유리); 태스크 간 프로세스 레벨 격리가 강하다 - 태스크는 별도 컨테이너/파드에서 실행되어 프로세스·파일시스템 수준에서 서로 영향을 줄 수 없으며, 그럼에도 XCom·커넥션·변수 같은 표준 Airflow 메커니즘을 Execution API를 통해 여전히 사용할 수 있다(전체 격리 모델은 ``security/security_model`` 참고).

단점: 태스크 시작에 오버헤드가 있다 - 보통 venv를 동적으로 만드는 것만큼 크지는 않지만(특히 ``KubernetesPodOperator``는) 여전히 상당하다; TaskFlow 데코레이터를 쓰면 호출할 전체 메서드가 직렬화되어 Docker 컨테이너나 Kubernetes Pod로 전송돼야 하고, 메서드 크기에 대한 시스템 레벨 제약이 있으며 직렬화·전송·역직렬화 자체에도 오버헤드가 붙는다; 이 두 오퍼레이터로 태스크를 실행하려면 최소 두 개의 프로세스가 필요하다 - Docker 컨테이너/Kubernetes Pod에서 실행되는 태스크 프로세스 하나와, Docker/Kubernetes에 잡을 제출하고 실행을 모니터링하는 Airflow 워커의 감독(supervising) 프로세스 하나; 컨테이너 이미지를 미리 준비해둬야 해서 즉석에서 바꿀 수 없다 - 시스템 의존성 추가, Python 요구사항 수정에는 이미지 재빌드와 게시(보통 사설 레지스트리로)가 필요하며, 신뢰할 만한 배포 파이프라인이 필수적이다; 데코레이터로 실행하려면 Python 콜러블이 직렬화 가능해야 하고, Airflow 환경에 없는 의존성은 콜러블 내부에서 로컬 import해야 하며 top-level에서 import/use하면 안 된다; Dag를 작성하려면 Docker 컨테이너나 Kubernetes가 어떻게 동작하는지 좀 더 자세히 이해해야 한다 - 이들이 제공하는 추상화는 다소 '누수'가 있어 리소스·네트워킹·컨테이너 등에 대한 이해가 더 필요하다.

핵심 포인트

  • DockerOperator/KubernetesPodOperator는 프로세스 레벨의 강한 격리를 제공하며 Python 이외 언어, 다른 아키텍처(x86/arm)도 지원한다
  • 태스크 간 통신은 격리된 컨테이너/파드에서도 XCom/커넥션/변수를 Execution API를 통해 그대로 사용할 수 있다
  • 단점은 시작 오버헤드(특히 KubernetesPodOperator), 태스크 실행에 최소 2개 프로세스(태스크 프로세스+워커의 감독 프로세스) 필요, 이미지를 미리 빌드/배포해야 하는 점이다
  • 원본 문서는 @task.docker(2.2)와 @task.kubernetes(2.4)의 versionadded 태그가 다름에도 본문에서 둘 다 '2.2부터'라고 서술하는 표기 오류가 있다 - 실제 @task.kubernetes는 2.4에서 추가되었다

다중 Docker 이미지 + Celery Queue - 아직 권장되지 않는 전략

Airflow 배포에 대한 깊은 지식이 필요하긴 하지만, 여러 개의 독립적인 Docker 이미지를 사용해 Airflow 태스크를 실행하는 것도 가능하다. 이는 서로 다른 태스크를 서로 다른 Queue에 할당하고, Celery 워커가 Queue별로 다른 이미지를 쓰도록 설정함으로써 이룰 수 있다. 다만 (적어도 현재는) 상당한 수작업 배포 설정과, Airflow·Celery·Kubernetes 동작 방식에 대한 깊은 이해가 필요하다. 또한 태스크 실행에 상당한 오버헤드를 유발한다 - 리소스 재사용 기회가 줄어들고, 성능·안정성에 영향을 주지 않으면서 자원 비용 관점에서 이런 배포를 세밀하게 튜닝하기가 훨씬 어렵다.

이 방식을 더 유용하게 만들 수 있는 방법 중 하나는 `AIP-46 Runtime isolation for Airflow tasks and Dag parsing <https://cwiki.apache.org/confluence/display/AIRFLOW/AIP-46+Runtime+isolation+for+airflow+tasks+and+dag+parsing>`_ 과 `AIP-43 Dag Processor Separation <https://cwiki.apache.org/confluence/display/AIRFLOW/AIP-43+DAG+Processor+separation>`_ 의 완성이다. 이들이 구현되기 전까지는 이 접근법을 쓸 때의 이점이 거의 없으므로 권장되지 않는다.

이 AIP들이 구현되면, 여러 팀이 Dag의 파싱부터 실행까지 전체 생애주기에 걸쳐 완전히 격리된 의존성 집합을 가질 수 있는 좀 더 멀티테넌트에 가까운 접근이 가능해질 전망이다.

핵심 포인트

  • 다중 Docker 이미지 + Celery Queue 조합은 태스크별로 다른 이미지를 쓸 수 있게 하지만, 수작업 배포 설정과 깊은 지식이 필요하며 오버헤드가 크다
  • AIP-46(Runtime isolation)과 AIP-43(Dag Processor Separation)이 완성되기 전까지는 이 접근법이 권장되지 않는다
  • 이 AIP들이 완성되면 파싱부터 실행까지 팀별로 완전히 격리된 멀티테넌트 배포가 가능해질 전망이다