
Хранение файлов в 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 в регионе Санкт-Петербург (группа ЦОД «Цветочная»). Новый пул расширит географию размещения данных и позволит повысить отказоустойчивость и гибкость инфраструктуры. Следите за обновлениями — скоро мы расскажем подробнее.
s0borro
Вот это материал! Вышка!
kilgur
У вас тэг sarcasm пропал куда-то... "Высосать" статью из 2х заголовков --- это "вышка", да.
s0borro
Это же первая статья автора) Везде могут быть изъяны) Как-то уж очень сурово высказались Вы
s0borro
Мне, например, как тому, кто не работал никогда с S3, было интересно)