[입 개발: Redis 장애] twilio 의 Redis 장애의 원인과 해결책(?)

몇일전에 twilio에 빌링 관련해서 장애가 발생했다고 합니다. 주 내용은 http://www.twilio.com/blog/2013/07/billing-incident-post-mortem.html 에서 보실 수 있습니다. 그리고 이 글이 해커 뉴스(https://news.ycombinator.com/item?id=6093954) 에 올라 왔고, 이에 대해 다시 @antirez가 답변을 블로그에 올리기도 했습니다.(http://antirez.com/news/60)

그럼 왜 이런 현상이 일어났을까요?

먼저 twilio의 발표에는 네트웍 단절로 인해서 slave 노드들이 모두 연결이 실패하고 이에 따라서 master 노드에 여러대의 slave가 동시에 sync 과정을 거치면서 master의 부하가 높아져서, 실제 처리해야 할 리퀘스트들을 처리하지 못하게 되어서 장애가 생긴걸로 설명하고 있습니다.(밑에 덧글도 읽어보면 싸우기 시작하는군요. 재미있습니다.)

먼저 현재 2.6.x 대의 Redis는 master와의 연결이 끊어지면 무조건 sync를 다시 하게 됩니다. sync 과정은 master가 rdb를 백그라운드로 생성하게 되고(무조건!!!) 이를 client에 전달하고, 그 사이에 쌓인 버퍼를 전달해서 sync를 맞추게 됩니다. 이 때, 여러 개의 client가 동시에 sync를 요청해도 백그라운드로 rdb는 하나만 생성되고, 현재 쌓인 버퍼만 복사해서 전달하게 됩니다.
syncCommand에 자세히 설명되어 있습니다. 코드를 보면 child_pid != -1이면 현재 child_pid 생성중이면, 현재 sync를 기다리는 자식 노드가 있으면 sync를 위한 추가 명령 버퍼만 복사하고, 리스트에 추가합니다. slave가 아무것도 없으면, 지금 rdb 저장이 마무리 되었다고 생각하고, 다음 rdb 생성이 일어나길 기다리게 됩니다. 반대로 child_pid == -1이면 현재 아무런 sync 요청이 없으므로 rdb를 백그라운드로 생성합니다.

void syncCommand(redisClient *c) {
    /* ignore SYNC if already slave or in monitor mode */
    if (c->flags & REDIS_SLAVE) return;

    /* Refuse SYNC requests if we are a slave but the link with our master
     * is not ok... */
    if (server.masterhost && server.repl_state != REDIS_REPL_CONNECTED) {
        addReplyError(c,"Can't SYNC while not connected with my master");
        return;
    }

    /* SYNC can't be issued when the server has pending data to send to
     * the client about already issued commands. We need a fresh reply
     * buffer registering the differences between the BGSAVE and the current
     * dataset, so that we can copy to other slaves if needed. */
    if (listLength(c->reply) != 0) {
        addReplyError(c,"SYNC is invalid with pending input");
        return;
    }
    redisLog(REDIS_NOTICE,"Slave ask for synchronization");
    /* Here we need to check if there is a background saving operation
     * in progress, or if it is required to start one */
    if (server.rdb_child_pid != -1) {
        /* Ok a background save is in progress. Let's check if it is a good
         * one for replication, i.e. if there is another slave that is
         * registering differences since the server forked to save */
        redisClient *slave;
        listNode *ln;
        listIter li;

        listRewind(server.slaves,&li);
        while((ln = listNext(&li))) {
            slave = ln->value;
            if (slave->replstate == REDIS_REPL_WAIT_BGSAVE_END) break;
        }
        if (ln) {
            /* Perfect, the server is already registering differences for
             * another slave. Set the right state, and copy the buffer. */
            copyClientOutputBuffer(c,slave);
            c->replstate = REDIS_REPL_WAIT_BGSAVE_END;
            redisLog(REDIS_NOTICE,"Waiting for end of BGSAVE for SYNC");
        } else {
            /* No way, we need to wait for the next BGSAVE in order to
             * register differences */
            c->replstate = REDIS_REPL_WAIT_BGSAVE_START;
            redisLog(REDIS_NOTICE,"Waiting for next BGSAVE for SYNC");
        }
    } else {
        /* Ok we don't have a BGSAVE in progress, let's start one */
        redisLog(REDIS_NOTICE,"Starting BGSAVE for SYNC");
        if (rdbSaveBackground(server.rdb_filename) != REDIS_OK) {
            redisLog(REDIS_NOTICE,"Replication failed, can't BGSAVE");
            addReplyError(c,"Unable to perform background save");
            return;
        }
        c->replstate = REDIS_REPL_WAIT_BGSAVE_END;
    }

    if (server.repl_disable_tcp_nodelay)
        anetDisableTcpNoDelay(NULL, c->fd); /* Non critical if it fails. */
    c->repldbfd = -1;
    c->flags |= REDIS_SLAVE;
    c->slaveseldb = 0;
    listAddNodeTail(server.slaves,c);
    return;
}

그런데, 사실 이것 자체가 큰 문제는 아니었는데, 로드가 높다고 생각하고, twilio 운영팀은 Redis 재시작을 결정합니다. 그리고 master를 재시작 했는데, 기본 설정이 잘못되어 있었던 것입니다. 즉 RDB 파일을 읽을 것이라고 생각했는데 AppendOnlyFile을 읽어버린 거죠. 왜냐하면 AOF와 RDB 중에 AOF는 바로 직전 까지의 값을 가지고 있으므로 AOF 활성화 옵션이 있으면 rdb 대신에 AOF를 읽습니다. 아래의 코드를 보면 aof 설정이 켜져 있으면 aof를 읽는 것을 볼 수 있습니다.

/* Function called at startup to load RDB or AOF file in memory. */
void loadDataFromDisk(void) {
    long long start = ustime();
    if (server.aof_state == REDIS_AOF_ON) {
        if (loadAppendOnlyFile(server.aof_filename) == REDIS_OK)
            redisLog(REDIS_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
    } else {
        if (rdbLoad(server.rdb_filename) == REDIS_OK) {
            redisLog(REDIS_NOTICE,"DB loaded from disk: %.3f seconds",
                (float)(ustime()-start)/1000000);
        } else if (errno != ENOENT) {
            redisLog(REDIS_WARNING,"Fatal error loading the DB: %s. Exiting.",strerror(errno));
            exit(1);
        }
    }
}

그런데 여기서 twilio는 aof를 사용하지 않아서 빈 데이터를 로딩하게 되고, 이를 slave가 리플리케이션 받게 되어서 데이터가 날라가게 된 것입니다.

그렇다면, 이런 문제에 대해서 어떻게 대처를 해야할까요?

1] Full resync에 대해서는 2.8 부터는 partial sync라는 기능이 들어가서, 만약 이전 마스터와 동일한 runid를 가지고 있고, 현재 저장된 버퍼 안까지만의 차이라면, 그냥 이 차이값만 받아서 업데이트 하게 됩니다. 아직 2.6.x는 못썻으니…

2] 설정에 대한 검증을 강화해야 합니다. 처음부터 aof를 안쓴느데, aof 관련 설정이 켜져있었다는 것은 뭔가 실수가 있었다는 것입니다. 이런것도 검증하는 서비스가 필요하지 않을가 싶네요.

* 핵심정리 중 하나: slave 가 sync 해야할때의 rdb는 옵션을 끄더라도 무조건 발생합니다. 이것이 메모리를 적당히 나눠서 rdb 관련 이슈를 줄여야 하는 이유중에 하나입니다.