Hatena::ブログ(Diary)

lambda {|diary| lambda { diary.succ! } }.call(hatena) このページをアンテナに追加 RSSフィード

 

2013-05-06

[][] ddbcli - 対話型のDynamoDBクライアント

https://bitbucket.org/winebarrel/ddbcli

DynamoDBのmysqlコマンド的なクライアント作りました。ほんとはAWS Tools Hackathonでこのネタをやろうとしていたのですが、DynamoDBのAPIさわり始めたらあれよあれよと実装が進んでしまいまして。本番どうするかな…


§導入

最初にgemインストールしてから、環境変数を設定します。

shell> gem install ddbcli
shell> export AWS_ACCESS_KEY_ID='...'
shell> export AWS_SECRET_ACCESS_KEY='...'
shell> export DDB_REGION=ap-northeast-1
shell> ddbcli # プロンプト表示

ddbcliコマンドを実行すると、以下のようなプロンプトが表示されます。

ap-northeast-1> show tables;
[
  "employees"
]
// 1 row in set (0.33 sec)

ap-northeast-1>


§テーブルの作成

JSONをそのまま書くのは手間なので、各アクションはSQLっぽい文法で実行できるようにしています。

テーブルの作成は以下の通り。

ap-northeast-1> create table foo (
             -> hoge string hash,
             -> fuga number range,
             -> index my_idx (piyo string) all)
             -> read = 1, write = 1;

create table foo (
  hoge string hash,
  fuga number range,
  index my_idx (piyo string) all
) read = 1, write = 1;

ap-northeast-1> desc foo;
{
  "AttributeDefinitions": [
    {
      "AttributeName": "fuga",
      "AttributeType": "N"
    },
    {
      "AttributeName": "hoge",
      "AttributeType": "S"
    },
    {
      "AttributeName": "piyo",
      "AttributeType": "S"
    }
  ],
  "CreationDateTime": 1367815317.165,
  "ItemCount": 0,
  "KeySchema": [
    {
      "AttributeName": "hoge",
      "KeyType": "HASH"
    },
    {
      "AttributeName": "fuga",
      "KeyType": "RANGE"
    }
  ],
  "LocalSecondaryIndexes": [
    {
      "IndexName": "my_idx",
      "IndexSizeBytes": 0,
      "ItemCount": 0,
      "KeySchema": [
        {
          "AttributeName": "hoge",
          "KeyType": "HASH"
        },
        {
          "AttributeName": "piyo",
          "KeyType": "RANGE"
        }
      ],
      "Projection": {
        "ProjectionType": "ALL"
      }
    }
  ],
  "ProvisionedThroughput": {
    "NumberOfDecreasesToday": 0,
    "ReadCapacityUnits": 1,
    "WriteCapacityUnits": 1
  },
  "TableName": "foo",
  "TableSizeBytes": 0,
  "TableStatus": "CREATING"
}

ap-northeast-1>

※TableStatusがACTIVEになるまで若干時間がかかります

「SHOW CREATE TABLE」で、CREATE文の表示も出来ます。

ap-northeast-1> show create table foo;
CREATE TABLE `foo` (
  `hoge` STRING HASH,
  `fuga` NUMBER RANGE,
  INDEX `my_idx` (`piyo` STRING) ALL
) read=1, write=1

ap-northeast-1>


§データを入れる

INSERTでデータを入れます。

ap-northeast-1> insert into foo (hoge, fuga, piyo) values ('AAA', 100, 'BBB');
// 1 row changed (0.39 sec)
ap-northeast-1> select all * from foo;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB"}
]
// 1 row in set (0.08 sec)

ap-northeast-1>

BULK INSERTも使えます。

ap-northeast-1> insert into foo (hoge, fuga, piyo) values ('CCC', 101, 'DDD'), ('EEE', 201, 'FFF') ;
// 2 rows changed (0.44 sec)
ap-northeast-1>


§データを取り出す

SELECT(Query)/SELECT ALL(Scan)でデータを取り出します。

Queryアクションは高速ですがキー属性を指定する必要があり、ちょっとデータを見るだけの時には不便です。

Scanアクションは全件走査になりますが、Queryより使えるオペレーターが多く、条件なし(フィルタなし)でもデータを取得できます。

http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html

SELECT

ap-northeast-1> select * from foo where hoge = 'AAA' and fuga >= 100;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB"}
]
// 1 row in set (0.05 sec)

ap-northeast-1>

ローカルセカンダリインデックスを使う場合はUSE INDEXでインデックスを指定します。

ap-northeast-1> select * from foo use index (my_idx) where hoge = 'AAA' and piyo = 'BBB';
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB"}
]
// 1 row in set (0.37 sec)

ap-northeast-1>

デバッグモードにすると、実際に投げているHashオブジェクトが表示されます。

ap-northeast-1> .d t
ap-northeast-1> select * from foo use index (my_idx) where hoge = 'AAA' and piyo = 'BBB';
---request begin---
Action: Query
{"TableName"=>"foo",
 "IndexName"=>"my_idx",
 "KeyConditions"=>
  {"hoge"=>{"ComparisonOperator"=>"EQ", "AttributeValueList"=>[{"S"=>"AAA"}]},
   "piyo"=>{"ComparisonOperator"=>"EQ", "AttributeValueList"=>[{"S"=>"BBB"}]}}}

---request end---
---response begin---
{"Count"=>1,
 "Items"=>[{"hoge"=>{"S"=>"AAA"}, "piyo"=>{"S"=>"BBB"}, "fuga"=>{"N"=>"100"}}]}

---response end---
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB"}
]
// 1 row in set (0.05 sec)

ap-northeast-1>

SELECT ALL

Scanの場合、特に条件を指定しなくてもデータを取得できます。

ap-northeast-1> select all * from foo;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB"},
  {"fuga":201,"hoge":"EEE","piyo":"FFF"},
  {"fuga":101,"hoge":"CCC","piyo":"DDD"}
]
// 3 rows in set (0.68 sec)

ap-northeast-1>

また、インデックスを指定しなくてもフィルタリングは可能です。

ap-northeast-1> select all * from foo where piyo = 'FFF';
[
  {"fuga":201,"hoge":"EEE","piyo":"FFF"}
]
// 1 row in set (0.35 sec)

ap-northeast-1>

その他

MySQLと同じように\Gで表示を変更できます。

ap-northeast-1> select all * from foo where piyo = 'FFF' \G
[
  {
    "fuga": 201,
    "hoge": "EEE",
    "piyo": "FFF"
  }
]
// 1 row in set (0.13 sec)

ap-northeast-1>


§データを更新する

UPDATE/UPDATE ALLでデータを更新します。

UPDATEは通常のUpdateItemアクションです。キーを指定しての更新しか出来ません。

UPDATE ALLは裏でScanを行っているので、Scanでつかえるオペレータでまとめて更新できます(遅いですが)。

UPDATE

ap-northeast-1> update foo set xxx = 'XXX' where hoge = 'AAA' and fuga = 100;
// 1 row changed (0.07 sec)
ap-northeast-1> select all * from foo;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB","xxx":"XXX"},
  {"fuga":201,"hoge":"EEE","piyo":"FFF"},
  {"fuga":101,"hoge":"CCC","piyo":"DDD"}
]
// 3 rows in set (0.06 sec)

ap-northeast-1>

RANGEキーがある場合、HASHキーだけでは更新できないようです。SELECTはHASHキーだけでいいのに何でだろう、、、

UPDATE ALL

ap-northeast-1> update all foo set zzz = 'ZZZ';
// 3 rows changed (0.49 sec)
ap-northeast-1> select all * from foo;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB","xxx":"XXX","zzz":"ZZZ"},
  {"fuga":201,"hoge":"EEE","piyo":"FFF","zzz":"ZZZ"},
  {"fuga":101,"hoge":"CCC","piyo":"DDD","zzz":"ZZZ"}
]
// 3 rows in set (0.07 sec)

ap-northeast-1>

属性の削除

NULLをセットすると属性を削除できます。

ap-northeast-1> update all foo set zzz = null;
// 3 rows changed (0.57 sec)
ap-northeast-1> select all * from foo;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB","xxx":"XXX"},
  {"fuga":201,"hoge":"EEE","piyo":"FFF"},
  {"fuga":101,"hoge":"CCC","piyo":"DDD"}
]
// 3 rows in set (0.07 sec)

ap-northeast-1>

ADD

「UPDATE テーブル名 SET」の代わりに「UPDATE テーブル名 ADD」を使うと、Itemへの処理にADDを使います。

http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html#DDB-UpdateItem-request-AttributeUpdates

ap-northeast-1> update all foo set zzz = 100;
// 3 rows changed (0.30 sec)
ap-northeast-1> select all * from foo;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB","xxx":"XXX","zzz":100},
  {"fuga":201,"hoge":"EEE","piyo":"FFF","zzz":100},
  {"fuga":101,"hoge":"CCC","piyo":"DDD","zzz":100}
]
// 3 rows in set (0.06 sec)

ap-northeast-1> update all foo add zzz = 1000; /* set -> add */
// 3 rows changed (2.52 sec)
ap-northeast-1> select all * from foo;
[
  {"fuga":100,"hoge":"AAA","piyo":"BBB","xxx":"XXX","zzz":1100},
  {"fuga":201,"hoge":"EEE","piyo":"FFF","zzz":1100},
  {"fuga":101,"hoge":"CCC","piyo":"DDD","zzz":1100}
]
// 3 rows in set (0.05 sec)

ap-northeast-1>

数値に数値をADDすると加算、配列配列ADDすると配列の結合等、パターンがあるようです。


§データを削除する

UPDATE/UPDATE ALLでデータを削除します。違いはUPDATEと同じです。

ap-northeast-1> delete from foo where hoge = 'AAA' and fuga = 100;
// 1 row changed (0.27 sec)
ap-northeast-1> select all * from foo;
[
  {"fuga":201,"hoge":"EEE","piyo":"FFF","zzz":1100},
  {"fuga":101,"hoge":"CCC","piyo":"DDD","zzz":1100}
]
// 2 rows in set (0.07 sec)

ap-northeast-1> delete all from foo;
// 2 rows changed (0.18 sec)
ap-northeast-1> select all * from foo;
[
]
// 0 row in set (0.05 sec)

ap-northeast-1>

データが大量にある場合、反映までには時間がかかるようです。


§その他

NEXT

データが一度に表示されない場合、NEXTで次のデータを表示できます。

ap-northeast-1> select all * from employees limit 3;
[
  {"birth_date":"1954-12-16","emp_no":35176,"first_name":"Jiafu","gender":"M","hire_date":"1998-03-05","last_name":"Wilharm"},
  {"birth_date":"1960-04-16","emp_no":15886,"first_name":"Kish","gender":"M","hire_date":"1986-12-09","last_name":"Zuberek"},
  {"birth_date":"1964-05-05","emp_no":13335,"first_name":"Val","gender":"F","hire_date":"1994-05-25","last_name":"Akaboshi"}
]
// 3 rows in set (0.04 sec)
// has more

ap-northeast-1> next;
[
  {"birth_date":"1955-06-20","emp_no":40627,"first_name":"Rance","gender":"M","hire_date":"1992-05-28","last_name":"Hemaspaandra"},
  {"birth_date":"1953-10-05","emp_no":15337,"first_name":"Masaru","gender":"M","hire_date":"1988-08-07","last_name":"Radivojevic"},
  {"birth_date":"1961-02-23","emp_no":17502,"first_name":"Gor","gender":"M","hire_date":"1990-01-03","last_name":"Moehrke"}
]
// 3 rows in set (0.05 sec)
// has more

ap-northeast-1>

Rubyとの連携

末尾に「|」をつけると、Rubyコードにデータを渡せます。

ap-northeast-1>  select all * from employees limit 3 | size;
3

ap-northeast-1>  select all * from employees limit 3 | emp_no.avg;
38572

ap-northeast-1>  select all * from employees limit 3 | self.class;
"Array"

ap-northeast-1>

DynamoDBから取得したオブジェクト(たいていの場合はHashの配列)のコンテキストでコードが評価されます。

Arrayにgroup_by/avg/sumなどのメソッドを追加しています。

ap-northeast-1> select all * from employees where first_name begins_with 'Al' | group_by(:gender) {|i| i.length };
{
  "F": 76,
  "M": 107
}

ap-northeast-1>

help

.h でヘルプが出ます。

ap-northeast-1> .h
##### Query #####

SHOW TABLES
  displays a table list

SHOW REGIONS
  displays a region list

SHOW CREATE TABLE table_name
  displays a CREATE TABLE statement

CREATE TABLES table_name (
     key_name {STRING|NUMBER|BINARY} HASH
  [, key_name {STRING|NUMBER|BINARY} RANGE]
  [, INDEX index1_name (attr1 {STRING|NUMBER|BINARY}) {ALL|KEYS_ONLY|INCLUDE (attr, ...)}
   , INDEX index2_name (attr2 {STRING|NUMBER|BINARY}) {ALL|KEYS_ONLY|INCLUDE (attr, ...)}
   , ...]
) READ = num, WRITE = num
  creates a table

DROP TABLE table_name
  deletes a table

ALTER TABLE table_name READ = num, WRITE = num
  updates the provisioned throughput

GET {*|attrs} FROM table_name WHERE key1 = '...' AND ...
  gets items

INSERT INTO table_name (attr1, attr2, ...) VALUES ('val1', 'val2', ...), ('val3', 'val4', ...), ...
  creates items

UPDATE table_name {SET|ADD} attr1 = 'val1', ... WHERE key1 = '...' AND ...
UPDATE ALL table_name {SET|ADD} attr1 = 'val1', ... [WHERE attr1 = '...' AND ...] [LIMIT limit]
  updates items

DELETE FROM table_name WHERE key1 = '...' AND ..
DELETE ALL FROM table_name WHERE [WHERE attr1 = '...' AND ...] [ORDER {ASC|DESC}] [LIMIT limit]
  deletes items

SELECT {*|attrs|COUNT(*)} FROM table_name [USE INDEX (index_name)] [WHERE key1 = '...' AND ...] [ORDER {ASC|DESC}] [LIMIT limit]
SELECT ALL {*|attrs|COUNT(*)}  FROM table_name [WHERE attr1 = '...' AND ...] [LIMIT limit]
  queries using the Query/Scan action
  see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html

DESC[RIBE] table_name
  displays information about the table

USE region_or_endpoint
  changes an endpoint

NEXT
  displays a continuation of a result
  (NEXT statement is published after SELECT statement)


##### Type #####

String
  'London Bridge is...',  "is broken down..." ...

Number
  10, 100, 0.3 ...

Binary
  x'123456789abcd...', x"123456789abcd..." ...

Identifier
  `ABCD...` or Non-keywords


##### Operator #####

Query (SELECT)
  = | <= | < | >= | > | BEGINS_WITH | BETWEEN
  see http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html#DDB-Query-request-KeyConditions

Scan (SELECT ALL)
  = | <> | != | <= | < | >= | > | NOT NULL | NULL | CONTAINS | NOT CONTAINS | BEGINS_WITH | IN | BETWEEN
  see http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html#DDB-Scan-request-ScanFilter


##### Pass to Ruby/Shell #####

Ryby
  query | ruby_script

  ex) SELECT ALL * FROM employees WHERE gender = 'M' | birth_date.map {|i| Time.parse(i) };
      [
        "1957-09-16 00:00:00 +0900",
        "1954-12-16 00:00:00 +0900",
        "1964-05-23 00:00:00 +0900",
        ...

Shell
  query ! shell_command

  ex) SELECT ALL * FROM employees LIMIT 10 ! sort;
      {"birth_date"=>"1957-09-16", "emp_no"=>452020,...
      {"birth_date"=>"1963-07-14", "emp_no"=>16998, ...
      {"birth_date"=>"1964-04-30", "emp_no"=>225407,...
      ...


##### Command #####

.help                           displays this message
.quit | .exit                   exits sdbcli
.consistent      (true|false)?  displays ConsistentRead parameter or changes it
.debug           (true|false)?  displays a debug status or changes it
.retry           NUM?           displays number of times of a retry or changes it
.retry_interval  SECOND?        displays a retry interval second or changes it
.timeout         SECOND?        displays a timeout second or changes it
.version                        displays a version

ap-northeast-1>

2013-02-03

[][] SimpleDBで手軽に集計処理を行う

あまりがっつりとSimpleDBを使い込んでいる訳ではないですが、ちょっとしたデータをつっこんでおくにはなかなか便利です。特にログを残しておきたいときは特別なミドルウェアを用意する必要がないので、サーバミドルウェアの検証をやるときにはよく使っています。

クライアントはいくつかあるのですが、コマンドラインで使えるクライアントamazon-simpledb-cliぐらいしか見つからなかったので、一年くらい前に自作しました

このクライアントの売りはmysqlクライアント感覚でデータを引っ張ってこれるところで、普通にSELECTはたたけるし

ap-northeast-1> select * from employees limit 3;
---
- ["100000", {first_name: Hiroyasu, hire_date: "1991-07-02", birth_date: "1956-01-11", last_name: Emden}]
- ["100001", {first_name: Jasminko, hire_date: "1994-12-25", birth_date: "1953-02-07", last_name: Antonakopoulos}]
- ["100002", {first_name: Claudi, hire_date: "1988-02-20", birth_date: "1957-03-04", last_name: Kolinko}]
# 3 rows in set

複数行のUPDATEも(一応)出来るし

ap-northeast-1> update employees set age = '35';
# 100 rows changed

と、結構便利に作っていたのですが、集計処理が出来ないのが難点でした。


一応、SQLのパーサを書いたのですが、SELECT文のパースをSimpleDBに丸投げだったりとなかなかヤクザなパーサです。集計系の構文(MAX、SUM、GROUP BY)をマジでパースしようと思うとかなり骨なので、集計処理の実装については棚上げ状態でした。

もちろん標準出力に出してRubyでこねくり回せばどうとでも出来るのですがめんどくさい!

どうしたものかと悶々としていたところ『だいたいRubyメソッド使えばいくらでも集計できるじゃん?構文のきれいさにこだわらなければどうとでもなるんじゃね?』と思い至ったのが以下の構文です。


ap-northeast-1> select * from employees limit 3 | map {|i| i.hire_date };
---
- "1991-07-02"
- "1994-12-25"
- "1988-02-20"
# 3 rows in set

『|』以降の文字列Rubyに丸投げというひどい代物になりました。


とはいえRubyをほぼそのまま使えるのはなかなか強力です。

first_nameだけを抽出することも出来ますし

ap-northeast-1> select * from employees limit 3 | first_name;
---
- Hiroyasu
- Jasminko
- Claudi
# 3 rows in set

first_nameが『C』で始まる人は何月生まれが多いかとかも集計できますし

ap-northeast-1> select * from employees | select {|i| i.first_name =~ /^C/ }.map {|i| Time.parse(i.birth_date).mon }.inject({}) {|r, i| r[i] ||= 0 \; r[i] += 1\; r }.sort_by {|k,v| k } ;
---
- [1, 1]
- [3, 1]
- [5, 1]
- [8, 2]
- [10, 1]
- [12, 3]
# 6 rows in set

itemNameを数値に変換して平均を出すとか訳のわからないことも出来ます。

ap-northeast-1> select * from employees | itemname.to_f.avg;
--- 91941.4

というわけで見た目はクソですが意外と便利ですので、興味のある方はどうぞご利用ください。

2013-01-28

[] スポットインスタンス強制終了前に割り込み処理を入れる

EC2のスポットインスタンスは安くて大変お買い得ですが、価格が上がると強制ターミネートされるのがにんともかんとも。きちんとサービスアウトできる余裕が必要なので以下のようなスクリプトを書いて終了時にsleepするようにしてみたのですが、二通目のメールは届かず…

#!/bin/bash
# chkconfig: - 99 00

start()
{
  touch /var/lock/subsys/notify-stopped
}

stop()
{
  date | mail foo@example.com
  sleep 15
  date | mail foo@example.com
}

restart()
{
  stop
  start
}

case "$1" in
start)
  start
  ;;
stop)
  stop
  ;;
restart)
  restart
  ;;
*)
  echo "Usage: $0 {start|stop|restart}"
  ;;
esac

exit 0

いったんターミネートが始まると、さすがに速攻で殺されるようでした。


価格上昇とターミネート開始までのタイムラグ

いろいろと調べてみたところ、現在の価格がMaxPriceを超えてから実際のターミネートが開始されるまで一分程度のタイムラグがあるようでした。一分も余裕があれば自分でサービスアウトするのに十分な時間です。そこで数秒おきに価格をポーリングし現在の価格がMaxPriceを超えた時に特定の処理を行うスクリプト群を書いてみました。

autognosis

autognosis is a tool which processes when EC2 Spot Instance is terminated compulsorily.

インストール

※現在はサーバクライアント型になりました

スポットインスタンス激戦区のus-east-1にテスト用のインスタンスをセットアップ。スポットインスタンスの起動時には「{"maxPrice":0.3}」のようなJSON形式のuser-dataを仕込みます。

sudo yum install mailx -y
wget https://bitbucket.org/winebarrel/cronexec.spec/downloads/cronexec-0.90-1.amzn1.x86_64.rpm
wget https://bitbucket.org/winebarrel/jq.spec/downloads/jq-1.2-1.amzn1.x86_64.rpm
wget https://bitbucket.org/winebarrel/describe-spot-price-history/downloads/describe-spot-price-history-0.1.2-1.noarch.rpm
wget https://bitbucket.org/winebarrel/autognosis/downloads/autognosis-0.1.4-1.amzn1.noarch.rpm
sudo rpm -ihv *.rpm

sudo vi /etc/sysconfig/autognosis
sudo initctl start autognosis

/etc/sysconfig/autognosisは以下の通り。

# AWS Credential
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...

# expected json: {"maxPrice":0.3}
MAX_PRICE=`curl -s 169.254.169.254/latest/user-data | jq '.maxPrice'`

#CHECK_INTERVAL=5

ON_TERMINATE='echo detected termination: `curl 169.254.169.254/latest/meta-data/instance-id` `date` | mail -s "detected termination: $(curl 169.254.169.254/latest/meta-data/instance-id) $(date)" foo@example.com'

#EXECUTE_ONCE=1
#EXEC_FLAG_FILE=/var/tmp/autognosis.executed

また、価格上昇と実際にインスタンスが停止するまでのタイムラグを計るため、終了処理開始時にメールを送るようにします。

sudo vi /etc/init.d/notify-stopped
sudo chmod 755 /etc/init.d/notify-stopped
sudo chkconfig --add notify-stopped
sudo chkconfig notify-stopped on
sudo touch /var/lock/subsys/notify-stopped
#sudo /sbin/shutdown -r now

終了スクリプト(/etc/init.d/notify-stopped)は前述のものと同様。

#!/bin/bash
# chkconfig: - 99 00

start()
{
  touch /var/lock/subsys/notify-stopped
}

stop()
{
  INSTANCE_ID=`curl 169.254.169.254/latest/meta-data/instance-id`
  DATE=`date`
  echo stopped: $INSTANCE_ID $DATE | mail -s "stopped: $INSTANCE_ID $DATE" foo@example.com
}

restart()
{
  stop
  start
}

case "$1" in
start)
  start
  ;;
stop)
  stop
  ;;
restart)
  restart
  ;;
*)
  echo "Usage: $0 {start|stop|restart}"
  ;;
esac

exit 0

結果

スポットインスタンスを立ち上げて、強制的にターミネートされるまで待った結果が以下の通り。

f:id:winebarrel:20130128031602p:image

  • stopped: i-42815632 Sun Jan 27 17:41:05 UTC 2013
  • stopped: i-44815634 Sun Jan 27 17:41:05 UTC 2013
  • detected termination: i-44815634 Sun Jan 27 17:40:09 UTC 2013
  • detected termination: i-42815632 Sun Jan 27 17:40:06 UTC 2013
  • stopped: i-645b8f14 Sun Jan 27 16:59:21 UTC 2013
  • detected termination: i-645b8f14 Sun Jan 27 16:58:22 UTC 2013

おおむね一分程度のタイムラグです。


補足

us-east-1以外のリージョンではテストしていないので、もしかしたら差異があるかもしれないです。というかus-east-1以外は平和すぎてテストが長引くんですよね…。

また、公式の情報ではないので今後も一分のタイムラグが続くかは不明です。


安全な終了処理が出来ないのは結構痛いので、Amazon側でなんらかの機能を出してほしいところではあります。

2013-01-16

[][] Rubyec2-describe-spot-price-history

#!/usr/bin/env ruby
require 'cgi'
require 'base64'
require 'net/https'
require 'openssl'
require 'optparse'
require 'rexml/parsers/pullparser'
require 'time'
require 'yaml'

Net::HTTP.version_1_2

class EC2Client
  API_VERSION = '2012-12-01'
  SIGNATURE_VERSION = 2
  SIGNATURE_ALGORITHM = :SHA256

  def initialize(accessKeyId, secretAccessKey, endpoint = nil)
    @accessKeyId = accessKeyId
    @secretAccessKey = secretAccessKey
    @endpoint = endpoint

    if /\A[^.]+\Z/ =~ @endpoint
      @endpoint = "ec2.#{@endpoint}.amazonaws.com"
    end
  end

  def spot_price_history(params = {})
    source = query('DescribeSpotPriceHistory', params)
    parser = REXML::Parsers::PullParser.new(source)

    instance_type = nil
    product_description = nil
    spot_price = nil
    timestamp = nil
    availability_zone = nil

    while parser.has_next?
      event = parser.pull
      next if event.event_type != :start_element

      case event[0]
      when 'instanceType'
        instance_type = parser.pull[0]
      when 'productDescription'
        product_description = parser.pull[0]
      when 'spotPrice'
        spot_price = parser.pull[0].to_f
      when 'timestamp'
        timestamp = Time.parse(parser.pull[0]).localtime.iso8601
      when 'availabilityZone'
        availability_zone = parser.pull[0]
        yield [instance_type, product_description, spot_price, timestamp, availability_zone]
      end
    end
  end

  private

  def query(action, params = {})
    params = {
      :Action           => action,
      :Version          => API_VERSION,
      :Timestamp        => Time.now.getutc.strftime('%Y-%m-%dT%H:%M:%SZ'),
      :SignatureVersion => SIGNATURE_VERSION,
      :SignatureMethod  => "Hmac#{SIGNATURE_ALGORITHM}",
      :AWSAccessKeyId   => @accessKeyId,
    }.merge(params)

    signature = aws_sign(params)
    params[:Signature] = signature

    https = Net::HTTP.new(@endpoint, 443)
    https.use_ssl = true
    https.verify_mode = OpenSSL::SSL::VERIFY_NONE

    https.start do |w|
      req = Net::HTTP::Post.new('/',
        'Host' => @endpoint,
        'Content-Type' => 'application/x-www-form-urlencoded'
      )

      req.set_form_data(params)
      res = w.request(req)

      res.body
    end
  end

  def aws_sign(params)
    params = params.sort_by {|a, b| a.to_s }.map {|k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join('&')
    string_to_sign = "POST\n#{@endpoint}\n/\n#{params}"
    digest = OpenSSL::HMAC.digest(OpenSSL::Digest.const_get(SIGNATURE_ALGORITHM).new, @secretAccessKey, string_to_sign)
    Base64.encode64(digest).gsub("\n", '')
  end
end # EC2Client

# main
access_key = nil
secret_key = nil
endpoint = nil
params = {}

ARGV.options do |opt|
  begin
    opt.on('-k', '--access-key ACCESS_KEY') {|v| access_key = v }
    opt.on('-s', '--secret-key SECRET_KEY') {|v| secret_key = v }
    opt.on('-r', '--region REGION') {|v| endpoint = v }
    opt.on(''  , '--start-time TIME') {|v| params['StartTime'] = Time.parse(v).iso8601 }
    opt.on(''  , '--end-time TIME')   {|v| params['EndTime'] = Time.parse(v).iso8601 }
    opt.on('-t', '--types TYPE_LIST', Array) {|v| v.each_with_index {|t, i| params["InstanceType.#{i + 1}"] = t } }
    opt.on('-d', '--descs DESC_LIST', Array) {|v| v.each_with_index {|t, i| params["ProductDescription.#{i + 1}"] = t } }
    opt.on('-z', '--zone AVAILABILITY_ZONE') {|v| params['AvailabilityZone'] = v }

    opt.parse!

    access_key ||= ENV['AWS_ACCESS_KEY_ID']
    secret_key ||= ENV['AWS_SECRET_ACCESS_KEY']
    endpoint ||= (ENV['REGION_NAME'] || ENV['EC2_ENDPOINT'])

    unless access_key and secret_key and endpoint
      puts opt.help
      exit 1
    end

    if (availability_zone = params['AvailabilityZone'])
      region = endpoint

      if region =~ /\Aec2\.([^.]+)\.amazonaws\.com\Z/
        region = $1
      end

      if availability_zone =~ /\A[a-z]\Z/i
        params['AvailabilityZone'] = region + availability_zone
      end
    end
  rescue => e
    $stderr.puts e
    exit 1
  end
end

def to_yaml_style; :inline; end

ec2cli = EC2Client.new(access_key, secret_key, endpoint)

puts '---'

ec2cli.spot_price_history(params) do |row|
  puts YAML.dump(row).slice(2..-1)
end

~/work$ ./ec2-describe-spot-price-history -k "..." -s "..." -r us-west-1 -t t1.micro,c1.xlarge -d Linux/UNIX,Windows --start-time=10:00 --end-time=16:00
---
- [c1.xlarge, Linux/UNIX, 0.11, "2013-01-16T13:33:46+09:00", us-west-1c]
- [t1.micro, Windows, 0.007, "2013-01-16T12:49:00+09:00", us-west-1b]
- [c1.xlarge, Windows, 0.24, "2013-01-16T10:30:29+09:00", us-west-1c]
- [c1.xlarge, Windows, 0.24, "2013-01-16T10:30:29+09:00", us-west-1a]
- [t1.micro, Windows, 0.007, "2013-01-16T10:28:50+09:00", us-west-1c]
- [t1.micro, Windows, 0.007, "2013-01-16T10:01:03+09:00", us-west-1a]
- [c1.xlarge, Windows, 0.24, "2013-01-16T03:49:35+09:00", us-west-1b]
- [t1.micro, Linux/UNIX, 0.004, "2013-01-16T02:24:39+09:00", us-west-1b]
- [t1.micro, Linux/UNIX, 0.004, "2013-01-15T19:52:38+09:00", us-west-1c]
- [c1.xlarge, Linux/UNIX, 0.11, "2013-01-15T19:09:04+09:00", us-west-1b]
- [c1.xlarge, Linux/UNIX, 0.11, "2013-01-15T17:14:45+09:00", us-west-1a]
- [t1.micro, Linux/UNIX, 0.004, "2013-01-15T17:13:26+09:00", us-west-1a]
- [c1.xlarge, Linux/UNIX, 0.11, "2013-01-15T13:32:47+09:00", us-west-1c]
- [t1.micro, Windows, 0.007, "2013-01-15T12:48:13+09:00", us-west-1b]
- [c1.xlarge, Windows, 0.24, "2013-01-15T10:30:04+09:00", us-west-1c]
- [c1.xlarge, Windows, 0.24, "2013-01-15T10:30:04+09:00", us-west-1a]
- [t1.micro, Windows, 0.007, "2013-01-15T10:28:27+09:00", us-west-1c]
- [t1.micro, Windows, 0.007, "2013-01-15T10:00:57+09:00", us-west-1a]
~/work$