Recent improvements to Redis Lua scripting

Salvatore Sanfilippo

Redis Lua 스크립팅의 최근 개선 사항

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

Lua 스크립팅은 Redis가 이미 꽤 대중화된 이후에 도입된 기능 중에서는 아마도 가장 성공적인 기능일 것이다. 사용자들이 가장 원하는 것 중 몇 가지가 스크립팅과 관련되어 있다는 사실도 놀랍지 않다. 다음 두 가지 기능은 지난 2년 동안 여러 차례 제안되었고, 몇 주 전 열린 Redis 개발자 미팅에서도 많은 사람들이 내 관심을 이 중 하나로 끌어보려 했다.

  1. Redis Lua 스크립트를 위한 제대로 된 디버거.
  2. Lua 스크립트를 평소대로 스크립트 자체로 복제하는 대신, 스크립트 *효과*를 구체화하는 쓰기 명령어들의 집합으로 복제하고 AOF에 저장하는 기능.

두 번째 기능은 단순히 스크립트를 어떻게 복제하느냐의 문제가 아니라, 뒤에서 살펴보겠지만 Lua 스크립팅으로 무엇을 할 수 있는지와도 연결된다.

런던에서 돌아온 뒤 두 기능을 모두 구현했다. 이 블로그 글에서는 두 기능을 모두 설명하고, 독자들에게 흥미로울 만한 설계 및 구현 측면의 힌트도 몇 가지 덧붙이려 한다.

제대로 된 Lua 디버거

Lua 스크립팅은 애초에 정말 간단한 스크립트를 작성하기 위해 고안됐다. 키가 존재하면 이렇게 하라, 같은 것들이다. 가능한 모든 명령어 변형으로 Redis를 비대하게 만들지 않기 위한 몇 줄짜리 코드였다. 물론 사용자들은 이를 훨씬 더 많이 활용했고, 쿼드 트리 구현부터 복잡한 의미론을 가진 완전한 메시징 시스템에 이르기까지 복잡한 스크립트를 작성하기 시작했다. Lua 스크립팅은 Redis를 프로그래밍 가능하게 만들고, 프로그래머들은 보통 프로그래밍 가능한 것들을 그냥 지나치지 못한다. 모든 Lua 스크립트가 동일한 인터프리터를 사용해 실행되고 캐시되어 매우 빠르다는 점도 도움이 된다. 대부분의 경우 Lua 스크립팅을 이용하면 기능적으로나 초당 연산 수 측면에서도 Redis 인스턴스로 훨씬 더 많은 일을 할 수 있다. 그래서 오늘날 복잡한 스크립트는 완전히 제 자리를 잡았다. 우리는 스크립팅 기능에 대한 매우 차가운 반응(데이터베이스에 스크립트를 보낸다는 것처럼 동적인 것!)에서 대중적인 사용으로, 그리고 몇 년 만에 복잡한 스크립트 작성으로 옮겨왔다.

하지만 간단한 스크립트를 작성하는 것과 복잡한 스크립트를 작성하는 것은 완전히 다른 문제다. 프로그램이 커질수록 복잡도는 기하급수적으로 증가하며, 이는 코드가 10줄에서 200줄로 늘어날 때조차 느낄 수 있다. 간단한 스크립트라면 몇 가지 변형을 시도해 데이터셋에 미치는 영향을 관찰하거나 중간에 로깅 몇 줄을 넣는 식으로 무작정 디버깅할 수 있지만, 복잡한 스크립트에서는 디버거 없이는 고생하게 된다.

내 동료 Itamar Haber는 최근 복잡한 스크립트를 작성하는 데 많은 시간을 썼다. 한때 그는 Lua debug 라이브러리를 이용해 Redis Lua 스크립팅용 디버거 비슷한 것을 만들기도 했다. 이 디버거는 이제 더 이상 동작하지 않는데, 샌드박싱 문제로 debug 라이브러리가 더 이상 스크립트에 노출되지 않기 때문이다. 그리고 일반적으로 Redis 디버거에 원하는 것은 서버와 함께 동작하며 좋은 디버깅 경험을 제공할 수 있는 제대로 된 클라이언트를 갖춘, 대화형 원격 디버거다. 디버깅 자체가 이미 고된 작업인 만큼, 탄탄한 도구를 갖추는 것은 정말 필수다. 이 결과를 달성할 수 있는 유일한 방법은 Redis 자체에 제대로 된 디버깅 지원을 추가하는 것이었다.

그래서 런던에서 돌아온 뒤 Itamar와 나는 디버거가 어느 정도 유용하고 과거에 비해 진정한 업그레이드가 되려면 사용자에게 무엇을 제공해야 할지 이야기하기 시작했다. Redis 생태계 밖에 이미 존재하는 Lua 디버거들에 대한 지원을 그냥 추가하는 방안도 논의됐다. 하지만 나는 모든 것이 Redis와 잘 동작하도록 특별히 설계될 때 사용자 경험이 향상된다고 강하게 믿었고, 결국 디버거를 처음부터 직접 작성하기로 했다. 몇 가지는 확실했다. 우리는 Redis에 붙어서 디버깅 세션을 시작하고, 스크립트가 Redis 데이터셋에 대해 무엇을 하는지 잘 관찰할 수 있는 원격 디버거가 필요했다. 물론 내 특별한 관심사 중 하나는 컬러 출력이었다 ;-) 디버깅을 재미있는 경험으로 만들고 학습 곡선을 매우 가파르지 않게 만들고 싶었는데, 이 둘은 서로 관련되어 있다.

이제 디버거가 어떻게 동작하는지를 블로그 글로 보여주는 것도 분명 가능하지만, Courier 폰트로 글을 쓰는 순수주의자인 나조차도 가끔은 영상에 의지하게 된다. 그래서 Redis 디버거의 주요 기능을 보여주는 다소 긴 영상을 준비했다. 이른 아침이라 초반에는 좀 침울하게 말하지만, 몇 분 지나 커피가 돌기 시작하면 훨씬 밝아진 모습을 보게 될 것이다.

(힌트: 대화형 세션의 글자 크기를 제대로 보려면 영상을 전체 화면으로 시청하세요. 영상 화질이 충분히 높아 글자를 읽을 수 있습니다)

영상 보기를 선호하지 않는다면, Lua 디버거로 할 수 있는 일을 디버거 도움말 화면 자체가 간략하게 요약해 준다:

$ ./redis-cli --ldb --eval /tmp/script.lua
Lua debugging session started, please use:
quit    -- End the session.
restart -- Restart the script in debug mode again.
help    -- Show Lua script debugging commands.

* Stopped at 1, stop reason = step over
-> 1   local src = KEYS[1]
lua debugger> help
Redis Lua debugger help:
[h]elp               Show this help.
[s]tep               Run current line and stop again.
[n]ext               Alias for step.
[c]continue          Run till next breakpoint.
[l]list              List source code around current line.
[l]list [line]       List source code around [line].
                     line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
                     to show before/after [line].
[w]hole              List all source code. Alias for 'list 1 1000000'.
[p]rint              Show all the local variables.
[p]rint <var>        Show the value of the specified variable.
                     Can also show global vars KEYS and ARGV.
[b]reak              Show all breakpoints.
[b]reak <line>       Add a breakpoint to the specified line.
[b]reak -<line>      Remove breakpoint from the specified line.
[b]reak 0            Remove all breakpoints.
[t]race              Show a backtrace.
[e]eval <code>       Execute some Lua code (in a different callframe).
[r]edis <cmd>        Execute a Redis command.
[m]axlen [len]       Trim logged Redis replies and Lua var dumps to len.
                     Specifying zero as <len> means unlimited.
[a]abort             Stop the execution of the script. In sync
                     mode dataset changes will be retained.

Debugger functions you can call from Lua scripts:
redis.debug()        Produce logs in the debugger console.
redis.breakpoint()   Stop execution like if there was a breakpoing.
                     in the next line of code.
lua debugger>

어떻게 동작할까?

디버거 전체는 거의 자체 완결적인 코드 블록이며, 약 1300줄로 이루어져 있다. 대부분은 scripting.c 안에 있지만, 디버거의 클라이언트 역할을 하는 CLI 특수 모드를 구현하기 위해 일부는 redis-cli.c 안에 있다. 이미 말했듯이 이는 서버-클라이언트 원격 디버거다.

Lua C API에는 꽤 유용한 디버깅 인터페이스가 있다. 그 자체로 디버거는 아니지만 디버거를 작성하는 데 필요한 기본 요소들을 제공한다. 하지만 Redis 맥락에서 디버거를 작성하는 것은 독립형 Lua 디버거를 작성하는 것보다 조금 더 까다로웠다. 스크립트를 디버깅하려면 스크립트 실행 중에 호출되는 콜백이 필요하다. 그런데 Redis가 스크립트를 실행할 때는 EVAL 맥락에서 클라이언트 명령을 실행 중인 상태다. 거기서 블로킹된 상태로 어떻게 I/O를 할 수 있을까? 또 Redis 서버는 어떻게 될까? 디버깅은 운영 서버가 아닌 개발 서버에서 수행해야 한다고 해도, 인스턴스를 완전히 멈춰 버리는 것은 좋은 생각이 아닐 수 있다. 다른 개발자가 인스턴스를 사용하고 싶을 수도 있고, 스크립트를 디버깅 중인 개발자 한 명이 새로운 병렬 디버깅 세션을 만들고 싶을 수도 있다. 마지막으로, 디버깅 중에 스크립트가 가하는 변경과 관계없이 동일한 Redis 데이터셋으로 같은 스크립트를 반복해서 테스트할 수 있도록 변경 사항을 롤백하는 것은 어떻게 할까? 디버깅 맥락에서 결정론은 금과 같다.

그래서 겉보기에는 복잡한 구현이 필요해 보였다. 아니면 크게 꼼수를 써서, 훨씬 적은 코드와 복잡도로 내가 원하던 이점의 90%를 주는 기묘한 해결책을 찾아야 했다. 그 기묘한 해결책은 결국 다음과 같았다:

  • 디버깅 세션이 시작되면 Redis를 fork()한다.
  • 클라이언트 파일 디스크립터를 확보해 디버깅 세션이 활성화된 동안 직접 블로킹 I/O를 수행한다.
  • Redis 프로토콜을 사용하되, 단 몇 줄로 구현할 수 있는 아주 단순한 부분 집합만 사용해서 Redis 이벤트 루프에 재진입하지 않는다. I/O는 디버거의 일부다.

400줄 정도 작성하자 기본적인 동작이 모두 완성됐고, 나머지는 기능을 추가하고 버그와 코너 케이스를 수정하는 일이었다.

이렇게 하면 필요한 모든 것을 얻을 수 있다. 각 디버깅 세션이 별도의 프로세스이므로 서버가 블로킹되지 않고, EVAL 호출 중간에 이벤트 루프에 재진입할 필요도 없으며, 롤백도 공짜로 얻는다.

다만 서버를 블로킹하면서 데이터셋에 대한 변경을 유지한 채로 무언가를 디버깅해야 하는 경우를 위한 동기 모드도 있다. 이 모드가 많이 쓰일 것 같지는 않지만, fork하지 않고 마지막에 클라이언트 정리만 처리하면 되는 일이라 이 모드도 추가했다.

그 위에서 Lua “line” 훅을 이용해 스테핑과 중단점을 구현하는 등 나머지 모든 것을 추가할 수 있었다. 디버거가 Redis 자체에 통합되어 있기 때문에 모든 Redis 호출을 가로채 사용자에게 무슨 일이 일어나고 있는지 보여주는 것은 간단했다. I/O 모델도 단순해서, 사용자 입력을 읽고 출력을 버퍼에 추가하기만 하면 된다. 디버거가 어딘가에서 멈출 때마다 출력은 “status reply” 배열로 클라이언트에 플러시된다. 각 줄의 접두사는 redis-cli에 어떤 색상을 입혀야 할지 힌트를 준다.

이 설계 덕분에 디버거는 2일 만에 동작했고 4일 만에 완성됐다. 게다가 이 설계 덕분에 완전히 자체 완결적인 코드를 작성할 수 있었고, 디버거는 Redis의 나머지 부분과 거의 상호작용하지 않는다. 덕분에 12월에 Redis 3.2와 함께 출시할 수 있게 됐다.

디버거를 거의 공짜로 훨씬 더 강력하게 만드는 간단한 방법은 두 개의 새로운 Redis Lua 호출을 추가하는 것이었다. 각각 디버거 안에서 중단점을 시뮬레이션하거나(다음에 실행될 줄에서 멈추도록) 디버거 콘솔에 Lua 객체를 로그로 남기는 redis.breakpoint()와 redis.debug()다. 이렇게 하면 뭔가 흥미로운 일이 있을 때만 발동하는 중단점을 추가할 수 있다:

    if some_odd_condition then redis.breakpoint() end

이는 디버거에 추가할 수 있는 많은 복잡한 기능들을 사실상 대체한다. 물론 디버거 안에는 정적 중단점이나 상태를 관찰하는 기능 등 일반적인 기능들도 모두 직접 들어 있다.

복잡한 스크립트를 작성하는 사용자들이 이에 대해 어떻게 생각할지 매우 궁금하다! 지켜보자.

스크립트 효과 복제

스크립트의 *효과*만을 복제하는 것이 왜 흥미로운지 이해하기 전에, 왜 오히려 기본적으로 스크립트 자체를 복제해 슬레이브에서 재실행하도록 하는 것이 최선으로 여겨졌고 지금도 기본값인지 이해하는 것이 좋다. 핵심은 마스터와 슬레이브 사이의 대역폭, 그리고 일반적으로 슬레이브가 마스터를 “따라가는”(지나친 지연 없이 동기화를 유지하는) 능력과 뒤처지지 않는 것이다.

이 작은 Redis Lua 스크립트를 생각해 보자:

    local i;
    for i=0,1000000 do
        redis.call(“lpush”,KEYS[1],ARGV[1])
    end

이 스크립트는 지정된 리스트에 100만 개의 요소를 추가하며, 내 테스트 환경에서는 0.75초 만에 실행된다. 불과 몇 바이트에 불과하고 서버 내부에서 실행되므로, 이 스크립트를 스크립트 실행으로 생기는 100만 개의 명령어가 아니라 스크립트 그대로 복제하는 것이 훨씬 합리적이다.

정반대인 스크립트들도 있다. 스펙트럼의 다른 한쪽 끝에는 리스트에 저장된 100만 개의 정수 요소의 평균을 계산해 SET으로 어떤 키에 결과를 저장하는 스크립트가 있다.

스크립트의 효과는 단지 SET somekey 94.29 한 줄일 수도 있다.

하지만 실제 실행에는 2초 정도의 연산이 걸릴 수 있다. 이 스크립트를 결과 명령어로 복제하는 것이 훨씬 낫다. 그러나 스크립트 복제와 효과 복제 사이에는 차이가 있다. 둘 다 사용 사례에 따라 최적일 수도 비효율적일 수도 있지만, 스크립트 복제는 비효율적일지언정 항상 동작한다. 복제 링크가 엄청난 양의 데이터를 전송해야 하는 상황을 만들지도 않고, 슬레이브가 마스터보다 훨씬 더 많은 작업을 해야 하는 상황을 만들지도 않는다. 그래서 지금까지 Redis는 항상 스크립트를 verbatim 그대로 복제해 왔다.

하지만 아마도 가장 흥미로운 부분은 이것이 단순히 효율성의 문제가 아니라는 점이다. 스크립트를 복제할 때는 각 스크립트가 *순수 함수*여야 한다. 동일한 초기 데이터셋으로 실행된 스크립트는 항상 같은 결과를 만들어야 한다. 이 요구사항 때문에 사용자는 예를 들어 TIME 명령어나 SRANDMEMBER를 사용하는 스크립트를 작성할 수 없다. Redis는 이러한 위험한 상황을 감지하고 첫 번째 쓰기 명령이 호출되려는 순간 스크립트를 중단시킨다.

하지만 현재 시간, 난수 또는 랜덤 요소를 사용하는 스크립트에 대한 사용 사례는 많다. 스크립트의 효과를 복제하면 이러한 제한도 극복할 수 있다.

그래서 마침내, 지난 몇 달 동안 Redis 내부에서 진행된 리팩터링 덕분에 스크립트 효과 복제에 대한 옵트인 지원을 구현할 수 있었다. 스크립트 시작 부분에서 다음 명령을 호출하는 것만큼 간단하다:

    redis.replicate_commands()
    … do something with the script …

스크립트는 쓰기 명령어들의 집합으로만 복제된다. 사실 replicate_commands()를 첫 번째 명령으로 호출할 필요는 없다. 어떤 쓰기 작업 전에만 호출하면 되므로, Lua 스크립트가 해야 할 작업을 확인한 뒤 적절한 복제 모델을 선택할 수도 있다. replicate_commands()가 호출될 때 이미 쓰기가 수행된 경우 단순히 false를 반환하고 일반적인 전체 스크립트 복제가 사용되므로, 잘못 사용하더라도 이 명령이 스크립트 실행을 막는 일은 절대 없다.

하지만 우리는 더 고급스럽고 어쩌면 위험할 수 있는 일을 하고 싶은 유혹을 참지 못했다. 나는 Redis Labs의 동료 Yossi Gottleib와 함께 이 기능을 설계했는데, 그는 복제 스트림에서 선택된 명령을 제외할 수 있는 위험한 기능에 대해 매우 설득력 있는 사용 사례를 가지고 있었다.

아이디어는 스크립트가 다음과 같은 일을 할 수 있다는 것이다:

  1. 임시 값을 쓰는 특정 명령들을 호출한다. 집합 간의 교집합을 떠올리면 이해하기 쉽다.
  2. 어떤 집계를 수행한다.
  3. 스크립트의 효과로서 작은 결과를 저장한다.
  4. 임시 값들을 버린다.

위 패턴에 대한 정당한 사용 사례가 몇 가지 있으며, 놀랍지 않게도 임시 쓰기를 AOF와 슬레이브에 복제하고 싶지 않을 것이다. “3” 단계만 복제하고 싶을 것이다. 그래서 결국 우리는 스크립트 효과 복제가 활성화된 경우, 과감한 사용자가 다음 API를 이용해 무엇을 복제하고 무엇을 복제하지 않을지 선택할 수 있도록 하기로 했다:

    redis.set_repl(redis.REPL_ALL); -- The default
    redis.set_repl(redis.REPL_NONE); -- No replication at all
    redis.set_repl(redis.REPL_AOF); -- Just AOF replication
    redis.set_repl(redis.REPL_SLAVE); -- Just slaves replication

오용의 여지가 많지만, 전문적이지 않은 사용자는 이 기능을 아예 건드릴 가능성이 매우 낮고, 강력한 도구를 어떻게 다뤄야 할지 아는 사람에게는 도움이 될 수 있다.

출시 일정

두 기능 모두 Redis 3.2에서 제공될 예정이며, 2015년 12월 중순에 RC로 출시될 예정이다. Redis 3.2는 API 레벨에서 사용자에게 노출되는 흥미로운 신기능들을 많이 포함할 예정이다. 운영과 내부 완성도에 더 집중했던 시기가 지나고 나서 사용자층의 상당수가 이를 요청해 왔다.

더 알고 싶은 점이나 궁금한 점이 있다면 댓글로 자유롭게 질문해 달라.

Hacker News 스레드는 여기에서 확인할 수 있다: https://news.ycombinator.com/item?id=10594236

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글