안녕하세요. DevOps 팀 Cloud Platform Unit의 Yop입니다. 최근 하이퍼커넥트 Kubernetes 클러스터에서 로그 수집용 agent로 사용 중인 Fluent Bit의 버전을 올리다가 segmentation fault를 맞닥뜨렸는데요. 이 글에서는 어떻게 segmentation fault를 디버깅하여 문제점을 분석하고 Fluent Bit에 PR을 merge 하였는지 그 과정을 소개해 드리고자 합니다. 이 글에서 다룰 주제는 다음과 같습니다.

  • Fluent Bit에 대한 간단한 소개
  • Fluent Bit의 버전을 올리고 모니터링하기
  • core 파일 확보하기
  • gdb 환경 셋업하기
  • core 분석하기
  • PR 제출하기
  • 결론

Fluent Bit 란?

fluentbit

Fluent Bit (https://fluentbit.io/) 는 Fluent 생태계의 로그 프로세싱 및 포워딩 프로그램입니다. Fluentd에 비하여 메모리 사용량이 적고 의존성이 적어 가볍다는 장점이 있습니다.1 하이퍼커넥트에서는 ElasticSearch, Kibana와 함께 EFK 스택을 구성하여 사용하고 있는데요. 최근 Fluent Bit 2.0이 릴리즈되어 버전을 올렸습니다.

Fluent Bit의 버전을 올리고 모니터링하기

kibana_1

하이퍼커넥트에서는 주로 Fluent Bit를 DaemonSet으로 띄워 사용하지만, 출력 로그 양이 특별히 많은 애플리케이션들에 대해서는 Pod의 sidecar로 붙여서 사용하고 있습니다. 그래서 유저 임팩트가 적은 서비스를 선택해 Fluent Bit sidecar를 업그레이드 하였습니다. 그러고나서 며칠간 모니터링을 진행하였는데, “Kafka output plugin을 사용하는 컨테이너”만 SIGSEGV로 인해 재시작되고 있었습니다!

kibana_2

당시에 함께 찍혔던 스택트레이스를 통해 2에서 크래시가 발생했음을 알 수 있었습니다. 이런 일이 일어난 정확한 원인을 위해서는 core 파일을 분석해야 합니다. 하지만 Fluent Bit가 Kubernetes 환경에서 돌고 있던 탓에, core 파일이 남지 않았습니다.

core 파일 확보하기

Pod에 PersistentVolume 추가하기

add_persistent_volume

Core 파일을 확보하기 위해, 해당 Pod에 PersistentVolume을 붙여주기로 합니다. EBS 같은 것을 붙여줘도 되지만, 간단히 hostPath Volume을 붙였습니다.

커널 파라미터 core_pattern 변경하기

Core 파일이 떨어지는 경로를 변경하려면 Fluent Bit sidecar Pod이 돌고 있는 node에 접속하여 core_pattern이라는 커널 파라미터를 변경해주어야 합니다.

$ echo "/tmp/cores/core.%e.%p.%h.%t" > /proc/sys/kernel/core_pattern

이렇게 하면 프로세스가 SIGSEGV를 받고 종료될 때 /tmp/cores에 프로세스 이름, pid, hostname, 타임스탬프 등의 정보와 함께 core를 남기게 됩니다.

테스트해보기

정말 위 설정이 잘 적용되었을까요? 간단한 방법으로 테스트해볼 수 있습니다.

  1. 실험하고자 하는 Pod에 exec로 쉘을 띄웁니다.
    $ kubectl exec ... bash
    
  2. 당연하지만, 실제 돌고 있는 프로세스에 SIGSEGV를 날리면 장애로 이어질 수 있으니, 실험용 프로세스를 띄웁니다.
    $ sleep 3600 &
    [1] 33
    
  3. 실험용 프로세스에 SIGSEGV를 날립니다.
    $ kill -SIGSEGV %1
    $ fg %1
    bash: fg: job has terminated
    [1]+  Segmentation fault      (core dumped) sleep 3600
    
  4. core가 잘 덤프되었는지 확인합니다.
    $ ls /tmp/cores
    core.sleep.33.some-pod-7dd6dc88c7-gdvp7.1669192602
    
  5. host에서도 잘 덤프되었는지 확인합니다.
    $ ls /tmp/cores
    core.sleep.33.some-pod-7dd6dc88c7-gdvp7.1669192602
    

Fluent Bit core 파일 확보하기

이렇게 설정해둔 뒤 퇴근합니다. 다시 출근하여 확인해보니 core 파일이 잘 덤프되어 있습니다. 어렵게 구한 데이터이니 소중히 로컬로 옮겨왔습니다.

$ ls /tmp/cores
core.flb-out-kafka.0.1.some-pod-7dd6dc88c7-rz724.1669206388

gdb 환경 셋업하기

이제 이 파일을 분석할 환경이 있어야 합니다. core를 분석하기 위해서는 gdb3가 필요합니다. 또한 대상 프로세스와 동일한 바이너리, 동일 시스템 환경과 소스 코드까지 있으면 좋습니다. 다행히, containerized 환경이기 때문에 로컬에서 docker를 통해 아주 쉽게 동일 환경을 재현할 수 있습니다. CPU 아키텍쳐(arm64)에 주의하며 컨테이너를 띄워줍니다.

$ docker run --platform linux/arm64 -it -v "$HOME/cores:/tmp/cores" fluent/fluent-bit:2.0.3-debug bash

운 좋게도, fluent/fluent-bit:2.0.3-debug 이미지에는 gdb와 git이 설치되어 있었습니다. 바로 gdb로 core 파일을 열어보겠습니다.

# gdb /fluent-bit/bin/fluent-bit -c core.flb-out-kafka.0.1.some-pod-7dd6dc88c7-rz724.1669206388
GNU gdb (Debian 10.1-1.7) 10.1.90.20210103-git
Copyright (C) 2021 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
(중략)
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".
Core was generated by `/fluent-bit/bin/fluent-bit -c /fluent-bit/etc/fluent-bit.conf'.
Program terminated with signal SIGTRAP, Trace/breakpoint trap.
#0  __GI_abort () at abort.c:107
107     abort.c: No such file or directory.
[Current thread is 1 (Thread 0xffff9b7f19f0 (LWP 12))]

bt 명령을 통해 스택트레이스를 확인해보겠습니다.

(gdb) bt
#0  __GI_abort () at abort.c:107
#1  0x0000aaaade5847c4 in flb_signal_handler (signal=11) at /src/fluent-bit/src/fluent-bit.c:587
#2  <signal handler called>
#3  0x0000aaaade93bb10 in produce_message (tm=0xffff9a641568, map=0xffff9a651020, ctx=0xffff9d8160e0, config=0xffff9ea31580) at /src/fluent-bit/plugins/out_kafka/kafka.c:189
#4  0x0000aaaade93c50c in cb_kafka_flush (event_chunk=0xffff9d83ad20, out_flush=0xffff9a60d000, i_ins=0xffff9ea8c000, out_context=0xffff9d8160e0, config=0xffff9ea31580) at /src/fluent-bit/plugins/out_kafka/kafka.c:483
#5  0x0000aaaade634a18 in output_pre_cb_flush () at /src/fluent-bit/include/fluent-bit/flb_output.h:519
#6  0x0000aaaadf06e3fc in co_switch (handle=0x0) at /src/fluent-bit/lib/monkey/deps/flb_libco/aarch64.c:133
#7  0x0000000000000000 in ?? ()
Backtrace stopped: previous frame identical to this frame (corrupt stack?)

스택트레이스를 읽어보면, produce_message() 함수에서 segmentation fault가 발생하여 signal handler가 호출되고, 핸들링을 마친 뒤 abort 되었음을 알 수 있습니다. 여기서 signal handler는 stdout으로 스택트레이스를 출력하는 역할을 합니다. up 명령을 통해 스택을 한단계 거슬러 올라가 보겠습니다.

(gdb) up
#1  0x0000aaaade5847c4 in flb_signal_handler (signal=11) at /src/fluent-bit/src/fluent-bit.c:587
587     /src/fluent-bit/src/fluent-bit.c: No such file or directory.

/src/fluent-bit/src/fluent-bit.c: No such file or directory. 라는 에러 메시지가 출력되었는데, gdb에서 해당하는 소스 코드를 보여주려다가 파일을 찾을 수 없다는 오류가 발생한 것입니다. Fluent Bit의 소스 코드는 gitub에 호스팅되고 있고 컨테이너에 git이 설치되어 있어 소스 코드를 구하기는 굉장히 쉬우니, /src에 소스 코드를 넣어주도록 하겠습니다. 꼭 브랜치를 신경써서 checkout 해주어야 합니다.

(gdb) q

# git clone https://github.com/fluent/fluent-bit /src/fluent-bit -b v2.0.3
Cloning into '/src/fluent-bit'...
remote: Enumerating objects: 85043, done.
remote: Counting objects: 100% (8/8), done.
remote: Compressing objects: 100% (8/8), done.
remote: Total 85043 (delta 1), reused 2 (delta 0), pack-reused 85035
Receiving objects: 100% (85043/85043), 70.70 MiB | 14.92 MiB/s, done.
Resolving deltas: 100% (58484/58484), done.
Note: switching to '86250fa62db1c3024c40391a1b92f992ca2e3885'.

다시 gdb로 코어를 열고 다시 시도해보겠습니다.

(gdb) up
#1  0x0000aaaade5847c4 in flb_signal_handler (signal=11) at /src/fluent-bit/src/fluent-bit.c:587
warning: Source file is more recent than executable.
587             abort();

소스코드가 잘 출력되는 것을 알 수 있습니다.

core 분석하기

근원적인 문제를 찾기 위해 조금 더 거슬러 올라가보겠습니다.

(gdb) up
#2  <signal handler called>
(gdb) up
#3  0x0000aaaade93bb10 in produce_message (tm=0xffff9a641568, map=0xffff9a651020, ctx=0xffff9d8160e0, config=0xffff9ea31580) at /src/fluent-bit/plugins/out_kafka/kafka.c:189
warning: Source file is more recent than executable.
189             key = map->via.map.ptr[i].key;

list 명령을 통해 주변 소스코드를 볼 수 있습니다.

(gdb) list
184             size = map->via.map.size;
185             msgpack_pack_map(&mp_pck, size);
186         }
187
188         for (i = 0; i < map->via.map.size; i++) {
189             key = map->via.map.ptr[i].key;
190             val = map->via.map.ptr[i].val;
191
192             msgpack_pack_object(&mp_pck, key);
193             msgpack_pack_object(&mp_pck, val);

소스코드를 확인해보면, map->via.map.ptr[i].key에 엑세스하다가 segmentation fault가 발생한 것으로 보입니다. p 명령을 통해 당시 변수들의 값을 확인해보겠습니다.

(gdb) p i
$1 = 165717

(gdb) p *map
$2 = {type = MSGPACK_OBJECT_FLOAT64, via = {boolean = 48, u64 = 4744787967024849456, i64 = 4744787967024849456, f64 = 1669206388.2559929, array = {size = 1561354800, ptr = 0xffff9a66a002}, map = {size = 1561354800, ptr = 0xffff9a66a002}, str = {size = 1561354800,
      ptr = 0xffff9a66a002 "\020b1\252@timestamp\313A\330\337\204]\020b0", '\300' <repeats 177 times>...}, bin = {size = 1561354800, ptr = 0xffff9a66a002 "\020b1\252@timestamp\313A\330\337\204]\020b0", '\300' <repeats 177 times>...}, ext = {type = 48 '0', size = 1104732036,
      ptr = 0xffff9a66a002 "\020b1\252@timestamp\313A\330\337\204]\020b0", '\300' <repeats 177 times>...}}}

map->type은 float64 타입(MSGPACK_OBJECT_FLOAT64)인데, 소스코드에서는 msg->via.map로 해당 데이터를 map 타입으로 참조하고 있음을 알 수 있습니다. 눈치채신 분들도 있으시겠지만, msg->via는 union 타입입니다. 다음 명령을 통해 이를 확인해볼 수 있습니다.

(gdb) ptype *map
type = struct msgpack_object {
    msgpack_object_type type;
    msgpack_object_union via;
}
(gdb) ptype map->via
type = union {
    _Bool boolean;
    uint64_t u64;
    int64_t i64;
    double f64;
    msgpack_object_array array;
    msgpack_object_map map;
    msgpack_object_str str;
    msgpack_object_bin bin;
    msgpack_object_ext ext;
}

본래 데이터는 double이므로 map->via.f64로 참조되어야 하며, 그 값은 타임스탬프임을 알 수 있습니다.

(gdb) p map->via.f64
$3 = 1669206388.2559929

소스코드대로 map->via.map을 출력해보면 이상한 값이 출력됨을 확인해볼 수 있습니다. size가 매우 큰 값으로 나왔습니다.

(gdb) p map->via.map
$4 = {size = 1561354800, ptr = 0xffff9a66a002}

이 상황을 토대로 생각해봤을 때, map이라는 변수에 map type이 전달되어야 했음을 추리해볼 수 있습니다. 이 변수는 어디서 전달된 걸까요? 소스코드 분석을 통해, caller가 parameter로 전달해주었음을 알 수 있습니다. 한 번 더 스택을 거슬러 올라가 보겠습니다.

(gdb) up
#4  0x0000aaaade93c50c in cb_kafka_flush (event_chunk=0xffff9d83ad20, out_flush=0xffff9a60d000, i_ins=0xffff9ea8c000, out_context=0xffff9d8160e0, config=0xffff9ea31580) at /src/fluent-bit/plugins/out_kafka/kafka.c:483
483             ret = produce_message(&tms, obj, ctx, config);
(gdb) list
478         while (msgpack_unpack_next(&result,
479                                    event_chunk->data,
480                                    event_chunk->size, &off) == MSGPACK_UNPACK_SUCCESS) {
481             flb_time_pop_from_msgpack(&tms, &result, &obj);
482
483             ret = produce_message(&tms, obj, ctx, config);
484             if (ret == FLB_ERROR) {
485                 msgpack_unpacked_destroy(&result);
486                 FLB_OUTPUT_RETURN(FLB_ERROR);
487             }

flb_time_pop_from_msgpack에서 처리된 obj가 produce_message 함수에 map으로 전달되고 있음을 알 수 있습니다. 여기서도 아까와 같이 변수들의 값을 출력해보겠습니다.

(gdb) p tms
$5 = {tm = {tv_sec = 1669206388, tv_nsec = 255992842}}
(gdb) p result
$6 = {zone = 0x0, data = {type = MSGPACK_OBJECT_POSITIVE_INTEGER, via = {boolean = false, u64 = 0, i64 = 0, f64 = 0, array = {size = 0, ptr = 0xaaaadf0535c0 <msgpack_unpack_next+100>}, map = {size = 0, ptr = 0xaaaadf0535c0 <msgpack_unpack_next+100>}, str = {size = 0,
        ptr = 0xaaaadf0535c0 <msgpack_unpack_next+100> "\340/@\371\340\063"}, bin = {size = 0, ptr = 0xaaaadf0535c0 <msgpack_unpack_next+100> "\340/@\371\340\063"}, ext = {type = 0 '\000', size = 0, ptr = 0xaaaadf0535c0 <msgpack_unpack_next+100> "\340/@\371\340\063"}}}}
(gdb) p *obj
$7 = {type = MSGPACK_OBJECT_FLOAT64, via = {boolean = 48, u64 = 4744787967024849456, i64 = 4744787967024849456, f64 = 1669206388.2559929, array = {size = 1561354800, ptr = 0xffff9a66a002}, map = {size = 1561354800, ptr = 0xffff9a66a002}, str = {size = 1561354800,
      ptr = 0xffff9a66a002 "\020b1\252@timestamp\313A\330\337\204]\020b0", '\300' <repeats 177 times>...}, bin = {size = 1561354800, ptr = 0xffff9a66a002 "\020b1\252@timestamp\313A\330\337\204]\020b0", '\300' <repeats 177 times>...}, ext = {type = 48 '0', size = 1104732036,
      ptr = 0xffff9a66a002 "\020b1\252@timestamp\313A\330\337\204]\020b0", '\300' <repeats 177 times>...}}}

tms는 타임스탬프, result는 MSGPACK_OBJECT_POSITIVE_INTEGER이므로 0, obj는 아까 살펴보았던 map과 같네요. flb_time_pop_from_msgpack 함수의 소스코드도 한번 살펴보겠습니다.

(gdb) list flb_time_pop_from_msgpack
361     int flb_time_pop_from_msgpack(struct flb_time *time, msgpack_unpacked *upk,
362                                   msgpack_object **map)
363     {
364         int ret;
365         msgpack_object obj;
366
367         if (time == NULL || upk == NULL) {
368             return -1;
369         }
370
371         if (upk->data.type != MSGPACK_OBJECT_ARRAY) {
372             return -1;
373         }
374
375         obj = upk->data.via.array.ptr[0];
376         *map = &upk->data.via.array.ptr[1];
377
378         ret = flb_time_msgpack_to_time(time, &obj);
379         return ret;
380     }
381

flb_time_pop_from_msgpackupk를 받아서 타입을 체크한 뒤 array라면 원소들을 분리한 다음, 0번째 원소로부터 time에 시간 정보를 채워주고, map에는 1번째 원소를 넣어 되돌려줍니다. 즉, 함수의 input은 upk, output은 timemap이며 Fluent Bit 문서4에 명시되어 있는 Event의 구조와 일치하는 것을 미루어 봤을때, input의 원소를 분리하여 timestamp를 파싱하고, message를 리턴해주는 함수라고 요약할 수 있겠습니다. Event의 구조에 맞는 input이 들어왔다면 message는 map이라고 생각해도 될 것입니다. 하지만 upk의 값으로 전달되었던 result의 타입은 MSGPACK_OBJECT_POSITIVE_INTEGER 였으므로, 잘못된 input이 들어왔음을 알 수 있습니다. 따라서, msgpack_unpack_next에 넘어갔던 파라미터들도 출력해보겠습니다.

(gdb) p *event_chunk
$8 = {type = 0, tag = 0xffff9d84c090 "some.application.tag", data = 0xffff9ef1402a, size = 4911, total_events = 1}
(gdb) p off
$9 = 859

event_chunk의 데이터 사이즈가 4,911인데 오프셋 859를 처리하다가 오류가 발생했다고 생각할 수 있습니다. x 명령을 통해 데이터를 살펴보겠습니다.

(gdb) x/4911xb event_chunk->data
0xffff9ef1402a: 0x92    0xd7    0x00    0x63    0x7e    0x11    0x74    0x0f
0xffff9ef14032: 0x42    0x24    0x0a    0x8a    0xaa    0x63    0x6f    0x6e
0xffff9ef1403a: 0x74    0x65    0x78    0x74    0x4d    0x61    0x70    0x83
0xffff9ef14042: 0xa8    0x74    0x72    0x61    0x63    0x65    0x5f    0x69
0xffff9ef1404a: 0x64    0xd9    0x20    0x30    0x35    0x32    0x63    0x31
0xffff9ef14052: 0x32    0x61    0x65    0x37    0x63    0x65    0x62    0x66
0xffff9ef1405a: 0x64    0x62    0x65    0x39    0x65    0x33    0x62    0x38
0xffff9ef14062: 0x33    0x63    0x31    0x35    0x34    0x37    0x33    0x37
(후략)

데이터가 잘 들어있는 것으로 보이는데, off 주변은 어떨지도 확인해보았습니다.

(gdb) x/4911xb event_chunk->data+859
0xffff9ef14385: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0xffff9ef1438d: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0xffff9ef14395: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0xffff9ef1439d: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0xffff9ef143a5: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0xffff9ef143ad: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0xffff9ef143b5: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0xffff9ef143bd: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
(후략, 모두 0임)

이상하게도 데이터가 모두 null byte로 차 있습니다. 어떠한 이유로 인해 데이터가 깨진 것 같네요. 이 가설을 확인해보기 위해 해당 데이터를 덤프한 뒤 파이썬으로 decode 해보겠습니다.

(gdb) dump memory event_chunk_data event_chunk->data event_chunk->data+4911
(gdb) q
# ls -al event_chunk_data
-rw-r--r-- 1 root root 4911 Dec 13 06:50 event_chunk_data

잘 덤프되었습니다. 이 데이터를 디코딩하기 위한 msgpack 라이브러리를 설치해주겠습니다.

# apt update && apt install -y python3-pip
# pip3 install msgpack
Collecting msgpack
  Downloading msgpack-1.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (313 kB)
     |████████████████████████████████| 313 kB 2.4 MB/s
Installing collected packages: msgpack
Successfully installed msgpack-1.0.4

Fluent Bit의 구현과 같이 streaming unpacking을 구현하여 decode 해보겠습니다.

>>> import msgpack
>>> unpacker = msgpack.Unpacker(open('event_chunk_data', 'rb'))
>>> result = list(unpacker)
>>> result
[[ExtType(code=0, data=b'c~\x11t\x0fB$\n'), {'contextMap': {'trace_id': '052c12ae7cebfdbe9e3b83c154737be0', 'trace_flags': '00', 'span_id': '4539caaf24c93491'}, 'loggerFqcn': 'default', 'level': 'INFO', ...}], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (후략))]

gdb에서 살펴보았던 것과 같이 원래 처리됐어야 하는 메시지에 추가로 수많은 null byte들이 붙어있는데, 필요 없는 데이터인 것으로 보입니다. 이 데이터는 어디서 만들어져서 온 걸까요? 제가 up을 통해 계속 거슬러 올라가 보니 Fluent Bit 코어 쪽에서 온 데이터이고, event_chunk 자체가 asynchronous하게 처리되는 탓에 core에 stack frame이 없어 당시 상황을 정확하게 알기는 어려웠습니다. 다만 Fluent Bit 설정대로 input_tail_file 플러그인과 filter_modify 플러그인으로부터 데이터가 만들어졌고 그때부터 데이터가 깨졌을 것으로 추정됩니다.

이제 이 버그를 어떻게 고쳐야할지 감이 오시는 분들도 계실텐데요. map이 아닌 데이터가 produce_message 함수에 들어오지 않도록 수정하면 됩니다. 그렇게 코드를 수정하려던 차에, 왜 Kafka output 플러그인에서만 문제가 발생하는지 궁금해져서 다른 플러그인에서는 어떻게 처리하고 있는지 확인해보았습니다.

놀랍게도, 다른 플러그인들은 해당 케이스를 예외 처리하고 있었습니다. 5

PR 제출하기

code_changes

디버깅을 통해 event_chunk가 깨져서 오는 근본적인 원인은 찾지 못했지만, Kafka output 플러그인을 어떻게 고쳐야 할지는 명확해졌습니다. 당시와 똑같은 인풋을 줘도 항상 재현되는 문제가 아니라서, 테스트 코드를 작성하지는 못했는데요. 대신 위와 같이 소스코드를 수정한 뒤, 테스트를 돌려보고, 커스텀 빌드하여 sidecar로 적용해보니 segmentation fault가 해결됨을 확인할 수 있었습니다.

the_pr

그리고 PR6을 제출했습니다. PR은 제출한 지 8일만에 upstream에 merge되었습니다.

결론

segmentation fault가 발생하면 linux에서는 core를 dump하며, 이 파일을 분석하여 버그를 찾아낼 수 있습니다. (windows에서도 crash dump를 생성하며 약간 다른 방식으로 파일을 분석하면 버그를 찾아낼 수 있습니다.) 이렇게 중요한 core를 Kubernetes 환경에서 확보하고 분석하는 방법에 관하여 다루어보았습니다. 직접 분석하기 어려울 경우, core 파일만 공유해서 버그를 찾아달라고 할 수도 있겠지만, core에는 실행 중이던 프로세스의 메모리 일부가 들어있으므로 민감 정보가 포함될 수 있어 주의해야 합니다.

마무리는 채용공고입니다. 하이퍼커넥트에 오시면 이런 재밌고 챌린징한 일들을 함께하실 수 있습니다! 많은 지원 부탁드립니다. https://career.hyperconnect.com/jobs

References