클래스로딩 디버깅
클래스로딩 디버깅 (Debugging Classloading)
이 문서는 Flink에서 클래스로딩(classloading)이 어떻게 동작하는지, 그리고 클래스로딩 관련 문제를 진단·해결하는 방법을 다뤄요. 역전 클래스로딩(inverted classloading), 의존성 충돌 해결, 동적 로드 클래스의 언로딩 등을 설명해요.
출처: 문서
본문
Flink에서의 클래스로딩 개요
Flink 애플리케이션을 실행할 때 JVM은 시간에 따라 다양한 클래스를 로드해요. 이러한 클래스는 출처에 따라 세 그룹으로 나눌 수 있어요.
- Java Classpath: Java의 일반적인 classpath로, JDK 라이브러리와 Flink의
/lib폴더의 모든 코드(Apache Flink의 클래스와 일부 의존성)를 포함해요. 이들은AppClassLoader가 로드해요. - Flink Plugin Components: Flink의
/plugins폴더 아래 폴더의 plugin 코드예요. Flink의 plugin 메커니즘이 시작 시 한 번 동적으로 로드해요. - Dynamic User Code: 동적으로 제출된 job의 JAR 파일(REST, CLI, web UI를 통해)에 포함된 모든 클래스예요. 이들은
FlinkUserCodeClassLoader가 job별로 동적으로 로드(및 언로드)해요.
일반 규칙으로, Flink 프로세스를 먼저 시작하고 나중에 job을 제출할 때마다 job의 클래스는 동적으로 로드돼요. Flink 프로세스가 job/애플리케이션과 함께 시작되거나, 애플리케이션이 Flink 구성 요소(JobManager, TaskManager 등)를 생성하면, 모든 job의 클래스는 Java classpath에 있어요. plugin 구성 요소의 코드는 plugin당 전용 클래스로더가 한 번 동적으로 로드해요.
다음은 다양한 배포 모드에 대한 더 자세한 내용이에요.
세션 모드 (Session Mode, Standalone/Yarn/Kubernetes)
Flink 세션(Standalone/Yarn/Kubernetes) 클러스터를 시작하면, JobManager와 TaskManager는 Java classpath에 Flink 프레임워크 클래스와 함께 시작돼요. 세션에 (REST/CLI로) 제출된 모든 job/애플리케이션의 클래스는 FlinkUserCodeClassLoader가 동적으로 로드해요.
애플리케이션 모드 (Application Mode, Standalone/Yarn/Kubernetes)
Standalone/Kubernetes Flink 클러스터를 애플리케이션 모드로 실행하면, 사용자 jar(시작 명령에서 지정한 JAR 파일과 Flink의 usrlib 폴더의 모든 JAR 파일)는 FlinkUserCodeClassLoader가 동적으로 로드해요.
Yarn Flink 클러스터를 애플리케이션 모드로 실행하면, 사용자 jar(시작 명령에서 지정한 JAR 파일과 Flink의 usrlib 폴더의 모든 JAR 파일)는 기본적으로 시스템 classpath(AppClassLoader)에 포함돼요. yarn.classpath.include-user-jar를 DISABLED로 설정하면 Flink는 사용자 jar를 사용자 classpath에 포함하고 FlinkUserCodeClassLoader로 동적으로 로드해요.
역전 클래스 로딩과 ClassLoader 해석 순서 (Inverted Class Loading and ClassLoader Resolution Order)
동적 클래스로딩이 관여하는 구성(plugin 컴포넌트, 세션 구성의 Flink job)에서는 일반적으로 두 개의 ClassLoader 계층이 있어요. (1) classpath의 모든 클래스를 가진 Java의 application classloader, (2) plugin 또는 사용자 코드 jar에서 클래스를 로드하는 동적 plugin/사용자 코드 classloader. 동적 ClassLoader는 application classloader를 부모로 가져요.
기본적으로 Flink는 클래스로딩 순서를 역전(inverted)시켜, 먼저 동적 classloader를 살펴보고, 클래스가 동적으로 로드된 코드의 일부가 아닌 경우에만 부모(application classloader)를 살펴봐요.
역전 클래스로딩의 이점은 plugin과 job이 Flink 코어 자체와 다른 라이브러리 버전을 사용할 수 있다는 것이에요. 서로 다른 라이브러리 버전이 호환되지 않을 때 매우 유용해요. 이 메커니즘은 IllegalAccessError나 NoSuchMethodError 같은 흔한 의존성 충돌 오류를 피하는 데 도움을 줘요. 코드의 서로 다른 부분은 단순히 클래스의 별도 복사본을 가지면 돼요 (Flink의 코어나 그 의존성 중 하나는 사용자 코드나 plugin 코드와 다른 복사본을 사용할 수 있어요).
대부분의 경우 이 방식이 잘 동작하며 사용자의 추가 구성이 필요 없어요. 그러나 역전 클래스로딩이 문제를 일으키는 경우가 있어요 (아래 "X cannot be cast to X" 참고).
사용자 코드 클래스로딩의 경우, Flink 구성의 classloader.resolve-order를 parent-first로 설정해 (Flink 기본값 child-first에서) ClassLoader 해석 순서를 구성함으로써 Java의 기본 모드로 되돌릴 수 있어요.
특정 클래스들은 Flink 코어와 plugin/사용자 코드 또는 plugin/사용자 코드 지향 API 사이에서 공유되므로 항상 parent-first 방식으로 (부모 ClassLoader를 먼저 거쳐) 해석된다는 점에 주의하세요. 이러한 클래스의 패키지는 classloader.parent-first-patterns.default와 classloader.parent-first-patterns.additional로 구성돼요. parent-first로 로드될 새 패키지를 추가하려면 classloader.parent-first-patterns.additional 구성 옵션을 설정하세요.
사용자 코드에 대한 동적 클래스로딩 피하기 (Avoiding Dynamic Classloading for User Code)
모든 구성 요소(JobManager, TaskManager, Client, ApplicationMaster 등)는 시작 시 classpath 설정을 로그로 남겨요. 이들은 로그 시작 부분의 환경 정보로 찾을 수 있어요.
JobManager와 TaskManager가 특정 하나의 job 전용인 구성에서는, 사용자 코드 JAR 파일을 /lib 폴더에 직접 넣어 classpath의 일부가 되도록 하고 동적으로 로드되지 않게 할 수 있어요.
보통 job의 JAR 파일을 /lib 디렉터리에 넣는 방식이 동작해요. JAR은 classpath(AppClassLoader)와 동적 클래스로더(FlinkUserCodeClassLoader) 양쪽의 일부가 돼요. AppClassLoader는 FlinkUserCodeClassLoader의 부모이므로(Java는 기본적으로 parent-first로 로드), 클래스가 한 번만 로드되어야 해요.
job의 JAR 파일을 /lib 폴더에 넣을 수 없는 구성(예: 여러 job이 사용하는 세션인 경우)에서는, 공통 라이브러리를 /lib 폴더에 넣고 그 라이브러리에 대한 동적 클래스로딩을 피하는 것도 가능할 수 있어요.
사용자 코드에서의 수동 클래스로딩 (Manual Classloading in User Code)
어떤 경우에는 변환 함수, 소스, 또는 싱크가 (리플렉션을 통해 동적으로) 클래스를 수동으로 로드해야 할 수 있어요. 그러려면 job의 클래스에 접근할 수 있는 classloader가 필요해요.
이 경우 함수(또는 소스, 싱크)를 RichFunction(예: RichMapFunction 또는 RichWindowFunction)으로 만들고 getRuntimeContext().getUserCodeClassLoader()를 통해 사용자 코드 클래스로더에 접근할 수 있어요.
X cannot be cast to X 예외 (X cannot be cast to X exceptions)
동적 클래스로딩 구성에서 com.foo.X cannot be cast to com.foo.X 형태의 예외를 볼 수 있어요. 이는 클래스 com.foo.X의 여러 버전이 서로 다른 클래스로더에 의해 로드되었고, 그 클래스의 유형끼리 서로 할당하려 시도되었음을 의미해요.
흔한 이유 중 하나는 라이브러리가 Flink의 역전 클래스로딩 방식과 호환되지 않는 것이에요. 역전 클래스로딩을 꺼서(Flink 구성에서 classloader.resolve-order: parent-first 설정) 이를 검증하거나, 라이브러리를 역전 클래스로딩에서 제외할 수 있어요 (Flink 구성에서 classloader.parent-first-patterns.additional 설정).
또 다른 원인은 Apache Avro 같은 라이브러리가 생성하는 캐시된 객체 인스턴스, 또는 (예: Guava의 Interners를 통한) 객체 인터닝(interning)일 수 있어요. 해결책은 동적 클래스로딩이 없는 구성을 사용하거나, 해당 라이브러리가 동적으로 로드된 코드에 완전히 포함되도록 하는 것이에요. 후자는 라이브러리를 Flink의 /lib 폴더에 추가하지 말고, 애플리케이션의 fat-jar/uber-jar의 일부가 되게 해야 한다는 뜻이에요.
사용자 코드에서 동적으로 로드된 클래스의 언로딩 (Unloading of Dynamically Loaded Classes in User Code)
동적 사용자 코드 클래스로딩이 관여하는 모든 시나리오(세션)는 클래스가 다시 언로딩되는 것에 의존해요. 클래스 언로딩은 가비지 컬렉터가 클래스의 객체가 더 이상 존재하지 않음을 발견하고 클래스(코드, 정적 변수, 메타데이터 등)를 제거하는 것을 의미해요.
TaskManager가 task를 시작(또는 재시작)할 때마다 그 특정 task의 코드를 로드해요. 클래스를 언로딩할 수 없다면, 클래스의 새 버전이 로드되고 로드된 클래스의 총 개수가 시간이 지나며 누적되므로 메모리 누수가 돼요. 이는 전형적으로 OutOfMemoryError: Metaspace로 나타나요.
클래스 누수의 흔한 원인과 권장 수정:
- 잔류 스레드 (Lingering Threads): 애플리케이션 함수/소스/싱크가 모든 스레드를 종료하도록 하세요. 잔류 스레드는 자체적으로 리소스를 소비하고, 보통 (사용자 코드) 객체에 대한 참조를 유지해 가비지 컬렉션과 클래스 언로딩을 방지해요.
- Interners: 함수/소스/싱크의 수명을 넘어서 살아있는 특수 구조에서 객체를 캐싱하는 것을 피하세요. 예로는 Guava의 interners, 직렬화기의 Avro 클래스/객체 캐시가 있어요.
- JDBC: JDBC 드라이버는 사용자 코드 클래스로더 밖으로 참조를 누출해요. 이러한 클래스가 한 번만 로드되도록 하려면 드라이버 jar를 사용자-jar에 번들링하는 대신 Flink의
lib/폴더에 추가해야 해요. 어떤 사용자-jar도 드라이버를 번들링하지 않음을 보장할 수 없다면,classloader.parent-first-patterns.additional를 통해 드라이버 클래스를 parent-first로 로드되는 클래스 목록에 추가로 넣어야 해요.
동적으로 로드된 클래스의 언로딩에 유용한 도구는 사용자 코드 클래스로더 릴리스 훅(user code class loader release hooks)이에요. 이는 classloader의 언로딩 전에 실행되는 훅이에요. 일반적으로 리소스를 일반 함수 수명주기의 일부로(전형적으로 close() 메서드에서) 종료하고 언로딩하는 것이 권장돼요. 그러나 어떤 경우(예: 정적 필드)에는 classloader가 확실히 더 필요하지 않을 때 언로딩하는 것이 더 좋아요.
클래스로더 릴리스 훅은 RuntimeContext.registerUserCodeClassLoaderReleaseHookIfAbsent() 메서드로 등록할 수 있어요.
maven-shade-plugin으로 Flink와의 의존성 충돌 해결하기
애플리케이션 개발자 측면에서 의존성 충돌을 해결하는 방법은 의존성을 shading으로 감춰 노출하지 않는 것이에요.
Apache Maven은 maven-shade-plugin을 제공하며, 이를 사용하면 클래스를 컴파일한 후 패키지를 변경할 수 있어요 (따라서 작성하는 코드는 shading의 영향을 받지 않아요). 예를 들어 사용자 코드 jar에 aws sdk의 com.amazonaws 패키지가 있다면, shade plugin은 이를 org.myorg.shaded.com.amazonaws 패키지로 재배치해서, 코드가 여러분의 aws sdk 버전을 호출하게 해요.
이 문서 페이지는 shade plugin을 사용한 클래스 재배치를 설명해요.
Flink의 대부분 의존성(guava, netty, jackson 등)은 Flink 관리자들이 이미 shading 처리하므로, 사용자는 보통 이에 대해 걱정할 필요가 없어요.