HaskellでS3にファイルアップロードしてみた

2021.01.04

はじめに

あけましておめでとうございます。CX事業本部の吉川です。

年末年始連休は全体的にダラダラとした寝正月を過ごしていましたが、ちょっとくらい何かやろうということで自由研究として「Haskell+AWS」に取り組んでみました。

ずっとHaskellに興味があり、すごいH本『入門Haskellプログラミング』は買ったのですが、すっかり積んでしまっていました。

そんなわけで、勉強しようしようと思いつつ手を出せていなかったのでこの機会にえいやで始めました。
今回は手頃なところでS3へのファイルアップロードを行います。

GitHubリポジトリ

まずは完成物から。
stack run でS3バケットに10MBと100MBのダミーファイルをアップロードします。

https://github.com/dyoshikawa/amazonka-s3-playground

S3バケットとIAMユーザ作成

検証に必要なAWSリソースを作成します。

  • S3バケット (非公開)
  • IAMユーザ (S3FullAccess権限を付与)
    • アクセスキーを発行し控えておく

をそれぞれ作成しました。

Stack導入

Haskellを始めるならとりあえずStackを入れておけば良いという雰囲気があるそうなので入れていきます。

  • プロジェクトで指定したバージョンのコンパイラをダウンロード
  • コンパイルと実行
  • パッケージ管理

といった、開発に必要な諸々をやってくれるツールです。
RustのCargoみたいなものだと思います (たぶん) 。

Macだと

brew install stack

で入れるのが楽だと思います。

プロジェクト作成とセットアップ

Stackはプロジェクト作成もできます。

stack new myproj # `myproj` は任意のプロジェクト名

作成できました。

ここで stack.yaml を編集します。

resolver: lts-13.18

resolver の値を lts-13.18 に変更します。
初期に生成される lts-16.27 のまま後述の依存パッケージを導入するとコンパイルエラーが発生したためです (2021/1/4現在) 。

ダミーファイルと環境変数の仕組みを作る

「ダミーファイルをS3にアップロードする」という目標に向けて、まずは

  • ダミーファイル
  • シークレットキー等の環境変数をセットする仕組み (キーをハードコーディングしたくないため)

をそれぞれ作ります。

ダミーファイル

https://qiita.com/toshihirock/items/6cb99a85d86f524bc153

こちらを参考に下記のコマンドで作成しました。

dd if=/dev/zero of=10m.dummy bs=1000000 count=10 # 約10MBのファイル
dd if=/dev/zero of=100m.dummy bs=1000000 count=100 # 約100MBのファイル

環境変数セットの仕組み (dotenv)

dotenv-hsパッケージを使うことにしました。

ここで package.yaml を編集していきます。
パッケージを追加する際は基本的にこのファイルを変更します。

# package.yaml

dependencies:
- base >= 4.7 && < 5
- dotenv >= 0.8.0.0 # 追加

変更できたら stack build を実行するとパッケージを落としてきてくれます。

dotenvパッケージからロードするための .env ファイルを作ります。

AWS_ACCESS_KEY_ID="xxxxxxxxxxxxxxxxxxx"
AWS_SECRET_ACCESS_KEY="xxxxxxxxxxxxxxxxxxxxx"
AWS_S3_BUCKET_NAME="sample-bucket"

それぞれ自分の環境の値を記述します。

この .env をロードする関数を書きます。

# src/Lib.hs

initEnv :: IO ()
initEnv = void $ loadFile defaultConfig

S3アップロード

ではいよいよS3アップロードの処理を書きます。

package.yaml にHaskell用のAWS SDKであるamazonkaの依存関係を記述します。 amazonkaはlensと一緒に使う必要があるので加えるのと、文字列の変換に必要なtextbytestringも入れます。

# package.yaml
dependencies:
- base >= 4.7 && < 5
- dotenv >= 0.8.0.0
- text >= 1.2.3.0 # 追加
- bytestring >= 0.10.8.2 # 追加
- amazonka >= 1.6.1 # 追加
- amazonka-s3 >= 1.6.1 # 追加
- lens >= 4.17 # 追加

変更したら stack build でダウンロードします。

まずは10MBのファイルをS3にアップロードする関数を書いていきます。
https://github.com/brendanhay/amazonka/blob/develop/examples/src/Example/S3.hs を参考にしました。

# src/Lib.hs

createAwsEnv :: IO Env
createAwsEnv = do
    logger <- newLogger Debug stdout
    accessKeyId <- getEnv "AWS_ACCESS_KEY_ID"
    secretAccessKey <- getEnv "AWS_SECRET_ACCESS_KEY"
    newEnv (FromKeys (AccessKey $ BC.pack accessKeyId) (SecretKey $ BC.pack secretAccessKey)) <&> set envLogger logger . set envRegion Tokyo

s3Upload :: Env -> Text -> Text -> String -> IO ()
s3Upload env bucketName key filePath =
    runResourceT . runAWST env $ do
        body <- chunkedFile 10000000 filePath
        send $ putObject (BucketName bucketName) (ObjectKey key) body
        return ()

createAwsEnvEnv インスタンスを作って返す関数です。
Env はクレデンシャル、リージョン、ロガー等の情報を持つ構造体のようなもの・・・だと思います。

s3Upload が実際のアップロード処理で使う関数です。
この関数の引数に Env インスタンスが必要になります。

S3アップロード (マルチパートアップロード)

大容量ファイルをアップロードする際はマルチパートアップロードが必要になってきます。

マルチパートアップロードについては下記をご参考ください。

https://dev.classmethod.jp/articles/sugano-045-s3-lifecycle-policy/

S3 は最大5TBまでのオブジェクト(ファイル)を保管できるのですが、保存のため一度に送信できるサイズは5GBという制限があります。 ではどうやって5TBのオブジェクトを保存するのか?というと aws cli や SDK を使って小さく分割したファイルを送信し、全ての部品が揃ったら S3 で一つのファイルに復元されます。 この機能を「マルチパートアップロード」といいます。

また、公式のFAQで下のようにアドバイスがあります。

https://aws.amazon.com/jp/s3/faqs/

Q: Amazon S3 にはどれだけのデータを保存できますか? 格納可能なデータの総量とオブジェクトの数には制限はありません。個別の Amazon S3 オブジェクトのサイズは、最低 0 バイトから最大 5 テラバイトまでさまざまです。1 つの PUT にアップロード可能なオブジェクトの最大サイズは 5 GB です。100 MB 以上のオブジェクトの場合は、マルチパートアップロード機能を使うことをお考えください。

100MB以上になるとマルチパートアップロードを検討した方が良いということなので、このために約100MBのダミーファイルをあらかじめ用意したのでした。

マルチパート機能を使ってストリーミングにアップロードしたい場合は、さらにamazonka-s3-streamingというパッケージを使うのが楽そうでしたので、一緒に必要なconduitとあわせて追加します。

# package.yaml

dependencies:
- base >= 4.7 && < 5
- text >= 1.2.3.0
- bytestring >= 0.10.8.2
- dotenv >= 0.8.0.0
- amazonka >= 1.6.1
- amazonka-s3 >= 1.6.1
- lens >= 4.17
- amazonka-s3-streaming >= 1.0.0.1 # 追加
- conduit >= 1.3.0 # 追加

と、ここで stack build すると次のようなエラーが発生しました。

Error: While constructing the build plan, the following exceptions were encountered:

In the dependencies for myproj-0.1.0.0:
    amazonka-s3-streaming must match >=1.0.0.1, but the stack configuration has no specified version  (latest matching version is 1.1.0.0)
needed since myproj is a build target.

Some different approaches to resolving this:

  * Recommended action: try adding the following to your extra-deps in /Users/USER/path/to/myproj/stack.yaml:

- amazonka-s3-streaming-1.1.0.0@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

私自身Stackの仕組みをわかりきれていないのですが、こういう時は素直に Recommended action に従って stack.yamlextra-deps にもパッケージ名とバージョンを記載すれば解決するようです。

# stack.yaml

extra-deps: # 追加
- amazonka-s3-streaming-1.0.0.1 # 追加
- http-client-0.5.14 # こちらも `Recommended action` で指摘されるので追加

この追記を行って stack build すると無事通ったのでコードを書きます。
https://github.com/axman6/amazonka-s3-streaming/blob/master/Main.hs を参考にしました。

s3StreamUpload :: Env -> Text -> Text -> String -> IO ()
s3StreamUpload env bucketName key filePath =
    runResourceT . runAWST env $ do
        runConduit (sourceFileBS filePath .| streamUpload Nothing (createMultipartUpload (BucketName bucketName) (ObjectKey key)))
        return ()

大サイズファイルをストリーミングにアップロードするための関数です。

main関数を書いて実行

ここまでできたらあとはmain関数からそれぞれ呼び出してあげるだけです。

main :: IO ()
main = do
    initEnv
    env <- createAwsEnv
    bucketName <- getEnv "AWS_S3_BUCKET_NAME"
    s3Upload env (Text.pack bucketName) "10m.dummy" "./10m.dummy"
    s3StreamUpload env (Text.pack bucketName) "100m.dummy" "./100m.dummy"
    return ()
stack run

成功レスポンスが出力され、実際にバケットにアップロードできていることも確認できました。

おわりに

正直Haskellの構文はチンプンカンプンな状態で始めてそれはどうなんだ・・・というのもありますが、いつまで経っても学習が進まないのは「自分があまり根気のある人間ではないのに入門書を頭から読むような勉強法を試みるからではないか」とも思います。

休暇中、勉強しないとなーと思いつつダラダラしてしまっている中、ふと「パラシュート勉強法」という言葉があったな・・・と思い出しました。

ある文系プログラマがテックリードを任されるまでに学んだこと ── 最前線で生き延びる4つの戦略

パラシュート勉強法とは、経済学者の野口悠紀雄氏が著書『「超」勉強法』で紹介した、「必要になったものを必要なだけ学ぶ」という学習の手法です。 例えば数学のように、ある単元がその前の単元の知識を前提とする積み上げ型の教科を学習する場合、「今の単元がわからないので『数I・A』からやり直そう!」と考えがちです。しかし、十中八九、わずかに進めるだけで、目的に到達する前に力尽きてしまいます。 そうではなく、バラシュートでまっすぐに目的地に降りるように目標を定め、目的地に至るまで、必要な最低限の知識だけを補いながら進んでいくのがこの勉強法です。

「よししょぼくても良いからS3にアップロードするところまでやろう」と具体的な目標を立てて勉強し始めると急に集中できたので、ぶっつけでとにかく動くものを作ってみるのが自分に合ったやり方なのかもしれません。
積ん読本達もこの記号わかんねえな・・・って時に拾い読みするだけで非常に役に立ちました。おすすめです。

なお本記事の一部についてHaskell-jpで相談させて頂きました1
質問に答えてくださったHaskellコミュニティの皆様ありがとうございました。


  1. 内容に間違いがあった場合、私の責任です。念の為。