Секреты и переменные окружения для serverless-функций

Почему serverless-секреты должны жить в per-function конфигурации и внедряться как env vars — вне кода, промптов и клиентских бандлов. Граница auth-против-config и честные рамки: хранилище конфигурации, а не менеджер секретов.

Секреты и переменные окружения для serverless-функций

Любой serverless-функции, которая делает что-то полезное, рано или поздно требуется учётная запись доступа: ключ OpenAI, секрет Stripe, строка подключения к базе, секрет для проверки подписи вебхука. Интересный вопрос не в том, есть ли у вас секреты — они есть, — а в том, где они живут и как попадают в код, который их использует. Ошибитесь с этой границей — и ключ окажется в истории git, в промпте модели или в клиентском бандле, где до чужого ключа остаётся один скриншот. Проведите её правильно — и секреты остаются скучными: заданы один раз, внедряются в рантайме, никогда не печатаются в логах.

Этот пост — о прагматичном и правильном значении по умолчанию для serverless-секретов в Inquir Compute: о per-function переменных окружения, внедряемых в момент вызова. И, что не менее важно, о честности относительно границ этого механизма. Это хранилище конфигурации для ваших функций, а не самостоятельный корпоративный менеджер секретов, и попытка использовать его как второе вас подведёт.

Serverless-секреты место в конфигурации, а не в коде

Есть ровно три места, где секрета быть не должно никогда:

  1. В закоммиченных файлах. Ключ, зашитый в обработчик, — это ключ в истории git навсегда. Переписывание истории не «расклонирует» репозиторий, который уже стянули. К тому же такой ключ попадает в каждый артефакт сборки.
  2. В промпте модели. Если вы отправляете "authenticate with sk-..." в LLM, эта строка уходит к провайдеру модели, логируется на его стороне и часто возвращается бумерангом через ваши же трейсы и инструменты наблюдаемости. Промпт — наименее приватная поверхность в AI-приложении; относитесь ко всему, что в него кладёте, как к публичному.
  3. В клиентском бандле. Всё, что скачивает браузер, доступно для чтения. Переменные вида NEXT_PUBLIC_*, значения, встроенные при сборке фронтенда, секреты, возвращённые в ответе API и отрисованные клиентом, — всё это по сути опубликовано.

Альтернатива скучна в самом хорошем смысле. Секрет живёт в per-function конфигурации, внедряется как переменная окружения в рантайме, а обработчик читает его из process.env. Значение не появляется в дереве исходников, не едет в браузер и не должно быть в промпте. В этом вся суть переменных окружения для serverless-функций: отделить код (который вы версионируете и деплоите) от конфигурации (специфичной для окружения и чувствительной) и позволить платформе соединить их в самый последний момент — в момент вызова.

Переменные окружения для serverless-функций, по одной функции за раз

В Inquir Compute секреты привязаны к конкретной функции как её envVars. Задать их можно двумя способами: в редакторе через Config → Environment или через API. Форма API намеренно ничем не примечательна:

// Per-function env vars (Config → Environment in the editor, or API):
await api.updateFunction("function-id", {
  envVars: {
    OPENAI_API_KEY: "sk-...",
  },
});

// Values are stored on the function and injected into the container at invoke time

Два свойства этой модели стоит усвоить.

Область видимости — функция. Это per-function serverless env vars, а не глобальный мешок переменных, общий для всего аккаунта. Каждая функция несёт свой набор. Это преимущество, а не ограничение: радиус поражения любого отдельного секрета — одна функция, и рассуждать о том, «до чего может дотянуться этот код», можно по одной панели конфигурации, а не по общему хранилищу.

Внедрение происходит в момент вызова, а не при деплое. Значения хранятся в записи функции и попадают в контейнер только тогда, когда функция реально исполняется. Ваш вывод сборки их не содержит; ваш деплой-бандл их не содержит. Если на развёртывании настроен ENCRYPTION_KEY, хранимые значения зашифрованы в покое (об этой честной оговорке — ниже). А поскольку платформа посредничает при каждом чтении, она может делать за вас полезные вещи — например, скрывать значение везде, где UI могло бы его показать, а также в логах и трейсах.

Это работает одинаково во всех поддерживаемых рантаймах — Node.js 22, Python 3.12, Go 1.22 — потому что переменные окружения это наименьший общий знаменатель, понятный любому языку. Ничего платформенно-специфичного импортировать не нужно: process.env, os.environ и os.Getenv просто работают.

Чтение serverless env vars в обработчике (и чего делать нельзя)

Вот реалистичный обработчик: AI-суммаризатор, который читает промпт из запроса, вызывает модель и возвращает результат. Единственный нужный ему секрет — OPENAI_API_KEY — приходит из env функции, и код относится к нему как к «только для чтения» и «непечатаемому».

export async function handler(event, context) {
  // OPENAI_API_KEY comes from function env — avoid logging it
  const payload = typeof event.body === 'string' ? JSON.parse(event.body || '{}') : (event || {});
  const { prompt } = payload;
  const openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });

  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: prompt }],
  });

  return {
    statusCode: 200,
    body: JSON.stringify({ result: completion.choices[0].message.content }),
  };
}

Обратите внимание, чего обработчик не делает. Он не кладёт ключ в массив messages. Он не возвращает ключ в теле ответа. Он не логирует его. Пользовательский prompt уходит в модель; API-ключ уходит в конструктор SDK и больше никуда. Это разделение — пользовательские данные внутрь, учётные данные держим приватно, наружу отдаём только производный результат — и есть вся дисциплина целиком.

Сценарии провала достаточно распространены, чтобы выписать их отдельным списком «так нельзя». Каждая строка ниже — реальный способ слить ключ:

// DON'T: hard-code the key — it lands in git history and the build artifact
const openai = new OpenAI({ apiKey: "sk-proj-abc123realkey" });

// DON'T: put the secret in the model prompt — it leaks to the provider and its logs
const messages = [{ role: "system", content: "Authenticate with sk-proj-abc123realkey" }];

// DON'T: return it to the caller or ship it in a client bundle
return { statusCode: 200, body: JSON.stringify({ key: process.env.OPENAI_API_KEY }) };

// DON'T: log it — even to debug
console.log("calling openai with", process.env.OPENAI_API_KEY);

Практическое правило: секрет должен входить в функцию через process.env и покидать её только внутри заголовка Authorization к тому сервису, которому он принадлежит. Любой другой выход — строка лога, поле ответа, строка промпта — это утечка. Ещё один практический момент: исходящая сеть по умолчанию отключена, поэтому, чтобы этот обработчик достучался до OpenAI, вы явно включаете для функции egress — небольшой, но реальный выигрыш в эшелонированной защите.

Граница auth-против-config: входящие ключи против исходящих секретов

Самое полезное различение во всей теме — между ключом, который охраняет вашу функцию, и секретами, которые ваша функция несёт. И то и другое — «API-ключи», именно поэтому их путают, и именно эта путаница порождает настоящие баги.

Входящая аутентификация — про то, кому разрешено вызывать вашу функцию. В Inquir Compute каждый маршрут шлюза задаёт свой режим auth: none, api-key (заголовок X-Api-Key) или bearer. Платформенные API-ключи — это 32 байта base64url, хранимые только как SHA-256-хеш и показываемые ровно один раз при создании. Маршрут можно ограничить конкретными ключами через allowedApiKeyIds, а ключи с областью деплоя отклоняются на шлюзе. Это аутентификация вызывающих — парадная дверь.

Исходящая конфигурация — про то, какие учётные данные ваша функция предъявляет другим сервисам. Ваш OPENAI_API_KEY, ваш секрет Stripe, пароль от базы — это ключи, которые несёт ваш код, когда обращается вовне. Они живут в envVars, внедряются в рантайме, читаются из process.env.

Эти две вещи никогда не соприкасаются. X-Api-Key, который клиент присылает, чтобы вызвать ваш маршрут шлюза, — это не ваш ключ OpenAI, и его нельзя использовать как таковой. Ваш ключ OpenAI — это не то, что вы когда-либо отдаёте вызывающему. Когда граница чёткая — входящие ключи аутентифицируют тех, кто обращается к вам, исходящие секреты аутентифицируют вас перед сервисами, к которым обращаетесь вы, — целый класс ошибок «стоп, а это какой ключ?» просто исчезает. Если вы строите бэкенд для AI-агента, именно эта граница позволяет управлять тем, кто может запустить инструмент (входящий api-key на маршруте), отдельно от того, какие учётные данные инструмент использует для работы (исходящий OPENAI_API_KEY в env).

Изоляция по функциям и автоматическая редакция

Поскольку Inquir Compute запускает один контейнер на функцию, секреты изолированы естественным образом. envVars функции видны только внутри контейнера этой функции в момент вызова. Нет общего глобального окружения, которое наследует каждая функция, и — что важно и честно — нет механизма перекрёстных ссылок между функциями: функция B не может прочитать секреты функции A, сославшись на них. Если двум функциям нужен один и тот же ключ, вы задаёте его на обеих — чуть более ручной труд и правильный компромисс: изоляция по умолчанию лучше общего пространства имён, которое тихо расширяет радиус поражения каждого секрета.

Поверх изоляции платформа активно работает, чтобы держать секреты подальше от поверхностей, где они склонны утекать:

  • Клиент всегда видит [redacted]. Когда вы открываете панель конфигурации, хранимые значения секретов не отправляются в браузер в открытом виде — вы видите заглушку-редакцию. Повторное сохранение формы с оставленным [redacted] сохраняет существующее значение, а не перезаписывает его этой буквальной строкой. По сути хранилище доступно в основном на запись: значение можно задать, но нельзя прочитать обратно через UI.
  • Логи и трейсы редактируются по ключу и по паттерну. Значения, чьи ключи выглядят чувствительно (password, secret, token, api-key и подобные), и значения, совпадающие с известными формами секретов (например, токен Bearer), вычищаются из хранимых логов и трейсов. Это страховочная сетка, а не индульгенция на небрежность — секрет всё равно логировать нельзя, — но она означает, что случайный console.log не того объекта с меньшей вероятностью зафиксирует живой ключ в ваших данных наблюдаемости.

Изоляция плюс редакция — хорошая базовая линия. Но это не полноценный продукт управления секретами, и это подводит нас к самой важной части поста.

Честно о границах: хранилище конфигурации, а не менеджер секретов

Вот честная оговорка, сказанная прямо, потому что именно она отделяет хорошее использование этой возможности от неприятного сюрприза потом. Возможность секретов в Inquir Compute — это per-function хранилище переменных окружения. Это не самостоятельный корпоративный менеджер секретов. Страница /features/secrets говорит ровно это, и я не собираюсь смягчать формулировку.

Конкретно это значит:

  • Шифрование в покое условно. Хранимые envVars зашифрованы в покое только если на развёртывании настроен ENCRYPTION_KEY (src/utils/env-vars-storage.ts). На управляемом развёртывании, где этот ключ задан, ваши значения зашифрованы. Если вы разворачиваете самостоятельно и не настроили ENCRYPTION_KEY, они не зашифрованы. Не предполагайте шифрование — проверьте, что ключ задан. Это самая несущая оговорка на всей возможности.
  • Нет ротации. Встроенной ротации учётных данных нет. Если ключ скомпрометирован, вы ротируете его у провайдера (OpenAI, Stripe, база) и сами обновляете значение envVars. Платформа не будет ротировать, истекать по сроку или выдавать в аренду учётные данные за вас.
  • Нет версионирования. Нет истории версий значений секрета, нет «откатиться к предыдущему значению», нет журнала аудита каждого изменения конкретного секрета. Это конфигурация текущего состояния.
  • Нет перекрёстных ссылок между функциями. Как выше, функции не могут ссылаться на секреты друг друга. Нет общего хранилища, нет «ссылок» на секреты, нет динамических секретов.

Так когда же стоит тянуться к выделенному менеджеру секретов (Vault, AWS Secrets Manager, облачный KMS) вместо per-function env vars или перед ними? Когда вам действительно нужны автоматическая ротация, динамические/короткоживущие учётные данные, тонкие политики доступа на каждый секрет с полным журналом аудита или единый источник правды, общий для многих сервисов и платформ. Для некоторых организаций это реальные требования, и данная возможность не притворяется, что их закрывает. А вот в чём per-function env-конфигурация превосходна — так это в подавляюще частом случае: дать одной serverless-функции горстку API-ключей, которые ей нужны, так, чтобы эти ключи никогда не касались вашего кода, ваших промптов или вашего клиента. Для этого — для практического управления секретами в функциях — это ровно нужное количество механики.

Паттерны управления API-ключами для serverless AI-агентских инструментов

Бэкенды AI-агентов создают особое давление на секреты, потому что «инструмент агента» — это обычно serverless-функция, которая вызывает сторонний API от имени модели: инструмент поиска, запускатель кода, поиск в CRM, платёжное действие. Каждому инструменту нужны свои учётные данные, и вы не хотите, чтобы модель была где-либо рядом с ними. Несколько устойчивых паттернов:

Один ключ на инструмент, на собственной функции инструмента. Поскольку секреты пофункциональны, дайте каждому инструменту агента свою функцию и свои envVars. Инструмент поиска получает ключ поискового API; платёжный — ключ Stripe; ни один не видит секрет другого. Это отображает область секрета на инструмент в изоляцию по функциям, так что баг или prompt-инъекция в одном инструменте не дотянется до учётных данных другого.

Модель выбирает инструмент, но никогда — ключ. LLM решает, какой инструмент вызвать и с какими аргументами. Она никогда не получает и никогда не нуждается в API-ключе, который инструмент использует внутри, — ключ читается из process.env внутри обработчика, уже после того как модель приняла решение. Держите учётные данные строго ниже по потоку от рассуждений модели. Это практический способ управлять API-ключами в serverless для AI-инструментов: ключ — деталь реализации инструмента, а не часть контекста агента.

Ограничивайте инструмент на шлюзе, а не исходящим ключом. Если вызывать инструмент должны только определённые клиенты, используйте auth маршрута (api-key / bearer) на шлюзе — входящую сторону границы из раздела выше. Не пытайтесь использовать собственные исходящие учётные данные инструмента как механизм контроля доступа; они не для этого, и это вынуждает вас раскрывать учётные данные, чтобы ограничивать доступ.

Считайте промпт публичным. Всё, что агент может прочитать, — системный промпт, описания инструментов, извлечённый контекст — следует считать публичным. Секреты живут в env, и точка. Если возникает соблазн «просто положить ключ в системный промпт, чтобы инструмент им воспользовался», — это сигнал остановиться и перенести его в envVars.

Вывод

Секреты на serverless-платформе — это не сложно; это просто легко сделать небрежно. Устойчивое и правильное значение по умолчанию — per-function переменные окружения: задайте значение один раз (в Config → Environment или через api.updateFunction), позвольте платформе внедрить его в контейнер в момент вызова и читайте из process.env в обработчике. Держите его вне закоммиченных файлов, промптов модели и клиентских бандлов. Держите два вида API-ключей раздельно — входящая auth маршрута охраняет тех, кто может вас вызвать; исходящие envVars — это то, что вы предъявляете сервисам, к которым обращаетесь. Опирайтесь на изоляцию по функциям и редакцию как на страховочную сетку.

И оставайтесь честными относительно границ. Это per-function хранилище конфигурации для serverless env vars, с шифрованием в покое когда настроен ENCRYPTION_KEY и автоматической редакцией — а не полноценный менеджер секретов с ротацией, версионированием или перекрёстными ссылками между функциями. Для повседневной задачи безопасно доставить несколько API-ключей в ваши функции это именно то, что нужно. А когда вам действительно нужны ротация и динамические учётные данные — тянитесь к выделенному менеджеру секретов и точно знайте, зачем. Подобрать инструмент под задачу — вот ход зрелого инженера.