Livebook で AWS S3 のデータを分析したり画像処理したり

## はじめに

Livebook　から AWS のサービスを操作するシリーズです

今回は AWS S3 を操作します

S3 はクラウドストレージサービスで、低コストで安全・簡単にファイルを保存することができます

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

今回は Livebook から S3 のファイル一覧を取得して一覧表示したり、画像ファイルをダウンロードしてきて画像処理したりします

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/aws/s3.livemd

## 事前作業

AWS のアカンウトと、 S3 の権限を持った IAM ユーザーと、その認証情報（ACCESS_KEY_ID と SECRET_ACCESS_KEY）が必要です

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_users_create.html#id_users_create_console

## 実行環境

Livebook 0.7.2 の Docker　イメージを元にしたコンテナで動かしました

https://hub.docker.com/layers/livebook/livebook/0.7.2/images/sha256-23b577ff5c956fa017bc148d41e219d3697b159813feeefbfc37a6853ccaf62e?context=explore

コンテナ定義はこちらを参照

https://github.com/RyoWakabayashi/elixir-learning

## セットアップ

ExAWS.S3 の hexdocs を参考に必要なモジュールをインストールします

https://hexdocs.pm/ex_aws_s3/ExAws.S3.html

また、ファイル一覧などを表にするために Explorer 、画像ファイルを画像処理するために Evision などもインストールします

```elixir
Mix.install([
  {:ex_aws, "~> 2.0"},
  {:ex_aws_s3, "~> 2.0"},
  {:poison, "~> 5.0"},
  {:hackney, "~> 1.18"},
  {:sweet_xml, "~> 0.7"},
  {:explorer, "~> 0.3"},
  {:evision, "~> 0.1"},
  {:download, "~> 0.0.4"},
  {:kino, "~> 0.7"},
])
```

エイリアス等の準備をします

```elixir
alias ExAws.S3
alias Explorer.DataFrame
alias Explorer.Series
require Explorer.DataFrame
```

## 認証

入力エリアを用意し、そこに IAM ユーザーの認証情報を入力します

ACCESS_KEY_ID　と SECRET_ACCESS_KEY は秘密情報なので、値が見えないように `Kino.Input.password` を使います

https://qiita.com/RyoWakabayashi/items/debb1d048613ce505988

```elixir
access_key_id_input = Kino.Input.password("ACCESS_KEY_ID")
```

```elixir
secret_access_key_input = Kino.Input.password("SECRET_ACCESS_KEY")
```

操作対象にする S3 のバケットのリージョンもここで入力しておきましょう

```elixir
region_input = Kino.Input.text("REGION")
```

![スクリーンショット 2022-12-03 0.01.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9c877571-862a-22bd-528f-32d5cb6c28b5.png)

各認証情報を ExAws に渡すためにまとめておきます

秘密情報が実行結果に現れないよう、セルの最後には `"dummy"` を入れておきましょう

```elixir
auth_config = [
  access_key_id: Kino.Input.read(access_key_id_input),
  secret_access_key: Kino.Input.read(secret_access_key_input),
  region: Kino.Input.read(region_input)
]

"dummy"
```

## バケット一覧の取得

操作は基本的に AWS CLI の `aws s3api` と同じように実行できます

操作を指定するコードの後に `ExAws.request` を入れることで、実行されます

このとき第2引数に認証情報を渡します

```elixir
S3.list_buckets()
|> ExAws.request(auth_config)
```

![スクリーンショット 2022-12-03 0.09.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/03ed5da1-b13a-3eea-dabb-a45aa2a67b29.png)

body　の buckets の中にバケットの一覧が返ってきていますね

これだと見にくいので、 Explorer を使って一覧を表にしましょう

```elixir
S3.list_buckets()
|> ExAws.request!(auth_config)
|> then(& &1.body.buckets)
|> DataFrame.new()
|> DataFrame.select(["name", "creation_date"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-03 0.11.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/76f49b0c-1a7f-dbbe-7707-cce70787ec31.png)

## ファイル一覧の取得

バケット名を入力します

```elixir
bucket_name_input = Kino.Input.text("BUCKET_ANME")
```

`S3.list_objects_v2` を使って、バケット内のファイル一覧を取得します

今回使ったバケットは実際にはファイルが 100 件未満なので、 `max_keys` をあえて指定して、最初の 20 件だけを取得してみましょう

```elixir
{contents, next_continuation_token} =
  bucket_name_input
  |> Kino.Input.read()
  |> S3.list_objects_v2(max_keys: 20)
  |> ExAws.request!(auth_config)
  |> then(&{&1.body.contents, &1.body.next_continuation_token})
```

body.contents にファイルの一覧、 body.next_continuation_token に、次のページを取得するためのトークンが入っています

![スクリーンショット 2022-12-03 0.16.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7b4876cb-3d1c-f08e-3816-aeae5595602e.png)

`continuation_token` に取得したトークンを渡して、次の 20 件を取得してみましょう

```elixir
bucket_name_input
|> Kino.Input.read()
|> S3.list_objects_v2(max_keys: 20, continuation_token: next_continuation_token)
|> ExAws.request!(auth_config)
|> then(&{&1.body.contents, &1.body.next_continuation_token})
```

![スクリーンショット 2022-12-03 0.19.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1f011635-4b5e-8d39-a946-0e09dc8fe09b.png)

ファイルは40件以上あるため、まだ次のページ用トークンが返ってきます

全件取得するためには、トークンが空文字になるまで再帰的に呼び出さなければなりません

再帰処理用のモジュールを作ります

```elixir
defmodule S3LS do
  def get_contents(continuation_token, bucket_name, auth_config) do
    bucket_name
    |> S3.list_objects_v2(max_keys: 20, continuation_token: continuation_token)
    |> ExAws.request!(auth_config)
    |> then(&{&1.body.contents, &1.body.next_continuation_token})
  end

  def get_contents_cyclic(continuation_token, bucket_name, auth_config) do
    {contents, next_token} =
      get_contents(continuation_token, bucket_name, auth_config)

    case next_token do
      # 空であれば次ページを取得しない
      "" ->
        contents

      # 空以外の場合は次ページを取得する
      _ ->
        contents ++ get_contents_cyclic(next_token, bucket_name, auth_config)
    end
  end

  def get_all_contents(bucket_name, auth_config) do
    get_contents_cyclic(nil, bucket_name, auth_config)
  end
end
```

実行してみましょう

```elixir
all_contents =
  bucket_name_input
  |> Kino.Input.read()
  |> S3LS.get_all_contents(auth_config)
```

![スクリーンショット 2022-12-03 0.21.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/470d0c60-1a75-41d1-e457-9e7d8f70e634.png)

ちゃんと全件取れたか分からないので、これも表にしてみましょう

また、このままだとファイルサイズや最終更新日が文字列になっているため、数値と日時に変換しておきます

```elixir
all_contents_df =
  all_contents
  |> DataFrame.new()
  |> DataFrame.select(["key", "last_modified", "size"])
  # サイズを数値に変換
  |> DataFrame.mutate(size: cast(size, :float))
  # 最終更新日を日付に変換
  |> then(fn df ->
    DataFrame.put(
      df,
      "last_modified",
      df["last_modified"]
      |> Series.transform(fn input ->
        NaiveDateTime.from_iso8601!(input)
      end)
    )
  end)

all_contents_df
|> Kino.DataTable.new()
```

ファイルは 82 件、全部取れたようです

![スクリーンショット 2022-12-03 0.24.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/54412839-7655-7169-34af-c3f3dc5cd83d.png)

ファイルサイズの合計を見てみましょう

```elixir
all_contents_df["size"]
|> Series.sum()
```

![スクリーンショット 2022-12-03 0.27.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/35897fc6-04fc-9530-9dbe-1e3891429034.png)

およそ 26.5 MB です

続いて最大サイズ

```elixir
all_contents_df["size"]
|> Series.max()
```

![スクリーンショット 2022-12-03 0.29.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4f983368-b087-e150-702b-0df50db7b67c.png)

およそ 1.8 MB です

例えば 5,000 バイト以上のファイル一覧を見たい場合は

```elixir
all_contents_df
|> DataFrame.filter(size > 5_000)
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-03 0.31.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/638e6153-ed04-6eea-cb63-fc2f01e567f0.png)

2021/9/1 以降更新されていないファイルは

```elixir
all_contents_df
|> DataFrame.filter(last_modified < ~N[2021-09-01 00:00:00])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-03 0.32.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/824f3899-e14b-40e6-2cbb-9591bac83606.png)

一番上の階層毎のファイル数、ファイルサイズ合計は

```elixir
all_contents_df
|> then(fn df ->
  DataFrame.put(
    df,
    "dir",
    df["key"]
    |> Series.transform(fn input ->
      paths = String.split(input, "/")

      case Enum.count(paths) do
        1 ->
          ""

        _ ->
          Enum.at(paths, 0)
      end
    end)
  )
end)
|> DataFrame.group_by(["dir"])
|> DataFrame.summarise(size: sum(size))
|> DataFrame.arrange(desc: size)
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-03 0.34.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/899d26ca-83a5-8c6b-9f4f-80988ca54e5d.png)

こんな感じで色々分析できます

## ファイルアップロード

S3 にファイルをアップロードします

まず、アップロード用のファイル（いつもの Lenna さん）をインターネット上からダウンロードしてきます

```elixir
File.rm("Lenna_%28test_image%29.png")

lenna =
  Download.from("https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png")
  |> elem(1)
```

![スクリーンショット 2022-12-03 0.36.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/480a6a22-50ff-0753-5ce5-37b05c759a2d.png)

`lenna` がファイル名になりました

ファイル名を指定してファイルをアップロードします

```elixir
lenna
|> S3.Upload.stream_file
|> S3.upload(Kino.Input.read(bucket_name_input), "lenna.png")
|> ExAws.request!(auth_config)
```

![スクリーンショット 2022-12-03 0.37.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/de5f77dd-2d3d-6bb0-c8b0-a0e56dfbdf94.png)

status_code が 200 なので、成功したようです

続いて、インメモリに読み込んだバイナリデータをアップロードします

（これができれば、一々ローカルにファイルを保存せずにアップロードできます）

まず、ファイルを Evision で読み込みます

```elixir
mat = Evision.imread(lenna)
```

![スクリーンショット 2022-12-03 0.40.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9ebb83f2-74dd-7a36-94ff-04c2082c1bd0.png)

では、この `mat` を S3 にアップロードしましょう

`Evision.imencode` で画像をバイナリにして `S3.put_object` で S3 にアップロードします

```elixir
bucket_name_input
|> Kino.Input.read()
|> S3.put_object("lenna_2.png", Evision.imencode(".png", mat))
|> ExAws.request!(auth_config)
```

![スクリーンショット 2022-12-03 0.42.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e3ca7cfb-1b88-6dd2-74e0-9ce7b0433931.png)

こちらも成功したようです

確認のため、もう一度ファイル一覧を取得します

```elixir
bucket_name_input
|> Kino.Input.read()
|> S3LS.get_all_contents(auth_config)
|> DataFrame.new()
|> DataFrame.select(["key", "last_modified", "size"])
|> Kino.DataTable.new()
```

確かにアップロードできていますね

![スクリーンショット 2022-12-03 0.48.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9ff40f41-2c73-b146-4b49-572fdffe6144.png)

## ファイルダウンロード

今度はファイルをダウンロードしてみましょう

```elixir
bucket_name_input
|> Kino.Input.read()
|> S3.download_file("lenna.png", "lenna_downloaded.png")
|> ExAws.request!(auth_config)
```

ダウンロードできたか、ファイルを開いて確認します

```elixir
mat = Evision.imread("lenna_downloaded.png")
```

![スクリーンショット 2022-12-03 7.53.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e8273733-bf09-0eae-2738-f366dbb742b8.png)

画像を見たいだけでストレージに保存したくない場合は `S3.get_object` でバイナリを取得します

`Evision.imdecode` でバイナリを開けば画像が表示できます

```elixir
bucket_name_input
|> Kino.Input.read()
|> S3.get_object("lenna.png")
|> ExAws.request!(auth_config)
|> then(&Evision.imdecode(&1.body, Evision.Constant.cv_IMREAD_COLOR))
```

![スクリーンショット 2022-12-03 7.55.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c97360f8-50ca-1a35-fad5-95cdb5254bf9.png)

最後に、画像をバイナリでダウンロードして画像処理したのち、ストレージには保存せずアップロードします

今までの組み合わせですね

```elixir
bucket_name =
  bucket_name_input
  |> Kino.Input.read()

bucket_name
|> S3.get_object("lenna.png")
|> ExAws.request!(auth_config)
|> then(&Evision.imdecode(&1.body, Evision.Constant.cv_IMREAD_COLOR))
|> Evision.blur({9, 9})
|> then(&S3.put_object(bucket_name, "lenna_blur.png", Evision.imencode(".png", &1)))
|> ExAws.request!(auth_config)
```

![スクリーンショット 2022-12-03 7.59.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/cc67bb07-f9a5-e9e4-56b4-772efd9bdb74.png)

はい、できました

処理した画像を S3 から取得して見てみましょう

```elixir
bucket_name_input
|> Kino.Input.read()
|> S3.get_object("lenna_blur.png")
|> ExAws.request!(auth_config)
|> then(&Evision.imdecode(&1.body, Evision.Constant.cv_IMREAD_COLOR))
```

ちゃんと blur ぼかし処理されていますね

![スクリーンショット 2022-12-03 8.00.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/fb13bf11-8278-c07a-75bc-d32fb73f745a.png)

## まとめ

通常の Elixir プロジェクトでは　AWS の認証情報は config に記載しますが、 Livebook　だと認証情報直書きはまずいので、 `Kino.Input` を使ってリクエスト時に付加する方式にしました

今後も同じ方式で他の AWS サービスをバンバン Livebook から呼び出します

本当に Jupyter でやってたことなら Livebook で何でもできますね