DataKit 로그 수집 시스템 설계 및 구현
머리말¶
로그 수집(로깅)은 Guance DataKit의 중요한 측면입니다. 능동적으로 수집되거나 수동적으로 수신된 로그 데이터를 처리하여 최종적으로 Guance 센터에 업로드합니다. 로그 수집은 데이터 소스에 따라 "네트워크 흐름 데이터"와 "로컬 디스크 파일"의 두 가지 유형으로 나눌 수 있습니다.
네트워크 스트림 데이터¶
기본적으로 네트워크 인터페이스에 가입하여 로그 생성 측에서 전송된 데이터를 수동적으로 수신합니다.
가장 일반적인 예는 Docker 로그를 보는 것입니다. docker logs -f CONTAIENR_NAME 명령이 실행되면 Docker는 별도의 프로세스를 시작하고 메인 프로세스에 연결하고 메인 프로세스에서 보낸 데이터를 수신하여 터미널에 출력합니다. Docker 로그 프로세스와 기본 프로세스가 동일한 호스트에 있지만 상호 작용은 로컬 루프백 네트워크를 통해 이루어집니다.
Kubernetes 클러스터와 같은 더 복잡한 네트워크 로그 시나리오에서는 해당 로그가 여러 노드에 분산되어 API 서버를 통해 전송되어야 합니다. 이는 Docker의 단일 액세스 트레이스보다 두 배 더 복잡합니다.
그러나 네트워크를 통해 얻은 대부분의 로그에는 문제가 있습니다. 로그 위치를 지정할 수 없습니다. 로그 수신 측에서는 로그를 처음부터 최대한 많이(한 번에 수십만 개까지) 수신하도록 선택할 수 있습니다. 또는 tail -f와 같이 꼬리부터 시작하여 현재 생성된 최신 데이터만 수신합니다. 로그 수신 측의 프로세스가 다시 시작되면 해당 기간 동안의 로그는 손실됩니다.
DataKit의 컨테이너 로그 수집은 처음에는 네트워크 수신을 사용했습니다. 위와 같은 문제로 오랫동안 고민을 했습니다. 이후 점차 아래에서 언급하는 '로컬 디스크 파일' 수집 방식으로 조정됐다.
로컬 디스크 파일¶
로컬 로그 파일을 수집하는 것이 가장 일반적이고 효율적인 방법입니다. 복잡한 전송 단계를 제거하고 보다 조작하기 쉬운 디스크 파일에 직접 액세스합니다. 그러나 구현이 더 복잡하며 다음과 같은 일련의 세부적인 문제에 직면하게 됩니다.
- 디스크의 데이터를 보다 효율적으로 읽는 방법은 무엇입니까?
- 파일이 삭제되거나 회전된 경우 어떻게 해야 합니까?
- 파일을 다시 열 때 "계속 읽기"를 위한 마지막 위치를 찾는 방법은 무엇입니까?
이러한 문제는 Docker 로그 서비스를 확산시키는 것과 동일합니다. 모든 세부 사항과 실행은 직접 처리하며 최종 네트워크 전송 부분만 생략합니다. 구현의 복잡성은 단순히 네트워크를 사용하여 수신하는 것보다 훨씬 더 까다롭습니다.
이 글에서는 주로 "로컬 디스크 파일"에 초점을 맞춰 아래에서 위로 "파일 검색", "데이터 수집 및 처리", "전송 및 동기화"의 세 가지 측면으로 나누고 DataKit 로그 수집 시스템의 설계 및 구현 세부 사항을 차례로 소개합니다.
또한 DataKit 로그 수집 실행 흐름은 다음과 같으며 위의 "세 가지 측면"을 다루고 세분화합니다.
glob 검색 파일 Docker API 검색 파일 CRI(Containerd) 검색 파일
| | |
`-----------------------+----------------------------`
|
로그 스케줄러에 추가, 지정된 라인에 할당
|
.-----------------------+-------------------------.
| | | |
line1 line2 line3 line4
|
| +- 데이터 수집, 분기
| |
| +- 데이터 트랜스코딩
.-----> | |
| | +- 특수문자 처리
| |- 문서 A |
| | 하나의 획득 주기 +- 다중 라인 처리
| | |
| | +- Pipeline 처리
| | |
| | +- 보내다
| | |
| | +- 파일 수집 위치 동기화
| | |
| Pipeline | `-파일 상태 감지
| 루프 |
| |
| +- 문서 B |-
| |
| |
| +- 파일 C |-
| |
+----------+
로그 파일 검색 및 위치 확인¶
로그 파일을 읽고 수집하려면 먼저 디스크에서 파일 위치를 찾아야 합니다. DataKit에는 두 개의 컨테이너 로그와 하나의 일반 로그를 포함하여 세 가지 주요 유형의 파일 로그가 있습니다. 수집방법은 비슷합니다. 이번 글에서도 이 세 가지 유형을 주로 소개합니다. 그들은:
- 일반 로그 파일
- Docker Stdout/Stderr, Docker 서비스 자체가 로그 관리 및 디스크 배치를 수행합니다(DataKit은 현재
json-file드라이버 구문 분석만 지원합니다) - Containerd Stdout/Stderr, Containerd에는 로그 출력 전략이 없습니다. 이 단계에서 Containerd Stdout/Stderr는 Kubernetes의 kubelet 구성 요소에 의해 관리되며 앞으로는
Containerd(CRI)로 통칭됩니다.
일반 로그 파일 검색¶
일반 로그 파일이 가장 일반적인 유형입니다. 읽을 수 있는 기록 데이터를 디스크 파일에 직접 쓰는 프로세스입니다. 로그 파일은 유명한 "log4j" 프레임워크 또는 echo "this is log" >> /tmp/log 명령을 실행하여 생성됩니다.
이러한 종류의 로그 파일 경로는 대부분 고정되어 있습니다. 예를 들어 Linux 플랫폼에서 MySQL의 로그 경로는 /var/log/mysql/mysql.log입니다. DataKit MySQL 수집기를 실행하면 기본적으로 로그 파일을 찾을 수 있는 경로를 찾습니다. 그러나 로그 저장 경로는 구성 가능하며 DataKit은 모든 상황을 고려할 수 없으므로 파일 경로를 수동으로 지정하는 기능을 지원해야 합니다.
glob 패턴을 사용하여 와일드카드를 사용하여 파일 이름을 찾는(물론 와일드카드 없이) DataKit에서 파일 경로를 구성합니다.
예를 들어 현재 다음과 같은 파일이 있습니다.
tree /tmp
/tmp
├── datakit
│ ├── datakit-01.log
│ ├── datakit-02.log
│ └── datakit-03.log
└── mysql.d
└── mysql
└── mysql.log
3 directories, 4 files
DataKit 로깅 수집기에서는 다음과 같이 logfiles 매개변수를 구성하여 수집할 로그 파일을 지정할 수 있습니다.
datakit디렉터리의 모든 파일을 수집합니다. glob은/tmp/datakit/*입니다.- 이름이
datakit인 모든 파일을 수집합니다. 해당 글로브는/tmp/datakit/datakit-*log입니다. mysql.log를 수집하지만 중간에mysql.d및mysql라는 두 개의 디렉터리가 있습니다.mysql.log파일을 찾는 방법에는 여러 가지가 있습니다.- 직접 지정:
/tmp/mysql.d/mysql/mysql.log - 단일 별표 지정:
/tmp/*/*/mysql.log, 이 방법은 기본적으로 사용되지 않습니다. - 이중 별표(
double star):/tmp/**/mysql.log, 이중 별표**를 사용하여 중간 다중 계층 디렉토리 구조를 대체하는 것이 더 간단하고 일반적으로 사용되는 방법입니다.
- 직접 지정:
glob을 사용하여 구성 파일에 파일 경로를 지정한 후 DataKit은 규칙과 일치하는 파일을 디스크에서 주기적으로 검색합니다. 수집목록에 없는 것으로 확인되면 추가 수집됩니다.
컨테이너 Stdout/Stderr 로그 파일 위치 확인¶
컨테이너에 로그를 출력하는 방법에는 두 가지가 있습니다.
- 하나는 마운트된 디스크 디렉터리에 직접 쓰는 것입니다. 이 방법은 호스트에게 위에서 언급한 "일반 로그 파일"과 동일하게 보입니다. 디스크의 고정된 위치에 있는 파일입니다.
- 또 다른 방법은 Stdout/Stderr로 출력하는 것이며, 컨테이너의 런타임은 디스크 배치를 수집하고 관리합니다. 이것은 또한 더 일반적인 방법입니다. 이 디스크 배치 경로는 런타임 API에 액세스하여 얻을 수 있습니다.
DataKit은 명령줄에서 docker inspect --format='{{.LogPath}}' $INSTANCE_ID를 실행하는 것과 유사하게 Docker 또는 Containerd의 sock 파일에 연결하고 API에 액세스하여 지정된 컨테이너의 LogPath를 얻습니다.
docker inspect --format='{{.LogPath}}' cf681e
/var/lib/docker/containers/cf681eXXXX/cf681eXXXX-json.log
컨테이너 LogPath를 얻은 후 이 경로와 해당 구성을 사용하여 로그 수집을 생성합니다.
로그 데이터 수집 및 처리¶
로그 수집 스케줄러¶
로그 파일 경로를 얻은 후 DataKit은 Golang으로 작성되었기 때문에 일반적으로 하나 이상의 고루틴을 시작하여 파일을 독립적으로 수집하도록 선택합니다. 모델은 간단하고 구현하기 쉽습니다. DataKit은 이전에 이 작업을 수행했습니다.
하지만 파일이 너무 많으면 열려는 고루틴의 수도 늘어나 고루틴 관리에 매우 해롭습니다. 따라서 DataKit은 로그 수집 스케줄러를 구현합니다.
대부분의 스케줄러 모델과 마찬가지로 DataKit은 스케줄러 아래에 여러 Pipeline을 구현합니다. 새로운 로그 수집이 스케줄러에 등록되면 DataKit은 각 Pipeline의 가중치에 따라 이를 할당합니다.
각 Pipeline은 루프로 실행됩니다. 즉, 파일 A를 한 번 수집(또는 상황에 따라 N초 동안 지속적으로 수집)한 다음 파일 B를 수집한 다음 파일 C를 수집합니다. 이를 통해 고루틴 수를 효과적으로 제어하고 다수의 고루틴에 대한 기본 스케줄링 및 리소스 경쟁을 피할 수 있습니다.
이 로그 수집에서 오류가 발견되면 Pipeline에서 제거되며 다음 주기에 다시 수집되지 않습니다.
데이터 읽기 및 행 분할¶
로그 데이터를 읽는 경우 대부분의 경우 호출될 때마다 전체 로그 줄을 반환하는 Readline()와 같은 메서드 함수를 먼저 생각하게 됩니다. 그러나 이것은 DataKit에서는 구현되지 않습니다.
보다 세부적인 제어와 더 높은 성능을 보장하기 위해 DataKit은 가장 기본적인 Read() 방법만 사용하여 매번 4KiB 데이터를 읽고(버퍼 크기는 4KiB이며 실제 읽기는 더 적을 수 있음) 개행 문자 \n를 사용하여 4KiB 데이터를 N 부분으로 수동으로 나눕니다. 두 가지 상황이 발생합니다.
- 이 4KiB 데이터의 마지막 문자는 개행 문자이므로 나머지 없이 N 부분으로 나눌 수 있습니다.
- 이 4KiB 데이터의 마지막 문자는 개행 문자가 아닙니다. 위와 비교하면 이번 스플릿은 N-1밖에 남지 않은 부분이 있다. 나머지 부분은 다음 4KiB 데이터의 헤더에 추가됩니다.
DataKit 코드에서는 최대 메모리 재사용을 달성하기 위해 동일한 버프에 대해 update CursorPosition, copy 및 truncate가 지속적으로 수행됩니다.
처리 후 읽은 데이터는 한 줄씩 처리되며 실행 흐름의 다음 단계인 트랜스코딩 및 특수 문자 처리로 이동할 수 있습니다.
문자 인코딩 변환 및 특수 문자 처리¶
특수 문자의 트랜스코딩 및 처리는 데이터가 형성된 후에 수행되어야 합니다. 그렇지 않으면 문자 중간이 잘리고 잘린 데이터 섹션이 처리됩니다. 예를 들어 UTF-8 중국어 문자는 3바이트를 차지하고 첫 번째 바이트가 수집될 때 트랜스코딩됩니다. 이것은 정의되지 않은 동작입니다.
데이터 트랜스코딩은 매우 일반적인 동작이므로 인코딩 유형과 빅엔디안 및 리틀엔디안(있는 경우)을 지정해야 합니다. 이 문서에서는 "특수 문자 처리"에 중점을 둡니다.
여기서 "특수문자"는 데이터의 색상 문자를 의미합니다. 예를 들어, 다음 명령은 명령줄 터미널에 빨간색 단어 rea를 출력합니다.
처리를 수행하지 않고 색상 문자를 삭제하지 않으면 최종 로그 데이터에 \033[0;31m도 포함되어 모양이 부족하고 저장 공간을 차지할 뿐만 아니라 후속 데이터 처리에 부정적인 영향을 미칠 수도 있습니다. 따라서 여기에서 특수 색상 문자를 필터링하세요.
오픈소스 커뮤니티에는 많은 경우가 있는데 대부분 정규식을 사용하여 구현한 것인데 성능이 그다지 좋지 않습니다.
그러나 성숙한 로그 출력 프레임워크의 경우 색상 문자를 해제하는 방법이 있어야 합니다. DataKit은 로그 생성 종료 시 컬러 문자 인쇄를 피할 수 있도록 이 접근 방식을 권장합니다.
행 데이터 파싱¶
"행 데이터 구문 분석"은 주로 컨테이너 Stdout/Stderr 로그용입니다. 컨테이너 런타임이 로그를 관리하고 작성할 때 생성 시간, 소스가 stdout 또는 stderr인지 여부, 이 로그가 잘렸는지 여부 등과 같은 일부 추가 정보 필드가 추가됩니다. DataKit은 이 데이터를 구문 분석하고 해당 필드를 추출해야 합니다.
- 단일 Docker JSON 파일 로그의 형식은 다음과 같습니다. JSON 형식이며 텍스트는
log필드에 있습니다.log콘텐츠의 끝이\n인 경우 이는 이 데이터 행이 완전하고 잘리지 않았음을 의미합니다.\n가 아닌 경우에는 데이터가 너무 길어서 16KB를 초과하여 잘랐다는 의미이며 나머지 부분은 다음 JSON에 있습니다.
{"log":"2022/09/14 15:11:11 Bash For Loop Examples. Hello, world! Testing output.\n","stream":"stdout","time":"2022-09-14T15:11:11.125641305Z"}
- 단일 Containerd(CRI) 로그의 형식은 다음과 같으며 각 필드는 공백으로 구분됩니다. Docker와 마찬가지로 CRI(Containerd)에도
F외에 로그 잘림 표시, 즉 세 번째 필드P가 있습니다.P는 불완전하고 잘린Partial를 의미합니다.F는Full를 의미합니다.
2016-10-06T00:17:09.669794202Z stdout P log content 1
2016-10-06T00:17:09.669794202Z stdout F log content 2
접합 후 로그 데이터는 log content 1 log content 2입니다.
라인 데이터를 파싱하면 로그 텍스트, stdout/stderr 등의 정보를 얻을 수 있으며, 마크를 기반으로 불완전한 잘린 로그인지 여부를 판단하고 로그 스플라이싱을 수행할 수 있습니다. 일반 로그 파일에는 잘림이 없으며 파일의 한 줄의 데이터는 이론적으로 무한정 길 수 있습니다.
또한 한 줄의 로그가 잘려도 아래에 언급된 여러 줄의 로그가 아닌 스플라이싱 후 한 줄의 로그에 속하게 됩니다. 이것은 두 가지 다른 개념입니다.
여러 줄 데이터¶
여러 줄 처리는 로그 수집에서 매우 중요한 측면입니다. 특성에 맞지 않는 일부 데이터를 데이터 손실 없이 특성에 맞게 만들 수 있습니다. 예를 들어 로그 파일에는 일반적인 Python 스택 인쇄인 다음 데이터가 포함되어 있습니다.
2020-10-23 06:41:56,688 INFO demo.py 1.0
2020-10-23 06:54:20,164 ERROR /usr/local/lib/python3.6/dist-packages/flask/app.py Exception on /0 [GET]
Traceback (most recent call last):
File "/usr/local/lib/python3.6/dist-packages/flask/app.py", line 2447, in wsgi_app
response = self.full_dispatch_request()
2020-10-23 06:41:56,688 INFO demo.py 5.0
여러 줄 처리가 없으면 최종 데이터는 위의 7줄이 되며 원본 텍스트와 정확히 동일합니다. 라인 3의 Traceback (most recent call last): 또는 라인 4의 File "/usr/local/lib/python3.6/dist-packages/flask/app.py", line 2447, in wsgi_app가 고정된 형식이 아니기 때문에 이는 후속 Pipeline 파싱에 도움이 되지 않습니다.
효과적인 다중 행 처리 후에는 이 7개 행의 데이터가 3개 행이 되며 결과는 다음과 같습니다.
2020-10-23 06:41:56,688 INFO demo.py 1.0
2020-10-23 06:54:20,164 ERROR /usr/local/lib/python3.6/dist-packages/flask/app.py Exception on /0 [GET]\nTraceback (most recent call last):\n File "/usr/local/lib/python3.6/dist-packages/flask/app.py", line 2447, in wsgi_app\n response = self.full_dispatch_request()
2020-10-23 06:41:56,688 INFO demo.py 5.0
보시다시피, 로그 데이터의 각 라인은 이제 2020-10-23와 같은 특성 문자열로 시작하고, 특성에 맞지 않는 원본 텍스트의 라인 3, 4, 5가 라인 2의 끝에 추가됩니다. 이는 훨씬 더 아름답게 보이고 후속 Pipeline 현장 파싱에 도움이 됩니다.
이 함수는 복잡하지 않으며, 특성 문자열의 정규식만 지정하면 됩니다.
DataKit 로깅 수집기는 multiline_match 항목으로 구성됩니다. 위의 예에서 이 항목의 구성은 ^\d{4}-\d{2}-\d{2}여야 하며 이는 2020-10-23와 같이 줄의 시작 부분과 일치합니다.
구체적인 구현은 데이터 구조의 스택 구조와 유사합니다. 특성을 충족하면 이전 항목이 스택에서 팝된 다음 스택에 푸시됩니다. 특성을 충족하지 않으면 스택에 푸시되어 이전 항목의 끝에 추가됩니다. 이렇게 하면 외부에서 수신한 팝된 데이터는 모두 해당 특성을 충족하게 됩니다.
또한 DataKit은 자동 다중 행도 지원합니다. 로깅 수집기의 구성 항목은 auto_multiline_detection 및 auto_multiline_extra_patterns입니다. 그 논리는 매우 간단합니다. 즉, multiline_match 세트를 제공하고, 원본 텍스트에 따라 모든 규칙을 순회 및 일치시키며, 일치에 성공하면 다음 번에 먼저 선택할 수 있도록 가중치를 늘리는 것입니다.
자동 다중 라인은 구성을 단순화하는 방법입니다. 사용자 구성 외에도 "기본 자동 여러 줄 규칙 목록"도 제공합니다. 자세한 내용은 기사 마지막에 있는 링크를 참조하세요.
Pipeline 파싱 및 로그 status¶
Pipeline은 텍스트 데이터에 대한 실행 규칙을 작성하기 위한 다양한 기능과 구문을 제공하는 간단한 스크립팅 언어입니다. 주로 문자열 텍스트 줄을 여러 의미 있는 필드로 자르거나 구조화된 텍스트(예: JSON)에서 부분 정보를 추출하는 등 구조화되지 않은 텍스트 데이터를 자르는 데 사용됩니다.
Pipeline 구현은 비교적 복잡합니다. 이는 추상 구문 트리(AST)와 일련의 내부 상태 머신 및 기능적 순수 함수로 구성되며 여기서는 자세히 설명하지 않습니다.
사용 시나리오를 살펴보고 간단한 예를 들어보세요. 원문은 다음과 같습니다.
Pipeline 스크립트:
최종 결과:
{
"message": "2020-10-23 06:41:56,688 INFO demo.py 1.0",
"msg": "demo.py 1.0",
"status": "info",
"time": 1603435316688000000
}
참고: Pipeline 파싱 후
status필드는INFO이지만 DataKit은 매핑 처리를 완료했기 때문에 엄격함을 위해 소문자info로 표시됩니다.
Pipeline은 로그 데이터 처리의 마지막 단계입니다. DataKit은 Pipeline의 결과를 사용하여 행 프로토콜을 구축하고, 객체를 직렬화하고, 패키징하여 Dataway로 전송할 준비를 합니다.
데이터 전송 및 동기화¶
데이터 전송은 매우 일반적인 동작입니다. DataKit에는 기본적으로 "패키징", "트랜스코딩" 및 "전송"의 세 가지 단계가 있습니다.
그러나 전송 후 작업은 "현재 파일의 읽기 위치 동기화" 및 "파일 상태 감지"와 같이 필수적이고 중요합니다.
동기화¶
기사의 첫 번째 섹션에서 "네트워크 스트림 데이터"를 소개할 때 고정된 지점에서 로그 파일을 계속 읽을 수 있도록 하기 위해 "파일의 처음부터 읽기" 또는 tail -f 모드만 지원하는 대신 DataKit에서는 현재 파일의 읽기 위치(위치)를 기록하는 중요한 작업을 도입한다고 언급했습니다.
로그 수집이 디스크 파일에서 데이터를 읽을 때마다 파일의 데이터 위치가 기록됩니다. 일련의 처리와 전송이 완료되어야 이 위치 정보가 다른 데이터와 연결되어 단일 디스크 파일로 동기화됩니다.
이 방법의 장점은 DataKit이 로그 수집을 시작할 때마다 이 로그 파일이 이전에 수집된 경우 마지막 위치를 찾아서 이번에도 데이터 수집이 중복되거나 중간에 일부 데이터가 손실되지 않고 수집을 계속할 수 있다는 것입니다.
함수 구현은 복잡하지 않습니다.
DataKit은 로그 수집을 시작할 때 "파일 절대 경로 + 파일 inode + 파일 헤더의 N 바이트"를 사용하여 독점 키 값을 구성합니다. 지정된 경로를 가진 파일에서 위치를 찾으려면 이 키를 사용하십시오.
- 위치를 찾을 수 있다면 지난번에 파일을 수집했고 현재 위치부터 읽어온다는 뜻이다.
- 위치를 찾을 수 없으면 새 파일이라는 의미입니다. 상황에 따라 파일의 처음부터 읽거나 파일의 끝부터 읽도록 선택합니다.
파일 상태 확인¶
디스크 파일의 상태는 정적이지 않습니다. 파일이 삭제되거나 이름이 바뀌거나 오랫동안 변경되지 않을 수 있습니다. DataKit은 이러한 상황을 처리해야 합니다.
-
파일이 오랫동안 수정되지 않았습니다.
- DataKit은 파일(
file Modification Date)의 수정 시간을 주기적으로 얻습니다. 현재 시간이 특정 제한을 초과하는 것으로 확인되면 파일이 "비활성"인 것으로 간주하고 더 이상 수집을 위해 닫지 않습니다. - 이 논리는 glob 규칙을 사용하여 로그 파일을 검색할 때도 존재합니다. glob 규칙과 일치하는 파일이 발견되었지만 오랫동안 수정(또는 "업데이트")되지 않은 경우 해당 파일에 대한 로그 수집이 수행되지 않습니다.
- DataKit은 파일(
-
파일이 반전(회전)됩니다.
- 파일 회전은 매우 복잡한 논리입니다. 평신도의 관점에서 보면 파일 이름은 변경되지 않지만 파일 이름이 가리키는 특정 파일(예: inode)이 변경됩니다. 일반적인 예는 Docker 로그 다운로드입니다.
DataKit은 현재 수집 중인 파일이 순환되는지 여부를 정기적으로 확인합니다. 검사 논리는 다음과 같습니다. 이 파일 이름을 사용하여 새 파일 핸들을 열고, SameFile()와 유사한 함수를 호출하고, 두 핸들이 동일한 지점을 가리키는지 확인합니다. 일치하지 않는 경우 현재 파일 이름이 회전되었음을 의미합니다.
파일이 회전되었음을 감지하면 DataKit은 현재 파일의 나머지 데이터(EOF까지)를 수집한 다음 파일을 다시 엽니다. 이때는 이미 새 파일이므로 작업 프로세스는 평소대로 계속됩니다.
요약¶
로그 수집은 많은 세부 처리 및 최적화 로직이 포함되는 매우 복잡한 시스템입니다. 이 글은 DataKit 로그 수집 시스템의 설계와 구현을 소개하는 것을 목표로 합니다. 유사한 프로젝트와의 벤치마크 보고서 및 성능 비교가 없습니다. 이 작업은 나중에 적절하게 완료할 수 있습니다.
추가 링크: