なにこれ
S3互換のストレージをpythonでつくろうという記事です。勉強の意味合いが強いです。
クライアントの動きを探る
AWS公式のAPIドキュメントにサクッと目を通し終わったので、実際にS3クライアントがどのような動きをしているのか調べることにしました。
前提条件
- ダミーサーバーAPPには、python&flaskを利用
- クライアントには、cyberduckを利用 (手元にあり、簡単に使えそうだった為)
- HTTPSを利用(cyberduckでs3利用する場合の標準ポートが443になっていた為)
- 仮想ホスト形式のアクセスではなく、パス形式でのアクセスを想定
- AccessKeyID: hogehoge_user1
- バケット名:test-bucket
操作
Object Listing
- GETクエリーでPrefix指定、デミリタ関連の設定している
- バケットのルートが叩かれる
127.0.0.1 - - [29/Jan/2017 01:02:24] "GET /test-bucket/?max-keys=1000&prefix&delimiter=%2F HTTP/1.1"
【リクエストヘッダ】
Authorization: AWS hogehoge_user1:ysH68SkszwcudzrtZAlxlV9z8WA=
Content-Length:
User-Agent: Cyberduck/4.7.2.18004 (Mac OS X/10.10.5) (x86_64)
Connection: upgrade
Host: b.tgr.tokyo
X-Amz-Request-Payer: requester
Date: Sun, 29 Jan 2017 01:02:23 GMT
Content-Type:
Upload
- PUTメソッドを利用 (POSTではない)
- アップしたいオブジェクトのパスがそのまんまHTTPのパスに現れてる
- Content-Lengthが送られる
127.0.0.1 - - [29/Jan/2017 01:04:19] "PUT /test-bucket/gopher.png HTTP/1.1"
【リクエストヘッダ】
Authorization: AWS hogehoge_user1:suTrxv+XQuecbq7vUMYoQ3rWBcM=
Content-Length: 114063
User-Agent: Cyberduck/4.7.2.18004 (Mac OS X/10.10.5) (x86_64)
Connection: upgrade
Host: b.tgr.tokyo
Date: Sun, 29 Jan 2017 01:04:18 GMT
Content-Type: image/png
Download
- GETメソッドを利用
- 落としたいオブジェクトのパスがHTTPのパスに現れる
127.0.0.1 - - [29/Jan/2017 01:05:09] "GET /test-bucket/gopher.png HTTP/1.1"
【リクエストヘッダ】
Authorization: AWS hogehoge_user1:rhqMjHlbcYg/APr7bv9PH7tbyy4=
Content-Length:
User-Agent: Cyberduck/4.7.2.18004 (Mac OS X/10.10.5) (x86_64)
Connection: upgrade
Host: b.tgr.tokyo
X-Amz-Request-Payer: requester
Date: Sun, 29 Jan 2017 01:05:09 GMT
Content-Type:
Delete
- DELETEメソッドを利用
- 消したいオブジェクトのパスがHTTPのパスに現れる
127.0.0.1 - - [29/Jan/2017 01:05:59] "DELETE /test-bucket/gopher.png HTTP/1.1"
【リクエストヘッダ】
Authorization: AWS hogehoge_user1:U2NEDsKLvJ08mLYdPIB43R+IAu0=
Content-Length:
User-Agent: Cyberduck/4.7.2.18004 (Mac OS X/10.10.5) (x86_64)
Connection: upgrade
Date: Sun, 29 Jan 2017 01:05:59 GMT
Host: b.tgr.tokyo
Content-Type:
感想
結構やりとりスッキリしてるので、以下の機能を持つAPPサーバーを書けば
Cyberduckからアクセス可能ななんちゃってS3が作れそうですね。
- Authorizationヘッダーをチェックし、認可をする所(全リクエスト共通)
- バケットのルートを叩くようなリクエスト(GET)が来たら、オブジェクトをリスト化して返す
- オブジェクトを直接叩くリクエスト(GET)が来たら、オブジェクトを直接返す
- PUTメソッドを使いファイルが来たら、HTTPのペイロードをファイルに書き出す
- DELETEでオブジェクトが叩かれたら、ファイルを削除する
実装する
完成版ですが、simple-s3-cloneにPUSHしました。詳しいコードはこちらを御覧ください。
バケットという概念がなく、エラーハンドリングがまだ甘いので、後で追加していきます。
下記の説明で登場するコードですが、説明の為だいぶ削っています。
概念だけでも伝わればと思っています。
認証部分
一番苦労したところがここです。
基本的には以下の手順で認証を行うのですが、X-Amzヘッダの存在を忘れていて、いつまでたってもクライアントの生成したシグネチャと合わず挫折しかけました。
- シグネチャ生成用の文字列を作る(この文字列は、リクエストのメソッド、パス、ヘッダー情報から生成が可能)
- この文字列とシークレットアクセキーを使い、HMAC-SHA1でハッシュをとり、シグネチャーを生成する
- AccessKeyId・サーバー側で生成したシグネチャーがユーザーが送ってきたものと一致していればOK(一致していない場合、403を返却する)
def get_x_amz_headers():
return filter(lambda x: x[0].startswith('X-Amz-'), request.headers.items())
def generate_x_amz_string():
ret = ''
# 認証用文字列生成の為にX-Amzヘッダーをソートして連結する
for key in sorted(get_x_amz_headers()):
k = key[0].lower()
v = request.headers.get(key[0])
ret += '{}:{}\n'.format(k, v)
return ret
def generate_auth_string():
s = '{}\n{}\n{}\n{}\n{}{}'.format(
request.method,
request.headers.get('Content-Md5', ''),
request.headers.get('Content-Type', ''),
request.headers.get('Date', ''),
generate_x_amz_string(),
request.path
)
return s
def auth_check(auth_raw_string):
auth_info = request.headers.get('Authorization')
access_key_id = 'hogehoge_key_id'
secret_access_key = 'hogehoge_secret'
# HMAC-SHA-1を使い、生成した文字列をシークレットキーでハッシュ化する(シグネチャ生成)
hashed = hmac.new(secret_access_key, auth_raw_string,
hashlib.sha1).digest()
# ユーザーが送ってくる認証ヘッダの形(AWS AccessKeyID:Signature)を作る
generated_signature = 'AWS {}:{}'.format(
access_key_id, base64.encodestring(hashed).rstrip())
# ユーザーが生成したシグネチャと比較する
if auth_info != generated_signature:
raise exception.SignatureDoesNotMatch()
@app.before_request
def before_request():
# 全てのリクエスの前には認証実行
s = generate_auth_string()
auth_check(s)
オブジェクトのリスティング、ダウンロード
@app.route("/<path:path>")
def get_request_with_path(path):
if g.resource_path == '':
return process_object_list()
else:
return download_object()
オブジェクトのアップロード、フォルダ生成
@app.route("/<path:path>", methods=['PUT'])
def put_request_with_path(path):
if int(request.headers.get('Content-Length')) != len(request.data):
raise exception.MissingContentLength()
if g.resource_path[-1] == '/':
return create_prefix()
else:
return create_object()
オブジェクトの削除、フォルダ削除
@app.route("/<path:path>", methods=['DELETE'])
def delete_request_with_path(path):
if g.resource_path[-1] == '/':
return delete_prefix()
else:
return delete_object()
動いている様子
s3cmdでは、リクエストの送り方がcyberduckと異なるため、動きませんでした。
ただ、認証部分に手をいれれば、すぐに対応できそうです。
最後に
今までシミュレーションなど科学技術計算等をやってきましたが、入社してからS3のようなサービスを知り
サーバーサイドを実装してみたくなったというのが今回のモチベーションです。