Sunday, 20 September 2020

Decaton - High throughput asynchronous task processing based on Apache Kafka

 Introduction 

Decaton은 LINE에서 open source화 한 Apache Kafka 기반의 streaming task processing framework이다. Decaton은 하나의 partition에서 소비되는 record의 동시처리를 가능하게 하도록 설계되었다. Kafka는 기본적으로 consumer 당 하나의 thread로 동작하는데 Decaton은 여기서부터 오는 한계점을 개선하고자 한 framework이다. Decaton은 아래 세가지 성질을 만족한다. 

- 하나의 partition으로부터 오는 record를 동시처리(multi-thread or asynchronous) 
- record key 별 처리 순서 보장
- record들이 처리되는 순서와 상관 없이 at-least-once semantic을 보장  

아래 두가지는 Kafka consumer에서 기본적으로 보장해주는 성질인데 일관성 유지 및 계산 결과 보장 등 데이터 정합성을 위해 아주 중요하다. 동시처리 model에서는 이것이 깨질 수가 있는데 Decaton에서는 동시처리를 하면서도 높은 수준의 데이터 정합성을 지킨다. 

Why Need Concurrent Processing in Consumer

Kafka topic은 partition이라는 분산처리의 단위로 나누어져서 저장된다. 어떤 topic에 저장할 record는 아래의 그림과 같이 여러 partition에 나누어 저장되며 각 partition 내에서는 순서대로 read/write가 되는 것을 보장한다. 


 consumer는 하나의 topic으로 produce되는 message를 모두 처리하는데, 하나의 consumer가 처리할 수 있는 message의 양보다 producer가 생상하는 message의 양이 더 많으면 (= producer의 throughput이 더 높으면) 어떤 message들은 처리되지 못하고 계속 broker에 남아있게 된다. 더 많은 message를 소비할 수 있도록 consumer instance를 추가한다. 하지만 Kafka에서 하나의 partition을 여러 consumer에서 처리하는 것은 불가능하므로 consumer의 최대 concurrency는 partition 개수로 한정된다. 


Kafka를 사용하는 여러 사례들에서 이 model로도 충분히 우수한 성능을 보였다. 하지만 사용 중인 Kafka에서 concurrency를 높여야 하는 상황일 때 다음의 두가지 문제점이 있을 수 있다. 첫째, consumer instance를 추가하면서 (horizontal scaling) 비용이 추가로 발생하며, consumer group coordinator의 workload가 증가하고 network traffic이 더 발생할 수 있다. 둘째, consumer에서 처리하는 작업이 CPU intensive한 작업이 아니라 대기 시간을 요하는 I/O intensive 작업이라면 instance를 추가하는 것으로부터 얻는 효용이 높지 않다. consumer의 CPU 사용률이 높지 않은데도 instance를 추가하려는 상황이라면 기존 instance의 사용률을 높일 방안을 생각해보아야 한다.

Multi-Threaded Message Consumption with Kafka Consumer


Kafka에 대한 enterprise solution을 제공하는 Confluent의 웹페이지에서 동일한 문제를 다루고 있다. 마찬가지로 thread per consumer model에서부터 오는 한계점을 지적하고 있으며 multi-threaded model을 도입했을 때의 side effect 및 개선 방안을 제시한다.

요약하면 다음과 같다.

suggestion.
한 consumer 내에서 동시처리를 위해 고정된 개수의 thread pool을 사용한다. partition 별로 thread를 할당하여 비동기로 처리하도록 한다.

improvements.
CPU 사용률을 제한하기 위해 고정된 개수의 thread pool을 사용하였다. 반면 비동기로 처리하므로 consumer는 별도의 대기 없이 poll을 통해서 record를 무한정 가져올 수가 있는데, 그러면 thread pool의 처리량을 넘게 되고 서로다른 thread에서 동일한 partition의 message를 처리하게 된다. 여러 thread에서 한 partition의 message를 처리하는 것은 곧 Kafka에서 제공하는 데이터 정합성을 깨뜨린다는 의미이다. 이를 방지하기 위해서 consumer는 poll 전에 각 partition에 할당된 thread의 상태를 확인한 다음 추가 record를 가져와도 된다면 fetch 해오고 아니면 일시 정지시키며 (KafkaConsumer.pause(), KafkaConsumer.resume()) 한 partition에 두개 이상의 thread 할당되지 않도록 한다. 또 automatic offset commit option을 off 시켰다. 개별 thread에 할당된 각 record가 처리되었는지 main thread는 모르기 때문이다. 마찬가지로 consumer는 poll 전에 작업 완료한 thread 별로 offset을 얻어와 commit 한다. 
consumer 내에서 여러 partition에 대한 동시처리를 하고 있을 때 consumer group rebalancing이 발생할 수 있다. 각 partition 별로 할당된 thread들은 이에 대한 처리를 해주어야 한다. 이를 위해서 subscription 시 ConsumerRebalanceListener.onPartitionsRevoke() callback을 등록하여 callback 내부에서 revoked partition들에 대한 처리를 멈추고 작업 완료된 마지막 offset를 commit하게 하였다.

Decaton

위 Confluent 글을 읽으면서 Decaton이 해결하고자 하는 문제를 조금 더 쉽게 이해할 수 있었다. 요약하자면 consumer 내 동시처리를 위해서 offset commit coordination이 필요한데 Decaton은 이를 대용량 처리 환경에서도 적절하게 처리할 수 있도록 한다. Decaton에서는 sliding offset range를 방식으로 동작하며 TCP의 sliding window protocol과 유사하다고 이해하면 쉽다. 위의 방식과 성능 비교는 당장에 할 수 없지만 개별 stream 당 1초에 100만건 이상의 I/O task를 처리하는 LINE 서버 시스템에서 검증되었으므로 그 우수한 성능을 이미 입증했다고 볼 수 있다.

Decaton의 동작 방식을 이해하기 위해서 다음의 class들을 중점적으로 살펴보는 것을 추천한다.
```
ProcessorSubscription
PartitionContext
PartitionContexts
ProcessPipeline, Processors 
PartitionProcessor
TaskRequest
```
하나의 topic으로부터 message를 받아오는 consumer의 동작은 ProcessorSubscription class가 담당하며 전체적인 흐름은 아래와 같다. PartitionContext는 subscription instance에 할당된 한 partition에 대한 모든 exeuction context를 제공한다. 한 partition마다 가지는 여러 thread들은 ProcessorUnit에 대응된다. 


ProcessorSubscription

1. pollOnce()하여 records들을 받아옴.
1-1. record마다 각 partition에 대응되는 context에 offset 등록 및 task 제출. 
1-2. 제출된 task는 scheduleThenProcess() 이후 complete()를 호출하여 lcocally commited 된다. 각 context 들은 가장 먼저 등록되었던 task가 locally commited 되었으면 highWatermark를 해당 offset으로 갱신한다.
2. commitIntervalMillis 만큼의 시간이 지났으면 commitCompletedOffsets() 하여 각 partition 마다 offset을 갱신한다.
2-1. consumer.commitSync()를 호출하여 ConsumerCoordinator를 통해 offset을 commit 한다.
2-2. local의 lastCommittedOffset을 갱신한다. 
```
public class ProcessorSubscription extends Thread implements AsyncShutdownable {
    public void run() {
        try {
            consumer.subscribe(subscribeTopics(), new ConsumerRebalanceListener(),...
 
            long lastCommittedMillis = System.currentTimeMillis();
            while (!terminated.get()) {
                pollOnce(consumer);
                if (System.currentTimeMillis() - lastCommittedMillis >= commitIntervalMillis.value()) {
                    try {
                        commitCompletedOffsets(consumer);
                    } catch (CommitFailedException | TimeoutException e) {
                        logger.warn("Offset commit failed, but continuing to consume", e);
                        // Continue processing, assuming commit will be handled successfully in next attempt.
                    }
                    lastCommittedMillis = System.currentTimeMillis();
                }
            }
        }
...
    }
 
    private void pollOnce(Consumer<String, byte[]> consumer) {
        ConsumerRecords<String, byte[]> records = consumer.poll(POLL_TIMEOUT_MILLIS);
 
        records.forEach(record -> {
            TopicPartition tp = new TopicPartition(record.topic(), record.partition());
            PartitionContext context = contexts.get(tp);
            DeferredCompletion completion = context.registerOffset(record.offset());
 
            if (blacklistedKeysFilter.shouldTake(record)) {
                TaskRequest taskRequest =
                        new TaskRequest(tp, record.offset(), completion, record.key(), record.value());
                context.addRequest(taskRequest);
            } else {
                completion.complete();
            }
        });
        contexts.updateHighWatermarks();
        contexts.maybeHandlePropertyReload();
        pausePartitions(consumer);
        resumePartitions(consumer);
    }
 
    private void commitCompletedOffsets(Consumer<?, ?> consumer) {
        // fetch offsets to be committed 
        Map<TopicPartition, OffsetAndMetadata> commitOffsets = contexts.commitOffsets();
        if (commitOffsets.isEmpty()) {
            logger.debug("No new offsets to commit, skipping commit");
            return;
        }
        logger.debug("Committing offsets: {}", commitOffsets);
        consumer.commitSync(commitOffsets);
        contexts.updateCommittedOffsets(commitOffsets);
    }
}
```

PartitionContext

```
public class PartitionContext {
    public OptionalLong offsetWaitingCommit() {
        long readyOffset = commitControl.commitReadyOffset();
        if (readyOffset > lastCommittedOffset) {
            return OptionalLong.of(readyOffset);
        }
        return OptionalLong.empty();
    }
 
    public void updateHighWatermark() {
        commitControl.updateHighWatermark();
        metrics.tasksPending.set(commitControl.pendingOffsetsCount());
    }
 
    public DeferredCompletion registerOffset(long offset) {
        return commitControl.reportFetchedOffset(offset);
    }
}
```

PartitionProcessor

```
/**
 * This class is responsible for following portions:
 * - Create and manage configured number of {@link ProcessorUnit}s to parallel-ize processing of tasks received
 *   from single partition.
 * - Route fed task appropriately to one of belonging {@link ProcessorUnit}s, respecting task's key for keeping
 *   process locally and ordering.
 * - Manage lifecycle of {@link DecatonProcessor}s for each {@link ProcessorUnit}s.
 */
public class PartitionProcessor implements AsyncShutdownable {
    private final PartitionScope scope;
    private final Processors<?> processors;
 
    private final List<ProcessorUnit> units; // unit of parallelism 
 
...
}
```


OutOfOrderCommitControl

Decaton의 offset commit은 OutOfOrderCommitControl class에서 이루어진다. OutOfOrderCommitControl class 내에서 각 task에 대한 상태(offset , committed)는 deque에서 저장하여 sliding window 방식으로 broker에 commit 한다. 

```
public class OutOfOrderCommitControl {
 
    static class OffsetState {
        private final long offset;
        private volatile boolean committed;
  }
 
    private final Deque<OffsetState> states;
    ... 
     
    public synchronized DeferredCompletion reportFetchedOffset(long offset) {
...
        OffsetState state = new OffsetState(offset, false);
        states.addLast(state);
        latest = state.offset;
 
        return () -> complete(state);
    }   
 
    public synchronized void updateHighWatermark() {
...
        long lastHighWatermark = highWatermark;
 
        OffsetState state;
        while ((state = states.peekFirst()) != null) {
            earliest = state.offset;
            if (state.committed) {
                highWatermark = state.offset;
                states.pollFirst();
            } else {
                break;
            }
        }
 
        if (highWatermark != lastHighWatermark) {
            logger.debug("High watermark updated for {}: {} => {}",
                         topicPartition, lastHighWatermark, highWatermark);
        }
    }
}
```

Internal Queueing and Workers

> Once the offset commit coordination works, we can process records from single partition in parallel using multiple threads.
Decaton manages internal queues and associated threads to process records for each partition. It relaxes ordering guarantee from partition-based to key-based and route each record into internal queue which will be consumed and processed by associated worker with logic that you supply.




Decaton은 동시처리에 단위인 thread 마다 internal queue가 있어 각 partition으로부터의 record를 처리한다. 이는 partition-based에서 key-based로 완화된 guarantee를 제공하며 key에 따라 internal queue에 routing 한다. 본래 producer에서 partition으로의 분배가 key에 따라 결정되는데 decaton에서는 이 key에 대해 추가적으로 routing 해주기 위해서 prefix를 첨부하여 mumur2 hash 처리를 한다. 

Offset Commit Strategy 


Decaton의 offset commit은 OutOfOrderCommitControl class에서 이루어진다. OutOfOrderCommitControl class 내에서 각 task에 대한 상태(offset , committed)는 deque에서 저장하여 sliding window 방식으로 broker에 commit 한다. 

References

- https://github.com/line/decaton
- https://www.confluent.io/blog/kafka-consumer-multi-threaded-messaging/
- https://www.confluent.io/blog/how-choose-number-topics-partitions-kafka-cluster/
- https://kafka.apache.org
- https://dzone.com/articles/kafka-topic-architecture-replication-failover-and


Saturday, 12 September 2020

[Book Review] 도시는 무엇으로 사는가 - 유현준

 Introduction

2020년 09월 대한민국에서 가장 중요한 이슈 중 하나는 부동산일 것이다. 부동산은 토지 및 그 위에 있는 건축물을 의미하는데 요즘 부동산은 최근 더 가팔라진 가격 상승세로 인해 자산으로서 더 부각되어지는 것 같다. 이 현상을 두고 저마다의 의견을 낸다. 작은 국토 면적, 그리고 수도권 과밀화에 따른 희소성으로 가격이 계속해서 오를 것이라는 경제학적 근거에 기반한 의견은 비교적 합리적으로도 보인다. 하지만 이런 모든 논의의 문제점은 이런 결론을 가진다고 해서 다수의 사람이 겪고 있는 박탈감, 불안감은 전혀 해소되지 않는다는 것이다. 

건축과 공간은 그리 단순하지 않다. 자산으로서 측면을 간과해서는 안되겠지만 그렇다고 해서 그것이 우리의 생각과 결정을 지배하게 둘 수 없다. 왜냐하면 우리 인간의 삶이 그리 단순하지 않기 때문이다. 공간에서 매일을 보내며 함께 있는 사람들과 같이 지내면서 다양한 경험을 하고 또 다양한 생각과 감정을 얻는다. 책의 저자에 따르면 인간은 건축 및 공간에 영향을 미치며 또한 건축과 공간은 인간에 영향을 미친다고 한다. 능동적인 삶을 꾸려나가는 생물체로서 인간은 자신과 영향을 주고받는 공간에 대해서 여러 측면에 대해서 탐색할 필요가 있다. 

그런 점에서 이 책은 우리 주변을 둘러싼 건축과 공간, 그리고 도시에 대해서 우리가 다양한 측면에서 살펴볼 수 있도록 돕는다. 살면서 경험적으로 어렴풋이 가지고 있었던 나의 공간에 대한 선호도를 속 시원하게 되짚어 볼 수 있도록 한다. 사람들은 강남 테헤란로보다 명동 거리를 왜 더 선호하는지, 우리가 서울에서 번잡하게 살아내며 겪는 답답함이 어디서부터 오는지 저자의 관점에서 이야기를 한다. 책을 읽으면서 놀랍도록 공감대를 얻을 수 있을 것이다. 책을 통해서 건축과 공간이 주는 다양한 측면을 바라볼 수 있다면 우리는 혼란스러운 시기를 잘 이해하고 앞으로 더 현명한 선택을 할 수 있을 것이다. 


어떤 거리는 왜 걷고 싶은가


거리는 크게 인간 중심의 거리, 차 중심의 거리로 나눌 수 있다. 주로 유럽 도시는 역사가 오래되어 인간 중심의 거리로 점진적으로 발달해온 반면 산업화 이후에 건축이 이루어진 미국은 차가 다니기 위해 차 중심의 거리로 발달하였다. 
그렇다면 인간 중심의 거리는 어떤 점에서 인간 친화적인걸까? 저자는 이벤트 밀도, 공간의 속도라는 두가지 정량적인 기준을 제시한다. 이벤트 밀도는 단위 면적 당 발생할 수 있는 이벤트의 경우의 수이며 공간의 속도는 전체 면적에서 각각의 구성이 전체 속도에 기여하는 평균치이다. 이벤트 밀도가 높고 공간의 속도가 사람이 걷는 속도에 가까울수록 인간의 중심의 거리이다. 예를 들어 강남 테헤란로와 샹젤리제 거리를 비교할 수 있다. 같은 대형 도로더라도 강남 테헤란로에서는 자동차들이 재빠르게 달려가고 있으며 상점의 개수도 많지 않다. 반면 샹젤리제 거리는 이런저런 가게가 즐비해있고 (쇼핑의 성지라고 한다) 차량의 출입을 통제하고 데크가 거리로 나와있음으로써 공간의 속도를 사람과 최대한 맞춘다. 그렇기에 우리는 샹젤리제 거리에서 더 친숙함을 느끼는 것이다. 

현대 도시들은 왜 아름답지 않은가 


왜 2차 세계대전 이전에 세워진 오래된 도시들은 멋있고 그 이후에 만들어진 도시들은 그렇지 못한 것일까? 이 장에서는 건축 구조 기술과 재료의 관점에서 이야기해보자.

과거 
지역에 따라 가능한 건축 재료가 서로 달랐고 한정된 거리에서 이를 조달하였다. 건축은 인간의 노동을 통해서 이루어졌기 때문에 주로 휴먼 스케일의 건축물이 대부분이었다. 정부에서 지은 대형 스케일의 랜드마크 건물이 혼재되어서 지금과 같이 지역 특색이 있는 도시로 자라왔다. 

현재
건축 재료는 어디서나 조달 가능하고 크레인과 철골 구조로 어디서나 대형 스케일의 건축이 가능해졌다. 지역의 정체성이나 사람들의 선호로 도시가 계획되는 것이 아니라 경제적인 관점에서 건축이 이루어짐으로써 통일성과 컨텍스트가 부재한 카오스적인 도시 탄생.
 
'골목 대신 복도'의 건축
현대의 도시들에서 살고 싶은 느낌이 들지 않는 이유 중 중요한 부분을 차지한다. 근현대의 건축을 이야기할 때 르 코르뷔지에를 빼놓고 이야기할 수 없는데 근대 이후 건축물에, 특히 개발도상국에 지어지는 대부분의 현대 건축물 및 도시를 만드는 가장 큰 영향력을 행사한 인물이다. 이 사람이 주창한 도시의 비전 중에서 '빛나는 도시' 라는 것이 있는데 파리 도심을 고층 아파트 단지로 리모델링하는 신도시 계획안이다. 고밀도의 고층 대형 건물과 이들 사이를 떨어뜨려 놓고 그 사이에 공원을 만들어서 도심 속에서도 자연을 느끼게 살자는 계획이지만 이는 바라보기에 아름다운 것일 뿐이었다. 그곳의 사는 사람들은 인간으로서 체험해야 할 길이나 골목을 박탈 당했다. 자연이 일상에서 체험되기보다는 보기만하는 대상으로 전락해린다는 점에서 계획안은 실패라는 저자의 의견. 또한 르 코르뷔지에의 주장 중에 '건축은 사람이 살 수 있게 하는 기계'라는 말이 있다. 하지만 건축은 기능과 감정이 모두 더해졌을 때 인간에게 완전함을 선사한다.  

펜트하우스가 비싼 이유 


펜트하우스는 부자들이 권력을 갖는다는 자본주의 사회의 권력구조를 확실히 보여주는 공간 형태다. '공간은 권력을 만들어 낸다'라는 명제를 판옵티콘(Panopticon)처럼 잘 설명해주는 것은 없을 것이다. 판옵티콘은 제레미 벤담이 1791년 죄수들을 감시할 목적으로 설계하였으며 당시에는 주목을 받지 못하다가 1975년 철학자 미셀 푸코가 감옥 안의 죄수들이 계속해서 감시를 당한다는 점에서 현대인의 삶과 비슷하기 때문에 판옵티콘의 디자인과 우리가 사는 사회 구조는 유사하다고 이야기하면서 유명해진 계획안이다. 공간을 구획함으로써 권력을 얻는다면 자본주의 사회에서 부자들은 권력을 얻기 위해 얼마든지 거금을 지불할 의사가 있다. 그것의 결정체가 펜트하우스이다. 

도시는 무엇으로 사는가: 뉴욕 이야기 


로프트 
건축은 사회, 경제, 역사, 기술의 산물이며 도시는 살아 움직인다. 이 명제를 뉴욕의 로프트처럼 잘 보여주는 건축 형태도 없다. 뉴욕의 소호 지역에 있는 로프트를 일컫는다.

산업화 시대에 진입하면서 공장들이 생겼으나 시간이 지나서 공장들이 문을 닫게 되자 인구가 빠져나가고 슬럼이 되려고 함. 이때 뉴욕시는 예술가들을 끌어들여서 헐값에 살게 하였다. 공장처럼 큰 창문과 높은 천장 높이는 화가와 조각가에게 최적의 공간이었다. 예술가들이 창작 활동을 하면서 그들의 공간에 전시를 하면서 다시 세간의 주목을 받기 시작하였다. 자본가들이 로프트 내의 삶에 우상을 가지기 시작하면서 로프트를 매입하기 시작. 아이러니하게도 예술가들로부터 발전한 로프트는 다시 가격이 상승해 예술가들이 쫓겨나게 되엇다. 

라이프스타일과 냉장고
인류의 역사를 사펴보면 새로운 발명품은 새로운 라이프스타일을 만들어냈고, 새로운 라이프스타일은 새로운 건축과 도시를 만들어 왔음을 알 수 있다.

그중 냉장고의 발명은 기존에 뉴욕에 거주하던 자본가 유태인들은 뉴저지와 롱아일랜드로 떠나게 했다. 더이상 식료품 가게 근처에 살 필요가 없어진 것이다. 이렇게 버려진 할렘을 구제하기 위해서 뉴욕시는 다시 머리를 썼다. 개발업자들에게 헐값에 건물을 블럭 단위로 장기 임대를 시킨 것이다. 개발업자는 자신이 속한 건축물을 다시 발전시키고 스타벅스, 반스앤노블를 입점시키면서 인구 유입을 유도했다. 그 결과 또 다시 빈민들은 쫓겨날 수 밖에 없었다. 

우리는 왜 공원이 부족하다고 말할까 


현대에 진입하면서 마당과 골목길이 부재하게 되면서 사람들은 더 넓은 평형의 아파트를 갈구하게 되었다. 하지만 더 넓은 아파트에서도 여전히 우리는 공간에 대한 갈증을 느낀다. 왜 그런걸까? 30평짜리 주택이 100평짜리 주상복합보다 넓게 느껴지기도 한다. 그 이유는 마당은 계속해서 바뀌기 때문이다. 한 공간에서 다양한 활동과 시간에 따라 변하는 기후로부터 우리는 여러 기억들을 가지게 되면서 심리적으로 더 여유로움을 느끼게 된다. 

남산과 센트럴 파크 
서울의 녹지 공급률은 센트럴 파크를 가진 뉴욕보다 낮고, 서울과 유사한 아시아 대도시인 도쿄에 비해서도 매우 낮다. 하지만 실제로 서울의 안팎에는 많은 자연 공간이 있는데. 그럼에도 불구하고 우리는 왜 공원이 부족하다고 말할까? 

거기에는 두가지 이유가 있다. 하나는 녹지 주변 상황 문제이고 다른 하나는 땅의 기울기 문제이다. 첫째로 녹지 주변 상황이 인간 친화적일수록 우리는 공원으로부터 다양한 경험을 가질 수 있고 편함을 느낀다. 예를 들어 서울숲 주변에는 한강 고가도로 및 강변 도로, 그리고 고층건물이 있는 반면 센트럴 파크의 주변에는 주거 공간과 상업 시설이 즐비하다. 두번째로 대지 경사의 문제다. 센트럴 파크는 대부분 평지로 되어있는데 서울의 공원은 대부분 경사져있기 때문에 공간 내에서의 활동이 제약되고 모두가 단일 방향으로의 활동만을 하게 된다. 즉 그곳에서의 경험이 단순해진다. 

한강과 고수부지
우리 주변에서 가장 넓고 평평한 공간은 한강의 고수부지일 것이다. 한강은 정신없는 서울의 일상에서 벗어난 비움의 공간으로 잘 이용되고 있다. 그런 점에서 한강의 이런 소중함은 시간이 지나도 잘 지켜져야 할 것으로 보인다.