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

[입 개발] memcached slabclass 에 대해서…

$
0
0

오래간 만에 memcached 소슬 보니, 너무 잘못 이해하고 있거나 한것들이 많아서 처음부터 새로 보면서 이번에는 기록을 좀 남겨둘려고 합니다. 흔히들 memcached가 내부적으로 메모리를 어떻게 관리하는지 잘 아시지만, 코드 레벨로는 잘 모르실 수도 있기 때문에 그냥 정리합니다.

먼저 간단히 용어를 정리하자면…

  • chunk_size : key + value + flag 정보를 저장하기 위한 최소 크기: 기본 48
  • factor : item size 크기를 얼마만큼씩 증가시킬지 결정하는 값: 기본 1.25
  • Chunk_align_bytes : chunk 할당시에 사용하는 align : 8
  • item_size_max: 최대 item의 크기: 기본 1MB

그리고 사이즌 chunk_size * 1.25^(n-1) 형태로 증가하게 됨.

이제 slab.c를 보면 slabclass_t 를 볼 수 있습니다.

typedef struct {
    unsigned int size;      /* sizes of items */
    unsigned int perslab;   /* how many items per slab */

    void *slots;           /* list of item ptrs */
    unsigned int sl_curr;   /* total free items in list */

    unsigned int slabs;     /* how many slabs were allocated for this class */

    void **slab_list;       /* array of slab pointers */
    unsigned int list_size; /* size of prev array */

    unsigned int killing;  /* index+1 of dying slab, or zero if none */
    size_t requested; /* The number of requested bytes */
} slabclass_t;

static slabclass_t slabclass[MAX_NUMBER_OF_SLAB_CLASSES];

MAX_NUMBER_OF_SLAB_CLASSES 는 201로 정의되어 있습니다. 즉 최대 201개의 slabclass 가 만들어지는데, 실제로는 chunk_size 와 factor 값에 따라서 최대 item_size_max를 넘어가 버리면, slabclass는 거기까지만 사용됩니다.(slab_init 를 보면 쉽게 알 수 있습니다.)

/**
 * Determines the chunk sizes and initializes the slab class descriptors
 * accordingly.
 */
void slabs_init(const size_t limit, const double factor, const bool prealloc) {
    int i = POWER_SMALLEST - 1;
    unsigned int size = sizeof(item) + settings.chunk_size;

    ......
    ......

    while (++i < POWER_LARGEST && size <= settings.item_size_max / factor) {
        /* Make sure items are always n-byte aligned */
        if (size % CHUNK_ALIGN_BYTES)
            size += CHUNK_ALIGN_BYTES - (size % CHUNK_ALIGN_BYTES);

        slabclass[i].size = size;
        slabclass[i].perslab = settings.item_size_max / slabclass[i].size;
        size *= factor;
        if (settings.verbose > 1) {
            fprintf(stderr, "slab class %3d: chunk size %9u perslab %7u\n",
                    i, slabclass[i].size, slabclass[i].perslab);
        }
    }

    power_largest = i;
    slabclass[power_largest].size = settings.item_size_max;
    slabclass[power_largest].perslab = 1;
    if (settings.verbose > 1) {
        fprintf(stderr, "slab class %3d: chunk size %9u perslab %7u\n",
                i, slabclass[i].size, slabclass[i].perslab);
    }

    ......
    ......

위의 소스에 나오는 size는 slab별로 할당되는 기본 사이즈의 크기이고 위의 slabclass 구조체에는 item_size_max(기본 1MB) 를 넣어주고, perslab 에는 item_size_max / size로 몇개의 아이템이 들어갈 수 있는지 들어가게됩니다.

그리고 이 slab 안에 array로 item 들이 할당되게 됩니다. 기본적으로 array의 크기는 16으로 설정되고 그 뒤로는 2배씩 증가하게 됩니다. 관련 함수는 grow_slab_list를 보시면 됩니다. 그리고 slab에서 사용하는 chunk가 항상 item_size_max 인것은 아니고, size * perslab으로 될 때도 있습니다.(do_slabs_newslab 에서 확인 가능, memory_allocate 를 이용함)

static int do_slabs_newslab(const unsigned int id) {
    slabclass_t *p = &slabclass[id];
    int len = settings.slab_reassign ? settings.item_size_max
        : p->size * p->perslab;
    char *ptr;

    if ((mem_limit && mem_malloced + len > mem_limit && p->slabs > 0) ||
        (grow_slab_list(id) == 0) ||
        ((ptr = memory_allocate((size_t)len)) == 0)) {

        MEMCACHED_SLABS_SLABCLASS_ALLOCATE_FAILED(id);
        return 0;
    }

    memset(ptr, 0, (size_t)len);
    split_slab_page_into_freelist(ptr, id);

    p->slab_list[p->slabs++] = ptr;
    mem_malloced += len;
    MEMCACHED_SLABS_SLABCLASS_ALLOCATE(id);

    return 1;
}

slabclass 는 여기까지 하고, 다음번에는 실제 item 의 추가 삭제시에 어떻게 되는가에 대해서 정리해보도록 하겠습니다.



[입 개발] Redis 가 멀티스레드라구요?

$
0
0

제가 항상 강조하는 것중에 Redis는 멀티스레드가 아니고 싱글 스레드이기 때문에 항상 사용에 주의해야 한다고 말을 드렸는데… 뭔가 깽기는게 있어서 ps -eLf 를 해보겟습니다.

charsyam@charsyam-vm-main:~/redis$ ps -eLf | grep "redis"
charsyam 31860  2920 31860 10    3 22:58 pts/0    00:00:05 src/redis-server *:6379
charsyam 31860  2920 31861  0    3 22:58 pts/0    00:00:00 src/redis-server *:6379
charsyam 31860  2920 31862  0    3 22:58 pts/0    00:00:00 src/redis-server *:6379

헉… 무려 스레드가 3개가 떠 있습니다. 자 바로 주먹에 돌을 쥐시면서, 이 구라쟁이야 하시는 분들이 보이시는 군요. (퍽퍽퍽!!!)

자… 저는 분명히 맨날 싱글 스레드 싱글 스레드라고 외쳤는데… Redis는 무려 멀티 스레드 어플리케이션이었던 것입니다!!!

그러면… 이 스레드들을 늘리면… 엄청난 성능 향상이 있을까요?

힌트를 드리자면, 이 스레드들은… 성능과 영향은 있지만… 더 늘린다고 해서 성능 향상이 생기고 기존 명령이 한꺼번에 많이 처리되지는 않는다는 것입니다.

이게 무슨소리냐!!! 라고 하시는 분들이 계실껍니다.

먼저, 목숨을 부지하기 위해서 결론부터 말씀드리자면… 이 두 스레드는 Redis에서 데이터를 처리하는 스레드가 아닙니다.(진짜예요!!! 이번엔 믿어주세요 T.T)

Redis의 스레드를 처리하는 파일은 bio.h 와 bio.c 이고 bio.h 를 보면 다음과 같은 코드를 볼 수 있습니다.

#define REDIS_BIO_CLOSE_FILE    0 /* Deferred close(2) syscall. */
#define REDIS_BIO_AOF_FSYNC     1 /* Deferred AOF fsync. */
#define REDIS_BIO_NUM_OPS       2

REDIS_BIO_NUM_OPS는 몇개의 잡큐(개별 하나당 스레드)를 만들 것인지에 대한 내용이고, REDIS_BIO_CLOSE_FILE 과 REDIS_BIO_AOF_FSYNC를 보면… 아하.. 이것들이 뭔가 데이터 처리를 안할꺼라는 믿음이 생기시지 않습니까?(퍽퍽퍽)

크게 두 가지 함수가 존재합니다. 하나는 작업 큐에 작업을 넣는 bioCreateBackgroundJob 함수
그리고 이걸 실행하는 bioProcessBackgroundJobs 입니다.

void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3) {
    struct bio_job *job = zmalloc(sizeof(*job));

    job->time = time(NULL);
    job->arg1 = arg1;
    job->arg2 = arg2;
    job->arg3 = arg3;
    pthread_mutex_lock(&bio_mutex[type]);
    listAddNodeTail(bio_jobs[type],job);
    bio_pending[type]++;
    pthread_cond_signal(&bio_condvar[type]);
    pthread_mutex_unlock(&bio_mutex[type]);
}

void *bioProcessBackgroundJobs(void *arg) {
    struct bio_job *job;
    unsigned long type = (unsigned long) arg;
    sigset_t sigset;

    /* Make the thread killable at any time, so that bioKillThreads()
     * can work reliably. */
    pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
    pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);

    pthread_mutex_lock(&bio_mutex[type]);
    /* Block SIGALRM so we are sure that only the main thread will
     * receive the watchdog signal. */
    sigemptyset(&sigset);
    sigaddset(&sigset, SIGALRM);
    if (pthread_sigmask(SIG_BLOCK, &sigset, NULL))
        redisLog(REDIS_WARNING,
            "Warning: can't mask SIGALRM in bio.c thread: %s", strerror(errno));

    while(1) {
        listNode *ln;

        /* The loop always starts with the lock hold. */
        if (listLength(bio_jobs[type]) == 0) {
            pthread_cond_wait(&bio_condvar[type],&bio_mutex[type]);
            continue;
        }
        /* Pop the job from the queue. */
        ln = listFirst(bio_jobs[type]);
        job = ln->value;
        /* It is now possible to unlock the background system as we know have
         * a stand alone job structure to process.*/
        pthread_mutex_unlock(&bio_mutex[type]);

        /* Process the job accordingly to its type. */
        if (type == REDIS_BIO_CLOSE_FILE) {
            close((long)job->arg1);
        } else if (type == REDIS_BIO_AOF_FSYNC) {
            aof_fsync((long)job->arg1);
        } else {
            redisPanic("Wrong job type in bioProcessBackgroundJobs().");
        }
        zfree(job);

        /* Lock again before reiterating the loop, if there are no longer
         * jobs to process we'll block again in pthread_cond_wait(). */
        pthread_mutex_lock(&bio_mutex[type]);
        listDelNode(bio_jobs[type],ln);
        bio_pending[type]--;
    }
}

보면 두 개의 잡큐가 각각 CLOSE 와 AOF_FSYNC 를 처리하고 있습니다. 그리고 이 두 잡큐에 잡을 넣는 것은 모두 aof.c 에 존재합니다.

하나는 aof_background_fsync 함수이고, 나머지 하나는 backgroundRewriteDoneHandler 에서 호출하고 있습니다. 하나는 aof_를 닫을 때 이를 Async 하게 close 하기 위한 것이고, 또 하나는 aof 작업중에 fsync를 통해서 데이터를 동기화 시키는 부분입니다.

이 것들은 disk를 flush 하거나, 파일을 닫기위해서 OS 작업이 되는 것을 해당 스레드에서 하게 되면, 블럭이 되어서 다른 작업이 느려질 수 있으므로, 해당 작업들을 OS 레벨에서 비동기로 처리하기 위한 것입니다.

즉, 이 스레드들은… 더 늘릴 필요도 없고(AOF는 한번에 하나만 생성이 됩니다.) 더 늘린다고 해서 실제로 Redis 자체의 작업을 빠르게 해주는 게 아니라는 것입니다.

즉, 여전히 Redis 는 싱글 스레드라고 보셔야 합니다.


서버를 만드실때는 포트를 32768 이전으로 설정하세요.

$
0
0

최근에 아주 재미난(?) 일을 격었습니다. 서버를 시작시키는데, 아무리해도 서버가 뜨지 않는 것이었죠.
코드를 봐서는 아무런 문제가 없는데… 에러 로그는

“binding error: port is already used” 비슷한 오류가 발생하는 것이었습니다.

netstat 으로 LISTEN 포트만 찾아봐서는… 제가 사용하는 포트를 찾을 수도 없었죠.

그런데 netstat으로 포트를 더 찾아보니… 이상한 결과를 볼 수 있었습니다.

tcp        0      0 192.168.1.4:48121          192.168.1.4:6379         ESTABLISHED 15598/redis-server

여기서 제 서버에서 사용하고자 하던 포트를 48121 이라고 가정합니다. -_-(잉, 뭔가 가정이 이상하다구요. 그렇죠, 저도 뭔가 많이 이상합니다. 그러나 그것이 현실로 일어났습니다.)

그리고 다시 한번 제목을 보시죠.. 일단 포트에 대해서 잘 모르는 지식을 잠시 정리하자면 다음과 같습니다.

  • socket 이 바인딩하게 되는 포트는 udp/tcp 는 별개다. 즉 udp:53, tcp:53번 모두 바인딩 가능합니다. 실제로 DNS가 일반적으로 이렇게 하고 있죠.
  • socket 이 바인딩하게 되는 주소는 ip + port 다 즉 1.2.3.4 를 가지고 있는 서버는 127.0.0.1:48121 과 1.2.3.4:48121 를 따로 바인딩할 수 있다.

즉 다음과 같은 예제를 보면 에러가 발생해야 합니다.

# -*- coding: utf-8 -*-.

from flask import Flask, request, redirect, url_for, jsonify

app = Flask(__name__,static_folder='static', static_url_path='')

@app.route('/health_check.html')
def health():
    return "OK"

if __name__ == '__main__':
    app.run(host="127.0.0.1", port=20000)

두 개를 실행시키면 다음과 같이 오류가 발생합니다.

 * Running on http://127.0.0.1:20000/
Traceback (most recent call last):
  File "2.py", line 23, in <module>
    app.run(host="127.0.0.1", port=20000)
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/flask/app.py", line 772, in run
    run_simple(host, port, self, **options)
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 710, in run_simple
    inner()
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 692, in inner
    passthrough_errors, ssl_context).serve_forever()
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 486, in make_server
    passthrough_errors, ssl_context)
  File "/Users/charsyam/anaconda/lib/python2.7/site-packages/werkzeug/serving.py", line 410, in __init__
    HTTPServer.__init__(self, (host, int(port)), handler)
  File "/Users/charsyam/anaconda/lib/python2.7/SocketServer.py", line 419, in __init__
    self.server_bind()
  File "/Users/charsyam/anaconda/lib/python2.7/BaseHTTPServer.py", line 108, in server_bind
    SocketServer.TCPServer.server_bind(self)
  File "/Users/charsyam/anaconda/lib/python2.7/SocketServer.py", line 430, in server_bind
    self.socket.bind(self.server_address)
  File "/Users/charsyam/anaconda/lib/python2.7/socket.py", line 224, in meth
    return getattr(self._sock,name)(*args)
socket.error: [Errno 48] Address already in use

이제 강제로 아이피를 설정해보겠습니다.

127.0.0.1, 192.168.1.4, 0.0.0.0 으로 세 개의 ip로 실행을 시켰습니다. 모두 20000 번으로 실행했습니다.

charsyam ~/kakao_home_affiliate (master*) $ python 1.py
 * Running on http://127.0.0.1:20000/

charsyam ~/kakao_home_affiliate (master*) $ python 2.py
 * Running on http://0.0.0.0:20000/

charsyam ~/kakao_home_affiliate (master*) $ python 3.py
 * Running on http://192.168.1.4:20000/

charsyam ~ $ telnet 0 20000 <-- 이건 실제로 local loopback으로 전달되므로 127.0.0.1 과 동일합니다.
Trying 0.0.0.0...
Connected to 0.
Escape character is '^]'.
GET /health_check.html HTTP/1.1

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 2
Server: Werkzeug/0.9.4 Python/2.7.5
Date: Mon, 14 Apr 2014 14:48:43 GMT

OK

charsyam ~ $ telnet 127.0.0.1 20000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /health_check.html HTTP/1.1

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 2
Server: Werkzeug/0.9.4 Python/2.7.5
Date: Mon, 14 Apr 2014 14:48:53 GMT

OK

charsyam ~ $ telnet 192.168.1.4 20000
Trying 192.168.1.4...
Connected to 192.168.1.4.
Escape character is '^]'.
GET /health_check.html HTTP/1.1

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 2
Server: Werkzeug/0.9.4 Python/2.7.5
Date: Mon, 14 Apr 2014 14:49:08 GMT

OK

모두 동작하는 걸 볼 수 있습니다.

charsyam ~/kakao_home_affiliate (master*) $ python 1.py
 * Running on http://127.0.0.1:20000/
127.0.0.1 - - [14/Apr/2014 23:48:43] "GET /health_check.html HTTP/1.1" 200 -
127.0.0.1 - - [14/Apr/2014 23:48:53] "GET /health_check.html HTTP/1.1" 200 -

charsyam ~/kakao_home_affiliate (master*) $ python 2.py
 * Running on http://0.0.0.0:20000/

charsyam ~/kakao_home_affiliate (master*) $ python 3.py
 * Running on http://192.168.1.4:20000/
192.168.1.4 - - [14/Apr/2014 23:49:08] "GET /health_check.html HTTP/1.1" 200 -

실제로 0.0.0.0 이 INADDR_ANY이지만 127.0.0.1 이나 192.168.1.4로 명시하면, 각각의 주소가 명시되므로 거기로 전달되게 됩니다.
(이러면… port 를 탈취하는 것도 가능할것 같네요. 실제로 테스트해보면 192.168.1.4를 kill 하면 그 다음부터는 0.0.0.0 으로 전달이 되네요.)

자자… 그러면… 이제 무슨 일이 있었던 걸까요?

정리하자면… 실제 서버가 Redis 서버에 연결되면서 하필이면 192.168.1.4:48121를 만들어서 실제 192.168.1.4:6379 에 붙어버린겁니다. 그러면서 서버는 48121포트를 할당하려고 하니 이미 bind 에러가 발생한것이죠.(흑흑흑, 정말 재수가 없었던겁니다.)

그럼… 이를 해결하는 방법이 있을까요? 뭔가 할당되는 정책이 있을까요? 이것을… 사실 local port 라고 합니다. 외부로 나가기 위해서 만들어지는 port 이죠.

이 값은 그럼 어떻게 설정될까요? 기본적으로 shell 에서 sysctl -A 를 해보면 거기서 다음과 같은 두 개의 값을 발견할 수 있습니다.

net.ipv4.ip_local_port_range = 32768	61000
net.ipv4.ip_local_reserved_ports =

ip_local_port_range 를 보면 기본적으로 저 값 안에서 32768에서 61000번 사이에서 할당이 하게 됩니다. 즉, 외부에서 접속하는 소켓의 포트가 위의 범위에서 할당되므로, 이 값을 피하는게 좋습니다. 위의 32768, 61000이 기본으로 설정되는 범위입니다. 즉 다음과 같은 port는 서버 설정시에 피하시는게 좋습니다.

그럼 실제로 커널 코드를 까보면 다음과 같습니다. 코드는 지루할테니 설렁설렁 보겠습니다. linux kernel 3.14를 기본으로 합니다.
먼저 해당 범위 값을 가져오는 함수는 inet_get_local_port_range 함수입니다.

void inet_get_local_port_range(struct net *net, int *low, int *high)
{
  unsigned int seq;

  do {
    seq = read_seqbegin(&net->ipv4.sysctl_local_ports.lock);

    *low = net->ipv4.sysctl_local_ports.range[0];
    *high = net->ipv4.sysctl_local_ports.range[1];
  } while (read_seqretry(&net->ipv4.sysctl_local_ports.lock, seq));
}

그리고 이 함수를 inet_csk_get_port 에서 호출합니다. 이 코드에서 실제로 local_port 가 결정납니다.

    do {
      if (inet_is_reserved_local_port(rover))
        goto next_nolock;
      head = &hashinfo->bhash[inet_bhashfn(net, rover,
          hashinfo->bhash_size)];
      spin_lock(&head->lock);
      inet_bind_bucket_for_each(tb, &head->chain)
        if (net_eq(ib_net(tb), net) && tb->port == rover) {
          if (((tb->fastreuse > 0 &&
                sk->sk_reuse &&
                sk->sk_state != TCP_LISTEN) ||
               (tb->fastreuseport > 0 &&
                sk->sk_reuseport &&
                uid_eq(tb->fastuid, uid))) &&
              (tb->num_owners < smallest_size || smallest_size == -1)) {
            smallest_size = tb->num_owners;
            smallest_rover = rover;
            if (atomic_read(&hashinfo->bsockets) > (high - low) + 1 &&
                !inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb, false)) {
              snum = smallest_rover;
              goto tb_found;
            }
          }
         if (!inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb, false)) {
            snum = rover;
            goto tb_found;
          }
          goto next;
        }
      break;
    next:
      spin_unlock(&head->lock);
    next_nolock:
      if (++rover > high)
        rover = low;
    } while (--remaining > 0);

뭔가 코드는 복잡하지만 inet_is_reserved_local_port 를 이용해서 예약된 local_port 는 피하는 것을 알 수 있습니다.
(udp는 또 udp_lib_get_port 에서 결정이 됩니다.)

물론 여기서 좀 더 깊게는 더 커널을 살펴봐야 겠지만… 대략 위의 형태로 동작한다는 것을 알 수 있습니다. 일단 이번 글은 여기서 끝!!!


[입 개발:Redis] 나의 잘못된 오해, AOF

$
0
0

오늘 제대로 알고 있지 못한 부분에 대해서 한 독자님의 지적을 받고… AOF 관련 코드를 유심히, 그리고 좀 자세히 보게 되었습니다. 그런데… 음… 제가 완전히 잘못 알고 있던 부분이 있었습니다.

일단, 저는 Redis 의 AOF 가 DB의 WAL(Write ahead Log) 의 변종이라고 생각하고 있습니다. 먼저 Write ahead Log 에 대해서 아주 간략하게 설명하자면…(이번에 조사하면서 WAL조차도 잘못 이해하고 있었다는 걸 알았습니다. T.T)

데이터의 변경이 발생하기 전에 이 변경사항에 대한 Log를 남기고, 이를 이용해서 Data의 durability 를 보장하는 방법입니다. 디비등에서는 실제 데이터 영역의 변경을 하기 전에, 이에 대한 변경 사항을 commit시에 Log로 남기고, 이를 이용해서 나중에 실제 데이터 영역을 변경하기 위해서 사용하기도 합니다.

여기서 중요한 부분은 Log 가 persistent 할 수도 있고, 아닐 수도 있다는 점입니다. 왜냐하면 매번 disk에 write가 발생하면, 느려질테니깐요. 그래서 보통 Log를 Buffering 하고 이를 한꺼번에 쓰는 형태의 작업을 하게됩니다. 그런데… 여기서 이제부터 일반적으로 고민이 시작되는거죠.

DB의 데이터는 중요하다. 그런데 insert, update, delete등에 의해서 변경이 벌어지는데, 이것이 log buffer에 쌓이고, 실제 디스크에 쓰여지지 않는다면, 데이터의 유실이 발생할 수도 있는 것입니다.

그래서 mysql 의 innodb 의 경우는 innodb_flush_log_at_trx_commit 옵션을 이용해서 Disk에 flush 하는 주기를 조절하거나, 매번하도록 되어있습니다.(여기서의 기준은 하나의 Query Event 가 그 단위가 되는 것입니다.)

그럼 Redis 의 AOF는 무엇이 다르냐?

어떻게 보면, 거의 유사합니다. 하지만 다음과 같은 부분이 다릅니다.
1. AOF buffer 에 데이터를 남기는 시점이, 실제 메모리에 데이터가 변경된 이후이다.
-> 데이터의 메모리 변경 후에, 커맨드를 만들어서 AOF buffer 에 저장한다.
2. 그리고 실제 disk 에 flushing 하는 시점은 매 event loop의 시작 부분인 beforesleep 에서 동작한다.
-> 즉 AOF buffer 들어 있는 내용은, 하나의 event loop가 모두 끝난 다음에 디스크에 쓰여진다.
-> 각각의 버퍼는 각 명령 수행뒤에 propagation 에서 만들어짐.
-> Redis 는 single thread로 동작하기 때문에, 이 사이에 만약 1024개의 커맨드가 처리되었다고 한다면,
그 사이에 장애가 발생하면 해당 데이터를 돌릴 수 있는 방법은 없다.

여기서 잘못된 저의 오해는
1. Mysql처럼 Query 단위로, 데이터의 변경이 발생하기 전에 Logging이 되어야 된다고 생각
-> 그러나 실제로 Redis 에서는 커맨드 실행 후에, AOF buffer 만 만들어서 저장
-> event loop 전의 beforeSleep 에서 flushAppendOnlyFile 을 호출해서 AOF Buffer 를 Disk에 Flush 함.

그러면 옵션의 appendfsync 는 어떻게 동작하는가? 다음과 같습니다.
1. aof buffer 의 디스크에 쓰기는 오직 flushAppendOnlyFile()에서만 저장된다.
2. appendfsync가 no 면, 그냥 beforeSleep때 마다 os의 write를 호출하고, 실제 os와 disk간의 sync 는 os에
맡긴다.
3. EVERYSEC의 현재 fsync 작업이 스레드 큐에 존재하면, write를 하지 않고 return.
없으면 write 후에 fsync 를 타 스레드에 하도록 돌림.
만약 계속 fsync 작업이 남아있는걸로 판단하면, 그냥 write 함.
EVERYSEC 으로 되어있지만, 해당 cron 작업에 따라, 더 느려질 가능성도 존재.
4. ALWAYS의 경우, 매번 beforeSleep에서 디스크에 쓰고, fsync 도 동기로 호출

즉 Redis 의 AOF는 어떤 옵션을 쓰더라도, Write가 많을 경우에는 장애가 발생할 경우, 바로 직전의 명령이 아니라, 한 이벤트 루프 안에서 업데이트된 꽤 많은 데이터가 유실될 가능성도 있다는 걸 알아두고 사용하시면 좋을듯 합니다.


[책 리뷰] 글로벌 소프트웨어를 말하다.

$
0
0

김익환님의 새 책인 “글로벌 소프트웨어를 말하다.”가 나왔다. 이전 작품인 “글로벌 소프트웨어를 꿈꾸다.” 라는 책을 꽤나 재미있게 읽은 편이라, 이번 책도 나름 집중해서 읽어 보게 되었다.

먼저 제목부터 생각해보면, “글로벌 소프트웨어” 라 멋지지 않은가? 제목에서 부터, 두 가지를 의미한다고 본다. 정말 글로벌한 소프트웨어를 만드는 것과 아직 우리나라에서는 “글로벌 소프트웨어”를 만들기 어렵다라는 부분…

실제로 전작인 “글로벌 소프트웨어를 꿈꾸다” 부터 신작인 “글로벌 소프트웨어를 말하다” 역시, 강하게 동의하는 부분도 있지만, 또한 역시 강하게 동의하기 어려운 부분도 많은 책이다. 그러나, 내가 동의하지 않는다고 해서, 틀렸다고 말하기는 어렵고, 다른다고 말해야 하지 않을까?

이전 책에서 가장 맘에 들던 내용은 실리콘 밸리쪽에서는 70점 부터 시작하지만, 우리나라는 2~30점 부터 시작하기 때문에, 거기에 대한 격차가 벌어진다는 것… 내가 여러 회사를 다닌 것은 아니지만, 그래도 소스 관리 툴등은 기본적으로 사용하는 회사에서 다녔고, 회사의 정책에 따라서, QP활동(정적분석툴, 소스 리뷰) 등도 해봤지만, 이게 문화로 자리잡는게 정말 어렵다는 걸 알고 있기 때문에, 정말 공감이 가는 얘기였다. 또한 내가 경험해보지 못한 영역인 SRS에 대한 강조는 나로서는 또한 이해하기 어려운… 그 취지에는 공감이 가지만, 그게 전부라고 말하는 것은 아직 내 경험상 이해하기 어려운 일이었다.

이번 신작에서는 “손자 병법”이 손무가 오자서에 의해 등용되기 전에 쓰여졌다는 부분… 즉, 알고 있는 것과, 이를 제대로 이해해서 실천하는 것의 큰 차이가 있다는 부분에 다시 한번 크게 동의했다. 사실 대규모 서비스에 들어가는 기술들도 대부분은 우리가 이미 알고 있는 것들이다. 정말 특별하다고 할 것은 없지만… 이를 제대로 구현하고 디테일하게 챙기는 것이 쉽지가 않다는 것… 개발자가 이에 대해 알아야 하는 것도 많고… 개인적으로 클라우드나 로컬 서버에 따라서 설계가 바뀌는 것은, 그 환경이 다름을 이해하면, 당연히 고려해야할 문제들일 수 있다. 전통적인 환경보다 더 장애가 많을 수 있고, 반대로 디플로이가 빠르고 환경에 따라서 쉽게 확장이 가능한 장점도 있다.

반대로, 약간 인터넷 포털의 입장보다는 솔루션 소프트웨어 개발쪽의 의견이 더 강하다는 느낌이 들었다. 페북/구글, 라인/카카오/네이버는 소프트웨어 업체라기 보다는 인터넷 포털이나 서비스 업체라고 보는게 더 강한데, 이런 곳에서의 릴리즈는 하루에도 열두번씩도 발생할 수 있고, 요구 상황도 상황에 맞춰서 자주 바뀌게 되는데, “일년에 릴리즈를 세번 이상 하는게 나쁜 회사라고 하기는 어렵다.”(물론 릴리즈가 조금 다른 의미이겠지만…)

외국 회사의 면접 이야기…(하루에 6번 정도 면접을 보면… 정말 피가 마르고, 머리는 멍해진다. 그것도 영어로 보니 한국인의 입장에서는 더더욱 힘든…)

전체적으로 앞부분은, 전작에서 하지못한 다른 이야기, 뒷부분은, 약간 다른 이야기들을 하고 있다. 약간, 국내보다 실리콘 밸리는 무조건 잘하고 있다라는 느낌도 적지 않지만… 그래도 “많은 개발자들의 꿈인 해외 취업” 을 하고 오신 선배님의 재미난 이야기로 생각하면, 꽤 도움이 될 내용들이 많다는 것… 그리고 자신의 경험에 비추어 본다면, 꽤 도움이 될것 같다.

내식대로 내용을 살짝 재해석 한다면…
1. 소스 관리 툴은 뭐든지 일단 도입하자(VSS, CVS, SVN, GIT)
2. Jenkins 등의 CI로 빌드는 항상 확인하자(github이라면 travis CI 같은…)
3. Jira형태의 이슈 관리 툴을 사용하자. 꼭 Jira가 아니더라도, 기록이 잘 남고, 검색이 되면 좋다.
4. 소스 코드 리뷰는, 자신의 실력향상을 위해서 필요하다. 남을 지적질 할 생각이 아니라, 어떻게 짰는지 보고 배우는 형태로…
5. test가 품질을 올려주지는 못하지만, 리그레이션 테스트는 꼭 도입하자.
6. 자신이 알게된 새로운 내용은 팀원들하고 항상 공유하자.

개인적으로 좋은 소프트웨어를 만들어낸다는 가정하에, “개발이 재미있어야 한다.”에 한마디를 더 하고 싶다. “스칼라는 모르지만…” 트위터가 자바로 가지않고 스칼라를 선택한 이유가 개발자의 재미를 위해서였다는 것은… 참 멋진 일이라고 생각한다.


[입 개발] org.apache.commons.codec.binary.Base64 decoding 동작에 관해서…

$
0
0

원래 개인적으로 삽질을 잘 하긴 하지만, 다시 최근에 큰 삽을 한번 팠습니다. 일단, 제가 자바를 잘 모르고, 초보다 보니, 남들 다 아는 지식을 모르고 넘어가는 경우가 많은데, 웬지 딴분들은 다 아셔서 이런일이 안생길것 같지만, 그냥 정리 차원에서 적어둡니다.

먼저 발생한 사건은 다음과 같습니다. 제 아이디인 charsyam 을 base64로 인코딩하면 다음과 같은 값이 나옵니다.

“Y2hhcnN5YW0=”

그리고 이걸 다시 decode 하면 “charsyam” 이라는 값이 나올겁니다. 그런데… 이제 보통 c/c++의 구현을 보면…
하다가 이상한 문자가 있으면 오류가 발생합니다. 즉 위의 “Y2hhc###nN5YW0=” 이런 글자가 있으면 일반적인 기대값은 오류라고 생각합니다.(아니라면 T.T)

그런데… apache coomons의 Base64구현은 조금 재미납니다.

“Y2hhc###nN5YW0=”
“Y2hhc#n#N#5YW0=”
“Y2hhc$$$nN5YW0=”
“Y2hhc$#n4N5YW0=”
“###Y2hhcnN5YW0=”
“###Y2hhcnN5YW0#=”
“Y2hhcnN5YW0=”

이런 것들이 모두 동일하게 “charsyam”으로 제대로 디코딩이 됩니다. 이것은 해당 코드가 DECODE_TABLE이라는 것을 가지고 여기에서 사용하지 않는 문자들은 전부 버린 뒤에 사용하기 때문에 일어나는 동작입니다. 다만 저 같은 사람은… 일단 제 코드를 의심하기 때문에, 삽질이 흑흑흑

먼저 다음과 같이 DECODE_TABLE 이 정의되어 있습니다.

    private static final byte[] DECODE_TABLE = {
            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
            -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, 62, -1, 63, 52, 53, 54,
            55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4,
            5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
            24, 25, -1, -1, -1, -1, 63, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34,
            35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51
    };

DECODE_TABLE 에서 -1에 지정되는 문자들은 전부 버리는 거죠. 사실 이게 apache commons base64의 문제(?)는 아닙니다. base64 에서 사용하는 table 이 사용하는 도메인에 따라서 조금씩 다르기 때문에, 이걸 전부 만족하도록 하게 구현이 되어 있는것입니다. 사실 더 많이 고려된 것이기도 하죠. 자세한건 여기(http://en.wikipedia.org/wiki/Base64)에서 확인하시면 됩니다.

그럼 이제 실제 구현을 살펴보면, 제가 말한 대로 DECODE_TABLE[value]의 값이 0 보다 클 경우에만 사용하게 되어있습니다.

   @Override
   void decode(final byte[] in, int inPos, final int inAvail, final Context context) {
       if (context.eof) {
           return;
       }
       if (inAvail < 0) {
           context.eof = true;
       }
       for (int i = 0; i < inAvail; i++) {
           final byte[] buffer = ensureBufferSize(decodeSize, context);
           final byte b = in[inPos++];
           if (b == PAD) {
               // We're done.
               context.eof = true;
               break;
           } else {
               if (b >= 0 && b < DECODE_TABLE.length) {
                   final int result = DECODE_TABLE[b];
                   if (result >= 0) {
                       context.modulus = (context.modulus+1) % BYTES_PER_ENCODED_BLOCK;
                       context.ibitWorkArea = (context.ibitWorkArea << BITS_PER_ENCODED_BYTE) + result;
                       if (context.modulus == 0) {
                           buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 16) & MASK_8BITS);
                           buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 8) & MASK_8BITS);
                           buffer[context.pos++] = (byte) (context.ibitWorkArea & MASK_8BITS);
                       }
                   }
               }
           }
       }

       // Two forms of EOF as far as base64 decoder is concerned: actual
       // EOF (-1) and first time '=' character is encountered in stream.
       // This approach makes the '=' padding characters completely optional.
       if (context.eof && context.modulus != 0) {
           final byte[] buffer = ensureBufferSize(decodeSize, context);

           // We have some spare bits remaining
           // Output all whole multiples of 8 bits and ignore the rest
           switch (context.modulus) {
               case 0 : // impossible, as excluded above
               case 1 : // 6 bits - ignore entirely
                   // TODO not currently tested; perhaps it is impossible?
                   break;
               case 2 : // 12 bits = 8 + 4
                   context.ibitWorkArea = context.ibitWorkArea >> 4; // dump the extra 4 bits
                   buffer[context.pos++] = (byte) ((context.ibitWorkArea) & MASK_8BITS);
                   break;
               case 3 : // 18 bits = 8 + 8 + 2
                   context.ibitWorkArea = context.ibitWorkArea >> 2; // dump 2 bits
                   buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 8) & MASK_8BITS);
                   buffer[context.pos++] = (byte) ((context.ibitWorkArea) & MASK_8BITS);
                   break;
               default:
                   throw new IllegalStateException("Impossible modulus "+context.modulus);
           }
       }
   }

나름 더 정확한 내용을 알 수 있게 되긴 했지만, 제 멍청함으로 인한 삽질한 시간들은 흑흑흑… 역시… 진리는 소스입니다. 흑흑흑


[입 개발] RabbitMQ 설치하기 for CentOS 6.5

$
0
0

1. OS: Centos 6.5
2. 설치
– erlang(EPEL)
-> wget -O /etc/yum.repos.d/epel-erlang.repo http://repos.fedorapeople.org/repos/peter/erlang/epel-erlang.repo
-> yum install erlang

– rabbitmq
-> http://www.rabbitmq.com/install-rpm.html
-> http://www.rabbitmq.com/releases/rabbitmq-server/v3.3.4/rabbitmq-server-3.3.4-1.noarch.rpm
-> sudo rpm -Uvh rabbitmq-server-3.3.4-1.noarch.rpm

3. 설정
– /etc/hosts 호스트를 등록한다.
– /etc/rabbitmq/rabbitmq.config

[
   {mnesia, [{dump_log_write_threshold, 1000}]},
   {rabbit, [
       {tcp_listeners, [5672]},
        {log_levels, [{connection, info}]}
   ]},
   {rabbitmq_management, [
       {listener,[{port, 55672}]},
       {redirect_old_port, false}
   ]}
 ].

– .erlang.cookie 설정(/var/lib/rabbitmq/.erlang.cookie)
-> 동일하게 맞춘다.

4. rabbitmq 설정(rabbit1, rabbit2 서버 두대인 경우)
– rabbit1> sudo service rabbitmq-server start
– rabbit2> sudo service rabbitmq-server start
– rabbit2> sudo rabbitctl stop_app
– rabbit2> sudo rabbitmqctl join_cluster –ram rabbit@indigo117
– rabbit2> sudo rabbitmqctl change_cluster_node_type disc
– rabbit2> sudo rabbitmqctl start_app
– rabbit1> sudo rabbitmqctl cluster_status
– rabbit2> sudo rabbitmqctl cluster_status
– rabbit1> sudo rabbitmqctl set_policy ha-all “^\.” ‘{“ha-mode”:”all”}’

5. web plugin(all node)
– rabbitmq-plugins enable rabbitmq_management
– sudo service rabbitmq-server restart

6. 유저 설정
– sudo rabbitmqctl delete_user guest
– sudo rabbitmqctl add_user test 1234
– sudo rabbitmqctl set_user_tags test administrator

7. http://servername:55672/

8. example
– pip install pika

#!/usr/bin/env python
import pika

#Very Important!!!
credentials = pika.PlainCredentials("id", "password")
connection = pika.BlockingConnection(pika.ConnectionParameters(
        host='localhost', credentials=credentials))

channel = connection.channel()
channel.queue_declare(queue='hello')

channel.basic_publish(exchange='',
                      routing_key='hello',
                      body='Hello World!')
print " [x] Sent 'Hello World!'"
connection.close()

[입 개발] mecab 설치 with 은전한닢 (mac)

$
0
0

1. Download Files
– 은전한닢 mecab & dic
* https://bitbucket.org/eunjeon/mecab-ko
* https://bitbucket.org/eunjeon/mecab-ko-dic/downloads/mecab-ko-dic-1.6.1-20140515.tar.gz
– python binding
* https://bitbucket.org/eunjeon/mecab-python-0.996

2. Build
– mecab-ko
* ./configure
* make
* make install

3. mecab-ko-dic
* ./autogen.sh
* ./configure
* make
* make install

4. python binding
* python setup.py build
* python setup.py install

5. Test

import MeCab
m = MeCab.Tagger('-d /usr/local/lib/mecab/dic/mecab-ko-dic')
ret = m.parse("아버지가 방에 들어가신다.")

아버지	NNG,*,F,아버지,*,*,*,*,*
가	JKS,*,F,가,*,*,*,*,*
방	NNG,*,T,방,*,*,*,*,*
에	JKB,*,F,에,*,*,*,*,*
들어가	VV,*,F,들어가,*,*,*,*,*
신다	EP+EF,*,F,신다,Inflect,EP,EF,시/EP+ᆫ다/EF,*
.	SF,*,*,*,*,*,*,*,*
EOS


[입 개발] hadoop fs -tail [-f] URI 의 구현에 대해서

$
0
0

가끔씩 보면 tail -f 는 어떻게 동작할지에 대해서 궁금할때가 생깁니다. 얘가 무슨 수로… 뒤에를 계속 읽어올까?
OS에서 제공하는 notify 관련 함수를 이용할까? 등의 별별 생각을 해보지만… 사실 백가지 생각이 불여일견입니다.

hadoop cmd 는 각각의 command 클래스를 이용하는 CommandPattern 형태로 되어있습니다. 그러나 여기서 관심있는 것은 오직 tail 뿐… 그런데, 너무나 간단하게 Tail 소스만 까면… 끝납니다.

1. -f 옵션이 붙으면, 무한루프를 돈다.(파일 사이즈를 계산해서 offset 보다 적으면 종료)
2. 한번 looping 후에 followDelay 만큼 sleep 한다.
3. 기본적으로 현재 파일 사이즈의 끝 – 1024 만큼의 offset 부터 읽는다.
4. 한번에 읽어들이는 내용은 conf 에 정의되어 있다.(여기서 사이즈가 1024보다 적으면… 음… 맨 뒤가 아닐수도 있는데, 이부분은 뭔가 최저값이 셋팅되지 않을까?)

class Tail extends FsCommand {
  public static void registerCommands(CommandFactory factory) {
    factory.addClass(Tail.class, "-tail");
  }
..
  public static final String NAME = "tail";
  public static final String USAGE = "[-f] <file>";
  public static final String DESCRIPTION =
    "Show the last 1KB of the file.\n" +
    "\t\tThe -f option shows appended data as the file grows.\n";

  private long startingOffset = -1024;
  private boolean follow = false;
  private long followDelay = 5000; // milliseconds

  @Override
  protected void processOptions(LinkedList<String> args) throws IOException {
    CommandFormat cf = new CommandFormat(1, 1, "f");
    cf.parse(args);
    follow = cf.getOpt("f");
  }
  // TODO: HADOOP-7234 will add glob support; for now, be backwards compat
  @Override
  protected List<PathData> expandArgument(String arg) throws IOException {
    List<PathData> items = new LinkedList<PathData>();
    items.add(new PathData(arg, getConf()));
    return items;
  }

  @Override
  protected void processPath(PathData item) throws IOException {
    if (item.stat.isDirectory()) {
      throw new PathIsDirectoryException(item.toString());
    }

    long offset = dumpFromOffset(item, startingOffset);
    while (follow) {
      try {
        Thread.sleep(followDelay);
      } catch (InterruptedException e) {
        break;
      }
      offset = dumpFromOffset(item, offset);
    }
  }

  private long dumpFromOffset(PathData item, long offset) throws IOException {
    long fileSize = item.refreshStatus().getLen();
    if (offset > fileSize) return fileSize;
    // treat a negative offset as relative to end of the file, floor of 0
    if (offset < 0) {
      offset = Math.max(fileSize + offset, 0);
    }
....
    FSDataInputStream in = item.fs.open(item.path);
    try {
      in.seek(offset);
      // use conf so the system configured io block size is used
      IOUtils.copyBytes(in, System.out, getConf(), false);
      offset = in.getPos();
    } finally {
      in.close();
    }
    return offset;
  }
}

[입 개발] Redis Montoring 바보 되기…

$
0
0

오늘은 레디스 모니터링 중에 바보된 사연을 소개합니다.

resque 나 sidekiq 같은 Redis를 Queue 로 사용하면서 겪은 삽질입니다.(개인적으로 둘 중에 하나를 추천하라면, sidekiq을 강력하게 추천합니다. 일단 sentinel과 연동된 failover 기능도 있고, resque가 lpop을 쓰는데 비해 sidekiq은 blpop을 써서 네트웍이나 redis 의 resource를 훨씬 적게 사용합니다.)

보통 위와 같이 redis를 queue로 사용할 때 redis network 의 input size 가 output size 보다 크거나 비슷해야 합니다. 왜냐하면 queue 이기 때문에 input == output 입니다. request 는 rpush queue_name data 인데, response 는 lpop queue_name + data 이니, 그런데… output 이 훨씬 많은 경우가 발생했습니다.

특정 기능이 동작하는지 보기 위해서 redis monitor 명령을 사용했습니다.

이유가 뭘까 고민을 하면서 다음과 같은 가정을 몇가지 해봤습니다.
1. 에러가 나서 리턴 스트링이 더 많다?
예를 들어 rdb 장애로 쓰기 금지 상태이면 ping 만 보내도 “MISCONF 어쩌고” 하는 에러 메시지가 전달됩니다.

그런데 이건 아니더군요.

그런데 의외로 간단하고 멍청한 이슈였습니다.

위의 기능을 확인하기 위한 redis monitor를 걸었더니… 그것도 외부에서 redis monitor | grep “1234” 식으로 했더니… 해당 서버에서 모니터링 메시지가 급증했던 것입니다. 실제로 모든 데이터가 넘어오니…

또한 해당 서버로 들어가서 redis monitor만 해도 실제로 ssh를 통해서 모니터링 메시지를 모두 보기 때문에, network outbound 가 늘어납니다. redis monitor | grep “1234” 이렇게 해당 장비 내부에서 하면 거의 늘어나지 않겠죠?

바보 같은 삽질기이지만… 웬지 다음에 또 실수할까 봐서 올립니다.


[입 개발] Redis Replication 이 실패하는 경우에 살펴 보아야 할 것들…

$
0
0

Redis 에서 Replication은 매우 핫 한 기능입니다. 다만, 메모리를 많이 쓰고 있는 경우에, Replication을 새로 걸어서 새로운 슬레이브를 추가하고 싶다면, Replication이 실패하는 경우가 생길 수 있습니다. 이 때, 어떤 것들을 보아야 할지… 알아보도록 하겠습니다.

1. 디스크의 여유 공간을 확인한다.
-> 의외로 쉽게 발생할 수 있는 이슈입니다. Redis는 Default로 fork 후에 메모리의 내용들을 압축해서 RDB로 저장하게 됩니다. 즉, 디스크에 여유 공간이 없으면 실패하게 됩니다. 이 때는 뭐, 간단하게 디스크 여유 공간을 만들어줌으로써, 해결할 수 있습니다.

3.0에서 추가될것으로 보이는 것 중에, 기존에는 Replication을 위해서 RDB 파일을 로컬에 저장하고 이를 읽어서 전달하는 방식이었는데, 이제, 파일을 저장하지 않고 메모리 상황에서 바로 보내는 기능이 추가되는 중이라서, 이 이슈는 점점 없어질 것으로 보입니다.

다만 2.8까지라면… 마스터/슬레이브 모두 디스크 여유 공간을 확인해야 합니다.

2. 디렉토리의 퍼미션을 확인한다.
-> 위와 같은 이유지만, 퍼미션 역시 확인해야 합니다. 이 경우 RDB 파일을 저장하지 못해서, 계속 마스터/슬레이브 연결이 실패하게 됩니다. 이때 재미난 상황은 슬레이브에서는 마스터랑 연결되었다고 나오지만… 마스터는 슬레이브가 연결된걸 확인하지 못합니다. SYNC 명령이 안끝나서 그렇게 인식이 되는… 재미난…

3. 메모리 사이즈를 확인한다.
-> 보통 버추얼 메모리라는 것은 물리 메모리 + Swap 사이즈를 말합니다. 이 크기를 넘어가면 프로세스가 죽을 수 있는데, vm.overcommit_memory 를 설정함으로써, 회피할 수 있습니다. 다만 이것도 너무 크기가 크면 실패할 수 있습니다.

sysctl vm.overcommit_memory=1

[입 개발] Memcached 의 delete 에 대한 변천사…

$
0
0

최근에 twemproxy 에서 delete 가 제대로 안된다는 제보를 듣고, 코드를 살펴보길 시작했었는데요. 여기서 재미난 것을 발견해서 포스팅을 할려고 합니다.(오래간만의 포스팅이네요.)

먼저 최근의 memcached 소스의 delete 를 보면 다음과 같습니다. 코드를 보면 ntokens 가 3보다 클 경우, 뒤에 0이 나오거나 noreply 가 있을때만 허용이 되고 있습니다.

즉 다음과 같은 경우가 허용이 됩니다. 다만 number는 0만 허용이 됩니다.

delete <key>
delete <key> <number>
delete <key> <noreply>
delete <key> <number> <noreply>

다음 코드를 보면… 쉽게 이해가 되실껍니다.

static void process_delete_command(conn *c, token_t *tokens, const size_t ntokens) {
    char *key;
    size_t nkey;
    item *it;

    assert(c != NULL);

    if (ntokens > 3) {
        bool hold_is_zero = strcmp(tokens[KEY_TOKEN+1].value, "0") == 0;
        bool sets_noreply = set_noreply_maybe(c, tokens, ntokens);
        bool valid = (ntokens == 4 && (hold_is_zero || sets_noreply))
            || (ntokens == 5 && hold_is_zero && sets_noreply);
        if (!valid) {
            out_string(c, "CLIENT_ERROR bad command line format.  "
                       "Usage: delete <key> [noreply]");
            return;
        }
    }

    key = tokens[KEY_TOKEN].value;
    nkey = tokens[KEY_TOKEN].length;

    if(nkey > KEY_MAX_LENGTH) {
        out_string(c, "CLIENT_ERROR bad command line format");
        return;
    }

    if (settings.detail_enabled) {
        stats_prefix_record_delete(key, nkey);
    }

    it = item_get(key, nkey);
    if (it) {
        MEMCACHED_COMMAND_DELETE(c->sfd, ITEM_key(it), it->nkey);

        pthread_mutex_lock(&c->thread->stats.mutex);
        c->thread->stats.slab_stats[it->slabs_clsid].delete_hits++;
        pthread_mutex_unlock(&c->thread->stats.mutex);

        item_unlink(it);
        item_remove(it);      /* release our reference */
        out_string(c, "DELETED");
    } else {
        pthread_mutex_lock(&c->thread->stats.mutex);
        c->thread->stats.delete_misses++;
        pthread_mutex_unlock(&c->thread->stats.mutex);

        out_string(c, "NOT_FOUND");
    }
}

그런데 java의 spymemcached 나 ruby 라이브러리를 보면 실제 위의 파트를 보내지
않습니다. 그런데 python의 경우는 time을 지정할 경우는 값이 전달되면서 실제로
memcached에서 delete가 실패하게 됩니다. 디폴트로는 0이 전달되게 되므로 사실 큰 문제는 없습니다.

    def delete_multi(self, keys, time=0, key_prefix='', noreply=False):
        """Delete multiple keys in the memcache doing just one query.
        >>> notset_keys = mc.set_multi({'a1' : 'val1', 'a2' : 'val2'})
        >>> mc.get_multi(['a1', 'a2']) == {'a1' : 'val1','a2' : 'val2'}
        1
        >>> mc.delete_multi(['key1', 'key2'])
        1
        >>> mc.get_multi(['key1', 'key2']) == {}
        1
        This method is recommended over iterated regular L{delete}s as
        it reduces total latency, since your app doesn't have to wait
        for each round-trip of L{delete} before sending the next one.
        @param keys: An iterable of keys to clear
        @param time: number of seconds any subsequent set / update
        commands should fail. Defaults to 0 for no delay.
        @param key_prefix: Optional string to prepend to each key when
            sending to memcache.  See docs for L{get_multi} and
            L{set_multi}.
        @param noreply: optional parameter instructs the server to not send the
            reply.
        @return: 1 if no failure in communication with any memcacheds.
        @rtype: int
        """

        self._statlog('delete_multi')

        server_keys, prefixed_to_orig_key = self._map_and_prefix_keys(
            keys, key_prefix)

        # send out all requests on each server before reading anything
        dead_servers = []

        rc = 1
        for server in six.iterkeys(server_keys):
            bigcmd = []
            write = bigcmd.append
            extra = ' noreply' if noreply else ''
            if time is not None:
                for key in server_keys[server]:  # These are mangled keys
                    write("delete %s %d%s\r\n" % (key, time, extra))
            else:
                for key in server_keys[server]:  # These are mangled keys
                    write("delete %s%s\r\n" % (key, extra))
            try:
                server.send_cmds(''.join(bigcmd))
            except socket.error as msg:
                rc = 0
                if isinstance(msg, tuple):
                    msg = msg[1]
                server.mark_dead(msg)
                dead_servers.append(server)

        # if noreply, just return
        if noreply:
            return rc

        # if any servers died on the way, don't expect them to respond.
        for server in dead_servers:
            del server_keys[server]

        for server, keys in six.iteritems(server_keys):
            try:
                for key in keys:
                    server.expect("DELETED")
            except socket.error as msg:
                if isinstance(msg, tuple):
                    msg = msg[1]
                server.mark_dead(msg)
                rc = 0
        return rc

그런데 이게 twemproxy 에서는 문제를 일으킵니다. 현재 twemproxy는 다음과 같은 룰만 허용합니다.

delete <key>

즉 뒤에 0이 붙으면… 그냥 잘라버립니다. T.T 그래서 실제로 python-memcache를 쓸 경우, delete_multi를 하면 실제 delete가 안되는 거죠. 흑흑흑…

그런데… 여기서 이유를 찾은게 문제가 아니라… 왜 이 코드가 있을까요? 이 의문을 가진건 위의 memcached 코드에 0이 아니면 에러가 나게 된 코드가… github을 보면… 이 코드가 2009년 11월 25일에 커밋된 코드라는 겁니다.(지금이 2014년인데!!!)

그래서 memcached 코드를 1.4.0 부터 현재 버전까지 뒤져봤습니다. 해당 코드는 그대로입니다. -_- 약간 변경이 있긴한데… 위의 time을 쓰는 코드는 아니었습니다. 그럼 이건 뭐지 할 수 있습니다.

실제로 1.2.7 소스를 보니 defer_delete 라고 해서 시간을 주면 해당 시간 뒤에 삭제되는 명령이 있었습니다. 정확히는 to_delete라는 곳에 넣어두고, item의 expire time을 지워져야할 시간으로 셋팅해주는 겁니다.

그러니 그 관련 코드가 아직까지도 python-memcache에 생존해 있던 것이죠 T.T 흑흑흑, 아마 현재는 거의 아무도 안쓰지 않을가 싶은… 다른 언어도 만들어서 쓸 수 있지만… 최신 버전의 memcached에서는 불가능 하다는…

그냥 이런 이슈가 있다는 걸… 알고 넘어가시면 좋을듯 합니다. 실제로 delete key time noreply 로 현재의 memcached에 호출하면, 실제로는 내부적으로 에러로 처리되서 실제로 안지워지지만, 응답은 먹히는 버그가 있습니다만… 이건 noreply 명령이 여러개일때 실제로 어는것이 에러가 난지를 알 수가 없기 때문에 그냥 known issue로 하고… 못 고친다는…


[Python] Simple Fabric 코드…

$
0
0

간단하게 Fabric 을 좀 봤는데… 좋네요. 다음과 같은 형태로 사용할 수 있는데, 다음과 같이 사용할 수 있습니다.

sudo pip install fabric
from fabric import tasks
from fabric.state import env
from fabric.api import run

def uptime():
    res = run('uptime')
    return res

ret = tasks.execute(uptime)
print ret

위에서 uptime() 에서 return 을 한 결과가 밑에 json 형태로 보여줍니다. 그것들이 tasks.execute의 결과에
호스트별로 맵 형태로 결과가 넘어옵니다.

[localhost] Executing task 'uptime'
[localhost] run: uptime
[localhost] out: 22:13  up 16 days, 21:25, 9 users, load averages: 1.55 1.88 1.96
[localhost] out:

{'localhost': '22:13  up 16 days, 21:25, 9 users, load averages: 1.55 1.88 1.96'}

[입 개발] Java.net.InetAddress 의 getLocalHost() 버그…

$
0
0

모 오픈소스를 실행시키다가 이상한 일이 생겨서, 버그인가 하고 보다가… 재미난 현상을 발견했습니다. Java.net.InetAddress 가 뭔가 이상한 결과를 넘겨주는 것입니다. 먼저… 간단한 소스를 보시죠. 테스트 프로그램은 다음과 같습니다.(결론부터 말하자면… 자바의 버그라고 할 수는 없습니다. ㅋㅋㅋ, DNS 변경으로 일단 원하는 결과가 나오는 ㅎㅎㅎ)

* 결론적으로는 U+등이 디지털 네임스랑 계약을 맺고, 분석이 되지 않는 도메인을 디지털네임스로 질의하고, 이를 디지털 네임스에서 키워드로 등록되었거나, 등록되지 않은 주소를 자신의 ip등으로 돌려줘서 발생하는 이슈로 추측되고 있습니다.)

import java.io.*;
import java.util.*;
import java.net.InetAddress;
import java.net.UnknownHostException;

public class Test {
  public static void main(String [] args) {
    try {
      System.out.println(InetAddress.getLocalHost());
    } catch(UnknownHostException var1) {
      System.out.println(&quot;Exception : &quot; + var1);
    }
  }
}

그런데 그 결과가 다음과 같습니다. -_-

charsyam ~/works/test $ java Test
charsyam.local/218.38.137.28
charsyam ~/works/test $ java Test
charsyam.local/218.38.137.28
charsyam ~/works/test $ java Test
charsyam.local/192.168.1.7
charsyam ~/works/test $ java Test
charsyam.local/192.168.1.7

네, getLocalHost()를 호출한 결과가 218.38.137.28 이거나 192.168.1.7이 나옵니다. 실제 저희 집의 네트웍은 공유기 밑에 접속이 되는 것이라, 192.168.1.7이 기대한 값입니다. 혹시나 외부 아이피인가 해서 확인해도 제 공유기가 가진 아이피도, 위의 218.38.137.28 값은 아니었습니다. 전혀 상관 없는 값이죠.

그런데 재미있는 것은 이 것은 단순히 자바의 문제는 아니라는 것입니다.
dig/nslookup 으로 해본 결과입니다.

nslookup 결과

charsyam ~ $ nslookup Macintosh-7.local
Server:		192.168.1.1
Address:	192.168.1.1#53

Name:	Macintosh-7.local
Address: 218.38.137.28

dig 결과

charsyam ~ $ dig Macintosh-7.local

; &lt;&lt;&gt;&gt; DiG 9.8.3-P1 &lt;&lt;&gt;&gt; Macintosh-7.local
;; global options: +cmd
;; Got answer:
;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NOERROR, id: 40380
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;Macintosh-7.local.		IN	A

;; ANSWER SECTION:
Macintosh-7.local.	3600	IN	A	218.38.137.28

;; Query time: 5 msec
;; SERVER: 192.168.1.1#53(192.168.1.1)
;; WHEN: Sun Dec 28 23:44:09 2014
;; MSG SIZE  rcvd: 51

네 DNS 결과가 이상합니다. 흑흑흑, 일단 네임서버가 뭔가 이상한 결과를 던져주는 건 일단 확실한데… 제가 보기엔 여기에 설정된 네임서버가 이상하고, 여기서 질의도 이상한데, 거기에 대해서 이상한 결과를 주는 듯 합니다.

그럼 왜 위의 getLocalHost()의 결과가 저럴까요? 이름만 보면 localhost를 줘야 할 것 같은데… 그게 아닙니다.
이제 Java 소스를 까보도록 하겠습니다. 저부분만…

코드를 해석하면 처음에 LocalHostName을 가져옵니다. 저의 경우는 Macintosh-7.local 이겠죠.
그리고 위의 값이 localhost면 그냥 루프백 주소를 줍니다. 즉 이러면 127.0.0.1 이나 정상적인 값이 갈듯 합니다.
그리고 그 호스트네임을 이용해서 InetAddress.getAddressesFromNameService 을 이용해서 InetAddress를 가져오는데, 여기서 뭔가 해당 도메인 파싱이 잘못되고, 도메인 네임서버로 가서 이상한 결과가 오는게 아닌가 싶습니다.

    public static InetAddress getLocalHost() throws UnknownHostException {

        SecurityManager security = System.getSecurityManager();
        try {
            String local = impl.getLocalHostName();

            if (security != null) {
                security.checkConnect(local, -1);
            }

            if (local.equals(&quot;localhost&quot;)) {
                return impl.loopbackAddress();
            }

            InetAddress ret = null;
            synchronized (cacheLock) {
                long now = System.currentTimeMillis();
                if (cachedLocalHost != null) {
                    if ((now - cacheTime) &lt; maxCacheTime) // Less than 5s old?
                        ret = cachedLocalHost;
                    else
                        cachedLocalHost = null;
                }

                // we are calling getAddressesFromNameService directly
                // to avoid getting localHost from cache
                if (ret == null) {
                    InetAddress[] localAddrs;
                    try {
                        localAddrs =
                            InetAddress.getAddressesFromNameService(local, null);
                    } catch (UnknownHostException uhe) {
                        // Rethrow with a more informative error message.
                        UnknownHostException uhe2 =
                            new UnknownHostException(local + &quot;: &quot; +
                                                     uhe.getMessage());
                        uhe2.initCause(uhe);
                        throw uhe2;
                    }
                    cachedLocalHost = localAddrs[0];
                    cacheTime = now;
                    ret = localAddrs[0];
                }
            }
            return ret;
        } catch (java.lang.SecurityException e) {
            return impl.loopbackAddress();
        }
    }

참고로 getAddressesFromNameService 는 다음과 같이 DNS 프로바인더를 이용해서 실제 DNS쿼리를 하게 됩니다.

    private static InetAddress[] getAddressesFromNameService(String host, InetAddress reqAddr)
        throws UnknownHostException
    {
        InetAddress[] addresses = null;
        boolean success = false;
        UnknownHostException ex = null;

        // Check whether the host is in the lookupTable.
        // 1) If the host isn't in the lookupTable when
        //    checkLookupTable() is called, checkLookupTable()
        //    would add the host in the lookupTable and
        //    return null. So we will do the lookup.
        // 2) If the host is in the lookupTable when
        //    checkLookupTable() is called, the current thread
        //    would be blocked until the host is removed
        //    from the lookupTable. Then this thread
        //    should try to look up the addressCache.
        //     i) if it found the addresses in the
        //        addressCache, checkLookupTable()  would
        //        return the addresses.
        //     ii) if it didn't find the addresses in the
        //         addressCache for any reason,
        //         it should add the host in the
        //         lookupTable and return null so the
        //         following code would do  a lookup itself.
        if ((addresses = checkLookupTable(host)) == null) {
            try {
                // This is the first thread which looks up the addresses
                // this host or the cache entry for this host has been
                // expired so this thread should do the lookup.
                for (NameService nameService : nameServices) {
                    try {
                        /*
                         * Do not put the call to lookup() inside the
                         * constructor.  if you do you will still be
                         * allocating space when the lookup fails.
                         */

                        addresses = nameService.lookupAllHostAddr(host);
                        success = true;
                        break;
                    } catch (UnknownHostException uhe) {
                        if (host.equalsIgnoreCase(&quot;localhost&quot;)) {
                            InetAddress[] local = new InetAddress[] { impl.loopbackAddress() };
                            addresses = local;
                            success = true;
                            break;
                        }
                        else {
                            addresses = unknown_array;
                            success = false;
                            ex = uhe;
                        }
                    }
                }

                // More to do?
                if (reqAddr != null &amp;&amp; addresses.length &gt; 1 &amp;&amp; !addresses[0].equals(reqAddr)) {
                    // Find it?
                    int i = 1;
                    for (; i &lt; addresses.length; i++) {
                        if (addresses[i].equals(reqAddr)) {
                            break;
                        }
                    }
                    // Rotate
                    if (i &lt; addresses.length) {
                        InetAddress tmp, tmp2 = reqAddr;
                        for (int j = 0; j &lt; i; j++) {
                            tmp = addresses[j];
                            addresses[j] = tmp2;
                            tmp2 = tmp;
                        }
                        addresses[i] = tmp2;
                    }
                }
                // Cache the address.
                cacheAddresses(host, addresses, success);

                if (!success &amp;&amp; ex != null)
                    throw ex;

            } finally {
                // Delete host from the lookupTable and notify
                // all threads waiting on the lookupTable monitor.
                updateLookupTable(host);
            }
        }

        return addresses;
    }

2014 year review…

$
0
0

2014년을 돌이켜보면… 역시 가장 먼저 생각나고… 가장 중요한 건… 저희집의 새로운 식구이자, 축복이된 2세의 탄생입니다. 작년에는 뱃속에 생긴것이 최고의 일이었는데… 올해는 태어나고, 내년에는 잘 키우는 가장 큰 숙제이지 않을까 싶네요.

일단 올해의 키워드를 뽑아보자면, 애기를 제외하고… “애아빠의 삶”, “어떻게 애를 쉽게 보나?” 이런것들이 있겠지만… 근엄한 모드로 돌아와서… “오픈소스” 와 “일” 이 아닐까 싶습니다.

먼저 “일” 이라는 키워드를 뽑은 이유는… 올해 초에 “카카오 스토리” 서버 개발자로 보직이 바뀌었는데, 서비스를 다시 하다보니… 재미난 것들이 많습니다. 타이밍이 좋았다고 해야할지… 나쁘다고 해야할지… Ruby -> Java 로 언어 전환을 하는 시기로 들어가서, 실제 전환으로 인한 장단점을 느끼게 되었고, Ruby를 조금 배우게 된것과, 배포 시스템, Rspec을 이용한 리그레이션 테스트 구축이라든지… “Redis”, “Arcus” 등으로, 조금 더 성능 이슈를 만나봤다든지… 장단점을 더 잘 느껴본… 자바 1.7 -> 1.8로의 변환으로 성능 개선(자바는 모르지만…)과, 또 장애도 만나게 되고, 그러면서, 장애 대응이나 발견을 어떻게 해볼 것인가에 대해서도 고민을 해볼 수 있는 시간이었네요. 뭐, 다 제가 한것도 아니고, 팀 분들이 하신걸 그냥 주워 듣거나, 옆에서 하는 걸 끼어서 도와주기만 했지만… 나름 많이 공부한 한해이네요. 역시, 회사를 다녀야 배우는게 늘어나는듯 합니다.

지금 있는 곳이, 여러가지를 해볼 수 있는 환경과 지원이 있는 회사라, 2015년에는 지금까지 했던 것들 위주로, 하나씩 개선시켜 본다거나 하는걸 해봐야 하지않을까 싶습니다.(잉여가 남는다면…)

두번째는 “오픈 소스”입니다. 이것저것 많이 건드리는 오픈소스계의 하이에나로서, 이것 저것 많이 건드린것 같지만… 연말에 집중하게 된건 “twemproxy”, “tajo” 두 가지 입니다. 뭐, 꼭 그러려고 하는 건 아니지만, 주로 관심을 두는게 Data Storage Layer쪽이라… 아마 내년에는 지금 보고 있는 것들에 postgresql를 좀 보지 않을까 싶습니다.(mysql 에 비해서는 확실히 코드가 깔끔한 ㅋㅋㅋ) 뭐, 일단 말은 이렇게 하지만… 워낙 그때 그때 바뀌어서, 딴걸 볼지도…

다시 개인적으로 돌아가자면, “건강”과 “영어”를 다시 뽑아봅니다. 애도 생겼으니… 건강에 신경을 써야 하는데, 제가 워낙 몸이 불량품인지라… 운동을 해서 건강을 좀 찾아야 할듯 합니다. 흑흑흑 일찍 일어나서 운동을… T.T

그리고 영어는… 올해 짧게 외국인과 얘기할 기회가 있었는데… 뭐라고 하는지 한마디도 못알아듣겠더라는… 흑흑흑, 뭔가 제대로 읽고 이해하고, 떠듬 떠듬 질문하고 이해할 수 있는 영어실력이 되면 좋겠네요.

흑흑흑, 리뷰라기 보다는… 그냥 올 한해를 보고, 내년 한해의 희망을 적었습니다. 흑흑흑… 잘 되기를…



[입 개발] Redis Scan은 어떻게 동작할까? PART #1

$
0
0

처음부터 꾸준히 입만 놀리는 입개발(or 혀로그래머) CharSyam입니다. Redis의 기능중에 사람들이 쓰면 안되지만, 그 단맛에 끌려 어쩔 수 없이 치게 되는 명령이 KEYS 입니다. KEYS를 쓰는 순간, Redis는 이 명령을 처리하기 위해서 멈춰버립니다. 특히 트래픽이 많은 서버는… 이 KEYS 명령 하나에 많은 장애를 내게 됩니다. 그런데… 어느 순간… Redis에 Scan이라는 명령이 생겼습니다. KEYS의 단점을 없애면서도, 느리지 않은… 그렇다면, Redis에서는 어떻게 Scan 이 동작하게 될까요?

Scan의 내부 동작을 알기 전에… 먼저 Redis가 어떻게 데이터를 저장하는지 부터 다시 한번 집고 넘어가야 합니다. Redis 의 가장 기초적인 자료구조는 KV 즉 Key/Value 형태를 저장하는 것입니다.(String 타입이라고도 합니다.) 이를 위해 Redis는 Bucket을 활용한 Chained Linked List 구조를 사용합니다. 최초에는 4개의 Bucket에서 사용하여… 같은 Bucket에 들어가는 Key는 링크드 리스트 형태로 저장하는 거죠. 즉 다음 그림과 같습니다.

redis_hash_1

이 Chained Linked List에는 다음과 같은 약점이 있습니다. 즉 한 Bucket 안에 데이터가 많아지면 결국 탐색 속도가 느려집니다. 이를 위해서 Redis는 특정 사이즈가 넘을때마다 Bucket을 두 배로 확장하고, Key들을 rehash 하게 됩니다. 먼저 이 때 Key의 Hash로 사용하는 해시함수는 다음과 같습니다. MurmurHash2를 사용합니다.

/* MurmurHash2, by Austin Appleby
 * Note - This code makes a few assumptions about how your machine behaves -
 * 1. We can read a 4-byte value from any address without crashing
 * 2. sizeof(int) == 4
 *
 * And it has a few limitations -
 *
 * 1. It will not work incrementally.
 * 2. It will not produce the same results on little-endian and big-endian
 *    machines.
 */
unsigned int dictGenHashFunction(const void *key, int len) {
    /* 'm' and 'r' are mixing constants generated offline.
     They're not really 'magic', they just happen to work well.  */
    uint32_t seed = dict_hash_function_seed;
    const uint32_t m = 0x5bd1e995;
    const int r = 24;

    /* Initialize the hash to a 'random' value */
    uint32_t h = seed ^ len;

    /* Mix 4 bytes at a time into the hash */
    const unsigned char *data = (const unsigned char *)key;

    while(len >= 4) {
        uint32_t k = *(uint32_t*)data;

        k *= m;
        k ^= k >> r;
        k *= m;

        h *= m;
        h ^= k;

        data += 4;
        len -= 4;
    }

    /* Handle the last few bytes of the input array  */
    switch(len) {
    case 3: h ^= data[2] << 16;
    case 2: h ^= data[1] << 8;
    case 1: h ^= data[0]; h *= m;
    };

    /* Do a few final mixes of the hash to ensure the last few
     * bytes are well-incorporated. */
    h ^= h >> 13;
    h *= m;
    h ^= h >> 15;

    return (unsigned int)h;
}

그리고 hash 값이 들어가야 할 hash table 내의 index를 결정하는 방법은 다음과 같습니다.

/* Returns the index of a free slot that can be populated with
 * a hash entry for the given 'key'.
 * If the key already exists, -1 is returned.
 *
 * Note that if we are in the process of rehashing the hash table, the
 * index is always returned in the context of the second (new) hash table. */
static int _dictKeyIndex(dict *d, const void *key)
{
    ......
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;
        ......
    }
    return idx;
}

table에는 Key를 찾기위해 비트 연산을 하기 위한 sizemask가 들어가 있습니다. 초기에는 table의 bucket이 4개 이므로 sizemask는
이진수로 11 즉 3의 값이 셋팅됩니다. 즉 해시된 결과 & 11의 연산결과로 들어가야 하는 Bucket이 결정되게 됩니다.

여기서 Key 가 많아지면 Redis는 Table의 사이즈를 2배로 늘리게 됩니다. 그러면 당연히 sizemask도 커지게 됩니다. Table size가 8이면 sizemask는 7이 됩니다.

먼저 간단하게 말하자면, Scan의 원리는 이 Bucket을 한 턴에 하나씩 순회하는 것입니다. 그래서 아래 그림과 같이 처음에는 Bucket Index 0를 읽고 데이터를 던져주는 것입니다.

redis_scan_0

        t0 = &(d->ht[0]);
        m0 = t0->sizemask;

        /* Emit entries at cursor */
        de = t0->table[v & m0];
        while (de) {
            fn(privdata, de);
            de = de->next;
        }

다음 편에서는 실제 사용되는 cursor 가 어떤 식으로 만들어지는 지, 그 외의 예외 케이스는 어떤 것이 있는지… 그리고 scan 을 사용할 때 주의사항에는 어떤 것이 있는지 알아보도록 하겠습니다.(이거 적고, 안 쓸지도 ㅎㅎㅎ)


[입개발] Redis Scan은 어떻게 동작할까? PART #2

$
0
0

지난 Part #1 에서는 기본적인 Redis 의 Scan 동작과 테이블에 대해서 알아보았습니다. 이번에는 Redis Scan의 동작을 더 분석하기 위해서 기본적으로 Redis Hash Table의 Rehash 과 그 상황에서 Scan이 어떻게 동작하는지 알아보도록 하겠습니다.

먼저, Redis Hash Table의 Rehashing에 대해서 알아보도록 하겠습니다. 전 편에서도 간단하게 언급했지만 Redis Hash Table은 보통 Dynamic Bucket 에 충돌은 list 로 처리하는 방식입니다.

redis_hash_1

처음에는 4개의 Bucket으로 진행하면 Hash 값에 bitmask를 씌워서 Hash Table 내의 index를 결정합니다. 그런데, 이대로 계속 데이터가 증가하면, 당연히 충돌이 많고, List가 길어지므로, 탐색 시간이 오래걸리게 되어서 문제가 발생합니다. Redis는 이를 해결하기 위해서 hash table의 사이즈를 2배로 늘리는 정책을 취합니다.

redis_hash_expand

2배로 테이블이 늘어나면서, bitmask는 하나더 사용하도록 됩니다. 이렇게 테이블이 확장되면 Rehash를 하게 됩니다. 그래야만 검색시에 제대로 찾을 수 있기 때문입니다. 먼저 Table을 확장할 때 사용하는 것이 _dictExpandIfNeeded 합수입니다. dictIsRehashing는 이미 Rehash 중인지를 알려주는 함수이므로, Rehashing 중이면 이미 테이블이 확장된 상태이므로 그냥 DICT_OK를 리턴합니다.

먼저 hash table에서 hash table의 사용 정도가 dict_force_resize_ratio 값 보다 높으면 2배로 확장하게 됩니다.

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    if (dictIsRehashing(d)) return DICT_OK;

    /* If the hash table is empty expand it to the initial size. */
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /* If we reached the 1:1 ratio, and we are allowed to resize the hash
     * table (global setting) or we should avoid it but the ratio between
     * elements/buckets is over the "safe" threshold, we resize doubling
     * the number of buckets. */
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
    {
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

실제로 _dictExpandIfNeeded 는 _dictKeyIndex 함수에서 호출하게 됩니다. 이렇게 테이블이 확장되면 Rehash를 해야 합니다. Rehash 라는 것은 테이블의 Bucket 크기가 커졌고 bitmask 가 달라졌으니… mask 0011이 전부 3번째 index였다면 이중에서 111은 7번째로, 011은 3번째로 옮기는 것입니다. 여기서 Redis의 특징이 하나 있습니다. 한꺼번에 모든 테이블을 Rehashing 해야 하면 당연히 시간이 많이 걸립니다. O(n)의 시간이 필요합니다. 그래서 Redis는 rehash flag와 rehashidx라는 변수를 이용해서, hash table에서 하나씩 Rehash하게 됩니다. 즉, 확장된 크기가 8이라면 이전 크기 총 4번의 Rehash 스텝을 통해서 Rehashing이 일어나게 됩니다. (이로 인해서 뒤에서 설명하는 특별한 현상이 생깁니다.)

그리고 현재 rehashing 중인것을 체크하는 함수가 dictIsRehashing 함수입니다. rehashidx 가 -1이 아니면 Rehashing 중인 상태입니다.

#define dictIsRehashing(d) ((d)->rehashidx != -1)

그리고 위의 _dictExpandIfNeeded 에서 호출하는 실제 hash table의 크기를 증가시키는 dictExpand 함수에서 rehashidx를 0으로 설정합니다.

/* Expand or create the hash table */
int dictExpand(dict *d, unsigned long size)
{
    dictht n; /* the new hash table */
    unsigned long realsize = _dictNextPower(size);

    /* the size is invalid if it is smaller than the number of
     * elements already inside the hash table */
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    /* Allocate the new hash table and initialize all pointers to NULL */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;

    /* Is this the first initialization? If so it's not really a rehashing
     * we just set the first hash table so that it can accept keys. */
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    /* Prepare a second hash table for incremental rehashing */
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}

위의 함수를 잘 살펴보면 dict 구조체 안의 ht[1] = n으로 할당하는 코드가 있습니다. 이 얘기는 hash table이 두 개라는 것입니다. 먼저 dict 구조체를 살펴보면 다음과 같습니다.

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int iterators; /* number of iterators currently running */
} dict;

실제로, redis의 rehashing 중에는 Hash Table이 두개가 존재합니다. 이것은 앞에 설명했듯이… 한번에 rehash step이 끝나지 않고, 매번 하나의 bucket 별로 rehashing을 하기 때문입니다. 즉 hash table의 확장이 일어나면 다음과 같이 두 개의 hash table 이 생깁니다.

redis_hash_expand_1

그리고 한스텝이 자나갈 때 마다 하나의 Bucket 단위로 해싱이 됩니다. 즉 첫번째 rehash step에서는 다음과 같이 ht[0]에 있던 데이터들이 ht[1]으로 나뉘어서 들어가게 됩니다.

redis_hash_rehash_1

두 번째, 세 번째, 네 번째 rehash 스텝이 끝나면 완료되게 됩니다.

redis_hash_rehash_2

그럼 의문이 생깁니다. Rehashing 중에 추가 되는 데이터는? 또는 삭제나 업데이트는? 추가 되는 데이터는 이 때는 무조건 ht[1]으로 들어가게 됩니다.(또 해싱 안해도 되게…), 두 번째로, 검색이나, 업데이트는, 이 때 ht[0], ht[1]을 모두 탐색하게 됩니다.(어쩔 수 없겠죠?)

dictRehash 함수에서 이 rehash step을 처리하게 됩니다. dictRehash 함수의 파라매터 n은 이 스텝을 몇번이나 할 것인가 이고, 실제로 수행할 hash table의 index는 함수중에서 ht[0]의 table이 NULL인 부분을 스킵하면서 찾게 됩니다. 그리고 ht[0]의 used 값이 0이면 rehash가 모두 끝난것이므로 ht[1]을 ht[0]로 변경하고 rehashidx를 다시 -1로 셋팅하면서 종료하게 됩니다.

/* Performs N steps of incremental rehashing. Returns 1 if there are still
 * keys to move from the old to the new hash table, otherwise 0 is returned.
 * Note that a rehashing step consists in moving a bucket (that may have more
 * than one key as we use chaining) from the old to the new hash table. */
int dictRehash(dict *d, int n) {
    if (!dictIsRehashing(d)) return 0;

    while(n--) {
        dictEntry *de, *nextde;

        /* Check if we already rehashed the whole table... */
        if (d->ht[0].used == 0) {
            zfree(d->ht[0].table);
            d->ht[0] = d->ht[1];
            _dictReset(&d->ht[1]);
            d->rehashidx = -1;
            return 0;
        }

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
        de = d->ht[0].table[d->rehashidx];
        /* Move all the keys in this bucket from the old to the new hash HT */
        while(de) {
            unsigned int h;

            nextde = de->next;
            /* Get the index in the new hash table */
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }
    return 1;
}

이제 다시 scan으로 돌아오면… Rehashing 중의 dictScan 함수는 다음과 같습니다.

    } else {
        t0 = &d->ht[0];
        t1 = &d->ht[1];

        /* Make sure t0 is the smaller and t1 is the bigger table */
        if (t0->size > t1->size) {
            t0 = &d->ht[1];
            t1 = &d->ht[0];
        }

        m0 = t0->sizemask;
        m1 = t1->sizemask;

        /* Emit entries at cursor */
        de = t0->table[v & m0];
        while (de) {
            fn(privdata, de);
            de = de->next;
        }

        /* Iterate over indices in larger table that are the expansion
         * of the index pointed to by the cursor in the smaller table */
        do {
            /* Emit entries at cursor */
            de = t1->table[v & m1];
            while (de) {
                fn(privdata, de);
                de = de->next;
            }

            /* Increment bits not covered by the smaller mask */
            v = (((v | m0) + 1) & ~m0) | (v & m0);

            /* Continue while bits covered by mask difference is non-zero */
        } while (v & (m0 ^ m1));
    }

실제로 이미 Rehashing이 된 bucket의 경우는 ht[0] 작은 hash table에는 이미 index의 값이 NULL이므로 실제로 돌지 않지만, 아직 rehash되지 않은 bucket의 경우는 ht[0] 와 ht[1] 의 두 군데, 즉 총 세 군데에 데이터가 존재할 수 있습니다. 그래서 먼저 ht[0]의 bucket을 돌고 나서, ht[1]을 찾게 됩니다. 여기서 당연히 ht[1]에서는 두 군데를 검색해야 하므로 두 번 돌게 됩니다.

v = (((v | m0) + 1) & ~m0) | (v & m0);

즉 위의 식은 만약 v가 0이고 m0 = 3, m1 = 7이라고 하면… (((0 | 3) + 1) & ~3) | (0 & 3) 이 되게 됩니다. ~3은 Bitwise NOT 3이 되므로 -4 가 나오고, (4 & -4) | 0 이므로 결론은 4 & -4 입니다. 3은 00000011, bitwise NOT하면 11111100 이 되므로, 즉 다시 풀면, 00000100 & 11111100 해서 00000100 즉 4가 나오게됩니다. 처음에는 index 0, 두번째는 index 4 가 되는 거죠. 그래서 첫 루프를 돌게 됩니다. 다시 4 & (m0 ^ m1) == 4 이므로 …

이제 두 번째 루프에서 다시 (((4 | 3) + 1) & -4) | (4 & 3) 이므로… 4 | 3 = 7, 4 & 3 = 0 이므로… 다시 한번 정리하면 ((7+1) & -4) | 0 이므로 결론은 8 & -4 = 4 가 되므로 00001000 & 111111100 이 되므로 v 는 이번에는 00001000 즉 8이 됩니다. 즉 한번 돌 때 마다, ht[0]의 size 만큼 증가하게 됩니다.(다들 한방에 이해하실 텐데… 이걸 설명한다고…) 그래서 그 다음번에는 8 & 4 가 되므로 루프가 끝나게 됩니다. 즉, 0, 4 이렇게 ht[1]에서 두 번 읽어야 하니, 두 번 읽는 코드를 만들어둔거죠.

이제 다음편에는 cursor 가 어떻게 만들어지는가에 대해서 간단하게 설명하도록 하겠습니다. (다음편은 짧을듯…)


[입개발] Redis Scan은 어떻게 동작할까? PART #3(결)

$
0
0

PART #1, PART #2를 보면 결국 Redis Scan에서의 Cursor는 bucket 을 검색해야할 다음 index 값이라고 볼 수 있습니다. 그런데 실제로 실행시켜보면, 0, 1, 2 이렇게 증가하지 않고…

그 이유중에 하나는… 실제 Cursor 값이 다음 index의 reverse 값을 취하고 있기 때문입니다. 이걸 보기 전에 먼저 다시 한번 Scan의 핵심 함수인 distScan을 살펴보도록 하겠습니다.(젤 뒤만 보면 됩니다.)

unsigned long dictScan(dict *d,
                       unsigned long v,
                       dictScanFunction *fn,
                       void *privdata)
{
    dictht *t0, *t1;
    const dictEntry *de;
    unsigned long m0, m1;

    if (dictSize(d) == 0) return 0;

    if (!dictIsRehashing(d)) {
        t0 = &(d->ht[0]);
        m0 = t0->sizemask;

        /* Emit entries at cursor */
        de = t0->table[v & m0];
        while (de) {
            fn(privdata, de);
            de = de->next;
        }

    } else {
      ......
    }

    /* Set unmasked bits so incrementing the reversed cursor
     * operates on the masked bits of the smaller table */
    v |= ~m0;

    /* Increment the reverse cursor */
    v = rev(v);
    v++;
    v = rev(v);

    return v;
}

한 이터레이션이 끝나고 나면 m0 의 bitwise NOT을 or 하고 reverse를 취한 다음 1을 더하고 다시 reverse를 취합니다. 일단 bucket이 4개만 있다고 가정하고, rehashing은 빼고 생각해보도록 합니다. 먼저 여기서 reverse는 비트를 쭈욱 세워놓고, 그걸 거꾸로 뒤집는 것입니다.
그래서 0의 rev(0) 은 그대로 0이고, rev(1)은 8000000000000000(16진수), rev(2)는 4000000000000000(16진수) 가 됩니다.(아 이걸 출력을 64bit 라 64bit hex로 찍어야 하는데 32bit로 찍었다가… 잘못된 이해를 ㅋㅋㅋ)

처음에는 v(cursor) 가 0입니다. scan이 끝나고 (0 |= ~3) = -4, 그 뒤에 rev(-4)는 3fffffffffffffff(16진수) 가 됩니다. 여기에 1을 더하면 4000000000000000 여기서 다시 rev(4000000000000000)가 되면 2가 나오게 됩니다.

/* Function to reverse bits. Algorithm from:
 * http://graphics.stanford.edu/~seander/bithacks.html#ReverseParallel */
static unsigned long rev(unsigned long v) {
    unsigned long s = 8 * sizeof(v); // bit size; must be power of 2
    unsigned long mask = ~0;
    while ((s >>= 1) > 0) {
        mask ^= (mask << s);
        v = ((v >> s) & mask) | ((v << s) & ~mask);
    }
    return v;
}

그런데 왜 reverse를 취하는 것일까요? 이것은 실제 적으로 1씩 증가하는 형태라면… cursor가 언제 끝나는지 알려주기가 애매해서 입니다. 즉 끝났다는 값을 다시 줘야 하는데, 그것보다는 0으로 시작해서 다시 0으로 끝날 수 있도록 reverse 형태를 취하는 것이죠.

PART #1, PART #2, PART #3 의 이유로 해서 SCAN은 다음과 같은 제약 사항을 가집니다.
1. count 값을 줄 수 있지만, 딱 그 개수를 보장하지 않는다.
2. 이미 scan 이 지나간 인덱스에 있는 index 에 나중에 추가된 아이템은 iteration 중에 데이터가 나오지 못한다.(Cursor가 이미 지나갔으므로…)
3. 해당 코드의 설명을 보면… 몇몇 데이터가 중복 될 수 있다는데… 이 부분은 저도 잘 이해가 안가는… 코멘트에 보면… hash table이 확장될때, 줄어들 때, rehashing 할 때 다시 스캔하지 않는다고 되어있는데… 이 부분은 잘 모르겠네요. ㅎㅎㅎ

그럼 Redis Scan 에 대한 부분은 마치도록 하겠습니다.


[입 개발] mosquitto build

$
0
0

mosquitto를 빌드하려고 하면 다음과 같은 에러가 발생할 수 있다.


In file included from /home/charsyam/mosquitto-1.3.5/lib/logging_mosq.c:34:0:
/home/charsyam/mosquitto-1.3.5/lib/mosquitto_internal.h:51:20: fatal error: ares.h: No such file or directory

이유는 ares.h가 없다는 것인데, DNS Lookup의 SRV랑 연관이 있다는데 이것이 무엇인지는 잘 모른다는 ㅋㅋㅋ

빌드를 손쉽게 하는 방법은 두가지가 있다.

1.  config.mk 에서 WITH_SRV를 찾아서 yes -> no로 바꾼다. 그리고 make

2. ares.h를 채워주면 된다. 우분투라면 apt-get install libc-ares2 libc-ares2-dev로 간단하게 설치 가능 그리고 빌드하면 됨.


[입 개발] Scala 의 App Trait는 어떻게 동작하는가?

$
0
0

요새 스칼라 스터디를 하고 있는데…(스칼라 어려워요 흑흑흑, 전 스맹 T.T) 아주 여러가지 기능들이 있습니다. 그런데 첫 부분에 나오는 예제부터 머리속을 땡땡 때리는 경우가 있습니다. 간단한 예를 들자면, tuple의 파라매터가 22개 밖에 안되는 것은 실제로 tuple1 ~ tuple22 까지의 클래스가 있어서 처리된다는 것(tuple 은 다시 product 이라는 것을 상속받는…)

보통 우리가 언어를 처음 배울때 쓰는 첫 예제는… 반가워 세상입니다. 즉 Hello World! 를 출력하는 것이죠.

 object HelloWorld {
    def main(args: Array[String]) {
      println("Hello, world!")
    }
  }

그런데 App 이라는 trait 를 상속받으면 다음과 같은 형태로 똑같이 동작이 됩니다.

object HelloWorld extends App {
  println("Hello, world!")
}

사실 스칼라를 공부하는 사람이야 그냥 당연하게 넘어갈 수 있지만, 두 번째 예의 경우는 println 코드가 있는 부분은 생성자입니다. 그런데 “어떻게 저게 자동으로 실행이 되는거지?” 라는 의문이 생기게 됩니다.(안생기면 500원…), 그리고 args 도 사용할 수 있습니다.

그래서 안을 조금 파보니…

App Trait 는 다시 DelayedInit 라는 Trait를 상속받습니다. 먼저 App Trait 부터 살짝 보도록 하겠습니다.

trait App extends DelayedInit {

  /** The time when the execution of this program started, in milliseconds since 1
    * January 1970 UTC. */
  @deprecatedOverriding("executionStart should not be overridden", "2.11.0")
  val executionStart: Long = currentTime

  /** The command line arguments passed to the application's `main` method.
   */
  @deprecatedOverriding("args should not be overridden", "2.11.0")
  protected def args: Array[String] = _args

  private var _args: Array[String] = _

  private val initCode = new ListBuffer[() => Unit]

  /** The init hook. This saves all initialization code for execution within `main`.
   *  This method is normally never called directly from user code.
   *  Instead it is called as compiler-generated code for those classes and objects
   *  (but not traits) that inherit from the `DelayedInit` trait and that do not
   *  themselves define a `delayedInit` method.
   *  @param body the initialization code to be stored for later execution
   */
  @deprecated("The delayedInit mechanism will disappear.", "2.11.0")
  override def delayedInit(body: => Unit) {
    initCode += (() => body)
  }

    /** The main method.
   *  This stores all arguments so that they can be retrieved with `args`
   *  and then executes all initialization code segments in the order in which
   *  they were passed to `delayedInit`.
   *  @param args the arguments passed to the main method
   */
  @deprecatedOverriding("main should not be overridden", "2.11.0")
  def main(args: Array[String]) = {
    this._args = args
    for (proc <- initCode) proc()
    if (util.Properties.propIsSet("scala.time")) {
      val total = currentTime - executionStart
      Console.println("[total " + total + "ms]")
    }
  }
}

젤 아래의 main을 보면, 아 여기서 실행되겠구나 할것입니다. 쉽네하고 보다보면, 다시 이상해집니다. 분명히 main이 보통 entrypoint 일텐데…(실제로는 object이니 이것을 실행하는 부분이 있긴하겠죠.) 뭔가 initCode 라는 것에서 proc를 가져와서 이걸 실행시킵니다.

그 위의 delayedInit 함수를 보니, body가 넘어와서 initCode에 저장됩니다.(여기서 body는 람다라고 보시면 될듯합니다.)

그럼 다시 처음으로 여기서 main이 실행되는 건 알겠는데… App Trait를 상속받은 object의 생성자를 실행을 시켜주는 걸로 봐서 아마도 위의 proc 가 App Trait를 상속받은 object의 생성자일꺼라는 예상을 할 수 있게 됩니다. 그러나, 여전히 delayedInit을 호출해주는 녀석은 보이지 않습니다. 다시 App Trait 가 DelayedInit Trait를 상속받으니, 이걸 살펴보도록 하겠습니다.

trait DelayedInit {
  def delayedInit(x: => Unit): Unit
}

악!!! 살펴볼 내용이 없습니다. 그냥 인터페이스만 정의가 되어있습니다. 그럼 뭔가 언어적으로 뭔가 해주지 않을까 싶습니다. 소스를 까보면 src/reflect/scala/reflect/internal/Definitions.scala 에서 다음 코드를 발견할 수 있습니다.

def delayedInitMethod = getMemberMethod(DelayedInitClass, nme.delayedInit)

해당 클래스에서 delayedInit를 뽑아내는 것 같습니다.

그리고 src/compiler/scala/tools/nsc/transform/Constructors.scala 를 보면 다음 코드가 있습니다.

    private def delayedInitCall(closure: Tree) = localTyper.typedPos(impl.pos) {
      gen.mkMethodCall(This(clazz), delayedInitMethod, Nil, List(New(closure.symbol.tpe, This(clazz))))
    }

그리고 위의 delayedInitCall은 rewriteDelayedInit() 에서 사용하고 있습니다. delayedInitCall을 실제로
호출하게 됩니다. 즉 여기서 아까 delayedInit가 호출되면서 App Trait 의 initCode 쪽에 생성자를 넣어주는 것입니다. 그래서 실제로 App Trait 의 main에서 그걸 호출하게 되는거죠.

    def rewriteDelayedInit() {
      /* XXX This is not corect: remainingConstrStats.nonEmpty excludes too much,
       * but excluding it includes too much.  The constructor sequence being mimicked
       * needs to be reproduced with total fidelity.
       *
       * See test case files/run/bug4680.scala, the output of which is wrong in many
       * particulars.
       */
      val needsDelayedInit = (isDelayedInitSubclass && remainingConstrStats.nonEmpty)

      if (needsDelayedInit) {
        val delayedHook: DefDef = delayedEndpointDef(remainingConstrStats)
        defBuf += delayedHook
        val hookCallerClass = {
          // transform to make the closure-class' default constructor assign the the outer instance to its pa>
          val drillDown = new ConstructorTransformer(unit)
          drillDown transform delayedInitClosure(delayedHook.symbol.asInstanceOf[MethodSymbol])
        }
        defBuf += hookCallerClass
        remainingConstrStats = delayedInitCall(hookCallerClass) :: Nil
      }
    }

마지막으로 Constructors.scala 안에서 다시 rewriteDelayedInit를 실행합니다. 그래서 App Trait를 상속받을 경우 생성자에만 코드를 넣어두면 실행이 되는 것입니다.

뭐, 이게 맞는 플로우인지는 정확하게 보증은 못합니다. 저도 이제 막 스칼라를 공부하는 중이고, 아무리 봐도, 스칼라를 편안하게 쓰지는 못할듯 하네요 T.T 흑흑흑


Viewing all 122 articles
Browse latest View live