bashでmemcachedを実装しました!

  • 240
    いいね
  • 0
    コメント

TL;DR

bashとsocatコマンドを利用してmemcachedを実装しました。

MakeNowJust/bashcached - GitHub

本文

はじめに

bashでmemcachedを実装したらHacker Newsでちょっと話題になったみたいなので記事にします。

とりあえずインストール方法

インストール方法です。

まずsocatが必要なので、

$ sudo brew install socat # macOSなら
$ sudo apt install socat  # Ubuntuなら

とかやってインストールしてください。

そしたら、

$ curl -LO https://raw.githubusercontent.com/MakeNowJust/bashcached/master/bashcached
$ chmod +x bashcached

とかやってスクリプト本体を落としてきて実行権限を付けてください。

あとは、

$ ./bashcached &

として実行するだけです。何も出力されなければ多分動いてます。

ちょっと試してみる

せっかくなのでRubyのmemcachedのライブラリから叩いてみたいと思います。

require 'memcached'

# bashcachedはデフォルトで25252番ポートを開きます
client = Memcached.new "localhost:25252"

# "hello" に "world" をセット
client.set "hello", "world"

# "hello" の値を取得
p client.get "hello" #=> "world"

実行してみると動いてるのが確認できると思います。

実装までの道程

さて、ここからはどうやってbashでmemcachedを実装していったのかを説明していきたいと思います。

memcachedとは

いまさら説明するまでもありませんが、memcachedというのは分散型キャッシュシステムです。分散型キャッシュシステムが何かとか、memcachedがどんなものかもっと説明が見たければWikipediaの記事なりを参照してください。

要は、時間のかかる処理の結果をメモリに置いておいて、高速に参照にできるようにするためのものです。

で、このmemcachedなんですか、内部で使われているプロトコルが公開されていたりします。

memcached/protocol.txt - GitHub

memcachedのプロトコルにはバイナリ版とテキスト版があって、テキスト版はこんな感じになります。

例えばsetなら、

$ telnet localhost 25252
set hello 0 0 5
world
STORED

最初の2行(setからworldまで)がmemcachedへの命令で、STOREDがmemcachedサーバーの応答です。

またgetなら、

get hello
VALUE hello 0 5
world
ENND

のようになります。(getから始まる行がmemcachedへの命令で、以降のVALUE〜というのがサーバーの応答です)

見れば分かると思いますが、単純な行指向のテキストプロトコルです。行指向のテキスト処理といったらならシェルスクリプトの得意技ですよね? というわけでbashで実装しました。

socatとは

ただ、bash単体ではネットワーク通信をすることができません。なので仕方なくsocatを使うことにしました。

(Hacker Newsに投稿した時点ではncatを使っていたのですが、ncatだとCtrl-Cで終了したときにポートをlistenしているプロセスが残ってしまって面倒だったので変えました。あとreuseaddrも設定できないし)

socatでTCPをlistenして特定のコマンドを起動するには次のようにします。

$ socat tcp-listen:25252,reuseaddr,fork system:cat

この場合は、25252番ポートを開いて、接続時にcatコマンドを起動するように指定しています。

なので、

$ telnet localhost 25252

のようにして繋げば、エコーサーバーになっているはずです。

また、このようなスクリプトを書けば、

#!/usr/bin/env bash

if [[ -z $COUNT ]]; then
  # 最初に実行したときだけここに来る
  export COUNT=0
  socat tcp-listen:25252,fork system:"$0" # サーバーを起動
else
  # 各アクセス毎に実行される場所
  echo $COUNT
fi

アクセスがある度に自分自身を起動するサーバーになることができます。これによって、サーバーとして機能する部分と、アクセスがある度に応答を返す部分を一つのスクリプトにすることができます。

名前付きパイプについて

socatでサーバーを作ることができるのはいいのですが、このままだと毎回別プロセスになってしまうため、状態を保存することができません。

つまり、先程のスクリプトをこのようにしても、

#!/usr/bin/env bash

if [[ -z $COUNT ]]; then
  # 最初に実行したときだけここに来る
  export COUNT=0
  socat tcp-listen:25252,fork system:"$0" # サーバーを起動
else
  # 各アクセス毎に実行される場所
  echo $[++COUNT] # COUNTをインクリメントしつつ表示
fi

COUNTの値は変わらず0のままです。

そこで、サーバーを起動するメインのプロセスと応答を返す各プロセスの間で、プロセス間通信を行ないます。

その、プロセス間通信に使うのが名前付きパイプになります。

名前付きパイプはmkfifoというコマンドで作成できて、読み込むときは書き込みがあるまで、書き込みがあるときは読み込みがあるまで処理をブロックするという性質があります。

詳しいことはWikipediaの記事とかman mkfifoとかman fifoを見てください。

名前付きパイプを使って先程のスクリプトをCOUNTをインクリメントするように改良します。

#!/usr/bin/env bash
IFS=$' \t\n\r' # ネットワークを介すので\nに加えて\rも区切りにする

if [[ -z $PIPE ]]; then
  # 最初に実行したときだけここに来る

  # 一時ファイルとして名前付きパイプを作成
  export PIPE="$(mktemp -u)"; mkfifo -m 600 "$PIPE"
  trap "rm -f '$PIPE'" EXIT # スクリプトの終了時に削除

  socat tcp-listen:25252,fork system:"$0" & # サーバーを起動
  #                                       ^-- socatで止まらないようにバックグラウンドで起動

  COUNT=0
  # メインループ
  # 各行の命令を受け取り、その結果をパイプを介して返す
  while true; do cat "$PIPE"; done | while read -ra cmd; do
    recv="$(echo -n "${cmd[0]}" | base64 -d)"
    case ${cmd[1]} in
    incr)
      echo $[++COUNT] >"$recv";;
    esac
  done
else
  # 各アクセス毎に実行される場所

  # 一時ファイルとして名前付きパイプを作成
  recv="$(mktemp -u)"; mkfifo -m 600 "$recv"
  # 名前付きパイプと命令を送信
  # (base64でエンコードするのは空白を含むかもしれないから)
  echo $(echo -n "$recv" | base64 -w0) incr >"$PIPE"
  # 結果を受け取る
  cat "$recv"
  rm -f "$recv" # 名前付きパイプを削除
fi

これをtelnetを使って試してみましょう。

$ telnet localhost 25252
1
$ telnet localhost 25252
2

アクセスする度に数値が増えていっているのが分かると思います。

さて、これだとアクセスすると一瞬で結果を返して接続が終わってしまいます。命令を受け取って結果を返すような、対話的な仕組みに改造しましょう。

#!/usr/bin/env bash
IFS=$' \t\n\r' # ネットワークを介すので\nに加えて\rも区切りにする

if [[ -z $PIPE ]]; then
  # 最初に実行したときだけここに来る

  # 一時ファイルとして名前付きパイプを作成
  export PIPE="$(mktemp -u)"; mkfifo -m 600 "$PIPE"
  trap "rm -f '$PIPE'" EXIT # スクリプトの終了時に削除

  socat tcp-listen:25252,fork system:"$0" & # サーバーを起動
  #                                       ^-- socatで止まらないようにバックグラウンドで起動

  COUNT=0
  # メインループ
  # 各行の命令を受け取り、その結果をパイプを介して返す
  while true; do cat "$PIPE"; done | while read -ra cmd; do
    recv="$(echo -n "${cmd[0]}" | base64 -d)"
    case ${cmd[1]} in
    incr)
      echo $[++COUNT] >"$recv";;
    decr)
      echo $[--COUNT] >"$recv";;
    esac
  done
else
  # 各アクセス毎に実行される場所

  # 一時ファイルとして名前付きパイプを作成
  recv="$(mktemp -u)"; mkfifo -m 600 "$recv"
  # メインのプロセスから送られてきた結果を受け取る
  while [[ -p "$recv" ]]; do cat "$recv"; done &
  trap "rm -f '$recv'" EXIT # 接続の終了時にパイプを削除

  # 命令を受け取り、それをメインに送信する
  while read -ra cmd; do
    [[ ${cmd[0]} == "quit" ]] && break
    echo "$(echo -n "$recv" | base64 -w0) ${cmd[*]}" >"$PIPE"
  done
fi

これでインクリメントだけでなくデクリメントもできるようになりました。

$ telnet localhost 25252
incr
1
incr
2
decr
1
incr
2
decr
1
quit

incrという文字列を送る度に値がインクリメントされて、decrという文字列を送る度に値がデクリメントされていることが分かると思います。また、quitと送られるとアクセスに対応するプロセスが終了するので、接続も終了します。

この時点で複数接続で値を共有するカウンタになっています。かなりmemcachedに近付いた、というかここまで来ればもうあと一踏ん張りです。

連想配列

実は、といってもかなり有名な話ですが、bashはバージョン3から連想配列を組み込みで持っています。なので、それを使えばmemcachedのようなKVSは簡単に実装できます。

連想配列は下のように使います。

declare -A kvs # 連想配列を宣言(宣言しないと使えません)

# なんかそれっぽい感じ値をセットする
kvs[key1]=value1
kvs[key2]=value2
# 取得もそれっぽい感じ
echo "${kvs[key1]}" #=> value1

# キーの一覧を取り出すには"${!kvs[@]}"とする
for key in "${!kvs[@]}"; do
  echo "kvs[$key]=${kvs[$key]}"
done
#=> kvs[key1]=value1
#=> kvs[key2]=value2

# キーの削除はunset
unset kvs[key1]

先程のスクリプトを改造して、incrdecrの代わりにsetgetという命令を、連想配列を使い実装してみます。

#!/usr/bin/env bash
IFS=$' \t\n\r' # ネットワークを介すので\nに加えて\rも区切りにする

if [[ -z $PIPE ]]; then
  # 最初に実行したときだけここに来る

  # 一時ファイルとして名前付きパイプを作成
  export PIPE="$(mktemp -u)"; mkfifo -m 600 "$PIPE"
  trap "rm -f '$PIPE'" EXIT # スクリプトの終了時に削除

  socat tcp-listen:25252,fork system:"$0" & # サーバーを起動
  #                                       ^-- socatで止まらないようにバックグラウンドで起動

  declare -A KVS=()
  # メインループ
  # 各行の命令を受け取り、その結果をパイプを介して返す
  while true; do cat "$PIPE"; done | while read -ra cmd; do
    recv="$(echo -n "${cmd[0]}" | base64 -d)"
    case ${cmd[1]} in
    set)
      KVS[${cmd[2]}]=${cmd[3]}
      echo STORED>"$recv";;
    get)
      echo "${KVS[${cmd[2]}]}">"$recv";;
    esac
  done
else
  # 各アクセス毎に実行される場所

  # 一時ファイルとして名前付きパイプを作成
  recv="$(mktemp -u)"; mkfifo -m 600 "$recv"
  # メインのプロセスから送られてきた結果を受け取る
  while [[ -p "$recv" ]]; do cat "$recv"; done &
  trap "rm -f '$recv'" EXIT # 接続の終了時にパイプを削除

  # 命令を受け取り、それをメインに送信する
  while read -ra cmd; do
    [[ ${cmd[0]} == "quit" ]] && break
    echo "$(echo -n "$recv" | base64 -w0) ${cmd[*]}" >"$PIPE"
  done
fi

試してみます。

$ telnet localhost 25252
set hello world
STORED
get hello
world
quit

set命令でhelloというキーに対してworldという値をセットし、get命令でhelloにセットされた値を読み出しています。

これで簡単なKVSが実装できました。ここからは地味にひたすらmemcachedの細かい仕様を実装していくだけです。
嘘です。
本当はmemcachedのstorage系の命令のdataを表す部分をメインに渡す方が難しかったのですが、それはbashcachedのコードを読んで読み解いてください。実のことを言えば、こんな記事を読むよりもコードを読んだ方がbashcachedの理解は進むんじゃないかと思います。

あとがき

Hacker Newsに投稿した時点ではこんなにウケるとは思っていませんでした。
というか、自分でも作っているときから「何の役に立つんだかよく分かんないものを作ってるなぁ」という気持ちでやっていたので、Hacker Newsに上げてもウケないだろうな、となんとなく考えていました。

これがウケたのは、恐らくHacker Newsを見ている連中がbashが好きだからなのでしょう。
Hacker Newsの "Cool. Why?" というコメントに対して "Because bash." というリプライがあるのは中々気が利いていると思います。

またCRON.WEEKLYというサイトでも紹介していただいたみたいです。Node.jsのv7.0.0のリリースと並んで自分の作ったものが紹介されていると不思議な気持ちになります。

ちなみにbashcachedのパフォーマンスは散々(ボクの手元ではmemcachedが秒間6000命令はこなせるのに対してbashcachedは秒間30命令色々いじっていたら60命令くらいになったとか)なので、実用は厳しいと思います。

最後に、どうしてこんなものを作ったのか、ボクの方から一言伝えておきたいと思います。

んなもん、面白そうだったからに決まってます。