엔지니어링

  • FastTrack을 활용한 Domain adaptive language model fine-tuning

    By 권용근

    Introduction

    이번 글에서는 Backend.AI의 MLOps 플랫폼인 FastTrack을 이용하여 공급망(Supply chain) 및 무역 관련 도메인에 특화된 언어모델을 학습시키고, 평가하는 방법에 대해 설명합니다. 해당 언어모델을 위한 Base model로는 공급망 및 무역 도메인 데이터 세트를 통해 continual-pretrained된 gemma-2-2b-it 모델을 사용하였습니다. 사용처에 따라 Question Answering task에 특화된 모델을 학습시키기 위해 웹에서 직접 수집, 가공한 도메인 데이터 세트를 학습 가능한 질문과 답변으로 구성되는 포맷(이하 Q/A task)으로 변환하여 사용하였습니다.

    AI를 개발하는 과정에서는 데이터 전처리, 학습, 검증, 배포, 추론과 같은 단계를 거쳐야 합니다. 래블업의 FastTrack을 사용하면 위와 같은 각각의 단계를 하나의 파이프라인으로 구성할 수 있고, 파이프라인 구성에 따라 특정 단계를 건너뛰거나 단계별 자원량을 다르게 설정하는 등 손쉽게 커스터마이징 할 수 있습니다.

    Concept of Domain Adaptation

    본격적인 모델 학습에 들어가기 앞서, Domain Adaptation이라는 과정이 필요합니다. 생소하신 분들을 위해 짧게 설명하자면, Domain Adaptation이란 사전 학습된 모델을 특정 도메인에 적합하도록 개선하는 프로세스를 말합니다. 오늘날 우리가 접하는 대다수의 일반적인 언어모델들은 특정 분야에 전문적인 지식을 가지도록 만들어지지 않았습니다. 대부분의 모델은 일반적인 도메인에서의 데이터 세트를 사용하여 다음 토큰을 잘 예측할 수 있도록 학습한 뒤, 전반적인 사용 방향에 맞게 fine-tune 되어 만들어집니다. 그러나 전문 도메인에서 사용될 목적으로 모델을 만든다면, 일반적인 데이터 세트를 사용하여 학습시키는 것은 충분치 않습니다. 예를 들어, 범용적인 도메인에서 학습된 모델은 "이 영화가 매우 훌륭했다"와 같은 일반적인 문장의 맥락을 잘 파악할 수 있지만, "법원이 채무자의 자산을 압류하도록 명령했다"와 같은 법률 도메인의 문장은 제대로 해석하지 못할 수 있습니다. 이는 모델이 각 도메인에서 사용되는 특수한 용어와 표현을 학습하지 않았기 때문입니다. 또 다른 예시로, 어떠한 Q/A task가 주어졌다면 일반적인 데이터로는 Q/A task를 구현할 수 없을 가능성이 있습니다. 제대로 된 Q/A task를 처리하기 위해서는 Q/A task에 특화된 데이터 세트로 사전 학습된 언어 모델을 fine-tune하는 식으로 '특정한 도메인의 데이터'를 넣어주어야 하기 때문이죠. 이러한 fine-tuning 과정은 모델이 작업의 뉘앙스를 더 잘 이해하여 사용자의 domain-specific한 질문에 대해 효과적으로 답변할 수 있도록 합니다.

    이번 글에서는 공급망(Supply Chain Management, 이하 SCM) 및 무역 도메인에 특화된 모델을 개발하는 과정을 다룹니다. 위 그림에서 볼 수 있듯이, "영화"나 "여행" 같은 일반 도메인 용어와 "항공화물운송장", "대금결제인"과 같은 SCM 도메인 용어 사이에는 현격한 차이가 있습니다. 이러한 차이를 좁히기 위해 SCM과 무역 도메인에서의 데이터 세트를 활용하여, 해당 도메인에 대한 모델의 이해도를 높이고, 맥락을 더욱 정확하게 파악할 수 있도록 조정하는 것이 우리가 오늘 달성해볼 목표입니다. 정리하면, Domain Adaptation은 본질적으로 서로 다른 도메인 간의 격차를 해소, 새로운 맥락에서 모델이 더 나은 성능을 발휘할 수 있도록 돕는 과정이라고 할 수 있습니다.

    Train model from scratch vs DAPT

    그렇다면 처음부터 해당 도메인의 데이터 세트를 통해 학습(Train model from scratch)하면 되지 않을까요? 물론 가능하지만, 여러가지 한계점이 존재합니다. 만약 처음부터 해당 도메인의 데이터 세트를 통해 학습하게 되면, 해당 도메인에서의 지식은 물론 일반적인 도메인에서의 지식조차 없는 상황이기 때문에 더 많은 데이터 세트와 학습이 요구될 수 있습니다. 일반적인 도메인에서의 딥러닝을 위한 데이터 세트를 수집하는 것도 어렵지만, 특정 도메인에 국한된 양질의 데이터를 수집하는 것은 더욱 어려운 일입니다. 데이터를 수집했다 치더라도, 모델 학습에 맞게 전처리하는 과정에서 많은 시간과 비용이 발생하게 되죠. 따라서 모델을 처음부터 학습시키는 것은 해당 도메인의 데이터 세트를 충분히 확보하고 있고, 자원을 충분히 보유한 기업에 더 적합한 방법이라고 할 수 있습니다.

    만약 domain-adaptive 한 model을 개발하고 싶은데 아주 많은 데이터 세트를 확보하지 못했거나, 자원이 충분하지 않다면 어떻게 해야 할까요? 이런 경우 선택할 수 있는 방법이 Domain-Adaptive Pre-Training (DAPT)입니다. DAPT란, 이미 일반적인 도메인을 통해 충분히 학습된 모델을 특정 도메인의 데이터 세트로 continual pretraining (지속 학습)하여 도메인에 특화된 모델을 개발하는 과정을 말합니다. 이 방법은 일반적인 도메인에 대한 지식을 이미 보유하고 있는 모델을 추가로 학습시키는 방법이기 때문에, 모델을 처음부터 학습시키는 방법에 비해 상대적으로 적은 비용과 데이터 세트를 요구합니다.

    Development environment Setup

    1. 모델 학습에 앞서, 필요한 패키지들을 설치합니다.
    pip install bitsandbytes==0.43.2
    pip install deepspeed==0.14.4
    pip install transformers==4.43.3
    pip install accelerate==0.33.0
    pip install flash-attn==1.0.5
    pip install xforms==0.1.0
    pip install datasets==2.20.0
    pip install wandb
    pip install evaluate==0.4.2
    pip install vertexai==1.60.0
    pip install peft==0.12.0
    pip install tokenizers==0.19.1
    pip install sentencepiece==0.2.0
    pip install trl==0.9.6
    pip install bitsandbytes==0.43.2
    pip install deepspeed==0.14.4
    pip install transformers==4.43.3
    pip install accelerate==0.33.0
    pip install flash-attn==1.0.5
    pip install xforms==0.1.0
    pip install datasets==2.20.0
    pip install wandb
    pip install evaluate==0.4.2
    pip install vertexai==1.60.0
    pip install peft==0.12.0
    pip install tokenizers==0.19.1
    pip install sentencepiece==0.2.0
    pip install trl==0.9.6
    
    1. 모듈 가져오기
    import os
    import json
    from datasets import load_from_disk, Dataset,load_dataset
    import torch
    from transformers import AutoTokenizer, AutoModelForCausalLM, Gemma2ForCausalLM, BitsAndBytesConfig, pipeline, TrainingArguments
    from peft import LoraConfig, get_peft_model
    import transformers
    from trl import SFTTrainer
    from dotenv import load_dotenv
    import wandb
    from huggingface_hub import login
    

    Dataset preparation

    데이터 세트는 fine-tuning의 목적에 따라 다르게 준비되어야 합니다. 이 글에서는 무역 영역에 대한 질문에 효과적으로 답변할 수 있는 모델을 학습하는 것을 목표로 하기 때문에, 웹 크롤링을 통해 자체적으로 수집한 데이터 세트를 사용합하기로 결정했습니다. 데이터 세트는 무역 자격증 시험 데이터 세트, 무역 용어-정의 데이터 세트, 무역 강의 스크립트 데이터 세트의 세 가지 유형으로 분류됩니다.

    1. 무역 자격증 시험 데이터 세트

    질문: 다음 중 우리나라 대외무역법의 성격에 대한 설명으로 거리가 먼 것을 고르시오. 1. 우리나라에서 성립되고 이행되는 대외무역행위는 기본적으로 대외무역법을 적용한다. 2. 타 법에서 명시적으로 대외무역법의 적용을 배제하면 당해 법은 특별법으로서 대외무역법보다 우선 적용된다. 3. 대외무역법은 국내법으로서 국민의 국내 경제생활에 적용되는 법률이기 때문에 외국인이 국내에서 행하는 무역행위는 그 적용 대상이 아니다. 4. 관계 행정기관의 장은 해당 법률에 의한 물품의 수출·수입 요령 그 시행일 전에 지식경제부 장관이 통합하여 공고할 수 있도록 제출하여야 한다. 정답: 대외무역법은 국내법으로서 국민의 국내 경제생활에 적용되는 법률이기 때문에 외국인이 국내에서 행하는 무역행위는 그 적용 대상이 아니다. 질문: ...

    1. 무역 용어 정의 데이터 세트
    {
      "term": "(계약 등을) 완전 무효화하다, 백지화하다, (처음부터) 없었던 것으로 하다(Rescind)",
      "description": "계약을 파기, 무효화, 철회, 취소하는 것; 그렇지 않았음에도 불구하고 계약을 시작부터 무효인 것으로 선언하고 종결짓는 것."
    }
    
    
    1. 무역 강의 스크립트 데이터 세트

    예전에는 전자상거래 셀러가 엑셀에다가 입력을 해서 수출신고 데이터를 업로드 해서 생성을 했잖아요 그리고 대량으로 전송하는 셀러는 api를 통해서 신고를 했습니다 그런데 그 수출신고 정보의 원천정보를 뭐냐면 쇼핑몰에서 제공하는 판매 주문정보입니다 그래서 그 쇼핑몰에 직접 저희가 연계를 해서 판매 주문 정보를 가져올 수 있게끔 새 서비스를 만들었어요 그래서 API 연계된 쇼핑몰들이 있는데 그게 현재 5개가 연결되어 있는데 쇼피 쇼피파이 라자다 라쿠텐 q10이 있고요 아마존하고 위치도 연계 예정에 있습니다 그래서 셀러는 ...

    Q/A 작업에 알맞은 모델을 만들려면 데이터 세트를 질의응답 형식으로 변환해야 합니다. 첫 번째 데이터 세트인 무역 자격증 시험 데이터 세트와 두 번째 데이터 세트인 무역 용어 정의 데이터 세트는 간단한 코드를 사용하여 변환할 수 있지만, 세번째 데이터 세트인 무역 강의 스크립트 데이터 세트를 확인해 보면 대화 형식의 데이터를 직접 변환하기 어려워 보입니다. 이 경우에는 대화형 스크립트에서 대규모 언어 모델(LLM)을 활용하여 Q/A 쌍을 추출하는 접근 방식을 사용할 수 있습니다. 프롬프트와 그 결과는 다음과 같습니다.

    Prompt

    summary: {summary}
    Instruction: 다음 summary를 기반으로 질문과 해당 답변을 추출하세요.
    출력 형식은 JSON으로, 각 질문과 답변을 객체로 묶어 'qa_pairs'라는 배열 안에 넣어야 합니다.
    'questions' key에는 요약에서 도출된 질문 목록이 포함되어야 하며, 'answers' key에는 각 질문에 대한 해당 답변 목록이 포함되어야 합니다:
    출력 예시:
    {{"qa_pairs": [{{"question": "Question1", "answer": "Answer1"}},{{"question": "Question2", "answer": "Answer2"}},{{"question": "Question3", "answer": "Answer3"}},]...}}
    Output:
    

    Output

    {"qa_pairs":
      [
        {"question": "전자상거래 무역 플랫폼은 어떤 서비스를 제공하나요?", "answer": "판매 주문 관리, 창고 관리, 수출입 통관, 배송 관리 등 전자상거래 업무를 위한 다양한 서비스를 제공합니다"},
        {"question": "쇼핑몰과의 API 연계를 통해 무엇을 간소화할 수 있나요?", "answer": "판매 주문 정보를 자동으로 가져와 수출신고를 간소화할 수 있습니다"},
        {"question": "창고 관리 기능은 어떤 것을 관리할 수 있나요?", "answer": "자가 창고, 위촉 창고, 해외 풀필먼트 센터 등을 관리할 수 있습니다"},
        {"question": "수출입 통관 서비스는 어떤 기능을 제공하나요?", "answer": "미국 세관과 연동된 수출입 통관 서비스를 통해 미국 수출 시 수입 신고를 간편하게 처리할 수 있습니다"},
        {"question": "배송 관리 기능은 어떤 서비스를 지원하나요?", "answer": "우체국 EMS, DHL 등 개별 배송과 함께 해외 현지 라스트 마일 배송까지 지원합니다"}
      ]
    }
    

    이제 간단한 코드를 사용하여 각 데이터 세트를 Q/A 데이터 집합으로 변환할 준비가 된 것 같습니다. 아래의 코드를 사용하여 각 데이터 세트를 Q/A 형식으로 변환해 보겠습니다.

    import os
    import json
    import re
    from datasets import Dataset, concatenate_datasets, load_from_disk
    
    def replace_dot_number(text):
        result = re.sub(r'\.(\d+)\.', r'. \1.', text)
        return result
    
    def read_json(path):
        with open(path, 'r', encoding='utf-8') as f:
            return json.load(f)
    
    def write_json(data, path):
        with open(path, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False)
    
    def dataset_maker(data:list) -> Dataset:
        return Dataset.from_list(data)
    
    def save_dataset(dataset, save_path):
        dataset.save_to_disk(save_path)
    
    def exam_qa_formatter():
        data = []
        root = 'dataset/exam_data'
        for file in sorted(os.listdir(root)):
            file_path = os.path.join(root, file)
            content = read_json(file_path)['fixed_text']
            question_list = content.split('질문:')[1:]
            for question in question_list:
                try:
                    question_and_options = replace_dot_number(question.split('정답:')[0]).strip()
                    answer = question.split('정답:')[1].strip()
                    data.append({"context": replace_dot_number(question), "question":question_and_options, "answer":answer})
    
                except Exception as e:
                    pass
        return data
    
    def description_to_term_formattter(kor_term, eng_term, description):
        context = f"{kor_term}: {description}"
        question = f"설명: '{description}' 이 설명에 해당하는 무역 용어는 무엇인가요?"
        answer = kor_term if eng_term is None else f"{kor_term}, {eng_term}"
        return context, question, answer
    
    def term_to_description(kor_term, eng_term, description):
        context = f"{kor_term}: {description}"
        question = f"'{kor_term}({eng_term})' 이라는 무역 용어는 어떤 의미인가요?" if eng_term is not None else f"'{kor_term}' 이라는 무역 용어는 어떤 의미인가요?"
        answer = description
        return context, question, answer
        
    def term_qa_formatter():
        data = []
        root = 'dataset/term_data'
        for file in os.listdir(root):
            file_path = os.path.join(root, file)
            term_set = read_json(file_path)
            if file == 'terms_data_2.json':
                term_set = [item for sublist in term_set for item in sublist]
            for pair in term_set:
                eng_term = pair.get('eng_term', None)
                if 'term' in pair.keys():
                    kor_term = pair['term']
                else:
                    kor_term = pair['kor_term']
                description = pair['description']
                context_1, question_1, answer_1 = description_to_term_formattter(kor_term, eng_term, description)
                context_2, question_2, answer_2 = term_to_description(kor_term, eng_term, description)
                data_1 = {"context": context_1, "question": question_1, "answer": answer_1} 
                data_2 = {"context": context_2, "question": question_2, "answer": answer_2} 
                data.append(data_1)
                data.append(data_2)
        return data
    
    def transcript_qa_formatter():
        data = []
        root = 'dataset/transcript_data/success'
    
        for file in sorted(os.listdir(root)):
            file_path = os.path.join(root, file)
            for line in open(file_path):
                line = json.loads(line)
                context = line['context']
                output = line['json_output']
    
                qa_pairs = json.loads(output)['qa_pairs']
                for pair in qa_pairs:
                    question = pair['question']
                    answer = pair['answer']
                    if type(answer) == list:
                        answer = answer[0]
                    data.append({"context": context, "question": question, "answer": answer})
        return data
    
    ###### Term dataset
    {'context': 'APEC 경제위원회(Economic Committee (EC)): 개별위원회나 실무그룹이 추진하기 어려운 여러분야에 걸친 이슈에 대한 분석적 연구작업을 수행하기 위해 결성된 APEC 기구,',
     'question': "설명: '개별위원회나 실무그룹이 추진하기 어려운 여러분야에 걸친 이슈에 대한 분석적 연구작업을 수행하기 위해 결성된 APEC 기구,' 이 설명에 해당하는 무역 용어는 무엇인가요?",
     'answer': 'APEC 경제위원회(Economic Committee (EC))'}
    
    ###### Transcript dataset
    {'context': '수입 신고는 일반적으로 입항 후에 하는 것이 원칙이며, 보세 구역에서 5부 10장을 작성하여 신고합니다',
     'question': '수입 신고는 언제 하는 것이 원칙인가요?',
     'answer': '수입 신고는 일반적으로 입항 후에 하는 것이 원칙입니다.'}
    
    ###### Exam dataset
    {'context': ' 다음 중 우리나라 대외무역법의 성격에 대한 설명으로 거리가 먼 것을 고르시오. 1. 우리나라에서 성립되고 이행되는 대외무역행위는 기본적으로 대외무역법을 적용한다. 2. 타 법에서 명시적으로 대외무역법의 적용을 배제하면 당해 법은 특별법으로서 대외무역법보다 우선 적용된다. 3. 대외무역법은 국내법으로서 국민의 국내 경제생활에 적용되는 법률이기 때문에 외국인이 국내에서 행하는 무역행위는 그 적용 대상이 아니다. 4. 관계 행정기관의 장은 해당 법률에 의한 물품의 수출·수입 요령 그 시행일 전에 지식경제부 장관이 통합하여 공고할 수 있도록 제출하여야  한다.정답: 대외무역법은 국내법으로서 국민의 국내 경제생활에 적용되는 법률이기 때문에 외국인이 국내에서 행하는 무역행위는 그 적용 대상이 아니다.',
     'question': '다음 중 우리나라 대외무역법의 성격에 대한 설명으로 거리가 먼 것을 고르시오. 1. 우리나라에서 성립되고 이행되는 대외무역행위는 기본적으로 대외무역법을 적용한다. 2. 타 법에서 명시적으로 대외무역법의 적용을 배제하면 당해 법은 특별법으로서 대외무역법보다 우선 적용된다. 3. 대외무역법은 국내법으로서 국민의 국내 경제생활에 적용되는 법률이기 때문에 외국인이 국내에서 행하는 무역행위는 그 적용 대상이 아니다. 4. 관계 행정기관의 장은 해당 법률에 의한 물품의 수출·수입 요령 그 시행일 전에 지식경제부 장관이 통합하여 공고할 수 있도록 제출하여야  한다.',
     'answer': '대외무역법은 국내법으로서 국민의 국내 경제생활에 적용되는 법률이기 때문에 외국인이 국내에서 행하는 무역행위는 그 적용 대상이 아니다.'}
    
    # Exam dataset
    Dataset({
        features: ['context', 'question', 'answer'],
        num_rows: 1430
    })
    
    # Term dataset
    Dataset({
        features: ['context', 'question', 'answer'],
        num_rows: 15678
    })
    
    # Transcript dataset
    Dataset({
        features: ['context', 'question', 'answer'],
        num_rows: 8885
    })
    
    # Concatenated dataset 
    Dataset({
        features: ['context', 'question', 'answer'],
        num_rows: 25993
    })
    

    Q/A 형식의 데이터 세트와 합쳐진 데이터 세트(학습 데이터 세트)는 위와 같습니다. 약 26,000개의 Q/A 쌍이 학습에 사용될 것으로 예상됩니다.

    이제 fine-tuning을 위한 데이터 세트가 준비되었습니다. 이 데이터 세트가 실제로 모델에 어떻게 입력되는지 확인해 보겠습니다.

    <bos><start_of_turn>user
    Write a hello world program<end_of_turn>
    <start_of_turn>model
    

    huggingface 웹사이트에서는 채팅 템플릿 형식과 모델의 프롬프트 형식 정의에 대한 정보가 포함된 gemma-2b-it의 모델 카드를 찾을 수 있습니다(gemma-2-2b-it). 즉, gemma에게 질문을 하려면 모델이 이해할 수 있는 형식의 프롬프트를 만들어야 하는 것이죠.

    대화의 시작은 <start_of_turn>으로 표시되며, 대화의 끝은 <end_of_turn>으로 표시됩니다. 화자는 사용자 및 모델로 지정되어 있음을 알 수 있습니다. 따라서 모델에게 질문을 할 때 프롬프트의 형식은 위와 같은 형식이어야 합니다.

    def formatting_func(example):
        prompt_list = []
        for i in range(len(example['question'])):
            prompt_list.append("""<bos><start_of_turn>user
        다음 질문에 대답해주세요:
        {}<end_of_turn>
        <start_of_turn>model
        {}<end_of_turn><eos>""".format(example['question'][i], example['answer'][i]))
            return prompt_list  
    

    이 문서에서는 Q/A 데이터 세트를 사용하여 모델을 학습시키는 데 중점을 두고 있으므로 '이런 종류의 질문에는 이렇게 대답해야 한다'는 식으로 접근하여 모델을 학습시킬 것입니다. 앞서 언급한 채팅 템플릿을 고려하면 위와 같은 형식으로 코드를 작성할 수 있습니다. 이때, 채팅 템플릿에 토큰이 명시적으로 포함되어 있지 않더라도 모델은 구분 기호 이상으로 더 많은 콘텐츠를 생성하려고 시도할 수 있습니다. 이 때 모델이 답변만 제공하고 턴을 종료하도록 하기 위해 토큰을 추가해줍니다.

    <bos><start_of_turn>user
    다음 질문에 대답해주세요:
    '(관세)감축률(Reduction Rate)' 이라는 무역 용어는 어떤 의미인가요?<end_of_turn>
    <start_of_turn>model
    관세를 감축하는 정도를 말함. 예를 들어 200%p에 관세감축률이 50%를 적용하면 감축 후 관세는 100%p가 됨. 극단적인 경우로 관세감축률이 100%이면 모든 관세는 감축 후에는 0%p가 됨.<end_of_turn><eos>
    

    실제 학습에서는 위와 같은 예시가 input으로 들어가게 됩니다. 이제 학습을 위한 데이터 세트 준비를 마쳤습니다.

    Training

    학습 코드는 아주 간단합니다. SFTTrainer를 사용하며, base model로는 이전에 SCM & 무역 데이터 세트를 통해 continual-pretrained 된 모델을 gemma-2-2b-it 모델을 사용합니다.

    model_id = "google/gemma-2-2b-it"
    output_dir = 'QA_finetune/gemma-2-2b-it-lora128'
    tokenizer = AutoTokenizer.from_pretrained(model_id, token=access_token)
    
    model = AutoModelForCausalLM.from_pretrained(
                # "google/gemma-2-2b-it",
                "yonggeun/gemma-2-2b-it-lora128-merged",
                device_map="auto",
                torch_dtype=torch.bfloat16,
                token=access_token,
                attn_implementation="eager", # attn_implementation,
                cache_dir="./models/models",
            )
    
    
    def formatting_func(example):
        prompt_list = []
        for i in range(len(example['question'])):
            prompt_list.append("""<bos><start_of_turn>user
    다음 질문에 대답해주세요:
    {}<end_of_turn>
    <start_of_turn>model
    {}<end_of_turn><eos>""".format(example['question'][i], example['answer'][i]))
        return prompt_list   
    
    
    def train(data):  
        valid_set = data["test"]
        valid_set.save_to_disk('QA_finetune/valid_set/gemma-2-2b-it-lora128')
    
        lora_config = LoraConfig(
            r=256,
            lora_alpha=32,
            lora_dropout=0.05,
            bias="none",
            target_modules=["q_proj", "o_proj", "k_proj", "v_proj", "gate_proj", "up_proj", "down_proj"],
            task_type="CAUSAL_LM",
        )
    
        training_args = TrainingArguments(
            per_device_train_batch_size=2,
            warmup_steps=2,
            logging_steps=1, 
            gradient_accumulation_steps=4,
            # num_train_epochs=3,
            num_train_epochs=3,  
            learning_rate=2e-4,
            save_steps=100,
            fp16=False,
            bf16=True,
            output_dir=output_dir,
            push_to_hub=True,
            report_to="wandb"
        )
    
        trainer = SFTTrainer(
            model=model,
            tokenizer=tokenizer,
            train_dataset=data['train'],
            args=training_args,
            formatting_func=formatting_func,
            peft_config=lora_config,
            max_seq_length=max_length,
            packing= False,
        )
    
        model.config.use_cache = False
    
        print("Training...")
        trainer.train()
        print("Training done!")
    

    Evaluation

    훈련이 성공적으로 완료되면 필수적으로 모델의 성능을 평가해야 합니다. 이 글에서는 특정 도메인에서의 Question Answering 성능을 평가하는 데 중점을 두었기 때문에 일반적인 모델의 벤치마크에 사용되는 것과는 다른 지표가 필요했습니다. 이 글에서는 SemScore와 Truthfulness를 활용하여 모델을 평가했습니다.

    SemScore: 대상 응답과 모델 응답 간의 의미적 텍스트 유사성을 기반으로 하는 평가 방법입니다.(SemScore)

    Evaluating Truthfulness: 이 방법은 LLM에 모델 응답과 답변을 제공한 다음 1에서 5까지의 척도로 진실성을 측정하는 방식입니다.(Truthfulness)

    Fasttrack pipeline

    이제 FastTrack에서 모델 학습에 사용될 파이프라인을 만들어보겠습니다. 파이프라인은 FastTrack에서 사용하는 작업 단위입니다. 각 파이프라인은 최소 실행 단위인 태스크(Task)의 묶음으로 표현될 수 있습니다. 하나의 파이프라인에 포함되는 여러 개의 태스크는 서로 의존 관계를 가질 수 있으며, 이 의존성에 따라 순차적으로 실행이 보장됩니다.

    Create Pipeline

    위 그림에서 파란색 '+' 버튼을 찾아 새 파이프라인을 만듭니다.

    파이프라인을 생성하면 파이프라인의 이름과 설명, 사용할 데이터 저장소의 위치, 그리고 파이프라인에서 공통적으로 적용할 환경변수 등을 선택할 수 있습니다. 필요한 정보를 입력한 후 하단의 "Save" 버튼을 클릭하여 파이프라인을 생성합니다.

    Drag and create task

    새 파이프라인이 만들어지면 작업 템플릿에 새 작업을 추가할 수 있습니다. Custom Task를 클릭해서 아래 작업 공간으로 끌고오면, task가 새롭게 생성됩니다.

    Enter information

    작업을 만들 때는 위와 같이 작업 실행에 필요한 정보를 입력해야 합니다. 태스크 이름과 설명을 이해하기 쉽게 작성하고, 단일 노드 또는 다중 노드 중 하나를 선택합니다. 본 문서에서는 단일 노드 훈련을 수행하므로 단일 노드를 선택하겠습니다.

    다음으로 명령어를 작성해야 합니다. 명령은 기본적으로 세션을 실행하는 명령어입니다. 실행할 스크립트가 오류 없이 작동할 수 있도록 마운트된 V-folder의 디렉터리를 정확하게 지정해야 합니다. 학습에 필요한 대부분의 패키지는 이미 세션에 설치되어 있지만, 추가 패키지를 설치해야 하거나 버전 문제가 있는 경우에는 패키지를 다시 설치해야 할 수 있습니다. 이 경우 requirements.txt 파일에서 필요한 패키지를 지정하고 설치 후 다른 스크립트를 실행할 수 있습니다.

    Resource configuration

    다음은 세션, 리소스 및 V-folder 설정입니다.

    본 글에서는 Pytorch 기반으로 코드를 작성하였지만, Pytorch 이외에도 Tensorflow, Triton server 등의 환경을 선택할 수 있습니다.

    FastTrack의 장점 중 하나는 리소스를 최대한 효율적으로 활용할 수 있다는 점입니다. 하나의 리소스 그룹 내에서도 여러 세션에 리소스를 분할하여 resource utilization rate를 극대화할 수 있습니다.

    데이터 세트 준비의 경우 별도의 GPU 연산이 필요하지 않기 때문에 GPU 리소스를 할당하지 않아도 괜찮습니다. 이를 통해 최소한의 리소스로 코드를 실행할 수 있으며, 이 시간 동안 다른 세션에 GPU 리소스를 할당할 수 있어 GPU 리소스가 유휴 상태로 남는 상황을 방지할 수 있습니다. 또한 병렬적으로 모델 학습이 필요한 경우(예: 10FGPU가 가용하고 각 훈련 세션에 5FGPU씩 필요한 경우) 모델을 병렬로 학습시킬 수 있죠. 이렇게 하면 리소스 낭비를 줄이고 학습 시간을 단축할 수 있습니다.

    준비한 데이터 세트와 학습 코드가 작성되어 있는 V-folder를 올바르게 선택합니다.

    Duplicate or delete task

    작업 블록의 우측 상단 미트볼 메뉴 아이콘 (⋯)을 누르면 생성된 작업을 복제하거나 삭제할 수 있습니다.

    FastTrack에서는 이와 같이 생성된 여러 개의 작업 사이 순서를 정할 수 있습니다. 이는 작업들 간의 의존성을 추가하는 과정입니다. 경우에 따라 여러개의 작업이 끝난 뒤에 다음 작업이 실행되도록 설정할 수도 있습니다. 이 경우에는 작업들 간의 의존성에 따라 모든 작업이 끝나기 전까지 다음 작업이 진행되지 않습니다. 완성된 예시는 위와 같습니다. 이번 글에서는 dataset preparation - fine-tuning - evaluation 순서로 작업을 진행합니다.

    각 작업이 올바르게 정의되었다면 'Run'을 클릭하여 파이프라인을 실행합니다.

    FastTrack 화면 좌측에서 생성했던 파이프라인들을 확인할 수 있습니다. 클릭하면 파이프라인 작업 세션에서 현재 실행 중인 작업 및 실행했던 작업들을 모니터링 할 수 있습니다.

    Monitoring jobs

    위와 같은 화면을 통해 작업을 모니터링 할 수 있습니다. 각 작업은 지정된 순서대로 진행되며, 이전 작업이 완료되면 다음 작업을 위한 세션을 시작하기 위해 리소스가 할당되고, 작업이 완료되면 세션이 종료됩니다. 필요한 경우 작업을 건너뛸 수 있는 옵션도 있습니다. 일례로, 위의 이미지에서는 dataset preparation 작업을 건너뛰고 fine-tuning 작업이 실행되고 있는 것을 볼 수 있습니다. 건너뛴 작업은 분홍색, 실행되고 있는 작업은 하늘색, 실행 예정인 작업은 노란색으로 나타납니다.

    Log checking

    빨간 네모로 강조되어 있는, 각 작업의 이름 옆의 파란색 버튼을 클릭하면 각 작업의 로그를 확인할 수 있습니다. 이를 통해 트레이닝 진행 상황을 직접 모니터링할 수 있습니다. 로그는 터미널과 동일한 결과로, 위의 화면과 같이 나타납니다. 학습이 잘 이뤄지고 있는 것을 확인할 수 있죠. 파이프라인 실행이 성공적으로 완료되면 결과를 확인할 수 있습니다. 본 문서에서는 평가 결과를 플로팅하여 /home/work/XaaS/train/QA_finetune/truthfulness_result.png 로 저장하도록 구성했습니다. (Backend.AI의 V-folder는 /home/work/~ 가 기본 디렉토리 구조입니다.)

    학습을 마친 뒤, 해당 경로에 결과 이미지가 생성된 모습입니다.

    Result checking

    위와 같이 파이프라인이 성공적으로 실행된 모습을 작업 이름의 왼쪽에서 확인할 수 있습니다.

    Result

    이제 모델을 Fine-tuning 한 결과를 gemma-2-2b-it와 비교하여 확인해보겠습니다.

    1. SemScore (목표 응답과 모델 응답 간의 의미론적 텍스트 유사성, 1.00 is the best)

    | Base Model | Trained Model | |------------|---------------| | 0.62 | 0.77 |

    학습된 모델의 SemScore가 증가했습니다(0.62 -> 0.77). 이 결과는 학습된 모델이 목표 응답과 의미적으로 더 유사한 출력을 생성할 수 있음을 나타냅니다. 즉, 학습된 모델이 의도한 목표 응답에 더 가깝고 의미적으로 더 일관된 응답을 생성하는 능력이 향상되었다는 것입니다. 결과적으로 학습된 모델의 전반적인 성능과 신뢰성이 크게 향상되었다고 할 수 있습니다.

    1. Truthfulness 학습된 모델은 고득점 사례는 증가하고 저득점 사례는 감소하는 경향을 보입니다. 낮은 점수(1, 2점) (1,111 -> 777), 높은 점수(4, 5점) (108 -> 376) 이는 모델이 진실에 가까운 도메인 정보를 식별하는 능력이 향상되고, 훈련이 효과적이었다는 것을 나타냅니다.

    Truthfulness result

    Conclusion

    이번 글에서는 Backend.AI의 MLOps 플랫폼인 FastTrack을 활용하여 특정 domain에 특화된 모델을 학습하는 Pipeline을 구축해보았습니다. FastTrack의 모든 기능을 활용하지 않고 일부 기능만을 사용했음에도 자원을 유연하게 활용하고, Task 설정을 자유롭게 하여 학습 시간을 단축하고, 자원 활용율을 끌어올릴 수 있었습니다. 또한 독립적인 실행 환경에서 안정적으로 학습을 시킬 수 있었으며, Pipeline Job의 실행 정보를 모니터링할 수 있어 학습이 진행되는 동안 각 파이프라인의 자원 사용량 및 실행 횟수를 파악할 수 있었습니다. 이 글에서 다룬 내용 이외에도 FastTrack은 스케줄링, 병렬 모델 학습과 같은 추가적인 기능들을 다양하게 지원하고 있습니다. 아래 첨부된 래블업 블로그 게시글에서 각각 강지현님과 강정석님이 작성하신 FastTrack의 다른 기능들에 대한 더 많은 정보를 확인할 수 있습니다.

    FastTrack의 모든 기능을 활용하지는 못했지만, 자원의 유연한 활용, 자유로운 task 설정 등을 통해 학습 시간을 단축하고 자원의 utilization rate를 높일 수 있었습니다. 또한 독립적인 실행환경에서 안정적인 학습이 가능하고, Pipeline Job 실행 정보를 통해 각 파이프라인에서의 자원 사용량, 실행 횟수 등을 파악할 수 있었습니다. 이외에도 FastTrack에서는 스케줄링, 병렬 모델 학습 등 많은 기능을 지원합니다. 아래의 문서들에서 FastTrack에 대한 더 많은 정보를 확인할 수 있습니다.

    Backend.AI MLOps 플랫폼 FastTrack을 소개합니다.

    FastTrack 길라잡이: 모델 학습 결과 알림 받기

    26 September 2024

  • Model Variant: 손쉽게 대접하는 다양한 모델 서비스

    By 강지현

    들어가며

    어떠한 연구 목적으로 AI를 학습시켜 결과물을 만들어내야 하는 상황에 있다고 가정해봅시다. 우리가 해야 할 일은 AI에게 가르쳐 준 데이터를 AI가 올바르게 학습하길 기다리는 것뿐이죠. 하지만 AI를 '활용'하는 어떠한 서비스를 만든다고 가정하면 이야기가 복잡해집니다. 다양한 모델을 어떻게 시스템에 적용시킬 것인지, 부하 상황에서 어떤 기준에 의해 스케일링을 시켜야 할지 모든 요소 하나하나가 고민거리죠. 이런 고민에 대한 답을 얻기 위해 함부로 사용자가 존재하는 프로덕션 환경을 수정할 수도 없습니다. 프로덕션 환경을 늘렸다 줄였다 하다가 사고라도 난다면 끔찍한 일이 생길 수도 있거든요. 만약에 끔찍한 일이 벌어졌다면, 벌어진 일을 수습하기 위한 시간이 필요할 텐데, 우리 서비스를 사용하는 소비자에게는 모델 학습을 기다리는 연구자와 같은 참을성을 기대할 수 없을 겁니다. 엔지니어링 영역의 어려움 외에 비용에 대한 어려움도 있습니다. 모델을 서비스하는데에는 당연히 비용이 들고, 모델을 학습시키는 그 순간에도 자원을 소모하고 있는 만큼 사용자가 비용을 지출하고 있는 셈이니까요. 그러나 걱정하실 필요는 없습니다. 이미 세상에는 잘 만들어진 모델들이 많이 존재하고, 우리는 그러한 모델들을 가져다가 서비스하는 것으로 충분한 경우가 많거든요. 저희 솔루션에 관심이 있으셨던 분들이라면 다 아시는 내용이겠지만, Backend.AI는 이미 여러분이 모델을 서비스할 때 필요로 하는 기능들을 다양하게 지원하고 있습니다. 트래픽에 따라 서비스를 늘리는 것이나 줄이는 것도, 사용자의 입맛에 맞춘 다양한 모델을 서비스하는 것도 가능하죠.

    그러나 여기서 멈출 Backend.AI 팀이 아닙니다. 저희는 Backend.AI의 23.09 버전부터 제공된 모델 서비스를 한 층 강화하였고, 다양한 모델을 손쉽게 서비스할 수 있도록 개선하였습니다. 이번 포스팅을 통해 어떤 방법으로 쉽고 간편하게 다양한 모델을 서비스할 수 있는지 알아봅니다.

    이번 포스팅에서는 다양한 종류의 모델을 더욱 간편하게 서비스할 수 있는 기능을 소개합니다. 모델 서비스에 대한 설명은 23.09 버전 업데이트를 릴리즈하며 한 차례 드린 적이 있기 때문에, 자세한 설명은 생략하겠습니다. Backend.AI의 모델 서비스가 생소하시다면, 다음 포스팅을 먼저 읽어보시는 것을 추천합니다. Backend.AI Model Service 미리 보기

    기존 방식

    | | 필요조건 | 기존 방식 | 모델 배리언트(Model Variant) | |---|----------|------------|----------------------------------| | 1 | 모델 정의 파일(model-definitionl.yaml) 작성 | O | X | | 2 | 모델 정의 파일을 모델 폴더에 업로드 | O | X | | 3 | 모델 메타데이터 필요 | O | △* (일부는 자체 다운로드 가능) |

    Backend.AI 모델 서비스는 실행하기 위한 모델 메타데이터 외에 모델을 서비스할 때 실행할 명령어를 일정한 형식으로 담아둔 모델 정의 파일 (model-definition.yaml)을 필요로 했습니다. 서비스를 실행하는 순서는 다음과 같습니다. 모델 정의 파일을 작성하고, 모델 정의 파일을 읽을 수 있도록 모델(model) 타입 폴더에 업로드한 뒤, 모델서비스 시작시 모델 폴더를 마운트하면 자동으로 모델 정의 파일에 따라 엔드유저의 입력을 받아 모델로 전달하고, 응답 값을 보내주는 API 서버 등이 실행되는 형태였습니다. 하지만 이 방식은 모델 정의 파일을 수정할 때마다 파일에 접근해야한다는 단점이 있었습니다. 또, 이미 모델 정의 파일에 모델 경로가 정해져있기 때문에 모델이 달라질 때마다 모델 정의 파일을 다르게 작성해야 하는 것도 귀찮은 부분이었습니다. 이번에 선보이는 모델 배리언트(Model Variant)는 모델 정의 파일이 없이 모델 메타데이터만을 가지고 몇 가지 설정값을 입력하거나, 또는 아예 입력할 필요없이 즉시 모델을 서비스할 수 있는 기능입니다. 모델 배리언트에서는 커맨드(command), vLLM, 그리고 NIM(NVIDIA Inference Microservice) 방식을 지원합니다. 서비스하는 방법과 모델 서비스 실행을 확인하는 방법은 다음과 같습니다.

    이번에 선보이는 모델 배리언트(Model Variant)는 모델 정의 파일이 없이 모델 메타데이터만을 가지고 몇가지 설정값을 입력하거나, 또는 아예 입력할 필요없이 즉시 모델을 서비스 할 수 있는 기능입니다. 모델 배리언트에서는 커맨드(command) 방식, vLLM 방식, 그리고 NIM(NVIDIA Inference Microservice) 방식을 지원합니다. 서비스하는 방법과 모델 서비스 실행을 확인하는 방법은 다음과 같습니다.

    기본적으로, 모델 서비스는 서빙할 모델 메타데이터를 필요로 합니다. 가장 손쉽게 접할 수 있는 모델 메타데이터를 받을 수 있는 Hugging Face 에서 서비스할 모델을 다운로드 받아보세요. 이번 예제에서는 Hugging Face 의 Llama-2-7b-hf 모델과 Calm3-22b-chat 모델을 사용했습니다. 모델 메타데이터를 모델 폴더에 업로드 하는 방법은 앞의 포스팅의 모델 스토리지 준비를 참고하십시오.

    빌드된 이미지에서 자동으로 모델 서비스하기 (command 방식)

    첫 번째로 소개하는 커맨드 방식은 모델 정의 파일에서 모델을 서비스하기 위해 실행하는 명령어 부분이 실행 이미지에 들어간 형태입니다. CMD 라는 환경변수에 실행할 명령어를 지정한 뒤, 이미지를 빌드해 실제 모델을 서비스할 때 다른 입력 없이 바로 실행하는 방식이죠. 커맨드 방식은 서비스가 제대로 실행되고 있는지 확인하는, 이른바 Health check를 지원하지 않습니다. 따라서 대규모의 서비스를 수행할 때보다는 프로토타입으로 바로 서비스를 띄워서 확인해 볼 때 적절합니다. 실행방법은 다음과 같습니다.

    1. 시작화면에서 서비스할 모델 서비스에 해당하는 모델 메타데이터가 들어있는 모델 폴더를 마운트하도록 Model Storage To Mount 항목에서 Llama-2-7b-hf 를 선택하고, Inference Runtime Variant 항목에서 Predefined Image Command 를 선택합니다.

    모델 서비스를 별도의 토큰없이 접근할 수 있도록 제공할 경우 Open To Public 스위치 버튼을 활성화 해주세요.

    모델-서비스-시작화면-모델-메타데이터-마운트-및-CMD-선택

    1. 서비스할 환경을 선택합니다. 여기서는 vllm:0.5.0 를 사용하고, 자원은 CPU 4 Core, Memory 16 GiB, NVIDIA CUDA GPU 10 FGPU 를 할당하도록 설정했습니다.

    모델-서비스-시작화면-실행환경-선택-및-자원할당

    1. 마지막으로 클러스터 크기를 선택하고, 시작버튼을 클릭합니다. 클러스터 크기는 싱글노드, 싱글 컨테이너로 설정했습니다.

    모델-서비스-시작-화면-클러스터-크기-선택-및-시작

    서비스가 성공적으로 띄워졌다면, 서비스 상태는 HEALTHY 로 바뀌게 되고 엔드포인트 주소가 나오게 됩니다.

    모델-서비스-상세-화면

    서비스 확인하기

    서비스가 정상적으로 띄워졌다면, cURL 명령어로 서비스 모델명을 우선 확인합니다.

    curl https://cmd-model-service.asia03.app.backend.ai/v1/models \
    -H "Content-Type: application/json"
    

    모델명-확인하기

    이제 서비스에 보낼 입력을 cURL 명령어로 보내고, 응답값을 확인해보겠습니다.

    CMD로 실행하는 모델 서비스는 이미지에 이미 모델명이 정의되어 있기 때문에 모델명을 확인후 요청을 보낼 때 모델명을 model 키의 값으로 입력해야 합니다.

    curl https://cmd-model-service.asia03.app.backend.ai/v1/completions \
    -H "Content-Type: application/json" \
    -d '{
    "model": "image-model",
    "prompt": "San Francisco is a",
    "max_tokens": 7,
    "temperature": 0}'
    

    모델-서비스-요청-결과-화면

    vLLM 모드로 모델 서비스하기

    vLLM 모드는 앞에서 소개한 커맨드 방식과 비슷하지만, vLLM 을 실행할 때 입력하는 여러가지 옵션들을 환경변수로 작성할 수 있습니다. 실행방법은 다음과 같습니다.

    실행방법

    1. 시작화면에서 서비스할 모델 서비스에 모델 폴더를 마운트하고, Inference Runtime Variant 항목에서 vLLM 을 선택합니다.

    모델-서비스-시작-화면-모델-메타데이터-마운트-및-vLLM-선택

    1. 서비스할 환경을 선택합니다. 앞서 설명한 커맨드 방식과 동일하게 vllm:0.5.0 으로 선택하고, (자원은 동일하게 설정해도 되지만) 이번에는 CPU 16 Core, Memory 64 GiB, NVIDIA CUDA GPU 10 fGPU를 할당하도록 하겠습니다.

    모델-서비스-시작-화면-실행환경-선택-및-자원-할당

    1. 마지막으로 클러스터 크기를 선택하고 환경 변수 BACKEND_MODEL_NAME 을 입력합니다. 이 값은 vLLM에서 --model-name 옵션에 대응하는 값으로, 사용자가 서비스에 요청을 보낼 때 지정하는 model 값이 됩니다.

    모델-서비스-시작-화면-실행환경-선택-및-자원-할당

    마찬가지로 서비스가 성공적으로 띄워졌다면, 서비스 상태는 HEALTHY 로 바뀌게 되고, 서비스가 띄워진 엔드포인트 주소가 나오게 됩니다.

    모델-서비스-상세-화면

    서비스 확인하기

    서비스에 보낼 입력을 cURL 명령어로 보내고, 응답값을 확인해보겠습니다. 이 때 model 값은 아까 설정한 BACKEND_MODEL_NAME 값으로 입력합니다. 입력이 끝났다면 START 버튼을 클릭해서 서비스를 생성합니다.

    curl https://vllm-calm3-22b-chat.asia03.app.backend.ai/v1/completions \
    -H "Content-Type: application/json" \
    -d '{
    "model": "vllm-model",
    "prompt": "初めて会う日本人ビジネスマンに渡す最高の挨拶は何でしょうか?",
    "max_tokens":  200,
    "temperature": 0
    }'
    

    모델-서비스-요청-결과-화면

    NIM 모드로 모델 서비스하기

    NIM 을 실행하기 위해서는 NGC의 NIM 모델 레지스트리에 접근할 수 있는 계정으로부터 발행된 API 키가 있어야 합니다. 키값을 얻는 방법은 다음 내용을 참고하시기 바랍니다. NVIDIA Docs Hub : How to get NGC API Key

    NIM(NVIDIA Inference Microservice) 모드 역시 커맨드 모드와 유사하나, NVIDIA의 NIM을 지원하는 모델 서버가 내장된 이미지로 실행해야 합니다. 또, 모델을 불러올 때에, NGC API 키 값이 필요합니다. 모든 것이 준비되었다는 가정하에 모델 서비스를 시작해보겠습니다.

    실행방법

    1. 시작화면에서 서비스할 NIM 에서 받아올 메타데이터를 캐싱할 비어있는 모델 타입 폴더를 선택하고, Inference Runtime Variant 항목에서 NIM 을 선택합니다.

    모델-서비스-시작-화면-모델-폴더-마운트-및-NIM-선택

    1. 서비스할 환경을 선택합니다. 여기서는 ngc-nim:1.0.0-llama3.8b 를 사용하고, 자원은 CPU 8 Core, Memory 32 GiB, NVIDIA CUDA GPU 15 FGPU 를 할당하도록 설정했습니다.

    모델-서비스-시작-화면-실행환경-선택-및-자원-할당

    1. 마지막으로 클러스터 크기를 선택하고 환경 변수 HF_HOME으로 기본 경로인 /models 경로를 입력합니다. 그리고 NGC_API_KEY 을 입력하고, 발급받은 키값을 입력합니다. 입력이 끝났다면 CREATE 버튼을 클릭해서 서비스를 생성합니다.

    모델-서비스-시작-화면-클러스터-크기-선택-환경변수-입력-및-시작

    NIM 을 사용할 경우 모델 메타데이터를 저장소로부터 받아오기 때문에 처음 실행시에는 다소 시간이 소요될 수 있습니다. 세션 페이지에서 서비스중인 라우팅 세션에 대한 컨테이너 로그를 확인하여 진행상황을 확인할 수 있습니다. 모델-서비스에-대응하는-라우팅-세션 NIM-에서-데이터를-받고-있는-로그가-띄워진-컨테이너-로그-화면

    커맨드, vLLM 모드와 같이 서비스가 성공적으로 띄워졌다면, 서비스 상태는 HEALTHY 로 바뀌게 됩니다. 서비스가 띄워진 엔드포인트 주소를 활용해 서비스에 보낼 내용을 다음과 같이 입력하고, 응답값을 확인해보겠습니다.

    서비스 확인하기

    from openai import OpenAI
    
    client = OpenAI(
      base_url = "https://nim-model-service.asia03.app.backend.ai/v1",
      api_key = "$YOUR_NGC_API_KEY"
    )
    
    completion = client.chat.completions.create(
      model="meta/llama3-8b-instruct",
      messages=[
          {        
            "role":"user", 
            "content":"Hello! How are you?"
          },
          {
            "role":"assistant",
            "content":"Hi! I am quite well, how can I help you today?"
          },
          {
            "role":"user",
            "content":"Can you write me a song?"
          }],
      temperature=0.5,
      top_p=1,
      max_tokens=1024,
      stream=True
    )
    
    for chunk in completion:
      if chunk.choices[0].delta.content is not None:
        print(chunk.choices[0].delta.content, end="")
    

    모델-서비스-요청-결과-화면

    마치며

    모델 배리언트 기능은 이미 학습된 모델로 실질적인 서비스를 제공하는 것을 목표로 하는 연구자와 기업에 많은 도움이 될 것입니다. 강력한 자원 관리 시스템과 NVIDIA GPU, AMD ROCm, TPU, Graphcore IPU, Furiosa Warboy, Rebellions ATOM, Hyperaccel LPU 등과 같이 다양한 AI 가속기 지원을 바탕으로 한 Backend.AI 는 이제 단순히 모델을 학습하는 것을 뛰어넘어 서비스까지 쉽게 배포할 수 있는 통합 환경을 제공하게 되었습니다. Backend.AI 와 함께 여러분이 원하는 AI 모델을 언제든 서비스해보세요.

    11 July 2024

  • Backend.AI 오픈소스 기여 가이드 (2024년 7월)

    By 성대현

    Backend.AI의 코어 엔진은 많은 오픈소스 소프트웨어를 활용함과 동시에 그 자체도 오픈소스로 개발되고 있습니다. 오픈소스로 개발되는 만큼, 버그를 찾았거나 불편함을 느낀 사용자가 있다면 개인이 직접 Backend.AI 프로젝트에 기여하는 것 또한 가능하죠. (물론, Backend.AI를 이용하시는 엔터프라이즈 고객분들께는 저희의 고객 및 기술 지원 채널을 통해 이슈가 생길 경우 지원을 해드리고 있답니다.)

    기여를 하기 위한 방법에는 두 가지가 있는데요, 첫번째 방법은 어떤 문제가 있는지, 어떤 개선 아이디어가 있는지 상세하게 개발팀에게 설명을 남기는 'issue'이고, 두번째 방법은 직접 코드를 수정하여 기여할 수 있는 'pull request' 입니다.

    Backend.AI 오픈소스 기여 가이드 글을 통해 래블업의 개발팀과 더욱 효과적이고, 빠른 의사소통을 위해 알아두면 좋은 내용을 소개합니다.

    GitHub 저장소 소개

    이전의 글 Backend.AI 오픈소스 기여 가이드에서 보듯 Backend.AI은 원래 Backend.AI meta-repository와 여러 하위 컴포넌트들로 저장소를 구분하여 개발되었습니다.

    그러나, Backend.AI의 "22.06"버전부터는 Pants를 이용한 mono-repository 방식으로 변경되었습니다.

    이와 같은 개발 워크플로의 전환으로 다수의 개별 컴포넌트에서 종종 발생하는 패키지 호환성 문제를 해결해 더욱 편리한 개발 환경을 구성하는 데에 많은 도움이 되었습니다.

    Pants는 빠르고, 확장성이 있으며, 사용자 친화적인 빌드 시스템입니다.

    우선, 이슈를 올리고 싶다면 가장 먼저 살펴보실 곳은 Backend.AI repository입니다. Backend.AI라는 프로젝트 이름을 가진 저장소는 Pants를 이용하여 통해 여러 패키지를 통합 설치하고 있습니다. 이 저장소는 프로젝트 관리뿐만 아니라 실제로 어떤 기능을 하는 코드가 들어가는 저장소입니다. Backend.AI의 서버 및 Client SDK 관련 이슈들은 모두 여기서 관리되고 있으며, README를 통해 다른 프로젝트로의 링크를 제공합니다.

    이슈를 새로 생성할 때 기본 템플릿으로는 bug report와 feature request 2가지 양식을 제공하고 있으나, 이 양식을 꼭 엄격하게 따라야만 하는 것은 아닙니다. 다만 Backend.AI의 복잡도나 다양한 사용 환경을 고려하였을 때 해당 양식에 맞춰서 내용을 작성해주시면 문제 파악을 위한 맥락 공유가 조금 더 쉬워진다는 점을 고려해주십시오.

    Mono-repository에 대한 소개

    Backend.AI는 버전 "22.06"부터 Backend.AI는 Pants를 이용한 mono-repository로 변경하였습니다. Mono-repository는 여러 프로젝트의 기본 종속성, 데이터 모델, 기능, 툴링 및 프로세스를 공유하는 소스코드를 가지고 통합한 코드 베이스의 프로젝트입니다. 이전에 사용하던 여러 프로젝트를 하나의 프로젝트로 통합하여 저장소를 운영하고 있습니다.

    Pants 소개

    Backend.AI는 Pants를 이용한 빌드시스템으로 설치합니다. Pants에 대한 자세한 내용은 다음의 링크 Pants - Getting started를 확인하시기 바랍니다.

    Backend.AI의 컴포넌트 관계

    그림 1. Backend.AI 주요 컴포넌트 사이의 관계 구조

    그림 1은 Backend.AI의 주요 컴포넌트 관계를 나타낸 다이어그램입니다.

    그림 2. Backend.AI의 주요 컴포넌트 구조도 및 실행 방법의 예

    그림 2는 Backend.AI의 주요 컴포넌트 구조를 나타낸 다이어그램이며, 컴포넌트의 소스코드 위치 및 실행 명령등을 보여주고 있습니다.

    Backend.AI의 대다수 컴포넌트는 Backend.AI repository에서 관리되며, 소스코드는 src/ai/backend/ 하위 디렉토리에 위치하여 있습니다. 간략하게, 컴포넌트별로 하는 일에 대해 디렉토리별로 요약하면 다음과 같습니다:

    • src/ai/backend/manager (Manager): 전체 클러스터의 연산자원 모니터링 및 세션 스케줄링을 담당하고 사용자 인증 및 세션 실행 등의 API를 제공하는 핵심 서비스
    • src/ai/backend/agent (Agent): 연산노드에 설치되어 컨테이너들을 관리 및 제어하는 서비스
    • src/ai/backend/common (Common): 여러 서버 측 컴포넌트에서 공통으로 또는 자주 사용되는 기능 및 데이터 형식을 모아놓은 라이브러리
    • src/ai/backend/client (Client SDK for Python): 공식 명령 줄 인터페이스(CLI)이자 Python을 위한 API wrapper 함수·클래스들을 제공하는 라이브러리
    • src/ai/backend/storage (Storage Proxy): 사용자 웹 브라우저 또는 Client SDK가 네트워크 스토리지로부터의 대용량 입출력을 바로 할 수 있도록 해주는 서비스
    • src/ai/backend/web (Web Server): Web UI와 SPA (single-page app) 구현을 위한 라우팅을 제공하고 웹 세션 기반 사용자 인증을 제공하는 HTTP 서비스
    • src/ai/backend/webui (Web UI & Desktop App): 실제 사용자가 접하는 UI의 웹 컴포넌트 기반 구현체. Electron 기반 데스크톱 앱 빌드도 지원. 또한, 사용자가 컨테이너 내부에서 실행 중인 애플리케이션 포트로 바로 접속할 수 있도록 해주는 app proxy의 로컬 경량화 버전도 포함.

    Backend.AI의 버전 관리 방법

    Backend.AI는 6개월(매년 3월과 9월)마다 주요 릴리즈가 이뤄지며, 릴리즈 후 사후 지원을 약 1년간 제공합니다. 따라서 버전 번호는 YY.0M.micro 방식의 CalVer 형식을 따르고 있습니다 (예: 20.09.14, 21.03.8). 다만 Python 패키징 시스템의 버전 번호 정규화 때문에 wheel 패키지의 버전은 월 부분에 zero-padding이 없는 YY.MM.micro 형식입니다 (예: 20.9.14, 21.3.8). 버전 업데이트 주기가 본체 릴리즈 주기와 다른 세부 컴포넌트들은 일반 SemVer 형식을 따르고 있는 예도 있습니다.

    개발 전 우선 설치해야 하는 필수 패키지

    Backend.AI를 설치하기 전에 먼저 Docker, Docker Compose v2 등을 설치해야 합니다. Backend.AI는 repository의 scripts/install-dev.sh 스크립트로 설치 시 Docker, Docker Compose v2 등의 설치 여부를 검사하여 설치 방법을 안내합니다. 만약, Python, pyenv, Docker, npm이 설치되지 않았으면 아래와 같이 필수 패키지 설치를 해야합니다. Python의 경우는 시스템 패키지의 Python3로 설치하시기 바랍니다. 이후, pyenvpyenv-virtualenv를 설치해야 합니다.

    $ curl https://pyenv.run | bash
    

    이후 Docker와 Docker Compose v2를 다음과 같이 설치하면 됩니다.

    MacOS

    MacOS의 경우는 Docker Desktop on Mac으로 설치하면 Docker와 Docker Compose v2가 자동으로 설치됩니다.

    Ubuntu, Debian, CentOS, Fedora Core등 Linux 환경

    Ubuntu, Debian, CentOS, Fedora Core의 경우 다음의 스크립트를 이용하면 Docker와 Docker Compose v2가 자동으로 설치됩니다.

    $ sudo curl -fsSL https://get.docker.io | bash
    

    Docker 설치한 후, 만약 sudo 없이 실행하였을때 다음과 같이 unix:///var/run/docker.sock 접근 권한 오류가 생기는 이슈가 있습니다.

    $ docker ps
    Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/json": dial unix /var/run/docker.sock: connect: permission denied
    

    위와 같은 권한 문제가 존재하는 경우 아래와 같이 명령어를 이용하여 권한을 설정합니다.

    $ sudo usermod -aG docker $(whoami)
    $ sudo chown root:docker /var/run/docker.sock
    

    이후, 재부팅을 한 후, docker run hello-world 를 실행하여, 정상 실행되는걸 확인하면 됩니다.

    $ docker run hello-world
    Unable to find image 'hello-world:latest' locally
    latest: Pulling from library/hello-world
    c1ec31eb5944: Pull complete
    Digest: sha256:94323f3e5e09a8b9515d74337010375a456c909543e1ff1538f5116d38ab3989
    Status: Downloaded newer image for hello-world:latest
    
    Hello from Docker!
    This message shows that your installation appears to be working correctly.
    
    To generate this message, Docker took the following steps:
    1. The Docker client contacted the Docker daemon.
    2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
        (amd64)
    3. The Docker daemon created a new container from that image which runs the
        executable that produces the output you are currently reading.
    4. The Docker daemon streamed that output to the Docker client, which sent it
        to your terminal.
    
    To try something more ambitious, you can run an Ubuntu container with:
    $ docker run -it ubuntu bash
    
    Share images, automate workflows, and more with a free Docker ID:
    https://hub.docker.com/
    
    For more examples and ideas, visit:
    https://docs.docker.com/get-started/
    

    chown 으로 /var/run/docker.sock의 group ownership 변경이 아닌 /var/run/docker.sock 파일의 권한을 666으로 변경하여 그룹내 다른 사용자도 접근 가능하게 변경하면 재부팅을 하지 않아도 됩니다.

    sudo chmod 666 /var/run/docker.sock
    

    그러나, /var/run/docker.sock 파일의 권한을 666으로 설정하면, 보안 취약점이 생깁니다.

    Docker Compose v2 설치 여부는 다음과 같이 확인해봅니다.

    $ sudo docker compose version
    Docker Compose version v2.28.1
    

    만약, nvm이 설치되어있지 않다면 nvm을 다음의 링크 nvm - install & Update Script에 나온 것처럼 설치해야 합니다.

    $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
    

    nvm 설치 이후에는 최신 LTS 버전의 Node.js을 설치 하고 사용 설정을 하면 됩니다.

    $ nvm install --lts
    $ nvm use --lts
    

    개발 환경 설치 방법

    실제로 코드를 통해 기여하기 위해서는 pull request를 작성해야 하는데, 단순한 오타 수정 혹은 문서 기여가 아니라면 코드를 수정하며 직접 결과물을 돌려서 확인해봐야 하기 때문에 개발 환경을 구축하는 절차가 꼭 필요합니다. Backend.AI는 여러 개의 컴포넌트가 함께 맞물려 돌아가는 구조로, 하나의 저장소를 clone하고 Python 가상환경을 만들어 editable install[1]을 해주는 것만으로는 설치가 끝나지 않습니다. 최소한 manager, agent, storage-proxy, webserver, wsproxy를 모두 설정 및 실행해야만 동작하는 GUI를 확인할 수 있으며 CLI 환경을 위해서는 여기에 client SDK도 별도로 설치해야 합니다. 또한 manager 구동 및 agent와의 통신을 위한 Redis, PostgreSQL, etcd 서버도 함께 실행해야 합니다.

    앞서 소개한 필수 패키지를 설치했고, Backend.AI의 여러 컴포넌트를 설치하려면 repository의 scripts/install-dev.sh 스크립트로 설치하면 됩니다. 이 스크립트가 하는 일은 다음과 같습니다:

    • pyenv, Python, Docker, npm 등의 설치 여부를 검사하여 설치 방법을 안내
    • 위와 같은 다양한 컴포넌트들을 모두 각자의 디렉토리에 설치
      • 이때 accelerator-cuda와 같이 다른 컴포넌트의 동작에 필요한 컴포넌트들은 editable 상태로 추가 설치됩니다.
    • 각 컴포넌트가 서로 바라볼 수 있는 기본 포트 설정 및 예제 인증키 등을 포함한 database/etcd fixture 추가
    • PostgreSQL, Redis, etcd 서비스를 "halfstack"이라는 이름으로 Docker Compose를 이용해 생성 및 실행

    install-dev 스크립트 실행이 성공적으로 완료되면, manager, agent 등의 서비스 데몬을 실행하기 위한 명령어 및 기본 설정된 예제 계정 정보를 출력합니다. 설명을 따라 tmux, screen 등의 터미널 멀티플렉서 또는 터미널 앱의 다중 탭 기능 등을 활용하여 각각 독립된 shell에서 서비스 데몬들을 실행하고, hello world 예제까지 동작하는 것을 확인하면 Backend.AI를 개발 및 테스트할 수 있는 준비가 된 것입니다.

    현재 이 방법은 Intel (amd64/x86_64) 및 ARM 기반 macOS 및 Ubuntu/Debian/CentOS/Fedora 및 Docker Compose가 설치되는 배포판의 Linux 환경만 지원합니다.

    보통 처음 이 install-dev 스크립트를 이용하면 도중에 다양한 오류나 사전 검사 실패로 인해 중단하고 다시 실행해야 하는 경우가 자주 발생합니다. 이때는 scripts/delete-dev.sh 스크립트를 활용하면 삭제 절차를 간편하게 수행할 수 있습니다.

    Backend.AI 설치 및 삭제하기

    이 install-dev 및 delete-dev 스크립트를 활용하면, Backend.AI를 자유롭게 설치하고, 삭제할 수 있습니다. 먼저 Backend.AI 저장소를 복제합니다.

    $ git clone https://github.com/lablup/backend.ai 
    

    위의 Backend.AI를 설치합니다.

    $ cd backend.ai
    $ ./scripts/install-dev.sh 
    

    설치가 완료되면, 화면에 나오는 결과 내용을 숙지하시기 바랍니다.

    만약, Backend.AI를 삭제하려면 Backend.AI 저장소를 복제한 위치에서 scripts/delete-dev.sh 스크립트를 실행하면 됩니다.

    $ cd backend.ai
    $ ./scripts/delete-dev.sh 
    

    컨트리뷰션 전에 알아야 할 사항

    대부분의 분산 버전 관리 시스템에서 관리되는 프로젝트와 마찬가지로, Backend.AI 에 기여하기 위해서는 원본 원격 저장소의 main 브랜치의 가장 최신 커밋 기준으로 코드 작업이 이뤄져야 하며, 충돌이 발생하는 경우에는 리뷰를 요청하기 전에 해결되어야 합니다. 원본 저장소를 fork 한 경우, 현재 본인이 fork 한 원본 저장소와 실제 원본 저장소가 동기화되어야 합니다.

    방법 안내 전 이해를 돕기 위해 아래 정리한 명칭을 참고해주세요.

    • 원본 원격 저장소(upstream): Backend.AI 원본 저장소. 모든 주요 커밋 내용이 반영됨.
    • fork 한 원본 저장소(origin): GitHub을 통해 "내" 계정으로 복사해온 Backend.AI 저장소. (주의: 원본 원격 저장소 != fork 한 원본 저장소)
    • 코드 복사본(local working copy): 현재 본인의 로컬 머신에 내려 받은 fork 된 저장소

    Git 명령의 브랜치 표기

    • main: 현재 local working copy의 main 브랜치
    • origin/main: 내가 local working copy를 만들기 위해 clone을 수행해온 저장소(origin)의 main 브랜치
    • upstream/main: 별도로 추가한 upstream 원격 저장소에 속한 main 브랜치

    작업 흐름 개념

    • fork 하는 시점에 origin/main 이 만들어짐
    • fork 한 저장소를 clone하면 내 작업 컴퓨터에 main 이 만들어짐
    • main 으로부터 새로운 topic branch를 만들어 작업 진행
    • 이 작업 branch를 origin에 올리고 PR을 생성하면 GitHub이 알아서 fork 의 원본 저장소를 가리키도록 해줌
    • 이때, 원본 저장소의 main 이 변경된 것을 작업 도중 동기화해오려면 아래의 절차를 따름

    동기화 하는 방법은 다음과 같습니다.

    • step1: upstream 이라는 이름으로 원본 원격 저장소 추가하기
    $ git remote add upstream https://github.com/lablup/backend.ai
    
    • step2: 원본 원격저장소의 main 브랜치의 최신 커밋을 코드 복사본(local working copy)으로 가져오기
    $ git fetch upstream
    
    • step3: 원본 원격저장소의 main 브랜치 최신 커밋 반영 내역을 origin(본인이 fork 한 원본 저장소의 코드 복사본(local working copy))로 가져오기
    $ git switch main && git merge --ff upstream/main
    
    • step4: step 1 ~ 3에서 진행된 코드 복사본(local working copy)의 변경 내역을 origin(본인이 fork 한 원본 저장소의 원격 저장소)에 반영하기
    $ git push origin main
    

    이제 upstream/mainorigin/mainmain을 거쳐 동기화된 것입니다.

    • step5: 작업 중인 내 브랜치에 최신 업데이트 반영하기
    $ git switch topic
    $ git merge main
    

    이 과정을 수행할 때 origin/mainupstream/main 간에 history 분기가 생긴 상태에서 5번 절차를 잘못 수행하면 굉장히 복구하기 까다로워질 수 있습니다. 또한, Backend.AI에서 사용하는 CI 도구들이 PR을 테스트할 때 upstream/mainorigin/topic 사이의 차이점을 보기 위해 공통 조상 커밋을 찾게 되어 있는데 topic 브랜치를 main 이름을 재활용하는 경우 그러한 도구들이 제대로 동작하지 않게 됩니다. 가능하면 새로운 분기를 만들 때는 항상 새로운 이름을 붙여준다고 생각하면 됩니다.

    Pull Request 작성 요령

    실제 특정 버그 패치나 기능 구현 사항을 PR로 보내려면 먼저 이를 GitHub에 올려야 합니다. 여러 방법이 있지만, 다음과 같은 방법을 권장합니다:

    • GitHub의 저장소 페이지에서 fork 를 뜹니다. (직접 커밋 권한이 있는 경우라면 fork 없이 바로 브랜치를 만드는 것을 권장합니다.)
    • 코드 복사본(local working copy)에서 git remote로 해당 fork 저장소를 가리키게 합니다.
      • 이때, 관례를 따라 래블업의 원본 저장소를 upstream으로, fork해서 새로 만든 저장소를 origin이라고 이름을 붙이면 좋습니다.
      • fork 후 처음 clone하는 경우가 아니라 install-dev로 설치를 먼저 했던 경우라면 원본 저장소가 origin일 것이므로 remote 이름 변경 작업을 해줘야 합니다.
    • 새 브랜치를 만듭니다.
      • 브랜치 이름은 버그 수정인 경우 fix/를, 기능 추가나 개선인 경우 feature/를 앞에 붙여 kebab-case 방식으로 주제를 요약하여 짓습니다. (예: feature/additional-cluster-env-vars, fix/memory-leak-in-stats) 그 외에 docs/, refactor/ 같은 prefix를 사용하기도 합니다.
      • main 브랜치에 직접 수정하여 PR을 작성하는 것도 가능하지만, PR 리뷰 및 수정 기간 동안 main 브랜치에 추가 변경사항이 생기는 경우 upstream 저장소와 동기화할 때마다 매번 rebase 또는 merge해줘야 하기 때문에 더 귀찮습니다. 별도의 브랜치를 따두면 내가 원할 때 rebase 및 merge 를 할 수 있습니다.
    • 변경사항을 해당 브랜치로 커밋합니다.
      • 커밋 메시지는 가급적 conventional commit 스타일을 따릅니다. 브랜치 이름과 마찬가지로 fix:, feat:, refactor:, docs:, release:와 같은 제목 접두어들을 사용하며, Backend.AI 한정으로 의존성 관련 커밋에는 setup:, gitignore 업데이트나 저장소 디렉토리 구조 변경과 같은 경우에는 repo: 같은 추가 접두어를 사용하기도 합니다. 괄호를 묶어 영향받는 컴포넌트를 표기하기도 합니다. (예: fix(scripts/install-dev): Update for v21.03 release)
      • 커밋 메시지는 영어로 작성해야 합니다.
    • 브랜치를 push하고 PR을 작성합니다.
      • 별도 이슈가 있는 PR의 경우 PR 본문에 해당 이슈 번호를 적어주어야 합니다. 만약, 저장소의 이슈를 참조하려면, 다음의 이슈 링크의 https://github.com/lablup/backend.ai/issues/401 숫자를 보고, #401과 같은 형식으로 적으면 GitHub이 자동 링크를 걸어줍니다.
      • PR 본문에 특정한 형식을 요구하지는 않지만, 어떤 문제를 해결하기 위한 것인지, 어떤 원리로 작성하였는지 혹은 어떤 도구나 라이브러리를 활용하였는지, 그러한 선택을 한 이유는 무엇인지 등을 적어주면 좋습니다.
      • PR 제목 및 본문은 영어 또는 한국어로 작성 가능합니다.
      • PR을 생성하면 다양한 자동화된 검사 도구가 동작하는 것을 볼 수 있습니다. 특히, CLA (contributor license agreement)는 반드시 서명(GitHub 사용자이름 등록)해주셔야만 리뷰가 진행됩니다.
      • 각 언어별 기본 코딩스타일·코딩 규칙 검사를 모두 통과해야 합니다. (Python 코드의 경우 flake8, mypy 등)
      • changes 디렉토리가 존재하고 towncrier 검사가 있는 저장소에서는, PR을 생성하여 그 번호를 받으면 changes/<PR번호>.<수정유형> 이름의 파일을 생성하여 Markdown 문법으로 변경사항 내용 요약을 한 줄의 영어 문장으로 작성합니다. (비교적 간단한 내용이거나 기존 이슈가 따로 있는 경우에는 이 내용이 PR 본문 역할을 대신하기도 합니다.) 수정 유형은 fix, feature, breaking, misc, deprecation, doc이 있으며 프로젝트별로 다른 부분은 각 저장소의 pyproject.toml에 정의됩니다. 기존 메시지들을 어떻게 적었는지는 CHANGELOG.md 또는 CHANGES.md와 같은 파일을 참고하면 됩니다.
    • 리뷰 과정을 진행합니다.
      • 완료되면 보통 squash-merge 형태로 리뷰어가 커밋 로그를 정리하여 하나의 단일 커밋으로 만들어 병합하게 됩니다.
      • 따라서 리뷰 과정에서 자잘한 수정 커밋을 자주 만드는 것에 부담을 가지지 않고 자유롭게 생각날 때마다 커밋을 만들어주시면 됩니다.

    GitHub CLI, SourceTree, GitKraken과 같은 도구들을 git 명령어와 함께 활용하면 더욱 좋습니다.

    정리

    지금까지 Backend.AI의 전체적인 컴포넌트 구조와 저장소 구조, 개발환경 설치 방법, 그리고 pull request 작성 요령을 살펴보았습니다. 이 가이드가 Backend.AI 소스코드에 한 발짝 더 다가갈 수 있도록 도움이 되었으면 좋겠습니다.


    [1]: "editable" 설치란 Python 패키지를 소스 디렉토리를 직접 바라보도록 설치하여 site-packages 디렉토리 내부를 편집하지 않고 소스 디렉토리를 수정하는 것만으로도 해당 패키지를 import 시 변경 내용이 바로 반영되어 있도록 하는 설치 방법을 말합니다.

    10 July 2024

  • FastTrack 길라잡이: 모델 학습 결과 알림 받기

    By 강정석

    이제는 클래식이 되어버린 AlexNet부터 오늘날 뜨거운 관심을 받고 있는 여러 거대 언어 모델(이하 LLM)들까지, 우리는 필요에 맞게 다양한 모델을 학습하고 평가합니다. 그러나 현실적으로 모델을 여러 번 실행해 보고 경험이 쌓이기 전까지 우리는 학습이 언제 종료될지 가늠하기 어렵습니다.

    Backend.AI의 뛰어난 스케줄링은 GPU의 유휴 시간을 최소화하고 우리가 잠든 사이에도 모델 학습이 실행될 수 있도록 하였습니다. 그렇다면 더 나아가서, 우리가 잠든 사이에 학습이 완료된 모델의 결과를 전달받을 수 있다면 어떨까요? 이번 글에서는 FastTrack의 신기능과 Slack을 활용하여 모델 학습 결과를 메시지로 수신하는 방법을 다뤄보도록 하겠습니다.

    이 글은 Backend.AI FastTrack 24.03.3 버전을 기준으로 작성되었습니다.

    들어가기에 앞서

    본문은 Slack App 및 Bot을 생성하는 방법을 다루지 않습니다. 자세한 내용은 공식 문서를 참고하는 것을 권장합니다.

    파이프라인 생성하기

    모델 학습에 사용할 파이프라인(Pipeline)을 만들어 보도록 하겠습니다. 파이프라인은 FastTrack에서 사용하는 작업 단위입니다. 각 파이프라인은 최소 실행 단위인 태스크(Task)의 묶음으로 표현될 수 있습니다. 하나의 파이프라인에 포함되는 여러 개의 태스크는 서로 의존 관계를 가질 수 있으며, 이 의존성에 따라 순차적으로 실행됨이 보장됩니다. 각 태스크마다 자원 할당량을 설정할 수 있어 전체 자원을 유연하게 관리할 수 있습니다.

    파이프라인에 실행 명령이 전달되면 해당 시점의 상태를 그대로 복제하여 실행되는데, 이러한 단위를 파이프라인 잡(Pipeline Job)이라고 합니다. 하나의 파이프라인에서 여러 개의 파이프라인 잡이 실행될 수 있으며, 하나의 파이프라인 잡은 하나의 파이프라인으로부터 생성됩니다.

    파이프라인 생성 버튼

    파이프라인 목록 상단에 위치한 파이프라인 생성 버튼("+")을 클릭합니다.

    파이프라인 생성하기

    파이프라인의 이름과 설명, 사용할 데이터 저장소의 위치, 그리고 파이프라인에서 공통적으로 적용할 환경변수와 파이프라인 초기화 방법 등을 선택할 수 있습니다. slack-pipeline-0이라는 이름을 입력한 후 하단의 "Create" 버튼을 클릭하여 파이프라인을 생성합니다.

    태스크 생성하기

    태스크 끌어오기

    새 파이프라인이 생성된 것을 볼 수 있습니다. 이제 태스크를 추가해 보도록 하겠습니다. 상단의 태스크 템플릿 목록(Task templates)에 있는 "Custom Task" 블럭을 마우스로 끌어와서 하단 작업 공간에 놓습니다.

    태스크가 수행할 동작 입력하기

    우측에 태스크의 세부사항을 입력할 수 있는 작업창이 나타납니다. model-training-task라는 이름을 주어 태스크의 역할을 나타낼 수 있으며, 모델 학습을 진행하기 위하여 pytorch:1.11-py38-cuda11.3 이미지를 사용하도록 설정합니다. 실제 모델 학습은 오랜 시간을 소요하므로 이번 예시에서는 아래와 같이 간단한 명령을 수행하도록 합니다.

    # 3초 동안 동작을 중지시킴으로써 실행 시간이 증가합니다.
    sleep 3
    # 파이프라인 전용 폴더에 `result.txt` 파일을 생성합니다. 학습이 완료된 모델의 정확도라고 가정합니다.
    echo "0.$RANDOM" > /pipeline/outputs/result.txt
    

    태스크 생성하기 (1)

    마지막으로 태스크에 할당할 자원량을 입력한 후 하단의 "Save" 버튼을 클릭하여 태스크를 생성합니다.

    또다른 태스크 끌어오기

    모델 학습 태스크가 작업 공간에 생성된 것을 확인할 수 있습니다. 이번에는 앞에서 저장한 result.txt 파일로부터 수치를 읽어와 Slack으로 알림을 보내는 태스크를 만들기 위하여 다시 "Custom Task" 블럭을 하단 작업 공간에 가져옵니다.

    태스크 단위 환경변수 `SLACK_TOKEN` 입력하기

    이번 태스크는 slack-alarm-task라고 이름을 설정하고, 아래와 같은 스크립트를 입력하여 Slack에 알림을 보내는 동작을 수행하도록 합니다.

    pip install slack-sdk
    python -c '
    import os
    from pathlib import Path
    from slack_sdk import WebClient
    SLACK_BOT_TOKEN = os.environ.get("SLACK_TOKEN")
    JOB_ID = os.environ.get("BACKENDAI_PIPELINE_JOB_ID")
    def main():
        result = Path("/pipeline/input1/result.txt").read_text()
        client = WebClient(token=SLACK_BOT_TOKEN)
        client.chat_postMessage(
            channel="#notification",
            text="Pipeline job({}) finished with accuracy {}".format(JOB_ID, result),
        )
    if __name__ == "__main__":
        main()
    '
    

    위 코드는 SLACK_TOKEN, BACKENDAI_PIPELINE_JOB_ID라는 이름의 두 환경변수를 활용하고 있습니다. BACKENDAI_* 형태의 환경변수는 Backend.AI 및 FastTrack 시스템에서 자동으로 추가하는 값들로, 그중 BACKENDAI_PIPELINE_JOB_ID는 각 태스크가 실행되고 있는 파이프라인 잡의 고유 식별자를 나타냅니다.

    또 하나의 환경변수인 SLACK_TOKEN는 태스크 단위 환경 변수로 추가된 값으로, 이 기능을 활용하면 코드 변경 없이 다양한 값을 관리 및 변경할 수 있습니다.

    태스크 생성하기 (2)

    slack-alarm-task 태스크에도 알맞은 자원을 할당해 준 후 하단의 "Save" 버튼을 클릭하여 태스크를 생성합니다.

    태스크 의존성 추가하기

    태스크 의존성 추가하기

    이제 작업 공간에는 두 개의 태스크(model-training-taskslack-alarm-task)가 존재합니다. 이때 slack-alarm-taskmodel-training-task이 종료된 후 실행되어야 하므로 두 태스크 간 의존성을 추가해야 합니다. 먼저 실행되어야 할 태스크(model-training-task)의 하단에서 나중에 실행되어야 할 태스크(slack-alarm-task)의 상단까지 마우스를 끌어다 놓습니다.

    파이프라인 실행하기

    파이프라인 실행하기 (1)

    의존성이 추가되어 model-training-task에서 slack-alarm-task 방향으로 뻗는 화살표가 연결된 것을 볼 수 있습니다. 이제 파이프라인을 실행하기 위하여 우측 상단의 "Run" 버튼을 클릭합니다.

    파이프라인 실행하기 (2)

    파이프라인을 실행하기에 앞서 파이프라인의 간단한 요약을 검토할 수 있습니다. 2개의 태스크가 존재하는 것을 다시 한번 확인한 후 하단의 "Run" 버튼을 클릭합니다.

    파이프라인 실행하기 (3)

    파이프라인이 성공적으로 실행되어 파이프라인 잡이 생성되었습니다. 하단의 "OK"를 클릭하면 파이프라인 잡의 정보를 볼 수 있습니다.

    파이프라인 잡

    파이프라인 잡이 정상적으로 생성되었습니다. 모델 학습(model-training-task)이 완료된 후 slack-alarm-task가 실행 중인 것을 확인할 수 있습니다.

    Slack 알림받기

    Slack 알림 (1)

    Slack 알림 (2)

    파이프라인 잡 실행 결과가 Slack을 통해 사용자에게 전달된 것을 확인할 수 있습니다. 이제 우리는 편한 마음으로 잠들 수 있게 되었습니다.

    30 May 2024

  • 실제로 동작하는 Raft 구현체 뜯어 보기 - 2

    By 이규봉

    지난 포스팅 에서는 raft-rs 타입들을 중심으로한 전체적인 개요와 시스템에 네트워크 장애가 발생했을 때 리더 선출이 어떤 식으로 이뤄지는지, 어떻게 로그 비일관성을 해소하고 장애를 극복한 후 일관적인 상태를 유지하게 되는지 세 가지 시나리오를 기반으로 알아보았습니다.

    이 글에선 지난 글에 이어 Raft 구현체의 동작 방식을 몇몇 시나리오에 걸쳐 살펴보겠습니다.

    이번에 살펴볼 시나리오는 Raft 클러스터의 상태를 어떤 과정을 거쳐 Stable storage에 저장하고, 클러스터를 다시 부트스트랩 했을 때 어떻게 이전 상태를 로그와 스냅샷으로부터 복구하게 되는지 알아보겠습니다.

    💡 Raftify는 Lablup에서 개발한 하이레벨의 Raft 구현체입니다. Raftify에 대해 궁금하시다면 해당 포스팅을 참고해보세요.

    타입을 중심으로 살펴보는 raft-rs 아키텍쳐

    이번 글에서도 마찬가지로 시나리오 분석에 앞서 raft-rs의 타입들 중 이번 글에 등장할 몇몇 타입들을 알아보도록 하겠습니다.

    ConfState

    클러스터는 여러 노드들로 구성되어 있으며 각 노드들은 장애 발생으로 인한 투표 상황에서 투표에 참여할 지의 여부에 따라 voterlearner로 나뉩니다. voterlearner 모두 클러스터 구성원으로서 클러스터로부터 합의를 공유하지만 learner의 경우 투표에 참여하지 않습니다.

    이러한 클러스터 구성원들에 대한 정보 역시 클러스터 구성원 간의 합의에 포함되며, 그렇기 때문에 로그 엔트리를 적용함으로써 구성되거나 변경될 수 있습니다.

    💡 raft-rs의 EntryType은 이런 ConfState 구성 변경을 위한 EntryConfChange 타입과 일반적인 상태 변경을 위한 EntryNormal 타입으로 나뉩니다.

    raft-rs에서 사용되는 타입들 중 네트워크 계층에 사용되는 타입들은 eraftpb.proto 파일에 정의되어 있으며 tonic에 의해 러스트 코드로 컴파일 됩니다.

    message ConfState {
        repeated uint64 voters = 1;
        repeated uint64 learners = 2;
    
        // The voters in the outgoing config. If not empty the node is in joint consensus.
        repeated uint64 voters_outgoing = 3;
        // The nodes that will become learners when the outgoing config is removed.
        // These nodes are necessarily currently in nodes_joint (or they would have
        // been added to the incoming config right away).
        repeated uint64 learners_next = 4;
        // If set, the config is joint and Raft will automatically transition into
        // the final config (i.e. remove the outgoing config) when this is safe.
        bool auto_leave = 5;
    }
    

    voters_outgoing, learners_next, auto_leave는 Joint consensus 지원을 위한 필드로 이 글에선 Joint consensus에 대한 설명은 생략하도록 하겠습니다.

    Snapshot과 SnapshotMetadata

    시스템의 가용성을 위해 로그를 무한정 쌓아둘 수 없기 때문에 오래된 로그들은 삭제되어야 하며 제거되기 전 반드시 상태 머신에 반영되어야 합니다.

    로그 시퀸스에서 특정 인덱스까지의 로그를 지우는 것을 로그 컴팩션이라고 부르며 해당 인덱스까지 로그 엔트리가 적용된 상태를 기록한 것을 스냅샷이라고 부릅니다.

    스냅샷은 이번 포스팅의 핵심 주제로 아래 시나리오 분석에서 자세히 살펴보겠지만 새로 가입한 노드로 클러스터의 상태를 전송하거나, 장애로부터 복구하기 위한 용도로 활용됩니다.

    message Snapshot {
        bytes data = 1;
        SnapshotMetadata metadata = 2;
    }
    
    message SnapshotMetadata {
        // The current `ConfState`.
        ConfState conf_state = 1;
        // The applied index.
        uint64 index = 2;
        // The term of the applied index.
        uint64 term = 3;
    }
    

    SnapshotMetadata은 스냅샷이 생성될 당시의 메타 데이터입니다.

    구체적으로 각 필드들은 아래와 같은 의미를 갖습니다.

    • conf_state: 스냅샷이 생성될 당시의 클러스터 구성원 정보를 나타냅니다.
    • index: 스냅샷이 생성된 당시 컴팩션이 이뤄진 마지막 로그 엔트리의 인덱스를 나타냅니다.
    • term: 스냅샷 생성된 당시 마지막 로그 엔트리가 갖는 term 값을 나타냅니다.

    위와 같은 메타 데이터들은 스냅샷을 활용할 때 로그 일관성을 깨지 않기 위해 필수적인 요소입니다.

    예를 들어 스냅샷으로 상태 정보를 복원할 때 스냅샷의 인덱스에 해당하는 로그 엔트리의 term과 스냅샷 메타 데이터의 term이 일치하지 않는 경우 일관성 유지를 위해 스냅샷 적용 요청을 무시해야 합니다.

    시나리오 분석

    1 - 스냅샷 기록

    Raftify에서 스냅샷 생성은 아래와 같은 RaftNode의 make_snapshot()라는 메서드 호출로 이뤄집니다.

    특정 인덱스 및 해당 인덱스에서의 로그 엔트리의 term 값을 인자로 넘겨줍니다.

    스냅샷에 저장할 데이터는 self.fsm.snapshot() 메서드가 리턴한 데이터로, 현재 상태 머신의 상태에 해당합니다.

    💡 self.fsm.snapshot() 메서드는 FSM(Finite State Machine)을 어떻게 저장할 것인지 여부에 따라 다르게 구현될 수 있으므로 Raftify 유저가 구현해 넘겨주어야 하는 구현 중 하나입니다. 예를 들어 인메모리에 FSM을 저장하는 HashStore 예제의 경우 snapshot()은 단순히 HashMap을 직렬화해 리턴합니다.

    상태 머신에 적용된 마지막 로그 엔트리의 인덱스 last_appliedcompact()에 넘겨주면 로그 엔트리에서 주어진 인덱스 이전까지의 로그를 삭제합니다.

    // lablup/raftify/blob/main/src/raft_node/mod.rs
    pub async fn make_snapshot(&mut self, index: u64, term: u64) -> Result<()> {
        ...
        let snapshot_data = self.fsm.snapshot().await?;
    
        let last_applied = self.raw_node.raft.raft_log.applied;
        let store = self.raw_node.mut_store();
        store.compact(last_applied)?;
        store.create_snapshot(snapshot_data, index, term)?;
        Ok(())
    }
    

    create_snapshot()는 넘겨 받은 스냅샷 데이터 data와 함께 스냅샷 메타 데이터들을 기록합니다.

    // lablup/raftify/blob/main/src/heed_storage/mod.rs
    fn create_snapshot(&mut self, data: Vec<u8>, index: u64, term: u64) -> Result<()> {
        let store = self.wl();
        let mut writer = store.env.write_txn()?;
        let conf_state = store.conf_state(&writer)?;
    
        let mut snapshot = Snapshot::default();
        snapshot.set_data(data);
    
        let meta = snapshot.mut_metadata();
        meta.set_conf_state(conf_state);
        meta.index = index;
        meta.term = term;
    
        store.set_snapshot(&mut writer, &snapshot)?;
        writer.commit()?;
        Ok(())
    }
    

    2 - 새로 조인한 노드에 스냅샷 전송

    시나리오

    클러스터에 새로 조인한 노드는 일관성을 유지하기 위해 기존 클러스터의 상태를 전송받아야 합니다.

    하지만 새 노드가 클러스터에 참여할 때마다 모든 로그 엔트리를 하나 하나 복제하는 것은 비효율적인 일입니다. 모든 노드는 같은 상태 머신을 가지기 때문에 모든 로그 엔트리를 전송하는 대신, 로그 엔트리들이 적용된 결과물인 스냅샷만을 전송해 문제를 해결할 수 있으며, 이 때 스냅샷 데이터를 전송하는 메시지의 타입은 MsgSnapshot입니다.

    따라서 이 섹션에서는 1번 노드가 리더 노드이고 2번 노드가 새로 조인한 노드라고 가정한 후 MsgSnapshot 메시지와 관련된 코드와 로그를 중심으로 어떤 일이 일어나고 있는지 살펴보도록 하겠습니다.

    Raftify에선 새로 조인한 팔로워가 리더 노드에게 별개의 스냅샷 요청을 전송하지 않습니다.

    구성 변경 요청(이후 ConfChange) 이 커밋되면 리더가 해당 로그 엔트리를 새로 조인한 노드에 보내려고 시도하고, 새 노드는 이 로그 엔트리를 갖고 있지 않기 때문에 이 MsgAppend 메세지는 거절됩니다.

    전편의 시나리오 2에서 네트워크 장애로 인해 MsgAppend 메시지가 거절되었을 때 생기는 노드 사이의 비일관성을 해소하는 시나리오를 다뤘었던 것을 기억하시나요?

    해당 시나리오에선 prepare_send_entries()를 통해 불일치하는 로그 엔트리들을 하나씩 동기화 했었습니다. 새로 조인한 노드와의 로그 비일관성을 해소하는 경우는, 단지 로그 엔트리를 하나씩 동기화 하는 대신 스냅샷(prepare_send_snapshot())을 통해 동기화 한다는 점이 다르다고 볼 수 있습니다.

    그럼 아래에선 코드 및 로그 분석을 통해 해당 시나리오가 어떤 과정을 통해 일어나고 있는 것인지 자세히 알아보겠습니다.

    코드 분석

    우선 해당 시나리오와 관련된 코드들 중 리더가 새로 조인한 노드에게 보낸 MsgAppend 메시지가 거절되는 부분부터 살펴보도록 하겠습니다.

    maybe_send_append() 코드를 살펴보면 아래와 같습니다. 아래 코드에서 새로 조인한 노드의 progress는 비어 있기 때문에 self.raft_log.term() 호출은 실패하게 되고, prepare_send_snapshot()가 호출되면서 maybe_send_append()false를 리턴합니다 (MsgAppend 거절)

    // tikv/raft-rs/blob/master/src/raft.rs
    fn maybe_send_append(
        &mut self,
        to: u64,
        pr: &mut Progress,
        allow_empty: bool,
        msgs: &mut Vec<Message>,
    ) -> bool {
        ...
            let term = self.raft_log.term(pr.next_idx - 1);
            match (term, ents) {
                (Ok(term), Ok(mut ents)) => {
                    if self.batch_append && self.try_batching(to, msgs, pr, &mut ents) {
                        return true;
                    }
                    self.prepare_send_entries(&mut m, pr, term, ents)
                }
                (_, Err(Error::Store(StorageError::LogTemporarilyUnavailable))) => {
                    // wait for storage to fetch entries asynchronously
                    return false;
                }
                _ => {
                    // 💡 이번 시나리오에선 아래 분기가 실행됩니다.
                    // send snapshot if we failed to get term or entries.
                    if !self.prepare_send_snapshot(&mut m, pr, to) {
                        return false;
                    }
                }
            }
        }
        self.send(m, msgs);
        true
    }
    

    호출된 prepare_send_snapshot()는 아래와 같은 함수로, self.raft_log.snapshot() 메서드를 호출해 스냅샷 데이터를 가져온 후 송신할 메시지에 설정합니다.

    그 후 해당 노드의 progress 객체를 snapshot 상태라고 표시한 후 리턴합니다.

    💡 여기서 노드의 상태가 snapshot 상태라는 것은 해당 노드가 스냅샷 복제 상태이기 때문에 이 노드로의 로그 복제 작업이 잠시 중단될 것임을 나타냅니다.

    // tikv/raft-rs/blob/master/src/raft.rs
    fn prepare_send_snapshot(&mut self, m: &mut Message, pr: &mut Progress, to: u64) -> bool {
        ...
        m.set_msg_type(MessageType::MsgSnapshot);
        let snapshot_r = self.raft_log.snapshot(pr.pending_request_snapshot, to);
        if let Err(ref e) = snapshot_r {
            if *e == Error::Store(StorageError::SnapshotTemporarilyUnavailable) {
                self.logger.debug(
                    format!(
                        "failed to send snapshot to {} because snapshot is temporarily unavailable",
                        to
                    )
                    .as_str(),
                );
                return false;
            }
            self.logger
                .fatal(format!("unexpected error: {:?}", e).as_str());
        }
        let snapshot = snapshot_r.unwrap();
        if snapshot.get_metadata().index == 0 {
            self.logger.fatal("need non-empty snapshot");
        }
        let (sindex, sterm) = (snapshot.get_metadata().index, snapshot.get_metadata().term);
        m.set_snapshot(snapshot);
        self.logger.debug(format!(
            "[firstindex: {first_index}, commit: {committed}] sent snapshot[index: {snapshot_index}, term: {snapshot_term}] to {to}; progress: {progress}",
            first_index = self.raft_log.first_index(),
            committed = self.raft_log.committed,
            snapshot_index = sindex,
            snapshot_term = sterm,
            to = to,
            progress = format!("{:?}", pr)
        ).as_str());
    
        pr.become_snapshot(sindex);
        self.logger.debug(
            format!(
                "paused sending replication messages to {}; progress: {:?}",
                to, pr
            )
            .as_str(),
        );
        true
    }
    

    따라서 Raftify는 ConfChange가 커밋될 때 1번 시나리오에서 살펴봤었던 RaftNode.make_snapshot() 호출을 통해 새 노드에 전송할 스냅샷을 미리 준비해둡니다.

    이렇게 전송된 스냅샷은 새로 조인한 노드의 Raft loop의 Snapshot 핸들링 로직에서 감지되어 복구하게 됩니다. 아래 로직의 self.fsm.restore()을 통해 전송 받은 스냅샷 데이터로 상태 머신을 복구하고, store.apply_snapshot()을 통해 Stable storage에도 적용해줍니다.

    // lablup/raftify/blob/main/raftify/src/raft_node/mod.rs
    async fn on_ready(&mut self) -> Result<()> {
        ...
        if *ready.snapshot() != Snapshot::default() {
            self.logger
                .info("Restoring state machine and snapshot metadata...");
            let snapshot = ready.snapshot();
            if !snapshot.get_data().is_empty() {
                self.fsm.restore(snapshot.get_data().to_vec()).await?;
            }
            let store = self.raw_node.mut_store();
            store.apply_snapshot(snapshot.clone())?;
        }
        ...
    }
    

    리더 노드 로그 분석

    이번엔 새로운 노드가 조인 했을 때 리더 노드에 출력되는 로그들을 하나씩 순서대로 분석해보겠습니다.

    1. 1번 노드는 2번 노드로부터 조인 요청을 받고 클러스터 구성이 변경됩니다.
    Apr 11 06:51:14.189 INFO Node 2 (127.0.0.1:60062) joined the cluster as voter.
    Apr 11 06:51:14.189 INFO switched to configuration; config: Configuration { voters: Configuration { incoming: Configuration { voters: {1, 2} }, outgoing: Configuration { voters: {} } }, learners: {}, learners_next: {}, auto_leave: false }
    Apr 11 06:51:14.189 DEBG Entries [9, 10) requested.
    
    1. 리더에 새로운 로그 엔트리가 추가되었기 때문에 2번 노드에 이 로그 엔트리를 복제하기 위해 MsgAppend 메시지를 송신합니다.
    Apr 11 06:51:14.189 DEBG <<< Sending from 1 to 2, msg: Message { msg_type: MsgAppend, to: 2, from: 0, term: 0, log_term: 1, index: 8, entries: [Entry { context: 7, data: ConfChangeV2 { transition: 0, changes: [ConfChangeSingle { change_type: AddNode, node_id: 2 }], context: [127.0.0.1:60062] }, entry_type: EntryConfChangeV2, index: 9, sync_log: false, term: 1 }], commit: 9, commit_term: 0, snapshot: Snapshot { data: [], metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    
    1. 그러나 새로 조인한 노드는 기존 클러스터의 정보를 갖고 있지 못하기 때문에 이 MsgAppend 메시지는 거절되며 1번 노드는 아래와 같이 요청이 거절되었다는 메시지를 받게 됩니다.
    Apr 11 06:51:14.298 DEBG >>> Node 1 received Raft message from the node 2, Message { msg_type: MsgAppendResponse, to: 1, from: 2, term: 1, log_term: 0, index: 8, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: [], metadata: None }, request_snapshot: 0, reject: true, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    Apr 11 06:51:14.298 DEBG received msgAppend rejection; reject_hint_index: 0, reject_hint_term: 0, from: 2, index: 8
    Apr 11 06:51:14.298 DEBG decreased progress of 2; progress: Progress { matched: 0, next_idx: 1, state: Probe, paused: false, pending_snapshot: 0, pending_request_snapshot: 0, recent_active: true, ins: Inflights { start: 0, count: 0, buffer: [], cap: 256, incoming_cap: None }, commit_group_id: 0, committed_index: 0 }
    
    1. 위에서 설명한것 처럼 새로 조인한 노드의 progress는 비어 있으므로, 스냅샷을 Stable storage에 저장하고 해당 인덱스까지의 로그 엔트리들을 제거하게 됩니다. 이 경우엔 8 이전까지의 로그 엔트리들이 제거되었으며 2번 노드의 조인 요청에 해당하는 로그 엔트리의 인덱스는 9입니다. 따라서 아래와 같이 first_index8이며, commit9라는 로그와 함께 스냅샷 메세지가 전송됩니다.
    Apr 11 06:51:14.298 DEBG [firstindex: 8, commit: 9] sent snapshot[index: 9, term: 1] to 2; progress: Progress { matched: 0, next_idx: 1, state: Probe, paused: false, pending_snapshot: 0, pending_request_snapshot: 0, recent_active: true, ins: Inflights { start: 0, count: 0, buffer: [], cap: 256, incoming_cap: None }, commit_group_id: 0, committed_index: 0 }
    
    1. 스냅샷 전송을 위해 로그 엔트리 복제를 중단합니다.
    Apr 11 06:51:14.299 DEBG paused sending replication messages to 2; progress: Progress { matched: 0, next_idx: 1, state: Snapshot, paused: false, pending_snapshot: 9, pending_request_snapshot: 0, recent_active: true, ins: Inflights { start: 0, count: 0, buffer: [], cap: 256, incoming_cap: None }, commit_group_id: 0, committed_index: 0 }
    
    1. 스냅샷을 전송하는 MsgSnapshot 타입의 메시지를 송신합니다. 스냅샷엔 이전에 임의로 넣어 놓은 data: {4: "A", 3: "A", 2: "A", 1: "A", 5: "A"} 라는 데이터가 들어 있는 것을 확인할 수 있습니다
    Apr 11 06:51:14.299 DEBG <<< Sending from 1 to 2, msg: Message { msg_type: MsgSnapshot, to: 2, from: 0, term: 0, log_term: 0, index: 0, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: HashStore(RwLock { data: {4: "A", 3: "A", 2: "A", 1: "A", 5: "A"}, poisoned: false, .. }), metadata: Some(SnapshotMetadata { conf_state: Some(ConfState { voters: [1, 2], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }), index: 9, term: 1 }) }, request_snapshot: 0, reject: false, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    

    팔로워 노드 로그 분석

    새로 조인한 팔로워 노드에 출력되는 로그를 분석해보면 아래와 같습니다.

    1. term 1에서 새로운 팔로워 노드가 됩니다.
    Apr 15 06:37:27.421 INFO became follower at term 1
    
    1. 리더 노드로부터 온 MsgAppend 메세지를 거절합니다.
    Apr 15 06:37:27.421 DEBG rejected msgApp [logterm: 1, index: 8] from 1; index: 8, logterm: Ok(0)
    Apr 15 06:37:27.421 DEBG <<< Sending from 2 to 1, msg: Message { msg_type: MsgAppendResponse, to: 1, from: 0, term: 0, log_term: 0, index: 8, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: [], metadata: None }, request_snapshot: 0, reject: true, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    
    1. 해당 노드가 장애 상태로 감지되어 불필요한 투표가 일어나선 안 되기 때문에 MsgHeartbeat 메시지엔 정상 응답해야 합니다.
    Apr 15 06:37:27.423 DEBG >>> Node 2 received Raft message from the node 1, Message { msg_type: MsgHeartbeat, to: 2, from: 1, term: 1, log_term: 0, index: 0, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: [], metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    Apr 15 06:37:27.423 DEBG <<< Sending from 2 to 1, msg: Message { msg_type: MsgHeartbeatResponse, to: 1, from: 0, term: 0, log_term: 0, index: 0, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: [], metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    
    1. MsgSnapshot 메시지를 통해 스냅샷을 전송 받습니다.
    Apr 15 06:37:27.424 DEBG >>> Node 2 received Raft message from the node 1, Message { msg_type: MsgSnapshot, to: 2, from: 1, term: 1, log_term: 0, index: 0, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: HashStore(RwLock { data: {3: "A", 5: "A", 2: "A", 4: "A", 1: "A"}, poisoned: false, .. }), metadata: Some(SnapshotMetadata { conf_state: Some(ConfState { voters: [1, 2], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }), index: 9, term: 1 }) }, request_snapshot: 0, reject: false, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    Apr 15 06:37:27.424 INFO log [committed=0, persisted=0, applied=0, unstable.offset=1, unstable.entries.len()=0] starts to restore snapshot [index: 9, term: 1]
    Apr 15 06:37:27.424 INFO switched to configuration; config: Configuration { voters: Configuration { incoming: Configuration { voters: {1, 2} }, outgoing: Configuration { voters: {} } }, learners: {}, learners_next: {}, auto_leave: false }
    
    1. 전송받은 스냅샷을 통해 상태를 복구합니다.
    Apr 15 06:37:27.424 INFO restored snapshot; commit: 9, last_index: 9, last_term: 1, snapshot_index: 9, snapshot_term: 1
    Apr 15 06:37:27.424 INFO [commit: 9, term: 1] restored snapshot [index: 9, term: 1]
    Apr 15 06:37:27.425 DEBG <<< Sending from 2 to 1, msg: Message { msg_type: MsgAppendResponse, to: 1, from: 0, term: 0, log_term: 0, index: 9, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: [], metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: [], deprecated_priority: 0, priority: 0 }
    Apr 15 06:37:27.425 INFO Restoring state machine and snapshot metadata...
    Apr 15 06:37:27.425 DEBG snapshot's persisted index  9
    

    3 - 대다수(Majority) 이상의 노드에 장애가 생긴 경우 복구

    특정 노드에 장애가 발생한 경우 해당 노드는 단지 네트워크가 복구된 후 리더 노드로부터 새 로그 엔트리들을 복제 받으면 되기 때문에 문제가 되지 않습니다. 노드가 새로 조인해야 하는 경우에도 2번 시나리오에서 다뤘듯이 스냅샷을 통해 상태를 복구할 수 있으므로 문제가 되지 않습니다.

    하지만 쿼럼 이상의 노드에 장애가 발생한 경우 클러스터는 스스로 장애를 복구할 수 없습니다.

    이 경우 관리자가 수동으로 개입해 어떤 노드의 로그 시퀸스를 정상 상태로 볼 것인지 결정한 후 해당 로그 시퀸스로부터 다시 클러스터를 부트스트랩 해 주어야 합니다.

    이 때 관리자의 판단에 따라 상태 머신에 모든 로그 엔트리를 하나 하나 직접 적용해가며 복구하거나 마지막으로 생성된 스냅샷으로부터 상태를 복구해야 합니다.

    WAL 스냅샷에서의 상태 복구

    해당 섹션에선 직접 Raftify의 예제 코드를 사용합니다.

    예제를 재현하기 위해 1번 노드에 간단하게 몇 개의 키값을 넣어준 후 /snapshot API를 통해 make_snapshot() 메서드를 호출해 스냅샷을 생성해줍니다. 그리고 노드에 장애가 일어났다고 가정하고 종료해볼 것입니다.

    WAL 스냅샷으로부터 복구하기 위해선 restore_wal_snapshot_from 라는 옵션에 복구할 노드의 node_id를 넘겨주면 됩니다. 여기선 1번 노드의 스냅샷으로 복구할 것이므로 1을 넣어주면 됩니다.

    그리고 로그 엔트리의 적용 여부를 확인하기 위해 apply()가 호출될 때 마다 "Inserted: (key, value)"와 같은 로그를 남겨보도록 하겠습니다.

    💡 apply() 역시 restore()와 마찬가지로 Raftify 유저가 정의해야 하는 StateMachine의 추상 메서드들 중 하나로 로그 엔트리가 커밋되는 시점에 호출됩니다.

    스냅샷을 찍고 1번 노드를 종료한 후 Raftify가 제공하는 CLI 명령어를 사용해 스토리지를 덤프해보면 아래와 같습니다.

    아래 로그를 통해 스토리지 내에 스냅샷이 저장되어 있고 { data: {2: \"A\", 5: \"A\", 3: \"A\", 4: \"A\", 1: \"A\"}와 같은 데이터를 갖고 있다는 것을 알 수 있습니다.

    ❯ raftify-cli debug persisted-all ./logs
    *----- node-1 -----*
    ---- Persisted entries ----
    Key: 8, "Entry { context: 6, data: Insert { key: 5, value: \"A\" }, entry_type: EntryNormal, index: 8, sync_log: false, term: 2 }"
    
    ---- Metadata ----
    HardState { term: 1, vote: 1, commit: 8 }
    ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }
    "Snapshot { data: HashStore(RwLock { data: {2: \"A\", 5: \"A\", 3: \"A\", 4: \"A\", 1: \"A\"}, poisoned: false, .. }), metadata: Some(SnapshotMetadata { conf_state: Some(ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }), index: 8, term: 2 }) }"
    Last index: 8
    

    그 후 ./target/debug/memstore-static-members --raft-addr=127.0.0.1:60061 --web-server=127.0.0.1:8001 --restore-wal-snapshot-from=1 명령어를 통해 1번 노드를 다시 부트스트랩 시켜 봅시다.

    이 때 1번 노드에 출력되는 로그는 아래와 같습니다. 상태를 스냅샷으로부터 바로 복구하므로 각 로그 엔트리들에 대한 apply()는 한 번도 실행되지 않았습니다.

    Apr 15 07:54:44.703 INFO RaftNode bootstrapped. Config { raft_config: { id: 0, election_tick: 10, heartbeat_tick: 3, applied: 0, max_size_per_msg: 0, max_inflight_msgs: 256, check_quorum: false, pre_vote: false, min_election_tick: 0, max_election_tick: 0, read_only_option: Safe, skip_bcast_commit: false, batch_append: false, priority: 0, max_uncommitted_size: 18446744073709551615, max_committed_size_per_ready: 18446744073709551615, }, log_dir: ./logs, save_compacted_logs: true, compacted_log_dir: ./logs, compacted_log_size_threshold: 1073741824, snapshot_interval: None, tick_interval: 0.1, initial_peers: Some(Peers { inner: {1: Peer { addr: 127.0.0.1:60061, role: Voter, client: None }, 2: Peer { addr: 127.0.0.1:60062, role: Voter, client: None }} }), lmdb_map_size: 1073741824, cluster_id: default, conf_change_request_timeout: 2, restore_wal_from: None, restore_wal_snapshot_from: Some(1), }
    Apr 15 07:54:44.705 INFO switched to configuration; config: Configuration { voters: Configuration { incoming: Configuration { voters: {1, 2} }, outgoing: Configuration { voters: {} } }, learners: {}, learners_next: {}, auto_leave: false }
    Apr 15 07:54:44.705 DEBG reset election timeout 0 -> 10 at 0
    Apr 15 07:54:44.705 INFO became follower at term 3
    Apr 15 07:54:44.705 INFO newRaft; term: 3, commit: 0, applied: 0, last index: 0, last term: 0, peers: Configuration { incoming: Configuration { voters: {1, 2} }, outgoing: Configuration { voters: {} } }
    Apr 15 07:54:44.705 INFO RawNode created with id 1.
    Apr 15 07:54:44.748 DEBG RaftServer starts to listen gRPC requests on "127.0.0.1:60061"...
    

    그리고 다시 스토리지를 덤프 해 봅시다.

    자기 자신의 스냅샷으로부터의 복구이기 때문에 아무런 상태 변화도 일어나지 않은 것을 확인할 수 있습니다.

    *----- node-1 -----*
    ---- Persisted entries ----
    Key: 8, "Entry { context: 6, data: Insert { key: 5, value: \"A\" }, entry_type: EntryNormal, index: 8, sync_log: false, term: 2 }"
    
    ---- Metadata ----
    HardState { term: 1, vote: 1, commit: 8 }
    ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }
    "Snapshot { data: HashStore(RwLock { data: {3: \"A\", 2: \"A\", 5: \"A\", 4: \"A\", 1: \"A\"}, poisoned: false, .. }), metadata: Some(SnapshotMetadata { conf_state: Some(ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }), index: 8, term: 2 }) }"
    Last index: 8
    

    WAL 로그에서의 상태 복구

    이번에는 특정 로그 시퀸스로부터 상태를 복구해봅시다.

    이번엔 아래 로그와 같이 스토리지에 스냅샷은 비어 있으며 대신 상태를 복구하기 위한 로그 엔트리들이 저장되어 있습니다.

    *----- node-1 -----*
    ---- Persisted entries ----
    Key: 1, "Entry { context: [], data: [], entry_type: EntryNormal, index: 1, sync_log: false, term: 2 }"
    Key: 2, "Entry { context: 0, data: Insert { key: 1, value: \"A\" }, entry_type: EntryNormal, index: 2, sync_log: false, term: 2 }"
    Key: 3, "Entry { context: 1, data: Insert { key: 1, value: \"A\" }, entry_type: EntryNormal, index: 3, sync_log: false, term: 2 }"
    Key: 4, "Entry { context: 2, data: Insert { key: 1, value: \"A\" }, entry_type: EntryNormal, index: 4, sync_log: false, term: 2 }"
    Key: 5, "Entry { context: 3, data: Insert { key: 2, value: \"A\" }, entry_type: EntryNormal, index: 5, sync_log: false, term: 2 }"
    Key: 6, "Entry { context: 4, data: Insert { key: 3, value: \"A\" }, entry_type: EntryNormal, index: 6, sync_log: false, term: 2 }"
    Key: 7, "Entry { context: 5, data: Insert { key: 4, value: \"A\" }, entry_type: EntryNormal, index: 7, sync_log: false, term: 2 }"
    Key: 8, "Entry { context: 6, data: Insert { key: 5, value: \"A\" }, entry_type: EntryNormal, index: 8, sync_log: false, term: 2 }"
    
    ---- Metadata ----
    HardState { term: 2, vote: 1, commit: 8 }
    ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }
    "Snapshot { data: [], metadata: Some(SnapshotMetadata { conf_state: Some(ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }), index: 0, term: 0 }) }"
    Last index: 8
    

    이전 섹션에서와 마찬가지로 장애를 가정해 1번 노드를 종료하고 다시 부트스트랩 했을 때 어떤 일이 일어나는지 살펴보겠습니다.

    1번 노드를 종료한 후 ./target/debug/memstore-static-members --raft-addr=127.0.0.1:60061 --web-server=127.0.0.1:8001 --restore-wal-from=1 명령어로 1번 노드를 다시 부트스트랩 시켜 봅시다.

    1번 노드에 아래와 같은 로그가 출력되며 이전에 입력한 로그 엔트리들이 한 번에 apply() 되며 이전의 상태를 복구하는 것을 알 수 있습니다.

    Apr 15 07:46:50.710 INFO RaftNode bootstrapped. Config { raft_config: { id: 0, election_tick: 10, heartbeat_tick: 3, applied: 0, max_size_per_msg: 0, max_inflight_msgs: 256, check_quorum: false, pre_vote: false, min_election_tick: 0, max_election_tick: 0, read_only_option: Safe, skip_bcast_commit: false, batch_append: false, priority: 0, max_uncommitted_size: 18446744073709551615, max_committed_size_per_ready: 18446744073709551615, }, log_dir: ./logs, save_compacted_logs: true, compacted_log_dir: ./logs, compacted_log_size_threshold: 1073741824, snapshot_interval: None, tick_interval: 0.1, initial_peers: Some(Peers { inner: {2: Peer { addr: 127.0.0.1:60062, role: Voter, client: None }, 1: Peer { addr: 127.0.0.1:60061, role: Voter, client: None }} }), lmdb_map_size: 1073741824, cluster_id: default, conf_change_request_timeout: 2, restore_wal_from: Some(1), restore_wal_snapshot_from: None, }
    Apr 15 07:46:50.712 INFO switched to configuration; config: Configuration { voters: Configuration { incoming: Configuration { voters: {1, 2} }, outgoing: Configuration { voters: {} } }, learners: {}, learners_next: {}, auto_leave: false }
    Apr 15 07:46:50.712 DEBG reset election timeout 0 -> 10 at 0
    Apr 15 07:46:50.712 INFO became follower at term 1
    Apr 15 07:46:50.712 INFO newRaft; term: 1, commit: 8, applied: 0, last index: 8, last term: 1, peers: Configuration { incoming: Configuration { voters: {1, 2} }, outgoing: Configuration { voters: {} } }
    Apr 15 07:46:50.712 INFO RawNode created with id 1.
    Apr 15 07:46:50.753 DEBG RaftServer starts to listen gRPC requests on "127.0.0.1:60061"...
    Apr 15 07:46:50.855 DEBG Entries [1, 9) requested.
    
    // 하나씩 로그 엔트리들을 apply하며 상태 머신 상태를 복구해나감
    Inserted: (1, A)
    Inserted: (1, A)
    Inserted: (1, A)
    Inserted: (2, A)
    Inserted: (3, A)
    Inserted: (4, A)
    Inserted: (5, A)
    

    이번에도 마찬가지로 자기 자신의 크러시 이전 상태를 복구한 것이므로 스토리지를 덤프해보면 이전과 같습니다. 다른 점은 이전엔 스냅샷을 통해 빠르게 상태를 복구한 것에 비해 모든 로그 엔트리들을 하나 하나 적용했다는 점입니다.

    *----- node-1 -----*
    ---- Persisted entries ----
    Key: 1, "Entry { context: [], data: [], entry_type: EntryNormal, index: 1, sync_log: false, term: 2 }"
    Key: 2, "Entry { context: 0, data: Insert { key: 1, value: \"A\" }, entry_type: EntryNormal, index: 2, sync_log: false, term: 2 }"
    Key: 3, "Entry { context: 1, data: Insert { key: 1, value: \"A\" }, entry_type: EntryNormal, index: 3, sync_log: false, term: 2 }"
    Key: 4, "Entry { context: 2, data: Insert { key: 1, value: \"A\" }, entry_type: EntryNormal, index: 4, sync_log: false, term: 2 }"
    Key: 5, "Entry { context: 3, data: Insert { key: 2, value: \"A\" }, entry_type: EntryNormal, index: 5, sync_log: false, term: 2 }"
    Key: 6, "Entry { context: 4, data: Insert { key: 3, value: \"A\" }, entry_type: EntryNormal, index: 6, sync_log: false, term: 2 }"
    Key: 7, "Entry { context: 5, data: Insert { key: 4, value: \"A\" }, entry_type: EntryNormal, index: 7, sync_log: false, term: 2 }"
    Key: 8, "Entry { context: 6, data: Insert { key: 5, value: \"A\" }, entry_type: EntryNormal, index: 8, sync_log: false, term: 2 }"
    
    ---- Metadata ----
    HardState { term: 2, vote: 1, commit: 8 }
    ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }
    "Snapshot { data: [], metadata: Some(SnapshotMetadata { conf_state: Some(ConfState { voters: [2, 1], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }), index: 0, term: 0 }) }"
    Last index: 8
    

    마무리

    이번 글에선 지난 편에 이어 스냅샷을 중심으로 새로 조인한 노드가 있을 때 로그 비일관성 해소 문제와 장애 복구 시나리오에 대해 알아보았습니다.

    Raftify는 2024 오픈소스 컨트리뷰션 아카데미에 참여형 프로젝트로 참가해 분산 시스템 구현에 관심이 있는 멘티 분들을 모집하고 있습니다! (모집 기간: ~ 06.23)

    참가자분들은 멘토들과 함께 분산 시스템의 기본 개념 학습부터 실제 구현 과정까지 경험해 볼 수 있습니다.

    많은 관심 부탁드립니다! 감사합니다 😊

    29 May 2024

  • 실제로 동작하는 Raft 구현체 뜯어 보기 - 1

    By 이규봉

    이 글에선 독자들이 Raft에 관한 이론적인 배경지식이 있다고 가정하고, tikv/raft-rs 코드를 샅샅이 훑어 보며 어떻게 실제로 분산 시스템에서의 상태 머신이 동기화되고 동작하게 되는지 몇 개의 간략한 시나리오에 걸쳐 알아보겠습니다.

    이 글은 raft-rs 코드 분석에 초점을 맞추고 있지만, raft-rs 구현체가 유연성을 위해 네트워크 및 스토리지 계층을 포함하지 않기 때문에 온전한 이해를 위해 일부 섹션에서 raftify 소스 코드를 예제로 사용합니다.

    💡 raftify는 Lablup에서 개발한 하이레벨의 Raft 구현체입니다. 이 글에선 raftify에 대해선 raft의 동작 방식을 이해하기 위한 최소한의 코드만을 설명합니다. raftify에 대해 궁금하시다면 해당 포스팅을 참고해보세요.

    타입을 중심으로 살펴보는 raft-rs 아키텍처

    시나리오를 살펴 보기에 앞서 코드 베이스에서 사용되는 대표적인 타입들을 중심으로 아키텍쳐를 대략적으로 살펴봅시다.

    Raft

    각 Raft 노드들의 Raft 객체는 메시지 큐 msgs 를 메모리에 들고 있으며 이 큐를 통해 다른 Raft 노드들과 상호작용합니다.

    raftify와 같은 하이레벨의 구현체에서 네트워크 계층은 후에 설명할 추상화 계층을 통해 이 큐에 메시지를 넣는 역할을 하게 됩니다.

    따라서 이 메시지 큐는 통신의 엔드 포인트로 볼 수 있으며, Raft 구현체는 현재 상태에 따라 이 메시지들을 처리해 나가며 노드 간의 일관된 상태를 유지합니다.

    이 Raft 노드의 상태에 해당하는 데이터들을 들고 있는 것이 RaftCore 타입입니다.

    또한 다른 Raft 노드들과의 로그 엔트리들을 동기화하기 위한 메타 데이터들을 담는 Progress란 타입이 있으며, 이것들은 상황에 따라 ProgressTracker에서 적절하게 업데이트 됩니다.

    결과적으로 Raft는 아래와 같은 타입이 됩니다.

    pub struct Raft<T: Storage> {
        pub msgs: Vec<Message>,
        pub r: RaftCore<T>,
        prs: ProgressTracker,
    }
    

    RaftLog

    RaftCore가 갖는 대표적인 데이터로 로그 엔트리 시퀸스에 대한 접근을 추상화하는 RaftLog가 있습니다.

    RaftLog<T: Storage>UnstableT 타입을 함께 다룰 수 있도록 추상화합니다. 여기서 T는 raftify와 같은 보다 높은 레벨에서 구현해 넣어 주어야 하는 영속적인 스토리지에 해당하며, Unstable은 이 스토리지에 기록되기 전 거치는 버퍼입니다.

    pub struct RaftLog<T: Storage> {
        pub store: T,
        pub unstable: Unstable,
    
        ...
    }
    

    💡 RaftCore 타입에 대해 더 궁금하다면 이 링크를 참고하세요.

    Raft Loop

    Raft 구현체들은 다른 Raft 노드들과 통신하며 일관된 상태를 유지하기 위해 무한 루프를 돌며 자신의 상태 머신을 업데이트 하는 반복적인 프로세스를 수행합니다. 이 글에선 이러한 루프를 Raft loop라고 부르겠습니다.

    raftify에서 Raft loop를 구현하는 소스 코드는 아래와 같습니다.

    (가장 minimal한 구현을 보고 싶다면 tikv/raft-rs의 예제 코드를 참고하실 수도 있습니다.)

    async fn on_ready(&mut self) -> Result<()> {
        if !self.raw_node.has_ready() {
            return Ok(());
        }
        let mut ready = self.raw_node.ready();
    
        if !ready.messages().is_empty() {
            self.send_messages(ready.take_messages()).await;
        }
    
        if *ready.snapshot() != Snapshot::default() {
            slog::info!(
                self.logger,
                "Restoring state machine and snapshot metadata..."
            );
            let snapshot = ready.snapshot();
            if !snapshot.get_data().is_empty() {
                self.fsm.restore(snapshot.get_data().to_vec()).await?;
            }
            let store = self.raw_node.mut_store();
            store.apply_snapshot(snapshot.clone())?;
        }
    
        self.handle_committed_entries(ready.take_committed_entries())
            .await?;
    
        if !ready.entries().is_empty() {
            let entries = &ready.entries()[..];
            let store = self.raw_node.mut_store();
            store.append(entries)?;
        }
    
        if let Some(hs) = ready.hs() {
            let store = self.raw_node.mut_store();
            store.set_hard_state(hs)?;
        }
    
        if !ready.persisted_messages().is_empty() {
            self.send_messages(ready.take_persisted_messages()).await;
        }
    
        let mut light_rd = self.raw_node.advance(ready);
    
        if let Some(commit) = light_rd.commit_index() {
            let store = self.raw_node.mut_store();
            store.set_hard_state_commit(commit)?;
        }
    
        if !light_rd.messages().is_empty() {
            self.send_messages(light_rd.take_messages()).await;
        }
    
        self.handle_committed_entries(light_rd.take_committed_entries())
            .await?;
    
        self.raw_node.advance_apply();
    
        Ok(())
    }
    

    RawNode

    각 Raft 노드들은 Raft 모듈을 포함하는 RawNode란 타입의 좀 더 하이레벨의 인스턴스를 갖습니다. RawNode는 메모리에만 유지되는 상태인 SoftState, 영속적인 스토리지에 저장되는 상태인 HardState와 아직 저장되지 않은 Ready의 메타 데이터들을 나타내는 records 필드를 갖고 있습니다.

    💡 Ready란 Raft 노드를 업데이트 해야 할 필요가 있을 때 갱신되어야 할 데이터들을 한꺼번에 넘겨주는 자료구조입니다.

    pub struct RawNode<T: Storage> {
        pub raft: Raft<T>,
        prev_ss: SoftState,
        prev_hs: HardState,
        max_number: u64,
        records: VecDeque<ReadyRecord>,
        commit_since_index: u64,
    }
    

    ready() 메서드가 호출될 때 Ready의 메타 데이터가 records에 저장되고, 저장되어야 하는 스냅샷, 로그 엔트리 등이 모두 처리된 후 함수의 마지막 부분인 RawNode::advance()에서 RawNode::commit_ready()를 호출하며 버퍼 Unstable의 스냅샷, 엔트리를 비웁니다.

    RaftNode

    RaftNode는 raftify에서 RawNode를 네트워크, 스토리지 계층과 통합해 좀 더 하이 레벨에서 추상화하는 타입입니다.

    raftify는 별개의 비동기 태스크에서 gRPC 클라이언트에서 보낸 메시지들을 수신하여, 채널을 통해 RaftNode.run() 태스크에 이 메시지들을 넘겨줍니다.

    메시지를 처리하고 난 후엔 on_ready()란 이름의 메서드(Raft loop)에서 상태 변경을 처리합니다.

    pub async fn run(mut self) -> Result<()> {
        let mut tick_timer = Duration::from_secs_f32(self.config.tick_interval);
        let fixed_tick_timer = tick_timer;
        let mut now = Instant::now();
    
        loop {
            ...
            tokio::select! {
                msg = timeout(fixed_tick_timer, self.server_rcv.recv()) => {
                    if let Ok(Some(msg)) = msg {
                        self.handle_server_request_msg(msg).await?;
                    }
                }
                ...
            }
    
            let elapsed = now.elapsed();
            now = Instant::now();
            if elapsed > tick_timer {
                tick_timer = Duration::from_millis(100);
                self.raw_node.tick();
            } else {
                tick_timer -= elapsed;
            }
    
            self.on_ready().await?
        }
    }
    

    좀 더 raftify의 구현에 대해 자세히 설명해보자면 raftify는 아래와 같은 과정을 반복 처리합니다.

    1. 클라이언트에서 요청 생성. (예를 들어 RaftServiceClient.propose()RaftNode.propose()를 호출)
    2. gRPC를 통해 원격 Raft 노드의 RaftServiceClient.propose()가 호출됨.
    3. RaftServiceClient.propose()가 채널을 통해 Propose 메시지를 RaftNode.run() 비동기 태스크로 넘김.
    4. 메시지 큐를 폴링하던 RaftNode.run()Propose 메시지가 들어오면 RawNode.propose() 호출.
    5. 상태 머신에 적용되어야 하는 변경 사항이 생기면 Ready 인스턴스가 생성되어 on_ready() 핸들러로 전달됨.
    6. on_ready() 핸들러에서 커밋된 엔트리들을 처리한 후 클라이언트에 응답함.

    이론적인 내용들은 이쯤에서 마무리하고 시나리오 몇 개를 분석해보며 어떤 일들이 일어나는지 살펴봅시다.

    💡 이 단락에서 Propose 메시지라고 임의로 칭한 것은 클러스터에 상태 변경을 제안하기 위한 목적으로 정의된 타입의 메시지입니다.

    시나리오 분석

    1 - 새 로그 엔트리 추가

    리더 노드 분석

    상태 머신을 변경하기 위해 클러스터에 변경 사항을 요청하면 (propose) 내부에서 어떤 일이 일어날까요? 이 섹션에선 RawNode.propose()를 호출했을 때 어떤 과정을 거치게 되는지 하나씩 분석해보겠습니다. RawNode.propose() 함수를 살펴보면 아래와 같습니다.

    pub fn propose(&mut self, context: Vec<u8>, data: Vec<u8>) -> Result<()> {
        let mut m = Message::default();
        m.set_msg_type(MessageType::MsgPropose);
        m.from = self.raft.id;
        let mut e = Entry::default();
        e.data = data.into();
        e.context = context.into();
        m.set_entries(vec![e].into());
        self.raft.step(m)
    }
    

    위 코드를 통해 propose() 함수는 step()을 호출해 MsgPropose 타입의 메시지를 처리하도록 만드는 것을 알 수 있습니다.

    여기서 step()은 raft-rs의 실질적인 메시지 핸들러에 해당하는 함수입니다. step()을 호출한 노드가 리더인 경우 step_leader(), 팔로워인 경우 step_follower(), 후보자인 경우 step_candidate()가 호출됩니다.

    step()의 코드를 모두 이해하는 것은 다소 복잡하기 때문에 여기선 리더 노드에서 MsgPropose 타입이 어떻게 처리되는지 코드를 따라가봅시다.

    fn step_leader(&mut self, mut m: Message) -> Result<()> {
        ...
        match m.get_msg_type() {
            MessageType::MsgPropose => {
                ...
                if !self.append_entry(m.mut_entries()) {
                    ...
                }
                self.bcast_append();
                return Ok(());
            }
        ...
        }
    }
    

    Raft.append_entry()RaftLog.append()를 호출해 엔트리들을 추가합니다. RaftLog.append()self.unstable.truncate_and_append()에서 Unstable 버퍼에 엔트리들을 추가합니다. 버퍼에 추가된 엔트리들은 Raft loop에서 Stable storage에 persist 될 것입니다.

    pub fn append(&mut self, ents: &[Entry]) -> u64 {
        ...
        self.unstable.truncate_and_append(ents);
        self.last_index()
    }
    

    그 다음으로 호출되는 bcast_append()에 대해 살펴보도록 하겠습니다.

    이전 섹션에서 설명한, 리더와 팔로워들의 로그 엔트리들을 동기화하기 위한 ProgressTracker (prs)를 통해 각 팔로워의 progress를 인자로 RaftCore.send_append()를 호출하는 것을 볼 수 있습니다.

    pub fn bcast_append(&mut self) {
        let self_id = self.id;
        let core = &mut self.r;
        let msgs = &mut self.msgs;
        self.prs
            .iter_mut()
            .filter(|&(id, _)| *id != self_id)
            .for_each(|(id, pr)| core.send_append(*id, pr, msgs));
    }
    

    send_append()는 아래와 같은 간략한 구조를 갖고 있습니다.

    fn send_append(&mut self, to: u64, pr: &mut Progress, msgs: &mut Vec<Message>) {
        self.maybe_send_append(to, pr, true, msgs);
    }
    

    maybe_send_append()RaftLog.entries를 통해 pr.next_idx ~ to 범위의 로그 엔트리들을 읽어온 후 prepare_send_entries()에 넘겨주며 성공하면 true, 실패하면 false를 리턴합니다.

    fn maybe_send_append(
        &mut self,
        to: u64,
        pr: &mut Progress,
        allow_empty: bool,
        msgs: &mut Vec<Message>,
    ) -> bool {
        ...
        let ents = self.raft_log.entries(
            pr.next_idx,
            self.max_msg_size,
            GetEntriesContext(GetEntriesFor::SendAppend {
                to,
                term: self.term,
                aggressively: !allow_empty,
            }),
        );
        ...
            match (term, ents) {
                (Ok(term), Ok(mut ents)) => {
                    if self.batch_append && self.try_batching(to, msgs, pr, &mut ents) {
                        return true;
                    }
                    self.prepare_send_entries(&mut m, pr, term, ents)
                }
                ...
            }
        ...
        self.send(m, msgs);
        true
    }
    

    prepare_send_entries()는 메시지 객체 m을 MsgAppend 타입으로 만들고 엔트리들을 메시지에 넣어줍니다. 그 후 progress를 업데이트 해 준 후 리턴합니다.

    fn prepare_send_entries(
        &mut self,
        m: &mut Message,
        pr: &mut Progress,
        term: u64,
        ents: Vec<Entry>,
    ) {
        m.set_msg_type(MessageType::MsgAppend);
        m.index = pr.next_idx - 1;
        m.log_term = term;
        m.set_entries(ents.into());
        m.commit = self.raft_log.committed;
        if !m.entries.is_empty() {
            let last = m.entries.last().unwrap().index;
            pr.update_state(last);
        }
    }
    

    그리고 self.send(m, msgs)에서 이 준비한 메시지를 msgs 메시지 큐에 넣어 줍니다.

    fn send(&mut self, mut m: Message, msgs: &mut Vec<Message>) {
        ...
        msgs.push(m);
    }
    

    메시지 큐에 들어간 MsgAppend 메시지는 네트워크 계층을 통해 send_messages()에서 팔로워 노드로 전송되게 됩니다. 따라서, 우리는 팔로워 노드가 MsgAppend 메시지를 받은 후 어떻게 처리하는지를 봐야 합니다.

    팔로워 노드 분석

    다음으로 팔로워 노드에서 일어나는 일을 살펴보면 아래와 같습니다. 팔로워 노드에서 MsgAppend 메시지를 수신했을 때 일어나는 일을 알아보려면 step_follower()를 보면 됩니다.

    fn step_follower(&mut self, mut m: Message) -> Result<()> {
        match m.get_msg_type() {
            ...
            MessageType::MsgAppend => {
                self.election_elapsed = 0;
                self.leader_id = m.from;
                self.handle_append_entries(&m);
            }
            ...
        }
    }
    

    위 코드를 통해 MsgAppend 메시지를 수신한 팔로워 노드가 handle_append_entries()를 호출하고 있는 것을 알 수 있습니다.

    이 함수는 아래처럼 MsgAppendResponse 타입의 메시지인 to_send를 만들고 RaftLog.maybe_append()를 호출합니다.

    pub fn handle_append_entries(&mut self, m: &Message) {
        ...
        let mut to_send = Message::default();
        to_send.to = m.from;
        to_send.set_msg_type(MessageType::MsgAppendResponse);
    
        if let Some((_, last_idx)) = self
            .raft_log
            .maybe_append(m.index, m.log_term, m.commit, &m.entries)
        {
            ...
            // MsgAppend 메시지를 수신
        } else {
            ...
            // MsgAppend 메시지를 거절
        }
        ...
        self.r.send(to_send, &mut self.msgs);
    }
    

    이 함수는 아래처럼 match_term()을 호출해 메시지의 logTerm과 로그 엔트리의 term 값이 같은지 확인하고, find_conflict()를 호출해 로그 엔트리 시퀸스에 충돌이 있는지 검사한 후 문제가 없다고 판단하면 Raft.append()를 호출합니다.

    pub fn maybe_append(
        &mut self,
        idx: u64,
        term: u64,
        committed: u64,
        ents: &[Entry],
    ) -> Option<(u64, u64)> {
        if self.match_term(idx, term) {
            let conflict_idx = self.find_conflict(ents);
            if conflict_idx == 0 {
            } else if conflict_idx <= self.committed {
                fatal!(
                    self.unstable.logger,
                    "entry {} conflict with committed entry {}",
                    conflict_idx,
                    self.committed
                )
            } else {
                let start = (conflict_idx - (idx + 1)) as usize;
                self.append(&ents[start..]);
    
                if self.persisted > conflict_idx - 1 {
                    self.persisted = conflict_idx - 1;
                }
            }
            let last_new_index = idx + ents.len() as u64;
            self.commit_to(cmp::min(committed, last_new_index));
            return Some((conflict_idx, last_new_index));
        }
        None
    }
    

    우리는 이 함수를 본 적이 있습니다. 리더 노드에서 로그 엔트리가 제안되었을 때 RaftLog.append()의 호출 전 마지막으로 호출된 함수였죠.

    이전과 마찬가지로 Raft.append_entry()RaftLog.append()를 호출해 엔트리들을 추가합니다. RaftLog.append()self.unstable.truncate_and_append()에서 Unstable 버퍼에 엔트리들을 append 합니다.

    이것으로 리더에 추가된 로그가 리더 노드에 persist 되고 팔로워 노드에 복사되는 시나리오를 간략하게 알아보았습니다.

    2 - 리더와 팔로워 노드 로그 시퀀스 불일치 시

    우리는 시나리오 1에서 정상적인 상황을 가정하고 코드를 들여다보았습니다. 하지만 실제로는 네트워크 단절 등의 이슈로 리더 노드와 팔로워 노드에 불일치가 생길 수 있습니다. 이번엔 리더 노드와 팔로워 노드 사이에 불일치가 생겼을 때 이를 어떻게 감지하고 해소하는지를 중심으로 다시 한번 코드를 들여다보겠습니다.

    3개의 노드로 이루어진 클러스터가 연속해서 상태 머신을 변경하는 수천 개의 요청을 처리하다가 네트워크 장애가 발생했다고 가정해봅시다.

    장애가 발생한 경우 코드부터 보는 게 아니라 우선 노드들에 출력된 로그들과 persist된 로그 엔트리들, 디버깅 정보들을 먼저 들여다 보며 맥락을 파악하는 것부터 시작해야 하지만, 글이 지나치게 장황해지는 것을 피하기 위해 노드들에 어떤 일들이 발생하고 있는지 대략적으로 파악하게 해 줄 로그만 골라서 분석해보겠습니다.

    우선 3번 노드에선 메시지를 reject 했음을 나타내는 rejected msgApp... 로그를 남기고 있습니다.

    Nov 28 05:30:59.233 DEBG rejected msgApp [logterm: 7, index: 3641] from 2, logterm: Ok(0), index: 3641, from: 2, msg_index: 3641, msg_log_term: 7
    

    위 로그를 통해 3번 노드는 팔로워 노드, 2번 노드가 장애가 발생한 후 새로 선출된 리더 노드이며 3641 번째 엔트리를 복제하려는 MsgAppend 메시지가 거절되었다는 것을 알 수 있습니다.

    이 로그가 어떤 함수에서 출력된 것인지 찾아보면, 시나리오 1에서 살펴 보았었던 handle_append_entries()에서 호출하는 것을 알 수 있는데요. (팔로워가 리더로부터 받은 MsgAppend 메시지를 처리하는 함수)

    pub fn handle_append_entries(&mut self, m: &Message) {
        ...
        let mut to_send = Message::default();
        to_send.to = m.from;
        to_send.set_msg_type(MessageType::MsgAppendResponse);
        ...
        if let Some((_, last_idx)) = self
            .raft_log
            .maybe_append(m.index, m.log_term, m.commit, &m.entries)
        {
            ...
        } else {
            debug!(
                self.logger,
                "rejected msgApp [logterm: {msg_log_term}, index: {msg_index}] \
                from {from}",
                msg_log_term = m.log_term,
                msg_index = m.index,
                from = m.from;
                "index" => m.index,
                "logterm" => ?self.raft_log.term(m.index),
            );
    
            let hint_index = cmp::min(m.index, self.raft_log.last_index());
            let (hint_index, hint_term) =
                self.raft_log.find_conflict_by_term(hint_index, m.log_term);
    
            if hint_term.is_none() {
                fatal!(
                    self.logger,
                    "term({index}) must be valid",
                    index = hint_index
                )
            }
    
            to_send.index = m.index;
            to_send.reject = true;
            to_send.reject_hint = hint_index;
            to_send.log_term = hint_term.unwrap();
        }
    
        to_send.set_commit(self.raft_log.committed);
        self.r.send(to_send, &mut self.msgs);
    }
    

    함수를 살펴보면 이 로그가 출력되었다는 것에서 maybe_append()가 None을 리턴했다는 것, 즉 match_term()이 False를 반환했다는 것을 알 수 있습니다. 이것은 메시지의 logTerm과 3641번 엔트리의 term 값에 불일치가 발생했다는 것을 의미합니다.

    따라서 term을 통해 충돌한 지점을 찾고 (find_conflict_by_term()) 충돌한 지점(hint_index)을 메시지의 reject_hint에 넣어 리더에 MsgAppendResponse 메시지 형태로 되돌려 줍니다.

    그럼 리더는 이 거절된 MsgAppendResponse 메시지를 어떻게 처리할까요?

    메시지를 거절한 리더 노드는 아래와 같은 MsgAppend가 거절했다는 로그를 남기게 됩니다.

    Nov 28 05:30:59.279 DEBG received msgAppend rejection, index: 3641, from: 3, reject_hint_term: 7, reject_hint_index: 3611
    

    따라서 우리가 그 다음으로 들여다 보아야 하는 것은 이 거절된 MsgAppend 메시지를 받은 후 "received msgAppend rejection"를 출력하는 함수입니다.

    이 함수는 handle_append_response()인데요, 함수 자체는 꽤 길지만 MsgAppend 메시지가 reject 되었을 때의 처리만 잘라놓고 보면 그리 길지 않습니다.

    fn handle_append_response(&mut self, m: &Message) {
        let mut next_probe_index: u64 = m.reject_hint;
        ...
        if m.reject {
            debug!(
                self.r.logger,
                "received msgAppend rejection";
                "reject_hint_index" => m.reject_hint,
                "reject_hint_term" => m.log_term,
                "from" => m.from,
                "index" => m.index,
            );
    
            if pr.maybe_decr_to(m.index, next_probe_index, m.request_snapshot) {
                debug!(
                    self.r.logger,
                    "decreased progress of {}",
                    m.from;
                    "progress" => ?pr,
                );
                if pr.state == ProgressState::Replicate {
                    pr.become_probe();
                }
    
                self.send_append(m.from);
            }
            return;
        }
        ...
    }
    

    메시지의 reject_hint를 가져와 next_probe_index로 만들고, Progress.maybe_decr_to()를 호출해 progress를 감소시킵니다. Progress가 Probe 상태임을 표시하고, send_append()를 호출해 다시 MsgAppend 메시지를 보내줍니다.

    💡 ProgressState는 각 노드들의 동기화 진행 상태를 나타내는 enum 입니다. 정상적인 상황, 로그를 복제하고 있는 상태에선 "Replicate" 이며, 복제된 마지막 인덱스를 모르고 있는 팔로워 노드는 조사 중인 상태란 의미로 "Probe", 스냅샷 전송을 통해 팔로워에 로그를 복제 중인 경우 "Snapshot"입니다.

    요약하자면 충돌이 발생하기 전 로그 엔트리의 인덱스 (next_probe_index)를 찾기 위해 해당 노드의 progress를 감소시키고 다시 MsgAppend 메시지를 보낸다는 것입니다. 이 과정은 리더와 팔로워 노드의 Common log prefix를 찾게 될 때까지 반복됩니다.

    Common log prefix를 찾게 되면, 해당 인덱스 이후의 로그 엔트리들은 리더로부터 팔로워로 단방향으로 복제되어 덮어쓰게 됩니다. 이 과정은 maybe_send_append() 함수에서 확인할 수 있습니다.

    아래와 같이 RaftLog.entries를 통해 얻어진 로그 엔트리들이 SendAppend 컨텍스트로 복제됩니다. 이때 max_msg_size는 Config의 max_size_per_msg이며, 이 값의 디폴트 값은 0입니다. RaftLog.entries를 통해 LMDBStorage.entries()의 (RaftLog의 T에 해당하는 persistent 스토리지 타입) 인자의 max_size0이 주어지는데, 이 주석을 토대로 생각해보면 이것의 의미는 따로 설정해 주지 않으면 리더와 팔로워 노드의 로그에 불일치가 발생했을 때 로그 엔트리를 한 개씩 동기화하란 의미임을 알 수 있습니다.

    이후엔 이전 섹션에서 설명한 것과 같이 prepare_send_entries()를 통해 MsgAppend 메시지를 준비하고, Raft.send()를 통해 팔로워 노드로 로그 엔트리들을 복제하기 위한 메시지가 전달됩니다.

    fn maybe_send_append(
        &mut self,
        to: u64,
        pr: &mut Progress,
        allow_empty: bool,
        msgs: &mut Vec<Message>,
    ) -> bool {
        ...
        let mut m = Message::default();
        m.to = to;
        if pr.pending_request_snapshot != INVALID_INDEX {
            ...
        } else {
            let ents = self.raft_log.entries(
                pr.next_idx,
                self.max_msg_size,
                GetEntriesContext(GetEntriesFor::SendAppend {
                    to,
                    term: self.term,
                    aggressively: !allow_empty,
                }),
            );
            ...
            let term = self.raft_log.term(pr.next_idx - 1);
            match (term, ents) {
                (Ok(term), Ok(mut ents)) => {
                    if self.batch_append && self.try_batching(to, msgs, pr, &mut ents) {
                        return true;
                    }
                    self.prepare_send_entries(&mut m, pr, term, ents)
                }
                ...
            }
        }
        self.send(m, msgs);
        true
    }
    

    중간에 많은 로그들이 생략되어 있지만 리더와 팔로워 사이에 3612 번째 엔트리부터 3642번째 엔트리 까지 위와 같은 과정을 거쳐 동기화가 일어난 후, 팔로워 노드로의 복제가 모두 끝나면 해당 팔로워의 ProgressStateReplicate로 변하며, 정상적으로 Heartbeat 메시지를 주고 받기 시작하는 것을 알 수 있습니다.

    2023-11-28 14:30:59,269 - INFO     - Entries [3612, 3643) requested
    Nov 28 05:30:59.269 DEBG Sending from 2 to 3, msg: Message { msg_type: MsgAppend, to: 3, from: 0, term: 0, log_term: 7, index: 3611, entries: [Entry { context: "1810", data: "{'key': '2292', 'value': '1'}", entry_type: EntryNormal, index: 3612, sync_log: false, term: 7 }], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 3, from: 2
    2023-11-28 14:30:59,259 - INFO     - Entries [3613, 3643) requested
    Nov 28 05:30:59.269 DEBG Sending from 2 to 3, msg: Message { msg_type: MsgAppend, to: 3, from: 0, term: 0, log_term: 7, index: 3612, entries: [Entry { context: "1811", data: "{'key': '2294', 'value': '1'}", entry_type: EntryNormal, index: 3613, sync_log: false, term: 7 }], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 3, from: 2
    2023-11-28 14:30:59,259 - INFO     - Entries [3614, 3643) requested
    Nov 28 05:30:59.269 DEBG Sending from 2 to 3, msg: Message { msg_type: MsgAppend, to: 3, from: 0, term: 0, log_term: 7, index: 3613, entries: [Entry { context: "1812", data: "{'key': '2295', 'value': '1'}", entry_type: EntryNormal, index: 3614, sync_log: false, term: 7 }], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 3, from: 2
    2023-11-28 14:30:59,259 - INFO     - Entries [3615, 3643) requested
    Nov 28 05:30:59.269 DEBG Sending from 2 to 3, msg: Message { msg_type: MsgAppend, to: 3, from: 0, term: 0, log_term: 7, index: 3614, entries: [Entry { context: "1813", data: "{'key': '2296', 'value': '1'}", entry_type: EntryNormal, index: 3615, sync_log: false, term: 7 }], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 3, from: 2
    ...
    
    2023-11-28 14:30:59,284 - INFO     - Entries [3641, 3643) requested
    Nov 28 05:30:59.283 DEBG Sending from 2 to 3, msg: Message { msg_type: MsgAppend, to: 3, from: 0, term: 0, log_term: 7, index: 3640, entries: [Entry { context: "1839", data: "{'key': '2457', 'value': '1'}", entry_type: EntryNormal, index: 3641, sync_log: false, term: 7 }], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 3, from: 2
    2023-11-28 14:30:59,284 - INFO     - Entries [3642, 3643) requested
    Nov 28 05:30:59.284 DEBG Sending from 2 to 3, msg: Message { msg_type: MsgAppend, to: 3, from: 0, term: 0, log_term: 7, index: 3641, entries: [Entry { context: "None", data: "None", entry_type: EntryNormal, index: 3642, sync_log: false, term: 12 }], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 3, from: 2
    Nov 28 05:31:01.635 DEBG Sending from 2 to 1, msg: Message { msg_type: MsgHeartbeat, to: 1, from: 0, term: 0, log_term: 0, index: 0, entries: [], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 1, from: 2
    Nov 28 05:31:01.635 DEBG Sending from 2 to 3, msg: Message { msg_type: MsgHeartbeat, to: 3, from: 0, term: 0, log_term: 0, index: 0, entries: [], commit: 3642, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 3, from: 2
    2023-11-28 14:31:01,637
    

    3 - 리더 선출

    시나리오 2에서 네트워크 장애로 인해 리더 선출이 일어났었던 것을 term 값의 증가를 통해 확인할 수 있었는데요, 이 시나리오에선 이 리더 선출 과정에 대해 자세히 들여다보도록 하겠습니다.

    리더에 장애가 생긴 경우 어떤 로그들이 찍히게 되는지 확인하기 위해 간단하게 3개의 노드로 이뤄진 클러스터를 만들고 리더 프로세스를 강제로 종료시켜 본 후 새로 리더로 선출되는 프로세스의 로그를 들여다보겠습니다.

    로그의 내용을 요약해보자면 리더 노드가 종료된 후, 3번 노드에서 선거를 시작하고 후보자(Candidate) 상태로 전이한 후 다른 voter들에게 MsgRequestVote 메시지를 보냅니다. 2번 노드로부터 MsgRequestVoteResponse 메시지를 받고, 자신은 본인에게 투표하기 때문에 과반수 이상의 투표를 받게 되어 새 리더로 선출된 후 term 값을 2로 증가시키고 자신이 리더로 선출되었음을 알리기 위한 특수한 종류의 메시지(Empty MsgAppend)를 보내는 과정이라고 요약할 수 있습니다.

    💡 election_tick 만큼 heartbeat 메시지를 받지 못한 팔로워 노드가 투표를 시작하게 됩니다. 이 때 투표 분열(Split vote)을 방지하기 위해 election_tick은 매번 min_election_tick ~ max_election_tick 사이에서 무작위 값으로 결정됩니다. 따라서 리더 노드가 종료된 후 나머지 두 노드들 중 어떤 노드라도 리더 노드가 될 수 있으며 이는 더 작은 election_tick을 가진 노드로 선출됩니다.

    Nov 29 01:30:30.210 INFO starting a new election, term: 1
    Nov 29 01:30:30.210 DEBG reset election timeout 16 -> 10 at 0, election_elapsed: 0, timeout: 10, prev_timeout: 16
    Nov 29 01:30:30.210 INFO became candidate at term 2, term: 2
    Nov 29 01:30:30.210 DEBG Sending from 3 to 1, msg: Message { msg_type: MsgRequestVote, to: 1, from: 0, term: 2, log_term: 1, index: 3, entries: [], commit: 3, commit_term: 1, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 1, from: 3
    Nov 29 01:30:30.210 DEBG Sending from 3 to 2, msg: Message { msg_type: MsgRequestVote, to: 2, from: 0, term: 2, log_term: 1, index: 3, entries: [], commit: 3, commit_term: 1, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 2, from: 3
    Nov 29 01:30:30.211 INFO broadcasting vote request, to: [1, 2], log_index: 3, log_term: 1, term: 2, type: MsgRequestVote
    2023-11-29 10:30:30,217 - WARNING  - Failed to connect to node 1 elapsed from first failure: 0.0000s. Err message: <AioRpcError of RPC that terminated with:
         status = StatusCode.UNAVAILABLE
         details = "failed to connect to all addresses; last error: UNKNOWN: ipv4:127.0.0.1:60061: Failed to connect to remote host: Connection refused"
         debug_error_string = "UNKNOWN:failed to connect to all addresses; last error: UNKNOWN: ipv4:127.0.0.1:60061: Failed to connect to remote host: Connection refused {created_time:"2023-11-29T10:30:30.216855+09:00", grpc_status:14}"
    >
    2023-11-29 10:30:30,222 - DEBUG    - Node 3 received Raft message from the node 2, Message: Message { msg_type: MsgRequestVoteResponse, to: 3, from: 2, term: 2, log_term: 0, index: 0, entries: [], commit: 0, commit_term: 0, snapshot: Snapshot { data: "None", metadata: Some(SnapshotMetadata { conf_state: Some(ConfState { voters: [], learners: [], voters_outgoing: [], learners_next: [], auto_leave: false }), index: 0, term: 0 }) }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }
    Nov 29 01:30:30.223 INFO received votes response, term: 2, type: MsgRequestVoteResponse, approvals: 2, rejections: 0, from: 2, vote: true
    Nov 29 01:30:30.223 TRCE ENTER become_leader
    Nov 29 01:30:30.223 DEBG reset election timeout 10 -> 17 at 0, election_elapsed: 0, timeout: 17, prev_timeout: 10
    Nov 29 01:30:30.223 TRCE Entries being appended to unstable list, ents: Entry { context: "None", data: "None", entry_type: EntryNormal, index: 4, sync_log: false, term: 2 }
    Nov 29 01:30:30.223 INFO became leader at term 2, term: 2
    Nov 29 01:30:30.223 TRCE EXIT become_leader
    Nov 29 01:30:30.223 DEBG Sending from 3 to 1, msg: Message { msg_type: MsgAppend, to: 1, from: 0, term: 0, log_term: 1, index: 3, entries: [Entry { context: "None", data: "None", entry_type: EntryNormal, index: 4, sync_log: false, term: 2 }], commit: 3, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 1, from: 3
    Nov 29 01:30:30.223 DEBG Sending from 3 to 2, msg: Message { msg_type: MsgAppend, to: 2, from: 0, term: 0, log_term: 1, index: 3, entries: [Entry { context: "None", data: "None", entry_type: EntryNormal, index: 4, sync_log: false, term: 2 }], commit: 3, commit_term: 0, snapshot: Snapshot { data: "None", metadata: None }, request_snapshot: 0, reject: false, reject_hint: 0, context: "None", deprecated_priority: 0, priority: 0 }, to: 2, from: 3
    

    그럼 이제 로그 내용을 바탕으로 코드에서 어떤 일들이 벌어지고 있는지 알아 봅시다.

    우선 "starting a new election" 이라는 로그를 출력하고 있는 함수는 hup() 입니다.

    hup()step()MsgHup 타입, step_follower()MsgTimeoutNow 타입의 메시지에 대한 처리 과정에서 호출됩니다.

    여기서 MsgTimeoutNow 메시지는 Leader election이 아닌, Leader transfer에 사용되는 메시지 타입입니다. 즉 리더가 MsgTransferLeader 메시지를 받게 되면 팔로워들에게 MsgTimeoutNow 타입의 메시지를 전송하게 되고 transfer_leader 플래그를 True로 둔 채 hup() 함수가 실행되게 됩니다. Leader election은 리더 장애 등의 상황으로 리더를 새로 선출하는 과정이지만, Leader transfer은 리더 프로세스가 다른 팔로워 프로세스에게 리더를 양도하는 과정입니다.

    그러므로 우리가 지금 따라가보아야 하는 메시지는 MsgHup 임을 알 수 있습니다. election_tick이 지났는데도 Heartbeat를 받지 못했기 때문에 리더 선출을 시작했다는 점을 통해 MsgHup 메시지를 넣어준 것이 아래 tick_election() 함수인 것을 추측해 볼 수 있습니다.

    RaftNode에서 tick_timer마다 self.raw_node.tick()을 호출했던 것을 기억하시나요? 이 RawNode.tick()을 통해 노드가 election_elapsedrandomized_election_timeout를 경과한 경우 자기 자신에게 MsgHup 메시지를 step 하게 되는 것입니다. (여기서 election_elapsed을 랜덤화하는 것은 모든 노드가 동시에 투표를 시작해 모든 노드가 자기 자신에게 투표하는 상황을 방지하기 위한 것입니다.)

    // raw_node.rs
    pub fn tick(&mut self) -> bool {
        self.raft.tick()
    }
    
    // raft.rs
    pub fn tick(&mut self) -> bool {
        match self.state {
            StateRole::Follower | StateRole::PreCandidate | StateRole::Candidate => {
                self.tick_election()
            }
            StateRole::Leader => self.tick_heartbeat(),
        }
    }
    
    // raft.rs
    pub fn tick_election(&mut self) -> bool {
        self.election_elapsed += 1;
        if !self.pass_election_timeout() || !self.promotable {
            return false;
        }
        
        self.election_elapsed = 0;
        let m = new_message(INVALID_ID, MessageType::MsgHup, Some(self.id));
        let _ = self.step(m);
        true
    }
    
    // raft.rs
    pub fn step(&mut self, m: Message) -> Result<()> {
        ...
        match m.get_msg_type() {
            ...
            MessageType::MsgHup => {
                self.hup(false)
            },
        }
    }
    
    // raft.rs
    pub fn pass_election_timeout(&self) -> bool {
        self.election_elapsed >= self.randomized_election_timeout
    }
    

    hup() 함수는 간단하게 요약해보자면 아래처럼 campaign() 함수를 CAMPAIGN_ELECTION 타입으로 실행합니다.

    fn hup(&mut self, transfer_leader: bool) {
        ...
        info!(
            self.logger,
            "starting a new election";
            "term" => self.term,
        );
    
        ...
        self.campaign(CAMPAIGN_ELECTION);
    }
    

    campaign() 함수는 아래처럼 자신의 상태를 Candidate 상태로 전이시킨 후 투표를 시작합니다.

    우선 self_id는 이름대로 노드 자신의 id입니다. 따라서 self.poll(self_id, vote_msg, true)는 자기 자신에게 투표한다는 의미입니다.

    이 결과가 VoteResult::Won인 경우 그대로 투표에서 승리하며 노드 본인이 리더가 되고 리턴합니다.

    따라서 MsgRequestVote, MsgRequestVoteResponse 등의 메시지는 싱글 노드 클러스터에서 오고 가지 않을 것임을 알 수 있습니다.

    하지만 물론 이 시나리오는 싱글 노드 클러스터가 아니기 때문에 경우에 해당하지 않습니다.

    pub fn campaign(&mut self, campaign_type: &'static [u8]) {
        let (vote_msg, term) = if campaign_type == CAMPAIGN_PRE_ELECTION {
            ...
        } else {
            self.become_candidate();
            (MessageType::MsgRequestVote, self.term)
        };
        let self_id = self.id;
        if VoteResult::Won == self.poll(self_id, vote_msg, true) {
            // We won the election after voting for ourselves (which must mean that
            // this is a single-node cluster).
            return;
        }
        ...
    }
    

    campaign()의 뒷부분을 더 들여다보기 전에 poll()은 어떻게 동작하는 것인지 알아봅시다.

    poll()은 아래처럼 record_vote(), tally_votes()를 호출하는 함수이며, 투표 결과에 따라 투표에서 승리했다면 리더 노드로 전이한 후, 자신이 클러스터의 새 리더라는 것을 브로드캐스팅 (bcast_append()) 합니다.

    투표에서 진 경우 팔로워 노드로 전이하며, 결과가 Pending인 경우 아무일도 수행하지 않고 리턴합니다.

    fn poll(&mut self, from: u64, t: MessageType, vote: bool) -> VoteResult {
        self.prs.record_vote(from, vote);
        let (gr, rj, res) = self.prs.tally_votes();
        if from != self.id {
            info!(
                self.logger,
                "received votes response";
                "vote" => vote,
                "from" => from,
                "rejections" => rj,
                "approvals" => gr,
                "type" => ?t,
                "term" => self.term,
            );
        }
    
        match res {
            VoteResult::Won => {
                if self.state == StateRole::PreCandidate {
                    self.campaign(CAMPAIGN_ELECTION);
                } else {
                    self.become_leader();
                    self.bcast_append();
                }
            }
            VoteResult::Lost => {
                let term = self.term;
                self.become_follower(term, INVALID_ID);
            }
            VoteResult::Pending => (),
        }
        res
    }
    

    record_vote()의 역할은 아주 단순합니다. id 값을 가진 노드가 자기 자신에게 투표했을 때 ProgressTracker의 해시맵 객체 votes에 기록하는 함수입니다.

    pub fn record_vote(&mut self, id: u64, vote: bool) {
        self.votes.entry(id).or_insert(vote);
    }
    

    tally_votes를 봅시다. 해시맵 votes를 통해 자기 자신에게 투표한 노드의 수와 거절한 노드의 수를 세서 튜플 형태로 리턴해주고 있는 것을 볼 수 있습니다.

    💡 "tally"라는 단어는 점수를 세거나 집계하는 행위를 의미합니다. 즉 "tally_votes"는 투표를 세서 집계하는 함수입니다.

    pub fn tally_votes(&self) -> (usize, usize, VoteResult) {
        let (mut granted, mut rejected) = (0, 0);
        for (id, vote) in &self.votes {
            if !self.conf.voters.contains(*id) {
                continue;
            }
            if *vote {
                granted += 1;
            } else {
                rejected += 1;
            }
        }
        let result = self.vote_result(&self.votes);
        (granted, rejected, result)
    }
    

    투표 결과를 어떻게 판단하는지 들여다볼까요?

    조인트 쿼럼의 경우 두 쿼럼 (Incoming quorum, Outgoing quorum)의 동의를 모두 얻어야 투표에서 승리할 수 있습니다.

    따라서 우리는 아래 세 vote_result() 함수를 들여다봐야 합니다.

    tracker.rs에선 해시맵 votes를 통해 노드 id가 자신에게 투표했는지 알 수 있게 해 주는 콜백 함수 check를 인자로 넘겨 줍니다.

    joint.rs에선 두 구성에서 모두 승리한 경우에만 VoteResult::Won를 리턴하고, 한 쪽에서라도 투표에서 졌다면 VoteResult::Lost를 리턴합니다. 그 외의 경우인 경우 VoteResult::Pending를 리턴합니다.

    득표 수를 실제로 카운트하는 작업은 majority.rsvote_result()에서 진행됩니다.

    클러스터의 voter들 중 자기 자신에게 투표한 노드의 수와 투표하지 않은 노드의 수를 세서 과반수보다 많은 노드들이 동의한 경우 VoteResult::Won, 과반수 이상의 투표를 얻지 못했지만 응답을 보내주지 못한 노드까지 포함시켰을 때 과반수를 넘는다면 VoteResult::Pending, 그 이외의 경우 VoteResult::Lost를 반환합니다.

    // tracker.rs
    pub fn vote_result(&self, votes: &HashMap<u64, bool>) -> VoteResult {
        self.conf.voters.vote_result(|id| votes.get(&id).cloned())
    }
    
    // joint.rs
    pub fn vote_result(&self, check: impl Fn(u64) -> Option<bool>) -> VoteResult {
        let i = self.incoming.vote_result(&check);
        let o = self.outgoing.vote_result(check);
        match (i, o) {
            // It won if won in both.
            (VoteResult::Won, VoteResult::Won) => VoteResult::Won,
            // It lost if lost in either.
            (VoteResult::Lost, _) | (_, VoteResult::Lost) => VoteResult::Lost,
            // It remains pending if pending in both or just won in one side.
            _ => VoteResult::Pending,
        }
    }
    
    // majority.rs
    pub fn vote_result(&self, check: impl Fn(u64) -> Option<bool>) -> VoteResult {
        ...
    
        let (mut yes, mut missing) = (0, 0);
        for v in &self.voters {
            match check(*v) {
                Some(true) => yes += 1,
                None => missing += 1,
                _ => (),
            }
        }
        let q = crate::majority(self.voters.len());
        if yes >= q {
            VoteResult::Won
        } else if yes + missing >= q {
            VoteResult::Pending
        } else {
            VoteResult::Lost
        }
    }
    
    // util.rs
    pub fn majority(total: usize) -> usize {
        (total / 2) + 1
    }
    

    투표 과정이 votes 해시맵을 기반으로 어떻게 진행되는지 살펴보았습니다. 하지만 이 과정을 밟기 전 MsgRequestVote, MsgRequestVoteResponse 메시지를 통해 이 해시맵이 적절하게 업데이트 되어야 합니다.

    따라서 campaign() 함수를 계속 따라가 보도록 합시다.

    campaign() 함수가 MsgRequestVote 타입의 메시지를 만들어 voter들에게 전송하고 있다는 것을 알 수 있습니다.

    따라서 그 다음으론 MsgRequestVote 메시지의 핸들러를 따라가 봅시다.

    pub fn campaign(&mut self, campaign_type: &'static [u8]) {
        let (vote_msg, term) = if campaign_type == CAMPAIGN_PRE_ELECTION {
            ...
        } else {
            self.become_candidate();
            (MessageType::MsgRequestVote, self.term)
        };
        let self_id = self.id;
        if VoteResult::Won == self.poll(self_id, vote_msg, true) {
            // We won the election after voting for ourselves (which must mean that
            // this is a single-node cluster).
            return;
        }
        // Only send vote request to voters.
        for id in self.prs.conf().voters().ids().iter() {
            if id == self_id {
                continue;
            }
            ...
            let mut m = new_message(id, vote_msg, None);
            m.term = term;
            m.index = self.raft_log.last_index();
            m.log_term = self.raft_log.last_term();
            m.commit = commit;
            m.commit_term = commit_term;
            ...
            self.r.send(m, &mut self.msgs);
        }
        ...
    }
    

    얼핏 보면 복잡해 보이지만 결국 MsgRequestVote 메시지의 핸들러가 하는 일은 이 투표에 동의하거나 동의하지 않는다는 메시지를 만들어 전송해주는 일입니다.

    vote_resp_msg_type에 따라 우리가 보낸 메시지 타입은 MsgRequestVote이므로 응답 메시지의 타입은 MsgRequestVoteResponse가 될 것입니다. (이 글에선 prevote 알고리즘에 대한 설명은 생략합니다)

    그럼 노드가 언제 투표에 동의하고 언제 동의하지 않는지 살펴봅시다. 주석과 함께 코드를 찬찬히 살펴보면 투표에 동의하기 위해선 아래 세 조건이 만족되어야 함을 알 수 있습니다.

    1. can_votetrue (이미 해당 노드에 투표한 경우이거나, 이번 term에서의 leader_id를 모르고 아직 투표 하지 않은 경우)

    2. self.raft_log.is_up_to_datetrue (메시지의 term 값이 RaftLog.last_term 보다 크거나, 만약 같다면 RaftLog.last_index보다 메시지의 인덱스가 큰 경우)

    3. 메시지의 인덱스가 RaftLog.last_index 보다 크거나, 더 높은 우선 순위를 갖는 경우

    이 세 조건이 만족된 경우 Vote에 동의하며 만족되지 않는 조건이 있다면 Vote를 거절한다는 메시지를 보냅니다.

    그럼 이제 MsgRequestVoteResponse의 수신부로 넘어가봅시다.

    // raft.rs
    pub fn step(&mut self, m: Message) -> Result<()> {
        ...
        match m.get_msg_type() {
            MessageType::MsgRequestVote => {
                // We can vote if this is a repeat of a vote we've already cast...
                let can_vote = (self.vote == m.from) ||
                    // ...we haven't voted and we don't think there's a leader yet in this term...
                    (self.vote == INVALID_ID && self.leader_id == INVALID_ID)
      
                // ...and we believe the candidate is up to date.
                if can_vote
                    && self.raft_log.is_up_to_date(m.index, m.log_term)
                    && (m.index > self.raft_log.last_index() || self.priority <= get_priority(&m))
                {
                    self.log_vote_approve(&m);
                    let mut to_send =
                        new_message(m.from, vote_resp_msg_type(m.get_msg_type()), None);
                    to_send.reject = false;
                    to_send.term = m.term;
                    self.r.send(to_send, &mut self.msgs);
                    if m.get_msg_type() == MessageType::MsgRequestVote {
                        // Only record real votes.
                        self.election_elapsed = 0;
                        self.vote = m.from;
                    }
                } else {
                    self.log_vote_reject(&m);
                    let mut to_send =
                        new_message(m.from, vote_resp_msg_type(m.get_msg_type()), None);
                    to_send.reject = true;
                    to_send.term = self.term;
                    let (commit, commit_term) = self.raft_log.commit_info();
                    to_send.commit = commit;
                    to_send.commit_term = commit_term;
                    self.r.send(to_send, &mut self.msgs);
                    self.maybe_commit_by_vote(&m);
                }
            }
        }
    }
    
    // raft.rs
    pub fn vote_resp_msg_type(t: MessageType) -> MessageType {
        match t {
            MessageType::MsgRequestVote => MessageType::MsgRequestVoteResponse,
            MessageType::MsgRequestPreVote => MessageType::MsgRequestPreVoteResponse,
            _ => panic!("Not a vote message: {:?}", t),
        }
    }
    
    // raft_log.rs
    pub fn is_up_to_date(&self, last_index: u64, term: u64) -> bool {
        term > self.last_term() || (term == self.last_term() && last_index >= self.last_index())
    }
    

    MsgRequestVoteResponse 메시지 핸들러는 매우 단순합니다!

    우리가 아까 봤었던 poll() 함수를 호출하여 votes 해시맵을 업데이트 하고 투표 결과가 결정된 경우 StateRole을 업데이트 합니다.

    fn step_candidate(&mut self, m: Message) -> Result<()> {
        match m.get_msg_type() {
            ...
            MessageType::MsgRequestVoteResponse => {
                ...
                self.poll(m.from, m.get_msg_type(), !m.reject);
                self.maybe_commit_by_vote(&m);
            }
        }
    }
    

    정리

    이 글에선 raft-rs에서 사용되는 타입들을 바탕으로 코드 아키텍쳐를 살펴본 후, 세 가지 기초적인 시나리오를 바탕으로 raft 구현체의 코드를 따라가며 분석해보았습니다. 이 글이 raft 모듈에 대한 이해를 넓히는데 도움이 되었기를 바랍니다. 다음 글에선 좀 더 다양한 시나리오들을 통해 raft 구현체의 작동 방식을 보다 깊이 살펴보도록 하겠습니다.

    감사합니다 😊

    29 March 2024

  • 2024 GTC 이벤트 실시간 랭킹: GraphQL Subscription 활용법

    By 김수진

    래블업은 2024년 GTC 이벤트를 기념하여 특별한 이벤트를 개최했다. 참가자들은 래블업이 제공한 LLM 모델을 이용하여 주어진 이미지와 유사한 이미지를 생성했고, 높은 점수를 받은 참가자 중에서 추첨을 통해 무려 NVIDIA RTX 4090 그래픽 카드를 증정했다. 🫢
    이번 포스트에서는 이벤트 페이지 중 참가자들의 점수를 실시간으로 확인할 수 있게 해주는 리더 보드 페이지에 사용된 GraphQL의 subscription 기능에 대해 알아보고자 한다.

    GTC24 이벤트 페이지

    Subscription 이란?

    클라이언트가 서버 측 이벤트 스트림으로부터 데이터를 구독하는 메커니즘이다.

    데이터가 실시간으로 바뀌는 경우, 예를 들어 실시간 로그나 채팅 어플리케이션 등을 구현할 때, 서버에서 업데이트를 푸시해주면 바로 반영할 수 있다.

    subscription은 필요한 정보가 서버에서 변경될 때만 데이터를 보내준다. 따라서 데이터 변경이 빈번하지 않은 경우, subscription은 데이터 트래픽을 줄이고, 이에 따른 비용 절감 효과도 있을 수 있다.

    비슷한 개념으로 GraphQL 의 network-only fetchPolicy 옵션을 주고 Query 를 요청해서 매번 최신 정보를 가져올수 있지만 subscription과 차이가 있다. Query 는 클라이언트가 데이터를 필요로 할 때마다 항상 서버에 요청하며 항상 최신 데이터를 보장하지만, 각 요청에 대한 네트워크 비용을 수반한다. 그래서 어떤 버튼을 클릭했을 때 항상 최신의 결과를 보여주도록 보장하기 위해 fetchPolic 를 network-only 로 설정하는 것은 괜찮지만, 주식 거래 창과 같이 업데이트가 빈번한 데이터를 가져오기 위해 query를 사용한다면 네트워크 비용이 상당해진다.

    결론적으로, 목표 응용 프로그램의 요구 사항, 사용자 수, 데이터의 업데이트 빈도 등에 따라 subscription 을 사용할지, query 를 사용할지 결정해야 한다.

    사용 방법

    subscription 정의하기

    사용 방법은 query 와 유사한데, 키워드만 subscription 을 사용해주면 된다.

      const leaderboardSubscriptions = graphql`
        subscription Ranking_leaderboardSubscription {
          leaderboard {
            submissions {
              id
              name
              score
              imageUrl
            }
            lastUpdatedAt
          }
        }
      `;
    

    leaderboard 스트림에서 이벤트가 발생할 때마다 애플리케이션에 알림이 전송되고, 클라이언트에서는 업데이트된 결과를 얻을 수 있다.

    그럼 다음과 같은 결과를 얻을 수 있다.

    leaderboard: {
    	submissions: [
    		{
        	"id": "76293167-e369-4610-b7ac-4c0f6aa8f699",
    	    "name": "test",
        	"score": 0.5910864472389221,
    	    "imageUrl": "<IMAGE_URL>"
    		},
        ],
    	lastUpdatedAt: 1710176566.493705
    }
    

    subscribe

    실시간 랭킹을 보여주기 위해 해당 페이지에 들어갈 때 subscribe 를 호출하고, 다른 페이지로 넘어갈 경우, dispose 를 호출하여 unsubscribe 하기 위해 useEffect를 사용했다.

    import { useEffect } from 'react';
    import { requestSubscription } from 'react-relay';
    
    useEffect(() => {
      const subscriptionConfig = {
        subscription: leaderboardSubscriptions,
        variables: {},
        onNext: (response: any) => {
          setLeaderboard(response.leaderboard.submissions); // 미리 정의된 state
        },
        onError: (error: any) => {
          console.error('Leaderboard subscription error', error);
        },
      };
      const { dispose } = requestSubscription(
        RelayEnvironment, // 아래 '설정 방법' 참고
        subscriptionConfig,
      );
      return () => {
        dispose();
      };
    }, []); // 빈 의존성 배열을 통해 컴포넌트가 마운트되거나 언마운트될 때만 이 부분이 실행되도록 함
    

    requestSubscription

    • 메소드는 반환 값으로 Disposable 오브젝트를 제공한다.
    • Disposable 오브젝트에는 구독을 취소하는 dispose 메서드가 포함되어 있다.

    onNext

    • subscription으로 데이터가 업데이트되면, 미리 정의해두었던 state 를 업데이트하여 실시간 랭킹을 보여주도록 하였다.
    • onNext, onError 외에도, subscription 이 끝날 때 호출되는 onCompleted, 서버 응답을 기반으로 메모리 내 릴레이 저장소를 업데이트를 위한 updater 등 다양한 설정들이 있다. 자세한 설명은 이 링크를 참고하길 바란다.

    dispose

    • useEffect hook 내에서 반환하는 cleanup 함수를 통해 컴포넌트가 언마운트될 때 dispose 메소드를 호출하여 구독을 종료하게 된다.

    설정 방법 (+Relay)

    Relay document 에 따르면, GraphQL subscriptions 은 WebSockets 으로 통신하며, graphql-ws를 사용해서 network를 설정하는 방법은 다음과 같다. (subscriptions-transport-ws를 사용하는 방법도 있지만 deprecated 되었으니 패스하기로 한다.)

    import { ExecutionResult, Sink, createClient } from 'graphql-ws';
    import {
      Environment,
      Network,
      RecordSource,
      Store,
      SubscribeFunction,
      RelayFeatureFlags,
      FetchFunction,
      Observable,
      GraphQLResponse,
    } from 'relay-runtime';
    import { RelayObservable } from 'relay-runtime/lib/network/RelayObservable';
    import { createClient } from 'graphql-ws';
    
    const wsClient = createClient({
      url: GRAPHQL_SUBSCRIPTION_ENDPOINT,
      connectionParams: () => {
        return {
          mode: 'cors',
          credentials: 'include',
        };
      },
    });
    
    const subscribeFn: SubscribeFunction = (operation, variables) => {
      return Observable.create((sink: Sink<ExecutionResult<GraphQLResponse>>) => {
        if (!operation.text) {
          return sink.error(new Error('Operation text cannot be empty'));
        }
        return wsClient.subscribe(
          {
            operationName: operation.name,
            query: operation.text,
            variables,
          },
          sink,
        );
      }) as RelayObservable<GraphQLResponse>;
    };
    
    // Export a singleton instance of Relay Environment
    // configured with our network function:
    export const createRelayEnvironment = () => {
      return new Environment({
        network: Network.create(fetchFn, subscribeFn),
        store: new Store(new RecordSource()),
      });
    };
    
    export const RelayEnvironment = createRelayEnvironment();
    

    wsClient

    • url 에는 GraphQL 서버의 웹소켓 URL 을 입력한다.
    • credentials 설정은 connectionParams를 통해 가능하다.

    subscribeFn

    • Observable의 구독 동작을 정의한다.
    • if (!operation.text) { ... } 에서 쿼리 문자열의 유효성을 확인하여 유효하지 않은 경우, 오류를 발생시키고 실행을 중단한다.
    • 마지막으로 return wsClient.subscribe( ... ) 코드는 웹소켓 클라이언트를 사용하여 실제로 subscription을 구독하고, GraphQL operation의 payload를 sink (즉, Observer) 에게 전달한다.
    • 간단히 말해, 이 함수는 GraphQL subscription 요청을 처리하고, subscription 이벤트가 발생할 때마다 해당 결과를 Observable 스트림에 push하는 역할을 한다고 볼 수 있다.

    createRelayEnvironment

    • 새로운 Relay Environment 를 생성하고 반환한다.
    • Relay의 Environment는 다른 고수준 Relay 객체들과 네트워크 계층, 캐시등을 관리하는 컨테이너이다.
    • GraphQL query/mutation 요청을 처리하는 함수를 fetchFn, subscription 요청을 처리하는 함수를 subscribeFn 에 할당한 상태이다.
    • 캐시 데이터를 저장하고 관리하는 Relay Store를 생성하기 위해 RecordSource 저장소를 사용했다.

    RelayEnvironment

    • createRelayEnvironment 함수를 호출함으로써 RelayEnvironment 를 초기화하고, 이를 추후 다른 곳에서 임포트해 사용할 수 있게 내보내는 역할을 한다.
    • 이렇게 구성된 RelayEnvironment는 주로 QueryRenderer, useLazyLoadQuery, commitMutation 등에서 사용된다.

    CORS 에러

    처음에 GraphQL 서버의 웹소켓 URL을 설정하기 위해 서버측에서 사용하는 config.toml 파일을 읽어와서 주소를 설정했다. 그런데 자꾸 CORS 에러가 나면서 요청 보낼 때마다 Unauthorized 가 뜨는 것이다. 그래서 이것저것 삽질을 한 결과, 동료분의 도움으로 해결할 수 있었다. (정말 감사합니다 🥹🙏)

    해결 방법은 바로 http-proxy-middleware 를 사용해 setupProxy 를 설정하는 것!

    create-react-app manual에서도 알 수 있듯이, 일반적으로 프론트엔드와 백엔드가 분리된 개발 환경에서 CORS 이슈를 방지하기 위한 설정이나, 개발 서버에서 실제 서버의 특정 경로에 대한 요청을 프록시하기 위해 setupProxy 를 설정할 수 있다.

    코드는 다음과 같다.

    const { createProxyMiddleware } = require('http-proxy-middleware');
    
    module.exports = function (app) {
      app.use(
        createProxyMiddleware('/graphql', {
          target: 'http://127.0.0.1:9220',
          changeOrigin: true,
          followRedirects: true,
          ws: true,
        }),
      );
    };
    

    createProxyMiddleware('/graphql', { ... })

    • '/graphql'에서 발생하는 모든 HTTP 요청을 미들웨어가 처리하도록 설정한다.

    target: 'http://127.0.0.1:9220'

    • 프록시 된 요청이 전달될 서버의 주소를 설정한다. 여기선 9220번 포트로 설정했다.

    changeOrigin: true

    • 요청의 호스트 헤더를 target의 호스트로 변경한다. CORS 이슈를 해결하기 위해 사용한다.

    followRedirects: true

    • 이 설정은 서버가 요청에 대해 리다이렉트 응답을 보냈을 때 그 리다이렉트를 프록시가 따르도록 한다.

    ws: true

    • 이 설정은 웹소켓 프록시를 활성화한다. 클라이언트와 서버 간의 웹소켓 연결도 이 프록시를 통해 전달되며, subscribe를 위해 true로 설정하였다.

    리더보드 페이지

    기나긴 삽질 끝에 마침내 완성한 리더보드 페이지! 🎉 참여해 주신 모든 분들께 깊은 감사를 드립니다. 🙇🏻‍♀️

    결론

    GraphQL 의 subscription 을 사용하여 실시간 랭킹 같은 기능을 구현할 수 있었다. CORS 때문에 설정 방법에 애를 먹긴 했지만, 사용 방법은 query 를 쓸 때와 크게 다르지 않아 어렵지 않았다.

    subscription 은 실시간 업데이트효율성이 가장 큰 장점이 아닐까 생각한다. 서버로부터 실시간으로 데이터를 수신하므로 사용자는 항상 최신 상태를 볼 수 있으며, 필요한 데이터가 변경될 때만 업데이트를 받기 때문에, 자주 변경되지 않은 데이터에 대해서는 서버 요청을 최소화할 수 있다.

    하지만 웹소켓 또는 유사한 실시간 프로토콜을 구현해야 하며, 클라이언트와 서버 사이의 연결 상태를 관리하는 로직도 필요하기에 복잡하긴 하다. 이 글에서 다루진 않았지만, subscription 을 위해 서버측에서 추가 작업이 필요하다. 그리고 실시간 연결을 필요로 하기 때문에 그에 따른 서버 자원과 클라이언트의 리소스 소모가 있을 수 있다.

    따라서 어떠한 방법이 비용이나 성능 면에서 더 효율적인지는 애플리케이션의 특성, 데이터의 갱신 빈도, 사용자의 동시 접속자 수 등 여러 요소에 따라 달라질 수 있으니 적절히 판단하여 사용하길 바란다.

    references

    • https://relay.dev/docs/v10.1.3/subscriptions/
    • https://relay.dev/docs/guided-tour/updating-data/graphql-subscriptions/#configuring-the-network-layer
    • https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
    • https://github.com/enisdenjo/graphql-ws
    • https://github.com/apollographql/subscriptions-transport-ws
    • https://graphql.org/blog/subscriptions-in-graphql-and-relay
    • https://create-react-app.dev/docs/proxying-api-requests-in-development

    28 March 2024

  • Backend.AI 와 Tool LLM 의 만남 : Tool 과 AI 의 협업 혁명 - 3부

    By Sergey Leksikov

    3부. 학습 및 서버 LLLM 없이 로컬에서 몇 줄의 코드만으로 자체 API 검색기 및 질문 답변 시스템을 만들기

    앞서 1부에서는 도구 LLM과 그 사용법에 대해 설명했습니다. 2부에서는 Backend.AI에서 Gorilla LLM을 실행하는 방법을 설명했습니다. 3부에서는 GPU를 사용할 수 없지만 API와 관련하여 도움과 지원을 받고자 하는 경우에 대해 이야기 해 보겠습니다.

    우리에게 Backend.AI가 있고, 질문과 답변 방식을 통해 보다 인터랙티브한 방식으로 Backend.AI REST API 및 Functional API에 대한 정보를 얻고 싶다고 가정해 보겠습니다. REST API의 예는 이 문서에서 설명할 수 있습니다(https://docs.backend.ai/en/latest/manager/rest-reference/index.html).

    그림 1. Backend.AI REST API 도큐먼트

    추가적으로 Backend.AI REST API 설명서를 openapi.json 형식으로 내보낼 수 있습니다:

    그림 2. Backend.AI openai.json

    Another source of BackendAI API is functional API defined in Backend.AI Client. We want to know how to interact with Backend.AI and which parts of code are responsible. The client code repository is responsible with managing and interacting with cloud and computing environment:

    Steps to make a Question Answering API system

    1. 로컬 PC 환경에서 https://github.com/lablup/backend.ai/tree/main/src/ai/backend/client 의 Backend.AI 클라이언트를 로컬로 설정하고 새 디렉토리 bai-dev/src/ai/backend/client/gpt_api_client 를 생성해 보겠습니다.

    그림 3. gpt_api_client 디렉토리 위치

    1. vector_data 아래에 REST API 문서: openapi.json 을 저장할 data1/ 그리고 API 질의응답 수행을 위해 선택된 B.AI 클라이언트 파일을 저장할 data2/ 두 개의 하위 디렉토리를 생성하겠습니다.

    그림 4. openapi.json 및 클라이언트 함수 코드 파일이 포함된 데이터 디렉터리 개요

    1. 파이썬 라이브러리인 LlamaIndex 를 설치합니다. Pip install llama-index 참고로 LlamaIndex 는 Meta 의 LLaMA 언어 모델과는 관련이 없습니다. LlamaIndex 는 검색을 위해 문서를 효율적으로 처리하고 저장하기 위한 데이터 구조 및 메서드와 관련이 있습니다.

    2. API와 코드 파일을 임베디드 벡터로 변환하고 LLamaIndex를 사용하여 벡터 데이터베이스에 저장해 보겠습니다. 로컬 PC에서 VSCode에 통합된 Jupyter Notebook 대화형 환경을 사용해 보겠습니다.

    그림 5. 주피터 노트북 대화형 환경. data/ 디렉터리에서 openapi.json을 로드. 이후 쿼리 엔진에서 벡터 인덱스를 통해 질문.

    1. 코드 함수를 사용하여 data2/ 디렉토리를 벡터화 합니다.

    그림 6. B.AI 클라이언트의 코드 파일을 이용하여 data2/ 디렉토리 로딩. 인덱스로 벡터화 후 질답 엔진 생성.

    객체를 저장하고 직렬화 하는데 일반적으로 사용되는 파이썬 Pickle 또는 Joblib 라이브러리를 사용하여 joblib.dump(index, "rest_api_index.joblib") 그리고 joblib.dump(index, "functional_index.joblib") 인덱스를 모두 저장하고 시스템에 로드가 가능합니다.

    1. 주피터 노트북 환경은 이미 대화형 방식으로 질문하고 답변을 받을 수 있는 기능을 제공하고 있습니다. 또한 저장된 벡터 인덱스를 FastAPI 서버에 불러와서 웹을 통해 질문에 답할 수 있습니다. 이전 2부에서는 Gorilla LLM으로 계산 세션을 설정했습니다. 이전 데모에서도 여전히 FastAPI 서버를 사용한 계산 세션이 있습니다.

    2. Backend.AI 클라우드 세션에서 rest_api_index.joblibfunctional_index.joblib 파일을 api_helper/ vFolder 로 전송 해 보겠습니다.

    3. server.py 파일에서 벡터 인덱스를 로딩하고 쿼리 엔진을 정의합니다.

    Figure 7. server.py 에서 인덱스 및 쿼리 엔진 파일 정의

    1. 각 쿼리 엔진에 대해 FastAPI 엔드포인트를 지정합니다.

    그림 8. REST 및 함수형 API 검색을 위한 코드 스니펫

    1. curl 명령을 사용하여 로컬 PC에서 서버 응답을 테스트합니다. 특정 엔드포인트에서 서버가 쿼리를 받으면 사용자로부터 응답을 받습니다.
    curl -X POST -H "Content-Type: application/json" -d '{"instruction":"Create a new session"}' http://127.0.0.1:8000/rest_api
    

    그림 9. curl 명령의 응답. 예제 1

    curl -X POST -H "Content-Type: application/json" -d '{"instruction":"Create a new session"}' http://127.0.0.1:8000/functional
    

    그림 10. curl 명령의 응답. 예제 2

    또한 사용자 입력을 받아 해당 엔드포인트로 전송하고 응답을 받는 웹 앱을 만들 수도 있습니다.

    그림 11. Backend.AI REST 및 함수형 API를 통한 질문 답변을 위한 웹 앱 프로토타입. 예제 1

    그림 12. Backend.AI REST 및 함수형 API를 통한 질문 답변을 위한 웹 앱 프로토타입. 예제 2

    맺음말

    3부에서는 오픈 소스 파이썬 라이브러리 LLamaIndex를 사용하여 로컬에서 질문-응답 시스템을 만드는 방법을 보여드리면서 문서와 Backend.AI 코드를 벡터 형식으로 변환하는 방법을 알아보았습니다. 질문과 답변은 Visual Studio Code 에서 플러그인을 통해 지원하는 주피터 노트북 환경에서 대화형 방식으로 수행할 수 있습니다. 또한 이러한 벡터 인덱스를 Gorilla LLM API 튜닝 모델이 서버로 있는 Backend.AI 클라우드 환경으로 옮기기로 결정했습니다. 그런 다음 네트워크를 통해 사용자를 지원하기 위해 API 질의응답 웹 앱을 구현했습니다.

    참고 자료:

    • LLama Index. https://docs.llamaindex.ai/en/stable/

    Backend.AI API 도우미 및 Gorilla LLM 데모 동영상입니다:

    30 January 2024

  • Backend.AI 와 Tool LLM 의 만남 : Tool 과 AI 의 협업 혁명 - 2부

    By Sergey Leksikov

    2부. Backend.AI 로 Gorilla LLM 모델 서빙하기

    이전 글에서는 Tool LLM의 기능과 사용법에 대해 설명했습니다. 이번 글에서는 Backend.AI 데스크탑 앱을 사용하면서 Backend.AI 클라우드에서 Gorilla LLM 모델을 실행하는 방법을 단계별로 데모 해 보겠습니다.

    그림 1. MacO에 설치된 Backend.AI 데스크탑 앱

    1. 시작 버튼을 누르면 세션 생성 메뉴가 나타납니다.

    그림 2. 새 세션 시작 화면

    1. NGC-Pytorch 23.07 이미지를 선택합니다.

    2. 모델 파일이 포함된 작업 디렉토리 vFolder를 첨부합니다. 디렉토리명 예 api_helper/

    그림 3. vFolder 첨부 화면

    1. 128 GB RAM 및 5 fGPU 크기의 리소스를 선택합니다.

    그림 4. 리소스 선택 화면

    1. Visual Studio Code 데스크탑 환경을 선택합니다.

    그림 5. IDE 환경 선택 화면

    1. /home/work/api_helper/ 디렉토리에 server.py 파일을 생성합니다.

    2. requirements.txt 파일을 생성합니다.

    그림 6. requirements.txt 파일 내용

    설치를 위해 다음 명령을 실행하세요: pip install -r requirements.txt

    그림 7. 설치 명령 실행

    1. server.py 파일을 추가하고 트랜스포머 라이브러리를 사용하여 토큰화 및 모델 로더를 정의합니다.

    그림 8. server.py 코드 스니펫

    1. 서버 IP 주소와 포트 번호를 명시합니다.

    그림 9. 서버 IP 주소 및 포트 번호 명시

    1. 다음 명령으로 모델을 실행합니다: python server.py

    그림 10. server.py 시작

    1. 생성된 서버에 접속합니다.

    VSCode는 기기에서 Backend.AI 클라우드 서버로 포트 터널링 세션을 자동으로 생성합니다. 로컬 호스트 주소에 액세스하여 서버 상태를 확인할 수 있으며, 요청은 Backend.AI 클라우드로 터널링됩니다. 또한 필요에 따라 다른 사용자 지정 엔드포인트를 정의할 수 있습니다.

    그림 11. 서버 실행 로그

    그림 12. VSCode 포트 포워딩 구성

    그림 13. 서버의 루트에 액세스하기

    여기까지는 Backend.AI Cloud에 계산 세션을 생성하고, api_helper/ vFolder 디렉터리에 요구사항.txt 파일과 server.py를 첨부했습니다. 그런 다음 HuggingFace 리포지토리에서 Gorilla LLM을 다운로드하고 추론/api .endpoint를 사용하여 계산 세션 메모리로 로드하는 FastAPI 서버를 시작합니다.

    1. API 추론 테스트 로컬 컴퓨터 명령줄에서 curl 요청을 생성하여 Gorilla LLM의 API 추론을 테스트할 수 있습니다:
    curl -X POST -H "Content-Type: application/json" -d '{"text":"Object detection on a photo. <<<api_domain>>>:"}' http://127.0.0.1:8000/inference
    

    그림 14. curl 명령 예제

    그림 15. 요청 수신 후 서버의 GPU 워크로드

    그림 16. 요청 수신 및 결과 인쇄에 대한 서버 로그

    1. UI 웹 앱을 정의합니다. 어떤 웹 기술이라도 사용하여 더 나은 방식으로 결과를 표시할 수 있는 UI 앱을 만들 수 있습니다. 예를 들어 html 및 JavaScript 파일을 사용하여 server.py의 루트 아래에 있는 정적 디렉터리에 배치한 다음 웹 앱의 엔드포인트를 정의할 수 있습니다.

    그림 17. FastAPI 서버에 html 웹 앱 추가 예시

    1. Gorilla LLM 웹 앱 프로토타입 - API 질문 답변 및 코드 생성을 위해 API가 튜닝된 대규모 언어 모델입니다.

    그림 18. Gorilla LLM 웹 앱 프로토타입. 예제 1

    그림 19. Gorilla LLM 웹 앱 프로토타입. 예제 2

    맺음말

    고릴라 LLM이 제공하는 몇 가지 어려움에도 불구하고, 자체 API로 튜닝된 LLM은 큰 잠재력과 가능성을 가지고 있습니다. 상용 대형 모델보다 더 정확한 파라미터와 함수 호출로 가장 최신의 결과를 제공할 수 있으며, API를 통한 질문 답변, 코드 자동 완성, API 코드 실행과 같은 작업에 유용하게 사용할 수 있기 때문입니다.

    한계점 및 어려움:

    Gorilla LLM 모델을 서버로 전송하는 동안 다음과 같은 문제를 고려해야 했습니다:

    • 모델이 예상과 다른 형식의 응답을 생성할 수 있음
    • 동일한 질문에 대해 모델이 다른 결과를 생성할 수 있음
    • LLM 응답 파싱 및 렌더링
    • 중복된 문장과 줄 제거하기

    29 January 2024

  • Backend.AI 와 Tool LLM 의 만남 : Tool 과 AI 의 협업 혁명 - 1부

    By Sergey Leksikov

    1부. LLM 와 Tool 의 협업 소개

    미래의 AI 기술 기능을 지금 바로 사용할 수 있다면 어떨까요? 아마도 직장에서 퇴근하는 길에 AI 어시스턴트에게 집에 도착하기 전에 집안의 에어컨을 켜달라고 요청할 수 있을 것입니다. 동시에 휴가를 계획하고 있는데 선택의 여지가 거의 없는 상황에서 AI 모델에게 호텔 예약을 대신 해달라고 요청할 수도 있습니다. 모델이 여행을 예약하면 클라우드 제공업체로부터 딥러닝 모델의 학습 진행 상황에 대한 알림을 받게 됩니다. AI 어시스턴트에게 성능 정확도를 위해 특정 값을 목표로 삼고 다른 매개변수 세트를 사용하여 다른 세션을 실행하도록 요청합니다. 이러한 미래적인 시나리오가 현재에 어떻게 실현될 수 있을까요?

    이러한 종류의 LLM과 실제 세계의 상호작용은 애플리케이션 프로그래밍 인터페이스(API)를 통해 가능합니다. API 데이터 세트에서 미세 조정된 특정 도구의 거대 언어 모델(LLM)은 특정 API로 사용자의 쿼리에 응답할 수 있으며, 해당 API는 프로그램이나 함수를 호출하여 실제 세계에 영향을 미칠 수 있습니다. 거대 언어 모델(LLM)은 문맥에 맞는 텍스트를 생성하는 뛰어난 기능과 문제 해결을 위한 추론 기능으로 인해 그 인기가 높아지고 있습니다. 텍스트 모델 활용 범위는 텍스트 생성, 편집뿐만 아니라 프로그래머의 코파일럿 역할까지 다양합니다. 텍스트 생성 기능 외에 LLM의 활용 범위를 확장할 수 있는 방법은 무엇일까요?

    Tool LLM을 통해 우리는 AI가 우리의 요청을 이해하는 것뿐만 아니라 다양한 온라인 도구를 사용하여 요청에 따라 행동할 수 있는 시대로 나아가고 있습니다. Tool LLM은 기능 및 REST API를 통해 도구를 통한 AI가 할 수 있는 일의 한계를 확장하고 있습니다.

    GPT-4는 현재 대부분의 AI 벤치마크에서 1위를 차지하고 있는 최신 LLM입니다. 이 시나리오에서 GPT-4 모델에 오디오 파일을 다른 언어의 텍스트로 변환하라는 요청을 받는 경우를 생각해 보겠습니다. 그러나 특정 API를 사용하라고 프롬프팅을 하게 되면 GPT-4는 존재하지 않는 API를 착각(hallucinate)하여 제안하거나 잘못된 인수를 제공할 수 있습니다. 결과적으로 기능 실행에 실패하여 사용자가 지정한 작업의 목표를 달성하지 못할 수 있습니다.

    환각(hallucinations)과 부정확성(inaccuracies) 문제 외에도 API 문서와 버전은 끊임없이 변화하고 있습니다. 범용 LLM을 재학습 하는 것은 비용이 많이 들고 지속적으로 변경되는 문서로 LLM 모델을 업데이트하는 것은 실용적이지 않습니다. Tool LLM은 프로그래밍 인터페이스를 통해 물리적 세계와 상호작용할 수 있도록 함으로써 일반적인 대형 모델의 환각 문제에 대한 해결책을 제시합니다. Tool LLM은 크기가 훨씬 작기 때문에 주기적으로 최신 데이터로 재학습할 수 있습니다. 또한 API 문서 검색 모듈을 모델 서빙 파이프라인에 추가하여 사용자의 입력 쿼리와 관련된 최신 API 문서로 모델을 보완할 수 있습니다.

    이러한 과제 극복을 위해 최근 연구자들은 각각 고유한 장점과 구체적인 사용 사례를 갖춘 Gorilla LLM, ToolLLaMA 와 같은 LLM 도구 사용 능력을 향상시키는 두 가지 주목할 만한 오픈 소스 방법을 제안했습니다. 또한 이러한 모델은 Backend.AI 클라우드에서 추론 서비스를 제공하기 위해 준비될 수 있습니다.

    Tool LLM이란 무엇인가요?

    Tool LLM은 사용자 쿼리가 포함된 데이터 세트와 API 코드 사용 및 API 설명 문서와 같은 관련 컨텍스트 정보가 포함된 API 요청에 대해 학습된 LLM입니다. 이러한 LLM의 응답은 코드로 실행될 수 있습니다. 코드 실행은 LLM이 다양한 온라인 서비스 및 도구와 상호 작용할 수 있음을 의미합니다. 클라우드 컴퓨팅 제공업체, Kubernetes 머신 러닝 및 딥 러닝 라이브러리, HuggingFace, TorchHub, TensorFlowHub 와 같은 리포지토리 등이 이에 해당합니다.

    이러한 도구 LLM의 가장 큰 장점은 사용자 쿼리에 대한 API 응답을 정확하게 생성하고 이를 실행하여 결과를 얻을 수 있다는 점입니다.

    API 유형 이해하기

    API(Application Programming Interface)는 현대 컴퓨팅 환경의 중요한 요소로, 서로 다른 소프트웨어 애플리케이션이나 하드웨어 시스템이 통신하고 상호 작용하는 방법에 대한 일련의 규칙과 프로토콜 역할을 합니다.

    함수형 API는 프로그래밍 환경 내에서 함수 콜을 통해 호출되도록 설계되었습니다. 예를 들어, HuggingFace 및 TensorFlow와 같은 머신 러닝 및 딥 러닝 라이브러리는 Functional API call을 통해 메모리에 로드하고 활용할 수 있는 다양한 모델을 제공합니다. 이러한 API는 소프트웨어 내에서 특정 기능과 연산을 실행하는 데 필수적입니다.

    API와 관련된 코드를 생성하는 LLM의 이러한 기능은 기본적인 텍스트 생성 및 처리 기능을 훨씬 뛰어넘어 그 활용도를 확장합니다. 도구 LLM은 클라우드 컴퓨팅 플랫폼에서 고급 머신 러닝 라이브러리에 이르기까지 다양한 온라인 서비스 및 도구와 원활하게 통합할 수 있습니다. 또한 사람의 쿼리에만 국한되지 않고 다른 프로그램이나 AI 에이전트와 상호 작용하는 시스템에도 통합할 수 있습니다. 이러한 다재다능함 덕분에 Tool LLM은 복잡한 시스템과 인프라에서 중요한 구성 요소로 자리매김하여 실제 애플리케이션에 대한 잠재력을 향상시킵니다.

    다음 섹션에서는 Tool LLM이 어떻게 학습되었고 어떻게 운영되는지 자세히 살펴보겠습니다. 그 다음은 Gorilla LLM과 ToolLLaMA라는 두 가지 구체적인 연구 사례를 다룰 것입니다.

    Tool LLM 학습 및 추론 워크플로

    Tool LLM 학습에는 API 데이터베이스 설정, 학습 데이터 세트 생성, 모델 학습 및 추론 등 여러 단계가 포함됩니다.

    API 데이터베이스에는 설명과 관련 코드 샘플이 포함되어 있습니다. Self-instruct 학습 데이터 세트를 생성하려면 API 데이터베이스 샘플을 {입력: 사용자 쿼리 - 출력: API} 쌍으로 사전 처리해야 합니다. ChatGPT는 사람이 물어볼 수 있는 다양한 시나리오와 복잡한 쿼리를 처리하여 이러한 데이터 세트를 자동으로 생성하는 데 도움을 줄 수 있습니다. 구체적인 사례부터 일반적이고 추상적인 사례까지. Self-instruct 데이터 세트가 생성된 후 모델은 사용자 입력 쿼리가 주어지면 API 측면에서 정확한 예측을 할 수 있도록 학습됩니다.

    Tool LLM 추론의 경우, LLM이 정확한 인수 매개변수로 응답할 뿐만 아니라 최신 API 설명서를 사용하는 것이 중요합니다. 따라서 최신 API 변경 사항으로 모델을 유지하는 데 도움이 되는 API 문서 검색기가 사용됩니다.

    그림 1. API Instruction 데이터 세트를 통한 Tool LLM 학습 및 추론 워크플로우 개요

    사례 연구: Gorilla LLM 및 ToolLLaMA

    Gorilla LLM

    Gorilla 는 API 호출 작성에서 GPT-4를 능가하는 미세 조정된 LLaMA 7B 기반 모델입니다. Gorilla의 주목할 만한 점은 다음과 같습니다:

    • API에 대한 정확한 입력 인수를 생성하는 데 있어 현재 LLM의 한계와 잘못된 API 사용에 대한 환각 경향을 해결합니다.
    • Gorilla 는 문서 API 검색기와 통합되어 문서의 실시간 변경 사항에 적응할 수 있으며, 이는 API가 얼마나 자주 업데이트 되는지를 고려할 때 상당한 이점입니다.
    • 개발자들은 이 모델의 능력을 평가하기 위해 APIBench라는 데이터 세트를 개발했으며, 여기에는 총 1,600개 이상의 API가 포함된 HuggingFace, TorchHub, TensorHub의 API가 포함되어 있습니다.
    • Gorilla는 환각 문제를 완화하고 LLM 출력의 신뢰성을 개선하는 것으로 추정됩니다. 또한, Gorilla는 AWS, GCP와 같은 클라우드 제공업체와 함께 작동하고 Kubernetes 클러스터를 관리할 수 있도록 업데이트 및 확장되었습니다.

    ToolLLaMA

    ToolLLaMA는 RapidAPI 리포지토리에 기반한 도구의 명령어 튜닝 데이터 세트인 ToolBench에서 미세 조정된 모델입니다. ToolLLaMA의 주요 키포인트는 다음과 같습니다:

    • ToolBench는 16,000개 이상의 실제 API를 다루며 다양한 명령어 세트와 솔루션 경로를 제공합니다.
    • 이 논문에서는 여러 도구 사용 및 다단계 추론과 같은 LLM의 추론 기능을 향상시키기 위해 새로운 심층 검색 기반 의사 결정 트리 알고리즘(Depth-First Search-Based Decision Tree : DFSDT)을 제안합니다.
    • ToolBench에서 미세 조정된 ToolLLAMA는 ChatGPT의 성능과 흡사하며 APIBench와 같이 배포되지 않은 데이터 세트에서 일반화 능력을 보여줍니다.

    두 논문 모두 방대한 API를 탐색하고 활용함으로써 실제 도구 사용에서 LLM의 기능의 한계를 뛰어넘었다는 데 의의가 있습니다. 이러한 발전은 실제 애플리케이션에 매우 중요합니다. 아래는 비교 요약 표입니다.

    그림 2. 두 API 튜닝 LLM 간의 비교 표

    Backend.AI와 ToolLLM 의 시너지 효과

    LLM의 학습 또는 모델 서비스에는 상당한 컴퓨터 리소스를 필요로 하는데, 특히 RAM 용량과 계산 속도가 높은 그래픽 처리 장치(GPU)에 대한 수요가 많기 때문입니다.

    Backend.AI는 다양한 모델을 구축, 학습 및 제공하기 위한 확장 가능한 기반을 제공합니다. Backend.AI에는 모델 추론을 위한 온디맨드 확장 기능과 외부 노드 추가를 통한 서비스 제공, 워크로드 최적화를 위한 부하 분산 기능이 포함되어 있습니다. Backend.AI 에는 고성능 LLM 추론에 사용할 수 있는 vLLM과 TensorRT 서버가 있습니다. 또한 사용자 친화적으로 잘 설계된 인터페이스와 파이프라인 메이커인 FastTrack 툴을 통해 다양한 복잡도의 컴퓨팅 환경 세션을 생성할 수 있습니다.

    맺음말

    다양한 인공지능 어시스턴트와 에이전트가 다양한 기기 및 서비스와 상호작용하는 미래 시나리오는 이러한 상호작용에 특화된 API와 Tool LLM 을 통해 실현될 수 있습니다. Gorilla LLM 과 ToolLLaMA 는 복잡한 업무에 활용할 수 있는 좋은 기회를 제공합니다. 이들이 어떻게 학습하고 서비스를 제공하는지에 대한 워크플로도 이해하기 쉽습니다. 머신 러닝 및 클라우드 관리 작업에는 Gorilla LLM을 사용하는 것을 추천할 수 있습니다. 보다 일반적인 API 사용, 다중 도구 및 다단계 사례에는 ToolLLaMA를 사용하는 것이 좋습니다.

    자체 API 문서나 코드에 대한 자체 모델을 학습시켜 코드를 이해하는 LLM 모델을 보유할 수 있다는 장점도 있습니다. 이러한 LLM은 관련 정보를 얻고자 하는 사용자를 지원하거나 상호 작용할 때 유용할 수 있습니다.

    자주 묻는 질문:

    • Q: 환각의 원인과 LLM의 한계는 무엇이며 Tool LLM에서 이를 어떻게 해결했나요?
    • A: 다른 대규모 언어 모델과 마찬가지로 GPT-4는 인터넷의 광범위하지만 오래되었거나 부정확할 가능성이 있는 데이터 세트를 학습하기 때문에 주로 환각과 부정확성과 같은 한계에 직면해 있습니다. 이러한 '환각'은 모델이 사실과 다르거나 현실에 근거하지 않은 정보를 자신 있게 생성하는 경우를 말하며, 이는 데이터의 크기나 물리적 세계와의 상호작용 부족이 아닌 순수 텍스트 기반 학습 데이터의 특성에서 비롯된 문제입니다. 이러한 문제를 해결하기 위해 전문화와 빈번한 업데이트에 중점을 두고 Tool LLM이 개발되고 있습니다. API 문서와 같은 특정 데이터 세트에 대한 미세조정을 거쳐 프로그래밍 인터페이스를 통해 실제 시스템과 직접 상호 작용하여 보다 정확하고 최신 정보를 얻을 수 있습니다. Tool LLM의 재학습 빈도는 애플리케이션과 관련 분야의 변화 속도에 따라 달라지며, 모델을 최신 트렌드와 정보로 최신 상태로 유지하기 위해 월별, 분기별 또는 연 2회 업데이트가 필요할 수 있습니다.
    • Q: 사용자 쿼리와 API 쌍 예시는 어떤 것들이 있을까요?
    • A: 다음 예제입니다.
    • User Query: "우주 탐사에 관한 이 기사를 요약하세요."
    • API Output: HuggingFace.summarize(text="기사 본문", model="facebook/bart-large-cnn")
    • User Query: "이 고객 리뷰의 감정은 어떤가요?"
    • API Output: HuggingFace.analyze_sentiment(text="고객 리뷰 본문", model="distilbert-base-uncased-finetuned-sst-2-english")
    • User Query: "이 사진에 있는 물체를 식별하세요."
    • API Output: HuggingFace.image_recognition(image_file="path/to/photo.jpg", model="google/vit-base-patch16-224")
    • User Query: "이 음성 녹음을 텍스트로 변환하세요."
    • API Output: HuggingFace.speech_to_text(audio_file="path/to/recording.wav", model="facebook/wav2vec2-base-960h")
    • Q: 모델을 훈련하고 추론하는 과정에서 API 문서를 활용하는 방식에 있어 GorillaLLM과 ToolLLaMA 논문은 어떻게 다른가요?
    • A: GorillaLLM은 학습 중에 관련 API 문서를 추가하고 두 가지 추론 모드를 제공하는 반면, ToolLLaMA는 API 도메인에서 임베딩을 미세 조정하기 위해 Sentence-BERT를 사용합니다. 문서 검색을 위해 GorillaLLM은 LLamaIndex의 BM25와 GPT-Retriever를 사용하는 반면, ToolLLaMA는 비슷한 목적으로 Sentence-BERT를 사용합니다.
    • Q: 소규모 API 모델은 얼마나 자주 재교육해야 하며, API 리트리버는 API 문서 변경 사항을 처리하는 데 어떤 역할을 하나요?
    • A: 소규모 API 모델을 매년 교육하는 것은 합리적이지만, API 변경 사항에 대해 매달 재교육하는 것은 현실적이지 않습니다. API 리트리버는 최신 문서를 사용하여 잦은 재교육의 필요성을 줄일 수 있습니다. 미세 조정된 API 모델과 RAG 방법을 평가하고 벤치마킹하는 것은 효율성을 위해 필수적입니다.
    • Q: ToolLLM과 RAG 시스템의 차이점은 무엇이며, LLM의 맥락에서 어떻게 작동하나요?
    • A: ToolLLM은 지식을 통합하는 데 중점을 두고 API 문서에 따라 미세 조정된 모델입니다. 반면에 RAG 시스템은 데이터 청킹, 저장, 검색, 재순위 지정 및 합성을 위한 알고리즘입니다. 이들은 독립적으로 또는 함께 작동하여 특히 컨텍스트 제한 및 지식 업데이트를 처리할 때 LLM 효율성을 향상시킬 수 있습니다.

    참고 자료:

    • Gorilla: Large Language Model Connected with Massive APIs. https://gorilla.cs.berkeley.edu/
    • ToolLLM: Facilitating Large Language Models To Master 16000+ Real-World APIs. https://github.com/OpenBMB/ToolBench

    28 January 2024

  • Introducing Raftify: 확장성에 초점을 맞춰 개발된 High-level Raft Framework

    By 이규봉

    안녕하세요, 저는 작년부터 Lablup에서 Backend.AI 매니저 프로세스에 Raft를 도입하는 작업을 맡아 수행하고 있습니다.

    제가 수행 중인 관련 작업을 대략적으로 나타내어 보면 아래와 같습니다.

    1. Backend.AI 매니저 프로세스에 Raft를 도입해 리더-팔로워 구조로 만드는 것.
    2. 기존 분산 락 기반의 GlobalTimer를 Raft 기반의 글로벌 타이머로 변경하고, 클러스터에서 특정 작업이 정확히 한 번만 수행되도록 보장하는 것.
    3. 매니저 프로세스 간 공유 가능한 전역적인 상태 저장소를 매니저 프로세스에 내장시키고 적절하게 동기화하는 것.

    이 글에선 이러한 작업을 수행하기 위해 제가 지난 1년간 삽질하며 개발하게 된 Raft 프레임워크와 이를 개발하며 마주친 여러 이슈들에 대해 소개드리고 총 300줄이 되지 않는 간략한 코드를 통해 분산 키값 저장소를 구현하는 raftify 예제 코드에 대해 설명드려 보도록 하겠습니다.

    raftify 소개

    raftify는 어떤 서버 애플리케이션과도 쉽게 통합될 수 있도록 확장성에 초점을 맞추어 개발된 Raft 프레임워크입니다.

    raftify는 프로덕션에서 활용되고 있는 Raft 구현체들 중 tikv의 raft-rs 구현체 위에 LMDB를 stable storage로, gRPC를 네트워크 계층으로 사용해 개발되었습니다.

    raft 모듈 바인딩

    저는 신뢰할 수 있는 Raft 구현체를 밑바닥부터 모두 쌓아올려 유지 보수하는 것은 현실적으로 큰 짐이 될 수 있다고 판단해 우선 Raft 모듈의 파이썬 바인딩을 작성해보기로 결정했습니다.

    그래서 처음에는 GitHub에서 가장 스타를 많이 받은 Raft 구현체인 hashicorp/raft 구현체를 gopy를 사용해 파이썬 바인딩을 작성해보면 어떨까 생각했습니다.

    하지만 gopy는 고루틴에 대한 바인딩을 지원해주지 못했고 최신 파이썬 버전도 지원해 주지 않고 있었습니다.

    그러던 참에 사내 시니어 개발자님의 조언을 통해 tikv/raft-rs란 Rust 구현체와 PyO3에 대해 알게 되며 PyO3를 통해 tikv/raft-rs의 파이썬 바인딩을 작성해 보아야겠다고 생각하게 되었습니다.

    rraft-py

    그렇게 rust, raft, py를 합쳐 rraft-py란 이름으로 Raft 모듈의 파이썬 바인딩 개발에 도전해보게 되었습니다.

    rraft-py를 개발하면서 가장 먼저 신경 쓴 것은 rust 코드와 파이썬 코드의 의미가 가능한 1:1 매칭이 되도록 만들어야겠다는 것이었습니다.

    1:1 매칭이 가능하려면 러스트의 문법에 관련된 세부 사항들을 잘 우회할 필요가 있었습니다.

    제가 당시에 제일 고민했던 것은 러스트의 참조를 파이썬 측으로 어떻게 노출해주어야 좋을지에 관련된 것이었으며, 관심이 있으시다면 해당 파이콘 발표 영상을 참고하실 수 있습니다.

    이렇게 개발된 rraft-py는 1만 줄 이상의 raft-rs의 통합 테스트 코드를 그대로 포팅해 파이썬에서 바로 사용할 수 있는 나름 신뢰할 수 있는 Raft 바인딩 구현체가 되었습니다.

    현재 raftify는 Rust로 완전히 재작성하는 과정을 거친 후 rraft-py를 사용하지 않게 되었지만 처음으로 PyO3 바인딩을 작성해보고 Raft 구현체의 API들을 사용해보는 좋은 경험이 되었습니다.

    riteraft-py

    rraft-py를 개발하고 raft-rs의 1만 줄 가량의 통합 테스트들과 multiple-mem-node example까지 파이썬 코드로 포팅해 정상적으로 동작하도록 개발한 후 든 생각은 여전히 어디에서부터 시작해야 할지 모르겠다는 것이었습니다.

    raft-rs는 정말 Raft 구현체 자체만을 제공했고 이것을 어떻게 애플리케이션에 통합할 수 있을지 전혀 감이 잡히지 않았습니다.

    Github을 찾아보던 중 How to use this lib?란 이슈에서 riteraft란 이름으로 tikv/raft-rs를 기반으로 하는 하이 레벨의 Rust 구현체를 발견하게 되었고, 해당 라이브러리는 훨씬 직관적으로 사용 방법을 파악할 수 있었습니다. 그래서 저는 파이썬에서 이것의 동작을 그대로 모방해 애플리케이션 레벨에 통합하는 것을 목표로 riteraft-py를 개발하기로 결심했습니다.

    riteraft는 Raft 모듈과 로그, 상태 머신, 네트워크 계층과 이 Raft 구현체를 직접 통합하는 일을 수행하는데요, 문제는 직관적인 사용법과 별개로 제대로 동작하지 않는다는 점이었습니다.

    리더가 죽었는데 Leader election이 일어나지 않는 문제, 특정 시나리오에서 데이터 복제가 일어나지 않는 문제, 커밋 갯수가 255개를 넘어갈 때 일어나는 패닉 등... 온갖 잡다한 이슈를 모두 해결해야 했습니다.

    위 이슈들을 모두 해결하고 클러스터가 동작하는 것 처럼 보이게 만든 후에도 이슈는 계속해서 발생했습니다. 잘 동작하는 것 같다가도 특정 장애 상황을 마주하면 클러스터 일관성이 깨지거나 로그 동기화가 락인 되는 등 치명적인 문제들이 발생했습니다.

    이슈가 발생할 때마다 매번 raft-rs의 기술적인 세부 사항들을 들여다보고 이해할 수 있어야 했으며 이 과정은 결국 raft-rs의 코드를 뜯어보고 하나 하나 이해해 나가는 과정을 요구했습니다.

    raftify

    이슈를 해결하는 과정에서 riteraft와 다른 추상화를 사용하기로 결정했고 노드와 클러스터 상태를 디버깅 하기 위한 CLI 모듈 등 여러 변경 사항들을 구현하면서 라이브러리 이름을 raftify로 변경하게 되었습니다.

    해당 라이브러리를 처음 개발하기 시작할 땐 어떤 파이썬 애플리케이션과도 잘 호환될 수 있도록 하는 것을 목표로 개발했기 때문에 raft화 시키겠다는 의미로 raftify라는 이름을 붙였습니다.

    파이썬 구현체는 현재는 더 이상 개발하고 있지 않지만 해당 브랜치에서 확인할 수 있습니다.

    raftify written in Rust

    rraft-py 위에 파이썬으로 개발된 raftify는 결과적으로 잘 작동되긴 했지만 멀티 프로세스 구조로 작성된 조잡한 테스트 하네스는 CI에서 테스트 하기 힘들었고 쉽게 클러스터 일관성이 깨졌으며 코드가 조금만 복잡해져도 제어하기 힘들어졌습니다.

    결과적으로 raftify 내부 로직을 러스트로 완전히 재작성하고 Raft 패키지의 하이 레벨에서의 인터페이스만을 파이썬으로 노출시키기로 결정하게 되었습니다.

    그렇게 완전히 러스트로 재작성된 raftify는 싱글 스레드만으로 통합 테스트 수행이 가능했고, CI에서 테스트할 수 있어 코드 변경의 두려움을 없애도록 도와주었습니다.

    raftify 예제 코드

    이 섹션에선 raftify를 사용해 간단한 분산 키값 저장소를 만들어봅니다.

    전체 소스 코드는 해당 링크를 참고하세요.

    상태 머신 정의

    우선은 키값 저장소에서 사용할 로그 엔트리와 상태 머신을 정의해야 합니다.

    이 글에선 간단하게 로그 엔트리로 값을 정의하는 Insert라는 타입의 명령어만 정의해보겠습니다.

    💡 이 글에선 Rust 문법, Raft의 이론적 배경에 대해 설명하지 않습니다.

    #[derive(Clone, Debug, Serialize, Deserialize)]
    pub enum LogEntry {
        Insert { key: u64, value: String },
    }
    

    상태 머신은 HashMap 타입으로 아래처럼 정의해보겠습니다.

    #[derive(Clone, Debug)]
    pub struct HashStore(pub Arc<RwLock<HashMap<u64, String>>>);
    

    그런 다음 이 자료구조들을 어떻게 직렬화, 역직렬화 할지 나타낼 encode, decode 메서드를 정의해주어야 합니다. bincode 크레이트를 사용해 아래처럼 간단하게 정의할 수 있습니다.

    impl AbstractLogEntry for LogEntry {
        fn encode(&self) -> Result<Vec<u8>> {
            serialize(self).map_err(|e| e.into())
        }
    
        fn decode(bytes: &[u8]) -> Result<LogEntry> {
            let log_entry: LogEntry = deserialize(bytes)?;
            Ok(log_entry)
        }
    }
    
    impl AbstractStateMachine for HashStore {
        fn encode(&self) -> Result<Vec<u8>> {
            serialize(&self.0.read().unwrap().clone()).map_err(|e| e.into())
        }
    
        fn decode(bytes: &[u8]) -> Result<Self> {
            let db: HashMap<u64, String> = deserialize(bytes)?;
            Ok(Self(Arc::new(RwLock::new(db))))
        }
    }
    

    마지막으로 HashStore에 raftify 내부 코드에서 사용될 세 가지 메서드를 정의하면 됩니다.

    HashStore에 새 로그 엔트리가 적용될 때 호출될 메서드인 apply, 현재 HashStore의 상태를 스냅샷으로 저장할 때 호출될 snapshot, 스냅샷 바이트 슬라이스를 통해 HashStore의 상태를 복구할 때 호출될 restore를 아래처럼 정의해줍니다.

    #[async_trait]
    impl AbstractStateMachine for HashStore {
        async fn apply(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
            let log_entry: LogEntry = LogEntry::decode(&data)?;
            match log_entry {
                LogEntry::Insert { ref key, ref value } => {
                    let mut db = self.0.write().unwrap();
                    log::info!("Inserted: ({}, {})", key, value);
                    db.insert(*key, value.clone());
                }
            };
            Ok(data)
        }
    
        async fn snapshot(&self) -> Result<Vec<u8>> {
            Ok(serialize(&self.0.read().unwrap().clone())?)
        }
    
        async fn restore(&mut self, snapshot: Vec<u8>) -> Result<()> {
            let new: HashMap<u64, String> = deserialize(&snapshot[..]).unwrap();
            let mut db = self.0.write().unwrap();
            let _ = std::mem::replace(&mut *db, new);
            Ok(())
        }
    }
    

    웹 서버 API 정의

    예제에서 사용될 웹 서버 API를 정의해봅시다. 이 API를 통해 노드의 Raft 객체에 접근해 HashStore를 조작할 것입니다.

    예제에선 actix-web 크레이트를 사용해 아래처럼 정의해보도록 하겠습니다.

    put 명령은 Raft 객체의 RaftNode에서 propose 메서드를 호출함으로써 구현할 수 있습니다. 이전에 정의한 Insert 타입 LogEntry를 인코딩해 RaftNode::propose 메서드의 인자에 넘겨주면 됩니다.

    get 명령은 인메모리에 저장되어 있는 HashMap에서 id에 해당하는 값을 리턴하는 것으로 구현할 수 있습니다.

    #[get("/put/{id}/{value}")]
    async fn put(data: web::Data<(HashStore, Raft)>, path: web::Path<(u64, String)>) -> impl Responder {
        let log_entry = LogEntry::Insert {
            key: path.0,
            value: path.1.clone(),
        };
        data.1.raft_node.propose(log_entry.encode().unwrap()).await;
    
        "OK".to_string()
    }
    
    #[get("/get/{id}")]
    async fn get(data: web::Data<(HashStore, Raft)>, path: web::Path<u64>) -> impl Responder {
        let id = path.into_inner();
    
        let response = data.0.get(id);
        format!("{:?}", response)
    }
    
    

    Raft 클러스터 부트스트랩

    이제 RaftNode들의 클러스터를 부트스트랩 시켜 봅시다.

    이 예제에서는 아래와 같은 구성을 갖는 Raft 클러스터를 부트스트랩할 것입니다.

    아래와 같은 구성에서는 세 개의 노드가 voter로서 부트스트랩되므로 클러스터에 리더가 존재하지 않아 election_timeout 후 바로 리더를 선출하게 됩니다.

    [[raft.peers]]
    ip = "127.0.0.1"
    port = 60061
    node_id = 1
    role = "voter"
    
    [[raft.peers]]
    ip = "127.0.0.1"
    port = 60062
    node_id = 2
    role = "voter"
    
    [[raft.peers]]
    ip = "127.0.0.1"
    port = 60063
    node_id = 3
    role = "voter"
    
    let options = Options::from_args();
    let store = HashStore::new();
    let initial_peers = load_peers().await?;
    
    let mut cfg = build_config();
    cfg.initial_peers = Some(initial_peers.clone());
    
    let node_id = initial_peers
        .get_node_id_by_addr(options.raft_addr.clone())
        .unwrap();
    
    let raft = Raft::bootstrap(
        node_id,
        options.raft_addr,
        store.clone(),
        cfg.clone(),
        logger.clone(),
    )?;
    
    let handle = tokio::spawn(raft.clone().run());
    
    // ...
    tokio::try_join!(handle)?;
    

    그리고 이 Raft 서버와 통신하기 위한 웹 서버를 붙여 줍시다.

    if let Some(addr) = options.web_server {
        let _web_server = tokio::spawn(
            HttpServer::new(move || {
                App::new()
                    .app_data(web::Data::new((store.clone(), raft.clone())))
                    .service(put)
                    .service(get)
            })
            .bind(addr)
            .unwrap()
            .run(),
        );
    }
    

    이제 터미널에서 아래처럼 세 노드로 이뤄진 Raft 클러스터를 부트스트랩 시킬 수 있습니다.

    $ ./target/debug/memstore --raft-addr=127.0.0.1:60061 --web-server=127.0.0.1:8001
    $ ./target/debug/memstore --raft-addr=127.0.0.1:60062 --web-server=127.0.0.1:8002
    $ ./target/debug/memstore --raft-addr=127.0.0.1:60063 --web-server=127.0.0.1:8003
    

    테스트

    이제 curl 명령을 통해 actix-web 서버 API를 통해 우리가 정의한 키값 저장소를 사용해 볼 수 있습니다.

    ❯ curl http://localhost:8001/put/1/test
    OK
    
    ❯ curl http://localhost:8001/get/1
    Some("test")
    

    더 자세한 내용이 궁금하시다면 raftify 레포지토리에서 디버깅을 도와주는 CLI 모듈의 사용법, RaftServiceClient의 예제 코드 등을 확인하실 수 있습니다.

    정리

    raftify는 일반 개발자 입장에서 접근하기 쉽지 않은 Raft 모듈을 누구나 쉽게 통합시킬 수 있도록 만드는 것을 목표로 하고 있는 실험적인 프레임워크입니다.

    Backend.AI 매니저 프로세스들에 리더-팔로워 구조를 도입하겠다는 목적으로 개발되었지만 이 글에서 설명드린 것 처럼 짧은 소스 코드로 자신만의 간단한 분산 키값 저장소를 만드는 등 HA 구조가 필요한 곳에서 다양하게 활용될 수 있을 것으로 보입니다.

    혹시 tikv/raft-rs 구현체 내부 동작 방식에 흥미가 생기셨다면 다음 글에서 몇 가지 시나리오에서 일어나는 일들을 소스코드 내부를 한 줄 한 줄 따라가며 분석해 볼 예정이니 기대해주시면 감사하겠습니다.

    26 January 2024

  • Backend.AI를 위한 Raft 합의 알고리즘: 리더 선출

    By 강정석

    현대 어플리케이션을 이야기할 때 고가용성(High Availability, HA)은 빼놓을 수 없는 개념이 되었습니다. 고가용성은 IT 시스템이 다운타임을 제거하거나 최소화하여 거의 100% 상시 액세스 가능하고 신뢰성을 유지하는 능력을 의미합니다^1. 래블업이 개발하고 서비스하는 Backend.AI도 고가용성을 유지하기 위하여 다양한 방법을 적용하고 있습니다.

    Backend.AI의 구조도

    배경

    Backend.AI는 매니저와 에이전트, 스토리지 프록시와 웹서버 등 다양한 컴포넌트로 구성됩니다. 각 컴포넌트들은 각각 분산 환경에서 다중 프로세스로 실행되어 안정성을 높이고 있습니다. 특히 매니저 경우 Backend.AI의 세션 실행 스케줄링 및 여러 핵심 기능을 담당하고 있기 때문에 특히 더 높은 신뢰성을 보장해야 합니다. 현재 매니저에는 부하 분산을 통해 고가용성을 보장하는 Active-Active HA 구조가 적용되고 있습니다.

    Backend.AI 매니저의 여러 기능 중 하나는 바로 이벤트 처리입니다. Backend.AI는 에이전트와 세션의 생명 주기(Lifecycle)를 추적하고 최적의 스케줄링을 제공하기 위하여 AgentStartedEvent, DoScheduleEvent 등 다양한 이벤트를 발생시킵니다. 예를 들어 한 Backend.AI Agent 프로세스가 실행될 때 AgentStartedEvent를 생성하게 되고, 이 이벤트를 수신한 Backend.AI Manager 프로세스는 특정 동작(schedule())을 수행하게 됩니다. 또한 Backend.AI Manager는 내부적으로 DoScheduleEvent를 발생시키며 주기적인 스케줄링을 보장합니다. 이때 문제가 발생합니다. 고가용성을 위하여 여러 개의 Backend.AI Manager 프로세스를 실행할 경우, 각 프로세스가 자체적인 타이머를 갖고 이벤트를 발생시킨다면 불필요한 부하가 가해지는 것과 더불어 전체 시스템의 상태가 보장되지 못할 수 있게 됩니다. Backend.AI 매니저는 동일 시스템 내에서 오직 하나의 매니저 프로세스만 이벤트를 생성하는 것을 보장하기 위하여 GlobalTimer를 구현하였습니다. GlobalTimer는 분산 락(Distributed Lock)을 통해 프로세스 간 상호배제성을 확보하고, 오직 하나의 프로세스에서만 이벤트가 발생하도록 합니다.

    @preserve_termination_log
    async def generate_tick(self) -> None:
        try:
            await asyncio.sleep(self.initial_delay)
            if self._stopped:
                return
            while True:
                try:
                    async with self._dist_lock:
                        if self._stopped:
                            return
                        await self._event_producer.produce_event(self._event_factory())
                        if self._stopped:
                            return
                        await asyncio.sleep(self.interval)
                except asyncio.TimeoutError:  # timeout raised from etcd lock
                    if self._stopped:
                        return
                    log.warn("timeout raised while trying to acquire lock. retrying...")
        except asyncio.CancelledError:
            pass
    

    현재 Backend.AI는 분산 락에 대한 인터페이스인 AbstractDistributedLock을 제공하고 있으며, 실제 구현체로는 FileLock, etcd concurrency API 기반의 EtcdLock, Redis Lock 기반의 RedisLock을 개발하여 사용하고 있습니다.

    etcd는 분산 시스템을 계속 실행하는 데 필요한 중요한 정보를 보관하고 관리하는 데 사용되는 분산 오픈소스 키-값 저장소이며^2, 대표적으로 Kubernetes 등에서 사용되고 있습니다.

    class AbstractDistributedLock(metaclass=abc.ABCMeta):
        def __init__(self, *, lifetime: Optional[float] = None) -> None:
            assert lifetime is None or lifetime >= 0.0
            self._lifetime = lifetime
    
        @abc.abstractmethod
        async def __aenter__(self) -> Any:
            raise NotImplementedError
    
        @abc.abstractmethod
        async def __aexit__(self, *exc_info) -> Optional[bool]:
            raise NotImplementedError
    

    요구사항

    GlobalTimer는 분산 환경에서 프로세스 단위로 이벤트 생성을 제어하는 역할을 잘 수행하고 있습니다. 하지만 요구사항은 늘 변화하고 소프트웨어는 그에 발맞춰 변화해야 합니다. 이번에 추가된 요구사항은 요청 횟수 제한(rate limit)을 구현하는 것이었습니다. 현재와 같은 부하 분산 방식으로는 매 요청이 동일한 매니저에서 처리된다고 보장할 수 없는데, 각 매니저의 상태가 공유되지 않기 때문에 아래와 같은 문제가 발생할 수 있습니다.

    1. 두 매니저의 카운터를 각각 0으로 설정하고 요청 횟수 제한을 1로 설정합니다.
    2. 첫 요청을 1번 매니저가 받습니다.
    3. 1번 매니저의 카운터를 1만큼 증가시킵니다. (C1: 0 -> 1)
    4. 카운터가 최대 허용 횟수에 도달하여 다음 요청은 거절하게 됩니다.
    5. 부하 분산에 의해 두 번째 요청을 2번 매니저가 받습니다.
    6. 2번 매니저의 카운터는 아직 0이기 때문에 최대 허용 횟수에 도달하지 않았습니다. (C2: 0)
    7. 2번 매니저가 요청을 처리합니다.
    8. 요청 횟수 제한이 제대로 동작하지 않았습니다!
    

    따라서 이런 한계점을 개선할 방법을 논의하기 위하여 아래와 같은 이슈가 제안되었습니다.

    분산 타이머 개선을 제안하는 이슈 (lablup/backend.ai#415)

    리더로 표현되는 단일 매니저 프로세스에 전역 상태 관리를 위임하기 위하여 합의 알고리즘(Consensus algorithms)을 조사하게 되었고, Kubernetes의 저장소로 사용되는(https://kubernetes.io/docs/concepts/overview/components/#etcd) etcd 등의 프로젝트에서 사용되며 충분한 검증을 거쳤다고 판단되는 Raft Consensus Algorithm(이하 Raft)을 이용하기로 결정했습니다.

    Raft 합의 알고리즘

    Raft 알고리즘은 2014년 USENIX에 제출된 "In Search of an Understandable Consensus Algorithm"^3에서 제안된 방법입니다. 당대 최고의 알고리즘이던 Paxos^4는 복잡한 합의 과정으로 인하여 실제로 이해하고 구현하는 데 어려움이 있었고, 제목에도 드러나듯 이러한 문제점을 개선하기 위하여 만들어졌습니다.

    But our most important goal — and most difficult challenge — was understandability.

    • In Search of an Understandable Consensus Algorithm

    Raft 클러스터는 일반적으로 5개의 노드로 구성되는데, 최대 2대의 노드에 문제가 발생해도 quorum을 만족하여 시스템을 유지할 수 있기 때문입니다. 클러스터를 구성하는 각 노드는 아래의 세 가지 상태(리더, 팔로워, 후보자) 중 하나를 가집니다. 일반적으로 각 클러스터에는 최대 한 개의 리더가 존재할 수 있고, 나머지 노드는 팔로워가 됩니다.

    용어 설명 #1

    • quorum: 의결(議決)에 필요한 최소한도의 인원수를 의미합니다. (N/2+1)
    Raft 노드의 상태 전이 다이어그램 (출처: In Search of an Understandable Consensus Algorithm)

    Raft 알고리즘은 선출된 리더에게 모든 권한을 위임하며, 로그의 흐름을 일방향으로 만듦으로써 전체적인 흐름을 이해하기 쉽도록 만듭니다. Raft 알고리즘은 아래와 같은 특징을 가집니다.

    용어 설명 #2

    • term: 현재 리더 혹은 후보자의 세대를 의미합니다. 리더 선거가 시작될 때마다 1씩 증가합니다.
    • index: 로그에서 특정 값의 위치를 의미합니다.
    • commit: 로그에 있는 특정 값을 상태 머신에 적용하였음을 나타냅니다.
    • commitIndex: 커밋에 성공한 가장 높은 index
    • Election Safety: 각 term에는 최대 하나의 리더가 존재합니다.
    • Leader Append-Only: 리더는 로그를 덮어쓰거나 삭제하지 않고 새로 추가만 가능합니다.
    • Log Matching: 두 로그에 동일한 index와 term을 가진 값이 있다면, 해당 index까지의 모든 값은 동일합니다.
    • Leader Completeness: 특정 term에 어떤 값이 로그에 commit되었다면, 이후 세대의 모든 리더는 이 값을 가지는 것을 보장합니다.
    • State Machine Safety: 한 서버가 특정 index의 로그 값을 상태 머신에 적용하였다면, 다른 서버는 동일한 index에 있는 다른 값을 적용할 수 없습니다.

    위의 특징을 이용하여 Raft는 전체 합의 과정을 서로 독립적인 세 부분으로 나눕니다.

    • Leader election: 기존 리더가 동작하지 않으면 새 리더가 선출되어야 합니다.
    • Log replication: 리더는 클라이언트로부터 받은 요청 로그를 다른 노드에 복제합니다. 이때 다른 노드들은 리더의 로그를 무조건적으로 수용합니다.
    • Safety: 한 서버가 특정 index의 로그 값을 상태 머신에 적용하면 다른 서버는 동일한 index의 다른 값을 적용할 수 없습니다.

    이번 글에서는 Raft 노드가 가지는 각 상태에 대하여 알아보고 리더 선출 과정을 코드로 구현해 보도록 하겠습니다.

    팔로워(Follower)

    팔로워는 자체적으로 요청을 보내지 않고 리더 혹은 후보자의 요청을 받아 대응하는 역할만 수행합니다. 논문에서 제안하는 팔로워의 행동 명세(Behavior Spec)와 이를 기반으로 작성된 코드는 아래와 같습니다.

    • 리더와 후보자의 RPC 요청을 처리합니다.
    async def on_append_entries(
        self,
        *,
        term: int,
        leader_id: RaftId,
        prev_log_index: int,
        prev_log_term: int,
        entries: Iterable[raft_pb2.Log],
        leader_commit: int,
    ) -> Tuple[int, bool]:
        await self._reset_timeout()
        if term < (current_term := self.current_term):
            return (current_term, False)
        await self._synchronize_term(term)
        return (self.current_term, True)
    
    async def on_request_vote(
        self,
        *,
        term: int,
        candidate_id: RaftId,
        last_log_index: int,
        last_log_term: int,
    ) -> Tuple[int, bool]:
        await self._reset_timeout()
        async with self._vote_request_lock:
            if term < (current_term := self.current_term):
                return (current_term, False)
            await self._synchronize_term(term)
    
            async with self._vote_lock:
                if self.voted_for in [None, candidate_id]:
                    self._voted_for = candidate_id
                    return (self.current_term, True)
            return (self.current_term, False)
    
    async def _synchronize_term(self, term: int) -> None:
        if term > self.current_term:
            self._current_term.set(term)
            await self._change_state(RaftState.FOLLOWER)
            async with self._vote_lock:
                self._voted_for = None
    
    • 일정 시간 동안 리더 혹은 후보자로부터 아무런 요청을 받지 못하면 후보자 상태가 됩니다.
    async def _wait_for_election_timeout(self, interval: float = 1.0 / 30) -> None:
        while self._elapsed_time < self._election_timeout:
            await asyncio.sleep(interval)
            self._elapsed_time += interval
        await self._change_state(RaftState.CANDIDATE)
    

    리더는 주기적으로 팔로워들에게 하트비트(heartbeat) 메시지를 보냄으로써 자신의 존재를 알려야 합니다. 팔로워는 일정 시간(election_timeout) 동안 아무런 메시지를 받지 못하면 클러스터에 리더가 없는 것으로 판단하고, 자신이 새로운 리더가 되기 위하여 후보자가 되어 선거를 시작합니다.

    후보자(Candidate)

    후보자의 행동 명세와 구현 코드는 다음과 같습니다.

    • 새로운 리더로부터 AppendEntries RPC 요청을 받으면 팔로워가 됩니다. (팔로워의 on_append_etries() 참고)
    • 아래의 절차를 통해 선거를 시작합니다.
      • term을 1만큼 증가시킵니다. (term += 1)
      • 자신에게 투표합니다.
      • 선거 제한시간을 초기화합니다.
      • 다른 노드들에 RequestVote RPC 요청을 보냅니다.
    async def _start_election(self) -> None:
        self._current_term.increase()
        async with self._vote_lock:
            self._voted_for = self.id
    
        current_term = self.current_term
    
        terms, grants = zip(
            *await asyncio.gather(
                *[
                    asyncio.create_task(
                        self._client.request_vote(
                            to=server,
                            term=current_term,
                            candidate_id=self.id,
                            last_log_index=0,
                            last_log_term=0,
                        ),
                    )
                    for server in self._configuration
                ]
            )
        )
    
    • 과반수 이상의 노드로부터 득표하면 리더가 됩니다.
        for term in terms:
            if term > current_term:
                await self._synchronize_term(term)
                break
        else:
            if sum(grants) + 1 >= self.quorum:
                await self._change_state(RaftState.LEADER)
    
    • 선거 제한시간이 초과되면 새 선거를 시작합니다.
    case RaftState.CANDIDATE:
        while self.__state is RaftState.CANDIDATE:
            await self._start_election()
            await self._reset_election_timeout()
            await self._initialize_volatile_state()
            if self.has_leadership():
                await self._initialize_leader_volatile_state()
                break
            await asyncio.sleep(self.__election_timeout)
    

    리더(Leader)

    • 선출 직후 최초의 하트비트(텅 빈 AppendEntries 요청) 메시지를 보냅니다. 이후 주기적으로 하트비트 메시지를 보냅니다.
    async def _publish_heartbeat(self) -> None:
        if not self.has_leadership():
            return
        terms, successes = zip(
            *await asyncio.gather(
                *[
                    asyncio.create_task(
                        self._client.append_entries(
                            to=server,
                            term=self.current_term,
                            leader_id=self.id,
                            prev_log_index=0,
                            prev_log_term=0,
                            entries=(),
                            leader_commit=self._commit_index,
                        ),
                    )
                    for server in self._configuration
                ]
            )
        )
        for term in terms:
            if term > self.current_term:
                await self._synchronize_term(term)
                break
    
    • 클라이언트로부터 요청을 받으면 로그에 값을 추가합니다. 해당 값이 상태 머신에 적용된 후 요청에 대한 응답을 보냅니다.
    • 팔로워가 리더가 추적하고 있는 값(nextIndex)보다 더 큰 index의 로그 값을 가지고 있을 경우, nextIndex부터 시작하는 로그를 팔로워에게 복제합니다.
      • 성공할 경우 리더의 nextIndex와 matchIndex를 갱신합니다.
      • 불일치(inconsistency)로 인해 실패할 경우 리더의 nextIndex를 감소시키고 다시 시도합니다.
    • 아래와 같은 값(N)이 존재할 경우 commitIndex를 해당 값으로 갱신합니다.
      • 과반수 이상의 matchIndex가 N 이상임 (matchIndex >= N)
      • N번째 로그의 term이 현재 term과 동일함

    리더는 팔로워들에 대하여 각각 nextIndex와 matchIndex를 관리합니다.

    • nextIndex: 각 팔로워에게 보내야 할 다음 인덱스
    • matchIndex: 각 팔로워에게 성공적으로 복제한 가장 높은 인덱스

    마무리

    이번 글에서는 Raft 알고리즘에 대하여 간단히 알아본 후 리더 선출을 수행하는 코드를 작성했습니다. 나머지 두 가지 기능(로그 복제, 멤버십 변경)은 실제로 구현하는 과정에서 타이밍 이슈 등 여러 다양한 문제를 마주하게 됩니다. 만약 Raft 알고리즘에 대하여 더 알고 싶으시다면 저자(Diego Ongaro)의 박사 학위 논문(CONSENSUS: BRIDGING THEORY AND PRACTICE)^6를 읽어보는 것을 추천합니다.

    마지막으로 ChatGPT는 Raft 알고리즘에 대하여 어떻게 설명해 주는지 확인하며 글을 마치겠습니다.

    ChatGPT가 설명하는 Raft 알고리즘 (출처: OpenAI ChatGPT 3.5)

    본 글은 lablup/aioraft-ng의 코드를 참고하여 작성되었습니다. 현재 래블업에서 개발하고 있는 차세대 Raft 프로젝트인 lablup/raftify에도 많은 관심 부탁드립니다.

    29 November 2023

  • Backend.AI Model Service Hands-on: GPT-NeoX 실행하기

    By 조규진

    Backend.AI 23.09 버전이 정식으로 공개되었습니다. 23.09 버전의 핵심 기능인 Model Service에 대해서는 이전의 Sneak Peek: Backend.AI Model Service 미리 보기 글에서 다뤘던 바가 있습니다. 그 이후로 GUI 지원, 인증 토큰 이력 관리 등의 다양한 새로운 기능이 추가되었는데요, 이러한 새 기능을 포함해서 Backend.AI Model Service를 쉽고 간편하게 이해할 수있도록 튜토리얼 형식으로 따라가 보는 시간을 가져보도록 하겠습니다.
    이번 튜토리얼 게시글에서는 Backend.AI Model Service를 이용해서 GPT-NeoX 모델을 Triton Inference Server 위에서 구동하는 법에 대해 안내합니다. Triton Inference Server는 NVIDIA에서 내놓은 오픈 소스 모델 인퍼런스 프레임워크이며, 자사의 TritonRT, FasterTransformer 및 TritonRT-LLM 및 PyTorch, TensorFlow, vLLM 등의 다양한 모델을 HTTP 및 gRPC 1 로 간편하게 제공할 수 있습니다.

    Model VFolder 생성

    1. 데이터 & 폴더 탭으로 이동합니다. "새 폴더" 버튼을 클릭하여 VFolder 생성 다이얼로그를 엽니다.
    2. 새 모델 폴더를 생성합니다. 폴더 이름은 어떻게 적어도 관계 없지만 하단의 "사용 방식" 을 "Model" 로 설정해야 합니다. 모든 값을 지정하였으면 하단의 "생성" 버튼을 클릭합니다. 이제 모델 VFolder가 생성되었습니다.

    FasterTransformer 형식 모델 변환

    1. "세션" 탭으로 이동합니다. "시작" 버튼을 클릭하여 세션 생성 다이얼로그를 엽니다.
    2. "실행 환경" 을 ngc-pytorch 로, 버전23.07 을 선택합니다. 선택이 완료되었으면 오른쪽 아래의 화살표 아이콘을 클릭합니다.
    3. 세션에 탑재할 VFolder를 선택하는 창입니다. 모델을 적재하기 위해 "마운트할 모델 스토리지 폴더" 섹션 아래에서 방금 생성한 VFolder를 선택합니다. 선택이 완료되었으면 오른쪽 아래의 화살표 아이콘을 클릭합니다.
    4. 모델 세션에서 사용할 자원량을 지정하는 창입니다. CPU 코어를 16개 이상, RAM은 128GB 이상 할당하여야 원활한 모델 변환이 가능합니다. 선택이 완료되었으면 오른쪽 아래의 화살표 아이콘을 클릭합니다.
    5. 모든 설정이 올바르게 적용되었는지 확인한 후 아래의 "시작" 버튼을 클릭하여 세션을 시작합니다.
    6. 세션이 생성되면 다음과 같이 앱을 선택하는 팝업이 나타납니다. "Console" 앱을 클릭하여 터미널 환경으로 접근합니다.
    7. 다음 쉘 스크립트를 실행하여 GPT-NeoX 20B 모델을 다운로드하고 FasterTransformer 형식에 맞게 변환합니다. 스크립트에서 <VFolder 이름> 이라고 언급된 부분을 생성한 모델 VFolder 이름으로 치환해서 실행해야 함에 주의하세요.
    cd /home/work/<VFolder 이름>
    pip install -U transformers bitsandbytes
    git clone https://github.com/NVIDIA/FasterTransformer
    git clone https://huggingface.co/ElutherAI/gpt-neox-20b
    cd neo-gptx-20b
    git lfs install
    git lfs pull
    

    GPT-NeoX 20B 모델은 실행에 40GB 이상의 VRAM을 요구합니다. 사용할 물리 GPU의 VRAM이 이보다 작아 모델을 여러 GPU에 나눠서 실행해야 할 경우, -i_g 매개 변수의 숫자를 사용할 GPU 갯수에 맞춰서 조정하세요.

    cd /home/work/<VFolder 이름>
    mkdir -p triton-deploy/gpt-neox-20b-ft
    python ~/<VFolder 이름>/FasterTransformer/examples/pytorch/gptneox/utils/huggingface_gptneox_convert.py \
      -i /home/work/<VFolder 이름>/gpt-neox-20b \
      -o /home/work/<VFolder 이름>/triton-deploy/gpt-neox-20b-ft \
      -i_g 1 \
      -m_n GPT-NeoX-20B
    

    1. 7번까지의 과정을 모두 완료했다면 VFolder 아래에 다음과 같은 폴더들이 존재할 것입니다.
    work@main1[PRRLCIqu-session]:~/GPT-NeoX-Triton-FT$ ls -al
    total 62
    drwxr-xr-x  5 work work 11776 Oct 12 12:14 .
    drwxr-xr-x  9 work work  4096 Oct 12 12:29 ..
    drwxr-xr-x 14 work work 12800 Oct 12 11:24 FasterTransformer
    drwxr-xr-x  3 work work 16896 Oct 12 10:18 gpt-neox-20b
    drwxr-xr-x  3 work work 11776 Oct 12 11:56 triton-deploy
    

    이제 Triton Inference Server의 설정 파일을 추가할 차례입니다. triton-deploy/gpt-neox-20b-ft/config.pbtxt 파일을 생성하고 다음 내용을 추가합니다.

    7번 과정에서 -i_g 매개 변수의 값을 1이 아닌 다른 값으로 설정했을 경우, 아래 설정의 tensor_para_size 값을 -i_g 값과 일치하도록 수정해야 합니다.

    name: "gpt-neox-20b-ft"
    backend: "fastertransformer"
    default_model_filename: "gpt-neox-20b-ft"
    max_batch_size: 1024
    
    model_transaction_policy {
      decoupled: False
    }
    
    input [
      {
        name: "input_ids"
        data_type: TYPE_UINT32
        dims: [ -1 ]
      },
      {
        name: "start_id"
        data_type: TYPE_UINT32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "end_id"
        data_type: TYPE_UINT32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "input_lengths"
        data_type: TYPE_UINT32
        dims: [ 1 ]
        reshape: { shape: [ ] }
      },
      {
        name: "request_output_len"
        data_type: TYPE_UINT32
        dims: [ -1 ]
      },
      {
        name: "runtime_top_k"
        data_type: TYPE_UINT32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "runtime_top_p"
        data_type: TYPE_FP32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "beam_search_diversity_rate"
        data_type: TYPE_FP32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "temperature"
        data_type: TYPE_FP32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "len_penalty"
        data_type: TYPE_FP32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "repetition_penalty"
        data_type: TYPE_FP32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "random_seed"
        data_type: TYPE_UINT64
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "is_return_log_probs"
        data_type: TYPE_BOOL
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "beam_width"
        data_type: TYPE_UINT32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "bad_words_list"
        data_type: TYPE_INT32
        dims: [ 2, -1 ]
        optional: true
      },
      {
        name: "stop_words_list"
        data_type: TYPE_INT32
        dims: [ 2, -1 ]
        optional: true
      },
      {
        name: "prompt_learning_task_name_ids"
        data_type: TYPE_UINT32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "top_p_decay"
        data_type: TYPE_FP32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "top_p_min"
        data_type: TYPE_FP32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      },
      {
        name: "top_p_reset_ids"
        data_type: TYPE_UINT32
        dims: [ 1 ]
        reshape: { shape: [ ] }
        optional: true
      }
    ]
    output [
      {
        name: "output_ids"
        data_type: TYPE_UINT32
        dims: [ -1, -1 ]
      },
      {
        name: "sequence_length"
        data_type: TYPE_UINT32
        dims: [ -1 ]
      },
      {
        name: "cum_log_probs"
        data_type: TYPE_FP32
        dims: [ -1 ]
      },
      {
        name: "output_log_probs"
        data_type: TYPE_FP32
        dims: [ -1, -1 ]
      }
    ]
    instance_group [
      {
        count: 1
        kind: KIND_CPU
      }
    ]
    parameters {
      key: "tensor_para_size"
      value: {
        string_value: "1"
      }
    }
    parameters {
      key: "pipeline_para_size"
      value: {
        string_value: "1"
      }
    }
    parameters {
      key: "data_type"
      value: {
        string_value: "fp16"
      }
    }
    parameters {
      key: "model_type"
      value: {
        string_value: "GPT-NeoX"
      }
    }
    parameters {
      key: "model_checkpoint_path"
      value: {
        string_value: "/models/triton-deploy/gpt-neox-20b-ft/1-gpu"
      }
    }
    parameters {
      key: "enable_custom_all_reduce"
      value: {
        string_value: "0"
      }
    }
    
    1. 마지막으로 Backend.AI Model Service 정의 파일을 VFolder 루트 아래에, model-definition.yaml (model-definition.yml 도 허용) 추가해야 합니다. Triton Inference Server를 실행하기 위한 모델 정의 파일을 자세히 들여다 보겠습니다.
    models:
    - name: "GPT-NeoX"
      model_path: "/models/triton-deploy"
    ...
    

    모델 이름과 모델의 경로를 지정하는 부분입니다.

    여기서 설정한 이름과 경로는 모델 서버 프로세스에서 각각 BACKEND_MODEL_NAME, BACKEND_MODEL_PATH 환경 변수로 접근할 수 있습니다.

    ...
      service:
        start_command:
          - tritonserver
          - --model-repository=/models/triton-deploy
          - --disable-auto-complete-config
          - --log-verbose
          - "1"
    ...
    

    모델 서버 프로세스를 시작하기 위한 명령줄 구문을 정의하는 부분입니다.

    ...
        port: 8000
    ...
    

    모델 서버 프로세스가 노출하는 API 통신용 포트를 기입하는 부분입니다. 지정하지 않은 경우, Triton Inference Server는 기본적으로 HTTP API 통신을 위해 8000 번 포트를 노출합니다. 그러므로 모델 정의 파일에도 해당 포트를 그대로 적어줍니다.

    ...
        health_check:
          path: /v2/health/ready
          max_retries: 3
          max_wait_time: 5
          expected_status_code: 200
    

    Health Check 기능을 활성화 및 설정하는 부분입니다. Health Check 기능이 활성화 된 경우, Backend.AI에서는 해당 경로에 지속적으로 HTTP GET 요청을 보내서 expected_status_code (생략 가능, 기본값 200) 에 해당하는 HTTP 응답 코드를 반환하는지를 검증합니다. 만약 모델 서버가 응답하지 않거나, 혹은 정의되지 않은 응답 코드를 반환하는 경우, Backend.AI는 해당 세션을 불량한 (Unhealthy) 세션으로 판단하고 서비스에서 제외합니다. 세션이 서비스에서 제외되더라도 해당 세션은 자동으로 종료되지 않으며 Model Service 관리자가 컨테이너 로그 등을 확인하여 적절한 조치를 직접 취해야 합니다.
    Health Check 기능은 해당 구문을 완전히 생략하는 것으로 비활성화 시킬 수 있습니다. 이렇게 할 경우 Backend.AI는 모델 서버의 상태를 검사하지 않고 항상 Healthy 상태인 것으로 간주합니다.
    max_wait_time 은 API 응답 Timeout을 정의하는 부분입니다. 초 단위의 숫자를 기입해야 합니다.
    max_retries 는 해당 모델 서버를 Unhealthy 상태로 판단하기 전까지 요청을 재시도하는 회수를 뜻합니다.
    완성된 모델 정의 파일은 다음과 같습니다.

    models:
    - name: "GPT-NeoX"
      model_path: "/models/triton-deploy"
      service:
        start_command:
          - tritonserver
          - --model-repository=/models/triton-deploy
          - --disable-auto-complete-config
          - --log-verbose
          - "1"
        port: 8000
        health_check:
          path: /v2/health/ready
          max_retries: 3
          max_wait_time: 5
    

    모델 정의 파일에 대한 더 자세한 내용은 Backend.AI WebUI 문서 에서 확인하실 수 있습니다.

    이제 Model Service를 실행하기 위한 모든 준비가 완료되었습니다.

    Model Service 생성

    1. "모델 서빙" 탭으로 이동합니다. "서비스 시작" 버튼을 클릭하여 Model Service 생성 창을 엽니다. 각 섹션에 대해 조금 더 상세히 살펴보겠습니다.
      • 서비스 이름: Model Service 이름을 지정하는 칸입니다. Model Service의 이름은 Model Service Endpoint의 Subdomain으로 사용될 수 있습니다 (추후 업데이트 예정).
      • 자원 그룹: Model Service용 Inference Session이 생성 될 자원 그룹을 선택하는 칸입니다.
      • 앱을 외부에 공개: 이 기능이 활성화 될 경우, 모델 서버로 향하는 모든 API 요청은 인증 헤더를 첨부해야 이루어질 수 있습니다. Model Service 인증에 대한 자세한 내용은 Backend.AI WebUI 문서 를 참고하세요.
      • 원하는 라우팅 수: 모델 서버 프로세스가 실행되는 추론 세션 수를 지정하는 칸입니다. 이 값을 1보다 큰 숫자로 설정할 경우 여러 개의 동일한 세션이 생성되고, API 요청은 이 세션들에게 균등하게 분배하는 라운드-로빈 로드 밸런서 기능이 활성화 됩니다. 이 값은 Model Service 생성 이후 언제든지 수정 가능합니다.
      • 추론 세션의 자원량을 지정하는 패널입니다.

    GPT-NeoX 20B 모델은 구동에 최소 40GB 이상의 vRAM을 요구합니다.
    Backend.AI의 fGPU 단위와 vRAM의 관계는 사용 중인 Backend.AI의 설정에 따라 다르게 적용될 수 있습니다. 자세한 사항은 사용 중인 Backend.AI 의 관리자와 상의하세요.

    모든 값을 올바르게 설정했다면 "확인" 버튼을 눌러 Model Service를 생성합니다.
    2. Model Service가 생성되었습니다. 추론 세션의 모델 프로세스가 아직 준비되지 않은 Model Service의 경우 상태가 "PROVISIONING" 에 머무르게 됩니다. "세션" 탭의 "INFERENCE" 섹션을 클릭하면 1에서 생성한 Model Service에 해당하는 추론 세션이 생성되었음을 확인할 수 있습니다. Model Service 관리자는 "제어" 행의 클립보드 아이콘을 클릭하여 추론 세션의 모델 서버 프로세스에 관련된 로그를 확인할 수 있습니다. 3. 모델 서버 프로세스가 정상적으로 실행되면 하단의 라우트의 상태와 상단의 상태가 모두 "HEALTHY" 로 변경되며, "서비스 엔드포인트" 에 Model Service에 접근하기 위한 주소가 나타납니다. 이제 해당 주소를 통해 추론 세션에 실행된 Triton Inference Server에 접근할 수 있습니다.

    마치며

    지금까지 Backend.AI Model Service를 이용해서 LLM 모델 서빙을 시작하는 방법에 대해 알아보았습니다. Model Service 기능은 Backend.AI의 Cloud Beta에서 사용 가능합니다. 지금 여러분만의 모델 서빙을 시작해 보세요!

    1: Backend.AI Model Service에서는 지원하지 않음

    21 November 2023

  • 천고마비의 계절, 컨테이너 다이어트하기

    By 조만석

    들어가며

    대부분의 리눅스 배포판, 예를 들어 우분투(Ubuntu)나 레드햇(RedHat, CentOS)에서는 시스템의 표준 C 라이브러리로 glibc를 사용합니다. 우분투에서는 apt, 레드햇 계열에서는 rpm(yum)으로 OpenSSL과 같은 라이브러리 패키지를 설치하면 기본적으로 glibc와 동적으로 링크됩니다.

    GNU(그누)는 운영체제(Operating System)이자 컴퓨터 소프트웨어의 넓은 범위를 포함하고 있습니다. GNU는 프리소프트웨어재단(FSF)에서 개발하고 유지보수하는 오픈소스입니다. GNU에서 만든 대표적인 것들로는 GCC, G++, Make 등의 컴파일러나 개발 도구가 있으며, GNU는 표준 C 라이브러리로 glibc를 사용합니다. glibc는 GNU Lesser General Public License를 사용합니다.

    musl(머슬)은 MIT 라이선스로 배포되는 리눅스 표준 C 라이브러리입니다. 개발자는 리치 펠커(Rich Felker)이며, glibc가 동적 링크를 사용하는 반면, musl은 정적 링크를 사용하여 POSIX 표준을 준수하는 표준 C 라이브러리를 구현하는 것을 목표로 합니다. 또한, 리눅스, BSD, glibc의 비표준 기능도 구현합니다.

    리눅스 환경에서 glibc와 musl의 차이

    리눅스에서 패키지를 설치하면 기본적으로 glibc를 사용합니다. 보통 gcc를 이용해 C/C++ 프로그램을 빌드해본 경험이 있다면 높은 확률로 glibc 기반의 동적 링크 빌드를 진행하였을 것입니다. 하지만 이렇게 흔히 쓰이는 glibc 동적 빌드 외에도 musl 기반의 동적/정적 빌드를 할 수도 있습니다.

    *-linux-gnu*-linux-musl 사이에는 다음과 같은 차이점이 있습니다.

    | 빌드 타겟 | 표준 C 라이브러리 | 링크방식 | |----------------|-------------------|----------------| | *-linux-gnu | glibc | 동적 링크 | | *-linux-musl | musl | 동적/정적 링크 |

    Rust로 실행파일을 빌드하는 경우를 생각해봅시다. rustup을 이용해 리눅스 환경에 Rust를 설치하면 *-linux-gnu가 기본 타겟으로 선택됩니다.

    별도의 옵션을 지정하지 않으면 Rust는 *-linux-gnu 타겟으로 바이너리를 빌드하고 glibc와 동적으로 링크합니다. 이렇게 빌드한 바이너리를 실행하려면 해당 리눅스 환경에 glibc가 설치되어 있어야 동작합니다. 만약 바이너리가 OpenSSL과 같은 외부 라이브러리에 의존하고 있다면(동적으로 링크되어 있다면), apt와 같은 패키지 관리자를 통해 해당 라이브러리도 설치해주어야 합니다. 이러한 동적 링크 바이너리를 일반 사용자가 실행하려면, 외부 라이브러리에 대한 의존성 정보가 기술된 DEB나 RPM 등의 패키지 형태로 묶어주면 됩니다. 그러면 패키지 관리자가 적절한 종속 라이브러리를 자동으로 찾아서 설치해줍니다. 하지만 패키지 관리자에 등록되지 않은 라이브러리를 사용하는 경우나 동일한 라이브러리더라도 설치된 버전과 개발할 때 사용한 버전 사이에 미묘한 호환성 문제가 있는 경우 빌드한 바이너리가 의도대로 실행되지 않을 가능성도 있습니다.

    Rust는 *-linux-musl 타겟을 지정하면 바이너리를 빌드할 때 musl과 정적으로 링크합니다. OpenSSL과 같은 외부 라이브러리에 의존하는 경우 이것들과도 정적 링크를 사용하여 바이너리에 모두 내장시킵니다. 즉, Rust의 단일 바이너리 파일 안에 이 모든 라이브러리들이 모두 포함되는 상태가 됩니다. 이런 정적 바이너리라면 CPU 아키텍처와 리눅스 커널에서 제공하는 시스템콜 집합만 맞으면 어떤 리눅스 환경에서도 실행할 수 있습니다. DEB나 RPM 등의 패키지를 사용하지 않고도 단일 바이너리만 전달하면 실행할 수 있기 때문에 바이너리를 배포하는 것이 더욱 간편해집니다.

    이렇게 바이너리 배포 과정을 쉽게 만들어주는 *-linux-musl 타겟을 왜 리눅스 환경에서는 기본값으로 사용하지 않는 것일까요?

    그 이유는 musl을 사용하면 빌드 준비가 다소 복잡해지기 때문입니다. 개발자가 만든 바이너리 패키지가 *-linux-musl를 사용하면서 동시에 외부 라이브러리에 의존하는 경우, 그 외부 라이브러리 또한 glibc와 동적으로 링크하는 대신 musl과 정적으로 링크된 것이어야 하기 때문입니다. 따라서 musl용 컴파일러를 사용해서 빌드하고자 하는 프로그램의 본체뿐만 아니라 모든 의존 라이브러리를 소스 코드부터 정적 링크로 빌드해야 합니다.

    다행히도, Rust에서 자주 사용되는 외부 라이브러리라면 처음부터 모든 것을 다 새로 빌드할 필요는 없습니다. 자주 사용되는 라이브러리와 Rust 컴파일러/gcc를 묶은 Docker 이미지를 활용하면 간편하게 musl 기반 정적 빌드를 만들 수 있습니다. (이제부터 등장하는 명령어 예제에서, 각 리눅스 배포판별 컨테이너 환경을 구분하기 위해 임의로 <배포판이름># 프롬프트를 사용하겠습니다.)

    $ docker run -it --name ubuntu ubuntu:22.04 bash
    ubuntu# apt update && apt install -y curl gcc vim
    

    개발에 주로 사용되는 Rust 언어 환경에서 동적 링크인 glibc와 정적 링크인 musl 환경을 구성해보겠습니다. 우선, 우분투 환경에 Rust를 설치합니다.

    ubuntu# curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    ubuntu# source $HOME/.cargo/env
    

    Rust의 기본 예제인 "Hello World" 출력을 통해 동적 링크와 정적 링크를 비교해보겠습니다.

    먼저, glibc를 이용하여 "Hello World"를 빌드해봅시다.

    ubuntu# cd
    ubuntu# cargo new --bin hello && cd $_
         Created binary (application) `hello` package
    ubuntu# cargo build --release
       Compiling hello v0.1.0 (/root/hello)
        Finished release [optimized] target(s) in 0.35s
    

    ldd 명령을 사용하여 glibc 환경에서 라이브러리가 동적 링크로 구성된 것을 확인해봅시다. linux-vdso, libgcc_s, libc 등이 동적 링크로 구성된 것을 확인할 수 있습니다.

    ubuntu# ldd target/release/hello
            linux-vdso.so.1 (0x00007fffe87df000)
            libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007fdce9c3f000)
            libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fdce9a17000)
            /lib64/ld-linux-x86-64.so.2 (0x00007fdce9cc2000)
    

    그러면 musl 정적 링크로 rust 타겟 구성을 변경해봅시다.

    ubuntu# rustup target add x86_64-unknown-linux-musl
    info: downloading component 'rust-std' for 'x86_64-unknown-linux-musl'
    info: installing component 'rust-std' for 'x86_64-unknown-linux-musl'
     34.7 MiB /  34.7 MiB (100 %)   8.6 MiB/s in  4s ETA:  0s
    
    ubuntu# rustup show
    Default host: x86_64-unknown-linux-gnu
    rustup home:  /root/.rustup
    
    installed targets for active toolchain
    --------------------------------------
    
    x86_64-unknown-linux-gnu
    x86_64-unknown-linux-musl
    
    active toolchain
    ----------------
    
    stable-x86_64-unknown-linux-gnu (default)
    rustc 1.72.0 (5680fa18f 2023-08-23)
    
    ubuntu# 
    

    "Hello World"를 빌드하여 정적 링크가 올바르게 구성되었는지 확인하겠습니다.

    ubuntu# cargo build --release --target=x86_64-unknown-linux-musl
       Compiling hello v0.1.0 (/root/hello)
        Finished release [optimized] target(s) in 0.37s
    
    ubuntu# ldd target/x86_64-unknown-linux-musl/release/hello
    statically linked
    

    "Hello World"가 musl 환경을 사용하여 정적 링크로 구성된 것을 확인할 수 있습니다.

    이제 동적 링크와 정적 링크로 빌드된 'Hello World'를 CentOS와 Alpine 환경에서 바이너리를 복사하여 실행해보겠습니다. CentOS 8은 glibc 동적 링크를 사용하고, Alpine 리눅스는 musl 정적 링크를 사용합니다.

    CentOS 컨테이너 환경

    $ docker run -it --name centos centos:centos8 bash
    centos#
    

    Alpine 컨테이너 환경

    Alpine 배포판은 glic가 아닌 musl을 기본으로 사용합니다.

    $ docker run -it --rm alpine:3.18
    alpine#
    

    'Hello World'를 glibc 환경과 musl 환경으로 복사하여 동작을 확인하겠습니다.

    $ docker cp ubuntu:/root/hello/target/x86_64-unknown-linux-musl/release/hello .
    $ docker cp hello centos:/root/
    $ docker cp hello alpine:/root/
    

    centOS에서 동작을 확인하겠습니다.

    centos# ./hello
    Hello, world!
    

    alpine에서 동작을 확인하겠습니다.

    alpine# ./hello
    Hello, world!
    

    Rust 어플리케이션 'slice'를 사용한 glibc와 musl 비교

    Rust 어플리케이션 'slice'를 가지고 glibc와 musl을 적용해서 만든 컨테이너 이미지를 비교해 보겠습니다.

    Python의 'slice'와 같이 Rust로 구현된 'slice'는 GitHub 저장소 https://github.com/ChanTsune/slice 에 공개되어 있습니다. 'slice'는 'head'나 'tail'처럼 파일의 앞 혹은 뒤에서부터 내용을 출력해주는 도구입니다. 예를 들어, 아래의 명령은 'file.txt'에서 10번째 줄부터 20번째 줄까지 출력하게 됩니다.

    $ slice 10:20 file.txt
    

    'slice'를 Rust 환경에서 빌드하고 컨테이너를 만들어 사용할 때는 다음과 같이 사용할 수 있습니다.

    $ docker run -i --rm -v `pwd`:`pwd` -w `pwd` slice
    

    Ubuntu 22.04 환경에서 glibc를 사용한 컨테이너를 빌드해보겠습니다.

    FROM rust:latest as builder
    
    WORKDIR /work
    RUN git clone https://github.com/ChanTsune/slice /work/.
    RUN cargo build --release
    RUN strip /work/target/release/slice -o /slice
    
    FROM ubuntu:22.04
    COPY --from=builder /slice /usr/local/bin/
    
    ENTRYPOINT ["slice"]
    

    이번에는 musl 정적 링크를 사용하여 Ubuntu 22.04 기반의 컨테이너 이미지를 만들어 보겠습니다.

    FROM rust:latest as builder
    
    RUN rustup target add "$(uname -m)"-unknown-linux-musl
    WORKDIR /work
    RUN git clone https://github.com/ChanTsune/slice /work/.
    RUN cargo build --release --target "$(uname -m)"-unknown-linux-musl
    RUN strip /work/target/"$(uname -m)"-unknown-linux-musl/release/slice -o /slice
    
    FROM ubuntu:22.04
    COPY --from=builder /slice /usr/local/bin/
    
    ENTRYPOINT ["slice"]
    

    musl 정적 링크를 사용하여 Alpine 배포판 기반의 컨테이너 이미지를 만들어 보겠습니다.

    FROM rust:latest as builder
    
    RUN rustup target add "$(uname -m)"-unknown-linux-musl
    WORKDIR /work
    RUN git clone https://github.com/ChanTsune/slice /work/.
    RUN cargo build --release --target "$(uname -m)"-unknown-linux-musl
    RUN strip /work/target/"$(uname -m)"-unknown-linux-musl/release/slice -o /slice
    
    FROM alpine
    COPY --from=builder /slice /
    
    ENTRYPOINT ["slice"]
    

    Ubuntu 22.04 기반의 glibc 컨테이너 이미지와 musl 컨테이너 이미지, 그리고 Alpine 기반의 musl 컨테이너 이미지의 크기를 비교해보면 musl을 사용한 컨테이너 이미지의 크기가 더 작은 것을 확인할 수 있습니다.

    $ docker images 
    REPOSITORY TAG               IMAGE ID       CREATED              SIZE
    slice      distroless-musl   d38a74f8568a   11 seconds ago        3.52MB
    slice      alpine-musl       e3abb5f0aace   39 seconds ago        8.4MB
    slice      ubuntu22.04-musl  467edd130e79   About a minute ago   78.9MB
    slice      ubuntu22.04-glibc 09fe5ad40d56   3 minutes ago        78.8MB
    

    우분투 환경에서는 glibc나 musl을 사용하더라도 컨테이너 이미지 크기에 큰 차이가 없지만, Alpine 배포판에서는 컨테이너 이미지 크기가 약 10분의 1로 줄어든 것을 확인할 수 있습니다. 이를 통해 정적 빌드를 사용하는 Alpine 리눅스를 활용하면 컨테이너 이미지를 가볍게 만들고 배포 시간을 단축할 수 있음을 알 수 있습니다.

    맺음말

    표준 C 라이브러리를 사용하는 프로그램에서 정적 링크를 사용하면 리눅스 바이너리 배포 과정을 단순화할 수 있습니다. 또한 컨테이너 이미지 크기가 동적 링크에 비해 작아지며, 배포판에 관계 없이 배포가 편리해집니다. glibc를 musl로 대체했을 때, 컨테이너 이미지 크기의 차이뿐만 아니라 musl에서 새롭게 지원하는 mDNS(a multicast-DNS-based zero config system), NUMA cluster와 같은 기능을 사용할 수 있는 이점이 있습니다. 더 나아가, musl을 보다 잘 활용하기 위해 구글에서 배포하는 distroless를 기본 컨테이너 이미지로 사용하면, 더 작은 컨테이너 이미지를 배포하여 활용할 수 있습니다.

    20 September 2023

  • bitsandbytes 이슈 삽질기

    By 강정석

    바야흐로 LLM(Large Language Model, 대형 언어 모델)의 시대입니다. 2022년 11월, OpenAI가 발표한 ChatGPTAlphaGo의 자리를 이어받아 현대 인공지능의 대명사가 되었습니다. 많은 기업과 연구소에서는 ChatGPT 기반의 자체적인 언어 모델을 개발하는 데 힘을 쏟고 있으며, Meta AI의 Llama 2와 같이 오픈 소스로 공개되는 사례도 증가하고 있어 개인의 접근성도 높아지고 있습니다.

    Backend.AI는 대규모 클러스터 운용과 분산처리에 편리성을 제공하고 있어 이러한 LLM을 개발하기 위한 환경으로써 많은 선택을 받고 있습니다. 실제로 다양한 고객사로부터 관련 피드백과 요청을 받고 있으며, 오늘은 그중에 하나를 해결한 과정을 다뤄보고자 합니다.

    2023년 4월 4일, NGC Catalog[^1](NVIDIA GPU Cloud)에서 제공하는 컨테이너 환경에서 특정 패키지를 실행할 때 에러가 발생한다는 이슈를 전달받았습니다. NGC Catalog는 AI/ML, 메타버스, 그리고 고성능 컴퓨팅 어플리케이션을 개발하기 위해 최적화된 환경이 구성된 컨테이너 목록[^2]으로, NVIDIA에서 직접 운영하고 배포하기 때문에 높은 신뢰도를 얻고 있으며 특히 CUDA 환경에서의 표준으로 여겨지고 있습니다. 따라서 해당 환경에서 문제가 발생한다는 것은 앞으로도 다수의 사용자가 마주하게 될 잠재적 위험을 안고 간다는 의미이기에 높은 우선순위로 이 이슈를 해결하기로 했습니다.

    문제 재현

    우선 정확한 원인을 파악하기 위하여 문제를 재현하는 과정을 먼저 거쳤습니다. 이번 사례는 Columbia University에서 개발한 ViperGPT[^3]를 실행하던 중 bitsandbytes라는 패키지에서 에러가 발생한 경우였습니다. ViperGPT는 아래와 같이 bitsandbytes에 의존성을 갖고 있습니다.

    accelerate==0.18.0
    backoff==2.2.1
    // highlight-next-line
    bitsandbytes==0.38.1
    cityscapesscripts==2.2.1
    git+https://github.com/openai/CLIP.git
    decord==0.6.0
    dill==0.3.6
    ...
    

    단순히 bitsandbytesimport 하는 것만으로 문제 재현이 가능했습니다.

    실행 환경은 nvcr.io/nvidia/pytorch:22.05-py3 이미지를 이용했습니다.

    $ pip install bitsandbytes  # 0.37.1
    $ python
    >> import bitsandbytes
    ===================================BUG REPORT===================================
    Welcome to bitsandbytes. For bug reports, please submit your error trace to: https://github.com/TimDettmers/bitsandbytes/issues
    ================================================================================
    CUDA exception! Error code: OS call failed or operation not supported on this OS
    CUDA exception! Error code: initialization error
    CUDA SETUP: CUDA runtime path found: /home/work/data/miniconda3/envs/vipergpt/lib/libcudart.so
    /home/work/data/miniconda3/envs/vipergpt/lib/python3.10/site-packages/bitsandbytes/cuda_setup/main.py:136: UserWarning: WARNING: No GPU detected! Check your CUDA paths. Proceeding to load CPU-only library...
      warn(msg)
    CUDA SETUP: Detected CUDA version 116
    CUDA SETUP: Loading binary /home/work/data/miniconda3/envs/vipergpt/lib/python3.10/site-packages/bitsandbytes/libbitsandbytes_cpu.so...
    /home/work/data/miniconda3/envs/vipergpt/lib/python3.10/site-packages/bitsandbytes/cextension.py:31: UserWarning: The installed version of bitsandbytes was compiled without GPU support. 8-bit optimizers and GPU quantization are unavailable.
      warn("The installed version of bitsandbytes was compiled without GPU support. "
    

    bitsandbytes는 실행 환경에 설치된 모든 CUDA 디바이스를 순회하며 Compute Capability[^4]를 확인합니다. 이때 아래와 같은 방식으로 libcuda.so를 이용하여 실행 환경에 설치된 CUDA 디바이스의 개수를 확인하도록 되어 있었습니다. 그중 cuDeviceGetCount()[^5]를 호출할 때 에러가 발생하는 것을 확인했습니다. 바로 304 CUDA_ERROR_OPERATING_SYSTEM 에러였습니다.

    def get_compute_capabilities(cuda):
        """
        1. find libcuda.so library (GPU driver) (/usr/lib)
           init_device -> init variables -> call function by reference
        2. call extern C function to determine CC
           (https://docs.nvidia.com/cuda/cuda-driver-api/group__CUDA__DEVICE__DEPRECATED.html)
        3. Check for CUDA errors
           https://stackoverflow.com/questions/14038589/what-is-the-canonical-way-to-check-for-errors-using-the-cuda-runtime-api
        # bits taken from https://gist.github.com/f0k/63a664160d016a491b2cbea15913d549
        """
    
        nGpus = ct.c_int()
        cc_major = ct.c_int()
        cc_minor = ct.c_int()
    
        device = ct.c_int()
    
        # highlight-next-line
        check_cuda_result(cuda, cuda.cuDeviceGetCount(ct.byref(nGpus)))
        ccs = []
        for i in range(nGpus.value):
            check_cuda_result(cuda, cuda.cuDeviceGet(ct.byref(device), i))
            ref_major = ct.byref(cc_major)
            ref_minor = ct.byref(cc_minor)
            # 2. call extern C function to determine CC
            check_cuda_result(cuda, cuda.cuDeviceComputeCapability(ref_major, ref_minor, device))
            ccs.append(f"{cc_major.value}.{cc_minor.value}")
    
        return ccs
    

    bitsandbytes란?

    Transformer의 등장 이래로 언어 모델은 높은 성능 향상을 보였고, 더 많은 Transformer 블록을 쌓아 모델의 규모를 키우는 것이 트렌드가 되었습니다. 이로 인해 모델을 학습시키는 것뿐만 아니라 서비스할 때마저 수많은 GPU 자원을 요구하게 되었습니다. 예를 들어, 175B의 파라미터를 가지고 있는 GPT-3를 서비스하기 위해서는 약 $15,000의 80GB A100 GPU가 8개 필요합니다. 총 $120,000의 비용이 요구된다는 의미입니다. 이것은 개인뿐만 아니라 기업 혹은 연구소에도 큰 부담이 될 수밖에 없고, 이에 따라 서비스를 위한 추론 모델을 경량화하는 연구가 활발하게 진행되고 있습니다.

    이미지 출처: A Gentle Introduction to 8-bit Matrix Multiplication for transformers at scale using Hugging Face Transformers, Accelerate and bitsandbytes (Hugging Face)

    bitsandbytes는 University of Washington의 박사과정 Tim Dettmers가 Facebook AI Research(現 Meta AI)와 함께한 연구인 LLM.int8()[^6]를 오픈 소스로 공개한 것입니다. 행렬 곱을 연산할 때 각 벡터를 독립적으로 처리하는 Vector-wise Quantization 방법을 적용하고, 중요한 벡터는 16-bit로 표현하여 손실을 최소화하는 등 8-bit와 16-bit를 혼용하는 기법을 통해 모델의 성능은 유지하면서 크기를 줄이는 성과를 보였습니다. Hugging Face의 Transformer 구현체에도 병합이 되었으며, Llama2, QLoRA, KoAlpaca, 그리고 KULLM 등의 다양한 모델에서 사용되고 있습니다.

    원인 파악

    문제가 발생하는 지점을 찾아 재현까지 완료하였으니 이제 본격적으로 원인을 파악해야 합니다. 비슷한 사례가 있을지 조사해 봤으나 찾아볼 수 없었습니다. 또한 cuInit()은 정상적으로 호출되었기 때문에 더 원인을 파악하기 어려웠습니다.

    import ctypes
    
    count = ctypes.c_int()
    
    libcuda = ctypes.CDLL("libcuda.so")
    libcuda.cuInit(0)  # 0 (CUDA_SUCCESS)
    libcuda.cuDeviceGetCount(ctypes.byref(count))  # 304 (CUDA_ERROR_OPERATING_SYSTEM)
    
    libcudart = ctypes.CDLL("libcudart.so")
    libcudart.cudaGetDeviceCount(ctypes.byref(count))  # 304 (CUDA_ERROR_OPERATING_SYSTEM)
    

    조언을 얻기 위하여 아래와 같이 GitHub 레포지토리에 이슈(TimDettmers/bitsandbytes#264)를 등록했고, 패키지를 최신 버전으로 업데이트한 후 다시 시도해 보라는 답을 받을 수 있었습니다. 당시 최신이었던 0.38.0.post1 버전으로 올린 후 다시 테스트했지만 동일한 문제가 발생했습니다. 시간을 너무 지체할 수 없었기 때문에 생각을 전환하여 문제가 되는 부분을 제거하기로 했습니다.

    이미지 출처: 만화로 보는 그리스 로마 신화 (가나출판사)

    문제 해결

    첫 번째 접근은 CUDA-Python[^7]을 사용하는 것이었습니다. CUDA-Python은 NVIDIA에서 공식적으로 배포하는 CUDA Python Low-Level Bindings 패키지입니다. 이전에도 유용하게 사용한 경험이 있어서 바로 떠올릴 수 있었고, 바로 설치 및 테스트를 해보기로 했습니다.

    $ pip install cuda-python
    
    from cuda import cuda
    from cuda import cudart
    
    cuda.cuInit(0)  # (<CUresult.CUDA_SUCCESS: 0>,)
    cudart.cudaGetDeviceCount()  # (<cudaError_t.cudaSuccess: 0>, 1)
    

    다행히 cudart.cudaGetDeviceCount()가 정상적으로 동작하였고 곧바로 bitsandbytes에 통합하는 테스트를 진행했습니다. 하지만 cuda.cuInit(0)를 호출한 후 torch.cuda.is_available()을 호출하면 에러가 발생했습니다. torch.cuda.is_available() 내부에서 cudaGetDeviceCount()를 호출했기 때문입니다.

    from cuda import cuda, cudart
    
    cuda.cuInit(0)  # <CUresult.CUDA_SUCCESS: 0>,)
    cuda.cudaGetDeviceCount()  # (<cudaError_t.cudaSuccess: 0>, 1)
    
    import bitsandbytes
    
    # ...
    # /opt/conda/lib/python3.8/site-packages/torch/cuda/__init__.py:82: UserWarning: CUDA initialization: Unexpected error from cudaGetDeviceCount(). Did you run some cuda functions before calling NumCudaDevices() that might have already set an error? Error 304: OS call failed or operation not supported on this OS (Triggered internally at /opt/pytorch/pytorch/c10/cuda/CUDAFunctions.cpp:109.)
    #   return torch._C._cuda_getDeviceCount() > 0
    # ...
    

    문제는 다시 원점으로 돌아온 것 같았습니다. 숨을 한번 고르고 위의 에러 로그를 차분하게 다시 읽었습니다. 그러자 무언가 눈에 들어왔습니다.

    torch._C._cuda_getDeviceCount() > 0

    bitsandbytes는 이미 내부적으로 PyTorch를 사용하고 있었습니다. 즉, PyTorch에 대한 의존성을 가지고 있었습니다. 정확히는 bitsandbytes의존성을 갖는 lion-pytorch가 PyTorch에 대한 의존성을 가지고 있었습니다. 그리고 PyTorch에는 이미 CUDA 함수들에 대한 인터페이스가 존재했습니다. 이번에는 이걸 이용해 보기로 했습니다.

    다행히 PyTorch에는 bitsandbytes에서 사용하는 CUDA 함수들이 모두 존재했습니다. 기존에 libcuda.solibcudart.so를 통해 호출되던 함수들을 아래와 같이 변경했습니다.

    libcuda/libcudarttorch
    libcuda.cuDeviceGetCount()torch.cuda.device_count()
    libcuda.cuDeviceGet()torch.cuda.device()
    libcuda.cuDeviceComputeCapability()torch.cuda.get_device_capability()
    libcudart.cudaRuntimeGetVersion()torch.version.cuda

    변경 후 정상적으로 동작하는 것을 확인한 후, 배포 패키지 버전에 적용하기 위하여 GitHub 레포지토리에 PR을 등록했습니다(TimDettmers/bitsandbytes#375).

    후기

    PR을 등록한 지 약 두 달이 지난 2023년 7월 14일, 해당 패치가 main 브랜치에 병합되었고 0.40.1 버전에 포함되었습니다.

    또한 저자인 Tim Dettmers로부터 피드백을 얻을 수 있었습니다. 이 짧은 글에서 저자의 생각과 철학을 느낄 수 있었습니다.

    이번 기회를 통해 LLM의 생태계에 대해서 더 자세하게 알아볼 수 있었습니다. 또한 오랜만에 오픈 소스 활동의 재미를 느낄 수 있는 시간이었습니다. 공간적 제약을 뛰어넘어 협업할 수 있다는 점, 그리고 서로의 생각을 나누며 배워갈 수 있다는 점이 오픈 소스 활동의 매력인 것 같습니다. Backend.AI는 엔터프라이즈 버전과 함께 오픈 소스 버전을 운영하고 있습니다. 항상 더 나은 사용자 경험, 그리고 더 나은 개발자 경험을 제공할 수 있도록 노력하겠습니다.

    [^1]: NVIDIA GPU Cloud [^2]: The NGC catalog hosts containers for AI/ML, metaverse, and HPC applications and are performance-optimized, tested, and ready to deploy on GPU-powered on-prem, cloud, and edge systems. [^3]: ViperGPT: Visual Inference via Python Execution for Reasoning, March 14, 2023. [^4]: https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#compute-capability [^5]: https://docs.nvidia.com/cuda/cuda-driver-api/group__CUDA__DEVICE.html#group__CUDA__DEVICE_1g52b5ce05cb8c5fb6831b2c0ff2887c74 [^6]: LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale, November 10, 2022. [^7]: https://developer.nvidia.com/cuda-python

    28 July 2023

  • Sneak Peek: Backend.AI Model Service 미리 보기

    By 조규진

    들어가며

    초거대 AI 모델들이 시장에 홍수처럼 쏟아지면서 모델을 개발하는 것 뿐만 아니라 어떻게 사용자에게 "잘", "효율적으로" 제공할 것이냐에 대한 고민이 늘어가고 있습니다. 거대 언어 모델 (Large Language Model, LLM) 이전의 AI 모델의 컴퓨팅 역량은 추론보다는 학습에 집중되었습니다. 학습이 완료된 모델으로 추론을 시도하기 위한 하드웨어 요구사항이 모델을 학습하는 데에 필요한 컴퓨팅 파워보다 월등히 작았기 때문입니다. 모델의 배포자는 실 사용자의 엔드 디바이스 (가령 스마트폰과 같은) 의 NPU 만으로도 추론을 위한 충분한 성능을 확보할 수 있었습니다. 그러나 LLM이 나타나며 상황이 역전되었습니다.

    Meta의 OPT 175b 를 예로 들어보겠습니다. OPT-175b는 이름에서 유추할 수 있듯 1750억 개의 파라미터를 보유하고 있으며, 추론 작업을 시행하기 위해 이를 GPU에 적재하는 데에만 대략 320GB 이상의 GPU 메모리를 필요로 합니다. LLM 이전에 유행했던 이미지 처리 계통의 모델들의 최대 요구치였던 4GB에 비하면 엄청나게 큰 차이입니다.
    AI 모델의 행태가 이렇게 변화하다 보니, 서비스 자원을 효율적으로 관리하는 것이 안정적으로 서비스를 운영하는 데에 무엇보다도 중요하게 작용하기 시작했습니다. 이번 글에서는 곧 출시될 Backend.AI의 모델 서비스 기능인 Backend.AI Model Service를 미리 살펴보며, Backend.AI를 이용하면 어떻게 AI 모델 훈련부터 서빙까지 하나의 인프라로 효율적으로 운용할 수 있을지에 대해 살펴보겠습니다.

    Backend.AI Model Service

    Backend.AI Model Service는 기존의 Backend.AI 솔루션 위에서 동작하는 모델 서빙 시스템입니다. 이미 많은 사례를 통해 안정성을 인정받은 Backend.AI의 컨테이너 관리 기술과 컨테이너 앱 제공 시스템인 AppProxy[^1]를 한 단계 더 고도화 하여, 추가적인 컴포넌트 설치 없이 기존의 Backend.AI 인프라 업그레이드만으로 하나의 인프라에서 AI 훈련과 모델 서비스를 모두 가능하게 합니다. 세션 별 GPU 사용량, API 호출 횟수 혹은 시간대 등에 따라서 자동으로 추론 세션의 규모를 확장 및 축소하는 오토 스케일링 기능 또한 지원하여 추론에 사용되는 AI 자원을 효과적으로 관리할 수 있습니다.

    추론 세션

    Backend.AI에서의 추론 세션은 기존의 훈련 세션과 개념적으로 동일합니다. 기존에 훈련을 위해 사용하던 실행 환경을 그대로 추론 세션에서 사용할 수도 있고, 추론 세션만을 위한 전용의 실행 환경을 배포할 수도 있습니다. 추론 세션은 휘발성이며 Stateless 하므로 세션의 상태가 좋지 않을 경우 언제든지 종료할 수 있습니다. 이 경우 Backend.AI에서는 새로운 추론 세션을 생성함으로써 원래의 상태를 복구하려고 시도함과 동시에 추론 요청을 다른 살아있는 추론 세션에게 전달하여서 추론 서비스의 Downtime을 최소화합니다.

    모델 스토리지

    Backend.AI를 통해 서비스를 제공할 모델들은 "모델 스토리지" 단위로 관리됩니다. 모델 스토리지는 모델 파일과 모델 서비스를 위한 코드, 그리고 모델 정의 파일로 이루어져 있습니다.

    모델 정의 파일

    모델 정의 파일은 서비스 제공자의 모델을 Backend.AI Model Service에서 실행하기 위한 정보를 정의하는 공간입니다. 모델 정의 파일에는 모델의 정보, 모델 서비스가 노출하는 포트, 모델 서비스를 실행하기 위해 실행해야 하는 일련의 작업들이 포함됩니다. 모델 서비스에서 자신의 상태를 보고하는 Health Check 기능을 제공할 경우, 해당 정보를 이용하여 불량 상태인 세션의 경우 서비스에서 제외하는 등의 조치가 가능합니다.

    models:
      - name: "KoAlpaca-5.8B-model"
        model_path: "/models/KoAlpaca-5.8B"
        service:
          pre_start_actions:
            - action: run_command
              args:
                command: ["pip3", "install", "-r", "/models/requirements.txt"]
          start_command:
            - uvicorn
            - --app-dir
            - /models
            - chatbot-api:app
            - --port
            - "8000"
            - --host
            - "0.0.0.0"
          port: 8000
          health_check:
            path: /health
            max_retries: 10
    

    다음은 잘 정의된 모델 정의 파일의 예시입니다. 이 예시는 KoAlpaca 5.8B 모델 을 모델 서비스로 실행하기 위한 일련의 과정을 담고 있습니다.

    튜토리얼: Backend.AI Model Service를 통해 모델 서비스 해 보기

    실제로 Backend.AI를 이용하여 이번 튜토리얼에서는 8bit로 양자화 된 KoAlpaca 5.8B 모델 을 서비스 하는 과정을 따라가 보겠습니다.

    API 서버 코드 작성

    모델을 제공하기 위한 간단한 API 서버를 작성합니다.

    import os
    from typing import Any, List
    
    from fastapi import FastAPI, Response
    from fastapi.responses import RedirectResponse, StreamingResponse, JSONResponse
    from fastapi.staticfiles import StaticFiles
    import numpy as np
    from pydantic import BaseModel
    import torch
    from transformers import pipeline, AutoModelForCausalLM
    import uvicorn
    
    URL = "localhost:8000"
    KOALPACA_MODEL = os.environ["BACKEND_MODEL_PATH"]
    
    torch.set_printoptions(precision=6)
    
    app = FastAPI()
    
    model = AutoModelForCausalLM.from_pretrained(
        KOALPACA_MODEL,
        device_map="auto",
        load_in_8bit=True,
    )
    
    
    pipe = pipeline(
        "text-generation",
        model=model,
        tokenizer=KOALPACA_MODEL,
    )
    
    
    class Message(BaseModel):
        role: str
        content: str
    
    
    class ChatRequest(BaseModel):
        messages: List[Message]
    
    
    BASE_CONTEXTS = [
        Message(role="맥락", content="KoAlpaca(코알파카)는 EleutherAI에서 개발한 Polyglot-ko 라는 한국어 모델을 기반으로, 자연어 처리 연구자 Beomi가 개발한 모델입니다."),
        Message(role="맥락", content="ChatKoAlpaca(챗코알파카)는 KoAlpaca를 채팅형으로 만든 것입니다."),
        Message(role="명령어", content="친절한 AI 챗봇인 ChatKoAlpaca 로서 답변을 합니다."),
        Message(role="명령어", content="인사에는 짧고 간단한 친절한 인사로 답하고, 아래 대화에 간단하고 짧게 답해주세요."),
    ]
    
    
    def preprocess_messages(messages: List[Message]) -> List[Message]:
        ...
    
    
    def flatten_messages(messages: List[Message]) -> str:
        ...
    
    
    def postprocess(answer: List[Any]) -> str:
        ...
    
    
    @app.post("/api/chat")
    async def chat(req: ChatRequest) -> StreamingResponse:
        messages = preprocess_messages(req.messages)
        conversation_history = flatten_messages(messages)
        ans = pipe(
            conversation_history,
            do_sample=True,
            max_new_tokens=512,
            temperature=0.7,
            top_p=0.9,
            return_full_text=False,
            eos_token_id=2,
        )
        msg = postprocess(ans)
    
        async def iterator():
            yield msg.strip().encode("utf-8")
    
        return StreamingResponse(iterator())
    
    
    @app.get("/health")
    async def health() -> Response:
        return JSONResponse(content={"healthy": True})
    
    
    @app.exception_handler(404)
    async def custom_404_handler(_, __):
        return RedirectResponse("/404.html")
    
    
    app.mount(
        "/",
        StaticFiles(directory=os.path.join(KOALPACA_MODEL, "..", "chatbot-ui"), html=True),
        name="html",
    )
    

    모델 정의 파일 작성

    API 서버에 맞추어 모델 정의 파일을 작성합니다.

    models:
      - name: "KoAlpaca-5.8B-model"
        model_path: "/models/KoAlpaca-Ployglot-5.8B"
        service:
          pre_start_actions:
            - action: run_command
              args:
                command: ["pip3", "install", "-r", "/models/requirements.txt"]
          start_command:
            - uvicorn
            - --app-dir
            - /models
            - chatbot-api:app
            - --port
            - "8000"
            - --host
            - "0.0.0.0"
          port: 8000
          health_check:
            path: /health
            max_retries: 10
    

    모델 서비스의 세션에서 모델 스토리지는 항상 /models 경로 아래에 탑재됩니다.

    모델 스토리지 준비

    작성한 모델 API 서버 코드와 모델 정의 파일, 그리고 KoAlpaca 모델을 모델 스토리지에 추가합니다.

    모델 서비스 생성

    모델 파일과 모델 정의 파일이 모두 준비가 되었다면 이제 Backend.AI Model Service를 시작할 수 있습니다. Model Service는 Backend.AI CLI의 backend.ai service create 명령을 통해 생성이 가능합니다. service create 가 허용하는 인자들은 backend.ai session create 명령과 거의 동일합니다. 사용할 이미지 뒤에는 모델 스토리지의 ID와 초기에 생성할 추론 세션의 갯수를 전달해 줍니다.

    backend.ai service info 를 이용하면 모델 서비스 및 서비스에 속한 추론 세션 상태를 확인할 수 있습니다. 1개의 추론 세션이 잘 생성되었음을 알 수 있습니다.

    추론 API 사용

    backend.ai service get-endpoint 명령을 이용하면 생성된 모델 서비스의 추론 엔드포인트를 확인할 수 있습니다. 추론 엔드포인트는 하나의 모델 서비스가 생성되고 제거되기 전까지 계속 고유한 값을 가집니다. 하나의 모델 서비스에 여러 개의 추론 세션이 속해 있을 경우 AppProxy는 여러 추론 세션에 요청을 분산합니다.

    추론 API 접근 제한

    추론 API에 접근 가능한 사용자를 제한하고자 하는 경우, --public 옵션을 제거한 채로 모델 서비스를 시작하면 추론 API에 인증 기능을 활성화할 수 있습니다. 인증 토큰은 backend.ai service generate-token 명령으로 발급할 수 있습니다.

    추론 세션 스케일링

    backend.ai service scale 명령을 이용하면 모델 서비스에 속한 추론 세션의 규모를 변경할 수 있습니다.

    마치며

    지금까지 Backend.AI Model Service와 Model Service 기능을 통해 실제로 모델 서비스를 배포하는 법에 대해 알아보았습니다. Backend.AI Model Service는 Backend.AI 23.03 버전에 정식 배포를 목표로 하고 있습니다. 빠른 시일 내에 Model Service 기능을 정식으로 선보일 수 있도록 노력하고 있으니 많은 기대 부탁드립니다.


    [^1]: Backend.AI Enterprise 부터 사용 가능.

    30 May 2023

  • Concurrent React가 가져온 변화: 급하지 않은 렌더링 구분하기

    By 이종은

    Backend.AI의 MLOps 플랫폼인 FastTrack은 React 18을 사용하고 있습니다. React 18의 Concurrent 렌더러 덕분에 가능해진 급하지 않은 렌더링 구분하기에 대해 알아보겠습니다.

    React의 Concurrent 기능은 Async Rendering이라는 이름으로 JSConf Iceland 2018 에서 처음 외부에 공개된 이후에 2022년이 되어서야 정식 기능으로 React 18에 포함되었습니다. 이 기간에서 예상할 수 있듯이 Concurrent 렌더러는 React 18에서 가장 크고 중요한 변화에 해당합니다. 렌더러가 변경되었지만 React 개발자들은 큰 변경 없이 React 18 이전 버전에서 제작한 React 코드를 React 18에서 실행 가능합니다. 심지어 React의 Concurrent 렌더러를 모르더라도 React로 UI를 만들 수 있습니다. 하지만 이 Concurrent 렌더러가 무엇이고 어떤 상황에서 유용한지를 이해한다면 React로 개발할 때 복잡했던 머릿속이 간단명료해지고 보다 나은 UX를 제공하는 UI를 개발할 수 있습니다. 이 글에서는 Concurrent 렌더러가 내부적으로 어떻게 동작하는지에 대해 이야기하지 않습니다. React를 이용해서 애플리케이션을 만드는 개발자에게 더 중요하다고 할 수 있는 Concurrent 렌더러가 무엇이며 React 개발자가 사고하는 방식이 어떻게 바뀔 수 있는지에 대하여 살펴봅니다.

    이 글의 내용을 요약해 보자면 다음과 같습니다.

    Concurrent 렌더러 덕분에

    • 컴포넌트 렌더링은 중단될 수 있습니다.
    • 화면에 보이지 않는 곳에서 트리의 일부를 렌더링 할 수 있습니다.
    • 이로 인해 React 개발자가 이전과는 달리 급하지 않은 렌더링을 구분할 수 있습니다.

    “React 컴포넌트는 추상적으로 순수 함수이다.”

    React 컴포넌트는 실제로 자바스크립트 함수로 만듭니다.(클래스로 만드는 방법도 있지만 대부분의 경우 추천하지 않습니다.) 함수는 입력을 주면 출력을 만듭니다. 입력이 변하면 출력이 달라질 수 있으므로 함수를 실행해서 새로운 출력을 만듭니다. (순수 함수는 입력이 같으면 출력이 같습니다.)

    React 컴포넌트의 입력과 출력은 무엇인가요?
    React 컴포넌트의 입력은 해당 컴포넌트가 함수로서 받게 되는 property(React에서는 props라 함)이며 출력은 함수가 리턴하는 React 엘리먼트입니다.

    hook을 통한 state도 입력일까요?
    hook도 추상적으로 함수의 입력이라 할 수 있습니다. React props와 동일하게 값이 변하면 다시 렌더링하게 만드는 트리거(trigger)이며 이 변화를 통해 React 컴포넌트의 출력을 달라지게 합니다.

    자, 다시 렌더링에 대한 이야기로 돌아가겠습니다.

    컴포넌트 렌더링은 중단될 수 있다.

    Concurrent React의 핵심은 '렌더링은 중단될 수 있다.'입니다. React 18 이전에는 중단될 수 없었습니다(experimental 제외). React 컴포넌트가 함수로서 렌더링을 위해 실행되게 되면 return 하기 전까지 그 어떤 자바스크립트 연산도 실행할 수 없었습니다. 렌더링을 위한 함수의 실행이 오래 걸린다면 엘리먼트를 return하기 전까지 사용자의 클릭을 처리하는 이벤트 핸들러 함수를 실행할 수 없다는 얘기입니다. 하지만 18버전부터는 중단될 수 있습니다.

    const A = ({ count }) => {
      return (
        <div>
          <span>{count}</span>
          <B/>
          <C/>
        </div>
      );
    };
    
    const B = () => {
      const [text, setText] = useState("");
      return (
        <div>
          <input value={text} onChange={(e) => setText(e.target.value)} />
          <D/>
        </div>
      );
    };
    
    const C = () => {
      return <span>C</span>;
    };
    
    const D = ({ text }) => {
      verySlowFunction(text); //연산이 수 초 걸리는 함수라 가정합시다.
      return <span>D</span>;
    };
    

    React 18 이전 버전에서는 A를 렌더링하면 B와 C가 렌더링되어야 하고, B를 렌더링하려면 D가 렌더링이 되어야 했습니다. A의 렌더링을 시작하면 필요한 B, C, D 가 모두 렌더링된 후 A의 리턴 값인 React 엘리먼트를 리턴하기까지 다른 자바스크립트 연산을 수행할 수 없습니다. A에서 리턴하는 컴포넌트 트리가 한 덩어리처럼 렌더링 됩니다. A의 렌더링이 시작되면 A의 렌더링을 중간에 중단하는 건 불가능했습니다.

    Concurrent React에서는 렌더링을 중간에 중단할 수 있습니다. 렌더링을 중단하는 것은 왜 필요한 것일까요? 다음을 생각해 볼 수 있습니다.

    • 지금 진행 중인 렌더링이 더 이상 유효하지 않을 때(stale)
      • 예를 들어 위 코드에서 A의 count prop이 1인 상태로 렌더링 중인 상황을 생각해봅시다. 이 때 이 렌더링이 완료되기 전에 count가 2로 변해서 2일 때의 A에 대한 렌더링 요청이 발생했습니다. 그러면 1일 때의 렌더링 결과는 최신 값을 표시하는게 아니므로 더이상 필요없어 집니다. 이런 상황에서 바로 1일 때의 렌더링을 중단하고 2일 때의 렌더링을 시작할 수 있다면 더 빨리 사용자에게 최신값인 2일 때의 화면을 보여 줄 수 있게됩니다.
    • 진행 중인 렌더링이 보여주려는 화면의 갱신 보다 더 먼저 처리하고 싶은 것이 있을 때
      • 렌더링 중에 사용자 이벤트가 발생할 경우, 즉각적으로 반응하기 위하여 진행 중인 렌더링을 중단하고 이벤트 핸들러를 우선적으로 실행할 수 있습니다.

    이러한 경우들은 모두 해당 컴포넌트의 렌더링을 중단해서 다른 처리를 할 수 있게 함으로써 UX를 개선하는 경우입니다.

    화면에 보이지 않는 곳에서 트리의 일부를 렌더링 할 수 있다.

    Concurrent React에서는 화면에 보이는 것과 일치하는 렌더링 외에 화면의 일부에 해당하는 컴포넌트만 별도로 렌더링 할 수 있습니다. 이는 기존 렌더링을 화면에 보여주고 계속 동작하게 하면서 앞으로 갱신될 화면의 일부를 미리 별도로 렌더링하고 렌더링이 완료되면 교체하게 됩니다. 렌더링을 필요 이상으로 하기 때문에 사용성을 떨어트리게 되는 게 아닌가 하는 걱정이 들 수 있습니다. 하지만 이 별도의 랜더링은 Concurrent 렌더러 덕분에 언제든지 중단될 수 있으므로 사용자 인터렉션을 방해하지 않게 됩니다. 오히려 이 특징을 이용하여 보다 나은 UX를 제공할 수 있습니다.

    지금까지 Concurrent 렌더러의 두 가지 특징을 살펴봤습니다. 이번에는 이 두 특징이 활용되는 '급하지 않은 렌더링 구분하기'가 무엇인지 알아봅시다.

    급하지 않은 렌더링 구분하기

    급한 렌더링과 급하지 않은 렌더링 예시:
     

    브라우저를 통해 사이트에 처음 방문하는 것을 생각 해봅시다. 하얀 빈 화면이 있는 상황에서 가장 급한 것은 무엇인가요? 어떻게든 빨리 사이트의 내용을 화면에 뿌려주는 게 가장 중요한 일일 겁니다. 하얀 화면에 오래 머문다면 사용자는 기다리지 않고 떠날 수 있으니까요. 그러니 첫 번째 렌더링은 딱히 중단할 일이 없습니다.
     

    홈페이지의 좌측 사이드바를 보니 내비게이션에 해당하는 메뉴들이 있습니다. 메뉴 A를 누르려다가 메뉴 B를 잘못 눌렀습니다. 사용자가 다시 메뉴 A를 누르려고 하는데 메뉴 B 렌더링이 오래 걸린다면 메뉴 B에 해당하는 화면의 렌더링이 끝나고 메뉴 A에 해당하는 화면이 렌더링이 될 것입니다.
     

    메뉴 B를 눌렀다가 메뉴 A를 바로 다시 누른 상황에서 메뉴 B에 대한 화면을 렌더링하는 것보다 메뉴 A에 대한 화면을 렌더링하는 것이 더 급합니다. B에 대한 화면은 이제 유효하지 않습니다.

    React 개발자는 어떻게 어떤 렌더링이 급하지 않은 렌더링이라고 React에게 알릴 수 있을까요? 렌더를 트리거하는 입력의 변화 중에 어떤 입력의 변화가 급하지 않은지를 표시하면 됩니다. 이를 개발자가 손쉽게 표시할 수 있게 해주는 hook이 useDeferredValueuseTransition 입니다. 이 두 API 모두 리액트 18에 새롭게 추가되었으며 급하지 않은 렌더링을 지연시키는 동일한 효과를 가져옵니다. 이 두 hook을 하나씩 살펴보면서 이 둘의 차이점을 이해해 봅시다.

    useDeferredValue: 변한 입력값을 이용하여 구분하기

    특정 값을 사용하는 컴포넌트에서 해당 컴포넌트는 그 특정한 값의 변화를 급하지 않게 처리하고자 할 때 사용합니다.

    function App() {
      const [text, setText] = useState('');
      return (
        <>
          <input value={text} onChange={e => setText(e.target.value)} />
          <SlowList text={text} />
        </>
      );
    }
    

    위 예시 코드는 beta.reactjs.org의 useDeferredValue 예제 중 하나입니다.

    여기서 text는 state이므로 text가 변하면 App이 다시 렌더링됩니다. 이 text<input><SlowList>의 입력(props)으로도 사용되고 있습니다. text가 변하면 App의 렌더링이 트리거가 되고 그 과정에서 inputSlowList가 바뀐 text를 사용하여 다시 렌더링됩니다. SlowList의 렌더링이 오래 걸린다면 사용자가 빠르게 타이핑을 이어가도 매 렌더링이 완료되기까지 사용자의 입력이 반영되지 않습니다.

    여기서 inputSlowList에서 사용자의 키보드 입력에 해당하는 input의 렌더링은 급한 렌더링이고 SlowList는 사용자 입력에 따른 어떠한 결과이므로 input보다는 급하지 않은 렌더링이라고 볼 수 있습니다. 여기서 useDeferredValue를 사용하면 급한 렌더링을 트리거하는 text를 이용하여 급하지 않은 렌더링을 할 때 화면에 표시되는 deferredText를 만들 수 있습니다.

    function App() {
      const [text, setText] = useState('');
      const deferredText = useDeferredValue(text);
      return (
        <>
          <input value={text} onChange={e => setText(e.target.value)} />
          <SlowList text={deferredText} />
        </>
      );
    }
    

    이렇게 하게 되면 text의 변화가 있을 때 deferredText는 바로 이전 text값을 갖게 되지만 화면에 보이지 않는 곳에서 deferredText도 최신의 값을 갖는 상태로 별도의 렌더링을 하게 되고 이 별도의 렌더링이 완료되어야 비로소 textdeferredText 모두 최신의 값을 갖는 상태로 렌더링됩니다. deferredText의 변화는 급하지 않은 렌더링이고 중단될 수 있습니다.

    동일한 컴포넌트에 대해 급하지 않은 렌더링 요청이 연속적으로 있을 경우, 먼저 시작된 급하지 않은 렌더링이 끝나지 않았다면 바로 중단하고 최근 변화의 렌더링을 시작하게 됩니다. 예를 들어 text에서 사용자가 비어있는 input 상자에 ab를 순서대로 타이핑하게 되면 a가 입력되었을 때 별도의 렌더링이 시작되고, 이 렌더링 끝나기 전에 b를 눌러 ab가 되었다면 a일 때 시작된 별도의 렌더링은 중단하게 되고 ab를 위한 렌더링이 시작됩니다.

    useTransition: 입력의 변화를 주는 함수를 이용하여 구분하기

    앞서 useTransitionuseDeferredValue 모두 급하지 않은 렌더링으로 구분할 때 사용된다고 했습니다. 이 둘의 차이점을 살펴보면서 useTransition에 대해 알아봅시다.

    :warning: 주의

    이 둘의 차이를 쉽게 이해하기 위해 useDeferredValue의 예제를 useTransition을 사용하도록 변경했습니다. useTransition은 업데이트가 동기적으로 일어나야 하는 input과 사용할 수 없습니다. 사용할 수 없는 이유는 beta.reactjs.orguseDeferredValue 페이지의 Trouble shooting에서 확인하시기 바랍니다.

    function App() {
      const [text, setText] = useState("");
      const [isPending, startTransition] = useTransition();
      return (
        <>
          <button
            onClick={(e) => {
              startTransition(() => setText((v) => v + "a"));
            }}
          >
            a 키
          </button>
          <SlowList text={text} />
        </>
      );
    }
    

    차이점

    급하지 않은 렌더링이라고 지정할 때 useDeferredValue는 그 값인 text를 이용했다면 useDeferredValue는 그 값(렌더 트리거)의 변화를 야기하는 setText를 이용합니다. text를 접근할 수 없는 상황에서 setText만 알아도 됩니다.

    startTransition 안에서 변경되는 text의 변화를 바로 화면에 표시할 방법이 없습니다. 별도의 렌더링으로 변경된 text를 위한 렌더링이 시작되지만 별도의 렌더링일 뿐 실제 화면을 위한 렌더링에는 그 변경된 값을 알 수는 없습니다. 다만 isPending을 통해 별도의 렌더링이 진행 중임은 알 수 있습니다. useTransitionstate의 변경을 지연하게 되고 useDeferredValue은 변경된 state에 따른 일부 렌더링을 지연하게 됩니다.

    공통점

    startTransition을 통해 동일한 컴포넌트에 대해 급하지 않은 렌더링 요청이 연속적으로 있을 경우 useDeferredValue와 동일하게 먼저 시작된 별도의 렌더링이 아직 진행 중이라면 바로 중단되고 최신 값을 사용하는 별도의 렌더링이 시작됩니다.

    정리

    React 18의 Concurrent 렌더러 덕분에 가능해진 '급하지 않은 렌더링 구분하기'에 대해서 살펴보았습니다. useTransition과 useDeferredValue를 사용하여 급하지 않은 렌더링을 구분하게 되면 복잡한 구조의 화면 갱신도 사용성을 떨어뜨리지 않으면서 가능해집니다. React 18 이전에는 이러한 막힘없는 사용성을 제공하기 위해서는 많은 개발공수가 들었습니다. React 18에서 간편해진 '급하지 않은 렌더링 구분하기'를 통해 사용자에게 쾌적한 UX를 제공해 보시기 바랍니다.

    29 January 2023

  • Backend.AI MLOps 플랫폼 FastTrack을 소개합니다.

    By 강지현

    이번 글에서는 Backend.AI의 MLOps 플랫폼인 FastTrack을 소개합니다. FastTrack을 사용하면 데이터 전처리, 학습, 검증, 배포, 그리고 추론과 같은 각각의 단계를 하나의 파이프라인으로 구성할 수 있습니다. 특히 FastTrack에서는 파이프라인을 구성할 때에 사용자가 각 단계를 손쉽게 커스터마이징 가능합니다. 이번 포스팅에서는 MLOps 플랫폼이 왜 필요한지와 함께 Backend.AI FastTrack 의 탄생 배경, 그리고 FastTrack 이 가지는 특장점을 함께 소개합니다.

    MLOps 플랫폼의 대두

    지난 몇 년간 IT 산업 뿐만 아니라, 디지털 트랜스포메이션이 일어난 대부분의 산업에서는 AI를 도입해 산재되어 있던 데이터로 유의미한 예측을 도출해 빠르게 변하는 시장에 대응할 수 있도록 각고의 노력을 기울여왔습니다. 이 과정에서 AI를 잘 활용하기 위해서는 모델 학습, 최적화에서 끝나는 것이 아니라, 데이터 I/O를 고려한 하드웨어 도입, 모델 버전 관리 등과 같이 다양한 단계에 대한 대응이 필요하게 되었습니다. 여기서 나온 개념이 MLOps(Machine Learning Operations) 입니다. MLOps에 대한 자세한 내용은 래블업 기술 블로그에서 다루고 있는 MLOps 시리즈 에서 확인하실 수 있으니, FastTrack 소개글을 보기에 앞서 MLOps 개념이 생소하신 분들께서는 위의 글을 훑어보시는 것을 추천합니다.

    FastTrack의 역사

    래블업은 DevOps 파이프라인 수요에 대응하고자 2019년 Backend.AI 파이프라인 기능을 베타 릴리즈로 추가했습니다. 복잡한 파이프라인 생성 및 관리 과정을 단순화하고, 중간에 두 경로 이상으로 나누어지는 단방향 파이프라인을 운영하는 기능을 개발 및 테스트로 공개하였습니다. 그러나, MLOps 개념의 대두와 함께 AirFlow, MLFlow, KubeFlow 등의 다양한 파이프라인 솔루션들이 보급됨에 따라 저희는 파이프라인 기능을 정식 기능으로 개발하는 대신, 오픈소스 파이프라인 도구들을 통합하고 지원하는 쪽으로 개발 방향을 선회했습니다.

    한편 AI 개발 파이프라인은 점차 복잡해지고, 유저들의 다양한 요구들을 오픈소스 MLOps 파이프라인 도구들이 채워줄 수 없음이 명확해진 시점에서 저희는 Backend.AI의 파이프라인 기능을 다시 되살리기로 했습니다. Backend.AI 파이프라인 기능의 재활성화 및 프로토타이핑 과정에서, 유저들의 요청을 바로 반영할 수 있도록 본체에 완전히 통합된 파이프라인 대신 Backend.AI 클러스터와 함께 동작하지만 독립적으로 동작하는 MLOps 파이프라인 솔루션으로 개발 방향이 변경되었습니다.

    이렇게 다양한 역사를 밟아온 래블업의 AI/MLOps 솔루션은 공항이나 물류 등에서 통과 및 통관 절차를 빨리 처리해주는 과정을 부르는 FastTrack Lane에서 힌트를 얻은 FastTrack으로 명명하였으며, Backend.AI 22.09와 함께 첫 정식 버전을 테스트 중입니다.

    FastTrack이란?

    FastTrack 이란 Backend.AI 클러스터를 기반으로 여러 개의 작업단위들을 사용자가 목적에 맞게 커스터마이징 하고, DAG(Directed Acyclic Graph)형태로 실행될 수 있도록 돕는 머신러닝 워크플로우 플랫폼입니다. 머신러닝 파이프라인의 각 단계에 대응하는 세션을 선,후 관계를 통해 실행할 수 있게 되면 사용자는 데이터 전처리, 학습, 검증, 배포, 모니터링, 최적화 등과 같은 각 단계를 필요에 따라 결합해 하나의 워크플로우로 다룰 수 있습니다. 다시 말해 기존 Backend.AI 클러스터에서 사용자가 일일이 수동으로 생성해야 했던 세션을 워크플로우로 구성하여 단계가 끝날 때마다 자동으로 스케줄링 해주기 때문에 사용자는 보다 편리하게 모델을 구축, 재사용할 수 있습니다.

    FastTrack 구조와 특징

    FastTrack에서는 워크플로우 템플릿을 파이프라인(Pipeline), 실행 대상인 워크플로우를 파이프라인 잡(Pipeline Job)으로 구분하고, 워크플로우 안의 작업단위를 태스크(Task), 실행 대상인 작업단위를 태스크 인스턴스(Task instance)로 구분합니다. 아래의 구조도와 함께 FastTrack에서 어떻게 단계별 작업이 진행되는지 설명합니다.

    파이프라인(Pipeline)

    파이프라인은 태스크들의 각각의 정보와 관계를 모아둔 집합체로, DAG(Directed Acyclic Graph) 구조를 갖습니다. AI 워크플로우를 만들기 위해서는 파이프라인을 생성하면 되는데, 이 때 학습이 잘 되고 있는지 등을 아티팩트(artifact)로 확인할 수 있도록 FastTrack에서는 Backend.AI 클러스터에 파이프라인 전용 폴더를 자동생성합니다. 또한 FastTrack에서는 드래그-앤-드랍(Drag and drop)과 같은 인터페이스로 사용자가 손쉽게 태스크 간 관계를 수정할 수 있고, 변경 결과를 즉시 도식화된 플로우로 확인 및 YAML 파일로 확인할 수 있어 매우 편리합니다. 또한 파이프라인은 YAML 파일로 관리되기 때문에 내보내기나 불러오기가 용이하여 사용자간 공유도 손쉽게 할 수 있습니다.

    파이프라인 잡(Pipeline Job)

    파이프라인 잡의 경우 생성된 파이프라인 정보를 기반으로 만들어지는 실제 개체로, 실행이 되는 동안에는 수정이 불가하다는 특성을 갖습니다. FastTrack GUI에서는 작업단위가 실행되는 것을 각 작업단위에 대응하는 노드의 색상으로 확인할 수 있습니다. 또한 파이프라인과 마찬가지로, 구성하고 있는 태스크 인스턴스의 정보와 관계를 YAML 형태로 관리합니다. 모든 태스크 인스턴스가 종료되면, 파이프라인 잡의 상태도 성공 또는 실패로 표시됩니다.

    태스크(Task)

    파이프라인을 이루는 최소 실행단위로, 용도 별로 자원 할당이 가능합니다. 가령 모델 학습만을 위한 태스크의 경우, 전처리 용과 달리 많은 GPU 자원을 집중할당하여 자원을 보다 효율적으로 사용할 수 있습니다. 또한 실행환경도 각각 지정할 수 있습니다. Backend.AI 클러스터에서 지원하는 이미지를 기준으로 TensorFlow, PyTorch, Python 3.x, NGC TensorFlow, NGC PyTorch 등과 같은 이미지를 도커 빌드과정 없이 그대로 사용할 수 있습니다. 또한 필요에 따라 Backend.AI 클러스터에서 생성한 가상폴더(Virtual Folder)를 태스크 별로 마운트할 수 있습니다.

    태스크 인스턴스(Task Instance)

    태스크 인스턴스는 파이프라인 잡이 생성될 때 파이프라인을 구성하는 태스크 정보를 바탕으로 생성되는 실제 개체라고 볼 수 있습니다. 즉 AI 워크플로우를 실행하는 것은 파이프라인 잡을 구성하는 태스크 인스턴스가 지정된 선,후 관계에 맞게 실행이 된다는 것을 의미합니다. 태스크 인스턴스는 현재 Backend.AI 클러스터의 세션(Session)과 1:1 대응이 되어 세션 상태와 태스크 인스턴스의 상태가 동일시 되고 있으나, 추후 세션 외에도 다양한 실행 단위로 확장될 예정입니다.

    마치며

    지금까지 Backend.AI MLOps 플랫폼인 FastTrack에 대한 소개와 함께 MLOps 에 대해 다뤄보았습니다. 현재 Backend.AI FastTrack의 경우 22.09 버전이 릴리즈 되었으며, 추후 파이프라인 버저닝, 파이프라인 간 의존 관계 추가, 태스크 자원 사용 최적화, GitHub 기반 모델/데이터 스토어 지원 등과 같은 다양한 사용자 편의 기능을 개발 및 제공할 예정입니다. 누구나, 언제 어디서든 AI 모델을 개발, 사용할 수 있게 하자는 래블업의 모토에 맞게, FastTrack을 이용하면 누구나 손쉽게 자동화된 모델 구축을 할 수 있도록 만들어가겠습니다. 앞으로의 행보에도 많은 관심 부탁드립니다.

    29 November 2022

  • aiomonitor-ng: 복잡한 asyncio 애플리케이션을 위한 디버깅 도구

    By 김준기

    프로그램의 복잡도가 올라갈수록 소프트웨어 개발자에게는 좋은 디버깅 도구가 필요합니다. 가장 이상적인 디버깅 과정은 마음껏 실험해볼 수 있는 개발환경에서 문제를 안정적으로 재현하는 방법을 알아내고 이를 자동화된 테스트로 만드는 것이죠. 하지만 재현 시나리오 구성 자체가 너무 복잡하거나 프로덕션 환경에서만 가끔씩 랜덤하게 발생하는 종류의 버그들은 차선책으로 로그를 상세히 남겨서 사후에라도 어떤 문제가 있었는지 파악할 수 있도록 해야 합니다. 이번 글에서는 복잡한 asyncio 프로그램의 디버깅을 쉽게 하기 위해 개발한 aiomonitor-ng 도구를 소개합니다.

    asyncio 애플리케이션 디버깅은 고유한 어려움들이 있습니다. 파이썬에서 디버깅할 때 가장 자주 활용하는 것이 바로 프로그램이 어느 부분을 실행하다가 예외가 발생했는지 보여주는 stack trace입니다. 그런데 asyncio 애플리케이션은 여러 개의 코루틴 작업들이 각자의 스택을 가지고 동시에 엮여서 실행되기 때문에 특정 예외가 발생한 코루틴 작업의 스택뿐만 아니라 '관련된' 코루틴 작업들의 스택도 함께 관찰해야 다른 코루틴 작업으로부터 야기된 오류인지 아닌지를 정확하게 파악할 수 있습니다. 특히, 내 코드에서 사용한 외부 라이브러리가 암묵적으로 코루틴 작업을 생성하고 그 코루틴 작업이 다시 내 코드를 호출하는 상황이라면 더욱 중요한 문제가 됩니다. 게다가 개발환경에서는 잘 발생하지 않고 프로덕션 환경에서만 발생하는 코루틴 작업 폭주 문제나 지속적으로 실행되어야 하는 코루틴 작업이 조용하게 종료되어버린다거나 하는 종류의 버그들은 굉장히 잡기 어렵습니다. 이런 종류의 버그들은 명시적인 예외가 발생하는 것이 아니기 때문에 사후 로그를 통해 문제점을 간접적으로 유추하는 수밖에 없기 때문입니다.

    aiomonitor는 asyncio 코어 개발자들이 개발한 프로덕션용 라이브 디버깅 도구입니다. asyncio 기반 코드를 monitor 객체로 감싸두면, 해당 코드가 실행 중일 때 프로세스 외부에서 미리 설정된 TCP 포트로 텔넷 세션을 열어 간단한 명령어들을 통해 이벤트루프가 실행하고 있는 코루틴 작업들의 목록과 개별 스택 현황을 조회할 수 있게 해줍니다. Backend.AI에는 이미 이 aiomonitor가 적용되어 개별 서비스 프로세스마다 고유의 디버깅용 텔넷 포트가 할당되어 있습니다. (물론 보안 상 이유로 localhost로부터의 접속만 허용합니다.) 이를 통해 프로덕션에서만 발생하는 문제들을 디버깅하는 데 큰 도움을 받을 수 있었죠. 하지만 여전히 Backend.AI 자체의 코드가 아닌 외부 라이브러리에 의해서 발생하는 코루틴 작업 폭주 문제나 조용하게 종료되어버리는 코루틴 작업이 왜 죽는지 디버깅하는 것은 정확히 그 문제가 발생하는 시점을 특정하여 그 순간에 aiomonitor를 들여다보는 방식으로는 디버깅에 한계가 있었습니다.

    그래서 aiomonitor-ng라는 확장 버전을 개발하게 되었습니다. ng는 next-generation의 약자입니다. 크게 다음과 같은 기능들이 추가 및 개선되었습니다.:

    • Task creation tracker: 모든 실행 중인 코루틴 작업에 대해, 각 코루틴 작업을 생성(asyncio.create_task())한 작업들에 대해 그 순간의 stack trace를 모두 보존하여 연속된 작업 생성 체인을 모두 알 수 있도록 하였습니다. (ps, where 명령)
    • Task termination tracker: 최근 종료된 코루틴 작업들을 최대 N개까지 로그를 보존하고 조회할 수 있게 해줍니다. 특히 어떤 한 작업이 다른 작업을 취소(Task.cancel())한 경우, 취소를 트리거한 순간의 stack trace를 함께 보존하여 연속된 취소 체인을 모두 알 수 있도록 하였습니다. (ps-terminated, where-termianted 명령)
    • Persistent task marker: 기본값으로는 메모리 누수를 방지하기 위해 종료된 작업을 최근 N개까지만 추적하지만, 애플리케이션 수명 주기 동안 계속 실행되어야 하는 특정 작업들을 데코레이터로 표시해두면 해당 작업들은 이력 개수 제한과 관계 없이 항상 종료 로그를 보존해주고 종료 로그 조회 명령에서 별도 옵션으로 필터링하는 기능을 제공합니다. (aiomonitor.task.preserve_termination_log 데코레이터)
    • 세련된 terminal UI: 기존에 손으로 짜여진 명령어 파싱을 바탕으로 했던 단순 REPL (read-evaluate-print loop) 구성이었던 명령줄 처리를 개선하였습니다. Clickprompt_toolkit을 활용하도록 aiomonitor 서버측 구현을 재작성하고, 클라이언트도 asyncio로 native하게 동작하는 텔넷 클라이언트를 자체 구현하여 명령어 및 task ID 등의 인자 자동완성을 제공합니다.

    실제 사용 화면은 다음과 같습니다.:

    aiomonitor-ng 도구를 활용하여 grpcio 라이브러리에서 콜백으로 생성하는 코루틴 작업이 과다 생성되어 발생하는 리소스 누수 및 성능 저하 문제, docker 데몬이 발생시키는 이벤트를 모니터링하는 작업이 특정한 메시지 입력 패턴에 의해 조용하게 종료되어 버리는 바람에 컨테이너 생성이나 삭제 작업의 결과가 리턴되지 않아 시스템이 멈추는 문제 등을 성공적으로 디버깅할 수 있었습니다.

    앞으로 aiomonitor-ng를 통해 래블업뿐만 아니라 다양한 Python asyncio 애플리케이션을 개발하는 독자분들께서도 디버깅에 큰 도움을 받기를 바라며 글을 마칩니다.

    aiomonitor-ng는 PyPI를 통해 pip install aiomonitor-ng 명령으로 설치하실 수 있으며, 제 깃헙 계정에 오픈소스로 공개되어 있으므로 누구나 사용 및 기여가 가능합니다.

    28 November 2022

도움이 필요하신가요?

내용을 작성해 주시면 곧 연락 드리겠습니다.

문의하기

본사 및 HPC 연구소

서울특별시 강남구 선릉로 577 CR타워 8층

© Lablup Inc. All rights reserved.