Y's note

Web技術・プロダクトマネジメント・そして経営について

本ブログの更新を停止しており、今後は下記Noteに記載していきます。
https://note.com/yutakikuchi/

【進撃の巨大データ】自作ApacheModuleとRedisでWebBrowserを一つ残らずUnique管理する


BrowserID管理の必要性

BehaviorTargeting調査レポート - Yuta.Kikuchiの日記 はてなブックマーク - BehaviorTargeting調査レポート - Yuta.Kikuchiの日記
進撃の巨人とADTechnologyの面白さを最近の楽しみとしている@yutakikuchi_です。BigDataという言葉が大変流行っていますが、巨大な力を持つ大量のユーザーアクセスとそれから生まれるログ、その処理と分析に追われるエンジニア/データサイエンティストはまさに進撃の巨人と人間の闘いのようです(笑)この記事のタイトルは進撃の巨人でエレンが言った「巨人を一匹残らず駆逐してやる」を文字っています。今日はそんな巨大データを扱うADTechnology分野のUserTrackingに欠かせないBrowser識別子とUnique管理について触れたいと思います。ADTechの面白さを少し話しておくと検索やKVS等の最新技術だけでなく機械学習や統計のアカデミック領域の知識も必要で、本当に毎日が勉強の連続です。目的はユーザーに最適な広告を表示してCTRを稼ぐ事でゲームをやってる時のワクワク、宝の山を探すロマン?!みたいなものを感じたりするんですよね。そんな最適な広告表示のためにCookieの中にBrowserIDという識別子を設定して、AccessLogからBrowserIDを取得してRedisに行動履歴を書き込むための技術を紹介します。実行環境はCentOSの6.3です。

Apacheの標準Moduleを使ってBrowserIDを生成

mod_usertrack

mod_usertrack.c はてなブックマーク -
ApacheModuleを自作する前に標準Moduleのmod_usertrackを使ってみます。mod_usertrackはCookieを指定した名前と有効期限で設定することができます。利用するためにはhttpd.confでLoadModule usertrack_module modules/mod_usertrack.soを有効にし、CookieTracking on CookieExpires "1 years" CookieName BrowserIDの3行を追記します。これで有効期間が1年間のBrowserIDを自動で発行します。追記が終わったらapacheのrestartを実行します。それでは設定したhostにアクセスしてみます。今回はlocalhostになります。curl --dump-headerでアクセスしたファイルのCookieを見てみるとBrowserID=::1.1372482316740936; という名前のCookieで識別子が振られている事がわかります。1.1372482745843590という値ですが、アクセス元IPとUnixTime情報の組み合わせのようです。しかしmod_usertrackのBrowserID生成には以下の問題があります。IPAddressとUnixTimeではパラメータのバリエーションが少ないので、以下の二つの条件が重なると問題が発生します。この問題を解決するために次章では自作のApache ModuleでBrowserIDを管理します。

  • アクセス元が携帯キャリアGWの場合同一になるケースが存在する。
  • UnixTimeに同一時間にRequestが来てしまうケースがある。
$ sudo vim /etc/httpd/conf/httpd.conf

LoadModule usertrack_module modules/mod_usertrack.so
CookieTracking on
CookieExpires "1 years"
CookieName BrowserID

$ sudo /usr/sbin/httpd -k restart

$ curl --dump-header - "http://localhost/cookie_test"
HTTP/1.1 200 OK
Date: Sat, 29 Jun 2013 05:12:25 GMT
Server: Apache/2.2.15 (CentOS)
Set-Cookie: BrowserID=::1.1372482745843590; path=/; expires=Sun, 29-Jun-14 05:12:25 GMT
Content-Length: 0
Connection: close
Content-Type: text/html; charset=UTF-8

Apache ModuleでBrowserIDを生成

Sample作成

mod_usertrackの問題を解決するためにCookieにBrowserIDを設定する処理のApacheModuleを自作します。当然ApacheModuleでやらなくてもいいのですがApplication側に逐一実装することが面倒になるのを回避するのと、C言語で処理を書いて高速化を狙います。まず最初に準備としてApacheModule開発に必要なパッケージをinstallします。またhttpd -lにてmod_so.cが入っている事を確認します。

$ sudo yum install httpd httpd-devel gcc -y
$ /usr/sbin/httpd -l
Compiled in modules:
  core.c
  prefork.c
  http_core.c
  mod_so.c

次にapxsというコマンドでTemplateを作成します。ちなみにapxsはAPache eXtenSion toolの略だそうです。apxsを利用してcookie_idという名前のModuleを作成します。作成されたcookie_id/mod_cookie_id.cというファイルを見てみると下のように定義されています。****_handler(request_rec *r)がメイン処理、****_register_hooks(apr_pool_t *p)がapacheのhookに登録する関数となります。cookie_id_handler(request_rec *r)の中では呼び込まれたHandlerがcookie_idか否かを判定し、cookie_idであればContetTypeをtext/html、HeaderRequestでなければ指定文言をResponseBodyで出力するという処理をしています。
サンプルとしてできたファイルを試しに仕込んでみます。apxsコマンドでCompileとInstallを一度にやってしまいます。apxsコマンドを実行するとCompile後のsoファイルを/usr/lib64/httpd/modules/に配置し、httpd.confにも自動でLoadModuleを追加します。strcmp(r->handler, "cookie_id")という行でHandlerがcookie_idかどうかを判定しているので、httpd.confのLocationタグで定義しておきます。この作業が面倒な場合はソース上からstrcmpの比較ロジックを削除しても良いと思います。httpd.confの修正が終わったらApacheの再起動をします。最後にLocationに仕込んだcookie_testというPathにアクセスしてみるとResponseBodyが出力されている事が分かります。

$ /usr/sbin/apxs -g -n cookie_id
Creating [DIR]  cookie_id
Creating [FILE] cookie_id/Makefile
Creating [FILE] cookie_id/modules.mk
Creating [FILE] cookie_id/mod_cookie_id.c
Creating [FILE] cookie_id/.deps
#include "httpd.h"
#include "http_config.h"
#include "http_protocol.h"
#include "ap_config.h"

/* The sample content handler */
static int cookie_id_handler(request_rec *r)
{
    if (strcmp(r->handler, "cookie_id")) {
        return DECLINED;
    }
    r->content_type = "text/html";      

    if (!r->header_only)
        ap_rputs("The sample page from mod_cookie_id.c\n", r);
    return OK;
}

static void cookie_id_register_hooks(apr_pool_t *p)
{
    ap_hook_handler(cookie_id_handler, NULL, NULL, APR_HOOK_MIDDLE);
}

/* Dispatch list for API hooks */
module AP_MODULE_DECLARE_DATA cookie_id_module = {
    STANDARD20_MODULE_STUFF, 
    NULL,                  /* create per-dir    config structures */
    NULL,                  /* merge  per-dir    config structures */
    NULL,                  /* create per-server config structures */
    NULL,                  /* merge  per-server config structures */
    NULL,                  /* table of config file commands       */
    cookie_id_register_hooks  /* register hooks                      */
};
$ sudo /usr/sbin/apxs -i -a -c mod_cookie_id.c

$ grep "cookie_id" /etc/httpd/conf/httpd.conf
LoadModule cookie_id_module   /usr/lib64/httpd/modules/mod_cookie_id.so

$ sudo vim /etc/httpd/conf/httpd.conf
#以下を追加
<Location "/cookie_test">
    SetHandler cookie_id
</Location>

$ sudo /usr/sbin/httpd -k restart

$ curl --dump-header - "http://localhost/cookie_test"
HTTP/1.1 200 OK
Date: Sat, 29 Jun 2013 01:01:33 GMT
Server: Apache/2.2.15 (CentOS)
Content-Length: 37
Connection: close
Content-Type: text/html; charset=UTF-8

The sample page from mod_cookie_id.c
BrowserIDの自動生成

上のサンプルコードを書き換えてSet-CookieにBrowserIDを仕込みます。BrowserIDは一意にしたいのでrequest_recの構造体から必要なデータを取り出してmd5のhash関数に掛けます。今回はRequestを受け付けたServerのIPアドレス、Requestを送信した側のIPアドレスApacheサーバのRequestTimeとConnectionID、RequestのUserAgentを文字列として連結しmd5のSeedにしています。これらのseedの基データはrequest_rec構造体から参照可能です。下のコードをapxsでコンパイルしてApacheを再起動するとSet-CookieのBrowserIDに32Byteの文字列を設定します。

#include "httpd.h"
#include "http_config.h"
#include "http_protocol.h"
#include "ap_config.h"
#include "util_md5.h"
#define COOKIE_NAME "BrowserID"

/* The sample content handler */
static int cookie_id_handler(request_rec *r)
{
    char *cookie_id;
    char cookie_string[256];
    char seed[1024];
    if (strcmp(r->handler, "cookie_id")) {
        return DECLINED;
    }
    r->content_type = "text/html";
    if (!r->header_only) {
      sprintf( seed, "%s%s%ld%ld%s", r->connection->local_ip, r->connection->remote_ip, r->request_time,r->connection->id, apr_table_get(r->headers_in, "User-Agent") );
      cookie_id = ap_md5( r->pool, (const unsigned char* )seed );
      sprintf( cookie_string, "%s=%s", COOKIE_NAME, cookie_id );
      apr_table_set(r->headers_out, "Set-Cookie", cookie_string );   
    }     
   return OK;
}

static void cookie_id_register_hooks(apr_pool_t *p)
{
    ap_hook_handler(cookie_id_handler, NULL, NULL, APR_HOOK_MIDDLE);
}

/* Dispatch list for API hooks */
module AP_MODULE_DECLARE_DATA cookie_id_module = {
    STANDARD20_MODULE_STUFF, 
    NULL,                  /* create per-dir    config structures */
    NULL,                  /* merge  per-dir    config structures */
    NULL,                  /* create per-server config structures */
    NULL,                  /* merge  per-server config structures */
    NULL,                  /* table of config file commands       */
    cookie_id_register_hooks  /* register hooks                      */
};
$ curl --dump-header - "http://localhost/cookie_test"
HTTP/1.1 200 OK
Date: Sat, 29 Jun 2013 02:19:23 GMT
Server: Apache/2.2.15 (CentOS)
Set-Cookie: BrowserID=faa6188355930572e9df5a133edd5c90
Content-Length: 0
Connection: close
Content-Type: text/html; charset=UTF-8
一度発行したBrowserIDは再利用する

上の例だとAccessする毎にBrowserIDが再発行されてしまいます。これではCookieとしての意味を為しません。そこで一度発行したBrowserIDは再利用することにします。またCookieの有効期限であるExpiresをhttpd.confの設定ファイルから読み込んで付与します。curlで同じようにRequestするとSet-Cookie: BrowserID=30ad38b6bbcb772c819cc8d15a84293d; path=/; expires=Tue, 01-Jul-14 16:28:28 GMTのようにexpiresが設定されていることが分かります。またWebBrowserからアクセスすると一度付与されてBrowserIDはExpiresの期限が来るまで再利用されます。

#include "apr.h"
#include "apr_lib.h"
#include "apr_strings.h"

#define APR_WANT_STRFUNC
#include "apr_want.h"

#include "httpd.h"
#include "http_config.h"
#include "http_core.h"
#include "http_request.h"
#include "util_md5.h"

module AP_MODULE_DECLARE_DATA cookie_id_module;

typedef struct {
    int always;
    int expires;
} cookie_log_state;

typedef enum {
    CT_UNSET,
    CT_NETSCAPE,
    CT_COOKIE,
    CT_COOKIE2
} cookie_type_e;

typedef struct {
    int enabled;
    cookie_type_e style;
    char *cookie_name;
    char *cookie_domain;
    char *regexp_string;  /* used to compile regexp; save for debugging */
    ap_regex_t *regexp;  /* used to find cookie_id cookie in cookie header */
} cookie_dir_rec;

/* Make Cookie: Now we have to generate something that is going to be
 * pretty unique.  We can base it on the pid, time, hostip */

#define COOKIE_NAME "BrowserID"

static void make_cookie(request_rec *r)
{
    cookie_log_state *cls = ap_get_module_config(r->server->module_config,
                                                 &cookie_id_module);
    /* 1024 == hardcoded constant */
    char cookiebuf[1024];
    char *new_cookie;
    char *cookie_id;
    char seed[1024];
    
    cookie_dir_rec *dcfg;
    dcfg = ap_get_module_config(r->per_dir_config, &cookie_id_module);
    sprintf( seed, "%s%s%ld%ld%s", r->connection->local_ip, r->connection->remote_ip, r->request_time,r->connection->id,apr_table_get(r->headers_in, "User-Agent") );
    cookie_id = ap_md5( r->pool, (const unsigned char* )seed );

    /* XXX: hmm, this should really tie in with mod_unique_id */
    apr_snprintf(cookiebuf, sizeof(cookiebuf), "%s", cookie_id);

    if (cls->expires) {

        /* Cookie with date; as strftime '%a, %d-%h-%y %H:%M:%S GMT' */
        new_cookie = apr_psprintf(r->pool, "%s=%s; path=/",
                                  dcfg->cookie_name, cookiebuf);

        if ((dcfg->style == CT_UNSET) || (dcfg->style == CT_NETSCAPE)) {
            apr_time_exp_t tms;
            apr_time_exp_gmt(&tms, r->request_time
                                 + apr_time_from_sec(cls->expires));
            new_cookie = apr_psprintf(r->pool,
                                       "%s; expires=%s, "
                                       "%.2d-%s-%.2d %.2d:%.2d:%.2d GMT",
                                       new_cookie, apr_day_snames[tms.tm_wday],
                                       tms.tm_mday,
                                       apr_month_snames[tms.tm_mon],
                                       tms.tm_year % 100,
                                       tms.tm_hour, tms.tm_min, tms.tm_sec);
        }
        else {
            new_cookie = apr_psprintf(r->pool, "%s; max-age=%d",
                                      new_cookie, cls->expires);
        }
    }
    else {
        new_cookie = apr_psprintf(r->pool, "%s=%s; path=/",
                                  dcfg->cookie_name, cookiebuf);
    }
    if (dcfg->cookie_domain != NULL) {
        new_cookie = apr_pstrcat(r->pool, new_cookie, "; domain=",
                                 dcfg->cookie_domain,
                                 (dcfg->style == CT_COOKIE2
                                  ? "; version=1"
                                  : ""),
                                 NULL);
    }

    apr_table_addn(r->headers_out,
                   (dcfg->style == CT_COOKIE2 ? "Set-Cookie2" : "Set-Cookie"),
                   new_cookie);
    apr_table_setn(r->notes, "cookie", apr_pstrdup(r->pool, cookiebuf));   /* log first time */
    return;
}

/* dcfg->regexp is "^cookie_name=([^;]+)|;[ \t]+cookie_name=([^;]+)",
 * which has three subexpressions, $0..$2 */
#define NUM_SUBS 3

static void set_and_comp_regexp(cookie_dir_rec *dcfg,
                                apr_pool_t *p,
                                const char *cookie_name)
{
    int danger_chars = 0;
    const char *sp = cookie_name;

    /* The goal is to end up with this regexp,
     * ^cookie_name=([^;,]+)|[;,][ \t]+cookie_name=([^;,]+)
     * with cookie_name obviously substituted either
     * with the real cookie name set by the user in httpd.conf, or with the
     * default COOKIE_NAME. */

    /* Anyway, we need to escape the cookie_name before pasting it
     * into the regex
     */
    while (*sp) {
        if (!apr_isalnum(*sp)) {
            ++danger_chars;
        }
        ++sp;
    }

    if (danger_chars) {
        char *cp;
        cp = apr_palloc(p, sp - cookie_name + danger_chars + 1); /* 1 == \0 */
        sp = cookie_name;
        cookie_name = cp;
        while (*sp) {
            if (!apr_isalnum(*sp)) {
                *cp++ = '\\';
            }
            *cp++ = *sp++;
        }
        *cp = '\0';
    }

    dcfg->regexp_string = apr_pstrcat(p, "^",
                                      cookie_name,
                                      "=([^;,]+)|[;,][ \t]*",
                                      cookie_name,
                                      "=([^;,]+)", NULL);

    dcfg->regexp = ap_pregcomp(p, dcfg->regexp_string, AP_REG_EXTENDED);
    ap_assert(dcfg->regexp != NULL);
}

static int spot_cookie(request_rec *r)
{
    cookie_dir_rec *dcfg = ap_get_module_config(r->per_dir_config,
                                                &cookie_id_module);
    const char *cookie_header;
    ap_regmatch_t regm[NUM_SUBS];

    /* Do not run in subrequests */
    if (!dcfg->enabled || r->main) {
        return DECLINED;
    }

    if ((cookie_header = apr_table_get(r->headers_in, "Cookie"))) {
        if (!ap_regexec(dcfg->regexp, cookie_header, NUM_SUBS, regm, 0)) {
            char *cookieval = NULL;
            /* Our regexp,
             * ^cookie_name=([^;]+)|;[ \t]+cookie_name=([^;]+)
             * only allows for $1 or $2 to be available. ($0 is always
             * filled with the entire matched expression, not just
             * the part in parentheses.) So just check for either one
             * and assign to cookieval if present. */
            if (regm[1].rm_so != -1) {
                cookieval = ap_pregsub(r->pool, "$1", cookie_header,
                                       NUM_SUBS, regm);
            }
            if (regm[2].rm_so != -1) {
                cookieval = ap_pregsub(r->pool, "$2", cookie_header,
                                       NUM_SUBS, regm);
            }
            /* Set the cookie in a note, for logging */
            apr_table_setn(r->notes, "cookie", cookieval);

            return DECLINED;    /* There's already a cookie, no new one */
        }
    }
    make_cookie(r);
    return OK;                  /* We set our cookie */
}

static void *make_cookie_log_state(apr_pool_t *p, server_rec *s)
{
    cookie_log_state *cls =
    (cookie_log_state *) apr_palloc(p, sizeof(cookie_log_state));

    cls->expires = 0;

    return (void *) cls;
}

static void *make_cookie_dir(apr_pool_t *p, char *d)
{
    cookie_dir_rec *dcfg;

    dcfg = (cookie_dir_rec *) apr_pcalloc(p, sizeof(cookie_dir_rec));
    dcfg->cookie_name = COOKIE_NAME;
    dcfg->cookie_domain = NULL;
    dcfg->style = CT_UNSET;
    dcfg->enabled = 0;

    /* In case the user does not use the CookieName directive,
     * we need to compile the regexp for the default cookie name. */
    set_and_comp_regexp(dcfg, p, COOKIE_NAME);

    return dcfg;
}

static const char *set_cookie_enable(cmd_parms *cmd, void *mconfig, int arg)
{
    cookie_dir_rec *dcfg = mconfig;

    dcfg->enabled = arg;
    return NULL;
}

static const char *set_cookie_exp(cmd_parms *parms, void *dummy,
                                  const char *arg)
{
    cookie_log_state *cls;
    time_t factor, modifier = 0;
    time_t num = 0;
    char *word;

    cls  = ap_get_module_config(parms->server->module_config,
                                &cookie_id_module);
    /* The simple case first - all numbers (we assume) */
    if (apr_isdigit(arg[0]) && apr_isdigit(arg[strlen(arg) - 1])) {
        cls->expires = atol(arg);
        return NULL;
    }

    /*
     * The harder case - stolen from mod_expires
     *
     * CookieExpires "[plus] {<num> <type>}*"
     */

    word = ap_getword_conf(parms->pool, &arg);
    if (!strncasecmp(word, "plus", 1)) {
        word = ap_getword_conf(parms->pool, &arg);
    };

    /* {<num> <type>}* */
    while (word[0]) {
        /* <num> */
        if (apr_isdigit(word[0]))
            num = atoi(word);
        else
            return "bad expires code, numeric value expected.";

        /* <type> */
        word = ap_getword_conf(parms->pool, &arg);
        if (!word[0])
            return "bad expires code, missing <type>";

        factor = 0;
        if (!strncasecmp(word, "years", 1))
            factor = 60 * 60 * 24 * 365;
        else if (!strncasecmp(word, "months", 2))
            factor = 60 * 60 * 24 * 30;
        else if (!strncasecmp(word, "weeks", 1))
            factor = 60 * 60 * 24 * 7;
        else if (!strncasecmp(word, "days", 1))
            factor = 60 * 60 * 24;
        else if (!strncasecmp(word, "hours", 1))
            factor = 60 * 60;
        else if (!strncasecmp(word, "minutes", 2))
            factor = 60;
        else if (!strncasecmp(word, "seconds", 1))
            factor = 1;
        else
            return "bad expires code, unrecognized type";

        modifier = modifier + factor * num;

        /* next <num> */
        word = ap_getword_conf(parms->pool, &arg);
    }

    cls->expires = modifier;

    return NULL;
}

static const char *set_cookie_name(cmd_parms *cmd, void *mconfig,
                                   const char *name)
{
    cookie_dir_rec *dcfg = (cookie_dir_rec *) mconfig;

    dcfg->cookie_name = apr_pstrdup(cmd->pool, name);

    set_and_comp_regexp(dcfg, cmd->pool, name);

    if (dcfg->regexp == NULL) {
        return "Regular expression could not be compiled.";
    }
    if (dcfg->regexp->re_nsub + 1 != NUM_SUBS) {
        return apr_pstrcat(cmd->pool, "Invalid cookie name \"",
                           name, "\"", NULL);
    }

    return NULL;
}

/*
 * Set the value for the 'Domain=' attribute.
 */
static const char *set_cookie_domain(cmd_parms *cmd, void *mconfig,
                                     const char *name)
{
    cookie_dir_rec *dcfg;

    dcfg = (cookie_dir_rec *) mconfig;

    /*
     * Apply the restrictions on cookie domain attributes.
     */
    if (strlen(name) == 0) {
        return "CookieDomain values may not be null";
    }
    if (name[0] != '.') {
        return "CookieDomain values must begin with a dot";
    }
    if (ap_strchr_c(&name[1], '.') == NULL) {
        return "CookieDomain values must contain at least one embedded dot";
    }

    dcfg->cookie_domain = apr_pstrdup(cmd->pool, name);
    return NULL;
}

/*
 * Make a note of the cookie style we should use.
 */
static const char *set_cookie_style(cmd_parms *cmd, void *mconfig,
                                    const char *name)
{
    cookie_dir_rec *dcfg;

    dcfg = (cookie_dir_rec *) mconfig;

    if (strcasecmp(name, "Netscape") == 0) {
        dcfg->style = CT_NETSCAPE;
    }
    else if ((strcasecmp(name, "Cookie") == 0)
             || (strcasecmp(name, "RFC2109") == 0)) {
        dcfg->style = CT_COOKIE;
    }
    else if ((strcasecmp(name, "Cookie2") == 0)
             || (strcasecmp(name, "RFC2965") == 0)) {
        dcfg->style = CT_COOKIE2;
    }
    else {
        return apr_psprintf(cmd->pool, "Invalid %s keyword: '%s'",
                            cmd->cmd->name, name);
    }

    return NULL;
}

static const command_rec cookie_log_cmds[] = {
    AP_INIT_TAKE1("CookieExpires", set_cookie_exp, NULL, OR_FILEINFO,
                  "an expiry date code"),
    AP_INIT_TAKE1("CookieDomain", set_cookie_domain, NULL, OR_FILEINFO,
                  "domain to which this cookie applies"),
    AP_INIT_TAKE1("CookieStyle", set_cookie_style, NULL, OR_FILEINFO,
                  "'Netscape', 'Cookie' (RFC2109), or 'Cookie2' (RFC2965)"),
    AP_INIT_FLAG("CookieTracking", set_cookie_enable, NULL, OR_FILEINFO,
                 "whether or not to enable cookies"),
    AP_INIT_TAKE1("CookieName", set_cookie_name, NULL, OR_FILEINFO,
                  "name of the tracking cookie"),
    {NULL}
};

static void register_hooks(apr_pool_t *p)
{
    ap_hook_fixups(spot_cookie,NULL,NULL,APR_HOOK_FIRST);
}

module AP_MODULE_DECLARE_DATA cookie_id_module = {
    STANDARD20_MODULE_STUFF,
    make_cookie_dir,            /* dir config creater */
    NULL,                       /* dir merger --- default is to override */
    make_cookie_log_state,      /* server config */
    NULL,                       /* merge server configs */
    cookie_log_cmds,            /* command apr_table_t */
    register_hooks              /* register hooks */
};
$ curl --dump-header - "http://localhost/cookie_test"
HTTP/1.1 200 OK
Date: Mon, 01 Jul 2013 16:28:28 GMT
Server: Apache/2.2.15 (CentOS)
Set-Cookie: BrowserID=30ad38b6bbcb772c819cc8d15a84293d; path=/; expires=Tue, 01-Jul-14 16:28:28 GMT
Last-Modified: Mon, 01 Jul 2013 16:22:55 GMT
ETag: "182275-0-4e075a419b0e4"
Accept-Ranges: bytes
Content-Length: 0
Connection: close
Content-Type: text/plain; charset=UTF-8

RedisでUnique管理

Redis, hiredisの設定

Redis はてなブックマーク - Redis
redis/hiredis はてなブックマーク - redis/hiredis
上で生成したBrowserIDをUnique管理するためにInMemeoryのKVSであるRedisを利用します。可能性を言えば非常に低いですが、md5なので衝突が無いとは言い切れないので念のためKVSに発行済BrowserIDを記録しておいて衝突が無いようにチェックします。RedisをApache Moduleから接続するためにCのライブラリであるhiredisを利用します。Redisの特徴はGoogle先生に聞けば沢山ドキュメントがでてきますが代表的な事を挙げておくと、インメモリだからスピードが速い、永続化のために非同期でディスクに書き込む、String型/List型/Set型/sort済みSet型/Hash型など様々なデータ型をサポート、データ分散のシャーディング等でしょうか。次はその設定手順を記述します。hiredisはgitからcloneしてmake,make installで入れるかrpmパッケージで入れるかのどちらかを選んでください。パッケージの方が設定が楽だと思いますのでお勧めです。最後にldconfigでhiredisのsoオブジェクトを読み込む命令を忘れないようにしましょう。設定が完了したらRedisにPingを送信してその結果を出力します。

$ wget "http://redis.googlecode.com/files/redis-2.6.14.tar.gz"
$ tar xzf redis-2.6.14.tar.gz
$ cd redis-2.6.14
$ make && sudo make install
$ sudo redis-server

// makeしてinstallする場合
$ git clone git://github.com/redis/hiredis.git
$ cd hiredis
$ make && sudo make install
$ sudo ldconfig

// rpmパッケージで入れる場合
$ sudo rpm -ivh http://dl.fedoraproject.org/pub/epel/6/x86_64/hiredis-0.10.1-3.el6.x86_64.rpm
$ sudo rpm -ivh http://dl.fedoraproject.org/pub/epel/6/x86_64/hiredis-devel-0.10.1-3.el6.x86_64.rpm
$ sudo ldconfig

//ldconfigの確認
$ ldconfig -p | grep redis
libhiredis.so.0.10 (libc6,x86-64) => /usr/lib64/libhiredis.so.0.10
libhiredis.so (libc6,x86-64) => /usr/lib64/libhiredis.so

$ vi redis-test.c
$ gcc redis-test.c -I/usr/include/hiredis -L/usr/lib64 -lhiredis
$ ./a.out
$ Redis Response : PONG
#include <stdio.h>
#include "hiredis.h"
int main(void) {
    redisContext *rc = redisConnect("127.0.0.1", 6379);
    if (rc->err) {
        printf( "Connection Error: %s\n", rc->errstr );
        return 1;
    }
    redisReply *reply = redisCommand(rc, "PING");
    printf("Redis Response : %s\n", reply->str);
    freeReplyObject(reply);
    return 0;
}
hiredisをApache Moduleから呼び出してUniqueIDを生成する

上で自作したApache Moduleにhiredisを設定します。以下では差分だけを記載しますが、make_cookie関数の中にredisへの接続プログラムを記述するだけです。新規に発行したBrowserIDがredisに登録されていないかどうかをチェックし、登録されていれば再発行を3回まで繰り返すようにしています。apxsでinstallするときはIncludeとLibraryのパスの指定を行います。installが完了したらapacheの再起動をしてcurlでアクセスしてみます。そうすると新しい32ByteのUniqueBrowserIDが発行されて、redis-cliでRedisにもデータの記録が残っていることを確認します。以上でUniqueなBrowserIDが生成されるようになりました。※imgの読み込みのRequestでBrowserIDを発行したく無い場合は、httpd.confで特定のLocationでLoadModuleの設定を入れるようにします。

@@ -63,6 +63,7 @@
 #include "http_core.h"
 #include "http_request.h"
 #include "util_md5.h"
+#include "hiredis.h"
 
 module AP_MODULE_DECLARE_DATA cookie_id_module;
 
@@ -91,6 +92,7 @@
  * pretty unique.  We can base it on the pid, time, hostip */
 
 #define COOKIE_NAME "BrowserID"
+int counter = 0;
 
 static void make_cookie(request_rec *r)
 {
@@ -107,6 +109,24 @@
     sprintf( seed, "%s%s%ld%ld%s", r->connection->local_ip, r->connection->remote_ip, r->request_time,r->connection->id,apr_table_get(r->headers_in, "User-Agent") );
     cookie_id = ap_md5( r->pool, (const unsigned char* )seed );
 
+    redisContext *rc = redisConnect("127.0.0.1", 6379);
+    if( counter < 3 ) {
+      counter++;
+      redisReply *reply = redisCommand(rc, "EXISTS %s", cookie_id );
+      if( reply->integer == 1 ) { 
+         freeReplyObject(reply);
+         make_cookie( r );
+      }
+    } else {
+      return;
+    }
+
+    redisReply *reply = redisCommand(rc, "SET %s %s", cookie_id, "1" );
+    if( strcmp( reply->str, "OK") != 0 ) {
+      return; 
+    }
+    freeReplyObject(reply);
+
     /* XXX: hmm, this should really tie in with mod_unique_id */
     apr_snprintf(cookiebuf, sizeof(cookiebuf), "%s", cookie_id);
$ sudo apxs -i -c -I/usr/include/hiredis -L/usr/lib64 -lhiredis mod_cookie_id.c

$ sudo /usr/sbin/httpd -k restart

$ curl --dump-header - "http://localhost/cookie_test"
HTTP/1.1 200 OK
Date: Tue, 02 Jul 2013 14:24:16 GMT
Server: Apache/2.2.15 (CentOS)
Set-Cookie: BrowserID=2a41c121fa3aa6c242301063bc026b5d; path=/; expires=Wed, 02-Jul-14 14:24:16 GMT
Last-Modified: Mon, 01 Jul 2013 16:22:55 GMT
ETag: "182275-0-4e075a419b0e4"
Accept-Ranges: bytes
Content-Length: 0
Connection: close
Content-Type: text/plain; charset=UTF-8

//redis上のデータを確認
$ redis-cli     
redis 127.0.0.1:6379> keys *
1) "2a41c121fa3aa6c242301063bc026b5d"
2) "da4b1f44ff74392dbe33fc7bf4086b86"

//特定のLocation以下だけBrowserIDを発行
$ sudo vim /etc/httpd/conf/httpd.conf
<Location /cookie_test>
   LoadModule cookie_id_module modules/mod_cookie_id.so
</Location>
AccessLogにもBrowserIDを記録する

Log解析をする時にBrowserIDを持つユーザーがどのような行動を起こしたのかを追跡するためにApacheAccessLogにもBrowserIDを記録しておきます。以下のLogFormat設定をhttpd.confに設定をしてapacheを再起動します。これでBroserIDのUnique管理ができ、Log解析でもBrowserIDを基にユーザーの行動履歴を追跡する事が可能になりました。後はLog解析用のパーサーを書いて行動履歴を追跡すれば良いと思います。

$ sudo vim /etc/httpd/conf/httpd.conf

#以下を追記
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %b" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent
LogFormat "%{cookie}i %t %{Referer}i -> %U" cookie
CustomLog logs/cookie_log cookie

$ sudo /usr/sbin/httpd -k restart

$ curl -H "Cookie:BrowserID=2a41c121fa3aa6c242301063bc026b5d;" http://localhost/cookie_test

$ cat /var/log/httpd/cookie_log
BrowserID=2a41c121fa3aa6c242301063bc026b5d; [02/Jul/2013:23:57:13 +0900] - -> /cookie_test

まとめと参考リンク

まとめ

巨大データからユーザーの行動履歴を解析するためにアクセスBrowserに対してUniqueなIDを発行する必要があります。IDを発行するのはApplicationを作るのではなく、ApacheModuleで実装して高速化を図ります。今回の実装ではmod_usetrackのソースコードを参考にmd5のBrowserIDを生成するロジックを記述し、Redisを使ってUnique管理する方法を試してみました。次回はLog解析からRedisにデータを書き込むような処理の流れを書いてみたいと思います。