Вызов ffmpeg из Go: таймауты, зависшие процессы и безопасность
Запуск ffmpeg через os/exec кажется тривиальным — до момента, когда процесс зависает на битом файле и съедает всю память. Разбираем три подхода к вызову внешних команд из Go с контролем времени, ресурсов и stderr.

Почему exec.Command недостаточно
Типичный вызов ffmpeg из Go выглядит просто: exec.Command("ffmpeg", "-i", input, output).Run(). На тестовом файле в 10 МБ всё работает. На проде появляется битый .mov на 4 ГБ — и процесс ffmpeg висит бесконечно, занимая ядро и оперативку. Run() блокирует горутину, таймаута нет, stderr никто не читает, а если горутина отменена через контекст — процесс-зомби остаётся в системе.
Ниже — три подхода к запуску ffmpeg (и любой тяжёлой CLI-утилиты) из Go, каждый со своей ценой. Код проверен на Go 1.22+ и ffmpeg 6.x / 7.x под Linux.
Что нужно до начала
- Go 1.21+ (в 1.21 появился
exec.CommandContextс настройками завершения процессов через полеCancelиWaitDelay). - ffmpeg, установленный и доступный в
$PATH. - Понимание того, что
os/execзапускает реальный системный процесс — со всеми последствиями для PID-таблицы, файловых дескрипторов и памяти.
Подход 1: CommandContext с таймаутом
Самый прямолинейный способ — передать контекст с дедлайном. Начиная с Go 1.21, у exec.Cmd есть поля Cancel и WaitDelay, которые решают проблему зомби-процессов.
package main
import (
"bytes"
"context"
"fmt"
"log"
"os/exec"
"time"
)
func convertWithTimeout(ctx context.Context, input, output string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg",
"-y", // перезаписывать выходной файл
"-i", input,
"-c:v", "libx264",
"-preset", "fast",
output,
)
// Go 1.21+: при отмене контекста отправляем SIGKILL,
// а через WaitDelay добиваем процесс, если не завершился.
cmd.Cancel = func() error {
return cmd.Process.Kill()
}
cmd.WaitDelay = 5 * time.Second
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg: %w, stderr: %s", err, stderr.String())
}
return nil
}
func main() {
err := convertWithTimeout(context.Background(), "input.mp4", "output.mp4", 2*time.Minute)
if err != nil {
log.Fatal(err)
}
}
Поле Cancel вызывается, когда контекст отменяется. Без него CommandContext в старых версиях Go отправлял os.Kill, но не ждал завершения — и Wait() мог вернуть ошибку раньше, чем процесс реально умер. WaitDelay даёт буфер: если после Cancel процесс не завершился за 5 секунд, Go прибьёт его принудительно.
Ограничение stderr
ffmpeg пишет в stderr много: прогресс, предупреждения, ошибки кодеков. Если файл большой, bytes.Buffer разрастётся. Для прода стоит ограничить объём:
// limitedWriter оборачивает Writer и перестаёт писать после лимита.
type limitedWriter struct {
buf bytes.Buffer
limit int
}
func (w *limitedWriter) Write(p []byte) (int, error) {
if w.buf.Len() >= w.limit {
return len(p), nil // молча отбрасываем
}
allowed := w.limit - w.buf.Len()
if len(p) > allowed {
p = p[:allowed]
}
return w.buf.Write(p)
}
Подставьте &limitedWriter{limit: 64 * 1024} вместо &stderr — и буфер не превысит 64 КБ.
Подход 2: SIGTERM перед SIGKILL
Жёсткий Kill() обрывает ffmpeg мгновенно. Выходной файл может остаться недописанным и невалидным — контейнер MP4 не будет закрыт, moov-атом не записан. Если частичный результат лучше, чем никакой, стоит сначала послать SIGTERM (на Linux) и дать ffmpeg секунду на корректное завершение.
import "syscall"
cmd.Cancel = func() error {
// Просим ffmpeg завершиться корректно
return cmd.Process.Signal(syscall.SIGTERM)
}
cmd.WaitDelay = 10 * time.Second // если не помогло — Kill через 10 с
ffmpeg перехватывает SIGTERM и пытается дописать заголовки контейнера. Десяти секунд обычно хватает, но на сетевых файловых системах может потребоваться больше.
Каждый процесс ffmpeg — это отдельный системный процесс с собственным потреблением памяти. Пять параллельных транскодирований 4K-видео легко съедят 8–10 ГБ RAM. Ограничивайте параллелизм через семафор (chan struct{}илиgolang.org/x/sync/semaphore), а не надейтесь на таймауты.
Подход 3: отслеживание прогресса через Unix-сокет
Если нужен не просто таймаут, а контроль «жив ли процесс», ffmpeg умеет отдавать прогресс по UDP или Unix-сокету через флаг -progress. Схема: создаёте Unix-сокет, передаёте его путь в аргументах, читаете строки вида out_time_ms=12345678 в отдельной горутине. Если за N секунд ни одной строки не пришло — процесс завис, можно убивать.
Этот подход сложнее, но решает проблему, которую таймаут не решает: ffmpeg может быть жив, но застрял на повреждённом фрагменте, не продвигаясь. Статический таймаут либо слишком мал для больших файлов, либо слишком велик для битых.
Реализация через Unix-сокет требует парсинга key=value строк из прогресса ffmpeg — формат описан в документации ffmpeg в разделе «Progress» (ключи out_time_ms, speed, progress). Полный пример выходит за рамки этой статьи, но идея проста: таймер сбрасывается при каждом новом значении out_time_ms.
Сравнение подходов
| Критерий | CommandContext + Kill | SIGTERM → Kill | Прогресс-сокет |
|---|---|---|---|
| Сложность | Минимальная | Низкая | Средняя |
| Корректный выходной файл | Нет — файл обрезан | Возможен, если ffmpeg успеет | Зависит от способа завершения |
| Защита от зависания на битом файле | Только по общему таймауту | Только по общему таймауту | Да — по отсутствию прогресса |
| Кроссплатформенность | Да | Linux/macOS (SIGTERM) | Linux/macOS (Unix-сокет) |
| Когда выбирать | Фоновые задачи, где результат либо готов, либо нет | Нужен частичный результат | Конвейер с файлами разного размера |
Безопасность аргументов
Go-шный exec.Command не передаёт строку через shell — каждый аргумент идёт отдельным элементом массива argv. Это значит, что инъекция через ; rm -rf / невозможна, пока вы не вызываете exec.Command("bash", "-c", userInput). Но есть другие риски:
- Путь к файлу от пользователя. Если имя файла начинается с
-, ffmpeg интерпретирует его как флаг. Решение: всегда добавляйте./или используйте абсолютный путь. Либо ставьте--перед именем входного файла — ffmpeg поддерживает это начиная с версии 4.x. - Запись в произвольный путь. Валидируйте выходной путь — пользователь не должен иметь возможности перезаписать
/etc/passwdчерез подставленныйoutput. - Протоколы ffmpeg. ffmpeg умеет читать по HTTP, FTP, даже
concat:иsmb://. Если входное имя файла приходит извне, злоумышленник может заставить ваш сервер делать запросы к внутренней сети (SSRF). Для защиты используйте флаги-protocol_whitelist file,pipe.
Что делать дальше
Таймаут и корректное завершение — необходимый минимум. Следующие шаги:
- Ограничить параллелизм: семафор на количество одновременных процессов ffmpeg, привязанный к числу ядер и доступной памяти.
- Настроить мониторинг: считать время конвертации, процент таймаутов, размер stderr. Prometheus-метрики на эти три параметра покажут проблемы раньше, чем пользователи.
- Изучить раздел «Protocols» в документации ffmpeg — понять, какие протоколы включены в вашей сборке и какие стоит отключить.
Сноска. WaitDelay — поле структуры exec.Cmd, появившееся в Go 1.21. Задаёт максимальное время ожидания после вызова Cancel: если процесс не завершился за это время, Go закрывает его pipes и возвращает ошибку из Wait(). До Go 1.21 аналогичное поведение приходилось реализовывать вручную через отдельную горутину с таймером.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: os/exec — Go standard library documentation, FFmpeg Protocols Documentation
Частые вопросы
Зачем передавать контекст, если можно просто вызвать cmd.Process.Kill() по таймеру?
Что будет, если использовать этот код на Go ниже 1.21?
Не проще ли использовать обёртку вроде go-ffmpeg вместо exec.Command?
Как выбрать правильный таймаут, если файлы разного размера?
Может ли зависший ffmpeg-процесс остаться после завершения Go-программы?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.