ちょっと話題の記事

Amazon S3へデータの格納・取得を行う【AmazonS3Client】 Java編

2013.02.04

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。LeapMotionが届いてる人がチラホラいるようですね。正直うらやましい。こむろです

今回は、AWS SDK for Javaを利用して、S3へのファイル転送の機能を作る際に、一箇所ハマった箇所があるので忘れないように記事にしておきます。

S3とは

Amazon S3とは、Amazonが提供するクラウドストレージサービスです。バケットと呼ばれるプライベートな領域にどんなファイルでも格納することができ、さらに数テラバイトに及ぶデータ容量でも容易に格納・取得が可能です。当然、認証により守られているので、許可されていないとファイルは見ることはできません。詳しくはこちら

JavaでS3へファイルの格納・取得を行う

JavaなどのプログラムからAmazon S3にファイルをアップロード、ダウンロードする操作は簡単です。SDKに含まれているAmazonS3Clientというクラスを利用します。基本的にこの子を使うだけで、S3へアクセスすることができます。

AmazonS3Clientを作成

使い方は至ってシンプル。アクセスキーと秘密鍵の文字列を渡し、認証オブジェクトを作ってコンストラクタに渡してあげればOKです。他にも各種様々な情報をコンストラクタ生成時に設定できます(後からでも設定できる模様。Setterがあった)。

Timeout時間やプロキシのUser名、Passwordなどの設定は、インスタンス生成時に指定することができる模様。ClientConfigurationを使って設定項目をバシバシ設定します。今回はTimeout時間のみ設定してみました。

private AmazonS3Client makeS3Client(String accessKey, accessSecretKey) {

    // 認証オブジェクトを作成
    AWSCredentials credentials = new BasicAWSCredentials(accessKey, accessSecretKey);

    // ConfigurationでTimeout時間を30秒に設定
    ClientConfiguration clientConfiguration = new ClientConfiguration();
    clientConfiguration.setConnectionTimeout(30000);

    // AmazonS3Clientをインスタンス化
    return new AmazonS3Client(credentials, clientConfiguration);
}

オブジェクトの格納

InputStreamをちょこちょこ読みながら、指定のサイズに分割してアップロードします。ObjectKeyというのは、相対パスのようなものです。バケット(ルートパスにあたるようなもの)以下にどのようにファイルを配置するかを指定します

// S3エンドポイント
private static final String ENDPOINT = "https://s3-ap-northeast-1.amazonaws.com";
// マルチパートアップロード最少サイズ
private static final long PART_SIZE = 5 * 1024L * 1024L;

// オブジェクト格納    
public void putObject(String objectKey, long contentLength, InputStream is) throws IOException {

     // AmazonS3Clientインスタンスを作成
    AmazonS3Client cli = makeS3Client();

    // Endpointの生成
    cli.setEndpoint(ENDPOINT);

    // TransferManagerを利用
    TransferManager manager = new TransferManager(cli);

     // 分割サイズを設定
    TransferManagerConfiguration c = new TransferManagerConfiguration();
    c.setMinimumUploadPartSize(PART_SIZE);
    manager.setConfiguration(c);

     // メタデータに分割したデータのサイズを指定
    ObjectMetadata putMetaData = new ObjectMetadata();
    putMetaData.setContentLength(contentLength);
    Upload upload = manager.upload(sre.rootDirectory, objectKey, is, putMetaData);

    try {
        upload.waitForCompletion();
    } catch (InterruptedException e) {
        new IOException(e);
    }
}

InputStreamを引数に、AmazonS3Client#putObject()で分割せずに送ることも可能です。

が、一度展開してバッファをメモリ上に作成してしまうため、巨大なファイル等を送信するときには、ヒープ領域を枯渇してしまう可能性があります。

そのため、TransferManagerを利用してサイズに分割してアップロードした方が安全かと思われます。たったこれだけのコードでAWSにファイルをアップロードできます

オブジェクトの取得

バケット名、ObjectKeyを指定すると、S3に格納されているバイナリデータを取得できるInputStreamを返します。このInputStreamを読み込むことで、S3からファイルを取得することができます。

// オブジェクト取得
public InputStream getObject(String bucketName, String objectKey) throws FileNotFoundException {
     // AmazonS3Clientインスタンスを作成
    AmazonS3Client cli = makeS3Client();

     // エンドポイントを設定
    cli.setEndpoint(ENDPOINT);

     // rootDirectory(Bucket名), objectKey(オブジェクトまでの相対パス)からリクエストを作成
    GetObjectRequest request = new GetObjectRequest(bucketName, objectKey);

    S3Object object = cli.getObject(request);
    
    // Objectを開いたInputStreamを返す
    return object.getObjectContent();
}

ファイルを取得するためのコード

やることはいつもどおりです。少々レガシーな方法ですが、特に問題はなかろうと。Channelとかでもいけるかも(試してません)

public void getS3Object() throws IOException, FileNotFoundException {
	
	String bucketName = "e987blkjvanlekjr23buaosda";
	String objectKey = "sample/20130203/code/sample.bin";

	InputStream is = null;
	FileOutputStream fos = null;

	try {
		is = getObject(bucketName, objectKey);
		fos = new FileOutputStream("/usr/local/temp/hoge.bin");
		
		byte[] buffer = new byte[1024*1024];
		int readSize = -1;
		while( (readSize = is.read(buffer, 0, buffer.length)) != -1) {
			fos.write(buffer, 0, readSize);
		}
		fos.flush();

	} finally {
		if (is != null) {
			is.close();
		}

		if (fos != null) {
			fos.close();
		}
	}
}

基本的に、これで問題なくファイルの格納・取得ができます!簡単ですね!

が、しかし問題が

比較的サイズの小さいファイルのやりとりであれば全く問題なかったのですが、テストの過程で最大1GBまでのファイルをテストすると、なぜか不定のタイミングで、Socketからの読み込みができなくなることが発覚しました。成功することもあれば失敗することもある, ファイルサイズはあまり関係ない

ファイルをDLしていると、突然の死!

突然の死

アイエェェェ!レイガイ!?レイガイナンデ?!

java.net.SocketException : socket closed
            at java.net.SocketInputStream.socketRead0(Native Method)
            at java.net.SocketInputStream.read( SocketInputStream.java:129)
            at com.sun.net.ssl.internal.ssl.InputRecord.readFully( InputRecord.java:293)
            at com.sun.net.ssl.internal.ssl.InputRecord.readV3Record( InputRecord.java:405)
            at com.sun.net.ssl.internal.ssl.InputRecord.read( InputRecord.java:360)
            at com.sun.net.ssl.internal.ssl.SSLSocketImpl.readRecord( SSLSocketImpl.java:863)
            at com.sun.net.ssl.internal.ssl.SSLSocketImpl.readDataRecord( SSLSocketImpl.java:820)
            at com.sun.net.ssl.internal.ssl.AppInputStream.read( AppInputStream.java:75)
            at org.apache.http.impl.io.AbstractSessionInputBuffer.read(AbstractSessionInputBuffer.java:187)
            at org.apache.http.impl.io.ContentLengthInputStream.read(ContentLengthInputStream.java:164)
            at org.apache.http.conn.EofSensorInputStream.read( EofSensorInputStream.java:138)
            at java.io.FilterInputStream.read( FilterInputStream.java:116)

やれやれ、なかなか厄介なトラブルパターンのようです。法則性がないように見えるし。さあ、ショータイムだ!

まずは、状況の整理から

  1. ファイルサイズに関係なく発生する。ただし、ファイルサイズが小さいほうが比較的起こる確率は低い
  2. read()の段階で死ぬ
  3. 特定のセクションを読んだ時点で死ぬわけではないようだ。読み込めるファイルサイズもランダム

1.read()の箇所を疑ってみる

実は当初、Channelを使って読み込んでいました。NIO系のクラスを使うとまずいのかと思い、1Byteずつ読み込むのに変えてみました。が、特に変化なし

2.AmazonS3ClientのTimeout時間を疑ってみる

短い時間のTimeout時間が設定されていると、通信環境によっては途中で通信が切断されてしまうのでは、と考え、setConnectionTimeout(0)に設定してみましたが、これも変化なし

鍵はGC

唯一、AWS Developer Forumにヒントを発見。それがこちら。"Android s3 download throws "Socket is closed" exception or terminates early"。ここから推測するに下記のような現象が起きていたようです

  1. AmazonS3Clientクラスは、コネクションの状態なども同時に管理している
  2. AmazonS3Clientのインスタンスがメソッドを抜けてInputStreamを返した後、参照がなくなったと判断され、GCの対象に。
  3. GCされる前に通信が終われば成功。通信中にGCされると、SocketがCloseされた状態になり、read()でコケる

通常の通信時はこんな感じ

Blog_image_01

GCされてしまうと

AmazonS3ClientがGCによってドナドナされてしまい、今までS3とのコネクションを仲介してくれてた人がいなくなってしまいます。このため、読み込んでいる最中にSocket Closedが発生し、ブチッと切断されてしまっていたようです

Blog_image_02

解決策

とりあえずの回避策として、GCの対象にならないようAmazonS3Clientオブジェクトのスコープを広げてみます。

// インスタンス変数へ格上げ
private AmazonS3Client client;

// オブジェクト取得
public InputStream getObject(String bucketName, String objectKey) throws FileNotFoundException {
     // AmazonS3Clientインスタンスを作成
    client = makeS3Client();

     // エンドポイントを設定
    client.setEndpoint(ENDPOINT);

     // rootDirectory(Bucket名), objectKey(オブジェクトまでの相対パス)からリクエストを作成
    GetObjectRequest request = new GetObjectRequest(bucketName, objectKey);

    S3Object object = client.getObject(request);
    
    // Objectを開いたInputStreamを返す
    return object.getObjectContent();
}

これで、メソッドを定義しているクラスのインスタンスが生存している間は、ひとまず殺されることがなくなりました。利用しているフレームワークによっては、このインスタンスが殺されてしまう場合もあるかもしれません。その場合は、staticな変数にするなど対処が必要です(ただし、この場合は上書きされるかもしれない上、きちんと処理しないと積み上がってしまう可能性もあるので、注意が必要)

色々と疑問は残りますが、GCによる"強制Socket Closed"をひとまずは回避できました。公式のReferenceには記述されていないので、なかなか気付きづらいですが、気を付けておいた方が良さそうです。

ネットで情報を探す限りは、日本の人は誰も困っていないようなので、自分のスキル不足か、はたまたあまり使われていないのか・・・。謎は深まるばかりデス。

それでは皆様ごきげんよう

参考資料