Livebook のスポンサーである Tigris を使ってみる -S3 互換のキャッシュ付クラウドストレージ-

## はじめに

2024/3/26 の Livebook ブログで紹介されている Tigris を使ってみます

https://news.livebook.dev/welcoming-tigris-as-a-new-sponsor-of-livebook-1rsFQk

Tigris は Livebook のスポンサーにもなっているので、私としても応援したいですね

https://github.com/livebook-dev/livebook?tab=readme-ov-file#sponsors

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

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

## Tigris とは

https://www.tigrisdata.com/

> Tigris is a globally distributed S3-compatible object storage service that provides low latency anywhere in the world, enabling developers to store and access any amount of data for a wide range of use cases.

和訳

> Tigrisは、グローバルに分散されたS3互換のオブジェクトストレージサービスで、世界中どこでも低遅延で利用できます。これにより、開発者は多様な用途に応じてあらゆる量のデータを保存およびアクセスすることが可能です。

Tigris は Amazon S3 と互換性のあるストレージサービスです

そのため、 AWS CLI や、各言語の AWS SDK から S3 と全く同じように使うことができます

https://aws.amazon.com/jp/pm/serv-s3/

S3 互換という点では以前紹介した Cloudflare R2 と同じですね

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

Elixir の場合は ExAWS から操作できます

https://github.com/ex-aws/ex_aws

最大の特徴は CDN （Contents Delivery Network）がデフォルトで付いている点です

ファイルは地理的に最も近いデータセンターからダウンロードされ、2回目以降のアクセスではキャッシュが使われるため高速にアクセス可能です

S3 の場合は CloudFront を使う必要がありますが、 Tigris は特に何もしなくても CDN が付いてきます

## Tigris の料金体系

Tigris では保存するデータ容量、リクエスト数で従量課金されます

私の感覚としては、他のサービスと比べても安くなっているように思います

https://www.tigrisdata.com/docs/pricing/

| 項目 | 料金体系 |
| :-- | ------: |
| 保存容量 | $0.02/GB/month |
| PUT, COPY, POST, LIST リクエスト | $0.005/1000 requests |
| GET, SELECT, その他のリクエスト   | $0.0005/1000 requests |
| データ転送 | $0.00/GB |

データ転送で課金されない点も要注目です

他のストレージサービスでは「どれくらいアップロード・ダウンロード・リージョン間移動したか」でも課金されますが、ここが 0 なのは珍しいです

https://www.tigrisdata.com/docs/pricing/#data-transfer-pricing

また、無料利用枠も用意されています

https://www.tigrisdata.com/docs/pricing/#free-allowances

- 毎月5GBのデータストレージ
- 月間 10,000 件の PUT, COPY, POST, LIST リクエスト
- 月間 100,000 件の GET, SELECT, その他のリクエスト

## Tigris のはじめ方

公式サイトの右上、 "Dashboard" をクリックします

https://www.tigrisdata.com/

![スクリーンショット 2024-06-28 13.38.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/6ba5092e-ec0c-96f6-b8d2-eb3e29d0adac.png)

Fly.io のサインインを求められます

実は Tigris には Fly.io のアカウントでサインインするようになっています

![スクリーンショット 2024-06-28 13.40.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/897a34a9-d530-0af8-7b95-e37936dcbc5b.png)

サインアップ、サインインすると Tigris のダッシュボードが開きます

![スクリーンショット 2024-06-28 13.42.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/14926e94-298a-1226-31ea-f798b824b858.png)

S3 と同じようにバケット内でオブジェクトを管理します

`Create a new bucket` をクリックしてバケットを作ってみましょう

モーダルでバケット名を入力し、 `Create` をクリックします

![スクリーンショット 2024-06-28 13.45.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2762ff89-8033-b408-825e-63706e754fdb.png)

バケットが作成されました

![スクリーンショット 2024-06-28 13.46.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/aa73cd3c-6e16-e449-1b55-48976dcc7c80.png)

## アクセスキーの作成

左メニューの "Access Keys" からアクセスキーの一覧を表示します

![スクリーンショット 2024-06-29 8.31.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7a41f1ef-74fb-dbcd-0c35-5a9b84ae5901.png)

右上 "Create New Access Key +" をクリックするとアクセスキー作成モーダルが開きます

アクセスキー名を入力し、バケットに対する権限を選択してから "Create" をクリックします

下画像の例では全バケットの更新権限を与えています

![スクリーンショット 2024-06-29 8.32.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/dd7d95c7-8327-bb20-0952-6c8a009ad65e.png)

アクセスキーが作成され、以下の情報が表示されます

- Access Key ID
- Secret Access Key
- Endpoint URL S3
- Region

注意文言にある通り、二度と Secret Access Key は表示されないため、どこか安全な場所に保存しておきましょう

![スクリーンショット 2024-06-29 8.33.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/fdd85e4c-733f-3d71-de05-4bd1edbfc3a9.png)

閉じるとアクセスキー一覧に戻ります

![スクリーンショット 2024-06-29 8.35.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e5d6517c-06a3-9e67-40b5-17aea196209f.png)

## ブラウザからのオブジェクト操作

バケット一覧でバケット名をクリックすると、バケット内のファイル一覧に遷移します

最初は何もファイルがない状態です

![スクリーンショット 2024-06-29 8.36.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3a0b0418-4a66-ff71-1b17-1f8834395549.png)

"Upload" ボタンをクリックするとモーダルが開きます

![スクリーンショット 2024-06-29 8.36.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/58eaf7a5-3c60-7f3c-baff-d61cb67f9053.png)

ファイルをドラッグ&ドロップして選択し、右下 "Upload" ボタンをクリックします

![スクリーンショット 2024-06-29 8.37.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2086c317-4482-ca43-a0c3-421bf439034a.png)

一覧にファイルが追加されました

![スクリーンショット 2024-06-29 8.37.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/45c37a50-d066-89f4-3a8f-8bbbd0e9a548.png)

ファイル名の右にあるアイコンから以下の操作が可能です

- アップロード用署名付リンク作成
- ダウンロード用署名付リンク作成
- ダウンロード
- 削除

リンクを作成する際やダウンロードを実行する際はアクセスキーを選択します

まだアクセスキーを作っていない場合は作成します

![スクリーンショット 2024-06-29 8.38.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1805c1e7-ce15-8ce4-8c70-8a5dd0e82942.png)

## AWS CLI からオブジェクトを操作する

以下のように環境変数を設定します

:::note info
コマンドは macOS や Linux の場合です
:::

```bash
export AWS_ACCESS_KEY_ID=<Tigris のアクセスキー ID>
export AWS_SECRET_ACCESS_KEY=<Tigris のシークレットアクセスキー>
export AWS_ENDPOINT_URL_S3=https://fly.storage.tigris.dev
export AWS_REGION=auto
```

以下のようにバケット一覧が取得できます

```bash
$ aws s3api list-buckets
{
    "Buckets": [
        {
            "Name": "rwakabay",
            "CreationDate": "2024-06-28T04:46:02+00:00"
        }
    ],
    "Owner": {
        "DisplayName": "tid_XXX",
        "ID": "tid_XXX"
    }
}
```

その他も S3 と同様に操作できます

アップロード

```bash
$ aws s3 cp ~/Downloads/ryo-000.png s3://rwakabay/
upload: Downloads/ryo-000.png to s3://rwakabay/ryo-000.png
```

一覧取得

```bash
$ aws s3 ls s3://rwakabay/
2024-06-29 09:07:08     249972 ryo-000.png
```

ダウンロード

```bash
$ aws s3 cp s3://rwakabay/ryo-000.png .
download: s3://rwakabay/ryo-000.png to ./ryo-000.png
```

## Livebook からのオブジェクト操作

Elixir から操作する場合も S3 や R2 と全く同じに扱えます

### セットアップ

S3 を操作するときと同じく、 `ex_aws` と `ex_aws_s3` をインストールします

https://github.com/ex-aws/ex_aws

https://github.com/ex-aws/ex_aws_s3

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

```elixir
Mix.install([
  {:ex_aws, "~> 2.5"},
  {:ex_aws_s3, "~> 2.4"},
  {:poison, "~> 5.0"},
  {:hackney, "~> 1.20"},
  {:sweet_xml, "~> 0.7"},
  {:explorer, "~> 0.8"},
  {:evision, "~> 0.2"},
  {:req, "~> 0.5"},
  {:kino, "~> 0.13"}
])
```

エイリアスやマクロの準備をしておきます

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

### 認証情報の入力

認証情報入力用の UI を作ります

Livebook の Secrets を使っても OK です

```elixir
access_key_id_input = Kino.Input.password("ACCESS_KEY_ID")
secret_access_key_input = Kino.Input.password("SECRET_ACCESS_KEY")
endpoint_host_input = Kino.Input.text("ENDPOINT_HOST")

[
  access_key_id_input,
  secret_access_key_input,
  endpoint_host_input
]
|> Kino.Layout.grid(columns: 3)
```

表示された入力エリアに Tigris 用のアクセスキーID、シークレットアクセスキー、エンドポイントのホスト名（エンドポイント URL の `http://` を除いたもの）を入力します

![スクリーンショット 2024-06-29 9.36.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/0d0a9a19-64ae-b79a-3b1c-5703dc1b2d7f.png)

以下のコードで接続用の認証情報を設定します

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

Kino.nothing()
```

:::note info
**ここから先は S3, R2 と全く同じコードです**
:::

### バケット一覧の取得

S3 のときと同様、 S3.list_buckets() でバケットの一覧が取得できます

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

実行結果
```elixir
{:ok,
 %{
   body: %{
     owner: %{
       id: "tid_XXX",
       display_name: "tid_XXX"
     },
     buckets: [%{name: "rwakabay", creation_date: "2024-06-28T04:46:02Z"}]
   },
   headers: [
     {"Access-Control-Allow-Headers", "*"},
     {"Access-Control-Allow-Methods", "*"},
     {"Access-Control-Allow-Origin", "*"},
     {"Access-Control-Expose-Headers", "*"},
     {"Content-Length", "353"},
     {"Content-Type", "application/xml"},
     {"Server", "Tigris OS"},
     {"Server-Timing", "total;dur=246"},
     {"Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload"},
     {"X-Amz-Request-Id", "1719620781158576483"},
     {"Date", "Sat, 29 Jun 2024 00:26:21 GMT"}
   ],
   status_code: 200
 }}
```

以下のようにしてバケット一覧を表形式で表示できます

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

### ファイル一覧の取得

バケット名を入力します

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

![スクリーンショット 2024-02-09 17.19.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1f46e137-ab7e-ce72-ab65-3b8bc584b88f.png)

`S3.list_objects_v2` でファイル一覧が取得できます
`max_keys` で一度に取得するファイル数（1 ページ分のファイル数）を指定しています

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

実行結果
```elixir
{[
   %{
     owner: %{id: "", display_name: ""},
     size: "249972",
     key: "ryo-000.png",
     last_modified: "2024-06-29T00:07:08Z",
     storage_class: "STANDARD",
     e_tag: "\"d10f45f7c326ecbd54b34db3b2055b5e\""
   },
   ...
   ],
 "xxx"}
```

取得した `next_continuation_token` を使って、次のページを取得することができます

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

全ファイルの一覧を取得するモジュールは以下のようになります

```elixir
defmodule S3LS do
  def get_contents(continuation_token, bucket_name, auth_config) do
    bucket_name
    |> S3.list_objects_v2(max_keys: 10, 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
```

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

アップロードする画像ファイルを Web からダウンロードしておきます

```elixir
ryo_path = "ryo-wakabayashi.jpg"

"https://www.elixirconf.eu/assets/images/ryo-wakabayashi.jpg"
|> Req.get!(into: File.stream!(ryo_path))
```

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

`S3.Upload.stream_file` と `S3.upload` でファイルをアップロードできます

```elixir
ryo_path
|> S3.Upload.stream_file()
|> S3.upload(Kino.Input.read(bucket_name_input), "ryo-wakabayashi.jpg")
|> ExAws.request!(auth_config)
```

#### インメモリのアップロード

画像ファイルを evision で読み込んでおきます

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

`S3.put_object` でインメモリのバイナリデータをアップロードできます

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

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

#### ローカルファイルへのダウンロード

`S3.download_file` で R2 上のファイルをダウンロードできます

```elixir
bucket_name_input
|> Kino.Input.read()
|> S3.download_file("ryo-wakabayashi.jpg", "ryo_downloaded.jpg")
|> ExAws.request!(auth_config)
```

#### インメモリへのダウンロード

`S3.get_object` でバイナリデータとしてダウンロードできます

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

## まとめ

Tigris を S3 や R2 と全く同じように操作することができました

料金も安く、 CDN も兼ねているため、画像などのコンテンツ配信には便利そうです