まいける's Tech Blog

LAMP関係のメモなどを不定期に掲載します

APC導入後、Unable to allocate memory for pool エラーが出る場合の対処法

 Webエンジニアが知っておきたいインフラの基本 ~インフラの設計から構成、監視、チューニングまで~を読んで、遅まきながら管理しているサーバに APC を導入してみました。体感的にも処理が早くなっていい感じだったのですが、しばらくしてログに

[xx-Jun-2015 xx:xx:xx UTC] PHP Warning:  Unknown: Unable to allocate memory for pool. in Unknown on line 0

のエラーが続々と記録されるようになりました。

 どうやら原因は、APC の設定で追加した

apc.ttl=3600

の部分だったようで、上記の設定を追加すると、設定した時間(3600秒)が経過するまでキャッシュされているエントリが滞留してしまい、apc.shm_size で設定している容量を超えてしまうと、それ以上のメモリを確保できなくなり、上記のエラーが発生するようです。

 したがって、対処法としては、apc.shm_size を十分に大きくするか、apc.ttl をメモリがいっぱいにならない程度に小さくするかのいずれかとなります。apc.ttl を小さくしすぎてしまうと、キャッシュしている意味がなくなるため、調整が難しそうですが

apc.ttl=0

とすることで、メモリ上限に達した時点でキャッシュの破棄がなされる(逆に上限に達しない限りずっと残り続ける)ので、とりあえずはこの設定で様子を見ればよいと思います(ちなみに、apc.ttl=0 がデフォルトの設定で、あえていじらなければ問題は起きなかった、というのは内緒)。

Gmail から特定の条件を満たすメールを抽出して RSS 化する

 ちょっとした必要に迫られて作ってみました。Gmail にはもともと、特定のラベルが付いたメールを ATOM で出力する機能が付いているのですが(このあたりの記事を参照)、HTTP 認証を使っているので、Feedly では利用することができません。ということで、Gmail API を利用して条件を満たすメールを抽出したうえで、RSS 化してみました。

 用意するものは

の2つ。Composer などを利用してインストールしておいてください。

 また、Gmail API を利用するための準備については、Google Developers のこちらの記事をご覧ください。RSS 化するスクリプトは、cron で定期的に実行することを念頭に置いているので、アプリケーションの種類は、“インストールされているアプリケーション”を選びます。

 以下、コードです。

<?php
require 'vendor/autoload.php';  // composer の autoload.phpの場所を指定

define('APPLICATION_NAME', 'Gmail To RSS');
define('CREDENTIALS_PATH', '~/.credentials/gmail-to-rss.json');
define('CLIENT_SECRET_PATH', 'client_secret.json'); // 準備で作成したクライアントIDに対応したJSONファイルの場所を指定
define('SCOPES', implode(' ', array(
  Google_Service_Gmail::GMAIL_READONLY) // 今回のスクリプトではメールに編集を加える必要がないので、与える権限は読み込みのみに
));

/**
 * Returns an authorized API client.
 * @return Google_Client the authorized client object
 */
function getClient() {
  $client = new Google_Client();
  $client->setApplicationName(APPLICATION_NAME);
  $client->setScopes(SCOPES);
  $client->setAuthConfigFile(CLIENT_SECRET_PATH);
  $client->setAccessType('offline');

  // Load previously authorized credentials from a file.
  $credentialsPath = expandHomeDirectory(CREDENTIALS_PATH);
  if (file_exists($credentialsPath)) {
    $accessToken = file_get_contents($credentialsPath);
  } else {
    // Request authorization from the user.
    $authUrl = $client->createAuthUrl();
    printf("Open the following link in your browser:\n%s\n", $authUrl);
    print 'Enter verification code: ';
    $authCode = trim(fgets(STDIN));

    // Exchange authorization code for an access token.
    $accessToken = $client->authenticate($authCode);

    // Store the credentials to disk.
    if(!file_exists(dirname($credentialsPath))) {
      mkdir(dirname($credentialsPath), 0700, true);
    }
    file_put_contents($credentialsPath, $accessToken);
    printf("Credentials saved to %s\n", $credentialsPath);
  }
  $client->setAccessToken($accessToken);

  // Refresh the token if it's expired.
  if ($client->isAccessTokenExpired()) {
    $client->refreshToken($client->getRefreshToken());
    file_put_contents($credentialsPath, $client->getAccessToken());
  }
  return $client;
}

/**
 * Expands the home directory alias '~' to the full path.
 * @param string $path the path to expand.
 * @return string the expanded path.
 */
function expandHomeDirectory($path) {
  $homeDirectory = getenv('HOME');
  if (empty($homeDirectory)) {
    $homeDirectory = getenv("HOMEDRIVE") . getenv("HOMEPATH");
  }
  return str_replace('~', realpath($homeDirectory), $path);
}

// Get the API client and construct the service object.
$client = getClient();

$service = new Google_Service_Gmail($client);

$user = 'me';

$optParams = array();
$optParams['maxResults'] = 50;
$optParams['q'] = 'label:hogehoge is:unread'; // hogehogeというラベルが付いた未読のメールを検索

try {
    $messages = $service->users_messages->listUsersMessages($user, $optParams); // 条件に合致するメールの一覧を取得

    $list = $messages->getMessages();

    $i = 0;

    foreach($list as $line) {
        $messageId = $line->getId();

        $optParamsGet = array();
        $optParamsGet['format'] = 'full'; // Display message in payload
        try {
            $message = $service->users_messages->get($user, $messageId, $optParamsGet); // 取得したメールIDから個々のメールデータを取得
            
            $messagePayload = $message->getPayload();

            $headers = $message->getPayload()->getHeaders();

            foreach($headers as $header) {
                $header_ary[$header->getName()] = $header->getValue(); // ヘッダ情報を後で使いやすいように配列に代入
            }

            $items[$i]['title'] = $header_ary['Subject'];
            $items[$i]['date'] = strtotime($header_ary['Date']);
            $items[$i]['message_id'] = $messageId;

            $body = $message->getPayload()->getBody(); // メール本文を取得

            $rawData = $body->data;
            $sanitizedData = strtr($rawData, '-_', '+/');
            $decodedMessage = base64_decode($sanitizedData); // Base64でエンコードされているのでデコード

            $items[$i]['description'] = $decodedMessage;

            $i ++;

        } catch (apiServiceException $e) {
           // Error from the API.
            print 'There was an API error : ' . $e->getCode() . ' : ' . $e->getMessage();
        } catch (Exception $e) {
            print 'There was a general error : ' . $e->getMessage();
        }
    }


} catch (apiServiceException $e) {
    // Error from the API.
    print 'There was an API error : ' . $e->getCode() . ' : ' . $e->getMessage();
} catch (Exception $e) {
    print 'There was a general error : ' . $e->getMessage();
}

// ここから RSS 化

date_default_timezone_set("Asia/Tokyo");

use \FeedWriter\ATOM;
$feed = new ATOM;

//チャンネル情報の登録
$feed->setTitle("hogehoge");          //チャンネル名
$feed->setLink("http://www.example.com/");     //URLアドレス
$feed->setDate(new DateTime());         //日付 (変更不要)

foreach($items as $tmp_item) {
    // メール本文中のURLを自動リンク化
    $description = mbereg_replace("(https?|ftp)(://[[:alnum:]\+\$\;\?\.%,!#~*/:@&=_-]+)", "<a href=\"\\1\\2\" target=\"_blank\">\\1\\2</a>" , $tmp_item['description']);
    // 改行を<br />に変換
    $description = nl2br($description);
        
    $item = $feed->createNewItem();
    $item->setTitle($tmp_item['title']);
    $item->setLink('http://www.example.com/archive.php?message_id=' . $tmp_item['message_id']); // この部分については次の記事で
    $item->setDate($tmp_item['date']);
    $item->setDescription($description);
    $feed->addItem($item);
}


file_put_contents('/path/to/hogehoge.rdf', $feed->generateFeed()); // ファイルに書き出す
?>

 このファイルに gmail2rss.php のような適当な名前を付けてサーバにアップロードします。CREDENTIALS_PATH や CLIENT_SECRET_PATH で指定している場所は、外部から見えない場所に設定してください。
 そのうえで、ターミナルから

php gmail2rss.php

を実行すると、初回は

Open the following link in your browser: https://〜

のようなメッセージが表示されますので、指定された URL にブラウザからアクセスしてください。このスクリプトに権限を与えてよいかのダイアログが表示されるので、許可すると、コードが表示されます。それをコピーしてターミナルに戻り

Enter verification code: 

に続けてペーストすると、スクリプトが実行され、指定された場所に RSS ファイルが生成されます。

 あとはこれを RSS リーダーで読めばよいのですが、このままだと、RSS リーダーで既読にしても、Gmail 側は未読のままです。そこで、記事の URL として指定された URL にアクセスすると Gmail 側も既読にしてアーカイブするようにしてみたいと思います。

中国方面からの Bittorrent 攻撃を防ぐ

 運用しているサーバ宛に

xxx.xxx.xxx.xxx - - [18/May/2015:03:18:27 +0900] "GET /announce/?info_hash=(以下略) HTTP/1.0" 404 371 "-" "Bittorrent"

こんな感じのログが大量に残されていました。見る人が見ればわかるように、Bittrorrent によるアクセスです。

 当然のことながら、運用しているサーバでは Bittorrent を立ち上げていません。あまりに多くのホストから大量のアクセスを受けるので、原因を調べてみたところ、中国の DNS サーバが(わざと?)間違った IP アドレスを返しているようで、その間違えて返す先がうちのサーバになっている様子。

 Fail2Ban を使って頻繁にやってくるホストについては、アクセスを遮断していたのですが、減りそうな気配もないため、さらに調べると、Bittorrent の中の人が、404(Not Found)を返すより、410(Gone)を返したほうが早く収束すると書いていたので(A Note on the DDoS Attacks)、それに従って

<Location ~ "^/announc">
ErrorDocument 410 "d14:failure reason13:not a tracker8:retry in5:nevere"
</Location>

を追加したのですが、テストしても 404 を返すばかり。
 実はこのディレクティブ、410 のエラーになったときのメッセージを変更するだけで、ステータスコードを変更する指定をしていないのです。

 なので、

RewriteEngine On
RewriteRule ^/announc - [G]

<Location ~ "^/announc">
ErrorDocument 410 "d14:failure reason13:not a tracker8:retry in5:nevere"
</Location>

のように、最初に mod_rewrite を使って指定の URL に対するアクセスに対して 410 を返したうえで、ErrorDocument の指定をする必要があります。

PHP で Google Analytics API(Service accounts版)

 Google APIs Client Library for PHP(v1.0.6beta)を使って Google Analytics API(v3) からデータを取得する方法について覚え書き。今回はサーバサイドでの処理を想定して、OAuth のクライアント種別を Service accounts にしています。ゴールは、このサンプルと同様の結果を得ることです。

 以下、コードです。

<?php

require_once 'vendor/autoload.php'; // composer の autoload.phpの場所を指定

$client_id = '(OAuth のクライアント ID を記入)';
$service_account_name = '(OAuth のメールアドレスを記入)'; //Email Address
$key_file_location = 'key.p12'; // OAuth のクライアント ID を作成したときに生成された p12 ファイルをアップロードし、その場所を指定

$client = new Google_Client();
$client->setApplicationName("Analytics_API_Examples");

$client->setClientId($client_id);

$key = file_get_contents($key_file_location);

$cred = new Google_Auth_AssertionCredentials (
    $service_account_name,
    array('https://www.googleapis.com/auth/analytics'),
    $key
);

$client->setAssertionCredentials($cred);

// 以下は上記のサンプルと同内容(Typo のみ修正)

$analytics = new Google_Service_Analytics($client);

runMainDemo($analytics);

function runMainDemo(&$analytics) {
    try {

        // Step 2. Get the user's first view (profile) ID.
        $profileId = getFirstProfileId($analytics);

        if (isset($profileId)) {

            // Step 3. Query the Core Reporting API.
            $results = getResults($analytics, $profileId);

            // Step 4. Output the results.
            printResults($results);
        }

    } catch (apiServiceException $e) {
        // Error from the API.
        print 'There was an API error : ' . $e->getCode() . ' : ' . $e->getMessage();

    } catch (Exception $e) {
        print 'There was a general error : ' . $e->getMessage();
    }
}

function getFirstprofileId(&$analytics) {
    $accounts = $analytics->management_accounts->listManagementAccounts();

    if (count($accounts->getItems()) > 0) {
        $items = $accounts->getItems();
        $firstAccountId = $items[0]->getId();

        $webproperties = $analytics->management_webproperties->listManagementWebproperties($firstAccountId);

        if (count($webproperties->getItems()) > 0) {
            $items = $webproperties->getItems();
            $firstWebpropertyId = $items[0]->getId();

            $profiles = $analytics->management_profiles->listManagementProfiles($firstAccountId, $firstWebpropertyId);

            if (count($profiles->getItems()) > 0) {
                $items = $profiles->getItems();
                return $items[0]->getId();

            } else {
                throw new Exception('No views (profiles) found for this user.');
            }
        } else {
          throw new Exception('No webproperties found for this user.');
        }
    } else {
        throw new Exception('No accounts found for this user.');
    }
}

function getResults(&$analytics, $profileId) {
    return $analytics->data_ga->get(
        'ga:' . $profileId,
        '2012-03-03',
        '2012-03-03',
        'ga:sessions');
}

function printResults(&$results) {
    if (count($results->getRows()) > 0) {
        $profileName = $results->getProfileInfo()->getProfileName();
        $rows = $results->getRows();
        $sessions = $rows[0][0];

        print "<p>First view (profile) found: $profileName</p>";
        print "<p>Total sessions: $sessions</p>";

    } else {
        print '<p>No results found.</p>';
    }
}


?>

こんな感じです。

最後にはまったのは、
There was a general error : Error calling GET https://www.googleapis.com/analytics/v3/management/accounts: (403) User does not have any Google Analytics account.
のエラー。OAuth のクライアント ID を作成するときに使用している Google アカウントに Analytics の閲覧権限を与えているのに…と悩んだのですが、OAuth のメールアドレス(上記コードの $service_account_name で指定したメールアドレス)に閲覧権限を与える必要があります。冷静に考えれば当たり前なのですが、気づきにくいかもしれません。

マルチドメインでSSLを1ドメインだけで利用しているときの注意ポイント

 複数ドメインを1つのIPアドレスで運用し、その中の1ドメインだけでSSLを使っているケースって多いと思うのですが、そのような場合に、SSLのデフォルト設定をしておかないと、大変なことになるというお話です。

 名前ベースのバーチャルホストを設定している場合

NameVirtualHost 192.51.100.57:80
NameVirtualHost 192.51.100.57:443

<VirtualHost 192.51.100.57:80>
    ServerName www.example.com
    DocumentRoot /var/www/www.example.com/htdocs
</VirtualHost>

<VirtualHost 192.51.100.57:443>
    ServerName www.example.com
    DocumentRoot /var/www/www.example.com/htdocs

    SSLEngine on
    SSLCertificateFile /etc/apache2/ssl/example.crt
    SSLCertificateKeyFile /etc/apache2/ssl/example.key
</VirtualHost>

<VirtualHost 192.51.100.57:80>
    ServerName www.example2.com
    DocumentRoot /var/www/www.example2.com/htdocs
</VirtualHost>

こんな感じで設定することが多いと思うのですが、この設定だけだと、https://www.example2.com/ をはじめとする未定義のホストにアクセスすると、/var/www/www.example.com/htdocs の内容が表示されてしまいます。これだと何かと都合が悪い(重複コンテンツになってしまいますし)ので、以下のように、デフォルトの設定を最初に加えておくことをおすすめします。

NameVirtualHost 192.51.100.57:80
NameVirtualHost 192.51.100.57:443

<VirtualHost 192.51.100.57:80>
    ServerName dummy
    CustomLog /var/log/apache2/access_log combined

    DocumentRoot /var/www/dummy
    <Directory /var/www/dummy>
        Options -Indexes
        Order allow,deny
        allow from all
    </Directory>
</VirtualHost>

<VirtualHost 192.51.100.57:443>
    ServerName dummy
    CustomLog /var/log/apache2/access_log combined

    DocumentRoot /var/www/dummy
    <Directory /var/www/dummy/>
        Options -Indexes
        Order allow,deny
        allow from all
    </Directory>
    SSLEngine on
    SSLCertificateFile /etc/apache2/ssl/example.crt
    SSLCertificateKeyFile /etc/apache2/ssl/example.key
</VirtualHost>

<VirtualHost 192.51.100.57:80>
    ServerName www.example.com
    DocumentRoot /var/www/www.example.com/htdocs
</VirtualHost>

<VirtualHost 192.51.100.57:443>
    ServerName www.example.com:443
    DocumentRoot /var/www/www.example.com/htdocs

    SSLEngine on
    SSLCertificateFile /etc/apache2/ssl/example.crt
    SSLCertificateKeyFile /etc/apache2/ssl/example.key
</VirtualHost>

<VirtualHost 192.51.100.57:80>
    ServerName www.example2.com
    DocumentRoot /var/www/www.example2.com/htdocs
</VirtualHost>

あとは、/var/www/dummy を作っておけばOKです。これで、https://www.example2.com/ にアクセスしてきた場合には、Not Found が返るようになります。Forbidden を返したければ

NameVirtualHost 192.51.100.57:80
NameVirtualHost 192.51.100.57:443

<VirtualHost 192.51.100.57:80>
    ServerName dummy
    <Location />
        Order deny,allow
        Deny from All
    </Location>
</VirtualHost>

<VirtualHost 192.51.100.57:443>
    ServerName dummy
    <Location />
        Order deny,allow
        Deny from All
    </Location>

    SSLEngine on
    SSLCertificateFile /etc/apache2/ssl/example.crt
    SSLCertificateKeyFile /etc/apache2/ssl/example.key
</VirtualHost>

以下略

こんな感じに。

 通常の80番ポートのほうは、取得したドメインの数だけホストの設定をすることが多いので、デフォルトの設定をしなくてもあまり問題が生じないのですが、443番のほうは逆に https を使わないホストについて設定することがほぼないと思います。それゆえにデフォルト設定が重要というわけです。
 ホストごとに設定ファイルを分けている場合(「001-example.com」みたいに)には、「000-default」のように、デフォルト設定が一番最初に読み込まれるようにファイル名を調整してください。

Zend ServerをUbuntu12.04にインストールしたときの覚え書き

  • インストール終了後に
update-alternatives --install /usr/bin/php php /usr/local/zend/bin/php 1
update-alternatives --config php

でインストールされた新しいPHPを標準のPHPにしておくとコマンドライン実行時に便利

  • pearのパッケージも再インストールが必要
/usr/local/zend/bin/pear install HTTP_Request2

など

気が付いたときに追記していきます

MySQLのNULL値と空文字の扱いについて

 覚え書き的にメモ。データ型はVARCHARの場合です。

SELECT * FROM tbl_name WHERE str = '';

→空文字のみがヒット

SELECT * FROM tbl_name WHERE str != '';

→空文字とNULL値が除外される

SELECT * FROM tbl_name WHERE str IS NULL;

→NULL値のみがヒット

SELECT * FROM tbl_name WHERE str IS NOT NULL;

→値が入っているものと空文字がヒット(NULL値が除外される)

str != '' の挙動が要注意ですね