Why NextWhy Next
К списку
💻 Разработка11 мин чтения

Если у одного бакета два владельца, побеждает тот, чей apply был последним

Я добавил одно lifecycle-правило для S3, и terraform apply упал. Причина: конфигурацией lifecycle одного и того же бакета владели два ресурса, каждый сам по себе. S3 lifecycle работает не на уровне отдельных правил, а на уровне документа целиком, поэтому последний победитель тихо стирает правила соперника.

Главное

Чтобы вернуть место, занятое объектами, у которых был presign, но так и не случился commit, я спроектировал prefix tmp/ и lifecycle-правило истечения. После мержа правила staging apply упал: модуль и настройки окружения владели lifecycle одного физического бакета каждый по отдельности. Lifecycle API в S3 заменяет документ целиком, поэтому два владельца при каждом apply перезаписывают правила друг друга. Ресурс на стороне модуля я вывел блоком removed (destroy = false), заставив terraform забыть его только в state, и оставил единственного владельца. У ресурса уровня документа владелец должен быть один.

Содержание

Началось всё с обычной задачи про уборку. Пользователи загружают медиафайлы (аудиозаписи и изображения) по схеме с presigned URL: сервер выдаёт URL для загрузки, клиент кладёт файл напрямую в S3, а затем вызывает commit API и сообщает: «зарегистрируй этот ключ как полноценный ассет». Проблема возникает, когда presign получен, а commit так и не пришёл. Приложение умерло на полпути, оборвалась сеть, пользователь ушёл с экрана - и в бакете остаётся объект, который нигде не зарегистрирован.

Я хотел вычистить это lifecycle-правилом, но повесить его было некуда. Объекты с commit и без него лежали вперемешку под одним prefix, так что правило «удаляй всё старое» снесло бы и полноценные ассеты. Дневная квота на загрузки сдерживала разрастание, но факт никуда не девался: пути возврата места не существовало.

Дизайн: всё, что без commit, живёт в tmp

Скелет решения - разделение неймспейса ключей.

  1. presign выдаёт временный ключ с prefix tmp/.
  2. Если commit проходит проверки (существование через HEAD, Content-Type, лимит размера), он повышает объект до постоянного ключа через CopyObject и удаляет оригинал в tmp.
  3. Объекты, до которых commit так и не добрался, остаются в tmp/, и lifecycle-правило удаляет их по истечении 7 дней.

Теперь lifecycle смотрит только на tmp/. Полноценные ассеты с самого начала вне зоны поражения. Клиентов менять не пришлось: я прочитал все три флоу загрузки и убедился, что каждый использует в последующих вызовах ключ из ответа commit, так что сервер может менять форму ключа незаметно для них. Commit по ключам старого формата, прилетевшим в момент деплоя, обрабатывается прежним путём как ни в чём не бывало.

Здесь притаилась первая ловушка. На этом бакете включено версионирование. В версионированном бакете expiration не удаляет объект, а лишь кладёт сверху маркер удаления; исходные байты остаются как noncurrent-версия. Без парного правила noncurrent_version_expiration (1 день) можно убираться сколько угодно, а занятый объём не уменьшится ни на байт.

Ревью дизайна тоже окупилось. Я повесил ревью на логику повышения, и оно нашло три подтверждённых дефекта. Дыра, через которую админский путь с кастомными ключами позволял закоммитить tmp/-ключ как финальный, после чего lifecycle стёр бы живой ассет (заблокировал tmp-финальные ключи на общей границе commit). Проблема, где sourceKey повышения превращался в примитив произвольного копирования (ввёл жёсткое соответствие между tmp-ключом и финальным). И проблема идемпотентности: при одновременном commit одного и того же tmp-ключа copy одной из сторон умирал с 404. Все три я починил в коде и пошёл дальше.

А потом apply упал

Я добавил lifecycle-правило в terraform и смержил. Staging apply упал.

Ошибка была про таймаут записи lifecycle-конфигурации, но, копнув глубже, я понял: таймаут - симптом, проблема в структуре. Lifecycle этого бакета принадлежал двум terraform-ресурсам сразу. Один жил внутри модуля ресайза изображений (модуль сам вешал правила под свои нужды), другой - в файле настроек бакетов для окружения (туда я и добавил tmp-правило). Один физический бакет, два владельца.

В lifecycle API у S3 нет операции «добавить одно правило». PutBucketLifecycleConfiguration заменяет документ целиком. Ресурс terraform aws_s3_bucket_lifecycle_configuration стоит поверх этого API, поэтому он перезаписывает весь lifecycle-документ бакета теми правилами, которые знает сам. Когда владельцев два, каждый заталкивает в бакет документ только со своими правилами. Встретятся в одном apply - конкурентная запись и таймаут. Встретятся порознь - ещё хуже: всё завершается успешно, без единой ошибки, и последний победитель стирает правила соперника.

Это самая жуткая часть всей истории. Таймаут - шумный отказ, и это, как ни странно, повезло. Живи эта структура дальше, любой следующий apply, задевший модуль ресайза изображений, тихо стёр бы только что добавленное правило истечения tmp. В plan это выглядело бы как одна строчка in-place update у ресурса бакета, и незакоммиченные объекты снова начали бы копиться вечно. Мониторинга, который заметил бы исчезновение правила уборки, не было нигде.

Справедливости ради, это место уже всплывало на код-ревью с подозрением «кажется, два ресурса трогают один и тот же бакет». Тогда я записал его в потенциальные риски; после мержа apply упал, и потенциальному риску хватило считаных минут, чтобы стать реальностью.

Решение: один владелец

Направление исправления было очевидным: у lifecycle бакета должен быть один владелец. Я убрал lifecycle-ресурс из модуля, сделал файл настроек окружения единственным владельцем и перенёс туда в том числе правила, которые раньше вешал модуль.

Инструментом здесь стал блок removed в terraform. Если просто стереть объявление ресурса из модуля, terraform попытается сделать destroy реальной lifecycle-конфигурации: возникнет окно, в котором работающие вживую правила удалены и ещё не созданы заново. Блок removed с destroy = false заставляет terraform забыть ресурс только в state. Реальный объект остаётся на месте, вычёркивается лишь запись в реестре, и владение переезжает в настройки окружения без простоя.

После исправления staging apply прошёл; докатив изменения до prod, я проверил фактическое состояние бакетов напрямую через AWS API. В обоих бакетах правило истечения tmp живо, три прежних правила целы. Почему я не остановился на terraform plan - этому научил сам инцидент. Plan - это лишь реестр каждого отдельного владельца, а в структуре, где документ перезаписывается целиком, два реестра друг о друге не знают.

Что осталось

У ресурса уровня документа должен быть один владелец. Это касается не только S3 lifecycle. Политика бакета, настройки CORS, настройки уведомлений - все terraform-ресурсы, стоящие поверх API «полной замены», устроены одинаково. Если объявления, указывающие на одну и ту же цель, есть и в модуле, и в настройках окружения, вместо ошибки компиляции вы получаете тихую перезапись в рантайме. Прежде чем добавлять новое правило, сначала выясните, нет ли у этого документа уже существующего владельца.

И ещё одно. Удалить объявление ресурса и удалить сам объект - разные вещи, и terraform выражает эту разницу блоком removed. Когда цель - передача владения, любой destroy по дороге ломает бесшовность.

Наконец, путь возврата места начинается с дизайна ключей. Когда данные с разным временем жизни, как «закоммиченное и незакоммиченное», живут в одном неймспейсе, ни одно правило уборки не повесить безопасно. Если время жизни разное, сначала разделите место жительства. К слову, этот подход не возвращает задним числом незакоммиченные объекты, накопленные раньше: их невозможно отличить от полноценных ассетов (с этого вся проблема и началась), поэтому я осознанно ограничил охват новыми загрузками.

Частые вопросы

Правила S3 lifecycle исчезают после terraform apply. Почему так происходит?

aws_s3_bucket_lifecycle_configuration - это ресурс, который владеет всем документом lifecycle-конфигурации бакета. Если таких ресурсов, указывающих на один бакет, два (например, один в модуле и один в настройках окружения), каждый заменяет весь документ только теми правилами, которые знает сам, и тот, чей apply прошёл последним, стирает правила другого. Если оба оказываются в одном apply, конкурентная запись может закончиться таймаутом. Сведите владение к одному ресурсу на бакет.

Я повесил lifecycle-истечение на версионированный S3-бакет, но занятый объём не уменьшается.

В версионированном бакете expiration не удаляет объект: он лишь добавляет маркер удаления и превращает существующую версию в noncurrent. Если не повесить рядом noncurrent_version_expiration, байты не будут возвращены никогда. Правило истечения и истечение noncurrent-версий нужно проектировать парой.

Похожие статьи

Комментариев: 0