패트로니오스(patroneos) : 동작 분석, 벤치마크, 운영 및 구현 수정 권고 사항

in #kr7 years ago (edited)

27017302889_9edc086467_z.jpg

안녕하세요. EOSeoul입니다.

이 글은 1) 패트로니오스의 동작 방식, 2) 벤치마크 결과, 3) 운영 및 구현에 관한 권장 사항을 공유합니다. 편의상 경어체를 사용하지 않습니다. 또한 패트로니오스의 simple/advanced 설정에 대해 충분히 이해하고 있는 독자를 가정합니다.

모든 설명은 아래의 커밋을 기준으로 삼습니다.

  • Update 한국표준시 2018.06.06 오후 2시
    • 분석 기준 코드 리비전 : 6501e4429f43f4de78444a227149773b914e221b
    • 분석 기준 코드 커밋 시점 : Tue Jun 5 17:49:59 2018 -0400
    • 주요 변경사항
      • Patroneos Issue #26 - 수정 확인됨
      • validateMaxTransactions 추가됨
      • 이에 따른 권고 사항 수정
    • 앞으로 1주일간, 블록 프로듀서의 패트로니오스 운영을 위해서 필요한 중요한 변경 사항이 있다면 이 글의 코멘트에 추가하겠습니다.
  • Original
    • 분석 기준 코드 리비전 : 48422fa05b47373ad68013f4d77d290e7fc31aae
    • 분석 기준 코드 커밋 시점 : Thu May 31 17:44:16 2018 -0400

들어가기

블록원은 EOSIO Dawn 4.2 출시와 함께 자사가 개발한 소프트웨어 하나를 공개한다. 이름은 패트로니오스(patroneos). 소설 해리 포터에는 혼을 빨아먹는 크리처인 디멘터가 등장하는데, 디멘터를 물리치는 마법이 '패트로누스(patronus)'다. 패트로니오스는 이 마법에서 이름을 빌려왔다.

EOS를 둘러싼 많은 사람들은 EOS 메인넷을 공격하는 다양한 공격 시도에 우려를 표해왔다. 커뮤니티와 블록원은 공격 경로별 시나리오를 도출하고 공격에도 끄덕없도록 EOSIO 소프트웨어를 개선해왔다. 패트로니오스는 이와 같은 노력의 산출물 중 하나다. 패트로니오스는 서비스 거부 공격(Denial of Service)의 기초적인 형태를 지닌 공격을 걸러내고, 정상적인 트랜잭션만 EOS RPC API Endpoint에 전달한다.

동작 방식

코드 구성

코드는 3개의 주요 파일로 구성되어 있다. main.go, filter.go, fail2ban-relay.go가 그것이다.

main.go

main.go는 다음 기능을 구현하고 있다.

  • 설정 파일
    • directive인 Config 정의
    • 동적으로 설정 파일을 업데이트할 수 있는 핸들러
    • 설정파일 파싱
  • 프로그램 구동
    • 커맨드라인 아규먼트 파싱
    • 실행 모드에 따른 핸들러 호출

프로그램은 Filter 모드(filter)와 Relay 모드(fail2ban-relay)로 동작하도록 구현되었다. 각각을 살펴보자.

filter.go

filter.go는 Filter 모드를 구현한 파일이다.

  • 동작
    • 패트로니오스가 받은 HTTP 요청을 다음 5가지 검증을 연속해서 진행한다.
    • 유효한 요청이라고 확인되면 EOS RPC API를 제공하는 API Endpoint로 요청을 던지고, 결과를 클라이언트에 전송한다.
    • 유효하지 않은 요청이라고 확인되면, 즉시 HTTP 400 Bad Request 결과값을 전송하고 연결을 종료한다.
    • 만약 유효한 요청을 API Endpoint로 전송한 후 API Endpoint로부터 HTTP 결과 코드 200을 받지 않으면, TRANSACTION_FAILED 에러를 발생하고 연결을 종료한다.
    • Filter 모드로 동작하는 패트로니오스에 /patroneos/fail2ban-relay로 HTTP 요청을 보내고, 로그를 남기고 연결을 종료한다.
  • 검증 로직 5가지
    • validateJSON() : HTTP POST의 body로 받은 JSON이 유효한지 검증. 실패할 경우 INVALID_JSON 에러 발생하고 걸러냄.
    • validateMaxTransactions() : 하나의 JSON에 담긴 트랜잭션 수가 최대값 미만인지 검증. 실패할 경우 TOO_MANY_TRANSACTIONS 에러 발생하고 걸러냄.
    • validateTransactionSize() : 트랜잭션에 담긴 서명의 수가 최대값보다 작은지 검증. 실패할 경우 INVALID_NUMBER_SIGNATURES 에러 발생하고 걸러냄.
    • validateMaxSignatures() : 트랜잭션이 블랙리스트 처리된 컨트랙트의 액션이 아닌지 검증. 실패할 경우 BLACKLISTED_CONTRACT 에러 발생하고 걸러냄.
    • validateContract() : 트랜잭션의 크기가 최대값보다 작은지 검증. 실패할 경우 INVALID_TRANSACTION_SIZE 에러 발생하고 걸러냄.
    • 위 로직 중 validateTransactionSize(), validateMaxSignatures(), validateContract()은 HTTP Request Body의 JSON이 Object임을 가정한다. 그러나 HTTP Chain API push_transactions는 JSON Array를 사용하는데, 이를 PARSING_ERROR로 처리한다. 이에 대해서 Patroneos Issue #26로 보고했다. 이 포스트가 변경 불가 상태가 되기 전에 이슈가 해결된다면, 업데이트하겠다.
  • 로깅 및 Relay 모드와 연동
    • 유효한 HTTP 요청의 경우
      • Relay 모드로 동작하는 패트로니오스를 연결해놓았다면, 처리 로그를 HTTP로 보냄
      • Relay 모드로 동작하는 패트로니오스가 없다면, 로그 파일에 로깅
    • 유효하지 않은 HTTP 요청의 경우
      • Relay 모드로 동작하는 패트로니오스를 연결해놓았다면, 처리 로그를 HTTP로 보냄
      • Relay 모드로 동작하는 패트로니오스가 없다면, 로그 파일에 로깅
      • Relay 모드로 동작하는 패트로니오스 설정 여부에 무관하게, HTTP 클라이언트에 HTTP 400 Bad Request 결과값을 전송함

fail2ban-relay.go

fail2ban-relay.go는 Relay 모드를 구현한 파일이다.

  • 대단히 간단하다. /patroneos/fail2ban-relay로 받은 결과를 fail2ban이 스캔할 로그 파일에 남긴다.

검토

Go Routine

패트로니오스는 구글이 2009년에 만든 프로그래밍 언어인 Go로 작성되었다. Go는 비동기 메커니즘으로 Go Routine(이하 고루틴)을 제공한다. 고루틴은 Go 런타임이 관리하는 경량 쓰레드(lightweight thread)다. "go” 키워드를 이용해서 함수를 호출하면 런타임은 해당 함수를 동일한 메모리 주소 공간에서 시분할 방식으로 동시에(concurrently) 실행한다.

Go 프로그램을 다수의 CPU 또는 코어로 병렬처리할 수도 있다. runtime.GOMAXPROCS() 함수를 이용하면, Go 프로그램이 사용할 수 있는 논리 코어(logical core) 수를 정할 수 있다. Go 버전 1.5부터 머신에 장착된 논리 코어 전부를 사용하도록 변경되었다. 이로써 고루틴으로 호출되는 함수는 멀티코어 머신에서 병렬처리된다.

2018년 6월 1일을 기준으로 한다면, 일반적으로 Go 1.10.2를 설치하게 된다. Ubuntu 18.04 LTS와 macOS High Sierra 10.13.4 에서 각각 apt와 brew를 통해서 golang을 설치하면 1.10.2가 설치되었다. CentOS 7.5 에서는 1.9.4가 설치되었다.

따라서 최근 출시된 Go의 버전으로 고루틴을 사용하면 멀티코어 환경에서 자동으로 병렬처리된다.

HTTP Request 처리 단계 : HTTP ServMux & ListenAndServe

httpServe() 함수를 살펴보면, 연결이 accept된 후 고루틴으로 새로운 연결을 serve로 처리한다. HTTP 요청을 받는 부분은 멀티코어를 이용하여 병렬로 처리됨을 알 수 있다.

ListenAndServe는 서버의 timeout이 없는 디폴트 http.Server를 사용한다. 분석 시점 기준으로 패트로니오스는 Timeout 설정이 없는 Server를 사용한다. 클라이언트 또는 패트로니오스로 HTTP 요청을 전달하는 포인트에서 적절한 timeout이 없는 경우, 패트로니오스는 HTTP 연결을 맺고 데이터를 받지 못한 상황에서 무한정 대기할 것이다.

따라서 패트로니오스는 1) Client의 요청을 직접 받지 않고 2) timeout이 존재하는 HTTP 요청을 받을 수 있도록 적절한 아키텍처를 구현하기를 권장한다.

유효성 검증 단계

5가지 유효성 검증은 "go” 키워드로 호출되지 않고, 직렬로 처리된다. 즉, 유효성 검증은 병렬로 처리되지 않는다. 현재까지 구현된 검증 로직은 매우 단순하므로 굳이 병렬로 처리할 필요가 보이지 않는다.

클라이언트로부터 HTTP body를 모두 받은 후 ServeMux.HandleFunc가 실행되므로, 유효성 검증 로직은 timeout의 이슈는 존재하지 않는다.

그러나 Relay를 사용하도록 설정한 경우는 timeout이 발생할 수 있으나 큰 문제가 없을 것으로 보인다. 이는 아래에 이어서 설명한다.

유효성 검증 결과 Relay 및 API Endpoint로 포워딩 단계 : HTTP Client

많은 관련 문서에서 Go의 HTTP Client는 고루틴을 이용한 concurrency 사용이 안전하다고 확인하고 있다.

HTTP Client는 별도의 처리가 없다면 Timeout이 없다. 분석 시점 기준으로 패트로니오스는 1) Relay용 패트로니오스에 Filter 결과를 전송할 때, 2) API Endpoint에 HTTP Request를 전달할 때 HTTP Client를 사용한다. 이 두 경우 모두 Timeout이 설정되어 있지 않다.

패트로니오스가 수신한 HTTP 요청 중 검증이 완료된 요청을 전달하는 API Endpoint에 적절한 timeout이 없는 경우, 패트로니오스는 응답을 받을 때까지 무한정 대기할 것이다.

따라서 API Endpoint는 적절한 timeout을 반드시 설정하기를 권장한다.

Relay 모드의 패트로니오스를 RP라고 하고, Filter 모드의 패트로니오스를 FP라고 하자. FP가 RP를 사용하지 않는다면, 아무런 이슈가 없다. FP가 RP를 사용하도록 설정했다고 하자. 만약 RP가 꺼진 상태거나 정상적으로 응답하는 상태라면, 아무런 이슈가 없다. RP의 로직이 대단히 간단하므로 file descriptor가 부족하거나 처리가 지연되는 상황이 아닌 일반적인 상황이라면, RP의 응답이 늦어지는 경우는 대단히 드물 것이다. 또한 RP가 사용하는 포트에 무한정 응답이 없는 TCP/HTTP 서버가 있다면 큰 문제가 되겠지만, 그런 경우 역시 대단히 드물 것이다. 따라서 FP가 RP를 사용하는 경우는 timeout과 관련된 이슈가 대단히 드물 것으로 예상된다.

벤치마크

패트로니오스 자체의 처리 용량을 확인하는 데 초점을 두었다. 그래서 HTTP request와 API Endpoint를 간단하게 구성했다. 이 벤치마크는 일종의 실험실 벤치마크로 이해할 수 있다.

크기가 다른 두 종류의 JSON을 테스트에 사용했고, HTTP request concurrency도 100과 1000으로 나누었다. 또한 API 처리 Latency를 감안하여 0 ms와 100 ms를 주고 테스트했다.

테스트 구성

아래는 벤치마크를 위한 테스트 구성이다. Production 환경에서는 상황에 따라 설정을 변경해야 한다.

  • 시스템
    • OS : Ubuntu 18.04 LTS
    • CPU : Intel i7-6700 CPU @ 3.40GHz / 8 logical cores
    • Memory : 32GB
    • 파라미터
      • max open files : 500,000
      • net.ipv4.tcp_tw_reuse = 1
      • net.ipv4.ip_local_port_range = “10000 65000”
  • HTTP request
  • 패트로니오스 구성과 설정
    • filter patroneos
{
    "listenPort": "8081",

    "nodeosProtocol": "http",
    "nodeosUrl": "127.0.0.1",
    "nodeosPort": "8000",

    "contractBlackList": {
        "currency": true
    },
    "maxSignatures": 10,
    "maxTransactionSize": 1000000,

    "logEndpoints": ["http://127.0.0.1:8080"],
    "filterEndpoints": [],

    "logFileLocation": "./fail2ban.log"
}
  • relay patroneos
{
    "listenPort": "8080",

    "nodeosProtocol": "http",
    "nodeosUrl": "127.0.0.1",
    "nodeosPort": "8000",

    "contractBlackList": {
        "currency": true
    },
    "maxSignatures": 10,
    "maxTransactionSize": 1000000,

    "logEndpoints": [],
    "filterEndpoints": ["http://127.0.0.1:8081"],

    "logFileLocation": "./fail2ban.log"
}
  • Dummy API Endpoint using Go : Core 1개 사용
package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
    "runtime"
    "io/ioutil"
)

func main() {
    runtime.GOMAXPROCS(1)
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if len(r.FormValue("case-two")) > 0 {
            fmt.Println("case two")
        } else {
            time.Sleep(time.Millisecond * 100)
            b, err := ioutil.ReadAll(r.Body)
            if err != nil {
                log.Fatal(err)
            }
            fmt.Println(b)
            //fmt.Println("case one end")
        }
    })

    if err := http.ListenAndServe(":8000", nil); err != nil {
        log.Fatal(err)
    }
}

테스트 방법과 결과

  • Test용 request JSON-A 타입
{"code":"currency","type":"transfer","recipients":["initb","initc"],"authorization":[{"account":"initb","permission":"active"}],"data":"000000000041934b000000008041934be803000000000000"}
  • Test용 request JSON-B 타입
{"id":"37df4598d37bb8fdbc440e31caae07906ac90fd3fd2cd060f2ca13e59e78781e","signatures":["SIG_K1_K8ojKDxMnWy5Q3zAVQPwJANbEE2h9kStmPX4BorEGGQKCJXUYK62UiEYxGyQbaynraMX5WvzEFYaQqAf5Mdwu2yBf36HG7"],"compression":"none","packed_context_free_data":"","context_free_data":[],"packed_trx":"5f3b125b17c726f418ba000000000100a6823403ea3055000000572d3ccdcd010000000000ea305500000000a8ed32322e0000000000ea305590d5cc5865570da420a107000000000004454f53000000000d4a756e676c652046617563657400","transaction":{"expiration":"2018-06-02T06:38:23","ref_block_num":50967,"ref_block_prefix":3122197542,"max_net_usage_words":0,"max_cpu_usage_ms":0,"delay_sec":0,"context_free_actions":[],"actions":[{"account":"eosio.token","name":"transfer","authorization":[{"actor":"eosio","permission":"active"}],"data":{"from":"eosio","to":"okapitestnet","quantity":"50.0000 EOS","memo":"Jungle Faucet"},"hex_data":"0000000000ea305590d5cc5865570da420a107000000000004454f53000000000d4a756e676c6520466175636574"}],"transaction_extensions":[]}}
  • Test #1

    • HTTP request
      • request JSON-A 타입
      • 동시 100개 고루틴 요청, 총 요청수 100,000개
    • Dummy
      • Response Latency 0ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 15.07초, 건당 평균 소요 시간 0.0149초, 6635.07 TPS
      • 메모리 사용량 : filter 모드 17.1MB, relay 모드 11.8MB
      • CPU 사용량 : 쓰레드당 Max 15%
  • Test #2

    • HTTP request
      • request JSON-A 타입
      • 동시 100개 고루틴 요청, 총 요청수 100,000개
    • Dummy
      • Response Latency 100ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 103.5748초, 건당 평균 소요 시간 0.1033초, 965.48 TPS
      • 메모리 사용량 : filter 모드 17.1MB, relay 모드 11.8MB
      • CPU 사용량 : 쓰레드당 Max 9.7%
  • Test #3

    • HTTP request
      • request JSON-A 타입
      • 동시 1000개 고루틴 요청, 총 요청수 500,000개
    • Dummy
      • Response Latency 0ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 69.79초, 건당 평균 소요 시간 0.1060초, 7163.58 TPS
      • 메모리 사용량 : filter 모드 391.7MB, relay 모드 23.7MB
      • CPU 사용량 : 쓰레드당 Max 26.9%
  • Test #4

    • HTTP request
      • request JSON-A 타입
      • 동시 1000개 고루틴 요청, 총 요청수 500,000개
    • Dummy
      • Response Latency 100ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 76.2931초, 건당 평균 소요 시간 0.1486초, 6553.67 TPS
      • 메모리 사용량 : filter 모드 143.4MB, relay 모드 20.0MB
      • CPU 사용량 : 쓰레드당 Max 25.0%
  • Test #5

    • HTTP request
      • request JSON-B 타입
      • 동시 100개 고루틴 요청, 총 요청수 100,000개
    • Dummy
      • Response Latency 0ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 29.53초, 건당 평균 소요 시간 0.0293초, 3385.76 TPS
      • 메모리 사용량 : filter 모드 17.MB, relay 모드 11.7MB
      • CPU 사용량 : 쓰레드당 Max 7.6%
  • Test #6

    • HTTP request
      • request JSON-B 타입
      • 동시 100개 고루틴 요청, 총 요청수 100,000개
    • Dummy
      • Response Latency 100ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 109.91초, 건당 평균 소요 시간 0.1093초, 909.80 TPS
      • 메모리 사용량 : filter 모드 15.8MB, relay 모드 11.0MB
      • CPU 사용량 : 쓰레드당 Max 5.3%
  • Test #7

    • HTTP request
      • request JSON-B 타입
      • 동시 1000개 고루틴 요청, 총 요청수 500,000개
    • Dummy
      • Response Latency 0ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 148.5215초, 건당 평균 소요 시간 0.2930초, 3366.51 TPS
      • 메모리 사용량 : filter 모드 110.5MB, relay 모드 14.3MB
      • CPU 사용량 : 쓰레드당 Max 9.2%
  • Test #8

    • HTTP request
      • request JSON-B 타입
      • 동시 1000개 고루틴 요청, 총 요청수 500,000개
    • Dummy
      • Response Latency 100ms
    • 결과
      • 패트로니오스 처리 결과 : 전체 소요 시간 153.37초, 건당 평균 소요 시간 0.3046초, 3259.90 TPS
      • 메모리 사용량 : filter 모드 103.0MB, relay 모드 13.5MB
      • CPU 사용량 : 쓰레드당 Max 8.6%

결과 요약

  • 메모리 사용
    • 필터 모드 : < 400MB
    • 릴레이 모드 : < 24MB
  • CPU 사용 : 쓰레드당 < 25.0%
  • File Descriptor : 위의 테스트 환경에서 이슈가 발생하지 않음
  • DNS resolving : 설정을 통해서 IP만 사용하여 DNS resolving을 최소화

권고 및 결론

  • 패트로니오스 운영 권고 사항

    • 패트로니오스의 오동작을 대비하여, 필요에 따라 즉시 패트로니오스 Layer를 bypass할 수 있는 아키텍처를 구현하고 bypass on/off를 충분히 훈련할 것
    • 패트로니오스 layer를 scale-out하는 기준을 확인하기 위해서 CPU 사용률, file descriptor error 등을 모니터링할 것
    • Filter 모드로 동작하는 패트로이노스가 1) Client의 요청을 직접 받지 않고 2) timeout이 존재하는 HTTP 요청을 받을 수 있도록 적절한 아키텍처를 구현
    • API Endpoint는 적절한 timeout을 반드시 설정
    • Relay 모드로 동작할 패트로니오스의 TCP 포트에 엉뚱한 서비스가 올라가지 않도록 주의
    • 가능하다면 nodeosUrl 설정에 FQDN 대신 IP 사용하면, DNS resolving overhead를 줄일 수 있음
    • 패트로니오스 프로세스마다 충분한 file descriptor를 확보할 수 있는 아키텍처를 구현하고 시스템 파라미터를 튜닝할 것
    • fail2ban-relay까지 사용한다면, 바이너리명을 따로 두는 것이 운영과 모니터링에 유리함. ex) patroneous-filter, patroneos-relay
    • TCP port reuse 및 Port range를 충분히 확보할 것
    • fail2ban.log 파일 rotate하고 로그는 되도록 SSD 사용하는 것을 권장
    • push_transactions의 JSON Array처리에서 문제가 발생하며 Patroneos Issue #26로 버그를 보고했음. 이 버그의 대응 현황을 확인하고, 그에 따라 push_transactions을 사용하는 request에 대해 반드시 URI route 변경하거나 bypass하도록 전체 아키텍처를 구현해야 함
    • Patroneos Issue #26 이슈가 한국표준시 2018년 6월 6일 오전 4시 1분에 수정되었고, push_transactions 역시 안전하게 사용할 수 있다.
    • Public API Endpoint로 사용하기 위해서는 nodeos 설정 access-control-allow-origin*로 사용하기를 권장
      • 의문이 있다면 Reference의 same-origin policy & CORS 섹션을 참고
  • 패트로니오스 구현 권고 사항 (to 패트로니오스 커미터 & Block.one)

    • HTTP ServerClient에 Timeout을 적절히 설정할 수 있는 Config 구조와 이를 반영할 수 있는 구현 변경을 권고한다.
      • HTTP Server : ReadTimeout, ReadHeaderTimeout, WriteTimeout
      • HTTP ClientTimeout
      • HTTP Client에 HTTP Transport를 사용
      • Transport의 파라미터 튜닝 : MaxIdleConnsPerHost, MaxIdleConns, IdleConnTimeout, ResponseHeaderTimeout, net.Dialer.Timeout
      • 자세한 내용은 아래 Reference의 다음 섹션을 참고하라: Go net/http implementation recommendations
    • Patroneos Issue #26 - 해결됨
  • 결론

    • 분석 시점 기준으로 운영 권고 사항을 충분히 따른다면, live production 환경에서 사용하기에 성능 상 무리가 없다고 보인다.
    • 주의 Patroneos Issue #26 이슈 해결 전에는 push_transactions에 대해서는 반드시 URI route를 변경하거나 bypass하도록 전체 아키텍처를 구현해야 한다. - 해결됨
    • timeout 설정을 추가하고 그에 따라 Server 동작과 Client 동작을 섬세하게 튜닝할 수 있도록 구현 상의 수정이 필요하다.

Reference

피드백 환영

EOSeoul은 EOS dApp/사용/개발/운영 등 다양한 토의를 할 수 있는 채널을 열어두고 있습니다.

Telegram(한국어) : http://t.me/eoseoul
Telegram(한국어, developer) : http://t.me/eoseoul_testnet
Steemit : http://steemit.com/@eoseoul
Github : http://github.com/eoseoul
Website : http://eoseoul.io
Twitter : http://twitter.com/eoseoul_kor
Facebook : http://www.facebook.com/EOSeoul.kr
Wechat account: neoply