読者です 読者をやめる 読者になる 読者になる

DebianにNVIDIAのドライバを入れたかったけど躓いた


いくつか躓くポイントがあったのでメモ。
以下、手順を書いていきます。
 

ドライバのダウンロード

これが無ければ始まりません。
以下から自分のビデオカードに対応するドライバを適当な場所にダウンロードします。

NVIDIAドライバダウンロード

 

ドライバのインストール

sh NVIDIA-Linux-xx-xxx.xx.run (ここはダウンロードしてきたファイル名)

するとエラーが。

ERROR: You appear to be running an X server; please exit X before            
         installing.  For further details, please see the section INSTALLING   
         THE NVIDIA DRIVER in the README available on the Linux driver         
         download page at www.nvidia.com.

Xサーバーが動いているからインストールできない的なメッセージが表示されます。

まずは必要なものをインストール。

sudo aptitude install build-essential linux-source linux-headers-`uname -r` xserver-xorg-dev


そしてgdmを停止します。

sudo /etc/init.d/gdm3 stop

ここで画面が真っ暗になります。
その状態で、[Control]+[Alt]+[F2] でコンソール画面に切り替えます。

適当なユーザでログインして、先程のインストールコマンドを再度入力します。

sh NVIDIA-Linux-xx-xxx.xx.run

するとまたしてもエラーが。

ERROR: The Nouveau kernel driver is currently in use by your system.  This
         driver is incompatible with the NVIDIA driver, and must be disabled
         before proceeding.  Please consult the NVIDIA driver README and your
         Linux distribution's documentation for details on how to correctly
         disable the Nouveau kernel driver.

NVIDIAドライバと競合するような既存のドライバが入っているようです。
恐らくデフォでオープンソースのドライバが入ってしまっているので、削除します。
/etc/modprobe.d/nvidia-installer-disable-nouveau.conf を開いて、以下を記載します。

blacklist nouveau
options nouveau modeset=0


保存したら再起動します。

sudo reboot


三度目の正直。 まずは gdm を停止します。

sudo /etc/init.d/gdm3 stop


インストール。

sh NVIDIA-Linux-xx-xxx.xx.run


やっとエラーなくインストーラーに入ることが出来ました。
あとは適当にOKとか選択していけば完了です。

おわり。

AWSのAutoScalingの設定をしてみる

AWS

AWS の AutoScaling 機能を使う機会があったので、忘れないうちに設定方法を書いておきます。

AutoScaling とは

負荷に合わせて EC2 インスタンスの数を自動的に増減してくれる機能です。
増減はさせずに、インスタンスの数を常に一定にしておくためだけに利用したりもできます。
AutoScaling の利用には追加費用は発生しません。(当然ですが、AutoScaling によって自動的に追加された分のインスタンスの利用料はかかります。)
負荷に合わせて、と書きましたが、トリガーには CloudWatch のメトリクスなどが使用できます。

以下の記事に分かりやすく概要が書いてあります。
【AWS】Auto Scalingまとめ - Qiita

設定

今回はマネジメントコンソールを使ってGUIで設定をしていきます。
AutoScaling を利用するためには、[起動設定] と [AutoScalingGroup] を作成する必要がありあす。

[AutoScalingGroup] では、インスタンスを起動するAZやトリガーなどの設定します。
[起動設定] では、インスタンスサイズやセキュリティグループ等の、起動するインスタンスの情報を設定します。

起動設定の作成

AutoScalingGroup を作成する前に、あらかじめ起動設定を作成しておく必要があります。

だいぶ適当ですがこんな感じです。
これで起動設定の作成は終わりです。

AutoScalingGroup の作成

  • [EC2 Management Console] のサイドバーにある [AutoScalingグループ] → [AutoScalingグループの作成] をクリックします。

  • 先程作成した起動設定を選択します。

  • [AutoScalingグループの詳細設定] の [高度な詳細] をクリックすると色々出てくるので設定していきます。
    ELB 配下のインスタンスを AutoScaling 対象にする場合には [ロードバランシング] にチェックを入れて、使用する ELB を選択します。
    [ヘルスチェックのタイプ] を [ELB] に設定すると、「EC2 インスタンス自体は生きているけど ELB のヘルスチェックが通らない」状況の時などに異常ありと判断され、インスタンスの置き換えが行われるようになります。
    [ヘルスチェックの猶予期間] には、インスタンスの起動後、サービス開始できるようになるまでの時間(ビルドやデプロイ等に掛かる時間)以上の値を入力します。
    ELB ヘルスチェックを正しく返せない状態でこの AutoScaling ヘルスチェックが開始されてしまうと、起動と削除が繰り返されていつまでたってもサービスが提供できない状態に陥るので注意が必要です。(確か EC2 インスタンスは起動した時点で1時間分の課金が行われるので、起動と削除が繰り返されると厄介です。)

  • [スケーリングポリシーの設定] では、トリガーの設定等を行います。
    負荷に合わせてインスタンスの増減をする場合は、[スケーリングポリシーを使用して、このグループのキャパシティを調整する] を選択し、インスタンス数の範囲と、インスタンス増減のタイミングを指定します。
    [グループサイズの増加] と [グループサイズの減少] のそれぞれで [新しいアラームの追加] を行い、閾値を指定します。
    [ステップを追加] で、負荷の度合いに応じて増減させるインスタンス数に変化をつけることも可能です。
    [ウォームアップ] の秒数を指定すると、インスタンス増加のイベントが発生した後、指定した時間が経つまで次の増加イベントが発生しなくなります。 (デフォルトでは5分)

  • [通知の設定] では、AutoScaling で発生したイベントを指定したメールアドレス等に通知する設定ができます。
    通知が必要な場合は [通知の追加] をクリックします。

  • 最後に起動するインスタンスへのタグ付けをして終わりです。

おわり

ここでは機能のすべてを使い切れているわけではありませんが、一応これだけで AutoScaling グループが起動しインスタンスが作成されます。
クラウドサービスらしい素晴らしい機能ですね。

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

MySQL

"重複したカラムをまとめた上でのカウント" がしたい時に、いつもどおり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 JavaScript Slack

最近 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】

Laravel AWS KVS

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 で動かす

Linux Apache

プロセスベースの並列実行をする 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 Linux

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のみイベント駆動モデルで実装されています。
こちらもスレッドを使用するモデルなので、非スレッドセーフなモジュールを利用する際には使用できません。