重複行をまとめた上でのカウントはGROUP BYではなくDISTINCTを使う

"重複したカラムをまとめた上でのカウント" がしたい時に、いつもどおりGROUP BYを使ったらうまくカウントできませんでした。
そりゃそうだろ、という話ではありますが。
以下、MySQLを使っています。


例えば、ユーザが何かしらの投稿をするサイトがあって、以下の様なデータベースが存在する場合を考えます。 f:id:norikone:20160409030058p:plain f:id:norikone:20160409030523p:plain f:id:norikone:20160409030520p:plain


ここで、1回でも投稿したことがあるユーザの数を求めたいとします。
この場合だと、投稿したことがあるのは ichirou と jirou の2人です。

直観的に posts テーブルを GROUP BY user_id でまとめてカウントしても意図した結果は返ってきません。
f:id:norikone:20160409034804p:plain ここに表示されている行数が返ってきて欲しいのです。


このような場合は、GROUP BY ではなく DISTINCT でまとめると上手くいきます。
f:id:norikone:20160409035448p:plain


DISTINCT でまとめれば上手くいきますが、場合によってはクエリ最適化が働いて、以下のように GROUP BY したものをカウントしたほうが早くなることがあるようです。 f:id:norikone:20160409040820p:plain



おわり。

Slack上で日程調整するアプリをGASで書いてみる

最近 GAS(Google Apps Script) や SlackBot の存在を知って、慣れていけばいろいろ捗りそうな気がしたので触ってみた時のメモ。

今は特に GAS で自動化したいようなものは無かったので、SlackBot と連携して簡易的な日程調整、出席管理アプリを作って遊んでみました。
JavaScript 自体も全然触ったことがなかったので、書いてみて少しは勉強になったかなと思います。
尚、実用性は皆無だと思われ(ry。

簡単にアプリの紹介

Slack 上で日程調整ができるアプリです。
特定のチャンネルから特定のワードを付けてメッセージを投稿すると、Google Spread Sheet と連携して出席状況の管理などを行ってくれます。

イベントの登録

新しくイベント(予定)を追加するには以下の形式でメッセージを投稿します。

新: [イベント名] [日付(M/d)] ... [日付(M/d)]

こんな感じ。

f:id:norikone:20160219182520p:plain:h95

そうすると、GAS 側で指定したフォルダに、イベント名の Spread Sheet を作成してくれます。

f:id:norikone:20160219182749p:plain:h120

参加可能日の提出

参加可能日を提出する方法は以下の4種類です。

出: [イベント名]                              #全ての日OK
出: [イベント名] [日付(M/d)] ... [日付(M/d)]   #指定した日だけOK
欠: [イベント名]                              #全ての日NG
欠: [イベント名] [日付(M/d)] ... [日付(M/d)]   #指定した日だけNG

こんな感じ。

f:id:norikone:20160219182548p:plain:h95

Spread Sheet にフラグが入ります。

f:id:norikone:20160219182938p:plain:h120

参加状況の確認

参加状況の確認方法は以下の2種類です。

確認: [イベント名]   #人数だけ表示
詳細: [イベント名]   #ユーザ名も表示

こんな感じ。

f:id:norikone:20160219183951p:plain:w350

リマインド

以下の入力で、未提出のイベントがあるユーザに DM でリマインドができます。

リマインド:

こんな感じ。

f:id:norikone:20160219184942p:plain:h80

また、GAS のトリガーとしてリマインドする関数を指定すれば、一定時間ごとにリマインドしてくれるようになります。
GAS プロジェクトを開いて、

[リソース] -> [現在のプロジェクトのトリガー] -> [トリガー追加リンクをクリック]

表示されたダイアログの [実行] に [remind()] を指定して、[イベント] の項目に任意のリマインドタイミングを指定します。

備忘録とかコードとか

以下、今回行った流れ。

GAS プロジェクトを用意

Google Drive の適当な場所に新規 GAS プロジェクトを作成します。

[Google Drive] -> [新規] -> [その他] -> [Google Apps Script]

SlackApp ライブラリ導入

Slack Bot を GAS で簡単に書くためのライブラリを導入します。
下の記事で紹介されています。

qiita.com

Slack API Token の取得

APIへアクセスするためのトークンを取得します。 下のリンクを開いて表示されるページの下の方に、自分が所属しているSlackチームやユーザ名、トークンが表示されています。
まだトークンを生成していない場合は、Slack Bot を作成するチームのトークンを [Create token] で生成します。

Slack Web API | Slack

Slack API Token を GAS プロジェクトに保存

上で取得したトークンを、先程作成したGAS プロジェクトのプロパティに格納します。
GAS プロジェクトを開いて、

[ファイル] -> [プロジェクトのプロパティ] -> [スクリプトのプロパティ]

に、今回は "token" というプロパティ名で登録しました。

Slack Outgoing WebHooks 設定

Slack の特定のチャンネルに特定の投稿があった際に、Slack から GAS へリクエストを渡してあげる設定です。
Trigger Word と呼ばれる項目に設定した値で始まる文字列の投稿があった際に、投稿データが指定した URL に POST されるようになります。

App Directory | Slack

上記 URL の検索窓に「outgoing」とか入力すると [Outgoing WebHooks] のメニューが出てくると思います。
クリックするとページの下の方に、所属している Slack チームが表示されるので 、ここから Slack Bot を作成するチームの Outgoing WebHooks を設定していきます。

[Install] -> [Add Outgoing WebHooks Integration]

で設定画面にたどり着きます。

「Integration Settings」の以下の項目を設定します。

  • Channel
    ここで指定したチャンネルに Trigger Word で始まる投稿があると、指定した URL にデータを POST するようになります。
    適当なチャンネルを指定します。
  • Trigger Word
    ここで指定した文字列で始まる投稿が指定した URL に POST されるようになります。
    今回作成したアプリでは Trigger Word として以下の6つを設定します。
    新:,出:,欠:,確認:,詳細:,リマインド:
  • URL
    上で「指定したURL」と書いていた部分がここです。
    今回は、後述する GAS アプリの URL をここに入力します。
  • Token
    Slack から GAS にデータが POST される際に、ここに記載されているトークンが付加されます。
    不正なアクセスでないかどうかを確かめるために GAS 側でこのトークンをチェックします。
    今回は、ここに記載されている値を "verifyToken" というプロパティ名で、先程 "token" を設定した時と同じように、スクリプトのプロパティに保存しました。

設定が完了したら [Save Setting] で保存します。

コードを書く

あとは実際に GAS でコードを書いていきます。
GAS では、POST リクエストがあった場合 doPost() が呼ばれるようになっています。
今回は、Outgoing Web Hooks によって Slack への投稿データが doPost() に入ってきます。
doPost(request) のように、第一引数を指定してあげれば投稿データを受け取る事ができます。

以下、コードをまとめてそのままここに掲載しておきます。
色々試しながらやっていたので冗長コードが入ってたりします(放置)。
1行目の FOLDER_ID には、このアプリで使用するシートを保存しておく Google Drive フォルダの ID を指定します。
フォルダ ID は、ブラウザで該当フォルダを開いた状態の時にアドレスバーで確認することも可能です。

var FOLDER_ID = "XXXXXXXXXXXXXXXXXXXXXXXXXXX";
var DATE_REGEXP = "^[0-1]?[0-9]\.[0-3]?[0-9]$";
var DATE_FORMAT = "M/d";
var MENTION = "@";
var UNIT = "名";
var START_ROW = 1;
var START_COL = 1;
var ATTEND_FLAG = 1;
var ABSENT_FLAG = 0;
var COL_IS_DATE = true;

var prop =  PropertiesService.getScriptProperties().getProperties();
var slackApp = SlackApp.create(prop.token);

function doPost(request) {

  if (prop.verifyToken != request.parameter.token) {
    throw new Error("invalid token.");
  }
  
  var queryTokens = request.parameter.text.split(":")[1].trim().replace(/ /g, " ").split(" ");
  queryTokens = removeOnlySpaceToken(queryTokens);
  try{
    switch(request.parameter.trigger_word){
      case "新:":
        tokenValidation(queryTokens,DATE_REGEXP,2)
        register(queryTokens);
        response(request.parameter.channel_id,"新しいイベントを追加しました",":new:");
        break;
      case "出:":
        tokenValidation(queryTokens,DATE_REGEXP,1)
        submit(queryTokens,true,request.parameter.user_name);
        response(request.parameter.channel_id,"出席登録が完了しました",":ok:");
        break;
      case "欠:":
        tokenValidation(queryTokens,DATE_REGEXP,1)
        submit(queryTokens,false,request.parameter.user_name);
        response(request.parameter.channel_id,"出席登録が完了しました",":ok:");
        break;
      case "確認:":
        show(queryTokens,false,request);
        break;
      case "詳細:":
        show(queryTokens,true,request);
        break;
      case "リマインド:":
        remind();
        response(request.parameter.channel_id,"リマインドが完了しました",":information_source:");
        break;
    }
  }catch(e){
    response(request.parameter.channel_id,e,":ng:");
  }
  
  return null;
}

function register(queryTokens){  
  var folder = DriveApp.getFolderById(FOLDER_ID); 
  var fileId = createSheet(folder,queryTokens[0]);
  var spreadSheet = SpreadsheetApp.openById(fileId);
  var sheet = spreadSheet.getSheets()[0];
  var userList = slackApp.usersList()["members"];
  
  userList.some(function(v, i){
    if (v.name=="slackbot") userList.splice(i,1);    
  });
  
  queryTokens.shift(); 
  var options = {
    rowHeader: userList,
    rowMember: "name",
    colHeader: queryTokens,
  };
  setHeaders(sheet,options);
  
  return null;
}

function submit(queryTokens,isAttend,userName){
  var sheet = getAttendanceSheet(queryTokens[0]);
  var data = sheet.getDataRange().getValues();
  var position = getPosition(sheet,userName);
  if(!position){
    throw new Error("ユーザが存在しません、名前が変更されている可能性があります");
  }
  
  queryTokens.shift(); 
  setValues(sheet,queryTokens,position,isAttend);
  
  return null;
}

function show(queryTokens,detail,request){
  var sheet = getAttendanceSheet(queryTokens[0]);
  var data = sheet.getDataRange().getValues();
  
  var sb = new StringBuffer("===============================");
  sb.append("\n【イベント名】 ").append(queryTokens[0]);
  var state = createSubmitStateData(sheet);
  sb.append("\n【提出者】   ").append(state.submitted.length).append(UNIT);
  sb.append("\n【未提出者】  ").append(state.unsubmitted.length).append(UNIT).append(" ");
  if(detail){
    for(var i=0 ; i<state.unsubmitted.length ; i++){
      sb.append(" ").append(MENTION).append(state.unsubmitted[i]);
    }
  }
  sb.append("\n===============================");
  
  for(date in state.dateState){
    sb.append("\n【").append(date).append("】").append(" ").append(state.dateState[date].length).append(UNIT).append(" ");
    if(detail){
      for(var j=0; j<state.dateState[date].length ; j++){
        sb.append(" ").append(MENTION).append(state.dateState[date][j]);
      }
    }
    sb.append("\n- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -");
  }
  
  response(request.parameter.channel_id,sb.toString(),":information_source:");
  
  return null;
}

function createSubmitStateData(sheet){
  var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
  var rows = Number(edge[0]);
  var cols = Number(edge[1]);
  
  var submittedUsers = [];
  var unsubmittedUsers = [];
  var dateState = {};
  
  if(COL_IS_DATE){
    for(var i=0 ; i<rows ; i++){
      var filled = sheet.getRange(START_ROW+i+1,START_COL+cols+1).getValue() == cols;
      var user = sheet.getRange(START_ROW+i+1,START_COL).getValue();
      
      if(filled){
        submittedUsers.push(user);
      }else{
        unsubmittedUsers.push(user);
      }
      
      for(var j=0 ; j<cols ; j++){
        var date = Utilities.formatDate(sheet.getRange(START_ROW,START_COL+j+1).getValue(),"JST",DATE_FORMAT);
        if(!dateState[date]){
          dateState[date] = [];
        }
        var attendFlag = sheet.getRange(START_ROW+i+1,START_COL+j+1).getValue();
        if(attendFlag){
          dateState[date].push(user);
        }
      }
    }
  }else{
    for(var i=0 ; i<cols ; i++){
      var filled = sheet.getRange(START_ROW+rows+1,START_COL+1+i).getValue() == rows;
      if(filled){
        submittedUsers.push(user);
      }else{
        unsubmittedUsers.push(user);
      }
    }
  }
  
  return {submitted:submittedUsers, unsubmitted:unsubmittedUsers, dateState:dateState};
}

function getPosition(sheet,target){
  var data = sheet.getDataRange().getValues();
  for(var i=0 ; i<data.length ; i++){
    for(var j=0 ; j<data[i].length ; j++){
      if(data[i][j] == target){
        return {row:i+1 ,col:j+1};
      }
    }
  }
  
  return null;
}

function setHeaders(sheet,options){
  var rowHeader = options.rowHeader || [1];
  var rowMember = options.rowMember || null;
  var colHeader = options.colHeader || [1];
  var colMember = options.colMember || null;
  var bgcolor = options.bgcolor || "#f5deb3";
  
  sheet.getRange(START_ROW,START_COL)
  .setValue(rowHeader.length + "," + colHeader.length);
  
  for(var i=0 ; i<rowHeader.length; i++){
    sheet.getRange(START_ROW+i+1,START_COL)
    .setValue(rowMember ? rowHeader[i][rowMember] : rowHeader[i])
    .setBackgroundColor(bgcolor);
    
    sheet.getRange(START_ROW+i+1,START_COL+colHeader.length+1)
    .setFormulaR1C1("=COUNT(R[0]C[-"+colHeader.length+"]:R[0]C[-1])");
  }

  for(var i=0 ; i<colHeader.length; i++){
    sheet.getRange(START_ROW,START_COL+i+1)
    .setValue(colMember ? colHeader[i][colMember] : colHeader[i])
    .setBackgroundColor(bgcolor);
    
    sheet.getRange(START_ROW+rowHeader.length+1,START_COL+i+1)
    .setFormulaR1C1("=SUM(R[-"+rowHeader.length+"]C[0]:R[-1]C[0])");
  }
  
  return null;
}

function setValues(sheet,tokens,position,isAttend){
  var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
  var rows = Number(edge[0]);
  var cols = Number(edge[1]);
  
  if(COL_IS_DATE){
    for(var i=0 ; i<cols ; i++){
      var date = Utilities.formatDate(sheet.getRange(START_ROW,START_COL+1+i).getValue(), "JST", DATE_FORMAT);    
      if(tokens.indexOf(date) >= 0 || tokens.length == 0){
        sheet.getRange(position.row,position.col+1+i).setValue(isAttend ? ATTEND_FLAG : ABSENT_FLAG);
      }else{
        sheet.getRange(position.row,position.col+1+i).setValue(isAttend ? ABSENT_FLAG : ATTEND_FLAG);
      }
    }
  }else{
    for(var i=0 ; i<rows ; i++){
      var date = Utilities.formatDate(sheet.getRange(START_ROW+1+i,START_COL).getValue(), "JST", DATE_FORMAT);    
      if(tokens.indexOf(date) >= 0 || tokens.length == 0){
        sheet.getRange(position.row+1+i,position.col).setValue(isAttend ? ATTEND_FLAG : ABSENT_FLAG);
      }else{
        sheet.getRange(position.row+1+i,position.col).setValue(isAttend ? ABSENT_FLAG : ATTEND_FLAG);
      }
    }
  }
  
  return null;
}

function getAttendanceSheet(sheetName){
  var folder = DriveApp.getFolderById(FOLDER_ID);
  var files = folder.getFilesByName(sheetName);
  if(!files.hasNext()){
    throw new Error("入力されたイベントは存在しません");
  }
  var spreadSheet = SpreadsheetApp.open(files.next());
  var sheet = spreadSheet.getSheets()[0];
  
  return sheet;
}

function createSheet(targetFolder,sheetName){
  var files = targetFolder.getFiles();
  while (files.hasNext()) {
    var file = files.next();
    if(file.getName() == sheetName){
      throw new Error("そのイベント名は既に存在しています");
    }
  }
  
  var fileId = SpreadsheetApp.create(sheetName).getId();
  var file = DriveApp.getFileById(fileId);
  targetFolder.addFile(file);
  DriveApp.getRootFolder().removeFile(file);
  
  return fileId;
}

function remind(){
  var folder = DriveApp.getFolderById(FOLDER_ID);
  var files = folder.getFiles();
  
  while (files.hasNext()) {
    var file = files.next();
    var spreadSheet = SpreadsheetApp.open(file);
    var sheet = spreadSheet.getSheets()[0];
    
    var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
    var rows = Number(edge[0]);
    var cols = Number(edge[1]);
    
    if(COL_IS_DATE){
      for(var i=0 ; i<rows ; i++){
        var filled = sheet.getRange(START_ROW+i+1,START_COL+cols+1).getValue() == cols;
        var user = sheet.getRange(START_ROW+i+1,START_COL).getValue();
        if(!filled){
          var sb = new StringBuffer("以下のイベントへの出席登録が完了していません!");
          sb.append("\n【イベント名】  ").append(file.getName());
          response(MENTION+user,sb.toString(),":warning:");
        }
      }
    }else{
      for(var i=0 ; i<cols ; i++){
        var filled = sheet.getRange(START_ROW+rows+1,START_COL+i+1).getValue() == rows;
        var user = sheet.getRange(START_ROW,START_COL+i+1).getValue();
        if(!filled){
          var sb = new StringBuffer("以下のイベントへの出席登録が完了していません!");
          sb.append("\n【イベント名】  ").append(file.getName());
          response(MENTION+user,sb.toString(),":warning:");
        }
      }
    }
  }
  
  return null;
}

function response(target,text,icon){
  slackApp.chatPostMessage(target, text, {
    username : "出席管理人",
    icon_emoji : icon 
  });
  
  return null;
}

function removeOnlySpaceToken(tokens){
  var result = [];
  for(var i=0 ; i<tokens.length ; i++){
    if(tokens[i].match(/\S/g)){
      result.push(tokens[i]);
    }
  }
  
  return result;
}

function tokenValidation(tokens,regexp,minLength){
  if(tokens.length < minLength){
    throw new Error("フォーマットが正しくありません");
  }
  for(var i=1 ; i<tokens.length ; i++){
    if(!tokens[i].match(regexp)){
      throw new Error("フォーマットが正しくありません");
    }
  }
  
  return false;
}

var StringBuffer = function(string) {
    this.buffer = [];

    this.append = function(string) {
        this.buffer.push(string);
        return this;
    };

    this.toString = function() {
        return this.buffer.join('');
    };

    if (string) {
        this.append(string);
    }
};

アプリを公開

GAS プロジェクトを開き、

[公開] -> [ウェブアプリケーションとして導入] -> [アプリケーションにアクセスできるユーザー] -> [全員(匿名ユーザーを含む)]
に設定して、[導入] をクリックします。

すると、ダイアログが表示されるので、[現在のウェブアプリケーションのURL] という欄に記載されている URL を、Outgoing WebHooks 設定ページの URL 欄にコピペします。

これで動くようになりました。

おわり

ということで、GAS を使って簡単なアプリを作成してみました。
仕様もコードも荒削りではありますが、飽きたので 一応動くものが出来たので、この辺で手を止めておきます。

【Elasticache】ELB配下の複数インスタンス間でのセッション管理【Laravel】

AWS の ELB に複数のインスタンスをぶら下げて負荷分散をしようと思った際に、Laravel アプリのセッション管理について考えたメモです。

ユーザにログインさせる必要があるアプリなどは、ELB によって接続先インスタンスを振り分けられたとしても、そのセッションを維持することが必要になってきます。
単に ELB にインスタンスを沢山ぶら下げるだけだと、デフォルトでは1クライアントからの各リクエストが別々のインスタンスに割り振られてしまいます。
つまり、インスタンス間でセッションを共有できていないので「さっきログインしたはずなのにまたログインしろ画面が出てきたんだけど」ということになってしまいます。

この記事では、この問題を解決する方法の紹介と、そのうちの一つを Laravel で設定してみます。

解決策

先に書いた問題を解決するための方法をいくつか簡単に紹介します。

スティッキーセッションを使う

ELB には、クライアントとリクエスト先のインスタンスを固定するスティッキーセッションという機能が備わっています。
この機能を使用した場合、一度クライアントがインスタンスに接続すると有効期限が切れるまで同じインスタンスに接続され続けます。
これによって、インスタンス間でセッション情報を共有する必要もなく問題を解決できます。
ただ、接続先のインスタンスがダウンした場合などは、やむを得ず他のインスタンスに接続する必要があります。
この場合、新しく接続されたインスタンスは新しく接続してきたクライアントのセッション情報を保持していないことに注意が必要です。
f:id:norikone:20160208023613p:plain:h300

セッション情報をDBで管理する

セッションをDBで管理することによってWebサーバ間で情報を共有できるので、スティッキーセッションを使用した際に注意しなければならない問題は発生しません。
しかし、DBで管理する際にはセッション情報の更新や削除処理が頻繁に行われるようになり、パフォーマンスの低下に繋がる可能性があります。
f:id:norikone:20160208023611p:plain:h300

セッション情報をKVSで管理する

memcached や Redis などの KVS を使用することで、DB で管理した場合に必要となるセッション破棄のための削除処理の実装が要らなくなります。
また、DB を使用する際に発生するディスクI/Oによる影響を減らすことにも期待ができます。
セッション情報は多くの場合で永続化する必要のないデータなので、揮発性が高くてもそこまで問題になりません。

f:id:norikone:20160208023612p:plain:h300

ということで、単純なセッション管理には KVS が向いていると判断したので、Laravel から KVS を使用してセッション管理をする設定をしてみます。

Laravel で Elasticache を使ってセッション管理をしてみる

セッション管理の KVS にElasticache 上の Redis を使います。
ここでは memcached か Redis かの検討については書きません。

まずは、Elasticache で Redis クラスタを作成します。
作成されると、エンドポイントを確認することができるので、それを控えておきます。

次に、Laravel 側の設定です。
config/session.php で、セッションドライバに Redis を使用するよう指定します。

'driver' => 'redis',

config/database.php に、接続するクラスタの情報を記述します。

'redis' => [
        'cluster' => false,
        'default' => [
            'host'     => 'hoge-cluster.XXXX.XXXX.XXXX.cache.amazonaws.com',
            'port'     => 6379,
            'database' => 0,
        ],
],

host には先程控えたエンドポイントを指定します。

設定はこれだけです。
実際にセッションが Redis に格納されるようになっているか確認していきます。
あらかじめ Laravel アプリにアクセスしてセッションを作成しておきましょう。

  • 接続用クライアントツールインストール
sudo wget http://download.redis.io/redis-stable.tar.gz
sudo tar xvzf redis-stable.tar.gz
cd redis-stable
sudo make
  • 接続
src/redis-cli -h hoge-cluster.XXXX.XXXX.XXXX.cache.amazonaws.com' -p 6379
  • 既存のキーを確認
keys *

ここで、"laravel:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" のように表示されていればセッションが格納できています。

  • 切断
quit

おわり

Elasticache(Redis) を使用してセッション管理をしてみました。
今のままだと、Redis が単一障害点となってしまっているのでよろしくありませんが、Elasticache の設定でレプリケーションをすることでこれを回避できます。
memcahced の場合はレプリケーションは行えません。
Redis とは冗長化の方式が異なるので、興味のある方はそちらも調べてみるといいかもしれません。

Apache2.4 を event MPM + FastCGI で動かす

プロセスベースの並列実行をする prefork を使用していたのですが、省メモリのためにスレッドベースの並列実行をする event へ変更します。
構成としては、Apache2.4 + event + mod_proxy_fcgi + php-fpm です。 それぞれの MPM の特徴に関しては、以前書いたこちらの記事で簡単に紹介しています。 norikone.hatenablog.com

動きを確認するためにそれぞれデフォルトの設定で、PHPプログラムが動いているサーバに簡単な負荷テストを行ってみたところ、プロセスを多数起動しない分やはり event の方が使用メモリ量が少ないという結果が出ました。
スループットも若干 event が prefork を上回る結果になりました。

ただ、速度に関しては配信するコンテンツの内容やリクエスト数によって prefork が上回るケースも十分考えられるので、環境ごとに検討する必要があるかと思います。

event で動かすために必要なものと背景

prefork を選択している場合には、ApachePHP の連携の定番である mod_php を使用することができました。
しかし、event を選択した場合には PHP の実行を mod_php に任せることができません。
というのも、mod_php がスレッドセーフではないためです。

prefork では、Apache がリクエストごとにプロセスを生成するため、メモリ空間上で競合が生じません。
event では、リクエストごとにスレッドを生成して処理するので、メモリ空間を共有していまい競合が生じてしまいます。
そのため、スレッドセーフな環境で実行してあげる必要があります。

そこで、 PHP 処理は php-fpm に任せることにします。
mod_php の場合、Apache が生成する各 httpd プロセスに PHPインタプリタを埋め込んでそのプロセス内で PHP を処理していたので結びつきが強かったのですが、php-fpm の場合 Apache とは分離された存在になっています。
その辺りはこちらの図が分かりやすいです。
  f:id:norikone:20160207044201p:plain  
f:id:norikone:20160207044159p:plain (参照 : http://z-issue.com/wp/apache-2-4-the-event-mpm-php-via-mod_proxy_fcgi-and-php-fpm-with-vhosts/)

一枚目が prefork + mod_php 構成の図で、二枚目が event + php-fpm + mod_proxy_fcgi 構成の図です。
二枚目では、スレッドがリクエストを受け付けて、mod_proxy_fcgi というモジュールを介して php-fpm に PHP の実行を依頼する、という流れです。
静的ファイル等は Apache が、PHPphp-fpm が、という形になります。

ということで前置きが長くなりましたが、php-fpm と mod_proxy_fcgi があれば event で動かすことが出来るようになるので、設定していきます。
環境は Amazon Linux AMI , Apache2.4 です。

php-fpm インストール

まずは PHP プログラムを処理するための php-fpm をインストールします。

yum install php56-fpm

起動します。

service php-fpm start

自動起動をONにします。

chkconfig php-fpm on

これで php-fpm の準備はできました。
特に設定を変更していなければ、9000番ポートでリクエストを待ち受けるようになります。

mod_proxy_fcgi を有効にする

Apache が起動したスレッドと php-fpm の仲介をするモジュールの設定をします。
mod_proxy_fcgi を有効にすることで、ApacheFastCGI プロトコルを使用して外部の PHP 処理系にリクエストを渡すことが出来るようになります。
また、mod_proxy_fcgi が動作するためには、mod_proxy が必要です。

恐らくデフォルトで有効になっていますが、/etc/httpd/conf.modules.d/00-proxy.conf の以下の行がコメントアウトされていないか念のため確認しておきます。

LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so

次に、仮想ホストファイルなどを編集してプロキシの設定をします。
以下の行を追記するだけです。

<FilesMatch \.php$>
    SetHandler "proxy:fcgi://127.0.0.1:9000/"
</FilesMatch>

例として、こんな感じになります。

<VirtualHost *:80>
    DocumentRoot "/var/www/html/public"
    ServerName hoge.com
    <Directory "/var/www/html/public">
        <FilesMatch \.php$>
            SetHandler "proxy:fcgi://127.0.0.1:9000/"
        </FilesMatch>
    </Directory>
</VirtualHost>

これで、mod_proxy_fcgi を通して php ファイルの実行を php-fpm に委任する設定が完了しました。

MPM を event に切り替える

event の動作に必要な環境は整いました。
実際に MPM を切り替えてみます。

/etc/httpd/conf.modules.d/00-mpm.conf で prefork をロードしている箇所を event をロードするよう修正します。
Apache2.3 からは MPM は LoadModule ディレクティブで動的に選択することが可能になっています。

# LoadModule mpm_prefork_module modules/mod_mpm_prefork.so #コメントアウト
~
LoadModule mpm_event_module modules/mod_mpm_event.so #コメントアウト削除

これだけで event へ切り替えることが出来ます。
あとは、/etc/httpd/conf.modules/01-cgi.conf 等をいじって使用しないモジュールをロードしないようにしておきましょう。

確認

httpd -M

で、 mpm_event_module (shared) が表示されていれば event が選択された状態になっています。
また、phpinfo で FastCGI で動作しているか確認できます。
f:id:norikone:20160207213201p:plain prefork + mod_php で動作している場合は、ここに「Apache handler 2.0」と表示されるので、変更できていることが分かります。

Apache MPMとはなんぞやという話

Apache のチューニングにあたって MPM について調査したときのメモ、なんとなくの概要。

Webサーバの実装モデルの話

MPMの話に入る前に、Webサーバの基本的な並行処理のモデルをおさえておきます。

Webサーバに同時に接続するクライアントが1人だけであれば並行処理について考える事項は少ないですが、多くの場合は同時に複数人のクライアントに対応しなければならないかと思います。
そういった複数人のクライアントからのリクエストを並行処理するためのWebサーバの実装モデルがいくつかあるのですが、代表的なものを簡単におさらいします。

マルチプロセスモデル

クライアントからのリクエストごとに fork を行い子プロセスを生成し、子プロセスに処理を委ねる方式です。
プロセスの fork では、メモリ上の親プロセスのアドレス空間を生成した子プロセスのアドレス空間にコピーするため、その分のコストが発生し、遅いと言われています。
リクエストが増えれば増えるほど、子プロセスの数とそれに伴うメモリ消費量も増えていきます(すべての子プロセスがPHPインタプリタおよび関連ライブラリをロードするため)。
メモリ空間がプロセスごとに独立しているためスクリプト言語などを組み込みやすい、後述のマルチスレッドモデルと違い資源の競合について考慮しなくてよい、などの利点があります。

マルチスレッドモデル

クライアントからのリクエストごとにスレッドを生成する方式と、あらかじめスレッドを生成しておくモデルがあります。
マルチプロセスモデルとは違い、プロセスではなくスレッドを使用するため、プロセスの fork の際に発生するようなコピー作業が発生しない(各スレッドはメモリ空間を共有する)ので、プロセスの生成よりもコストが小さいと言われています(メモリ消費量についても同様)。
メモリ空間を共有するので、コンテキスト切り替えの際に発生するメモリ空間の切り替えやそれに伴うキャッシュの削除を省略できるというメリットもあります。
メモリ空間を共有することのデメリットとして、スレッド間での資源の競合を考慮したプログラムを書く必要があり、実装が難しくなりコードも複雑なものになりやすい、などが挙げられます。

イベント駆動モデル

1つのプロセスで複数のリクエストを処理する方式です。
上記2つのモデルでは、クライアントがサーバに接続してレスポンスを受けてサーバとの接続を切る、という一連の流れ1つに対してを1つのプロセス or スレッドが割り当てられ、それぞれのリクエストに対応していました。
イベント駆動モデルでは、リクエスト数に関係なくイベント発生のタイミングで処理を切り替え1つのプロセスがすべてのリクエストを処理します。
プロセスが1つしか無いということは、CPUコアを1つしか活用できないことを意味します。
Nginx などでは、イベント駆動のプロセスをCPUコアそれぞれに起動しておくなどして、この欠点に対応しています。
このモデルは、リクエスト数が増えてもプロセスやスレッドの数が増えることがないので、メモリ消費量やコンテキスト切り替えのオーバヘッドなどのコストを抑える事ができるという利点があります。

ところで MPM って?

MPM は (Multi Processing Module) の略で、Webブラウザからのリクエストを Apache がどのように並行処理するか、という部分の処理をモジュール化したものです。
先にWebサーバのモデルに関して記述しましたが、Apache ではそのようなモデルの中からどれを使用するかをこの MPM によって選択することができます。
Apache そのものにこれらの処理が組み込まれずにモジュール化されていることによって、各々のWebサイト向けにカスタマイズすることが容易になっています。
Apache2.2までは MPM は静的にリンクしなければなりませんでしたが、Apache2.3 からは LoadModule ディレクティブで動的に選択することが可能になりました。

MPM の種類

代表的なものをまとめます。

prefork

マルチプロセスモデルです。
prefork という名の通り、クライアントからリクエストが来る前にあらかじめ一定数の子プロセスを fork して待機させておくことで、fork の回数を減らしてパフォーマンス向上を図ります。
リクエストごとにプロセスが分かれているため、あるプロセスの障害が他のプロセスに影響を及ぼすことが無く、安定した通信を行うことが可能です。

worker

マルチスレッドモデルとマルチプロセスモデルのハイブリッドモデルです。
制御用の親プロセスがいくつかの子プロセスを作成し、子プロセスそれぞれがマルチスレッドで動作します。
スレッド1つが1つのクライアントの処理を担当します。
prefork に比べ生成されるプロセスの数を抑えることができるので、資源の節約が可能です。
スレッドを使用するモデルなので、mod_php などの非スレッドセーフなモジュールを利用する際には使用できません。

event

worker をベースとしたマルチスレッドモデルとマルチプロセスモデルとイベント駆動モデルのハイブリッドモデルです。
KeepAlive の処理を別のスレッドに割り振って通信を処理することによって、パフォーマンスの向上を図っています。
また、クライアントとのネットワークI/Oのみイベント駆動モデルで実装されています。
こちらもスレッドを使用するモデルなので、非スレッドセーフなモジュールを利用する際には使用できません。

EC2インスタンスがCloundWatchにカスタムメトリクスの情報を送信してくれなかった

CloudWatchでEC2インスタンスのメモリ情報とかをモニタリングしたいと思った時にうまく行かなかった時のメモ。

流れとしてはこのような感じです。

  1. 必要な情報(メモリ使用率とか)をCloudWatchに送信する設定が完了したインスタンスを作成
  2. 作成したインスタンスをAMIに
  3. 作成したAMIから新しいインスタンスを起動

これだけで新しく作成したインスタンスも自身の情報を送信してくれるようになると思ったのですが、作成したインスタンスのパフォーマンスがCloudWatchに反映されず。
1で作成、設定したインスタンスは正常にパフォーマンス情報の送信が出来ている状態です。

原因

情報の送信に必要なCloudWatchClient.pmモジュールが、インスタンスメタデータをローカルでキャッシュしていて、新しく作成したインスタンスの方でもそのキャッシュを使用して情報の送信を行っていました。
結果として、1で作成したインスタンスインスタンスIDでメトリクスを出力していたということになります。
つまり情報を送信してなかったのではなく、送信元情報が間違っていた、ということです。

解決策

次のコマンドで、送信元インスタンスIDのキャッシュを削除します。

rm /var/tmp/aws-mon/instance-id

ちなみに、デフォルトではこのキャッシュの生存時間は6時間らしいので、6時間経てば正しい情報を送ってくれるようになります。

MySQL バイナリログとInnoDBログについて調べたメモ

MySQLのストレージエンジンであるInnoDBに備わっているバイナリログとInnoDBログについてよく分からなかったので軽く触れてみることにしましたが、ちょっと情報が古いかもしれません。

InnoDBには、InnoDBログとバイナリログが存在します。
InnoDBログはInnoDBの耐久性を実現する上で必要不可欠なファイルなので使用は必須ですが、バイナリログには使用しないという選択肢もあります。

InnoDBログ

クラッシュリカバリで使用されるファイルです。
InnoDBでデータ更新時に発生するフローを簡単に書くと以下のような流れです。

InnoDBログへ更新情報の書き込み
②バッファプールへの更新情報の書き込み
③適当なタイミングでバッファプールの情報をテーブルスペースへの反映

InnoDBでは、コミットと同時に直接ディスクに対してデータ更新処理や取得処理を行うようになっていません。
バッファプールと呼ばれる領域(ディスクキャッシュ)に一度読み込まれたデータが格納されていて、読み込みはもちろん書き込みまでもこの領域で行います。
バッファプールで更新された情報は、後に適切なタイミングでテーブルスペース(ディスク)に反映されるようになっています。

コミットと同時に直接ディスクにデータ更新処理をあててないのは、書き込み性能を上げるためです。
データを更新するためにはインデックスツリーの更新が必要であったり、更新する対象がテーブルスペース内のいろいろな所に分散して存在していたりするので更新箇所が多く、ランダムアクセスが発生してしまいます。
ようするに、ディスクのあちこちを行ったり来たりして弄る必要があるのでシーク時間的な意味で効率が良くないということです。

この問題を解決するために、一旦は書き込みにコストがかからないバッファプールとInnoDBログに書き込みを行って、適当なタイミングで裏で走っているスレッドがその情報をテーブルスペースに反映させるという方法をとっています。
書き込みの回数はバッファプール、InnoDBログ→テーブルスペースと2段に増えますが、InnoDBログへの書き込みはシーケンシャルライトになるため高速なので、コストはそこまで問題になっていないようです。

この方式をとって問題になるのは、バッファプールとテーブルスペースとで整合性がとれていない時間が存在するということです。
その整合性がとれていない時間、つまりバッファプールからテーブルスペースへのデータの反映が行われていない時に例えば電源が落ちてしまったりすると、バッファプールに格納されていたデータは消えてしまい、再起動後に消えたデータの復旧をしようと思ってもできなくなってしまいます。

InnoDBログはこのような状況に陥った際に使用されるファイルで、問題を解決します。
バッファプールとInnoDBログは同期されているので、InnoDBログのデータをテーブルスペースに反映していけばデータが消える前の状態までデータを復旧させることが出来ます。

バイナリログ

こちらもデータの更新が行われるとその更新情報が記録されるファイルです。
InnoDBログと似ていますが、バイナリログはロールフォワードリカバリやレプリケーションに使用されるファイルです。

レプリケーションでは、マスタの変更をスレーブに反映させる必要がありますが、その反映にバイナリログを使用します。
マスタは定期的にスレーブにバイナリログを送信して、バイナリログを受信したスレーブは書かれている更新内容を自身に反映させます。
スレーブが反映作業中に落ちてしまうと、再起動後に次はバイナリログのどこから反映作業を再開すればいいのか、どこまで終わっていたのかが分からなくなってしまいます。
これを回避するために、バイナリログをどこまで実行したかという情報を保持しておくためのテーブルが用意されています。

ロールフォワードリカバリでは障害直前の状態までデータを復旧させる必要がありますが、これにもバイナリログを使用します。
リカバリをする場合は、まず定期的にとっているであろうバックアップを使用して特定のバックアップ時点まで戻します(リストア)。
これだけではバックアップをとった時点以降のデータ更新情報が反映されていません。
そこで、リストアした後にバイナリログに記録されている更新情報を反映させていけば、障害直前の状態まで復旧することが出来ます。

バイナリログへの書き込みはデフォルトではディスクと同期されないので、障害が起きて落ちてしまったりすると、ディスクは最新の状態でバイナリログは最新の状態ではないということが起こり得ます。
設定することで同期させることも可能ですが、更新の速度が遅くなるのでここはトレードオフです。

おわり

InnoDBログは障害が発生した時に障害発生前のバッファプールの情報を復旧させるために使用するもの(クラッシュリカバリ)で、バイナリログは障害が発生した時に障害発生前の特定時点までデータを戻すために使用するもの(ポイントインタイムリカバリ)、という認識でいいのかどうかわかりませんが僕はそんな感じだと思ってます。