cmd.exe のコマンドラインの解釈と Windows と Linux のプロセス作成の違い

PHPescapeshellarg の実装が Windows だと謎すぎたので調べていたら下記の記事にたどり着きました。

わりと長いこと Windows を使っているつもりなのですが知らなかったこともあり、とても興味深いとともに、もう cmd.exe には関わりたくないと思いました。

環境変数の展開

フェーズ1の環境変数%APPDATA% みたいなの)の展開は、フェーズ2のコマンドラインの解釈やフェーズ3の特殊文字の処理よりも先にあったんですね、知りませんでした。

例えば %APPDATA% のような文字をコマンドに渡したい場合、次のように書いていましたが・・・

> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- ^%APPDATA^%
%APPDATA%[EOF]

これは %^(キャレット)でエスケープしているからではなかったんですね。

いや、エスケープしているといえばエスケープしているのですが、^%特殊文字としての効果を打ち消しているわけではなく、単に APPDATA^ などという環境変数は存在しないから展開されないのですね。

なので、2つあるキャレットのうち、先頭部分は意味がないです。次のように2番目のキャレットだけで環境変数の展開を抑止できます。

> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- %APPDATA^%
%APPDATA%[EOF]

逆に1番目のキャレットだけだと環境変数が展開されます。

> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- ^%APPDATA%
C:\Users\oreore\AppData\Roaming[EOF]

また、次のようにシェルの特殊文字環境変数に入れた場合、その特殊文字は有効です。

> set PIPE=^|
> echo hogehoge %PIPE% php -r "echo 'PHP: '; stream_copy_to_stream(STDIN, STDOUT);"
PHP: hogehoge

bash だとそうはならないです。

$ export PIPE="|"
$ echo hogehoge $PIPE php -r "echo 'PHP: '; stream_copy_to_stream(STDIN, STDOUT);"
hogehoge | php -r echo 'PHP: '; stream_copy_to_stream(STDIN, STDOUT);

ダブルクオートのエスケープ

ダブルクオートを文字として渡したい場合、ダブルクオートの中でダブルクオートを2つ繋げていました。

> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- "A""B""""C"
A"B""C[EOF]

が、バックスラッシュでもできたんですね。なぜか知りませんでした。

> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- A\"B\"\"C
A"B""C[EOF]

がしかし、バックスラッシュの次の動作はキモいです。

> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- \\\\\\\\\"
\\\\"[EOF]
> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- \\\\\\\\\
\\\\\\\\\[EOF]

バックスラッシュは " に前置で連続しているときだけ \ 自身をエスケープし、それ以外の \ はエスケープしません。

Windows は UNC で \\ComputerName\SharedFolder のような記述をするので、バックスラッシュ単独ではエスケープされないようにしたかったからでしょうかね、予想ですけど。

WindowsLinux のプロセス作成のコマンド引数の違い

どちらかといえば最初は Windows 畑で育ったわたしは元々知っていましたが、参考記事のコメントにも書かれている通り Windows ではプロセスを作成するときのコマンド引数の指定の方法が Linux とは全く違います。

Linux だとコマンド引数は呼び出し側(通常はシェル)がコマンド引数の文字列を配列にバラします。

system(3) だとコマンドラインの文字列そのものを指定しますが、これはシェルを経由するからであって、コマンド引数の文字列はシェルが配列にバラしています。

PHP で言うと pcntl_exec だけはコマンド引数を配列で渡すのに対して、その他の普通のプロセス実行系の関数はコマンドライン全体を文字列として渡します(shell_exec など)。これは、pcntl_exec 以外はすべてシェルを経由するからです。

node.js なら child_process.execchild_process.spawn にその違いが現れています。

他の言語でもたぶん同じです。シェルを経由するのであればコマンドライン全体を単一の文字列で指定し、シェルを経由しないのであればコマンドのパスと引数の配列を指定します。もし、シェルを経由しないにも関わらずコマンド引数を配列ではなく文字列で指定できるのであれば、それはホスト言語が自前で配列にバラしているはずです。

一方、Windows だとプロセスを作成するときには、実行するバイナリのパス、と、コマンド引数、の2つの文字列を指定します(文字列と文字列の配列、では無く)。

なので、パイプやリダイレクト、および、実行するバイナリのパスとその後の文字列の分割、までは cmd.exe の仕事ですが、コマンド引数の文字列を配列 argv[] にバラすのは呼び出された側の仕事です。

C言語なら CRT の仕事です。MSVC なら真のエントリポイントである mainCRTStartup が配列にバラして main 関数の argv[] にセットします。おそらく CommandLineToArgvW 関数 が使われているのではないでしょうか。参考記事によると ruby は独自に解釈しているっぽいし、gcc でビルドしたバイナリも解釈が異なるようです。

ソースを確認したわけではないですが、rubymain 関数の argv[] は無視して GetCommandLine 関数 かなにかでコマンドラインを取得して独自に解析しているのではないでしょうか? まさか ruby のための CRT がある、なんてことは無いと思いますので。

Linux でもシェルによってコマンド引数の文字列を配列にバラすルールは微妙に違うかもしれませんが、POSIX の sh で最低限の仕様は担保されているのだと思います(system(3) なんかはユーザーのデフォルトシェルに関わらず /bin/sh が呼ばれるんでしたっけ?)。

が、Windows だと実行するコマンドによってその動作はバラバラなので、汎用的な escapeshellarg 関数など作りようがありません。

コマンド引数に改行を含める

参考記事には記述がありませんでしたが、cmd.exe ではキャレットを改行の直前に記述するとその改行が除去されます。

> php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- A^
More? Z
AZ[EOF]

Bash で言うところの改行の直前の \ と同じです。

More? はプロンプトの文字なので無視してください。バッチファイルの方が判りやすいですね。

a.bat

@php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- A^
Z
> a.bat
AZ[EOF]

また、キャレットの後の改行の直後が改行だと、改行になります。つまり "^\n\n" が改行になります。

a.bat

@php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- A^

Z
> a.bat
A
Z[EOF]

改行を複数入れたければ次のようにします。

@php -r "echo $argv[1] . '[EOF]' . PHP_EOL;" -- A^

^

^

Z
> a.bat
A


Z[EOF]

が、しかし、MSVC の system 関数だと改行以降の文字が無視されました。

a.c

#include <stdio.h>
#include <stdlib.h>

int main()
{
    system("php -r \"echo $argv[1] . '[EOF]' . PHP_EOL;\" -- A^\n\nZ");
    return 0;
}
> a.exe
A[EOF]

うーん、謎。

さいごに

とりあえずの結論としては、WindowsPHP では escapeshellarg は使わないほうが良いと思います。あるいはメタ文字っぽいものが無いことをあらかじめチェックした上で使った方が良いです。メタ文字を含む文字をコマンドに渡すために使う関数なのにメタ文字が渡せないなんて本末転倒もいいところですが。

あるいは proc_openbypass_shell オプション付きで使い、参考記事のフェーズ4のメタ文字だけどうにかすれば良いです。

プロセス実行系の関数に動的な文字を含めるようなことをしないのが一番良いのは間違いないですけどね。

PhpStorm 7 で SSH 経由で PHPUnit を実行する

下記の記事によると PhpStorm 8 では SSH 経由で PHPUnit が実行できるようです。

ホストが Windows で開発環境の VMLinux(CentOS) なわたしは PhpStorm から PHPUnit が実行できるようにするためだけに Windows でも Linux でも動作するコードを書くように心がけているのですが(主に int のサイズ)、PhpStorm 8 がリリースされればそのような苦行とはさよならできそうです、やったね。

.

.

.

それはそれとして、思いつきで PhpStorm 7 でも SSH 経由で PHPUnit が実行できるようにしてみました。例外処理とかしていないやっつけですが PHPUnit を実行するだけなのでまぁいいかなと。

前提

  • ローカルホストは Windows
  • リモートホストLinux
    • php にはパスが通っている
  • PhpStorm で Deployment の設定ができている
    • あるいはローカルとリモートでディレクトリが共有されている
  • PhpStorm で ローカルホストで PHPUnit が実行できるように設定済
    • composer で phpunit をインストール
    • [PHP -> PHPUnit] で必要な設定を行う
  • [PHP -> Servers]マッピングの設定名はリモートホストのホスト名の最初のセグメントと一致する
    • abc.example.net なら abc という設定名にする

方法

PhpStorm の メインメニューから [Run -> Edit Configurations] を選択します。

[PHPUnit] の下にある設定をすべて削除します。

img:PHPUnit の下にある設定をすべて削除

[Defaults -> PHPUnit] を選択して次のように設定します。

img:Defaults -> PHPUnit を選択

  • Interpreter options: -d include_path=. -d auto_prepend_file=remote.php
  • Custom working directory: プロジェクトのルートディレクトリ

要するに PhpStorm で PHPUnit を実行するときに auto_prepend_fileremote.php が実行されれば OK です。

remote.php を次の内容で作成します。先頭の3つの変数は適当に書き換えてください。あるいは同じディレクトリに remote.local.php を作って、そのファイルに3つの変数を書くのでも OK です。

  • $remote_hostリモートホストの IP アドレスやホスト名
  • $remote_dirリモートホストのデプロイ先ディレクトリ
  • $local_dir はローカルの PhpStorm のプロジェクトのディレクトリ

PhpStorm で PHPUnit を実行します(PHPUnit on Server じゃなくてただの PHPUnit)。PHPUnitSSH 経由で実行され、PhpStorm や Run や Debug のコンソールに結果が表示されます。

次のような Windows では通らないテストも・・・

グリーンです。

img:グリーンです

テストがコケたときのスタックトレースも、それっぽく書き換えているので PhpStorm でクリックすることができます。

img:スタックトレースもクリックできる

リモートデバッグをしたいときは [Start Listen for PHP Debug Connections] を On にしてデバッグ実行してください。

img:Start Listen for PHP Debug

[PHP -> Servers] でパスのマッピングを行っていれば、ブレークポイントを仕込んだところで止まります。

img:ブレークポイント


PhpStorm から PHPUnitデバッグ実行したとき、本当なら XDEBUG_CONFIG で idekey が渡されるのですが、その idekey で xdebug.remote のセッションを開始してもパスのマッピングが解決できない(PhpStorm は xdebug からローカルのパスで通知されると思っている)ため、PHPUnitデバッグセッションではなく PHP Remote Debug のデバッグセッションとしてアタッチする必要があります。

ストリームラッパーを使って素の PHP で自動エスケープ

素の PHP をテンプレートとして使うフレームワークは今でも結構あるようです。

素の PHP をテンプレートに使う、というのがどういうことかというと、簡単な例ですが次のようなものです。

index.php

<?php
$name = filter_input(INPUT_GET, 'name');
include __DIR__ . '/index.html.php';

index.html.php

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello <?= $name ?>
</body>
</html>

php -S localhost:8888 でビルドインウェブサーバを起動して http://localhost:8888/?name=oreore にアクセスすると Hello oreore と表示されることでしょう。

しかし、↑のコードは $name がエスケープされておらずよろしくありません。 http://localhost:8888/?name=%3Cscript%3Ealert%28%27!!!%27%29%3C/script%3E などとアクセスすると !!! と alert されてしまいます。

これを防止するためには、テンプレートで変数を表示するときに htmlspecialchars をかます必要があります。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello <?= htmlspecialchars($name, ENT_QUOTES) ?>
</body>
</html>

さすがに毎度 <?= htmlspecialchars($name, ENT_QUOTES) ?> などと書くのはめんどくさいので、フレームワークによっては別の書き方が提供されています。

例えば・・・

  • Zend Framework<?= $this->escapeHtml($name) ?> とすればエスケープされます
    • あまり楽になっている気がしないです
  • CakePHP<?= h($name) ?> と書けば良いらしいです
    • こういうグローバル関数がたくさん定義されているようです
  • FuelPHP は変数をビューにアサインしたときに自動でエスケープされるようです
    • Security::htmlentities を見た感じオブジェクトをアサインするときに困りそうです

Twig も Smarty も自動的にエスケープする機能を持っており、かつ、FuelPHP のようなオブジェクトをアサインしたときの問題もありません。素の PHP でも同じようなことがやりたいです。。。

.

.

.

やってみました。

index.php

<?php
require __DIR__ . '/../vendor/autoload.php';

use PhpRenderer\Renderer;

$data = [
    'name' => "<script>alert('!')</script>",
    'html' => "<strong>safe string</strong>",
];

echo (new Renderer)->render(__DIR__ . '/index.html.php', $data);

index.html.php

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello <?= $name ?>.
<br>
None Escape <?php echo $html ?>
</body>
</html>
  • <?= $name ?> のようなショートタグを使うと自動的にエスケープされます
  • <?php echo $html ?> のように普通の PHP タグを使うとエスケープされません

↑の例は次のような HTML になります。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>
Hello &lt;script&gt;alert('!')&lt;/script&gt;.
<br>
None Escape <strong>safe string</strong></body>
</html>

実装方法

PHP にはストリームラッパーという便利なものがあります。fopen などのファイルシステム関数に http:// とかを渡すことができるあれです。

実は require や include にもストリームラッパーが使えます(いくつかのストリームラッパーは allow_url_include を on にしなければ使えません)。

例えば compress.zlib ストリームラッパーで gzip 圧縮された PHP のコードを include してみます。

gzip.php

<?php
echo "this is gzip\n";

index.php

<?php
include 'compress.zlib://' . __DIR__ . '/gzip.php.gz';
$ gzip gzip.php
$ php index.php
this is gzip

そして、ストリームラッパーは独自のものを新たに登録することもできます。

なので、独自のストリームラッパーを作れば require や include するときに PHP のコードをプリプロセスすることができます。

実装コード

実装したコードは下記にあります。

StreamWrapper.php がストリームラッパーの実装クラスです。php.renderer:// のようなスキームで呼べるように登録します。

rewrite()PHP のコードを書き換えています。 正規表現でざっくり はさすがに怖い気がしたので token_get_all() の字句解析の結果を元に T_OPEN_TAG_WITH_ECHOT_CLOSE_TAG を書き換えています(<?=?> です)。

問題点

opcache

素の PHP ですが opcache は効きません。opcache は file:// と phar:// 以外のストリームラッパーはキャッシュしないようです。

なので、コードの書き換えは都度発生します。

ストリームラッパーを使わずに、書き換えたコードをどこかに保存してそのまま include すれば大丈夫ですが・・・当初の目論見ではストリームラッパーによって変換された後の opcode がキャッシュされるかなーと思ってたので残念です。

parse error

単純に

<?= $name ?>

<?=StreamWrapper::html( $name )?>

と書き換えているだけなので、

<?= $name; ?>

などと書かれると

<?=StreamWrapper::html( $name; )?>

となってパースエラーになります。

その他

普段まったく使わないのですっかり忘れていましたが asp_tags なんてものもありました。 asp_tags を有効にして <%= の形式のときだけ自動エスケープを行うのも良いかもしれません。

さいごに

社内で勉強会したときのスライド。若干ネタ成分あり。

MySQL の where 狙いと order by 狙い

わたしは参加していないのですが YAPC::Asia Tokyo 2014 で次のような発表があったようです。

このセッションを聞いている人を WHERE gender = 'male' なら ORDER BY を狙った方がいいだろうし、WHERE gender = 'female' なら WHERE を狙った方がいい。

ちょっと試してみます。

次のようなテーブルがあったとします。age は年齢で gender は性別です。

drop table if exists yapcasia;
create table yapcasia (
  id int not null primary key,
  age int not null,
  gender char(6) not null
);

create index idx_yapcasia__age on yapcasia (age);
create index idx_yapcasia__gender on yapcasia (gender);

次のようにデータを入れます。

time seq 10000 |
  mysql test -e "
    truncate table yapcasia;
    load data local infile '/dev/stdin' into table yapcasia (@id)
      set id = @id,
          age = floor(rand() * 40 + 20),
          gender = if(@id % 500, 'male', 'female');
    analyze table yapcasia;
  "

男性が 9980 人に対して、女性が 20 人しかいません。

select count(*) from yapcasia where gender = 'male';
/* 9980 */

select count(*) from yapcasia where gender = 'female';
/* 20 */

性別ごとに年齢の若い順に 5 人列挙します。

select * from yapcasia where gender = 'male' order by age asc limit 5;
+-----+-----+--------+
| id  | age | gender |
+-----+-----+--------+
|  18 |  20 | male   |
|  51 |  20 | male   |
|  75 |  20 | male   |
| 122 |  20 | male   |
| 181 |  20 | male   |
+-----+-----+--------+

select * from yapcasia where gender = 'female' order by age asc limit 5;
+------+-----+--------+
| id   | age | gender |
+------+-----+--------+
| 4000 |  20 | female |
| 4500 |  20 | female |
| 1500 |  22 | female |
| 7500 |  22 | female |
| 3500 |  22 | female |
+------+-----+--------+

実行計画を見てみると、

explain select * from yapcasia where gender = 'male' order by age asc limit 5 \G
/*
           id: 1
  select_type: SIMPLE
        table: yapcasia
         type: index
possible_keys: idx_yapcasia__gender
          key: idx_yapcasia__age
      key_len: 4
          ref: NULL
         rows: 10
        Extra: Using where
*/

explain select * from yapcasia where gender = 'female' order by age asc limit 5 \G
/*
           id: 1
  select_type: SIMPLE
        table: yapcasia
         type: ref
possible_keys: idx_yapcasia__gender
          key: idx_yapcasia__gender
      key_len: 18
          ref: const
         rows: 20
        Extra: Using index condition; Using where; Using filesort
*/

男性を検索するときは ORDER BY 狙いでインデックスが、女性を検索するときは WHERE 狙いでインデックスが使われています。

InnoDB の統計情報はカーディナリティが極端に低い列だと値ごとの統計情報も持っているのでしょうかね?


この例なら yapcasia (gender, age) なインデックスがあれば両狙いになるのは言うまでもありません。

drop index idx_yapcasia__age on yapcasia;
drop index idx_yapcasia__gender on yapcasia;
create index idx_yapcasia__gender_age on yapcasia (gender, age);
analyze table yapcasia;

explain select * from yapcasia where gender = 'male' order by age asc limit 5 \G
/*
           id: 1
  select_type: SIMPLE
        table: yapcasia
         type: ref
possible_keys: idx_yapcasia__gender_age
          key: idx_yapcasia__gender_age
      key_len: 18
          ref: const
         rows: 5078
        Extra: Using where; Using index
*/

explain select * from yapcasia where gender = 'female' order by age asc limit 5 \G
/*
           id: 1
  select_type: SIMPLE
        table: yapcasia
         type: ref
possible_keys: idx_yapcasia__gender_age
          key: idx_yapcasia__gender_age
      key_len: 18
          ref: const
         rows: 20
        Extra: Using where; Using index
*/

AWS で WordPress を RDS とか s3fs とか使って動かしてみる

前回は WordPress にアップロードされた画像などのファイルを AWS SDK for PHP の S3 ストリームラッパーを使って無理やり S3 に保存しましたが、

s3fs-fuse を使えばもっと簡単そうなのでやってみました。

下記の手順は前回と変わらないので省略します。

前回の手順では S3 Budket の作成で Permissions の bucket policy でアップロードされたファイルが自動的に公開されるようにしていましたが、s3fs ならマウントオプション (default_acl) で指定できるので Permissions の設定は必要ありません(もしかしたら AWS SDK for PHP のストリームラッパーでもできたのかも?)。

s3fs-fuse のインストール

s3fs-fuse をインストールします。

まずはビルドに必要なパッケージをインストールします。

yum install kernel-devel libxml2-devel libcurl-devel gcc-c++ fuse fuse-devel openssl-devel git automake

ソースを取得してビルドします。

git clone https://github.com/s3fs-fuse/s3fs-fuse.git
cd s3fs-fuse/
./autogen.sh
./configure
make
sudo make install

マウントポイントを作成します。

mkdir -p /s3fs/wordpress/

fstab に追記します。

cat <<EOS>> /etc/fstab
/usr/local/bin/s3fs#example-wordpress /s3fs/wordpress fuse\
 _netdev,rw,allow_other,uid=$(id -u apache),gid=$(id -g apache),default_acl=public_read,iam_role=wp-role 0 0
EOS
cat /etc/fstab

マウントします。

mount -a

WordPress のインストール

WordPress をダウンロードして /var/www/html に展開します。

cd /usr/local/src/
wget http://ja.wordpress.org/wordpress-3.9.2-ja.zip
unzip wordpress-3.9.2-ja.zip
rm -fr /var/www/html/
mv wordpress /var/www/html/

wp-config.php を作ります。DB のホスト名には RDS の Endpoint のホスト名を指定します。

cd /var/www/html
mv wp-config-sample.php wp-config.php

sed wp-config.php -e "
  /^define('DB_NAME'/c     define('DB_NAME', 'wordpress');
  /^define('DB_USER'/c     define('DB_USER', 'wordpress');
  /^define('DB_PASSWORD'/c define('DB_PASSWORD', 'wordpress');
  /^define('DB_HOST'/c     define('DB_HOST', 'wordpress.xxxxxxxx.ap-northeast-1.rds.amazonaws.com');
" -i

curl https://api.wordpress.org/secret-key/1.1/salt/ > /tmp/secret

sed wp-config.php -e "
  /^define('AUTH_KEY'/r /tmp/secret
  /^define('AUTH_KEY'/d
  /^define('SECURE_AUTH_KEY'/d
  /^define('LOGGED_IN_KEY'/d
  /^define('NONCE_KEY'/d
  /^define('AUTH_SALT'/d
  /^define('SECURE_AUTH_SALT'/d
  /^define('LOGGED_IN_SALT'/d
  /^define('NONCE_SALT'/d
" -i

cat wp-config.php

ブザウザで http://aws.example.net/ にアクセスして WordPress のインストールウィザードに必要項目を入力してインストールを完了します。

この後、アップロードファイルの保存先の設定をするので、まだ記事の投稿は行いません。

WordPress のアップロード先設定

WordPress の次の URL を開きます。

  • http://aws.example.net/wp-admin/options.php

upload_url_path を探して、次の通りに入力して保存します。

upload_url_path  http://example-wordpress.s3-ap-northeast-1.amazonaws.com

WordPress のアップロード先ディレクトリにシンボリックリンクを作ります。

ln -sf /s3fs/wordpress /var/www/html/wp-content/uploads

動作確認

WordPress に画像をアップロードしてみます。上手くアップロードできれば成功です。

参考にした記事

参考にした記事だと IAM ロールを使うときは rc.local でマウントしていたけれど、fstab で _netdev を指定することでマウントのタイミングを遅らせるようにしてみました。

AWS で WordPress を RDS とか S3 とか使って動かしてみる

少し前に AWS で DRBD やら Pacemaker やら Heartbeat やら GlusterFS を使って WordPress を HA 構成にしてみましたが、

なんで RDS とか S3 とか使わないの? 馬鹿なの? 死ぬの? とセルフツッコミが入ったので RDS や S3 を使ってみました。

VPC の作成

VPC は以前作ったものをそのまま使います。

おさらいすると、次のような感じです。

- VPC
   - Name tag               wp-vpc
   - CIDR block             10.1.0.0/16
   - Tenancy                Default
- Subnets
   - Name tag               wp-net-a
   - VPC                    wp-vpc
   - Availability Zone      ap-northeast-1a
   - CIDR block             10.1.1.0/24
   - Auto-assign Public IP  yes
- Subnets
   - Name tag               wp-net-c
   - VPC                    wp-vpc
   - Availability Zone      ap-northeast-1c
   - CIDR block             10.1.2.0/24
   - Auto-assign Public IP  yes
- Route Tables
   - Routes
      - Destination         0.0.0.0/0
      - Target              wp-gw
- Internet Gateways
   - Name tag               wp-gw
   - VPC                    wp-vpc
- Security Group
   - Security group name    default
   - VPC                    wp-vpc
   - inbound Rule
      - Type                ALL Traffic
      - Source              sg-xxxxxxxx (default)
   - inbound Rule
      - Type                SSH(22)
      - Source              My IP
   - inbound Rule
      - Type                All ICMP
      - Source              My IP
- Security Group
   - Security group name    public
   - VPC                    wp-vpc
   - inbound Rule
      - Type                HTTP
      - Source              Anywhere

IAM ロールを作成

WordPress から S3 にアップロードできるようにするために IAM ロールを作成します。

IAM ユーザーを作成して、そのユーザーの Access Key ID と Secret Access Key を使って S3 にアクセスすることも出来ますが、その場合は Access Key ID と Secret Access Key を EC2 インスタンス内に保存する必要があります。

一方、IAM ロールならインスタンスの作成時にロールを紐つけるだけで良いので、Access Key ID や Secret Access Key をインスタンスに保存する必要がなく、簡単かつ安全です。

特別な事情で IAM ロールが使えない場合以外は、IAM ロールを使うことが奨励されているようです。

IAM ロールを作成

IAM の Management console を開いて、左ペインの Roles から Create New Role をクリックします。

Step 1 : Set Role Name

Role Name に wp-role などと適当な名前を入力します。

Step 2 : Select Role Type

AWS Service Roles の Amazon EC2 を選択します。

Step 4 : Set Permissions

Custom Policy を選択して、テキストエリアに次を貼り付けます。Policy Name はなんでも良いです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": "*"
    }
  ]
}

この IAM ロールが紐付けられた EC2 インスタンスは S3 に対するフルアクセスが可能になります。

本当はもっと絞ったほうがいいのだろうけど Resource の指定方法がよくわかりませんでした。

RDS サブネットグループの作成

RDS のインスタンスを作る前に RDS サブネットグループを作成します。ここで指定したサブネットで RDS インスタンスは起動します。

RDS の Management Console の左ペインで Subnet Groups を選択して、Create DB Subnet Group をクリックします。

次のように入力して作成します。

  • Name wp-db-nets
  • Description WordPress Subnet Group
  • VPC ID wp-vpc (前回作ったやつ)
  • Availability Zone と Subnet ID を選択して以下のサブネットを追加
    • wp-net-a
    • wp-net-c

RDS インスタンス作成

RDS のインスタンスを作成します。

Step 1: Select Engine

mysql を選択します。

Step 2: Production?

Yes を選択します。

Step 3: Specify DB Details

Instance Specifications を次のように指定します。

  • DB Instance Class db.t2.micro
  • Allocated Storage 5 GB
  • Use Provisioned IOPS No

Settings には次のように入力します。

  • DB Instance Identifier wordpress
  • Master Username wordpress
  • Master Password wordpress
  • Confirm Password wordpress

Step 4: Configure Advanced Settings

次のように設定します。

  • Network & Security
    • VPN wp-vpc (前回作ったやつ)
    • DB Subnet Group wp-db-nets (↑で作ったやつ)
    • Publicly Accessible No
    • VPC Security Group default (同じセキュリティグループ同士で通信可能)
  • Database Options
    • Database Name wordpress
  • Backup
    • Backup Retention Period 0 days
  • Maintenance
    • Auto Minor Version Upgrade No

とくに記載していない部分はデフォルトのままにします。

インスタンスが立ち上がったら、Management console でインスタンスの画面を開いて Endpoint に表示されているホスト名を覚えておきます。

S3 Budket を作成

画像などをアップロードする先として S3 のバケットを作成します。

まず、S3 の Management console を開いて Create Bucket をクリックして適当な名前のバケットを作成します(仮に example-wordpress とします)。

作成したバケットを選択して、右上の方にある Properties をクリックします。

Permissions をクリック、Add bucket policy をクリックして、テキストエリアに下記を貼り付けて Save をクリックします

{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "AddPerm",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::example-wordpress/*"
        }
    ]
}

これで、このバケットにアップロードされたファイルは自動的に公開されるようになります。

なお、バケットの名前はグローバルに一意(すべてのアカウントで一意)でなければならないので、実際に example-wordpress などという名前のバケットは作れません、他の人が既にその名前でバケットを作っているからです。

EC2 インスタンスの作成

EC2 の Management Console の Instances から Lunch Instance をクリックして、次のようなインスタンスを1台だけ作成します。

Step 1: Choose an Amazon Machine Image (AMI)

Quick Start で Amazon Linux AMI 2014.03.2 (HVM) を選択します。

Step 2: Choose an Instance Type

t2.micro を選択します。

Step 3: Configure Instance Details

下記のみ変更して、その他はデフォルトのままにします。

Network                 wp-vpc
Auto-assign Public IP   Enable
IAM role                wp-role

Step 4: Add Storage

Volume Type を General Purpose (SSD) に変更します。

Step 5: Tag Instance

Name に wp-ap などと判りやすい名前を入力します。

Step 6: Configure Security Group

Select an existing security group を選択して default を選択します。

Step 7: Review Instance Launch

ざっくり内容を確認して、Launch をクリックします。

しばらく待つとインスタンスが起動するので SSH でログインします。

いろいろインストール

作成したインスタンスにいろいろインストールします。

yum

yum update します。

yum update

mysqlapachephp をインストールします。

yum install httpd24 php55 php55-opcache php55-mysqlnd mysql55

リブートします。

reboot

各種設定

各種設定を行います。

php

php の設定ファイルを作ります。

cat <<EOS> /etc/php-5.5.d/_default_.ini
expose_php = Off
error_reporting = E_ALL
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/php.log
date.timezone = Asia/Tokyo
post_max_size = 500M
upload_max_filesize = 500M
memory_limit = 1G
EOS
cat /etc/php.d/_default_.ini

ログディレクトリを準備します。

mkdir /var/log/php/
chmod 777 /var/log/php/
touch /var/log/php/php.log
chmod 666 /var/log/php/php.log

ログローテートの設定を作成します。

cat <<EOS> /etc/logrotate.d/php
/var/log/php/*.log {
    missingok
    daily
    rotate 10
    ifempty
    create 0666 root root
}
EOS
cat /etc/logrotate.d/php

apache

いらなさそうな設定ファイルを空にします。

echo -n "" > /etc/httpd/conf.d/autoindex.conf
echo -n "" > /etc/httpd/conf.d/userdir.conf
echo -n "" > /etc/httpd/conf.d/welcome.conf

仮の index.html と ELB からのヘルスチェック先のページを作成します。

echo index > /var/www/html/index.html
echo readme > /var/www/html/readme.html

自動起動を有効にして起動します。

chkconfig httpd on
service httpd start

ELB の作成

ELB を作成します。

1. Define Load Balancer

Load Balancer name  wp-lb
Create LB Inside    wp-vpc

その他はデフォルトのままにします。

2. Configure Health Check

Ping Path           /readme.html

その他はデフォルトのままにします。

3. Select Subnets

ap-northeast-1aap-northeast-1c を追加します。

4. Assign Security Groups

defaultpublic を選択します。

5. Add EC2 Instances

↑で作成したインスタンス wp-ap を選択します。

Stickiness

ELB の作成後、Description の Port Configuration の近くにある Edit をクリックして Enable Load Balancer Generated Cookie Stickiness を選択します。

これをやっておかないと AP が複数になったときにセッションが維持されないことがあります(たぶん)。

ELB の DNS 名を登録

Route53 で ALIAS レコードで ELB を登録します。仮に aws.example.net だとします。

なお、ELB を作成してからしばらく時間をおかないと登録できませんでした。

登録したドメイン名をブラウザで閲覧して index.html が表示されれば正常に登録できています。

WordPress のインストール

WordPress をダウンロードして /var/www/html に展開します。

cd /usr/local/src/
wget http://ja.wordpress.org/wordpress-3.9.2-ja.zip
unzip wordpress-3.9.2-ja.zip
rm -fr /var/www/html/
mv wordpress /var/www/html/

wp-config.php を作ります。DB のホスト名には RDS の Endpoint のホスト名を指定します。

cd /var/www/html
mv wp-config-sample.php wp-config.php

sed wp-config.php -e "
  /^define('DB_NAME'/c     define('DB_NAME', 'wordpress');
  /^define('DB_USER'/c     define('DB_USER', 'wordpress');
  /^define('DB_PASSWORD'/c define('DB_PASSWORD', 'wordpress');
  /^define('DB_HOST'/c     define('DB_HOST', 'wordpress.xxxxxxxx.ap-northeast-1.rds.amazonaws.com');
" -i

curl https://api.wordpress.org/secret-key/1.1/salt/ > /tmp/secret

sed wp-config.php -e "
  /^define('AUTH_KEY'/r /tmp/secret
  /^define('AUTH_KEY'/d
  /^define('SECURE_AUTH_KEY'/d
  /^define('LOGGED_IN_KEY'/d
  /^define('NONCE_KEY'/d
  /^define('AUTH_SALT'/d
  /^define('SECURE_AUTH_SALT'/d
  /^define('LOGGED_IN_SALT'/d
  /^define('NONCE_SALT'/d
" -i

cat wp-config.php

ブザウザで http://aws.example.net/ にアクセスして WordPress のインストールウィザードに必要項目を入力してインストールを完了します。

この後、アップロードファイルの保存先の設定をするので、まだ記事の投稿は行いません。

WordPress のアップロード先設定

AWS SDK for PHP には S3 のストリームラッパーがあります。次のようなパスで S3 へのアップロードやダウンロードができるようになります。

  • s3://<BucketName>/<PathToFile>

WordPress がアップロードするディレクトリのパスをストリームラッパーのパスに変更して、S3 にアップロードされるようにしてみます。

まず、WordPress の次の URL を開きます。普通には弄れない内部的なオプションを変更するための画面なのだと思います。

  • http://aws.example.net/wp-admin/options.php

upload_pathupload_url_path を探して、次の通りに入力して保存します。

upload_path         s3://example-wordpress
upload_url_path     http://example-wordpress.s3-ap-northeast-1.amazonaws.com

upload_path に指定した値は /var/www/htmlWordPress のインストール先)からの相対パスになるようです。

なので、このままだと /var/www/html/s3://example-wordpress などという意味不明なパスになるので、WordPress のソースを弄って修正します。

wp-includes/functions.php の 1644 行目辺りをコメントアウトします。

    //} elseif ( 0 !== strpos( $upload_path, ABSPATH ) ) {
    //  // $dir is absolute, $upload_path is (maybe) relative to ABSPATH
    //  $dir = path_join( ABSPATH, $upload_path );

後は、S3 のストリームラッパーを登録するだけです。

AWS SDK for PHP は composer でインストールできるので、まずは composer をインストールします。

curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
chmod +x /usr/local/bin/composer

AWS SDK for PHP をインストールします。

cd /var/www/html
composer require aws/aws-sdk-php:\*

こんな場所で composer require すると composer.json とか vendor/ とかが丸見えになるのでよろしくありません。が、とりあえずそのままにします。

さらに wp-config.php に下記を追記します。

require 'vendor/autoload.php';
use Aws\S3\S3Client;
use Aws\Common\Enum\Region;
S3Client::factory(array('region' => Region::AP_NORTHEAST_1))->registerStreamWrapper();

これで S3 のストリームラッパーが登録され、アップロードファイルが S3 に保存されるようになりました。

適当に画像などをアップロードした後に S3 のバケットを閲覧してみると、アップロードされたファイルが保存されているのがわかります。

また、ブラウザで表示される画像の URL も S3 の URL になっています。

WordPress の補足

たぶん普通はこんなことしません。プラグインかなにかを使うものなのだと思いますが、実は WordPress ほとんど使ったことがなく、よく判らなかったので適当にソースを弄りました。

ググると下記のプラグインが出てきたのですが、プラグインのインストール方法とかをよく知らないので使いませんでした。

あるいは s3fs-fuse を使えば、単に /var/www/html/wp-content/uploads にマウントするだけで終わりそうです。

AMI の作成

最後に Auto Scaling を設定してみます、と言っても単にインスタンスをたくさん立ち上げただけです。

Auto Scaling には AMI が必要なので、作成済の EC2 インスタンスを AMI 化します。

インスタンスにログインして、AMI に含めたくないファイルを削除してシャットダウンします。

rm -f /root/.ssh/authorized_keys
rm -f /home/ec2-user/.ssh/authorized_keys

rm -f /etc/ssh/ssh_host_dsa_key
rm -f /etc/ssh/ssh_host_dsa_key.pub
rm -f /etc/ssh/ssh_host_key
rm -f /etc/ssh/ssh_host_key.pub
rm -f /etc/ssh/ssh_host_rsa_key
rm -f /etc/ssh/ssh_host_rsa_key.pub

find /var/log -type f | xargs rm -fv

rm -f /root/.bash_history

shutdown -h now

EC2 の Management console で インスタンスを選択して Create Image で AMI を作ります。

Launch configuration の作成

EC2 の Management console の Launch configuration で Create launch configuration をクリックします。

1. Choose AMI

My AMIs を選択して、↑で作成した AMI を選択します。

2. Choose Instance Type

t2.micro を選択します。

3. Configure details

Name に wp-auto などと判りやすい名前を設定します。

IAM role には wp-role を指定します。

4. Add Storage

Delete on Termination が On になっていることを確認します。なっていなければ On にします。

5. Configure Security Group

Select an existing security group を選択して default を選択します。

6. Review

ざっくり内容を確認して右下の Create launch configuration をクリックします。

Auto Scaling Group の作成

EC2 の Management console の Auto Scaling Groups で Create Auto Scaling Groups をクリックします。

Create an Auto Scaling group from an existing launch configuration を選択して、先ほど作成した Launch configuration を選択します。

1. Configure Auto Scaling group details

Group name          wp-group
Group size          起動したいインスタンスの数
Network             wp-vpc
Subnet              wp-net-a wp-net-c

Advanced Details を開いてさらに設定します。

Load Balancing      On wp-lb (チェックボックスを On にしてテキストボックスに ELB を入力)

2. Configure scaling policies

特になにも変更せずに次に進みます。

3. Configure Notifications

特になにも変更せずに次に進みます。

4. Configure Tags

特になにも変更せずに次に進みます。

5. Review

ざっくりと内容を確認して右下の Create Auto Scaling Groups をクリックします。

しばらく待つと、インスタンスがバコバコ起動して ELB に追加されます。

さいごに

試しにインスタンスの数を 100 台に設定してみたのですが、20 台目以降はインスタンスの作成に失敗しました。

EC2 の Management console の Limits を見ると t2.micro は 20 台がリミットになっているので、申請しなければ 20 台より多くは起動できないようです。

下図は 20 台起動したときの EC2 の Management console のキャプチャです。

f:id:ngyuki:20140824140252p:plain

祭りの後

f:id:ngyuki:20140824140326p:plain

Zend Framework 2 で Smarty を使うためのモジュール

そういえばだいぶ前に Zend Framework 2 で Smarty を使うためのモジュールを作りました。

なお、ZfcTwig を大いに参考にしました。

最初に作ったのは1年ぐらい前だったと思います。その当時に GitHub とかで同じようなものを探してみたところ、いくつか見つかったのですが次のような点で手に馴染まない気がしたので作ることにしました。

  • phtml を完全に置き換えてしまう
    • 既存のモジュールで phtml を使っていると動かなくなる
  • コントローラーのアクションで SmartyModel などを返さなければならない
    • コントローラーでテンプレートエンジンを意識したくない
  • ビューヘルパーが呼べない
    • 呼びたい

なお、今改めて確認すると下記とか良い感じかも知れません。

インストール

Packagist に登録しているので composer でインストールできます。

$ composer require ngyuki/zf2-smarty:dev-master

インストールが終わったら vendor/ngyuki/zf2-smarty/config/smarty.global.phpconfig/autoload/ にコピーします。

$ cp vendor/ngyuki/zf2-smarty/config/smarty.global.php config/autoload/

そして config/autoload/smarty.global.php を編集します。

基本的には smarty の部分に Smarty のオプションを書けば OK です。ひな形のファイルは development な感じにしているので production ならそれっぽく変更が必要です。

<?php
return array(
    'smarty' => array(
        // here!
    ),
);

さらに config/application.config.phpZendSmarty を追記します。

<?php
return array(
    'modules' => array(
        'Application',
        'ZendSmarty', // this is it!
    ),

    // ...
);

使い方

既存の .phtml と同じ命名規則で、拡張子だけ .tpl に変更したファイル名で Smarty のテンプレートを作成します。

module/Application/view/application/index/index.tpl

<h1>I am smarty</h1>
<code>PHP {$smarty.const.PHP_VERSION}</code>

拡張子 .tpl でファイルが見つかれば Smartyレンダリングされます。見つからなければ .phtml がレンダリングされます。

ビューヘルパー

ビューヘルパーは次のように書けます。

{url home}
{url application [controller => index, action => index]}

これは .phtml での次のコードと同じです。

<?= $this->url("home") ?>
<?= $this->url("application", ["controller" => "index", "action" => "index"]) ?>

echo が不要な場合は次のように書けます。

{do headTitle "Index Page"}

これは .phtml での次のコードと同じです。

<?php $this->headTitle("Index Page") ?>

これらは Smartyコンパイラ関数プラグインで実現しているのですが、メソッドチェインには対応していません。

メソッドチェインしたい場合は $this を使います。

{$this->headTitle("ZF2 Smarty")->setSeparator(' - ') nofilter}

これは .phtml での次のコードと同じです。

<?= $this->headTitle("ZF2 Smarty")->setSeparator(' - ') ?>

テンプレート継承

ZfcTwig だとレイアウトとコンテンツの両方のテンプレートが Twig であれば ZF2 のレイアウト機能を使わずに Twig のテンプレート継承でレイアウトを実現することができます(zfctwig.disable_zf_model デフォルトで true)。

Smarty3 でもテンプレート継承はできますが、残念ながらわたしのモジュールは ZfcTwig のような機能は実装していません。

というのも、ZfcTwig を使っていたときに、すべてのビューで共通する値をアサインするために、

<?php
class Module
{
    public function onBootstrap(MvcEvent $ev)
    {
        /* @var $view \Zend\View\View */
        $view = $ev->getApplication()->getServiceManager()->get('View');
        $view->getEventManager()->attach(ViewEvent::EVENT_RENDERER_POST, function (ViewEvent $ev) {
            $ev->getModel()->val = 123;
        });
    }
}

と、したところ、ZfcTwig だと意図通りに動かなかったからです。

ZfcTwig でレイアウトとコンテンツのテンプレートが両方 Twig の場合、レイアウトのレンダリング時に独自にコンテンツのレンダリングを行うようになっており、コンテンツのレンダリングに関して EVENT_RENDERER_POST イベントが発生しないためです。

気になったのはこれだけですが、なんとなく他にもドハマりしそうなものが隠れている気がしたので封印することにしました。

なので、このモジュールでもそのような機能は設けていません(ZfcTwig のようにオプションで切替えられるならそれでもいいかな―と思いましたが、どうせ使わないし)。

さいごに

Zend Framework 2 のモジュール名やコンフィグのエントリ名ってベンダ名っぽいプレフィックスを付けるものみたいですね。

ZendSmarty という名前はよろしくなさそうなので、変えたほうが良いですね・・・・