2013-11-30

wheelを使ってPythonのC拡張モジュールを本番デプロイする

Pythonの話。wheelを使ってC拡張モジュールをデプロイする仕組みが上手く稼動したのでメモ。

依存パッケージの本番デプロイ

アプリケーションが依存しているPythonパッケージをどうやって本番サーバーにデプロイするか。大抵はrequirements.txtにpip freezeで吐いた内容を保存しているだろう。とすると、本番サーバーでpip install -r requirements.txtすれば良いんだが、githubが落ちてたりPyPIが落ちてたりすると、外部要因でデプロイスクリプトが途中でコケる、というダサい事態になる。それを避けるために事前にパッケージを固めて各サーバーに配布する仕組みが必要になる。C拡張モジュールを使いたいけど本番サーバーでCコンパイラが自由に使えない、という時も同様で、事前にコンパイル済みの物を配布する必要がある。

pip bundleを使う場合 (deprecated)

Pure Pythonなパッケージだけを使っている場合はこれで十分だろう。
$ pip bundle -r requirements.txt myproduct.bundle
で、myproduct.bundleを作成しておいて、デプロイ先で
$ pip install myproduct.bundle
とする。しかし、C拡張モジュールがあると、デプロイ先でもりもりコンパイルが始まってしまって非効率なのと、そもそもpip 1.5でpip bundle自体が削除予定なので今後は忘れても良い機能だ。

pip wheel でコンパイル済みC拡張モジュールをデプロイする

これが本命、wheelというフォーマットでパッケージを配布する方式。
私はCIサーバーでwheelを作って本番サーバーに転送して使っている。手順は、まず事前に各サーバーでwheelを導入しておく。
$ pip install --upgrade pip        # 1.4以上が必要
$ pip install --upgrade setuptools # 0.8以上が必要
$ pip install wheel
wheelの作成は次の通り。慣例的にwheelhouseというディレクトリ名が使われる様だ。
# requirements.txtがこんな内容だとする
$ cat requirements.txt
MySQL-python==1.2.4
python-memcached==1.53
boto==2.14.0
simplejson==3.3.1

# wheelの作成
$ pip wheel --wheel-dir=./wheelhouse -r requirments.txt
Downloading/unpacking MySQL-python==1.2.4 (from -r requirements_prod.txt (line 1))
  Downloading MySQL-python-1.2.4.zip (113kB): 113kB downloaded
  Running setup.py egg_info for package MySQL-python
    Downloading http://pypi.python.org/packages/source/d/distribute/distribute-0.6.28.tar.gz
    Extracting in /tmp/tmpKuwtk2
    Now working in /tmp/tmpKuwtk2/distribute-0.6.28
    Building a Distribute egg in /web/httpd_spc/chatparty_api/python/build/MySQL-python
    /web/httpd_spc/chatparty_api/python/build/MySQL-python/distribute-0.6.28-py2.6.egg
Downloading/unpacking python-memcached==1.53 (from -r requirements_prod.txt (line 2))
  Downloading python-memcached-1.53.tar.gz
  Running setup.py egg_info for package python-memcached
    warning: no files found matching '*.rst'
    warning: no files found matching '*.txt'
    warning: no files found matching 'MakeFile'
    warning: no previously-included files matching '*.pyc' found anywhere in distribution
    warning: no previously-included files matching '.gitignore' found anywhere in distribution
    warning: no previously-included files matching '.DS_Store' found anywhere in distribution
Downloading/unpacking boto==2.14.0 (from -r requirements_prod.txt (line 3))
  Downloading boto-2.14.0.tar.gz (1.1MB): 1.1MB downloaded
  Running setup.py egg_info for package boto
    warning: no files found matching 'boto/mturk/test/*.doctest'
    warning: no files found matching 'boto/mturk/test/.gitignore'
Downloading/unpacking simplejson==3.3.1 (from -r requirements_prod.txt (line 4))
  Downloading simplejson-3.3.1.tar.gz (67kB): 67kB downloaded
  Running setup.py egg_info for package simplejson
Building wheels for collected packages: MySQL-python, python-memcached, boto, simplejson
  Running setup.py bdist_wheel for MySQL-python
  Destination directory: /web/httpd_spc/chatparty_api/wheelhouse
  Running setup.py bdist_wheel for python-memcached
  Destination directory: /web/httpd_spc/chatparty_api/wheelhouse
  Running setup.py bdist_wheel for boto
  Destination directory: /web/httpd_spc/chatparty_api/wheelhouse
  Running setup.py bdist_wheel for simplejson
  Destination directory: /web/httpd_spc/chatparty_api/wheelhouse
Successfully built MySQL-python python-memcached boto simplejson
Cleaning up...
生成されたファイル名を見ればわかる様に、wheelはPythonバージョンとアーキテクチャ毎に作られる。
$ ls -l wheelhouse/
boto-2.14.0-py26-none-any.whl
MySQL_python-1.2.4-cp26-none-linux_x86_64.whl
python_memcached-1.53-py26-none-any.whl
simplejson-3.3.1-cp26-none-linux_x86_64.whl
wheelを使ったインストールはpip installでwheelの場所を指定するだけ。一瞬で終って気持ちがいい。依存関係は既に解決済みなので --no-deps オプションを使う。
$ pip install --no-deps wheelhouse/*
Unpacking ./wheelhouse/MySQL_python-1.2.4-cp26-none-linux_x86_64.whl
Unpacking ./wheelhouse/boto-2.14.0-py26-none-any.whl
Unpacking ./wheelhouse/python_memcached-1.53-py26-none-any.whl
Unpacking ./wheelhouse/simplejson-3.3.1-cp26-none-linux_x86_64.whl
Cleaning up...



このエントリーをはてなブックマークに追加

2013-11-24

DjangoでMySQLにunicode絵文字を登録できるようにする(utf8mb4対応)

Djangoを使っているプロジェクトでMySQLにunicode絵文字を投入したくなったので。

Unicode絵文字

iOSで使える絵文字キーボードに含まれる絵文字はUTF-8で符号化した時に4バイトになる。UTF-8で符号化した時に4バイトになるのは一部も漢字もそうだが具体的にはこのあたり。
MacOSXだとことえりで「ハート」を変換すると1F493のBEATING HEARTあたりが出せる。Pythonでコードポイントを表示してみると次の通り。
(MySQL以前にUSC2だとサロゲートペアになるのでPythonを --enable-unicode=ucs4 でコンパイルしてないとおかしな結果になるかも。)

MySQLのcharcter setのutf8は3バイトまでの文字しか扱えないため、MySQL5.5で4バイトの文字を扱えるようにしたutf8mb4というcharcter setが追加された。

データベースのdefault charsetをutf8mb4にする

python manage.py syncdb
によって生成されるCREATE文はDEFAULT CHARSET指定が無い、よってデータベース作成時に指定しておくのが良いだろう。python Djangoチュートリアルを例にすると
mysql> create database django_tutorial default charset utf8mb4;
Query OK, 1 row affected (0.00 sec)

settings.pyのDB接続オプションでutf8mb4を指定する

DATABASES = {
            'default': {
                'ENGINE': 'django.db.backends.mysql',
                'NAME': 'mydatabase',
                'USER': 'root',
                'PASSWORD': 'password',
                'HOST': 'localhost',
                'PORT': '3306',
                'TIME_ZONE': '+09:00',
                'OPTIONS': {
                    'charset': 'utf8mb4'
                }
            }
}
これでunicode絵文字のINSERT、SELECTが上手くいくようになる。
Djangoチュートリアルで試すとこの通り。


このエントリーをはてなブックマークに追加

2013-11-22

QiitaにPythonネタをいくつか投稿してみた

MacのmarkdownメモクライアントのKobitoを使ってると圧倒的使い易さ。Bloggerもこういうの欲しい。



このエントリーをはてなブックマークに追加

2013-10-05

botoを使ってAWS SNSのMobile Pushを利用する (APNs編)

boto自体使うのが初めてだったので、おかしい所があるかもしれないが動作はした。APNsのSSL証明書作成等は毎度の事なので省略。Application ARNはAWS Consoleから既に作成してあるとする。

デバイス指定でメッセージ送信する

# -*- coding: utf-8 -*-

import boto.sns

AWS_ACCESS_KEY = 'xxxx'
AWS_SECRET_ACCESS_KEY = 'yyyyyyyy'
APPLICATION_ARN = \
    'arn:aws:sns:ap-northeast-1:0000:app/APNS_SANDBOX/aaaa'

# SNSに接続
sns_connection = boto.sns.connect_to_region('ap-northeast-1',
        aws_access_key_id=AWS_ACCESS_KEY,
        aws_secret_access_key=AWS_SECRET_ACCESS_KEY)

# 送信先デバイスを指定してendpointを作成
res = sns_connection.create_platform_endpoint(
        platform_application_arn=APPLICATION_ARN,
        token='xxxxxxxx')
endpoint = res.get('CreatePlatformEndpointResponse')\
              .get('CreatePlatformEndpointResult').get('EndpointArn')

# endpointに送信
sns_connection.publish(target_arn=endpoint, message=u"Hello World")
endpointは一度作成したら、保存しておけば再度 create_platform_endpoint する必要は無い。

Topic経由でメッセージ送信する

Topicをsubscribeしているデバイスに一斉送信するパターン、Topicをsubscribeする際の第二引数はbotoのソースを読んでもわからなかったが、AWSコンソールのTopic管理画面から推測すると "application" を指定したら良い気がする。そして実際に動作する。
# -*- coding: utf-8 -*-

import boto.sns

AWS_ACCESS_KEY = 'xxxx'
AWS_SECRET_ACCESS_KEY = 'yyyyyyyy'
APPLICATION_ARN = \
    'arn:aws:sns:ap-northeast-1:0000:app/APNS_SANDBOX/aaaa'

# SNSに接続
sns_connection = boto.sns.connect_to_region('ap-northeast-1',
        aws_access_key_id=AWS_ACCESS_KEY,
        aws_secret_access_key=AWS_SECRET_ACCESS_KEY)

# 送信先デバイスを指定してEndpointを作成
res = sns_connection.create_platform_endpoint(
        platform_application_arn=APPLICATION_ARN,
        token='xxxxxxxx') 
endpoint = res.get('CreatePlatformEndpointResponse')\
              .get('CreatePlatformEndpointResult').get('EndpointArn')

# Topicを作成
res = sns_connection.create_topic('test_topic')
topic = res.get('CreateTopicResponse')\
           .get('CreateTopicResult').get('TopicArn')

# TopicをSubscribe
sns_connection.subscribe(topic, 'application', endpoint)

# Topicに送信
sns_connection.publish(topic=topic,
                       message=u"Hello World for Topic subscriber")

Topicにぶらさげるデバイスの数は1万が上限との事なので、アプリのユーザー全員に送信するといった場合にはTopicを分けるか、全てのEndpointに送信するかどちらかになる。あまり楽させてはくれない様だ。Endpointを削除した場合、Topicから勝手にunsubscribeされると思いきやそうでもなかった。

JSON形式でbadgeやalertを送る

    # Payloadの中身
    message = json.dumps({
        "aps": {
            "alert": {
                "body": "Hey Hey",
                "action-loc-key": None
                },
            "badge": 100
            }
        })

    # Payloadの長さチェック
    if len(message) > 255:
        raise "APNs payload over 255 bytes!!!!"

    # 送信先プラットフォームの指定、本番であればAPNS
    # Android向けのPushもここで同時に指定できる
    data = json.dumps({"APNS_SANDBOX": message})

    # message_structure='json'を指定してPublish
    try:
        sns_con.publish(
                target_arn=endpoint,
                message=data,
                message_structure='json')
    except BotoServerError, e:
        logger.warn(
            "BotoServerError status:%s %s %s",
            e.status, e.reason, e.error_message)

Payloadを一度作った後、文字列にして再度JSONにつっこむという面倒な事が必要。

APNsのFeedback Serviceはいつ呼ばれるか

SNSの内部でよろしくやってくれてるようだが、その結果をboto経由で参照する方法は見つからなかった。Feedback Serviceの結果から、どの程度アプリがデバイスから削除されたかモニタリングしたいといった場合にはSNSは使えないだろう。

このエントリーをはてなブックマークに追加

2013-08-11

RabbitMQ 3.1の導入とCluster構成を検証する

RabbitMQ 3.1の導入と冗長化の検証をしたのでメモ。
検証のための構成はフロントのAPサーバー、RabbitMQが動作するキューサーバー、ワーカーそれぞれ二台づつ。キューサーバーが片方死んでも全体が動作し続けられる事、両方がダウンしたとしてもデータは損失しない事が確認できれば良い。要するに単一障害点にならないようにRabbitMQを使いたい。


サーバーの準備

仮想マシン6台はVagrantを使えば一発で用意できる、メモリ16GB積んでてよかった。ホスト名を後でいじるとrabbitmqctlで停止・再起動がうまくいかなくなった。ホスト名周りはEC2で使う時に面倒な事になりそうだ。

各サーバーの /etc/hosts にrabbit1とrabbit2は追加しておく。

RabbitMQ 3.1 のインストール

APTリポジトリの追加が必要、公式ページに手順があるのでその通りに。

起動確認

vagrant@rabbit1:~$ sudo rabbitmq-server
vagrant@rabbit2:~$ sudo rabbitmq-server

管理画面の有効化

vagrant@rabbit1:~$ sudo rabbitmq-plugins enable rabbitmq_management 
vagrant@rabbit1:~$ sudo rabbitmqctl stop
vagrant@rabbit1:~$ sudo rabbitmq-server

vagrant@rabbit2:~$ sudo rabbitmq-plugins enable rabbitmq_management 
vagrant@rabbit2:~$ sudo rabbitmqctl stop
vagrant@rabbit2:~$ sudo rabbitmq-server

それぞれのサーバーのポート15672で管理画面が起動する(2.x系の場合は55672)。Basic認証がかかっているが有効化直後は guest/guest で参照できる。

疎通テスト

クラスタ構成にする前にそれぞれのrabbitmqノードと疎通できるかチェックする。Hello Worldが届けばOK。
# sender.py
import pika

params = pika.ConnectionParameters('rabbit1')
connection = pika.BlockingConnection(params)
channel = connection.channel()
# Make Queue
channel.queue_declare(queue='mq_test')
# Publish
channel.basic_publish(
                exchange='',
                routing_key='mq_test',
                body='Hello World',
                )
print " [x] sent Hello World"
# receiver.py
import pika

params = pika.ConnectionParameters('rabbit1')
connection = pika.BlockingConnection(params)

channel = connection.channel()
channel.queue_declare(queue='mq_test')

def callback(ch, method, properties, body):
    print " [x] Received %r" % (body,)

channel.basic_consume(callback, queue='mq_test', no_ack=True)
channel.start_consuming()

メッセージの永続化

先のPublisherのコードだと、receiverがメッセージを受け取る前にrabbitmqを停止させるとキューの内容が消失した。それではまずいのでキューとメッセージにオプションを追加する。
# durable=True オプション付きでキューを宣言する
channel.queue_declare(queue='cluster_test', durable=True)
# 永続化オプション付きでメッセージをpublishする
channel.basic_publish(
                exchange='',
                routing_key='cluster_test',
                body=msg,
                properties=pika.BasicProperties(
                        # To Persistent
                        delivery_mode=2,
                )
        )

管理画面上でキューのパラメータに[D]と表示され、rabbitmqの起動再起動を繰りかえしても内容が保持されるようになった。

ACKの送出

先のComsumerの実装だと、ワーカーが正常に処理を完了したかどうかに関係無くキューのメッセージが消える。正常応答がComsumerから返ってきた場合のみキューのメッセージが消えるようにするには次の通り。
def callback(ch, method, properties, body):
    print " [x] Received %r" % (body,)
    # ackを返す (返さないとキューから消えない)
    ch.basic_ack(delivery_tag = method.delivery_tag)

# no_ack=Trueを削除
channel.basic_consume(callback, queue='mq_test')

Cluster化

次はrabbit1とrabbit2をクラスタ構成にする。まずは各ノードのerlang cookie (/var/lib/rabbitmq/.erlang.cookie) を同じにしてそれぞれ再起動。
rabbit2をrabbi1に参加させるには次のコマンド。
vagrant@rabbit2:~$ sudo rabbitmqctl stop_app
vagrant@rabbit2:~$ sudo rabbitmqctl join_cluster rabbit@rabbit1
vagrant@rabbit2:~$ sudo rabbitmqctl start_app
クラスタの状態を確認、二つともdiskノードになっている。
vagrant@rabbit2:~$ sudo rabbitmqctl cluster_status
ls: cannot access /etc/rabbitmq/rabbitmq.conf.d: No such file or directory
Cluster status of node rabbit@rabbit2 ...
[{nodes,[{disc,[rabbit@rabbit1,rabbit@rabbit2]}]},
 {running_nodes,[rabbit@rabbit1,rabbit@rabbit2]},
 {partitions,[]}]
…done.

いずれかのrabbitmqノードに送信する様にPublisherを改造する

HAProxyを各APサーバーに載せるのが硬そうだが、この場では生きているrabbitmqノードに接続できるまでランダムに選ぶというナイーブな get_connection を実装した。
# sender.py (ap1)
import random
import pika

def get_connection():
    mq_clusters = ['rabbit1', 'rabbit2']
    random.shuffle(mq_clusters)
    for mq in mq_clusters:
        try:
            params = pika.ConnectionParameters(mq)
            connection = pika.BlockingConnection(params)
            return connection
        except Exception, e:
            print("Try next node")
    raise Exception("Cannot establish connection")

def send(con, msg):
    channel = con.channel()
    # 同じなので中略

for i in xrange(1, 1000):
    con = get_connection()
    msg = 'From AP1 to %s: %i' % (con.params.host, i)
    send(con, msg)
    con.close()

実行結果

# AP1
[x] sent From AP1 to rabbit1: 900 
[x] sent From AP1 to rabbit1: 901 
[x] sent From AP1 to rabbit1: 902 
[x] sent From AP1 to rabbit1: 903 
[x] sent From AP1 to rabbit1: 904 
[x] sent From AP1 to rabbit1: 905 
[x] sent From AP1 to rabbit2: 906 
[x] sent From AP1 to rabbit1: 907 
[x] sent From AP1 to rabbit1: 908 
[x] sent From AP1 to rabbit2: 909 

# AP2
[x] sent From AP2 to rabbit2: 815
[x] sent From AP2 to rabbit1: 816
[x] sent From AP2 to rabbit2: 817
[x] sent From AP2 to rabbit2: 818
[x] sent From AP2 to rabbit2: 819
[x] sent From AP2 to rabbit2: 820
[x] sent From AP2 to rabbit1: 821
[x] sent From AP2 to rabbit1: 822
[x] sent From AP2 to rabbit2: 823
[x] sent From AP2 to rabbit1: 824
 
 # Worker1
[x] Received 'From AP1 to rabbit1: 903'
[x] Received 'From AP1 to rabbit1: 904'
[x] Received 'From AP1 to rabbit1: 905'
[x] Received 'From AP2 to rabbit2: 820'
[x] Received 'From AP1 to rabbit1: 907'
[x] Received 'From AP1 to rabbit1: 908'
[x] Received 'From AP2 to rabbit2: 823'

# Worker2
[x] Received 'From AP1 to rabbit1: 900'
[x] Received 'From AP1 to rabbit1: 901'
[x] Received 'From AP1 to rabbit1: 902'
[x] Received 'From AP2 to rabbit2: 817'
[x] Received 'From AP2 to rabbit2: 818'
[x] Received 'From AP2 to rabbit2: 819'
[x] Received 'From AP1 to rabbit2: 906'
[x] Received 'From AP2 to rabbit1: 821'
[x] Received 'From AP2 to rabbit1: 822'
[x] Received 'From AP1 to rabbit2: 909'
[x] Received 'From AP2 to rabbit1: 824'

これで上手くいったかと思いきや、rabbit1を停止させるとrabbit2にメッセージを投げた時にエラーが返ってくるようになる。
pika.exceptions.ChannelClosed: (404, "NOT_FOUND - home node 'rabbit@rabbit1' of durable queue 'cluster_test' in vhost '/' is down or inaccessible")
キューの内容がrabbit1にしか保持されていないからだ。rabbit2は生きているがrabbit1のキューにアクセスできなければ何もできない。

キューをミラーリングする設定

特定のキューの内容をクラスタの全てのノードにも持たせるには rabbitmqctl で ha-mode を指定する。
vagrant@rabbit1:~$ sudo rabbitmqctl set_policy all 'cluster_test' '{"ha-mode": "all"}'
vagrant@rabbit2:~$ sudo rabbitmqctl set_policy all 'cluster_test' '{"ha-mode": "all"}' 
キューの指定には正規表現が使えるので、全てのキューをミラーリングするには
sudo rabbitmqctl set_policy all '^.*' '{"ha-mode": "all"}'
としても良い。これで、rabbit1を落してもrabbit2だけで動作するようになった。管理画面でもミラーリングができているか確認ができる。


停止したノードはそのまま再起動でOK、元のクラスタ構成に戻る。

参考



このエントリーをはてなブックマークに追加