Quantcast
Channel: Charsyam's Blog
Viewing all 122 articles
Browse latest View live

[입 개발] SipHash의 사용, Data DDOS를 방지해볼까?

$
0
0

Python 3.3 부터는 내장 hash 함수가 RANDOM SEED를 이용하는 방식으로 바뀌었고, 내부 함수도 내부적으로 SipHash 를 쓰도록 바뀌었고, Redis 에서도 4.x 부터는 내부 해쉬 방식이 SipHash 를 쓰는 것으로 바뀌었습니다.

그러면 잘 쓰고 있던(?) 기존의 hash를 왜 siphash라는 구조로 바꾸는 것일가요? 아, 일단 먼저 고백할 것은 전 siphash 가 뭔지는 잘 모릅니다. siphash 홈페이지를 방문하면 다음과 같은 내용을 볼 수 있습니다.

SipHash is a family of pseudorandom functions (a.k.a. keyed hash functions) optimized for speed on short messages.

Target applications include network traffic authentication and defense against hash-flooding DoS attacks.

SipHash is secure, fast, and simple (for real):
SipHash is simpler and faster than previous cryptographic algorithms (e.g. MACs based on universal hashing)
SipHash is competitive in performance with insecure non-cryptographic algorithms (e.g. MurmurHash)
We propose that hash tables switch to SipHash as a hash function. Users of SipHash already include FreeBSD, OpenDNS, Perl 5, Ruby, or Rust.

The original SipHash returns 64-bit strings. A version returning 128-bit strings was later created, based on demand from users.

Intellectual property: We aren’t aware of any patents or patent applications relevant to SipHash, and we aren’t planning to apply for any. The reference code of SipHash is released under CC0 license, a public domain-like license.

뭔지 잘 모르겠지만, 자애로운 구글신을 영접하면 다음과 같이 번역되어 나옵니다.(아휴… 이제 번역은 구글느님이시죠.)

SipHash는 단문 메시지의 속도에 최적화 된 의사 난수 함수 (a.k.a. 키 해시 함수)의 계열입니다.

대상 응용 프로그램에는 네트워크 트래픽 인증 및 해시 넘침 DoS 공격 방어가 포함됩니다.

SipHash는 안전하고 빠르며 간단합니다 (실제).
SipHash는 이전의 암호화 알고리즘 (예 : 범용 해시 기반의 MAC)보다 간단하고 빠릅니다.
SipHash는 안전하지 않은 비 암호화 알고리즘 (예 : MurmurHash)
우리는 해시 테이블을 해시 함수로 SipHash로 전환 할 것을 제안합니다. SipHash 사용자는 이미 FreeBSD, OpenDNS, Perl 5, Ruby 또는 Rust를 포함합니다.

원래 SipHash는 64 비트 문자열을 반환합니다. 128 비트 문자열을 반환하는 버전이 나중에 사용자의 요구에 따라 만들어졌습니다.

지적 재산권 : 우리는 SipHash와 관련된 특허 또는 특허 출원을 모르고 있으며, 신청할 계획이 없습니다. SipHash의 참조 코드는 공개 도메인과 같은 라이센스 인 CC0 라이센스에 따라 릴리스됩니다.

그럼 왜 이런걸 사용하는가라고 한다면, 다음과 같은 예를 하나 들어보려고 합니다. Java 7이나 .NET의 Set 자료구조를 보면, 실제로 내부에는 Hash 자료구조를 사용하고 있습니다.(당연하지!!! 그것도 몰랐냐 이넘아 하시면… 전… 굽신굽신) Set이라는 자료구조는 특정 item 이 존재하는지 아닌지를 상수시간(이라고 적고 빠른 시간에) 확인할 수 있는 자료구조입니다. 그런데 Hash는 보통 충돌이라고 불리는 Hash 값이 겹칠 수 밖에 없는 경우가 존재하고 이를 막기 위해서, 링크드 리스트를 이용한 이중 체인 같은 것을 많이 사용합니다.

즉 다음 그림과 같은 구조가 됩니다. 일단 다음 그림은 아주 이상적으로 Hash 가 하나씩 차지한 경우이구요.
siphash1

다음은 이제 삐꾸가 나서 한 hash slot 에만 비정상적으로 몰리는 경우입니다.
siphash2

그런데 지금 이러한 내용을 왜 말하는가 하면, 이런 특성을 이용해서 특정 서비스에 DOS(Denial of Service) 서비스를 할 수 있다는 것입니다.(아니 자네, 지금 무슨 말을 하는 것인가?)

우리가 흔히 아는 DOS 또는 DDOS는 무수한 클라이언트를 이용해서, 특정 사이트에 접속을 시도하거나 해서, 서비스를 못할 정도로 네트웍을 사용하거나, 특정 서비스에 시간이 오래걸리는 무거운 작업을 하도록 하여서, 서비스를 하지 못하게 하는 공격입니다.

자, 여기서 힌트를 얻으신 분들이 있으실지도 모르겠습니다. 앞에서 말한 Hash를 바꾼 이유와, 시간이 오래걸리는 무거운 작업을 하도록 한다를 섞으면… 설마라고 생각하시는 분들이 계실텐데… 넵 바로 그 이유입니다.(전 사실 그 이유를 모르죠!!! 퍽퍽퍽…)

자, 만약에 특정 사이트에서 어떤 컴포넌트를 이용하는 걸 알고 있습니다. 그리고 그 툴에서 자료구조를 어떻게 처리하는 지도 안다면? 예를 들어, Java 7의 Set 이나 HashMap 을 사용하는 걸 알고, 거기에 데이터를 넣을 것이라는 걸 안다면…(오픈소스들이 위험할 수 있습니다.) 특정 패턴의 Key를 넣는 것으로, Hash 검색 속도를 미친듯이 느리게 만들 수 있습니다.

한 곳에 데이터가 몰리는 것을 skew 라고 하고 이진 트리등에서도 skew 가 되면 엄청 느린 검색 속도를 보여주게 되는데.. 위의 그림 처럼, 충돌이 일어날 경우에 linked list를 이용한 체이닝으로 문제를 푼다면, 거기에만, 천개, 만개, 십만개가 있다면, 이제 해당 슬롯에 있는 key를 조회하는 명령이 들어오면, 속도는 점점 느려지게 될 것입니다.(정말?)

먼저 java7 에서의 HashMap에서 hash 함수를 확인해보도록 하겠습니다.(왜 자바7이냐 하면 자바8에서는 HashMap이 충돌시에 Tree 형태로 저장되게 됩니다. – 그러나 이것도 효율적이긴 하지만, 정말 많은 데이터를 한 곳에 넣으면… 문제의 소지가!!!)

java7 에서의 HashMap 에서 사용하는 hash 함수는 다음과 같습니다.

    static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

hash(key.hashCode()) 이런식으로 이용하게 되는데 만약에 key가 string class의 경우 hashCode() 함수는 다음과 같습니다.

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

즉 특정 string 의 hash table의 위치는 항상 그대로 입니다. 이걸 이용해서 같은 해쉬 슬롯에 들어가는 데이터를 대량으로 던져준다면?(사실 이게 쉽지는 않습니다., 어떤 key를 추가하게 할 것인가라는게 사용자 입장에서는 접근할 여지가 적은… 하지만… 가능하다면?) 물론 java7에서도 아이템 개수가 커지면 Table을 키우게 되긴 합니다만… 이것 역시 동일한게 만들어주면… 사실 더 최악인 메모리는 계속 팩터 만큼 커지는데… 아이템은 계속 같은 곳에 몰리는 형상이 벌어질 수 있습니다.(이걸 의도한 공격이죠.)

실제로 java7 에서는 resize(), transfer() 함수가 테이블 확장을 하게됩니다.

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

그럼 siphash를 쓰면 어떤 부분에서 도움이 될까요? 위에서 보여준 java7에서의 hash 나 python의 기존방식이나, redis 3.x대의 hash 또는 MD5, SHA1, SHA256 계열의 경우, 우리가 알듯이 항상 특정 key 의 hash는 같은 알고리즘에서 항상 같은 결과가 나옵니다.(consistent hashing 같은 경우는 이런 특성을 이용한 방식이죠.)

그런데 siphash 종류의 hash는 seedkey라는 것을 hash 시에 추가로 받습니다. 그리고 이 seedkey로 인해 hash 결과가 바뀌게 됩니다. 그럼 이걸 어떻게 해야하는가? 예를 들어 프로세스가 시작되는 타이밍에 저 seedkey를 랜덤으로 생성합니다. 그러면, 해당 프로세스 내에서는 항상 동일한 값을 보장하지만, 새로운 프로세스가 뜨면, 동일한 key에 다른 hash값을 내놓을 것입니다.(물론 해당 프로세스 내에서는 항상 동일하겠죠.) 즉 어떤 서버에서 실행될 때, 이 key가 어떤 위치에 위치할지를 알 수 없게 됩니다. 그로 인해서 위의 알고리즘을 알더라도, 같은 슬롯에 충돌을 유도하기가 힘들어집니다. 다음은 Redis에서 패치된 코드입니다. 기본 hash가 내부적으로 siphash를 쓰도록 되어 있습니다.

int main(int argc, char **argv) {
    ......
    getRandomHexChars(hashseed,sizeof(hashseed));
    dictSetHashFunctionSeed((uint8_t*)hashseed);
    ......
}

uint64_t dictGenHashFunction(const void *key, int len) {
    return siphash(key,len,dict_hash_function_seed);
}

비슷한 이유로 Python 에서도 3.3 이후로는 기본으로 RANDOM SEED와 siphash류를 쓰도록 되어있습니다.


[입 개발] Memcrashed DDOS에 대해서 살짝 아는척 해봅시다.

$
0
0

우와, 최근에 CloudFlare 에서 아주 재미있는 제목으로 글을 냈습니다. 제목은 Memcrashed – Major amplification attacks from UDP port 11211 로, UDP 11211 포트를 이용한 대규모 DDOS 어택 정도로 생각하시면 될듯합니다.(영어를 못해서 의역으로…)

사실 원문을 보시는게 더 쉽게 이해하실 듯 하지만(당연히 원문 처럼 설명한 능력도 없지만…) public 에 포트가 열려있는 memcached 를 이용한 DDOS 방법입니다. 그래서 제목이 memcrashed 가 된거죠. memcached는 웹서비스 쪽에서는 누구나 알고 있는 유명한 In-Memory caching 솔루션입니다.(http://memcached.org/) 성능도 아주 끝내주죠. 간단한 연산은 초당 10~20만 까지도 가능합니다.

memcrashed

먼저 해당 이슈는 일단 다음과 같은 전제조건이 모두 만족해야 DDOS 공격이 가능합니다..

  • memcached 가 public 하게 열려있다. – 사실 절대로 해서는 안되는 행위입니다.
  • memcached 가 udp 포트를 열고 있다.
  • 그리고 memcached 자체가 DDOS 공격의 대상이 아니라, 공격을 할 수 있는 수단으로 사용됩니다.

    저도 몰랐던 사실인데(나름 memcached는 그래도 아는편이라고 생각했는데…) memcached는 UDP도 지원합니다.(당연히 UDP다 보니, 명령이나 응답이 유실될 수 도 있습니다.) 원래는 명시적으로 -U 0 을 주지 않는 이상은 UDP 11211 포트로 생성됩니다. memcached 1.5.5 버전을 받아서 설치하고 실행해보면 자동으로 IPv4(TCP, UDP), IPv6(TCP, UDP) 11211 포트가 열리는 것을 볼 수 있습니다.

    memcached_1_5_5_port

    즉 udp로 명령을 보내고 사용할 수 있다는 거죠. UDP 프로토콜은, 기존 TCP 프로토콜과 거의동일하지만 아주 미세한 차이가 있습니다.

    그런데 UDP의 경우에는 source ip를 위조하는 것에 굉장히 취약합니다.(IP Spoofing), TCP도 불가능한건 아니지만 훨씬 더 어렵습니다. 여기서 자세한 설명 없이 ip header 와 udp header를 첨부합니다.

    ip_header

    udp_header

    IP Spoofing 을 통해서 데이터를 보내게 되면 memcached 입장에서는 송신자를 체크할 방법이 없습니다. 그래서 그 응답결과를 송신자(로 속여진 victim) 에게 보내게 되는겁니다. 엄청나게 UDP 패킷이 전송될 수 있겠죠. 실제로 memcached는 디폴트로 1MB chunk를 사용하므로 데이터는 한번에 1MB 까지 가능합니다. 이렇게 열려있다는 것은 거기에 자신이 원하는 데이터도 심을 수 있으니…(다만 UDP로 1MB 데이터를 넣기는 힘들겠지만… 이 얘기는 TCP 도 열려있고 방화벽이 없을 가능성이 높으니… 원하는 데이터를 쉽게 넣을 수 있을듯 합니다.) 초당 엄청난 트래픽을 보낼 수 있게 됩니다.

    실제로 간단하게 셋팅을 해보았습니다. 간단하게 외부의 victim 에서 응답이 수신되는 걸 확인 할 수 있었습니다.

    spoof.png

    원문을 보면 nmap등을 이용해 간단하게 public 에 열려있는 memcached 서버들을 찾을 수 있습니다. 무시무시하지요.

    그럼 결론, 우리는 어떻게 대비해야 하는가?
    1] memcached를 public 에 공개하지 않는다. 이미 열려있는 곳이라면 iptable 등으로 방화벽을 따로 설정해서 UDP및 TCP 자체를 막으셔야 합니다. 이런 캐시서버는 public에 열리면 그냥 지옥입니다. redis의 경우도 바로 해당 계정이 탈취 당할 수 있습니다.(udp는 지원안합니다만…)

    2] UDP를 안 쓰면 사용하지 않는다.
    memcached 1.5.5 까지는 -U 0 라는 옵션을 주지 않으면 자동으로 UDP 11211 포트를 사용하였지만… 해당 이슈 이후에 긴급하게 나온 memcached 1.5.6은 UDP가 디폴트로 꺼져있습니다. 그러나 아마 대부분은 이전 버전을 쓰실거니… 시작 옵션도 미리 바꿔두시는게 좋습니다. 다음 memcached patch 를 보시면 UDP가 이제 디폴트로 disable 된걸 볼 수 있습니다.

    보안 관련 내용이라, 사용된 소스나 자세한 정보는 적지 않습니다.(다만 엄청 쉬워요 T.T)

    ps. 보안 이슈라는 것이, 방비를 잘 하더라도 안당한다고 말할 수 없지만, 대부분의 보안 이슈는, 사용하지 않는 서비스를 public 에 노출한다거나, 잘못된 설정으로 인해서 발생하는 경우가 많습니다. memcached 이슈도 마찬가지이고, S3에 대한 리포트 를 보셔도 실제로 전체 s3 버킷의 20%가 쓰기도 열려있다라는 충격적인 사실을 아실 수 있습니다. 큰 조직에서는 이런 문제를 전담해줄 만한 인력이 있지만, 중소 규모 사이즈에서는 더 신경을 많이 쓰셔야 합니다.

    [입 개발] 전문가는 계속 공부하는 사람이다. –김창준님의 개발자 실력 평가 어떻게 할 것인가 후기

    $
    0
    0

    안녕하세요. 입개발 CharSyam입니다. 둘째가 100일을 넘어서 저녁 약속 없는 신데렐라 시간을 하다가, 김창준님이 “개발자 실력 평가 어떻게 할 것인가?” 에 대해서 강의하신다고 해서 마님에게 애교를 부리면 허락을 받고 세미나를 들으러 왔습니다.

    평소 입개발자는 입개발의 단계를 올리기 위해서 부단히 노력해야 하며, 혀로 키보드 치기, 입에 발린 소리하기등을 연습해야 하는데, 개발자 실력 평가에서 어떻게 하면 잘 빠져나갈 수 있을 것인가에 대한 힌트를 얻기 위해서 겨우겨우 참여하게 되었습니다.

    웬지 창천항로님이 어마어마한 후기 를 이런식으로 남겨주실 것 같아서, 저는 느낌만…(참고로 저 링크는 창천항로님이 다른 세미나를 듣고 쓰신 후기… 대박!!! – 벌써 오늘 후기를)

    일단 요약부터 하자면, 개발자 실력이라고 적었지만, 전문가를 판별하는 방법은 질문(소통)과 공부를 하는 사람이라는 것이 핵심이었습니다. 먼저 코딩 테스트의 비효율성, 코딩 테스트로 테스트를 하면 코딩 테스트만 잘 푸는 사람이지, 회사의 업무를 잘 할 사람일 가능성과는 별 개의 일이라는 이야기… 전문가를 구분하는 테스트를 할 때, 기존의 테스트의 잘못된 가정으로 인해서(요새 통계학 쪽에서 말이 많은 P-Value 처럼), 지금까지는 비용이나 표본의 이슈등으로, 짧은 시간에 풀 수 있는 문제와, 혼자서 풀 수 있는 문제가 많았는데, 긴 시간을 들여야 하는 문제나, 협업해야 하는 문제의 경우, 정말 전문가는 다른게 문제를 인지하고 해결한다는 것이었습니다.

    또한 전문가는 컨텍스트를 이해하고 적용할려고 하는 반면에, 초보자는 단순히 문제를 풀려고 하는데, 제출되는 문제들의 경우는 컨텍스트가 제거되고 단순히 어떤 결과만을 바라는 문제들이라, 이걸 풀었다고 해서, 정말 일을 잘하는 지는 알 수 없다라는 얘기가 나왔습니다.(아, 제가 이해하고, 기억하는게 맞는건지… 애매하네요.)

    그럼 일단 전문가는 어떻게 알 수 있는가? 삼각측량 처럼, 다양한 평가(동료평가, 상사평가, 버그생성율, 코드 리뷰, 디자인 리뷰) 등을 거쳐서 점수가 골고루 높은 사람은 전문가일 가능성이 높은데, 뽑을 때는 이런 평가를 해서 뽑을 수는 없습니다.

    그렇다면, 우리회사에 적합한 사람은 어떻게 뽑을 것인가? 실제로 할 일을 비슷하게 만들어서, 이런 일을 해보도록 시키는것, 다만, 이를 위해 회사에서 실제로 잘하는 개발자, 평범한 개발자 그룹을 만들어서 비슷한 시험을 보게해서 잘하는 개발자들은 어떤 특징을 가져야 하는지를 찾아야 한다고 합니다. 그리고 이를 평가할 때, 누구나 비슷한 기준이 나오도록 기준을 정하는게 중요한데, 단순히 pass, fail이 아니라, 점수로 표현을 해야 한다고 하네요.
    그리고 이 채점 기준은, 잘하는 사람들과 평범한 사람들의 그룹에서 나오는 평균적인 행동의 차이(예를 들어, 잘하는 그룹은 평균적으로 질문을 5회 이상한다. 등의 기준을 찾아내야 한다고 합니다.)

    이걸 들으면서 생각난게 피보탈랩스의 입사 시험 방식입니다.(전 본적은 없고 듣기만…) 해당 팀의 업무를 모두 하루나 길면 이틀 단위로 나누고, 실제로 입사자와 해당 태스크를 직접 구현하고, 가능하면 배포까지 하는 것이 면접이라고 합니다. 팀의 새로운 툴에 대한 이해도나 커뮤니케이션 능력, 적응력을 다 볼 수 있는 테스트라고 하네요. 다만 다른 회사 분들과 얘기를 해보면, 소스 코드의 유출등이 가능하고, 시간을 너무 들여야 해서 어렵울 것 같다고 하시던데, 오픈소스 회사는 이런게 또 가능할 듯 합니다.

    회사 내에 해당 분야의 전문가가 있을 때는 기술력 검증이 쉽겠지만, 그렇지 않을 경우는 어떻게 할 것인가라는 질문이 있었는데, 전문가이고 잘할수록 더 열심히 공부하는 경향이 있다고 하네요.

    이제 여러분도 이런걸 명심하시고 대비하시면 좋은 입개발러가 되실 수 있습니다.(엉?)

    [입 개발] 신묘한 Python locals() 의 세계

    $
    0
    0

    오늘도 약을 팔러온 입개발 CharSyam 입니다. 오늘은 지인 분께서, Python에서 locals() 함수를 쓰면 local 변수를 참조할 수 있는데, 특정 현상은 이해가 안된다고 얘기를 하셔서, 한번 왜 그럴까에 꽃혀서 찾아본 내용을 정리할려고 합니다. 참고로, Python 쓰시는데 이런게 있구나 빼놓고는 아마 하등의 도움을 못 받으실 내용이니, 조용히 뒤로 가기 버튼을 눌리시고, 생산적인 페이스북을 하시는게 더 도움이 되실꺼라고 미리 경고드립니다.

    문제의 시작의 발단은 아래의 코드입니다.

    def test(x):
        locals()['v'] = x + 10
        print(locals()['x'])
        return locals()['v']
    
    print(test(100))
    

    locals() 함수는 현재의 local 변수를 dict type으로 던져주는 내장 함수입니다.
    그리고 사실 Python2, 3 문서에 보면 locals() 의 값은 고칠 수 없다라고 정의가 되어 있습니다.
    https://docs.python.org/3.3/library/functions.html#locals
    스크린샷 2018-05-04 오전 12.31.42

    그런데 위에서 보면 locals()[‘x’]의 경우는 로컬 변수 x의 값을 가져오고, locals()[‘v’]를 하면 v가 추가가 됩니다. 즉 해당 변수안에서 새로운 값을 이 locals()에 추가하는 것은 된다는 것입니다. 그렇다고 해서 그냥 v로 쓸 수 있지는 않고 locals()[‘v’] 이런식으로만 사용이 가능합니다.

    일단 locals()[‘v’] = 100 이라는 코드 자체는 locals()의 결과가 dict type이므로 전혀 문제가 없는 코드 입니다. 특별히 읽기 전용 속성 같은게 있는 것도 아니구요. 그리고 다시 locals()[‘v’]를 사용했을 때 해당 값을 가져올 수 있다는 것은, locals()가 같은 객체를 던져준다는 것입니다. id(locals()) 해보면 항상 같은 주소값을 던져줍니다.

    즉 우리는 다음과 같은 가설을 세울 수 있습니다. 가정1) locals()에 값이 저장이 된다.
    그럼 insert 가 되니 update 도 되지 않을까요? 당연히 아래와 같은 코드는 update도 잘됩니다. locals()의 결과는 dict type 의 객체일 테니…

    def test(x):
        locals()['v'] = x + 10
        locals()['v'] = 1
        return locals()['v']
    
    print(test(100))
    

    그런데 다음과 같은 코드가 출동하며 어떨까요?

    def test(x):
        locals()['x'] = 10
        return locals()['x']
    
    print(test(100))
    

    우리는 이미 답을 알고 있습니다. 처음 문서에 수정이 안된다고 했으니 당연히 결과는 100이 나오게 됩니다. 뭔가 이상하지 않나요? locals()의 결과는 그냥 dict type이고, 그래서 새로운 값을 추가하는 것도 분명히 되는데, 업데이트는 안됩니다. 뭔가 locals()는 특별하게 만든 기능이라, dict type 자체에서 set을 막고 있는 것일까요?

    일단은 먼저 Python 2.7.14 기준으로 설명합니다. Objects/dictobject.c 의 PyDict_SetItem 함수를 보면 특별히 특정 속성일 때 쓰기를 막는다 이런코드는 보이지 않습니다. dict_set_item_by_hash_or_entry 안으로 들어가봐도 큰 차이는 없습니다.

    int
    PyDict_SetItem(register PyObject *op, PyObject *key, PyObject *value)
    {
        register long hash;
    
        if (!PyDict_Check(op)) {
            PyErr_BadInternalCall();
            return -1;
        }
        assert(key);
        assert(value);
        if (PyString_CheckExact(key)) {
            hash = ((PyStringObject *)key)->ob_shash;
            if (hash == -1)
                hash = PyObject_Hash(key);
        }
        else {
            hash = PyObject_Hash(key);
            if (hash == -1)
                return -1;
        }
        return dict_set_item_by_hash_or_entry(op, key, hash, NULL, value);
    }
    

    그렇다면 dict type 자체의 이슈가 아니라 locals() 의 이슈가 아닐까라는 생각이 들게 됩니다. 제가 위에서 locals()는 내장함수라고 말씀드렸습니다. 그래서 Python/bltinmodule.c 를 살펴보면 builtin_locals를 찾을 수 가 있습니다. builtin_methods 테이블에 locals과 builtin_locals와 연결되어 있는 것을 찾을 수 있습니다.

    static PyObject *
    builtin_locals(PyObject *self)
    {
        PyObject *d;
        d = PyEval_GetLocals();
        Py_XINCREF(d);
        return d;
    }
    

    그냥 PyEval_GetLocals() 만 호출합니다. Python/ceval.c 에 있습니다. 다시 따라가 봅시다.

    PyObject *
    PyEval_GetLocals(void)
    {
        PyFrameObject *current_frame = PyEval_GetFrame();
        if (current_frame == NULL)
            return NULL;
        PyFrame_FastToLocals(current_frame);
        return current_frame->f_locals;
    }
    

    뭔가 이름이 있어 보이는 PyFrame_FastToLocals 함수가 보입니다. current_frame 은 뭔지는 잘 모르겠지만, locals니, 특정 scope에서의 코드 정보(함수안이냐, 글로벌이냐?)를 가져오는 걸로 예측이 됩니다.

    다시 PyFrame_FastToLocals 를 따라가 봅시다. Objects/frameobject.c 에 존재합니다.

    void
    PyFrame_FastToLocals(PyFrameObject *f)
    {
        /* Merge fast locals into f->f_locals */
        PyObject *locals, *map;
        PyObject **fast;
        PyObject *error_type, *error_value, *error_traceback;
        PyCodeObject *co;
        Py_ssize_t j;
        int ncells, nfreevars;
        if (f == NULL)
            return;
        locals = f->f_locals;
        if (locals == NULL) {
            locals = f->f_locals = PyDict_New();
            if (locals == NULL) {
                PyErr_Clear(); /* Can't report it 😦 */
                return;
            }
        }
        co = f->f_code;
        map = co->co_varnames;
        if (!PyTuple_Check(map))
            return;
    
        PyErr_Fetch(&error_type, &error_value, &error_traceback);
        fast = f->f_localsplus;
        j = PyTuple_GET_SIZE(map);
        if (j > co->co_nlocals)
            j = co->co_nlocals;
    
        if (co->co_nlocals)
            map_to_dict(map, j, locals, fast, 0);
        ncells = PyTuple_GET_SIZE(co->co_cellvars);
        nfreevars = PyTuple_GET_SIZE(co->co_freevars);
        fprintf(stderr, "co_nlocals: %d, ncells : %d, nfreevars : %d\n", co->co_nlocals, ncells, nfreevars);
        if (ncells || nfreevars) {
            fprintf(stderr, "map_to_dict1()\n");
            map_to_dict(co->co_cellvars, ncells,
                        locals, fast + co->co_nlocals, 1);
            /* If the namespace is unoptimized, then one of the
               following cases applies:
               1. It does not contain free variables, because it
                  uses import * or is a top-level namespace.
               2. It is a class namespace.
               We don't want to accidentally copy free variables
               into the locals dict used by the class.
            */
            if (co->co_flags & CO_OPTIMIZED) {
                fprintf(stderr, "map_to_dict2()\n");
                map_to_dict(co->co_freevars, nfreevars,
                            locals, fast + co->co_nlocals + ncells, 1);
            }
        }
        PyErr_Restore(error_type, error_value, error_traceback);
    }
    

    위의 코드를 보면 f->f_locals 를 locals에 대입하고 없으면 dict type을 생성하는 것을 알 수 있습니다. 우리의 locals()의 결과는 dict type이고 이름까지 비슷하니 이넘이구나 하실껍니다. 그런데 왜 update는 안되는 것일까요?

    그 의문은 다음 블로그에서… 는 뻥이고… co->co_nlocals 라는 변수에 있습니다. co는 codeobject 의 약어로 보이고, codeobject 는 해당 함수 관련 정보(global 변수 정보, local 변수 정보)를 가지고 있는 것으로 보입니다.(대충 봐서) 그 중에서 co_nlocals 는 local 변수의 개수를 가지고 있습니다. 즉 local 변수가 한개라도 있으면 map_to_dict 이 실행됩니다. 저는 처음에는 map_to_dict 에서 뭔가 locals 를 만들어 주는 줄 알았습니다. 그런데 map_to_dict 함수를 보면 map 에 있는 key를 dict에다가 PyObject_SetItem 를 통해서 덮어씌워버립니다. 그리고 저 map은 코드가 실행될때 넘어온 파라매터나 로컬 변수의 값들이 저장되어 있는 상황입니다.

    static void
    map_to_dict(PyObject *map, Py_ssize_t nmap, PyObject *dict, PyObject **values,
                int deref)
    {
        Py_ssize_t j;
        assert(PyTuple_Check(map));
        assert(PyDict_Check(dict));
        assert(PyTuple_Size(map) >= nmap);
        for (j = nmap; --j >= 0; ) {
            PyObject *key = PyTuple_GET_ITEM(map, j);
            PyObject *value = values[j];
    
            assert(PyString_Check(key));
            if (deref) {
                assert(PyCell_Check(value));
                value = PyCell_GET(value);
            }
            if (value == NULL) {
                if (PyObject_DelItem(dict, key) != 0)
                    PyErr_Clear();
            }
            else {
                if (PyObject_SetItem(dict, key, value) != 0)
                    PyErr_Clear();
            }
        }
    }
    

    헉, 하고, 이해를 하신 분들이 이미 계실꺼 같지만, 넵 그렇습니다. locals()를 호출할 때 아까 x의 값이 안바뀐 이유는 실제로 바뀌어도 locals()를 다시 호출할 때 map_to_dict 함수를 통해서 원래의 값으로 덮어씌워지기 때문입니다. 그래서 다른값들은 같은 locals를 사용하므로 추가나 변경, 삭제도 가능하지만, 기존에 존재하는 값들은 다시 원래의 값으로 덮어씌워지기 때문입니다.

    def test(x):
        x = 15
        locals()['x'] = 1
        return locals()['x']
    
    print(test(100))
    

    그래서 local 변수면 동일하게 동작하므로 파라매터가 아니고 그냥 내부에서 미리 선언된 local 변수라도 이렇게 값이 바뀌지 않습니다.

    def test():
        x = 15
        locals()['x'] = 1
        return locals()['x']
    
    print(test())
    

    우와 Python 의 locals()는 참 신묘합니다. 그런데… 문제는 여기서 끝이 아닙니다.(아니 도대체 쓸데 없는 이야기를 얼마나 더 할려고?) 그냥 여기까지 이해하고 넘어가려고 하는데… PyFrame_FastToLocals 함수 밑과 위에 map_to_dict 말고 dict_to_map 과 PyFrame_LocalsToFast 라는 함수가 있는게 아니겠습니까?

    먼저 dict_to_map 함수입니다.

    static void
    dict_to_map(PyObject *map, Py_ssize_t nmap, PyObject *dict, PyObject **values,
                int deref, int clear)
    {
        Py_ssize_t j;
        assert(PyTuple_Check(map));
        assert(PyDict_Check(dict));
        assert(PyTuple_Size(map) >= nmap);
        for (j = nmap; --j >= 0; ) {
            PyObject *key = PyTuple_GET_ITEM(map, j);
            PyObject *value = PyObject_GetItem(dict, key);
            assert(PyString_Check(key));
            /* We only care about NULLs if clear is true. */
    
            if (value == NULL) {
                PyErr_Clear();
                if (!clear)
                    continue;
            }
            if (deref) {
                assert(PyCell_Check(values[j]));
                if (PyCell_GET(values[j]) != value) {
                    if (PyCell_Set(values[j], value) f_locals into fast locals */
        PyObject *locals, *map;
        PyObject **fast;
        PyObject *error_type, *error_value, *error_traceback;
        PyCodeObject *co;
        Py_ssize_t j;
        int ncells, nfreevars;
        if (f == NULL)
            return;
        locals = f->f_locals;
        co = f->f_code;
        map = co->co_varnames;
        if (locals == NULL)
            return;
        if (!PyTuple_Check(map))
            return;
        PyErr_Fetch(&error_type, &error_value, &error_traceback);
        fast = f->f_localsplus;
        j = PyTuple_GET_SIZE(map);
        if (j > co->co_nlocals)
            j = co->co_nlocals;
        if (co->co_nlocals)
            dict_to_map(co->co_varnames, j, locals, fast, 0, clear);
        ncells = PyTuple_GET_SIZE(co->co_cellvars);
        nfreevars = PyTuple_GET_SIZE(co->co_freevars);
        if (ncells || nfreevars) {
            dict_to_map(co->co_cellvars, ncells,
                        locals, fast + co->co_nlocals, 1, clear);
            /* Same test as in PyFrame_FastToLocals() above. */
            if (co->co_flags & CO_OPTIMIZED) {
                dict_to_map(co->co_freevars, nfreevars,
                    locals, fast + co->co_nlocals + ncells, 1,
                    clear);
            }
        }
        PyErr_Restore(error_type, error_value, error_traceback);
    }
    

    다음은 PyFrame_LocalsToFast 함수입니다.

    void
    PyFrame_LocalsToFast(PyFrameObject *f, int clear)
    {
        /* Merge f->f_locals into fast locals */
        PyObject *locals, *map;
        PyObject **fast;
        PyObject *error_type, *error_value, *error_traceback;
        PyCodeObject *co;
        Py_ssize_t j;
        int ncells, nfreevars;
        if (f == NULL)
            return;
        locals = f->f_locals;
        co = f->f_code;
        map = co->co_varnames;
        if (locals == NULL)
            return;
        if (!PyTuple_Check(map))
            return;
        PyErr_Fetch(&error_type, &error_value, &error_traceback);
        fast = f->f_localsplus;
        j = PyTuple_GET_SIZE(map);
        if (j > co->co_nlocals)
            j = co->co_nlocals;
        if (co->co_nlocals)
            dict_to_map(co->co_varnames, j, locals, fast, 0, clear);
        ncells = PyTuple_GET_SIZE(co->co_cellvars);
        nfreevars = PyTuple_GET_SIZE(co->co_freevars);
        if (ncells || nfreevars) {
            dict_to_map(co->co_cellvars, ncells,
                        locals, fast + co->co_nlocals, 1, clear);
            /* Same test as in PyFrame_FastToLocals() above. */
            if (co->co_flags & CO_OPTIMIZED) {
                dict_to_map(co->co_freevars, nfreevars,
                    locals, fast + co->co_nlocals + ncells, 1,
                    clear);
            }
        }
        PyErr_Restore(error_type, error_value, error_traceback);
    }
    

    PyFrame_FastToLocals 가 원래 locals() 를 호출할 때 실제로 수행되는 함수였다면 PyFrame_LocalsToFast 는 뭔가 역으로 값을 바꿀 수 있는 함수가 아닐까라는 생각을 해봤습니다. 해당 함수를 역으로 살짝 추적해보니(악, 여기서 그만뒀어야 하는데!!!) Python/ceval.c 파일안에 exec_statement 라는 함수에서 이걸 호출해줍니다.

    설마설마 하면서 다음과 같은 예제를 만들어봤습니다.

    def a(x):
        exec("locals()['x'] = 100")
        print(x)
        return locals()['x']
    
    print(a(10))
    

    python 으로 실행을 시키니 그냥 10만 나옵니다. 안되나? 라고 생각했는데… 제가 보던 소스는 Python 2.7.14이고, 제가 실행시킨 python은 3.x(이래서 머리가 무식하면 손발이 고생합니다.), 다시 python2 버전으로 돌려보시면 짜잔, 값이 100이 나옵니다. 즉 우리는 로컬 변수 x를 덮어씌운겁니다. –-; 그런데 Python3를 살펴보면 이 exec_statement 라는 함수가 사라지고 실제로 PyFrame_LocalsToFast를 호출하기 힘든 형태로 바뀌었습니다. –-;(망할… 즉 python3 에서는 동작하지 않고 python2에서만 실행이 됩니다.)

    심지어 이렇게 하면 이런 이상한짓도 가능합니다. 아까는 locals()는 그냥 dict_type이라 뭔가 추가해도 다시 dict에서 꺼내와야 했는데… 아래의 예제를 사용하면 local 변수의 생성도 가능합니다. -_-(그런데 이렇게 생성할 필요가…, 참고로 Python3에서는 실행조차 안됩니다.)

    def a(x):
        exec("locals()['x'] = 100")
        exec("locals()['y'] = 10")
        print(x)
        print(y)
        return locals()['x']
    
    print(a(10))
    

    어쩌다 보니, 소스를 파다보니 이런 내용을 알게 되었는데, 사실 사용하실 때는 1의 도움도 안되는 뻘 글을 읽어주셔서 감사합니다.

    [입 개발] spring-security-oauth의 RedisTokenStore의 사용은 서비스에 적합하지 않습니다.

    $
    0
    0

    안녕하세요. 입개발 CharSyam 입니다. 저의 대부분의 얘기는 한귀로 듣고 한귀를 씻으시면 됩니다.(엉?) 일단 제목만 보면, 많은 Spring 유저들에게, 저넘의 입개발, 스프링도 모르면서라는 이야기를 들을듯 합니다.(아, 아이돌 까던 분들이 집단 따돌림을 당할 때의 느낌을 미리 체험할 수 있을듯 합니다. – 강해야 클릭율이 올라가는!!!)

    먼저, 내용을 시작하기 전에 저는 Spring 맹, Java 맹으로 무식하다는 걸 미리 공개하고 넘어갑니다. 흑흑흑 (나의 봄님이 이럴 리 없어!!!)

    일단 아시는 분이, Redis 와 oauth가 궁합이 안맞느냐라는 이야기를 들으면서 시작하게 됩니다. 아니, Redis와 oauth는 철자부터 다른데 무슨 얘기십니까? 라는 질문을 하다보니, Redis 에 과부하가 발생되서 처리가 잘 안된다는 얘기였습니다. 물론 수 많은 이유로 Redis가 느려질 수 있기 때문에, 자세한 정보를 요청했더니, spring-security-oauth 를 이용하고 계시다는 이야기를 들었습니다. 시간이 지나갈 수록 점점 느려진다는 느낌을 받고, 어떨 때는 Redis가 처리를 못한다고… 일반적인으로 아주 일반적으로 Redis는 아주 짧은 get/set 등의 요청은 초당 8~10만 정도는 가볍게 처리가 가능합니다.(일단 어떤식으로 문제에 접근했는가는 다음 번 주제로 미르고)

    결론부터 얘기하자면, 당연히 사용하는 사람의 어느정도 실수가 발생하기 때문이긴 하지만, spring-security-oauth 의 RedisTokenStore 는 서비스에서 장애를 일으키기 쉽습니다.(보통은 라이브러리보다는 우리의 문제를 확인하는 것이 첫번째, 두번째도 우리문제, 세번째는 내 잘못인지 확인이… 정답입니다.)

    먼저 Redis 는 Single Threaded 입니다. 즉, 하나의 명령이 많은 시간을 소모하면 그 동안은 아무런 작업을 하지 못합니다. 즉 Redis를 잘 쓰고 적합하게 이용하는 것은, 명령어를 빨리 수행해서 결과를 빨리 줄 수 있는 상황에서 이용하는 것이 적합하다는 것입니다.

    사실 spring-security-oauth 자체가 큰 문제라기 보다는, 이 코드를 작성한 분은 사용자가 이런식으로 사용하게 되면 문제가 될 것이라는 고려를 하지 않은 것이 가장 큰 이슈입니다. 내부적으로 RedisTokenStore는 단순히 키 자체를 저장하는 get/set 커맨드와, 현재까지 발급되었던 키 정보를 저장하는 List 자료구조를 쓰고 있습니다. 아래 보면 rPush를 사용하는 approvalKey 와 clientId 입니다.

    @Override
    public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
    ......
        if (!authentication.isClientOnly()) {
    	conn.rPush(approvalKey, serializedAccessToken);
        }
        conn.rPush(clientId, serializedAccessToken);
    ......
    }
    

    그런데 저 approvalKey와 clientId는 OAuth2Authentication -> OAuth2Request -> getClientId() 함수를 통해서 만들어집니다. 그런데 여기서 흔히들 하는 실수가 getClientId를 동일한 값으로 셋팅하는 것입니다. 그렇게 되면, 단순히 key가 있는지 확인할 때 말고 해당 List에 위와 같이 데이터가 들어가는 경우는 엄청나게 많은 아이템을 가진 List 자료구조가 생길 가능성이 높습니다.

    그런데 Redis의 List 자료구조는 앞이나 뒤로 넣고, 앞이나 뒤에서 빼는 것은 빠르지만 O(1), 그 안의 데이터를 찾거나 모두 가져오면 결국 선형 탐색이 일어납니다. O(N), 그러면 그 안에 백만개 천만개가 들어있다고 가정하면 엄청난 속도 저하를 가져오게 됩니다. 그리고 그 안에 다른 명령을 하나도 처리할 수 가 없게됩니다.

    아래 findTokensByClientIdAndUserName 와 findTokensByClientId 두 개의 함수는 대표적으로 모든 아이템을 가져오도록 하고 있습니다. 안쓰는걸로 하셔야 합니다.

    	@Override
    	public Collection findTokensByClientIdAndUserName(String clientId, String userName) {
    		byte[] approvalKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(clientId, userName));
    		List byteList = null;
    		RedisConnection conn = getConnection();
    		try {
    			byteList = conn.lRange(approvalKey, 0, -1);
    		} finally {
    			conn.close();
    		}
    		if (byteList == null || byteList.size() == 0) {
    			return Collections. emptySet();
    		}
    		List accessTokens = new ArrayList(byteList.size());
    		for (byte[] bytes : byteList) {
    			OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
    			accessTokens.add(accessToken);
    		}
    		return Collections. unmodifiableCollection(accessTokens);
    	}
    
    	@Override
    	public Collection findTokensByClientId(String clientId) {
    		byte[] key = serializeKey(CLIENT_ID_TO_ACCESS + clientId);
    		List byteList = null;
    		RedisConnection conn = getConnection();
    		try {
    			byteList = conn.lRange(key, 0, -1);
    		} finally {
    			conn.close();
    		}
    		if (byteList == null || byteList.size() == 0) {
    			return Collections. emptySet();
    		}
    		List accessTokens = new ArrayList(byteList.size());
    		for (byte[] bytes : byteList) {
    			OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
    			accessTokens.add(accessToken);
    		}
    		return Collections. unmodifiableCollection(accessTokens);
    	}
    
    

    위의 함수들은 안쓴다고 하더라도, 아래와 같이 removeAccessToken 함수는 자주 불리는데 여기서도 lRem을 통한 선형 탐색이 발생합니다.(lRem이 문제…)

    	public void removeAccessToken(String tokenValue) {
    		byte[] accessKey = serializeKey(ACCESS + tokenValue);
    		byte[] authKey = serializeKey(AUTH + tokenValue);
    		byte[] accessToRefreshKey = serializeKey(ACCESS_TO_REFRESH + tokenValue);
    		RedisConnection conn = getConnection();
    		try {
    			conn.openPipeline();
    			conn.get(accessKey);
    			conn.get(authKey);
    			conn.del(accessKey);
    			conn.del(accessToRefreshKey);
    			// Don't remove the refresh token - it's up to the caller to do that
    			conn.del(authKey);
    			List results = conn.closePipeline();
    			byte[] access = (byte[]) results.get(0);
    			byte[] auth = (byte[]) results.get(1);
    
    			OAuth2Authentication authentication = deserializeAuthentication(auth);
    			if (authentication != null) {
    				String key = authenticationKeyGenerator.extractKey(authentication);
    				byte[] authToAccessKey = serializeKey(AUTH_TO_ACCESS + key);
    				byte[] unameKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(authentication));
    				byte[] clientId = serializeKey(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId());
    				conn.openPipeline();
    				conn.del(authToAccessKey);
    				conn.lRem(unameKey, 1, access);
    				conn.lRem(clientId, 1, access);
    				conn.del(serialize(ACCESS + key));
    				conn.closePipeline();
    			}
    		} finally {
    			conn.close();
    		}
    	}
    

    당연히 그러면 이건 사용자 실수가 더 큰거 아니냐 라고 하실 수 있습니다. 그런데 꼭 사용자의 실수가 아니더라도 특정 사용자가 비정상적으로 이런 동작을 하면 비슷한 이슈가 발생할 수 있습니다.

    그러면 어떻게 해야하는가? 해당 로직들에서 list 를 사용하는 부분을 좀 더 속도가 빠르거나 하는 것들로 바뀌고 모든 데이터를 가져오는 부분은 사용하지 못하게 하는 것이 방법입니다.

    어떤 분들은 이미 이런걸 겪으셔서 내부적으로 해당 모듈을 수정해서 쓰시는 분도 계셨습니다. 그러나 이런부분 때문에 관련 부분을 어느정도는 직접 구현해서 쓰시는 거나, 이런 부분을 수정해서 쓰셔야 할듯 합니다. 결론적으로 spring-security-oauth 의 RedisTokenStore를 쓰는 부분은 서비스 부하가 늘어날 때 장애를 일으킬 확률이 높습니다.

    그 외에도 refreshToken의 경우는 보통 expire 기간이 훨씬 긴데, 이것들은 전부 메모리에 저장하는 부분들 또한 이슈가 생길 수 있습니다.(돈 많은면, 메모리 빵빵한 장비 쓰시면, 이 부분은 신경 안쓰셔도…)

    보통 Spring이라는 이름을 붙이면 굉장히 안정적인데, 소스를 보고, 문제가 생길만한 부분에 대한 가정을 너무 약하게 하고 넘어간 부분이 문제인듯합니다.(하지만 해당 코드는 벌써 3년 전에 만들어진 코드라는 거…)

    쉬시면서 Spring 코드 한번 읽어보시는 것도 좋을듯합니다.(전 읽어본 적 없습니다. ㅋㅋㅋ), 해당 글을 작성하는데 도움을 주신 우아한 형제들의 엯촋 이수홍 선생님께 감사드립니다.

    [입 개발] Redis 에서 zadd 와 zincrby 의 차이

    $
    0
    0

    안녕하세요. 입개발 CharSyam입니다. 오래간만에 포스팅을 하게 되네요. 오늘은 아주 간단한 것을 포스팅 할려고 합니다. 가끔씩 Redis의 sorted set 을 사용하는 명령중에 zadd 와 zincrby 가 있습니다. 과연 이 두 개의 명령은 어떤 차이가 있을까요?

    결론부터 말하자면, zadd 와 zincrby 는 사실 같은 기능을 사용하는 아주 유사한 명령입니다. 먼저 zadd 는 다음과 같이 사용하니다.

    zadd key score member
    

    그러면 sorted set 에 member 가 해당 score를 가지게 됩니다. 여기서 가지게 됩니다를 중요하게 봐주세요.

    그리고 zincrby 는 특정 값 increment 만큼 score를 증가시키는 명령입니다.

    zincrby key increment member
    

    그렇다면 다음 두 가지 경우로 나눠보도록 하겠습니다. 현재 해당 key에 member가 존재하지 않는 경우와 존재하는 경우입니다. 먼저 존재하지 않을 경우, 두 개의 명령 zadd 와 zincrby 는 모두 해당 member를 생성하게 됩니다. 그리고 zadd 는 해당 score로 설정하고 zincrby 는 0에 해당 increment를 추가한 것 처럼 동작합니다.(즉 둘 다 해당 값으로 설정하게 되는거죠.)

    그렇다면 해당 member가 존재할 경우는 어떻게 될까요? zadd는 해당 member의 score를 현재 넘겨준 값으로 변경시켜 버립니다. 즉 기존에 3이 었는데 zadd key 1 member 라면 해당 member 의 score 는 3에서 1로 변경이 됩니다. zincrby 의 경우는 zincrby key 1 member 라면 기존 값이 3이 있었다면, 이제 3에 1을 더하게 되어서 4가 되게 됩니다.

    코드를 보면 zadd 와 zincrby 는 Flag 하나만 다르고 동일한 함수를 사용합니다.

    void zaddCommand(client *c) {
        zaddGenericCommand(c,ZADD_NONE);
    }
    
    void zincrbyCommand(client *c) {
        zaddGenericCommand(c,ZADD_INCR);
    }
    

    그리고 zaddGenericCommand를 보면 zsetAdd 를 호출하게 되는데… zadd는 모두 기존 값을 가져와서, score 를 비교합니다. 그래서 zincrby 에서 ZADD_INCR 설정되면 new_score 로 기존값 + increment로 설정하고, zadd 에서는 new_score 를 넘겨준 설정값으로 설정하게 됩니다.

                /* Prepare the score for the increment if needed. */
                if (incr) {
                    score += curscore;
                    if (isnan(score)) {
                        *flags |= ZADD_NAN;
                        return 0;
                    }
                    if (newscore) *newscore = score;
                }
    
                /* Remove and re-insert when score changed. */
                if (score != curscore) {
                    zobj->ptr = zzlDelete(zobj->ptr,eptr);
                    zobj->ptr = zzlInsert(zobj->ptr,ele,score);
                    *flags |= ZADD_UPDATED;
                }
    

    그리고 score가 변경되면 sorted set 은 기존 member를 지우고 다시 insert 하는 식으로 동작하게 됩니다. 결국 zadd 와 zincrby 는 거의 다른게 없습니다.

    [입 개발] EXT4 에서 달라진 부분들 #1 – Flexible Block Group

    $
    0
    0

    사실 EXT4는 나온지 굉장히 굉장히 오래된 파일 시스템입니다. EXT의 역사를 보면 간략히 다음과 같습니다.

    • EXT, 1992년 출시
    • EXT2, 1993년 출시
    • EXT3, 2001년 커널 2.4.15에 포함
    • EXT4, 2006년 커널 2.6.19에 포함
    • 2008년 커널 2.6.28에 EXT4 안정 버전 포함

    한마디로 굉장히 오래되었습니다. 거의 10년이 지났네요. 그런데 아는 분들은 다 아시지만, 그래도 모르는 사람들은
    EXT2 -> EXT3 -> EXT4로 넘어가면서 무엇이 변했는지 잘 모릅니다. 그냥 좋아졌다?

    EXT4로 들어오면서 크게 바뀐것들이 좀 있는데, 그 중에서도 Flexible Block Group, Extent를 위한 HTree,
    많은 수의 파일 목록에서 쉽게 파일을 찾기 위한 Hash Directories 등이 있습니다.

    일단 생각은 해당 EXT4 시리즈에서는 크게 위의 세 가지만 다뤄볼려고 합니다. 자세한 것은 당연히 다루지 않습니다.
    왜냐… 잘모르니깐…

    Flexible Block Group 을 설명하기 위해서는 먼저 EXT에서 Disk Layout 을 어떻게 구성하는가 부터 알아야
    합니다. EXT에서는 Block Group 이라고 해서 특정 사이즈 단위로 디스크를 나눠서 관리 정보를 가지고 있다고
    보시면 됩니다.

    그림1

    즉 그림1 처럼 하나의 디스크를 여러개의 Block Group 으로 나누게 됩니다. 보통 하나의 Block Group 은 128MB
    할당 됩니다. 그럼 하나의 Block Group 은 어떻게 구성되는가? 그림2 처럼 각 Block Group 은 전체 정보를 가지는
    Super Block 과 GDTs, Reserved GDTs를 가지게 됩니다. 이 정보들은 주로 Block Group 0번의 것을 사용하지만
    백업용으로 다른 Block Group 들에도 존재합니다. 이 것 이외에 Block Bitmap, Inode Bitmap, Inode Table,
    Data 영역은 각 Block Group 단위로 관리되는 정보들입니다. 이 때 보면 Block Bitmap, Inode Bitmap은 하나의
    Block 만 할당 받기 때문에 Block의 크기가 4K 라면 bit 단위로 총 32k 개를 관리할 수 있습니다. 그래서 32k * 4K
    해서 하나의 Block Group 의 크기가 128MB로 보통 설정되게 됩니다.

    그림2

    그런데 이 구조의 단점은 무엇일까요? 작은 크기의 파일은 큰 문제가 없는데, 128MB(실제로는 이것보다 조금 더 작은 크기의)
    를 넘어가는 파일부터는 Block Group 하나에 저장되지 않아서, 무조건 Disk 에 물리적으로 Fragmentation 이 발생하게 됩니다.
    즉 연속적으로 읽을 수 없고, Random Access 가 발생해야 한다는 것이죠. 그리고 현재에는 사실 128MB 단위는 생각보다
    많이 작습니다.

    그럼 결국 Flexible Block Group 은 뭐냐? 몇개의 Block Group 들을 하나로 묶자는 것이 메인 아이디어 입니다.
    그래서 그림3 처럼 각 Block Group 의 Block Bitmap, Inode Bitmap 등이 쭈욱 연결되서 하나처럼 관리되게 됩니다. 여기서는
    8개의 Block Group 이 하나처럼 관리되는 것으로 가정했습니다.

    그림3

    정리하자면, 각 Block Group 에 있던, Block Bitmap, Inode Bitmap, Inode Table 영역들이 쭈욱 연결되는 형태로 들어가게 됩니다.
    0번 Block Group 에 다른 Block Group 들의 메타정보(Block Bitmap, Inode Bitmap, Inode Table) 들이 다 넘어오게 되는 것이죠.
    그림2와 비교해보시면 그 차이가 명확하게 보이실 겁니다.

    주의 할 것은 EXT4에서 Flexible Block Group 은 무조건 사용하는 것이 아니라, 헤더에 FLEX_BG 라는 Flag가 설정되어 있어야만 Flexible Block Group 을 사용 가능합니다. 즉, 이 Flag가 꺼져있다면, 예전 방식대로 처리해야한 합니다.

    다음번에는 EXT4에서 extents를 어떻게 관리하는지, 살펴보도록 하겠습니다.

    EXT4 에 대해서 Ext4 Disk Layout 이라는 매우 좋은 자료가 있습니다. 자세한 건 이걸 참고하세요.

    [입 개발] EXT4 에서 달라진 부분들 #2 –데이터 영역의 관리

    $
    0
    0

    EXT4가 들어오면서 크게 달라진 부분 그 두 번째는, 데이터 영역을 관리하는 방법입니다. 일단 기존에서 사용하던 방식을 알아보도록 하겠습니다. 파일시스템의 특성을 볼때는, 그 시기에 있던 기술의 한계를 알면 도움이 되는데, 최초에 ext가 나오던 시기는 그렇게 큰 파일이 많지는 않던 시기입니다. 즉 엄청 큰 파일을 처리할 일이 많지는 않던 시기입니다. 그리고 기본적으로 ext2~3 까지는 하나의 파일의 최대 크기는 4 bytes 변수를 사용하기 때문에 최대 4기가 한계입니다.(블럭의 크기와 상관없이 일단 최대한 가졌을 때 표현할 수 있는 한계가 4기가입니다. 파일 크기를 나타내는 변수가 4 bytes 이기 때문이죠.)

    여기서 보통 파일의 종류에 따라서 File/Directory 에서 File이면, 실제 데이터 영역에 파일의 내용이 있고, 디렉토리면, 해당 디렉토리에 있는 파일 목록에 대한 정보가 데이터 영역에 관리가 되게 됩니다. 즉, “I am a boy” 라는 내용을 가진 파일이 있다면, 그 파일의 크기는 10이고, block 크기가 4k라면, 한 개의 block만 할당이 되어있을것입니다. 그래서 데이터 영역에 가면 처음 10바이트가 “I am a boy”가 들어있게 됩니다. 이걸 관리하는 정보는 EXT는 inode에 있고, ext2~3에서는 그 크기는 총 60바이트입니다. 그리고 블럭을 가리키는 정보는 하나가 4 bytes 입니다. 즉, 60바이트의 공간이 할당되어 있는데, 한 칸이 4 bytes 면 총 15개의 정보를 저장하는 공간이 들어갈 수 있습니다.(FAT32 를 안다면 Fat Table 을 생각하시면 쉽습니다.)

    그림1

    그런데 여기서 잘 생각해보면, 그림1 처럼 한칸이 4 bytes라 하나의 블럭을 가리키면… 60바이트로는 총 15개… 한 칸이 하나의 블럭을 가리키므로, block 크기가 4k면 4k * 15 = 60k 밖에 안됩니다.(아까는 4기가가 최고라며!!!) 어떻게 된 것일까요? 사실 EXT2/3의 특징 중에 하나가 direct, indirect block, double indirect block, triple indirect block 으로 구성이 된다는 것입니다.

    먼저 시작하기 전에 inode에 들어있는 15 개의 칸은… 12개의 direct와 각각 하나씩의 indirect, double indirect, triple indirect 로 구성이 되어있습니다. 먼저 direct 는 그 칸 하나가 그림2처럼 실제 디스크의 블럭하나를 가리키는 것입니다. direct 로는 최대 60k 밖에 파일이 가질 수가 없으므로, 이 사이즈를 더 크게 사용하기 위한 것들이 위한 indirect, double indirect, triple indirect 방식입니다.

    그림2

    12개니 각 블럭이 4k면 48k의 공간을 지정할 수 있습니다. 그렇다면 13번째 칸의 indirect 는 뭐냐, indirect 가 가리키는 블럭으로 가면, 아까 12개의 direct block을 가리키는 정보가 있던 것과 같은 형태로 실제 디스크를 가리키는 블럭이 direct block이 나옵니다. 블럭 크기가 4k면 여기에 실제 디스크의 블럭을 가리킬 수 있는 정보가 각각 4 byte니 1024개가 들어가게 됩니다. 즉, indirect block은 실제로 direct 블럭 1024개를 가리키므로 4M의 공간을 지정할 수 있습니다. 그림3을 참고합시다.

    그림3

    그럼 이제 슬슬 이해가 가기 시작할 것입니다. indirect 블럭은 해당 블럭이 direct block을 어드레싱 하는 주소 정보가 들어가 있고, double indirect 블럭이 가리키는 정보는 indirect 블럭을 가리키는 블럭 1024개를 가리키는 블럭에 대한 정보가 됩니다. 그림4를 참고하면, 실제로 double indirect 블럭의 내용은 indirect block 정보들입니다.

    그림4

    여기서는 4M 짜리 1024개를 가리키므로 4G 의 디스크 정보를 가리킬 수 있습니다. 이쯤되면 이제 triple indirect 의 성격도 눈치 채실 껍니다.
    triple indirect block이 가리키는 정보는 double indirect block 1024개를 가리키는 정보입니다. 그림5를 참고하면 됩니다. 그런데 여기서
    의아한 것은 사실 double indirect block 만으로도… 실제 파일의 최대 크기인 4G가 넘어간다는 것입니다.(물론 블럭 크기가 4K 일 경우입니다. 1K로 설정되면 이 마지막 블럭을 사용하게 되겠지만… 블럭 크기가 4k면… 마지막 triple indirect 는 사용할 이유가 없습니다.)

    블럭 크기가 4k 일때, triple indirect block은 4G * 1024 = 4TB 의 데이터 영역을 가리킬 수 있습니다. 그림5를 참고합시다.

    그림5

    그런데 ext4로 가면서 왜 이런 구조를 버리는 것일까요? 간단하게 생각해보면 해당 구조는 낭비가 너무 심합니다. 혹시나 FAT32에서 NTFS로 가면서 내부구조가 어떻게 바뀌었는지 이해한다면, 실제로 EXT4의 extend를 이해하는 것은 굉장히 수월합니다.

    먼저 간단하게 생각해봅시다. 1000, 1001, 1002, 1003 4개의 블럭이 있다고 할 때 이걸 가리키는 방법으로 ext2에서의 기존 방식은 하나에 한칸을 가리켜야 하므로 총 4칸 16 바이트가 필요합니다. 그런데 이렇게 표현할 수 있지 않을까요? (시작위치, 블럭 개수), 이런 형태면 다음과 같은 (1000, 4) 형태로, 표현이 가능해집니다. 데이터 공간도 각각 4 바이트를 쓴다고 하더라도 8바이트면 줄어듭니다. 물론 매번 필요한 공간이 Fragmentation 이 발생한다면 (1000, 1), (1001, 1), (1002, 1), (1003, 1) 형태로 공간을 낭비하게 됩니다. 즉 연속된 공간이 많이 필요할 수록, 새로운 표현 방법이 공간을 절약할 수 있습니다. 또한 추가 분석할 필요 없이도 쉽게 뒤에 얼마만큼 읽어야 할지도 알 수 있게 됩니다.

    이런 방식을 적용한것이, ntfs의 cluster runs 나, ext4의 extent 라는 구조입니다. 그런데 extent는 (시작위치, 블럭 개수) 의 구조에서 블록 개수에는 2 byte만 할당되어 있습니다. 그래서 block 크기가 4k 기준일 때 무조건 32768 즉 4k * 32768 = 128MB 이상되면 아무리 디스크에 연속적으로 할당된 공간이 있더라도 extend가 추가로 생기게 됩니다. 즉 1GB 파일이면 8개는 extent가 생겨야 합니다.

    그런데 extent구조를 보면 헤더가 일단 12 바이트이고, 리프 노드(실제 파일의 정보를 가리키는)냐, 인덱스 노드(리프 노드나 다른 인덱스 노드의 정보를 가지는)냐에 따라 가지는 정보들이 각각 12 bytes입니다. 즉… 60바이트에서 헤더를 빼고나면 일단 inode 안에는 4개의 리프 노드만 들어갈 수 있습니다. 즉 512MB 보다 파일 사이즈가 커지거나 fragmentation 이 많이 나면… 결국 tree 형태로 관리되는 정보가 실제 다른 블럭에 들어가야만 합니다.

    그림6

    그래서 실제 구조는 그림6처럼 되게 됩니다. 그리고 이 extent도 실제로는 해당 파일의 inode의 flag에 USE_EXTENT 가 설정되어 있어야 사용하게 됩니다. 즉 ext4 내에 있는 파일이 어떤 건 extent 형태로, 어떤건 옛날 방식으로도 주소 지정이 가능하다라는 것입니다. 다음번에는 기존에 왜 한 디렉토리에 파일이 많으면, 파일 열기등이 느려지는지, ext4에서는 어떻게 풀고 있는지 가볍게 살펴보도록 하겠습니다.


    [구글스터디잼] Kubernetes in the Google Cloud #1

    $
    0
    0

    해당 글은 현재 하고 있는 구글 스터디잼 Kubernetes in the Google Cloud 을 학습하는 과정에서 배우는 것을 정리하는 글입니다.

    총 10개의 챕터가 있고 일단은 가장 처음 두 개인, Introduction to Docker, Hello Node Kubernetes 를 공부했는데…

    1] Introduction to docker
    -> 실제로 Docker 책을 보면 쉽게 보게되는 명령들을 소개해줍니다. 즉 docker 를 실행시키거나, 현재 수행중인 container 에 접속한다거나, 현재의 수정본을 docker hub 등의 외부 registry에 등록하는 방법(사실 여기서는 gcloud 를 이용해서 gcp내의 registry에 등록하더군요.)

    /node-app:0.2

    사실 이미 docker 를 사용해 보신 경험이 있다면, 특별한 차이를 느끼지 못하실꺼 같습니다. 다른 차이는 실습을 하게 되는 환경에서 이미 docker/kubenetes 등이 다 설치되어 있어서… 아주 편하게 실습을 할 수 있다는 것 정도… 일단 제목이 Kubernetes in the Google Cloud 인거 처럼 kubenetes 내용은 2장 부터 시작됩니다.

    2] Hello Node Kubernetes

    Kubernetes 는 Container Orchestration Tool 이라고 볼 수 있습니다.(즉 컨테이너를 관리해주는?) 여기서는 간단히 kubenetes 위에서 node application을 배포하고 Rolling update 하는 것을 보여주게 됩니다.

    2-1] Cluster 의 생성
    다음과 같은 과정을 통해서 클러스터를 생성하게 됩니다. 클러스터가 생성된다고 이미지가 돌고 있거나 그러지는 않고, 클러스터만 실행된다고 생각하면 됩니다.

    #project 이름 설정
    gcloud config set project PROJECT_ID
    
    #hello-world 라는 이름으로 cluster 생성, 노드는 2개, 머신 타입은 n1-standard-1, 생성되는 존은 us-centrall-a 입니다.
    gcloud container clusters create hello-world \
                    --num-nodes 2 \
                    --machine-type n1-standard-1 \
                    --zone us-central1-a
    
    
    

    2-2] Pod 의 생성
    생성된 클러스터에서 이제 실제로 container를 실행하게 됩니다.

    #hello-node 라는 이름으로 컨테이너 실행
    kubectl run hello-node \
        --image=gcr.io/PROJECT_ID/hello-node:v1 \
        --port=8080
    
    #kubectl get deployments 로 현재 deployment 상황을 볼 수 있고
    kubectl get deployments
    
    #kubectl get pods 로 현재 수행중인 pod를 볼 수 있습니다.
    kubectl get pods
    

    2-3] container 외부에 노출하기
    갓 만들어진 컨테이너를 외부에 서비스하기 위해서는 외부에서 접속을 할 수 있어야 합니다. 하지만 처음 생성되었을때는 kubenetes 내부에서만 연결이 되고 외부에서는 접속이 안될 것입니다. 이럴 때 특정 pod는 외부에서 접속할 수 있어야 하는데, 이러면 외부 IP를 가지거나 , 외부에서 해당 pod에 연결할 수 있는 proxy가 실행되어야 할껍니다.(아니면 기존 proxy의 설정이 바뀌거나…)

    #외부에 노출하기
    kubectl expose deployment hello-node --type="LoadBalancer"
    
    #어떤 서비스들이 있는지 확인
    kubectl get services
    #결과의 EXTERNAL-IP를 통해서 외부 IP를 확인할 수 있습니다.
    
    

    2-3] container scaling(개수 변경)
    최초에는 container 가 하나만 실행되는데, 이 개수를 바꾸거나 하고 싶을때 어떻게 해야하는지 설명합니다.

    #실행되는 컨테이너의 개수를 4개로 바꾸기
    kubectl scale deployment hello-node --replicas=4
    
    #kubectl get deployment를 하면 아까와 다른 결과를 볼 수 있습니다.
    

    다만 이 과정과정가 눈깜짝할 사이에 끝나지는 않습니다. 저는 실험결과 몇초에서 몇분 정도 지나야 이제 제대로 적용되는 걸 볼 수 있습니다.

    이제 쿠버네티스의 상태는 다음과 비슷합니다.
    kube1

    실제로 다음에 해보면 좋을것 같은 부분…
    scale 은 실제로 서비스를 할 때, 중요한 부분이므로, 자신들의 서비스를 넣고, 롤링 업데이트나 scale을 바꿀때, 실제로 영향이 어떻게 되는지를 확인하시는게 좋을듯 합니다.

    [구글스터디잼] Kubernetes in the Google Cloud #2

    $
    0
    0

    해당 글은 현재 하고 있는 구글 스터디잼 Kubernetes in the Google Cloud 을 학습하는 과정에서 배우는 것을 정리하는 글입니다.

    3장 부터 4장은 Kubernetes 에서 Pod, Service, Scaling과 deployment 를 보여주고, 5,6장은 젠킨스와 slackbot 예제를 보여주고 있습니다.

    실제로 이 예제를 따라가다 보면, 영어를 착실하게 보지 않는다면, Pod는 무엇이고 Service는 무엇인가에 대해서 막 혼란이 오게 됩니다.

    Pod는 하나 또는 여러개의 컨테이너와 볼륨으로 구성된 오브젝트입니다. namespace 를 공유하고 Pod당 하나의 ip가 할당됩니다.(여기서 할당되는 ip는 public 일수도 있고, 그냥 private 일 수도 있습니다. 그러나, 보통 private 이 할당되겠죠?)

    pods_1

    그리고 Service는 특정 Pod들에 대한 endpoint 가 됩니다. 해당 Service의 endpoint 에 접속하면 아래의 Pod들에게 자동으로 데이터가 한번씩 전달되게 됩니다.(Load Balancer 역할)

    service_1

    예제를 살펴봐도, 하나의 Pod가 여러개의 container를 포함하지만, 하나의 Pod가 한 종류의 여러 Container를 가지는 걸로 보이지는 않고, 하나의 서버 단위가 하나의 Pod라고 봐도 될듯합니다. 예를 들어, 하나의 Pod에 API Server, Volume 가 들어가고, 이제 Service가 그 Pod들을 로드밸런싱 합니다. 서비스에 더 처리용량이 필요하면, Pod를 계속 추가하는 거죠. 이제 kubernetes의 scaling 이라고 보시면 됩니다.

    아래 그림을 보면 kind 가 Pod로 나옵니다.
    스크린샷 2019-01-20 오후 8.00.53

    그리고 이 Pod 는 다음과 같이 실행이 가능합니다.

    kubectl create -f pods/monolith.yaml
    

    이제 이 앞단에 들어갈 서비스를 생성해봅시다.

    스크린샷 2019-01-20 오후 8.00.40

    생성하는 방법은 동일합니다.

    kubectl create -f service/monolith.yaml
    

    또, 논리적으로 구분하는 Namespace 라는 개념과, Label 이라는 개념을 이용해서 관리가 가능합니다. 해당 Pod는 frontend 다, backend다 이런식으로 태그를 달아주는 것이죠.

    그리고 이런 오브젝트들을 관리하기 위해 컨트롤로러라는 개념이 있다고 합니다. 여기에 replicaset, deployment 라는 컨트롤러가 있고 이를 이용해서 쉽게 scale 변경이 가능합니다.

    kubectl scale deployment hello --replicas=3
    kubectl scale deployment hello --replicas=5
    

    그리고 Rolling Update 도 지원합니다(한번에 정해진 단위로만 업데이트를 해서 전체를 업데이트 합니다.)
    다음과 같이 edit deployment 를 이용하여 container 이미지를 수정하게 되면 자동으로 Rolling update가 시작합니다.

    kubectl edit deployment hello
    

    Rolling update를 멈추거나 멈춘 업데이트를 재개하고 싶다면 다음 명령을 이용합니다.

    kubectl rollout pause deployment/hello
    kubectl rollout resume deployment/hello
    

    여기서 중요한 것은, 업데이트 이후에 이전 버전으로 돌리고 싶다면… rollout undo를 이용해서 이전 버전으로 쉽게 돌릴 수 있습니다. 이것도 Rolling update로 진행됩니다.

    kubectl rollout undo deployment/hello
    

    Reference
    1. https://zzsza.github.io/development/2018/04/17/docker-kubernetes/
    2. https://kubernetes.io/ko/docs/concepts/overview/working-with-objects/kubernetes-objects/
    3. http://bcho.tistory.com/1256
    4. https://blog.2dal.com/2017/03/07/kubernetes/

    [구글스터디잼] Kubernetes in the Google Cloud #3

    $
    0
    0

    해당 글은 현재 하고 있는 구글 스터디잼 Kubernetes in the Google Cloud 을 학습하는 과정에서 배우는 것을 정리하는 글입니다.

     * Setting up a Private Kubernetes Cluster
    외부 IP를 가지고 않고 내부 네트워크 ip 대역만 가지고 동작하게 되는 쿠버네티스 클러스터를 설정하는 방법에 대해서 소개하는 세션입니다.

    gcloud beta container clusters create private-cluster \
        --private-cluster \
        --master-ipv4-cidr 172.16.0.16/28 \
        --enable-ip-alias \
        --create-subnetwork ""
    

    그리고 해당 클러스터 master에 접근할 수 있는 네트웍 대역을 지정할 수 있습니다. 여기서 MY_EXTERNAL_RANGE 는 CIDR 형태로 172.16.0.16/28 같은 형태로 표현해야 합니다.

    gcloud container clusters update private-cluster \
        --enable-master-authorized-networks \
        --master-authorized-networks [MY_EXTERNAL_RANGE]
    

     

    • Helm Package Manager
      Helm 은 쿠버네티스 위에서 어플리케이션을 관리하는 매니저 툴입니다. 클라이언트/서버 구조로 구성되고 클라이언트는 Helm, 서버는 tiller 라고 부릅니다.

      Helm 의 설치는 다음과정을 통해서 이루어집니다.

      curl https://raw.githubusercontent.com/kubernetes/helm/master/scripts/get > get_helm.sh
      chmod 700 get_helm.sh
      ./get_helm.sh
      kubectl -n kube-system create sa tiller
      

      그리고 tiller role을 설정해줍니다.

      kubectl create clusterrolebinding tiller --clusterrole cluster-admin --serviceaccount=kube-system:tiller
      

      tiller를 설치합니다.

      helm init --service-account tiller
      

      이제 거기에 올라가서 동작할 chart를 설치해봅니다.

      helm repo update
      helm install stable/mysql
      
    • NGINX Ingress Controller on Google Kubernetes Engine
      Ingress는 해당 노드로 들어오는 트래픽, Egress는 해당 노드에서 외부로 나가는 트래픽을 의미합니다. 여기서는 앞에서 배운 Helm을 이용해서, nginx 라는 좋은 웹서버를 이용해서 Ingress 트래픽을 제어하는 예를 보여주게 됩니다. Ingress 트래픽을 제외한다는 것은 특정 요청을 다른곳으로 보낸다거나, 바꿀수 있습니다.

    ingress

    먼저 kubenetes 클러스터를 생성합니다.

    gcloud container clusters create nginx-tutorial --num-nodes 2
    

    그리고 helm 을 인스톨합니다.

    curl https://raw.githubusercontent.com/kubernetes/helm/master/scripts/get > get_helm.sh
    chmod 700 get_helm.sh
    ./get_helm.sh
    

    Role based access control(RBAC) 설정을 합니다.

    kubectl create serviceaccount --namespace kube-system tiller
    kubectl create clusterrolebinding tiller-cluster-rule --clusterrole=cluster-admin --serviceaccount=kube-system:tiller
    kubectl patch deploy --namespace kube-system tiller-deploy -p '{"spec":{"template":{"spec":{"serviceAccount":"tiller"}}}}'  
    
    helm init --service-account tiller --upgrade
    

    이제 hello-app 을 실행합니다.

     kubectl run hello-app --image=gcr.io/google-samples/hello-app:1.0 --port=8080
     kubectl expose deployment hello-app
    

    이제 ingress 용 nginx를 실행합니다.

    helm install --name nginx-ingress stable/nginx-ingress --set rbac.create=true
    

    스크린샷 2019-01-27 오후 3.42.38

    이제 위와 같이 nginx-ingress를 설정합니다.

    kubectl apply -f ingress-resource.yaml
    

    Reference

    1. https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/
    2. https://arisu1000.tistory.com/27835
    3. https://sktelecom-oslab.github.io/Virtualization-Software-Lab/Helm/
    4. https://kubernetes.io/blog/2016/10/helm-charts-making-it-simple-to-package-and-deploy-apps-on-kubernetes/
    5. https://bcho.tistory.com/1272

    [입 개발] spark-submit 시에 –properties-file 와 파라매터에서의 우선 순위

    $
    0
    0

    어쩌다보니… 갑자기 SparkSubmit 시에 사용되는 –properties-file(일종의 spark-defaults.conf)와 그냥 파라매터로 넘기는 것의 우선순위가 어떻게 적용되는지가 궁금해 졌습니다. 뭐, 당연히 일반적으로 생각하면 파라매터로 넘기는 것이 분명히 spark-defaults.conf 에 들어가있는 것 보다는 우선이 되는게 당연하겠지라는 생각을 가지고 있었고, 결론부터 말하자면, 이게 맞습니다.(다를 수가 없잖아!!! 퍽퍽퍽)

    그러나, 우리는 공돌이니 그래도 명확하게 해두자라는 생각이 들어서, 소스를 가볍게 살펴봤습니다.
    실제로 해당 내용은 “core/src/main/scala/org/apache/spark/deploy/SparkSubmitArguments.scala” 파일을 살펴보면 들어있습니다. 일단 main 코드는 다음과 같습니다. 여기서는 아주 간단히 확인할 것인데… 이름 부터 이미 parse 와 mergeDefaultSparkProperties 가 있습니다. 우리는 우선순위가 궁금할 뿐이니… parse 에서 가져온 것들을 mergeDefaultSparkProperties 에서 덮어쓸까만 확인하면 됩니다.

      parse(args.asJava)
      // Populate `sparkProperties` map from properties file
      mergeDefaultSparkProperties()
      // Remove keys that don't start with "spark." from `sparkProperties`.
      ignoreNonSparkProperties()
      // Use `sparkProperties` map along with env vars to fill in any missing parameters
      loadEnvironmentArguments()
      useRest = sparkProperties.getOrElse("spark.master.rest.enabled", "false").toBoolean
      validateArguments()
    

    parse를 확인해 봅시다. 특별히 중요한 것은 없고 findCliOption 가 넘겨진 opts 중에서 해당 옵션이 있는지 확인하는 코드이고 handle 에서 실제로 해당 값을 셋팅하는 코드가 있습니다.

      protected final void parse(List args) {
        Pattern eqSeparatedOpt = Pattern.compile("(--[^=]+)=(.+)");
    
        int idx = 0;
        for (idx = 0; idx 
          val properties = Utils.getPropertiesFromFile(filename)
          properties.foreach { case (k, v) =>
            defaultProperties(k) = v
          }
          // Property files may contain sensitive information, so redact before printing
          if (verbose) {
            Utils.redact(properties).foreach { case (k, v) =>
              logInfo(s"Adding default property: $k=$v")
            }
          }
        }
        defaultProperties
      }
    

    즉 defaultProperties -> sparkProperties 로 저장이 되는 겁니다. 그러면. 실제로 이 값의 우선순위는 어디에 저장이 되는가? 실제로 loadEnvironmentArguments 에서 해당 값이 설정이 됩니다. 아래에 보시면 Option에 먼저 executorMemory 가 NULL 이면 orElse 로 아까 저장한 sparkProperties 에서 가져오고 그래도 없으면 환경 변수에서 가져오고, 그래도 없으면 Null이 리턴됩니다.

      private def loadEnvironmentArguments(): Unit = {
        ......
        executorMemory = Option(executorMemory)
          .orElse(sparkProperties.get(config.EXECUTOR_MEMORY.key))
          .orElse(env.get("SPARK_EXECUTOR_MEMORY"))
          .orNull
        ......
      }
    

    마지막으로 정리하면 결국 우선순위는 다음과 같습니다.

    1. 파라매터로 전달함 –executor-memory 이런식으로
    2. properties-file 로 저장한 값
    3. 환경변수

    그런데 무조건 되는가에 대한 고민을 더 하셔야 합니다. 예를 들어 파라매터로 넘길 수 있는 것이 100%는 아닙니다. 다른 설정이 spark 설정 파일에 있을 수 가 있는 거죠. 즉 spark.yarn.executor.memoryOverhead 이런 값이 spark 설정 파일에 있다면, 여전히 이것 때문에 문제가 발생할 수 있다라는 것을 알아야 합니다.

    [입개발] NAGLE 알고리즘과 TCP_CORK

    $
    0
    0

    어쩌다보니, 오늘 처음으로…(정말로 처음으로!!!) TCP_CORK라는 옵션에 대해서 찾아보게 되었습니다.(아니 이게 무엇이오 여러분!!!)

    TCP_CORK라는 옵션을 설명하기 전에 먼저 TCP_NODELAY라는 옵션이 있습니다. 원래 데이터 전송의 효율성을 취하기 위해서 기본적으로 TCP 전송에 Nagle이라는 알고리즘이 적용되어 있습니다. 대용량 파일을 보낼 때는 유리하지만, 짧은 길이의 데이터를 보낼때는 사실 유용하지 않습니다. 네트웍 관련 서비스를 만들다 보면, 뭔가 응답이, 아무이유 없이, 아주 늦게 가는 케이스를 만나게 되는 경우가 있는데, 실제로, Nagle 알고리즘의 영향을 받아서 늦게 가는 경우가 종종 있습니다.

    그러면 먼저 Nagle 알고리즘에 대해서 알아보면, 그냥 어느정도 데이터가 쌓일 때 까지 패킷을 보내지 않고 기다려 놓다가… 일정 사이즈가 되면 보내겠다라는 알고리즘입니다. 동네 버스가, 사람이 적을 때는 출발하지 않고, 몇명 와야 출발하는 것 처럼…(판교역 앞에 계시면 이런 경우를 많이 보시게 됩니다.)

    이렇게 모아서 보내면 효율은 좋지만, 먼저 버스에 탄 사람은 인원 수가 모일때까지 가지 못하고, 기다려야 하는 단점이 있습니다. TCP 전송에서도 이게 그대로 발생합니다. 작은 패킷을 보내면 다른 패킷이 추가되어서 특정 사이즈가 되기 전까지는 전달이 되지 않습니다.(정확히는 send timeout 이 되면 전송됩니다.)

    예전에 서비스를 운영하다보면, 마지막 2바이트가 몇초 뒤에 전송되어서 항상 문제가 되는 경우가 발생한 적이 있는데, 결국 해당 서비스는 TCP_NODELAY를 적용함으로써 해결했습니다.(또다른 문제의 시작일수도?)

    앞에 Nagle을 이렇게 설명한 것을 잘생각해보면 DELAY가 생기는 거고, TCP_NODELAY는 바로 이 Nagle 알고리즘을 끄는 것입니다. 즉 패킷이 들어오면 바로바로 전송하는 거죠. 사실 여기까지가 제가 아는 Nagle 알고리즘이었습니다. 그런데 갑자기 TCP_CORK 가 딱!!!, TCP_CORK 는 Nagle과 유사한 알고리즘(?) 입니다.

    https://stackoverflow.com/questions/22124098/is-there-any-significant-difference-between-tcp-cork-and-tcp-nodelay-in-this-use 해당 링크가 잘 설명이 되어 있는데, 요약하면 Nagle은 TCP_CORK의 약화버전이고, Nagle은 ACK를 체크하지만, TCP_CORK는 사이즈만 본다? 라는 뭔가 설명이 있는데…(그렇습니다. 저는 영어가…)

    여기서 ACK는 TCP에서도 패킷을 보내고 나면 거기에 대한 ACK를 받게 됩니다. 혼잡제어나, 재전송이나… 그리고 사이즈라… 저 사이즈는 뭘까요. 패킷을 모아보내는 사이즈면 설정가능하지 않을까 하고 찾아보면, 따른 설정은 안보입니다. 네트워크 좀 아시는 분들은 아 그거 단위로 보내겠다고 쉽게 생각하시겠지만… 전 몰라요~~~

    그럼 이제 커널 소스를 보면서 간단하게 생각해보도록 하겠습니다. net/ipv4/tcp_output.c 파일을 보면 tcp_write_xmit 라는 함수가 있습니다. 여기서는 다시 tcp_mss_split_point 를 호출합니다.

    ......
    		limit = mss_now;
    		if (tso_segs > 1 && !tcp_urg_mode(tp))
    			limit = tcp_mss_split_point(sk, skb, mss_now,
    						    min_t(unsigned int,
    							  cwnd_quota,
    							  max_segs),
    						    nonagle);
    
    ......
    

    tcp_mss_split_point 를 보면 needed 가 버퍼에 존재하는 패킷의 사이즈로 예측이 됩니다. 소켓 버퍼에 있는 사이즈와 window 사이즈 중에 적은게 선택이 됩니다. 그리고 max_len 이 needed 보다 작으면 max_len 이 전송이 되고, 중요한 부분은 partial 은 구하는 것입니다. 모듈러 하는 변수명이 보이시나요? 아까 말한 그 사이즈는 바로 mss_now 인 것입니다. 여기서 partial은 원래 네트웍에서 패킷을 MSS 단위로 보내기 때문에, 모듈러 mss_now 하면, mss가 1024일 때 우리가 600만 보낸다면, 424바이트가 MSS에 모자라기 때문에 partial 은 424 바이트가 됩니다. 코드를 보면 tcp_nagle_check 하고나서 true면 nagle을 적용해야 하는 상황일테니… needed – partial 만큼의 사이즈를 리턴합니다. 즉 MSS 단위로 패킷을 보내도록 짤라준거죠.

    static unsigned int tcp_mss_split_point(const struct sock *sk,
    					const struct sk_buff *skb,
    					unsigned int mss_now,
    					unsigned int max_segs,
    					int nonagle)
    {
    	const struct tcp_sock *tp = tcp_sk(sk);
    	u32 partial, needed, window, max_len;
    
    	window = tcp_wnd_end(tp) - TCP_SKB_CB(skb)->seq;
    	max_len = mss_now * max_segs;
    
    	if (likely(max_len len, window);
    
    	if (max_len packets_out 이 0보다 커야 합니다.(보내는게 있다는 뜻으로...) 그리고 Nagle 알고리즘에서는 마지막으로 minshall 체크라는 걸 합니다. TCP_CORK는 별 다른게 없는데, 아까 Nagle과의 체크에서 ACK를 확인한다는 걸 기억하시나요?
    
    
    static bool tcp_nagle_check(bool partial, const struct tcp_sock *tp,
    			    int nonagle)
    {
    	return partial &&
    		((nonagle & TCP_NAGLE_CORK) ||
    		 (!nonagle && tp->packets_out && tcp_minshall_check(tp)));
    }
    

    minshall 이라는 분이 계시더군요(먼산…) 이 알고리즘은 그냥 보낸 패킷의 시퀀스와 ACK 받은 패킷의 시퀀스를 비교하기만 합니다. 아래 before 와 after는 그냥 before는 앞에 파라매터가 작으면 true, after는 앞에 파라매터가 크면 true 입니다. 저기서 snd_sml은 보낸 패킷의 시퀀스, snd_una는 ACK 받은 패킷의 시퀀스입니다. “!after(tp->snd_sml, tp->snd_nxt)” 이 코드는 시퀀스가 오버플로우 난 걸 확인하는 걸로 보입니다. 하여튼!!!, 즉 여기서 중요한 것은 ack를 다 받았다면 tp->snd_sml 과 tp->snd_una는 같은 값일 것이므로 false가 리턴됩니다. 즉 tcp_minshall_check가 true는 현재 ack 받아야할 패킷이 더 있다라는 뜻이고 false는 현재 모든 패킷의 ack를 받았다가 됩니다.

    static inline bool before(__u32 seq1, __u32 seq2)
    {
            return (__s32)(seq1-seq2) snd_sml, tp->snd_una) &&
    		!after(tp->snd_sml, tp->snd_nxt);
    }
    

    그럼 요약을 하면 TCP_CORK는 이것저것 확인안하고 켜지면 무조건 MSS 단위로만 보내겠다가 됩니다. 그런데 Nagle은 전부 ACK를 받았다면 5 byte만 보낸다고 하더라도… ACK를 모두 받았으므로 tcp_minshall_check 가 false 가 되어서, 패킷이 보내집니다. 요약하면 mss가 1024이고 4100 바이트를 보낸다면 partial = 4100 % 1024 = 4, TCP_CORK에서는 마지막 4바이트는 전송이 되지 않습니다. 언제까지? timeout 이 발생할때까지…, 그러나 Nagle은… ACK를 받을 패킷이 남아있다면 마지막 4바이트가 전송이 안되지만, ACK를 모두 받았다면… 마지막 4바이트도 전송이 되게 됩니다. 이게 두 가지 옵션의 차이이고, Nagle이, TCP_CORK 보다 조금 약한 제약이라는 의미입니다.

    [입 개발] Spark DataFrameWriter에서 saveAsTable 의 동작

    $
    0
    0

    s3 에 external table을 만들고 거기에 데이터를 넣는 작업을 하다가 이상한 현상을 경험했습니다.
    다음과 같은 테이블을 만들고

    create external table(
        id int,
        name varchar(22)
    ) LOCATION
      's3://bucket/tmp/tmp1';
    

    아래 코드를 돌렸는데…

    val tb = spark.sql(
            """
            |SELECT
            |  id, name
            |FROM original
            """
    )
    tb.write.mode(SaveMode.Overwrite).saveAsTable("tmp1")
    

    당연히 tmp1 테이블은 데이터를 s3에 그리고 external 테이블로 제대로 저장이 될 것을 기대했는데, 아래와 같이, managed 테이블로 바뀌고 저장 위치로 hdfs 로 바뀌는 것이었습니다. -_-(왜왜왜)

    create table(
        id int,
        name varchar(22)
    ) LOCATION
      'hdfs://tmp/blahblah/tmp1';
    

    Spark DataFrameWriter의 saveAsTable 을 SaveMode.Overwrite mode로 사용하게 되면 이런 일이 벌어지게 됩니다. 왜 그런가 해서 소스 코드를 까봤습니다. DataFrameWriter.scala를 보시면 됩니다.

    그냥 saveAsTable 소스를 보면 간단합니다. 아래를 보면 SaveMode.Overwrite 가 true 일때… 밑에서 dropTable, createTable 을 부르는군요. 어랏…. dropTable???, 이거 실화인가요? 즉, 이 때 테이블을 날려버립니다. 그리고 createTable로 재생성해줍니다.

      private def saveAsTable(tableIdent: TableIdentifier): Unit = {
        val catalog = df.sparkSession.sessionState.catalog
        val tableExists = catalog.tableExists(tableIdent)
        val db = tableIdent.database.getOrElse(catalog.getCurrentDatabase)
        val tableIdentWithDB = tableIdent.copy(database = Some(db))
        val tableName = tableIdentWithDB.unquotedString
    
        (tableExists, mode) match {
           ......
           case (true, SaveMode.Overwrite) =>
            // Get all input data source or hive relations of the query.
            val srcRelations = df.logicalPlan.collect {
              case LogicalRelation(src: BaseRelation, _, _, _) => src
              case relation: HiveTableRelation => relation.tableMeta.identifier
            }
    
            val tableRelation = df.sparkSession.table(tableIdentWithDB).queryExecution.analyzed
            EliminateSubqueryAliases(tableRelation) match {
              // check if the table is a data source table (the relation is a BaseRelation).
              case LogicalRelation(dest: BaseRelation, _, _, _) if srcRelations.contains(dest) =>
                throw new AnalysisException(
                  s"Cannot overwrite table $tableName that is also being read from")
              // check hive table relation when overwrite mode
              case relation: HiveTableRelation
                  if srcRelations.contains(relation.tableMeta.identifier) =>
                throw new AnalysisException(
                  s"Cannot overwrite table $tableName that is also being read from")
              case _ => // OK
            }
    
            // Drop the existing table
            catalog.dropTable(tableIdentWithDB, ignoreIfNotExists = true, purge = false)
            createTable(tableIdentWithDB)
            // Refresh the cache of the table in the catalog.
            catalog.refreshTable(tableIdentWithDB)
          ......
        }
      }
    

    그럼 external이 왜 managed가 되는지 살펴보시죠. createTable 코드를 보면 storage.locationUri.isDefined를 보고 EXTERNAL, MANAGED가 결정됩니다.(다른 글에서 쓰겠지만 Spark Sql에서 현재는 alter table 을 이용한 external, managed 변경이 안됩니다. Spark 2.4 기준)

      private def createTable(tableIdent: TableIdentifier): Unit = {
        val storage = DataSource.buildStorageFormatFromOptions(extraOptions.toMap)
        val tableType = if (storage.locationUri.isDefined) {
          CatalogTableType.EXTERNAL
        } else {
          CatalogTableType.MANAGED
        }
    
        val tableDesc = CatalogTable(
          identifier = tableIdent,
          tableType = tableType,
          storage = storage,
          schema = new StructType,
          provider = Some(source),
          partitionColumnNames = partitioningColumns.getOrElse(Nil),
          bucketSpec = getBucketSpec)
    
        runCommand(df.sparkSession, "saveAsTable")(CreateTable(tableDesc, mode, Some(df.logicalPlan)))
      }
    

    해당 값은 buildStorageFormatFromOptions 를 보면 가져옵니다. options map에서 path가 있으면 가져오는 군요.

      def buildStorageFormatFromOptions(options: Map[String, String]): CatalogStorageFormat = {
        val path = CaseInsensitiveMap(options).get("path")
        val optionsWithoutPath = options.filterKeys(_.toLowerCase(Locale.ROOT) != "path")
        CatalogStorageFormat.empty.copy(
          locationUri = path.map(CatalogUtils.stringToURI), properties = optionsWithoutPath)
      }
    

    여기서 path가 있으면 그냥 locationUri 에 복사해줍니다. 그럼 이제 위의 문제를 어떻게 해결해야 할까요? storage.locationUri.isDefined 를 true로 만들어주는 방법은, 넵 path option을 설정해 주면 간단하게 해결됩니다.

    val tb = spark.sql(
            """
            |SELECT
            |  id, name
            |FROM original
            """
    )
    tb.write
      .mode(SaveMode.Overwrite)
      .option("path", "s3://bucket/tmp/tmp1")
      .saveAsTable("tmp1")
    

    이렇게 하는 방법말고 만약에 그냥 쉽게 덮어쓰고 싶다면, insertInto 메서드를 쓰셔도 간단하게 해결됩니다.

    [입 개발] Hive MetaStore 에서 Location은 어떻게 관리될까?

    $
    0
    0

    최근에 아주 이상한 에러를 경험했습니다. 다음과 같은 managed table 이 있다고 가정합니다.

    CREATE TABLE `test1`(
      `id` bigint
    PARTITIONED BY (
      `datestamp` date)
    ROW FORMAT SERDE
      'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe'
    STORED AS INPUTFORMAT
      'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat'
    OUTPUTFORMAT
      'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat'
    LOCATION
      'hdfs://a.b.c:8020/user/hive/warehouse/charsyam.db/test1'
    TBLPROPERTIES (
      'transient_lastDdlTime'='1556186715')
    

    test1 이라는 table에 drop table 을 시도했는데!!! 다음과 같은 에러가 발생했습니다.

    FAILED: Execution Error, return code 1 from org.apache.hadoop.hive.ql.exec.DDLTask. MetaException(message:java.lang.IllegalArgumentException: Wrong FS: hdfs://a.b.c:8020/user/hive/warehouse/charsyam.db/test1, expected: hdfs://ip-b.b.c:8020)
    

    사실 이 에러는 hadoop 에서 checkPath 라는 함수에 의해서 발생하게 됩니다.(https://github.com/apache/hadoop/blob/trunk/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/fs/FileSystem.java#L711) 코드를 보면 실제 schema에 있는 Location 경로와 실제 hadoop 에서 인식하는 자신의 도메인이 맞지 않을 때 발생하는 에러입니다. 즉 a.b.c 도 ip 가 1.1.1.1 이고 b.b.c 도 ip 가 1.1.1.1 이지만 table location 이 hdfs://a.b.c:8020 인것과 hdfs://b.b.c:8020 은 서로 다른 도메인이 되는 거죠.

      protected void checkPath(Path path) {
        URI uri = path.toUri();
        String thatScheme = uri.getScheme();
        if (thatScheme == null)                // fs is relative
          return;
        URI thisUri = getCanonicalUri();
        String thisScheme = thisUri.getScheme();
        //authority and scheme are not case sensitive
        if (thisScheme.equalsIgnoreCase(thatScheme)) {// schemes match
          String thisAuthority = thisUri.getAuthority();
          String thatAuthority = uri.getAuthority();
          if (thatAuthority == null &&                // path's authority is null
              thisAuthority != null) {                // fs has an authority
            URI defaultUri = getDefaultUri(getConf());
            if (thisScheme.equalsIgnoreCase(defaultUri.getScheme())) {
              uri = defaultUri; // schemes match, so use this uri instead
            } else {
              uri = null; // can't determine auth of the path
            }
          }
          if (uri != null) {
            // canonicalize uri before comparing with this fs
            uri = canonicalizeUri(uri);
            thatAuthority = uri.getAuthority();
            if (thisAuthority == thatAuthority ||       // authorities match
                (thisAuthority != null &&
                 thisAuthority.equalsIgnoreCase(thatAuthority)))
              return;
          }
        }
        throw new IllegalArgumentException("Wrong FS: " + path +
                                           ", expected: " + this.getUri());
      }
    

    그런데 이런 일이 왜 일어날까가 사실 더 궁금할껍니다. 보통은 발생하면 안되는 일이죠. 사실 이런 현상은 Hadoop 노드를 계속 새로운 장비에 재 구축하는데, Hive Metastore 는 동일하게 저장하기 때문에 발생하는 현상입니다. 보통 하둡 장비들이 크게 바뀔일이 없으니… 거의 발생할 일도 없는거죠. 그런데 이런 일이 발생하게 되면, 다음과 같은 에러를 맞게 됩니다. 그래서 이걸 파다보니 실제로 Hive MetaStore 에서 Location 을 어떻게 저장하는가가 궁금해졌습니다. 보통 우리는 show create table 등으로 볼 수 있습니다. 그럼 이 정보들은 어디에 저장될까요?

    일단 Hive Metastore 는 실제 저장은 다른 곳 DBMS나 glue 등에 저장을 할 수 있습니다. 여기서는 MYSQL에 저장된 것으로 설명을 하겠습니다. 먼저 mysql 에 hive_metastore 라는 DB가 생성이 됩니다. 그리고 여기서 우리가 살펴볼 table 은 DBS, TBLS, SDS 입니다.(이거 세 개만 보면 됩니다.) 마지막 S는 전부 복수의 S로 보이고 각각 Database, Table, StorageDescriptor 로 보입니다.

    먼저 hive 등에서 create database 로 DB를 생성할 때 마다 해당 정보가 Database 에 저장이 됩니다. 먼저 DBS 테이블의 스키마는 다음과 같습니다.

    CREATE TABLE `DBS` (
      `DB_ID` bigint(20) NOT NULL,
      `DESC` varchar(4000) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `DB_LOCATION_URI` varchar(4000) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
      `NAME` varchar(128) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `OWNER_NAME` varchar(128) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `OWNER_TYPE` varchar(10) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      PRIMARY KEY (`DB_ID`),
      UNIQUE KEY `UNIQUE_DATABASE` (`NAME`)
    ) ENGINE=InnoDB DEFAULT CHARSET=latin1
    

    여기서 주목할 것은 역시 primary key 인 DB_ID 와 DB_LOCATION_URI 입니다. 만약 charsyam db라면, 이 DB_LOCATION_URI 에 들어가는 값이 hdfs://a.b.c/user/hive/warehouse/charsyam.db/ 까지가 들어가게 됩니다. 그리고 이 db에 테이블이 생성되면 table의 location이 항상 이 밑에 생기게 됩니다.

    실제로 TBLS 테이블을 보면 내부에 Location이 없습니다. 그래서 해당 DBS의 DB_LOCATION_URI를 바꾸면 바로 되지 않을까라고 생각을 처음에 했었는데… 사실 이건 말도 안되는 소리입니다. 왜냐하면 hive 등의 툴을 써보신 분들은 바로 아시겠지만, location 자체는 얼마든지 다른 것으로 바꿀 수 있습니다. 그렇다면 무엇인가? 테이블에는 자기만의 Location 정보가 꼭 있어야 한다는 것입니다. 즉 TBLS에 없다면, 다른곳에 있어야 합니다. 다음은 TBLS 의 schema 입니다.

    CREATE TABLE `TBLS` (
      `TBL_ID` bigint(20) NOT NULL,
      `CREATE_TIME` int(11) NOT NULL,
      `DB_ID` bigint(20) DEFAULT NULL,
      `LAST_ACCESS_TIME` int(11) NOT NULL,
      `OWNER` varchar(767) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `RETENTION` int(11) NOT NULL,
      `SD_ID` bigint(20) DEFAULT NULL,
      `TBL_NAME` varchar(256) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `TBL_TYPE` varchar(128) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `VIEW_EXPANDED_TEXT` mediumtext,
      `VIEW_ORIGINAL_TEXT` mediumtext,
      `IS_REWRITE_ENABLED` bit(1) NOT NULL,
      PRIMARY KEY (`TBL_ID`),
      UNIQUE KEY `UNIQUETABLE` (`TBL_NAME`,`DB_ID`),
      KEY `TBLS_N50` (`SD_ID`),
      KEY `TBLS_N49` (`DB_ID`),
      CONSTRAINT `TBLS_FK1` FOREIGN KEY (`SD_ID`) REFERENCES `SDS` (`SD_ID`),
      CONSTRAINT `TBLS_FK2` FOREIGN KEY (`DB_ID`) REFERENCES `DBS` (`DB_ID`)
    ) ENGINE=InnoDB DEFAULT CHARSET=latin1
    

    그렇다면 해당 정보는 어디에 있을까요? 당연히!!! 아까 언급했지만, 아직까지 안나온 SDS 입니다. 어떻게 SD 가 StorageDescriptor라고 생각하냐고 하면… Hive 소스를 보면 ql/src/java/org/apache/hadoop/hive/ql/metadata/Table.java 에 getPath 라는 메서드가 있고 여기서 getSd() 라는 메서드를 호출합니다.

    final public Path getPath() {
        String location = tTable.getSd().getLocation();
        if (location == null) {
          return null;
        }
        return new Path(location);
      }
    

    이걸 자세히 보면 다음과 같이 StorageDescriptor 라는 클래스를 리턴합니다. 아마도 그러니 저것도…

      public StorageDescriptor getSd() {
        return this.sd;
      }
    

    이제 SDS 테이블을 살펴보시죠. Location 항목이 보입니다. 여기에 저장이 되는겁니다.

    CREATE TABLE `SDS` (
      `SD_ID` bigint(20) NOT NULL,
      `CD_ID` bigint(20) DEFAULT NULL,
      `INPUT_FORMAT` varchar(4000) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `IS_COMPRESSED` bit(1) NOT NULL,
      `IS_STOREDASSUBDIRECTORIES` bit(1) NOT NULL,
      `LOCATION` varchar(4000) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `NUM_BUCKETS` int(11) NOT NULL,
      `OUTPUT_FORMAT` varchar(4000) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
      `SERDE_ID` bigint(20) DEFAULT NULL,
      PRIMARY KEY (`SD_ID`),
      KEY `SDS_N49` (`SERDE_ID`),
      KEY `SDS_N50` (`CD_ID`),
      CONSTRAINT `SDS_FK1` FOREIGN KEY (`SERDE_ID`) REFERENCES `SERDES` (`SERDE_ID`),
      CONSTRAINT `SDS_FK2` FOREIGN KEY (`CD_ID`) REFERENCES `CDS` (`CD_ID`)
    ) ENGINE=InnoDB DEFAULT CHARSET=latin1
    

    그런데 이거랑 실제 Table과 어떻게 매칭을 시킬 수 있을까요? 그 비밀은 Primary Key인 SD_ID가 중요합니다. 위에서 언급한 TBLS 테이블을 보면 DB_ID, SD_ID, TBL_NAME 이 있습니다. 즉, 먼저 DBS 에서 database 이름으로 DB_ID 값을 찾고, 그 DB_ID와 TBL_NAME을 이용해서 해당 테이블의 SD_ID를 찾을 수 있습니다. 그리고 그 SD_ID를 이용하면 마지막으로 SDS 테이블에서 최종적으로 해당 테이블의 StorageDescriptor 정보를 볼 수 있는 겁니다. 그리고 SDS 의 Location 값이 show create table 에서 볼 수 있는 바로 그값입니다. 그래서 여기서 해당 컬럼의 값을 바꿔버리면… show create table 시에도 값이 바뀌게 됩니다.

    당연하지만, SDS 테이블에 데이터가 많은 것은 StorageDescriptor는 테이블마다 하나씩이지만, 당연히 Partition 마다도 존재합니다. 그렇기 때문에 최소한 전체 테이블 수 + 전체 파티션 수 만큼 존재하는게 맞습니다. 🙂


    [입 개발] Redis 버그 – Dataset 사이즈가 200GB가 넘어가면 죽는다구요?

    $
    0
    0

    오늘은 최근에 이슈가 되었던, Redis 버그에 대해서 분석해보는 시간을 가지도록 하겠습니다.
    해당 이슈는 Redis issue 4493 를 보시면 됩니다.

    실제로 해당 이슈는 2017년 11월 30일에 올라왔습니다. 실제로 현재 버전에는 다 패치가 되어있습니다. 그러나 DBMS나 캐시등의 툴은 큰 보안버그가 없는 이상 업데이트가 굉장히 느립니다.(성능에 영향을 주기 때문에…)

    다른 건 일단 모두 제외하고 어떤 이슈가 있고, 어떻게 패치되었는지 확인해보도록 하겠습니다.

    일단 데이터가 200GB가 넘어가면 죽는다는 뭔가 쉽게이해하기 어려운 상황입니다. Redis 는 기본적으로 Key에 512MB까지 그리고 Value에 512MB가 할당되고, 메모리가 넘치기 전까지는 저장이 되어야 합니다. 그런데 보통 특정 사이즈가 문제가 되는것은, 누구나 예측이 되는 아주 간단한 이유가 있습니다. 바로 overflow 나 underflow, 이런 생각을 가지고 가면, 문제가 좀 더 해결하기 쉽지만, 갑자기 Redis가 죽으면 알기가 힘들죠.(이건 다, 우리는 현재 모든 정보를 알고 있기 때문에 쉽게 이해를 할 수 있는…)

    Redis 는 보통 죽기 전에 자신의 정보를 뿌리고 죽는데, 위의 링크를 보시면 다음과 같은 정보들이 있습니다. 우와 755GB 메모리를 가진 장비에 211GB의 메모리를 쓰고 있네요. 부럽습니다. 사실 메모리 영역만 보면 사실 큰 문제가 될께 없어보입니다.

    Memory
    used_memory:226926628616
    used_memory_human:211.34G
    used_memory_rss:197138104320
    used_memory_rss_human:183.60G
    used_memory_peak:226926628616
    used_memory_peak_human:211.34G
    used_memory_peak_perc:117.84%
    used_memory_overhead:137439490190
    used_memory_startup:486968
    used_memory_dataset:89487138426
    used_memory_dataset_perc:39.43%
    total_system_memory:811160305664
    total_system_memory_human:755.45G
    
    Keyspace
    db2:keys=2147483651,expires=0,avg_ttl=0
    

    그런데 Keyspace를 보니 조금 다르네요. db2의 key가 2147483651개가 있습니다. 흐음…

    다음과 같이 signed 변수들의 범위를 보면

      char : 127
      short : 32767
      int : 2147483647

    입니다. 흐음 일단 key의 개수가 int의 범위를 넘어갔네요. 2147483651을 음수로 바꾸면
    -2147483645 이 됩니다. 흐음… 이렇게 바뀌면 문제가 발생할 수도 있겠네요. 그런데 뭔가 이상합니다.

    분명히 해당 info정보에서는 제대로 2147483651 가 나와있는데요?
    해당 정보를 출력하는 info Command는 실제로 genRedisInfoString 라는 함수를 이용합니다.
    아래 코드를 보면 keys, vkeys는 long long 입니다. 그걸 사용하고 있는 dictSize 함수는 매크로로 그냥 값을 가져옵니다.
    실제로 redisDb 구조체는 dict를 가지고 있고 dict는 다시 dictht 라는 해시 테이블을 가지고 있습니다. 거기서 used 변수를
    가져오는게 dictSize 함수입니다.

    #define dictSize(d) ((d)->ht[0].used+(d)->ht[1].used)
    
    typedef struct redisDb {
        dict *dict;                 /* The keyspace for this DB */
        dict *expires;              /* Timeout of keys with a timeout set */
        dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
        dict *ready_keys;           /* Blocked keys that received a PUSH */
        dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
        int id;                     /* Database ID */
        long long avg_ttl;          /* Average TTL, just for stats */
    } redisDb;
    
    typedef struct dict {
        dictType *type;
        void *privdata;
        dictht ht[2];
        long rehashidx; /* rehashing not in progress if rehashidx == -1 */
        unsigned long iterators; /* number of iterators currently running */
    } dict;
    
    typedef struct dictht {
        dictEntry **table;
        unsigned long size;
        unsigned long sizemask;
        unsigned long used;
    } dictht;
    
    sds genRedisInfoString(char *section) {
        sds info = sdsempty();
        ......
    
        /* Key space */
        if (allsections || defsections || !strcasecmp(section,"keyspace")) {
            if (sections++) info = sdscat(info,"\r\n");
            info = sdscatprintf(info, "# Keyspace\r\n");
            for (j = 0; j < server.dbnum; j++) {
                long long keys, vkeys;
    
                keys = dictSize(server.db[j].dict);
                vkeys = dictSize(server.db[j].expires);
                if (keys || vkeys) {
                    info = sdscatprintf(info,
                        "db%d:keys=%lld,expires=%lld,avg_ttl=%lld\r\n",
                        j, keys, vkeys, server.db[j].avg_ttl);
                }
            }
        }
        return info;
    }
    

    그런데 used라는 변수는 unsigned long 입니다. 32bit에서는 문제가 될 수 있지만, 64bit에서는 8바이트가 할당되는 변수입니다.
    64bit 에서 다음과 같은 코드를 돌려보시면 알 수 있습니다.

    #include

    int main(int argc, char *argv[]) {
    printf(“%d\n”, sizeof(unsigned long));
    return 0;
    }

    그럼 제대로 64bit가 되어있는데 무엇이 문제인가!!!, 처음부터 다 분석하면 어려우니 해당 문제를 일으키는 곳을 바로 확인해 보겠습니다. 코멘트를 자세히 읽어보면… 실제로 아이템을 추가할 때가 문제가 됩니다. 다시 앞으로 돌아가서 아이템 개수가 int의 범위를 넘어갔다는 것을 기억해둡니다.

    다음 dictAddRaw 함수를 보면 index가 int 입니다. 헉… 바로 눈치 채시겠죠. 저기 int 로 인해서 아래의 ht->table[index]
    라는 코드가 overflow 로 음수가 들어가게 됩니다. 바로 Redis는 저세상으로…

    dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
    {
        int index;
        dictEntry *entry;
        dictht *ht;
    
        if (dictIsRehashing(d)) _dictRehashStep(d);
    
        if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
            return NULL;
    
        ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
        entry = zmalloc(sizeof(*entry));
        entry->next = ht->table[index];
        ht->table[index] = entry; /* used++;
    
        /* Set the hash entry fields. */
        dictSetKey(d, entry, key);
        return entry;
    }
    

    그럼 해당 실제 patch는 어떻게 적용되었을까요?

    해당 코드를 보시면 해당 int는 long으로, 그 외에 많은 부분들이 64bit unsigned 또는 signed 로 변경된것을 볼 수 있습니다.
    실제로 해당 PR은 두개로 나뉘어져 각각 반영되었고, Redis 4.0.7에 다음 두개의 commits으로 볼 수 있습니다.
    dict: fix the int problem for defrag

    dict: fix the int problem

    즉 해당 버그를 피하실려면 최소한 4.0.7 이후의 버전을 선택하셔야 하고 가능하면 최신 버전을 고르시는 걸 추천드립니다.
    그런데 21억개를 넘어가는 아이템이라… 웬만한 규모에서는 발생하지 않을 문제겠지만, 엄청 많은 데이터를 쓰는 곳에서만 발생할 수 있었던 문제로 보입니다.

    [입개발] Spring-data-redis 에서 Jedis로 TLS를 쓰면서 인증서 체크는 Disable 하는 방법

    $
    0
    0

    흐음… 최근에 Redis를 TLS로 사용해야 할 일이 생겼습니다.(아시는 분은 아시겠지만, 저는 자바맹, Spring 맹이라…) Spring-data-redis 를 쓰는 서비스에서 TLS를 키는 것은 사실 아주아주아주 쉽습니다. 일단 저희는 spring-data-redis에서 Jedis를 쓰고 있는 상황입니다.(여기서 왜 lettuce 안쓰고 Jedis 쓰냐고 물으시면, 원래 그렇게 만들어져 있었으니까라고 대답을 ㅎㅎㅎ)

    그런데 JedisConenctionFactory에서 TLS를 쓰는 건 아주 쉽습니다. 그냥 setUseSsl(true)만 호출하면 그 때부터 TLS가 딱!!! 됩니다.

    딱!!! 되는데 제대로 접속하기 위해서는 인증서가 제대로 된 위치에 존재해야 합니다. 그런데, 내부 레디스 서버마다 인증서를 만들기는 귀찮을 수 있습니다. 그럴때는 살포시… 인증서를 무시해주면 되는데…

    구글링을 해봐도, Spring Data Redis에 TLS를 적용하는 방법은 많이 나옵니다. 또, 인증서 체크를 Disable 하는 방법도 꽤 많이 나옵니다. 그런데 인증서 체크를 끄는 법은 다 HTTPS 관련 Rest Template 설정에 관한 것들입니다. 즉 아주 편하게 할 수 있는 Spring Data Redis 에서 TLS를 인증서 체크를 끄는 방법이 검색해도 안나오더라는…(아마도 제가 못 찾은걸 확신합니다만…)

    일단 기본적인 방법은 다음과 같습니다.(다음 StackOverflow 를 참고합니다.)

    1. TrustManager를 생성한다.
    2. SSLContext를 가져온다.
    3. SSLContext의 init를 아까 생성한 TrushManager를 이용하도록 설정한다.
    4. 해당 SSLContext의 SSLSocketFactory를 이용하도록 설정한다.
    import javax.net.ssl.*;
    import java.security.*;
    import java.security.cert.X509Certificate;
    
    public final class SSLUtil{
    
        private static final TrustManager[] UNQUESTIONING_TRUST_MANAGER = new TrustManager[]{
                new X509TrustManager() {
                    public java.security.cert.X509Certificate[] getAcceptedIssuers(){
                        return null;
                    }
                    public void checkClientTrusted( X509Certificate[] certs, String authType ){}
                    public void checkServerTrusted( X509Certificate[] certs, String authType ){}
                }
            };
    
        public  static void turnOffSslChecking() throws NoSuchAlgorithmException, KeyManagementException {
            // Install the all-trusting trust manager
            final SSLContext sc = SSLContext.getInstance("SSL");
            sc.init( null, UNQUESTIONING_TRUST_MANAGER, null );
            HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
        }
    
        public static void turnOnSslChecking() throws KeyManagementException, NoSuchAlgorithmException {
            // Return it to the initial state (discovered by reflection, now hardcoded)
            SSLContext.getInstance("SSL").init( null, null, null );
        }
    
        private SSLUtil(){
            throw new UnsupportedOperationException( "Do not instantiate libraries.");
        }
    }
    

    위의 코드는 HttpsURLConnection 을 위한 SSL 인증서 체크를 무시하는 방법입니다. 다만, 우리는 이걸 보면, 아 Spring Data Redis의 JedisConnectionFactory 도 유사하다고 추측할 수 있습니다. 실제로 JedisConnectionFactory 코드를 보면 SSLSocketFactory를 가져오는 함수는 보이지 않습니다.

    조금 더 파보면 JedisConnectionFactory 안에는 clientConfiguration 변수가 있고, 다음과 같은 setSslSocketFactory, getSslSocketFactory 와 같은 함수들이 보입니다.

    static class MutableJedisClientConfiguration implements JedisClientConfiguration {
    ......
    		@Override
    		public Optional getSslSocketFactory() {
    			return Optional.ofNullable(sslSocketFactory);
    		}
    
    		public void setSslSocketFactory(SSLSocketFactory sslSocketFactory) {
    			this.sslSocketFactory = sslSocketFactory;
    		}
    
    		/*
    		 * (non-Javadoc)
    		 * @see org.springframework.data.redis.connection.jedis.JedisClientConfiguration#getSslParameters()
    		 */
    		@Override
    		public Optional getSslParameters() {
    			return Optional.ofNullable(sslParameters);
    		}
    
    		public void setSslParameters(SSLParameters sslParameters) {
    			this.sslParameters = sslParameters;
    		}
    ......
    }
    

    그리고 createJedis 같은 함수를 보면 아래와 같이 Jedis 인스턴스를 생성할 때 clientConfiguration의 getSslSocketFactory 함수를 쓰고 있는걸 볼 수 있습니다.

    	private Jedis createJedis() {
    
    		if (providedShardInfo) {
    			return new Jedis(getShardInfo());
    		}
    
    		Jedis jedis = new Jedis(getHostName(), getPort(), getConnectTimeout(), getReadTimeout(), isUseSsl(),
    				clientConfiguration.getSslSocketFactory().orElse(null), //
    				clientConfiguration.getSslParameters().orElse(null), //
    				clientConfiguration.getHostnameVerifier().orElse(null));
    
    		Client client = jedis.getClient();
    
    		getRedisPassword().map(String::new).ifPresent(client::setPassword);
    		client.setDb(getDatabase());
    
    		return jedis;
    	}
    

    오호 이제 저 setSslSocketFactory 함수를 통해서 아까 얻은 SSLContext의 SSLSocketFactory 로 바꿔주면 될듯 합니다. 그런데 아주 사소한 문제가 있습니다. 어떻게 JedisConnectionFactory에서 저 값을 바꿀 수 있을까요? 살짝 살펴보니 오오 다음과 같은 함수가 존재합니다.

    public class JedisConnectionFactory implements InitializingBean, DisposableBean, RedisConnectionFactory {
            ......
            public JedisClientConfiguration getClientConfiguration() {
    		return clientConfiguration;
    	}
            ......
    }
    

    그런데, 오예!! 하면서 받아서 setSslSocketFactory를 호출해보려고 하면…. 문제가 발생합니다. 그것은!!!, JedisClientConfiguration 이 interface 인데… ReadOnly Interface라는 것입니다. 대략 다음과 같습니다.

    public interface JedisClientConfiguration {
    	boolean isUseSsl();
    	Optional getSslSocketFactory();
    	Optional getSslParameters();
    	Optional getHostnameVerifier();
    	boolean isUsePooling();
    	Optional getPoolConfig();
    	Optional getClientName();
    	Duration getConnectTimeout();
    	Duration getReadTimeout();
            ......
    }
    

    흐음, 그러면 어차피 내부는 MutableJedisClientConfiguration 클래스이니, 그냥 강제 형변환해버리면 되지 않을까요? 일단 제가 자바에 깊은 지식이 없어서… 실패했을 수도 있지만, Inner Class 고, 같은 패키지가 아니면 사용할 수가 없습니다. 그럼 무슨 말이야… 앞에 시도한 방법은 모두 실패… 우리는 이상한 산으로 가고 있던 것입니다.

    그럼 어떻게 해야 하며, 해당 소스를 살펴보니, 다행히 다음과 같은 생성자가 있습니다. 잘 살펴보면 RedisStandaloneConfiguration 과 JedisClientConfiguration 을 생성자로 받고 있습니다.

    public JedisConnectionFactory(RedisStandaloneConfiguration standaloneConfig, JedisClientConfiguration clientConfig) {
    
    		this(clientConfig);
    
    		Assert.notNull(standaloneConfig, "RedisStandaloneConfiguration must not be null!");
    
    		this.standaloneConfig = standaloneConfig;
    	}
    

    이제 대충 방법이 떠오르시나요? 즉, 다음과 같이 JedisClientConfiguration 을 상속받는 다른 클래스를 만들어서 JedisConnectionFactory 의 생성자로 넘기면, 우리가 원하는 동작을 하도록 만들 수가 있습니다.

    대략 저는 다음과 같은 JedisSSLClientConfiguration 이라는 클라스를 만들었습니다. 그리고 JedisConnectionFactory 에 넘겨주니, 인증서 체크를 잘 회피하면서 동작하게 되었습니다. 역시 소스를 보면 길이 보이는 경우가 종종 있네요. 자바를 잘하시는 분들이 부럽습니다. 전 맨날 삽질만…

        JedisSSLClientConfiguration() {
            setUsePooling(true);
            setUseSsl(true);
    
            TrustManager[] trustAllCerts = new TrustManager[] {
                    new X509TrustManager() {
                        public java.security.cert.X509Certificate[] getAcceptedIssuers()
                        {
                            return new X509Certificate[0];
                        }
                        public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType)
                        {
                        }
                        public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType)
                        {}
                    }
            };
    
            try {
                SSLContext sc = SSLContext.getInstance("TLS");
                sc.init(null, trustAllCerts, null);
                sslSocketFactory = sc.getSocketFactory();
                sslParameters = new SSLParameters();
                sslParameters.setEndpointIdentificationAlgorithm("");
            } catch (Exception e) {
                throw new RuntimeException(e.toString());
            }
    
            hostnameVerifier = new HostnameVerifier() {
                @Override
                public boolean verify(String s, SSLSession sslSession) {
                    return true;
                }
            };
        }
    

    [입 개발] redis-cli 에서 –rdb는 주의해서 사용하셔야 합니다.

    $
    0
    0

    최근에 지인에게서 문의가 왔습니다. 뭔가 특별히 한게 없는데, Redis 서버가 죽어버리고 재시작했다고, 이것저것 알아보니, 일단 메모리를 많이 사용하고 있던 상태였습니다. 웬지 느낌은, 아주 전통적인 Redis가 데이터 저장을 위해서 fork하면서 메모리를 추가로 사용하고, 이걸로 인해서 OOM등이 발생한게 아닐까라고 생각을 했는데…

    지인의 얘기로는 다음과 같은 –rdb 명령을 사용했다고 합니다.

    redis-cli --rdb
    

    일단 저도 한번도 해당 옵션을 써본적이 없어서 소스를 확인해보니 다음과 같습니다. 해당 소스는 redis-cli.c를 보시면 됩니다.

            } else if (!strcmp(argv[i],"--rdb") && !lastarg) {
                config.getrdb_mode = 1;
                config.rdb_filename = argv[++i];
            }
    

    getrdb_mode라는 옵션을 셋팅합니다. 해당 부분을 다시 찾아보면 getrdb_mode가 켜져있을 때 sendCapa()라는 함수와 getRDB를 호출합니다.

        /* Get RDB mode. */
        if (config.getrdb_mode) {
            if (cliConnect(0) == REDIS_ERR) exit(1);
            sendCapa();
            getRDB(NULL);
        }
    

    sendCapa를 호출해서 eof를 설정하면 diskless replication을 시도합니다. 여기서 주의할 것은 diskless replication은 서버에 RDB파일을 만들지 않을뿐, fork를 해야하는 것은 동일합니다. 그리고 이 부분이 문제를 일으키게 됩니다. 다만 여기서는 실제 RDB를 만들지 않고 해당 클라이언트에게는 diskless replication을 하라고 설정만 하는 단계입니다.(다만 서버가 repl_diskless_sync를 켜두지 않으면 만구땡…)

    void sendCapa() {
        sendReplconf("capa", "eof");
    }
    
    void sendReplconf(const char* arg1, const char* arg2) {
        printf("sending REPLCONF %s %s\n", arg1, arg2);
        redisReply *reply = redisCommand(context, "REPLCONF %s %s", arg1, arg2);
    
        /* Handle any error conditions */
        if(reply == NULL) {
            fprintf(stderr, "\nI/O error\n");
            exit(1);
        } else if(reply->type == REDIS_REPLY_ERROR) {
            fprintf(stderr, "REPLCONF %s error: %s\n", arg1, reply->str);
            /* non fatal, old versions may not support it */
        }
        freeReplyObject(reply);
    }
    

    getRDB 함수는 이제 실제로 SYNC 명령을 보내서 replication을 수행합니다. 원래의 Replication은 그 뒤로 계속 master로 부터 데이터를 받아야 하지만, 여기서 getRDB는 그냥 현재 메모리 상태의 스냅샷만 전달하고 종료하게 됩니다.

    코드를 보면 sendSync 후에 payload를 가져오게 되는데, 해당 payload는 RDB생성시 diskless_sync 방식이 아닐경우에만 파일 사이즈를 전달해줘서 가져올 수 있고, diskless_sync 방식을 쓸때는 eof_mark 라는 40바이트의 랜덤값을 처음에 전달해주고, 마지막에 해당 값이 나오면 RDB생성을 종료하게 됩니다.

    static void getRDB(clusterManagerNode *node) {
        int s, fd;
        char *filename;
        if (node != NULL) {
            assert(node->context);
            s = node->context->fd;
            filename = clusterManagerGetNodeRDBFilename(node);
        } else {
            s = context->fd;
            filename = config.rdb_filename;
        }
        static char eofmark[RDB_EOF_MARK_SIZE];
        static char lastbytes[RDB_EOF_MARK_SIZE];
        static int usemark = 0;
        unsigned long long payload = sendSync(s, eofmark);
        char buf[4096];
    
        if (payload == 0) {
            payload = ULLONG_MAX;
            memset(lastbytes,0,RDB_EOF_MARK_SIZE);
            usemark = 1;
            fprintf(stderr,"SYNC sent to master, writing bytes of bulk transfer "
                    "until EOF marker to '%s'\n", filename);
        } else {
            fprintf(stderr,"SYNC sent to master, writing %llu bytes to '%s'\n",
                payload, filename);
        }
    
        /* Write to file. */
        if (!strcmp(filename,"-")) {
            fd = STDOUT_FILENO;
        } else {
            fd = open(filename, O_CREAT|O_WRONLY, 0644);
            if (fd == -1) {
                fprintf(stderr, "Error opening '%s': %s\n", filename,
                    strerror(errno));
                exit(1);
            }
        }
    
        while(payload) {
            ssize_t nread, nwritten;
    
            nread = read(s,buf,(payload > sizeof(buf)) ? sizeof(buf) : payload);
            if (nread = RDB_EOF_MARK_SIZE) {
                    memcpy(lastbytes,buf+nread-RDB_EOF_MARK_SIZE,RDB_EOF_MARK_SIZE);
                } else {
                    int rem = RDB_EOF_MARK_SIZE-nread;
                    memmove(lastbytes,lastbytes+nread,rem);
                    memcpy(lastbytes+rem,buf,nread);
                }
                if (memcmp(lastbytes,eofmark,RDB_EOF_MARK_SIZE) == 0)
                    break;
            }
        }
        if (usemark) {
            payload = ULLONG_MAX - payload - RDB_EOF_MARK_SIZE;
            if (ftruncate(fd, payload) == -1)
                fprintf(stderr,"ftruncate failed: %s.\n", strerror(errno));
            fprintf(stderr,"Transfer finished with success after %llu bytes\n", payload);
        } else {
            fprintf(stderr,"Transfer finished with success.\n");
        }
        close(s); /* Close the file descriptor ASAP as fsync() may take time. */
        fsync(fd);
        close(fd);
        fprintf(stderr,"Transfer finished with success.\n");
        if (node) {
            sdsfree(filename);
            return;
        }
        exit(0);
    }
    

    다시 정리하자면 redis-cli에서 –RDB 옵션을 주면 서버에 diskless_sync를 요청합니다.(서버가 설정이 되어있어야만 동작합니다.) 그러나 해당 작업은 Redis 서버를 fork하게 만들기 때문에, 메모리 사용량이 높고 Write가 많은 상황에서는 절대적으로 피하시는게 좋습니다.

    [입 개발] 왜 Redis 응답이 느린데, slowlog에는 안찍히나요?

    $
    0
    0

    Redis를 쓰다보면, 확실히 응답이 늦게 와서, Client 에서는 timeout이 걸리는데, 실제 Redis의 slowlog를 보면 아무런 정보가 없을 때가 많습니다.

    오늘은 Redis에서 느린건 확실한거 같은데 왜 slowlog는 남지 않는지 살짝 알아보도록 하겠습니다. 먼저 느리다는 것은 어떤 의미일까요? 느리다는 것 자체는 우리가 기대(또는 예상) 하는 속도보다 더 시간이 걸리는 것으로 얘기할 수 있습니다. 영어로는 latency가 얼마인가? 이런식으로도 표현을…

    그런데 이 latency가 얼마냐? 라는 것은 어떻게 측정하는가에 따라서 달라집니다. 어떻게 보면 상대적인 거죠. 위에서 얘기했듯이, 분명히 클라이언트에서는 timeout이 걸릴정도로 느릴 수 있는데, Redis 입장에서는 slowlog가 쌓이지 않는다는 것은 Redis 입장에서는 이게 느리지 않다라는 것입니다.(여기서 살짝 뭔가를 덧붙이면, 사실 Redis가 응답을 늦게 주는 건 맞습니다. 그러나 Redis는 일찍 줬다고 측정하는거죠. 왜?)

    먼저, 이 속도를 측정하는 구간에 대해서 이야기를 해보겠습니다. 일반적으로 우리는 다음과 같은 상황으로 측정합니다. 보통 클라이언트 입장에서 latency 라고 하면 Redis를 호출하고, Redis의 응답을 받기까지의 시간을 의미합니다.

    latency1

    즉 위의 그림에서 1에서 2까지의 시간을 우리는 보통 latency라고 표현합니다. 그래서 이 시간동안의 timeout을 넘어가면 응답이 느리다고 인식하게 되는 것이죠.

    그런데 Redis 입장에서 latency, 즉 slowlog를 측정하는 방법은 좀 다릅니다.
    latency2

    위의 그림에서 빨간색 3으로 표시된 부분만이 Redis에서 slowlog의 대상이 되는 시간입니다. 소스를 보면 다음과 같습니다. c->cmd->proc 함수를 호출하는 바로 앞과 뒤에서 해당 함수의 수행시간을 재고 있습니다.

    void call(client *c, int flags) {
        ......
        start = server.ustime;
        c->cmd->proc(c);
        duration = ustime()-start;
    
        ......
    
        /* Log the command into the Slow log if needed, and populate the
         * per-command statistics that we show in INFO commandstats. */
        if (flags & CMD_CALL_SLOWLOG && !(c->cmd->flags & CMD_SKIP_SLOWLOG)) {
            char *latency_event = (c->cmd->flags & CMD_FAST) ?
                                  "fast-command" : "command";
            latencyAddSampleIfNeeded(latency_event,duration/1000);
            slowlogPushEntryIfNeeded(c,c->argv,c->argc,duration);
        }
       
        ......
    }
    

    이 상황에서 다시 문제가 되는 부분들을 확인해 봅시다. 크게 두 가지로 나눠집니다.

    • Case A: 클라이언트 입장에서도 latency가 느리고, Redis에서도 느려서 slowlog가 찍힐 만한 상황
    • Case B: 클라이언트 입장에서는 latency가 느리지만, Redis 입장에서는 느리지 않다고 인식하는 경우

    먼저 Case A 부터 살펴봅시다. Case A는 Redis에서 위에서 말한 빨간색 3에 해당하는 케이스입니다. Redis의 PrcoessCommand가 느려지는 경우는, O(N)의 명령을 사용하거나, 해당 메모리가 swaping 대상으로 매번 디스크를 접근해야 될 경우입니다. 후자는 메모리가 부족할 때 발생하는 케이스이고, 전자는 대량의 아이템을 한번에 가져가는 케이스, 예를 들어 KEYS 명령을 쓰거나, 굉장히 많은 item이 든 collection의 데이터를 모두 가져오는, 예를 들어, 10만개가 들었는데 10만개를 전부 가져오면 느려집니다. 대표적으로 다음과 같은 명령들이 있습니다.

    1. hgetll
    2. smembers

    아니면 이렇게 든 collection을 지울 때 발생하게 됩니다.

    그럼 이제 오늘의 주제였던 Case B를 살펴봅시다. 일단 다시 짚고 넘어가야할 것이, Redis는 싱글스레드입니다. 즉, 하나의 명령을 수행하는 동안 다른 명령을 수행하지 못합니다. 그리고, Redis의 네트웍 모듈 역시 이 싱글스레드에 영향을 받습니다.

    Redis에서 하나의 명령을 수행하는 과정은, 네트웍 패킷을 클라이언트 마다 epoll 등의 IO multiplexer를 이용해서 읽어들이고, 이게 완성되었다라고 판단이 들면 그 시점에 바로 수행을 해버립니다. 즉, 클라이언트에서 Request를 보냈다고 하더라도, TCP로 인해서 패킷이 분할되서 갈 수도 있고, 한방에 갈수도 있지만, 하나의 Request가 완성이 되었다고 Redis가 판단해야만, 그 때 실행되게 됩니다. 그렇다면, 내가 네트웍으로 Request를 보냈지만, Redis가 다른 명령들의 network 패킷을 읽어드리고 해당 명령어를 처리하는 중이라면, 우리가 보낸 Request는 아직 실행이 되지 않습니다. 즉, 우리가 Request를 보낸 시간은 계속 지나가지만, Redis가 다른 수 많은 명령을 처리한다면, 그리고 그 기간이 길어진다면 위에서 말한 1,2의 시간은 길지만, Redis 입장에서는 하나의 명령을 짧게 처리하면, 자신은 여전히 빨리 처리한다고 생각하므로, slowlog가 남지 않고, 클라이언트에서는 응답이 느려지는 현상이 발생하게 되는 것입니다.

    이럴 경우는 Redis에서, 시간이 느린 명령을 최대한 줄이거나(slowlog에 걸릴만큼은 아니지만 느린 명령들), Redis 서버를 늘려서, Requests 자체를 줄이는 방법이 있습니다. 시간이 느린 명령은 info all 명령으로 cmd states를 보시면, 호출 수와 평균 응답시간이 있으니 이거 기준으로 튜닝하시면 됩니다.

    그럼 고운 하루되세요.

    [입 개발] airflow 의 schedule_interval 에 대해서

    $
    0
    0

    일단 저는 airflow에 대해서는 초초초초초보입니다. 현재 airflow를 조금씩 사용해보고는 있지만, 아직 써본지 얼마 안된… 내부구조도 모르고 어떻게 돌아가는지도 잘 모릅니다. 그 전에는 pinball(pinball)을 몇년 썼지만, 아… pinball도 내부 구조는 거의 모르는 초초초초초초보입니다.(흑 나란 남자… 쓰레기…)

    airflow는 참 좋은거 같은데, 최초에 만나는 장벽이 바로 TimeZone 이슈입니다. airflow를 만든 수 많은 역촋들은 시간 이슈로 많은 고생을 하셨는지, 세상은 글로벌(글로 벌을 선다는 의미죠.) 다른 시간대는 다 집어쳐버려라는 마인드로… UTC만 이용하도록 만들었습니다.(물론 실제로 이런 이유는 절대로 아닙니다.)

    이번에 airflow 1.10.10 부터는 드디어 UI에 UTC가 아닌 다른 TimeZone을 보여주도록 기능이 추가되었습니다.([AIR-8046]) 그런데 이 1.10.10 은 4/9일에 릴리즈가 되었고, 화면의 UI만 바꿔주는 걸로 보입니다.

    앞에도 말했지만 airflow를 처음쓰게 되면 생기는 이슈가 바로 이 시간대와 execution_date 인데요.(사실 저는 아직도 잘 이해하지 못했습니다.) 여기에 대해서 정말 잘 설명한 글이 있어서 Link를 공유합니다.

    그래서 저걸 이해하고 나명 이제 남은것은 default_args 의 start_date와 end_date 입니다.
    아래와 같은 값을 설정하면 datetime(2020, 4, 16) 은 UTC 기준입니다. 우리네 KST와는 다르죠. 9시간 느립니다.
    즉 한국시간으로는 2020-04-16 09:00:00 이 됩니다.

    default_args = {
        'owner': 'airflow',
        'depends_on_past': False,
        'start_date': datetime(2020, 4, 16),
        'email': ['airflow@example.com'],
        'email_on_failure': False,
        'email_on_retry': False,
        'retries': 1,
        'retry_delay': timedelta(minutes=5),
    }
    

    그래서 보통 한국시간으로 코드를 보기 위해서, 다음과 같이 타임존을 지정합니다.

    KST = pendulum.timezone("Asia/Seoul")
    
    default_args = {
        'owner': 'airflow',
        'depends_on_past': False,
        'start_date': datetime(2020, 4, 16, tzinfo=KST),
        'email': ['airflow@example.com'],
        'email_on_failure': False,
        'email_on_retry': False,
        'retries': 1,
        'retry_delay': timedelta(minutes=5),
    }
    

    이제 시작 시간이 한국시간으로 2020-04-16 00:00:00 이 되었습니다. 그런데 여기에 airflow는 schedule_interval 이라는 값이 추가가 됩니다.

    schedule_interval 은 cron 형식과 동일하게 사용할 수 있습니다. 사용법 자체는 설명하지 않습니다. 옐르 들어
    “0 17 * * *” 라고 하면 매일 17시에 실행을 하라는 것입니다.(딱 그 시간에 시작되지는 않습니다.) 그런데 이러면… 이 17시의 기준이 되는 timezone은 무엇일까요?

    넵, 정답은… BYC~~~ 라고 하면 맞아 죽을꺼고 아까 제가 airflow의 timezone 철학은 UTC라고 했습니다라고 생각하시면, 경기도 오산의 오산이고, 구로구 오류동의 오류입니다.

    정답부터 얘기하자면 schedule_interval은 startdate 의 timezone에 영향을 받습니다. 당연히 자세한 설명은 피하고 코드를 봅니다. 일단 복잡하니, 중간과정은 다 생략하고 following_schedule 라는 함수를 봅니다.

        def following_schedule(self, dttm):
            """
            Calculates the following schedule for this dag in UTC.
    
            :param dttm: utc datetime
            :return: utc datetime
            """
            if isinstance(self.normalized_schedule_interval, str):
                # we don't want to rely on the transitions created by
                # croniter as they are not always correct
                dttm = pendulum.instance(dttm)
                naive = timezone.make_naive(dttm, self.timezone)
                cron = croniter(self.normalized_schedule_interval, naive)
    
                # We assume that DST transitions happen on the minute/hour
                if not self.is_fixed_time_schedule():
                    # relative offset (eg. every 5 minutes)
                    delta = cron.get_next(datetime) - naive
                    following = dttm.in_timezone(self.timezone).add_timedelta(delta)
                else:
                    # absolute (e.g. 3 AM)
                    naive = cron.get_next(datetime)
                    tz = pendulum.timezone(self.timezone.name)
                    following = timezone.make_aware(naive, tz)
                return timezone.convert_to_utc(following)
            elif self.normalized_schedule_interval is not None:
                return dttm + self.normalized_schedule_interval
    

    위의 메서드에서 다른 건 어려우니 following(다음 스케줄 시간)을 구하는 코드만 보겠습니다. 코드는 뭔가 차이가 나지만
    둘다 self.timezone을 사용하고 있습니다.

    following = dttm.in_timezone(self.timezone).add_timedelta(delta)
    ......
    tz = pendulum.timezone(self.timezone.name)
    following = timezone.make_aware(naive, tz)
    

    그럼 self.timezone이 어디서 오는지 살펴보면 되겠군요.

            # set timezone from start_date
            if start_date and start_date.tzinfo:
                self.timezone = start_date.tzinfo
            elif 'start_date' in self.default_args and self.default_args['start_date']:
                if isinstance(self.default_args['start_date'], str):
                    self.default_args['start_date'] = (
                        timezone.parse(self.default_args['start_date'])
                    )
                self.timezone = self.default_args['start_date'].tzinfo
    
            if not hasattr(self, 'timezone') or not self.timezone:
                self.timezone = settings.TIMEZONE
    

    네 그렇습니다. self.timezone 은 위와 같은 코드로 결정됩니다. start_date가 있으면 여기서 timezone 정보를
    가져옵니다. 그리고 그게 없으면 settings.TIMEZONE 을 가져옵니다. 마지막으로 조금 더 살펴보면 airflow.cfg 에서
    default_timezone 을 셋팅하면 지정한 값으로 해당 TIMEZONE이 셋팅됩니다. system으로 해두면 자동 시스템 로컬로
    설정이 되네요.

    TIMEZONE = pendulum.timezone('UTC')
    try:
        tz = conf.get("core", "default_timezone")
        if tz == "system":
            TIMEZONE = pendulum.local_timezone()
        else:
            TIMEZONE = pendulum.timezone(tz)
    except Exception:
        pass
    

    아주 당연한 얘기지만, airflow 에서 이런 값들이 어떻게 동작하는 지 알아야만, 내가 원하는 시간에 스케줄이 되도록
    스케줄 설정이 가능합니다. 이 timezone 말고 execution_date가 어떤건지를 잘 아셔야 정확하게 수행이 가능하니
    위의 링크의 글을 꼭 잘 읽어보시길 바랍니다. airflow 초보는 힘드네요.

    Viewing all 122 articles
    Browse latest View live