S3での排他制御をする方法を考える

## はじめに

S3はオブジェクトストレージなので、基本的には排他制御の機能がありません。
しかし、複数のLambdaで同じJSONを更新することがあるような構成にしていると、
他のプロセスが同時に同じファイルを更新しないようにしたいとなる場合があります。

今回は2024年8月にサポートが開始されたS3の条件付き書き込みを用いて排他制御のような機能が実現できるかを調査・検証しました。

https://aws.amazon.com/jp/about-aws/whats-new/2024/08/amazon-s3-conditional-writes/

## 排他制御とは

排他制御とは複数の処理が同時に同じリソースにアクセスされて、想定外のリソース操作がなされることを制御する仕組みのことです。
主に並行処理やマルチスレッド処理の場面で使われます。

さらに、排他制御には悲観的排他制御と楽観的排他制御の二つがあります。

### 悲観的排他制御とは

悲観的排他制御は「同時に更新がされることは往々にして考えられるからはじめから処理の始めに更新対象のオブジェクトにロックをかけて他には更新ができないようにする」というものです。
処理の始めにロックをかけてしまうので、同じオブジェクトへの更新処理の実行も1プロセスからしか行えなくなります。

ロックを取得できなかったプロセスに関してはロックをかけたプロセスの実行が終わるまで処理を待つ必要があります

### 楽観的排他制御とは

楽観的排他制御はざっくりといえば、「同時に更新されることなんてそうそうないでしょ。念の為更新時に確認するよー」的な排他制御です。

更新前のオブジェクトの1要素を掴んでおいて、更新時にその値が変わっていないかをチェックすることで、別のクライアントが先に別の更新をしたかをチェックするというものです。

そのため、"更新前のオブジェクトの1要素"はかならず、更新ごとに変わる値でチェックしなければなりません。

## 実装

ではこれをどのようにS3で実現すれば良いでしょうか？
今回は冒頭で書いた通りS3の条件付き書き込みの機能を使って実現します。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/conditional-writes.html

>If-Match ヘッダーを使用した条件付き書き込み
If-Match ヘッダーは、バケット内の既存のオブジェクトに対して評価されます。同じキー名を持ち、ETag が一致する既存のオブジェクトがある場合、書き込みオペレーションは成功し、200 OK レスポンスが返されます。ETag が一致しない場合、書き込みオペレーションは 412 Precondition Failed レスポンスで失敗します。

>If-None-Match ヘッダーを使用した条件付き書き込み
If-None-Match ヘッダーを使用した条件付き書き込みは、バケット内の既存のオブジェクトに対して評価を実行します。バケットに同じキー名を持つ既存のオブジェクトがない場合、書き込みオペレーションは成功し、200 OK レスポンスが返されます。既存のオブジェクトがある場合、書き込みオペレーションは失敗し、412 Precondition Failed レスポンスが返されます。


ということで、悲観的排他制御は`If-None-Match`を使って、
lockファイルを作成できたクライアントだけ、メインの処理ができるようにします。

一方で楽観的排他制御は`If-Match`を使ってputの時にあらかじめつかんでおいた更新前のEtagが今存在しているオブジェクトの値と等しいことを確認してputできるようにします。


以下にサンプルコードを記しますが、あくまでサンプルコードに留めているため、エラーハンドリング・リトライ処理については実装しておりません。
また、s3への処理はクラス化して扱いやすくしていますが文末にクラスの実装を追記しておきます。

### 実装(悲観的排他制御)

最初に悲観的排他制御の実装です。
はじめに.lockファイルの作成でロックを行います。
この際にIfNoneMatch: "*"を指定することで.lockが存在していない時にしかputが成功しません。

そのため、ロックを取得できたプロセス以外のプロセスはwhile文でputできるまでループします。
そのため、ロック取得後の処理は1つのプロセスからしか操作されずに安全に更新できるというわけです。





```ts
while (true) {
  try {
    // ロックの取得
    await s3.put({
      // ${kid}.lockが作成されていなければこのputが成功
      Key: `.lock`,
      IfNoneMatch: "*",
      Body: "\n",
    });
    break;
  } catch (e) {
    // PreconditionFailedでエラーになったらリトライ
    continue;
  }
}

// ここの処理は1つしか行われない
await s3.put({
  Key: PUT_KEY,
  Body: key,
});

// ロックの解放
await s3.delete('.lock');
```

### 実装(楽観的排他制御)

続いて楽観的排他制御です。
楽観的排他制御はputの時にあらかじめつかんでおいた更新前のEtagが今存在しているオブジェクトの値と等しいことを確認してputできるようにするのでした。

まずは存在していないとき(初めて作るとき)は"更新前のEtag"という概念がないので普通にputします。
ただ、存在確認 => putの間に他プロセスが同じKeyに書き込みをしたら、意図せず上書きされる可能性があるので、
初回のputではIfNoneMatchを使ってobjectが存在していないときにのみputできるようにします。




```ts
while (true) {
  try {
    // そもそも存在しないなら作る
    const isExist = await s3.isObjectExist(PUT_KEY);
    if (!isExist) {
      await s3.put({
        Key: PUT_KEY,
        Body: key,
        // TOCTOU問題回避
        IfNoneMatch: '*'
      });
      continue;
    }

    // putするKeyのEtagを取得
    const preEtag = await s3.getEtag(PUT_KEY);
    await s3.put({
      Key: PUT_KEY,
      Body: key,
      // 掴んだEtagが変わっていないことを確認
      IfMatch: preEtag
    })
    break;
  } catch {
    // PreconditionFailedでエラーになったらリトライ
    continue;
  }
}
```

### 参考：上記のs3のクラス

```ts
import {
  DeleteObjectCommand,
  GetObjectCommand,
  HeadObjectCommand,
  PutObjectCommand,
  PutObjectCommandInput,
  S3Client,
  S3ServiceException,
} from "@aws-sdk/client-s3";

export class S3 {
  private client: S3Client;
  public bucketName: string;
  constructor(bucketName: string) {
    const client = new S3Client({});
    this.client = client;
    this.bucketName = bucketName;
  }

  getEtag = async (objectKey: string) => {
    return (
      await this.client.send(
        new HeadObjectCommand({
          Bucket: this.bucketName,
          Key: objectKey,
        }),
      )
    ).ETag;
  };

  isObjectExist = async (objectKey: string): Promise<boolean> => {
    try {
      await this.client.send(
        new HeadObjectCommand({
          Bucket: this.bucketName,
          Key: objectKey,
        }),
      );
      return true;
    } catch (e) {
      if (!(e instanceof S3ServiceException)) {
        throw e;
      }
      if (e.name === "NotFound" || e.$metadata.httpStatusCode === 404) {
        return false;
      }
      throw e;
    }
  };

  put = async (props: Omit<PutObjectCommandInput, "Bucket">) => {
    await this.client.send(
      new PutObjectCommand({
        ...props,
        Bucket: this.bucketName,
      }),
    );
  };

  delete = async (key: string) => {
    await this.client.send(
      new DeleteObjectCommand({
        Bucket: this.bucketName,
        Key: key,
      }),
    );
  };
}
```

## 懸念点

ここまで排他制御を紹介してきましたが、以下のような懸念点が考えられるため注意が必要です。

### 悲観的排他制御
- .lock ファイルが処理中に例外などで削除されないと、他のプロセスは永遠に待ち続ける状態になってしまい、一生更新できなくなってしまう
- 同時に1プロセスしか処理させないため、待機時間が長くなってしまう恐れ


### 楽観的排他制御

- 他のプロセスと頻繁に競合することが予想される場面ではPrecondition Failed が頻繁に起こり、複数リトライされる
    - 実運に持って行く際はリトライの際に2秒待機、4秒待機のように指数的にリトライ待機時間を増やしていく指数バックオフでのリトライなどの考慮が必要になりそう
- 通常ETagはオブジェクトbodyのMD5だが、マルチパートアップロードが行われる場合やオブジェクトが暗号化されてる場合はアップロードされているobject bodyとは異なるため、ETagが一意でなくなる可能性がある

## 終わりに

S3で排他制御をしたくなった際にお役に立てれば幸いです。
もっとよい方法があればコメントいただければうれしいです！