Хранение файлов в S3 выглядит просто: добавляете объект в бакет по ключу и потом при необходимости удаляете или обновляете его. Но в реальной работе можно загрузить файл с помощью операции PutObject, не проверив, что в бакете уже лежит файл с этим ключом. В результате новое содержимое незаметно заменит старое. Или можно случайно удалить только что добавленный свежий бэкап вместо старого, что нарушает рабочий процесс. Чтобы избежать подобных ситуаций, в S3 есть условные операции записи (conditional write) — это когда действия вроде PutObject, CopyObject, DeleteObject или CompleteMultipartUpload выполняются только при соблюдении заданных условий.

Всем привет! Меня зовут Клюев Алексей, я старший разработчик S3-совместимого объектного хранилища в Selectel. В этой статье мы разберем, как работают условные заголовки, зачем они нужны и как применять их на практике. В качестве примеров будем использовать язык Go и aws-sdk-go v2.

Используйте навигацию, если не хотите читать статью полностью:

Часть работы в панели управления Selectel

В целом, здесь нам нужно просто создать и настроить бакет S3. Очень подробный пошаговый гайд вы найдете в статье моего коллеги. Я же перечислю только основные шаги.

1. Перейдите в панель управленияS3 и нажмите Создать бакет.

2. Выберите тип адресации vHosted. Что касается типа контейнера, то для работы с чувствительными данными подойдет приватный, а если планируете реализовать доступ к контенту без авторизации, выберите публичный.

3. Создайте сервисного пользователя с ролью «object_storage:admin» и доступом в нужный проект.

4. Создайте S3-ключ в панели управления.

5. Сохраните Access Key и Secret Key. Будьте внимательны: ключи не хранятся в наших системах и показываются только один раз.

Etag

Каждый объект, загруженный в S3, получает ETag — строку, которая в большинстве случаев является хэшем (MD5) содержимого объекта. Она служит своеобразным «отпечатком» данных и позволяет проверять, не изменился ли объект с момента последнего обращения.

Рассмотрим простой пример: загрузим объект и посмотрим его ETag:

# загрузим объект и посмотрим его etag
echo 'hello 1' | aws s3 cp - s3://conditional-write/hello.txt

#получим информацию об объекте
aws s3api head-object --bucket conditional-write --key hello.txt
{
    "AcceptRanges": "bytes",
    "LastModified": "Mon, 29 Sep 2025 16:33:27 GMT",
    "ContentLength": 8,
    "ETag": "\"99472f8d2f760bcc394e80d09275150a\"",
    "ContentType": "text/plain; charset=utf-8",
    "Metadata": {}
}

Бесплатное S3-хранилище на 30 дней

Для всех, кто ранее не использовал услугу в Selectel.

Оставить заявку →

Инициализация S3-клиента

Чтобы работать с S3 из Go, сначала нужно инициализировать клиента. Для этого мы будем использовать SDK от Amazon aws-sdk-go-v2. Ниже приведен пример вспомогательной функции NewS3Client, которую мы будем использовать в последующих примерах. Для инициализации SDK загружает настройки из стандартных файлов конфигурации (~/.aws/config и ~/.aws/credentials), проверяет регион и валидность учетных данных:

package main  
  
import (  
    "context"  
    "fmt"    
    "log"    
    "strings"  
    "github.com/aws/aws-sdk-go-v2/aws"    
    "github.com/aws/aws-sdk-go-v2/config"    
    "github.com/aws/aws-sdk-go-v2/service/s3"
    )  

// NewS3Client создает клиент S3 с указанными ключами
func NewS3Client(ctx context.Context, region string) (*s3.Client, error) {
    cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))  
    if err != nil {  
       return nil, fmt.Errorf("не удалось загрузить конфигурацию: %w", err)  
    }  
  
    // Проверяем валидность учетных данных  
    _, err = cfg.Credentials.Retrieve(ctx)  
    if err != nil {  
       return nil, fmt.Errorf("неверные учетные данные: %w", err)  
    }  
  
    return s3.NewFromConfig(cfg), nil  
}  
  
func main() {  
    ctx := context.Background()  
  
    bucket := "conditional-write"  
    region := "ru-7"
  
    client, err := NewS3Client(ctx, region)  
    if err != nil {  
       log.Fatal(err)  
    }
}

PutObject

Первая операция, с которой чаще всего сталкиваются при работе с S3, — PutObject. Важно понимать, что в S3 невозможно изменить содержимое существующего объекта. Любая запись — это фактически загрузка новой версии объекта под тем же ключом. И здесь легко столкнуться с неожиданной перезаписью данных.

Чтобы этого избежать, S3 поддерживает использование условных заголовков. Для PutObject это прежде всего два параметра:

  • If-None-Match — разрешает загрузку объекта, только если по указанному ключу объекта еще нет. В противном случае операция завершится ошибкой 412 Precondition Failed.

  • If-Match — разрешает загрузку объекта, только если ETag существующего объекта совпадает с переданным значением. Если объект изменился (ETag другой), загрузка не произойдет, и вы снова получите 412 Precondition Failed.

Пример с If-None-Match

Попробуем загрузить объект в бакет, разрешив это только в случае, если по ключу test.txt его еще нет:

func main() {  
    ctx := context.Background()  
  
    bucket := "conditional-write"  
    region := "ru-7"  
    key := "test.txt"  
    body := "hello"  
  
    client, err := NewS3Client(ctx, region)  
    if err != nil {  
       log.Fatal(err)  
    }  
  
	object, err := client.PutObject(ctx, &s3.PutObjectInput{  
	    Bucket:      aws.String(bucket),  
	    Key:         aws.String(key),  
	    Body:        strings.NewReader(body),  
	    IfNoneMatch: aws.String("*"),  
	})  
	if err != nil {  
	    log.Fatal(err)  
	}  
	  
	fmt.Printf("Объект успешно загружен: %s", *object.ETag)
}

Результат при двух последовательных запусках:

# Первый запуск
Объект успешно загружен: "5d41402abc4b2a76b9719d911017c592"
Process finished with the exit code 0

# Второй запуск
2025/09/29 22:04:50 operation error S3: PutObject, https response error StatusCode: 412, RequestID: 1bfd1ad9-d0cb-4211-8e23-a90f59f1cf93, HostID: , api error PreconditionFailed: At least one of the preconditions you specified did not hold.

Process finished with the exit code 1

Пример с If-Match

Теперь используем If-Match, чтобы обновить объект, только если он не изменился с момента последнего чтения. Для этого мы передаем известный ETag:

func main() {  
    ctx := context.Background()  
  
    bucket := "conditional-write"  
    region := "ru-7"  
    key := "test.txt"  
    body := "hello 1"  
  
    client, err := NewS3Client(ctx, region)  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    _, err = client.PutObject(ctx, &s3.PutObjectInput{  
       Bucket:  aws.String(bucket),  
       Key:     aws.String(key),  
       Body:    strings.NewReader(body),  
       IfMatch: aws.String("5d41402abc4b2a76b9719d911017c592"),  
    })  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    object, err := client.GetObject(ctx, &s3.GetObjectInput{  
       Bucket: aws.String(bucket),  
       Key:    aws.String(key),  
    })  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    objectBody, _ := io.ReadAll(object.Body)  
    fmt.Printf("Объект успешно загружен: %s (body=%q)", *object.ETag, objectBody)  
}

Успешный результат при совпадении ETag:

Объект успешно загружен: "df0649bc4f1be901c85b6183091c1d83" (body="hello 1")      
Process finished with the exit code 0

Если же указать неверный Etag, мы получим  ошибку 412 Precondition Failed:

2025/09/29 22:15:28 operation error S3: PutObject, https response error StatusCode: 412, RequestID: fd08857c-9c61-4aa1-bebe-52e32a71e97e, HostID: , api error PreconditionFailed: At least one of the preconditions you specified did not hold.

Process finished with the exit code 1

CopyObject

Операция CopyObject используется, когда нужно скопировать файл внутри одного бакета или между разными бакетами. При этом, как и с PutObject, можно столкнуться с ситуацией, когда данные были изменены другими процессами, и копирование приведет к неожиданному результату.

Условные заголовки позволяют выполнять копирование только при соблюдении определенных условий:

  • CopySourceIfMatch — скопировать объект, только если его ETag совпадает с указанным.

  • CopySourceIfNoneMatch — наоборот, скопировать, только если ETag не совпадает.

  • CopySourceIfModifiedSince — копировать объект, только если он был изменен после указанной даты.

  • CopySourceIfUnmodifiedSince — копировать, только если объект не изменялся с определенной даты.

Пример с CopySourceIfMatch

Допустим, в бакете уже есть объект test.txt. Мы хотим создать его копию под именем test-copy.txt, но только если содержимое исходного файла не менялось. Для этого используем CopySourceIfMatch с актуальным ETag:

func main() {  
    ctx := context.Background()  
  
    bucket := "conditional-write"  
    region := "ru-7"  
    key := "test.txt"  
    copyKey := "test-copy.txt"  
    client, err := NewS3Client(ctx, region)  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    _, err = client.CopyObject(ctx, &s3.CopyObjectInput{  
       Bucket:            aws.String(bucket),  
       CopySource:        aws.String(fmt.Sprintf("%s/%s", bucket, key)),  
       Key:               aws.String(copyKey),  
       CopySourceIfMatch: aws.String("df0649bc4f1be901c85b6183091c1d83"),  
    })  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    object, err := client.GetObject(ctx, &s3.GetObjectInput{  
       Bucket: aws.String(bucket),  
       Key:    aws.String(copyKey),  
    })  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    objectBody, _ := io.ReadAll(object.Body)  
    fmt.Printf("Объект успешно скопирован: %s (body=%q)", *object.ETag, objectBody)  
}

Успешное выполнение:

Объект успешно скопирован: "df0649bc4f1be901c85b6183091c1d83" (body="hello 1")
Process finished with the exit code 0

Если же указать неверный ETag, операция завершится ошибкой:

2025/09/29 22:25:07 operation error S3: CopyObject, https response error StatusCode: 412, RequestID: 73b833f3-e1c3-4e27-b873-a0bbdc854970, HostID: , api error PreconditionFailed: Precondition Failed

DeleteObject

Удаление объекта в S3 — одна из самых чувствительных операций, ведь потерянные данные восстановить уже не получится (если, конечно, не включены версии или другие механизмы защиты). Чтобы снизить риск случайного удаления актуального файла, в DeleteObject доступен условный заголовок:

  • If-Match - операция выполнится только в том случае, если текущий ETag объекта совпадает с указанным.

Таким образом можно гарантировать, что вы удаляете именно ту версию объекта, которую ожидали, а не уже обновленную кем-то другим.

Пример с If-Match

Удалим объект test-copy.tx, сначала указав неверный ETag, а затем корректный:

func main() {  
    ctx := context.Background()  
  
    bucket := "conditional-write"  
    region := "ru-7"  
    copyKey := "test-copy.txt"  
    client, err := NewS3Client(ctx, region)  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    _, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{  
       Bucket:  aws.String(bucket),  
       Key:     aws.String(copyKey),  
       IfMatch: aws.String("df0649bc4f1be901c85b6183091c1d83"),  
    })  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    fmt.Printf("Объект успешно удален: %s", copyKey)  
}

Если ETag неверный, получим уже знакомую ошибку:

2025/09/29 22:25:07 operation error S3: CopyObject, https response error StatusCode: 412, RequestID: 73b833f3-e1c3-4e27-b873-a0bbdc854970, HostID: , api error PreconditionFailed: Precondition Failed

При использовании правильного ETag объект удаляется успешно:

Объект успешно удален: test-copy.txt
Process finished with the exit code 0

CompleteMultipart

Последняя операция, которую рассмотрим, — CompleteMultipartUpload. Она завершает процесс загрузки больших файлов, которые разбиваются на части и отправляются в несколько запросов. После вызова этой операции части объединяются в один объект.

Как и в случае с PutObject и DeleteObject, здесь есть риск перезаписать существующий объект, если по ключу уже что-то лежит. Как вы, наверное, догадались, этот риск устраняется с помощью условных заголовков:

  • If-Match — завершить загрузку, только если текущий объект совпадает по ETag с указанным.

  • If-None-Match — завершить загрузку, только если по этому ключу еще нет объекта.

Таким образом, даже при работе с многокомпонентной загрузкой мы можем контролировать целостность и избежать неожиданных коллизий.

Пример с If-Match

В примере ниже создадим объект test-multipart.txt из трех частей и завершим загрузку с условием If-None-Match. Это позволит успешно загрузить объект в первый раз, но не даст перезаписать его во второй:

func main() {  
    ctx := context.Background()  
  
    bucket := "conditional-write"  
    region := "ru-7"  
    key := "test-multipart.txt"  
    client, err := NewS3Client(ctx, region)  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    multipartUpload, err := client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{  
       Bucket: aws.String(bucket),  
       Key:    aws.String(key),  
    })  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    parts := make([]types.CompletedPart, 0, 3)  
  
    for i := 1; i <= 3; i++ {  
       partNumber := int32(i)  
       part, err := client.UploadPart(ctx, &s3.UploadPartInput{  
          Bucket:     aws.String(bucket),  
          Key:        aws.String(key),  
          PartNumber: &partNumber,  
          UploadId:   multipartUpload.UploadId,  
          Body:       strings.NewReader(strconv.Itoa(i)),  
       })  
       if err != nil {  
          // client.AbortMultipartUpload(/.../)  
          log.Fatal(err)  
       }  
  
       parts = append(parts, types.CompletedPart{  
          ETag:       part.ETag,  
          PartNumber: &partNumber,  
       })  
    }  
  
    upload, err := client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{  
       Bucket:   aws.String(bucket),  
       Key:      aws.String(key),  
       UploadId: multipartUpload.UploadId,  
       MultipartUpload: &types.CompletedMultipartUpload{  
          Parts: parts,  
       },  
       IfNoneMatch: aws.String("*"),  
    })  
    if err != nil {  
       // client.AbortMultipartUpload(/.../)  
       log.Fatal(err)  
    }  
  
    fmt.Printf("Объект успешно загружен: %s (etag=%s)", *upload.Key, *upload.ETag)  
}

Результаты запуска:

# Первый запуск
Объект успешно загружен: test-multipart.txt (etag="78a31f6c0a4aef8ab2b9713cb9bd6c64-3")
Process finished with the exit code 0

# Второй запуск
operation error S3: CompleteMultipartUpload, https response error StatusCode: 412, ...
api error PreconditionFailed: At least one of the preconditions you specified did not hold.

Таким образом, даже при работе с MultipartUpload можно защитить данные от нежелательной перезаписи. Условные заголовки делают процесс более предсказуемым и позволяют писать надежный код, особенно в системах, где одновременно работают несколько клиентов.

Заключение

В статье я показал базовые примеры на Go с использованием aws-sdk-go-v2. На практике же условные заголовки поддерживаются всеми основными инструментами и клиентами для S3 — будь то aws cli, s3cmd, Rclone или файловые драйверы вроде s3fs. Это значит, что вы можете применять подходы conditional write не только в коде, но и в ежедневной работе с хранилищем.

Если у вас есть опыт использования условных операций или идеи по улучшению автоматизации работы с S3, делитесь ими в комментариях.

Кстати, уже в этом месяце мы откроем шестой пул S3-хранилища — ru-3 в регионе Санкт-Петербург (группа ЦОД «Цветочная»). Новый пул расширит географию размещения данных и позволит повысить отказоустойчивость и гибкость инфраструктуры. Следите за обновлениями — скоро мы расскажем подробнее.

Комментарии (4)


  1. s0borro
    15.10.2025 16:47

    Вот это материал! Вышка!


    1. kilgur
      15.10.2025 16:47

      У вас тэг sarcasm пропал куда-то... "Высосать" статью из 2х заголовков --- это "вышка", да.


      1. s0borro
        15.10.2025 16:47

        Это же первая статья автора) Везде могут быть изъяны) Как-то уж очень сурово высказались Вы


      1. s0borro
        15.10.2025 16:47

        Мне, например, как тому, кто не работал никогда с S3, было интересно)