Доброго времени суток, дорогой читатель! Сегодня хочу рассказать о достаточно простой задаче, однако сильно упрощающей мне жизнь.
Итак, проблема - есть Gitlab Runner, который собирает докер образы и пушит их в Docker Registry, все бы ни чего, да вот рано или поздно, начинает docker build на раннере падать, с ошибкой "No space left on device", как понятно из текста ошибки - кончилось место.
Что приходится делать в такой ситуации? Верно идти и удалять все образы и контейнеры с раннера. Но у этого подхода есть существенный недостаток, ведь мы чистим весь кеш Docker на машине и первые образы собираются ну очень долго, а если есть еще и родительские, от которых зависят образы проектов, то нужно пересобирать вообще все.. Это долго и больно...
Постановка задачи
Необходимо написать решение, которое будет в заданный интервал времени сканировать Docker Image и удалять те, которые старше заданного количества дней.
Решение
Для работы с апи Docker есть несколько вариантов:
- Работать напрямую с консольными командами и парсить ответы в структуры
- Работать с официальной библиотекой Docker API Go.
Мы пойдем вторым вариантом - https://pkg.go.dev/github.com/docker/docker/client
Пишем каркас
Итак, создадим пустой проект на Golang и приступим.
Первое, что нам необходимо сделать, это получить список Image которые были собраны на машине:
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
panic(err)
}
images, err := cli.ImageList(context.Background(), types.ImageListOptions{})
if err != nil {
panic(err)
}
cli, err := client.NewClientWithOpts(client.FromEnv) - инициализируем клиента Docker АПИ, который и будет основным источником данных Docker для нашего приложения
images, err := cli.ImageList(context.Background(), types.ImageListOptions{}) - получаем список Images собранных на нашем устройстве.
Важно:
Если попробовать выполнить код go run main.go , то с большой долей вероятности, можно получить ошибку
panic: Error response from daemon: client version 1.42 is too new. Maximum supported API version is 1.41
Как видно из текста ошибки, не нравится client version нашего Docker и как следствие дальнейшая работа не возможна. Как бороться? Необходимо добавить модификацию переменной окружения.
export DOCKER_API_VERSION=1.41 && go run main.go
Сразу после выполнения этой команды, можно увидеть список наших Docker Image на машине
$ export DOCKER_API_VERSION=1.41 && go run main.go
{-1 1674049935 sha256:5412c99a3409d80630ad348edcdb014f4dfa2cf9336b20a348e585e28891b012 map[meta:beta] [] [beta:latest] -1 1066485446 1066485446}
Что делать дальше? А дальше нужно эти образы куда-то сложить, давайте создадим канал куда и будем складывать наши образы.
imagesQueue := make(chan types.ImageSummary, 10)
defer close(imagesQueue)
for _, image := range images {
go func(curImage types.ImageSummary) {
imagesQueue <- curImage
}(image)
}
Ну и дальше читаем из канала и удаляем образы.
for {
select {
case img := <-imagesQueue:
_, err := cli.ImageRemove(context.Background(), img.ID, types.ImageRemoveOptions{})
if err != nil {
fmt.Println(err)
}
case <-ctx.Done():
return
}
}
Итак, давайте разберемся, что сейчас умеет делать наш скрипт. А именно, он умеет делать следующее
- Читать образы, которые были сбилдены в системе
- Отправлять эти образы в канал
- Удалять образы
Решает ли это поставленную ранее задачу? Конечно нет! Ведь мы хотели удалять не все под чистую, а именно образы, которые старше определенного количества дней. Приступим!
У types.ImageSummary есть поле Created типа int64 оно как раз и хранит в себе дату создания. Предположим, что мы хотим чистить образы старше 72 часов, тогда необходимо модифицировать нашу горутину отправляющую Docker Image (types.ImageSummary) в канал. Давайте сделаем это.
for _, image := range images {
go func(curImage types.ImageSummary) {
tmImage := time.Unix(curImage.Created, 0)
tmDuration := time.Now().Sub(tmImage)
if tmDuration.Hours() > 72 {
imagesQueue <- curImage
}
}(image)
}
После этого, логика программы изменится следующим образом, в наш импровизированный стек будут попадать только Images у которых дата создания больше 72 часов.
Теперь нам нужно следующее, нам нужно указывать те образы, которые подлежат удалению. Мы же не хотим, чтобы удалились образы, которые удалять ненужно. Например: родительские образы, которые собираются один раз.
Для указания тегов, можно использовать свойство Filters, в структуре types.ImageListOptions, при получении списка контейнеров.
images, err := cli.ImageList(ctx, types.ImageListOptions{
Filters: filters.NewArgs(filters.Arg("reference", "beta")),
})
Подключаем конфиг
Для работы с конфигами мы будем использовать удобную библиотеку для работы с ENV переменными
Итак, что нам нужно сделать.
- Необходимо создать сруктуру нашего конфига
- Сделать подгрузку в нее из Env переменных
- Пробросить в зависимые функции
Создадим файл configs.go в папке config следующего содержания
package config
type EnvironmentCfg struct {
MaxRegistryClearSeconds int64 `envconfig:"default=60"`
BetweenRegistryTimeHours float64 `envconfig:"default=72"`
}
После этого, сделаем подгрузку переменных окружения в нашу структуру в методе main.go и начнем пробрасывать по методам
func main() {
cfg := &config.EnvironmentCfg{}
err := envconfig.Init(cfg)
if err != nil {
panic(err)
}
runCronJobs(cfg)
}
Делаем запуск по времени - Cron
gocron - это пакет планирования заданий, который позволяет запускать функции Go с заранее определенными интервалами, определяя простой, удобный для пользователя синтаксис.
- Нам необходимо вынести наш логику из функции Main в дочернюю функцию
- Настроить GoCron
- Предотвратить закрытие приложения
Создадим функцию clearDockerImages и перенесем в нее нашу логику
func clearDockerImages(cfg *config.EnvironmentCfg) {
imagesQueue := make(chan types.ImageSummary, 10)
defer close(imagesQueue)
ctx, _ := context.WithTimeout(context.Background(), time.Second*time.Duration(cfg.MaxRegistryClearSeconds))
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
panic(err)
}
images, err := cli.ImageList(ctx, types.ImageListOptions{
Filters: filters.NewArgs(filters.Arg("reference", "beta")),
})
if err != nil {
panic(err)
}
for _, image := range images {
go func(curImage types.ImageSummary) {
tmImage := time.Unix(curImage.Created, 0)
tmDuration := time.Now().Sub(tmImage)
if tmDuration.Hours() > cfg.BetweenRegistryTimeHours {
imagesQueue <- curImage
}
}(image)
}
for {
select {
case img := <-imagesQueue:
_, err := cli.ImageRemove(context.Background(), img.ID, types.ImageRemoveOptions{Force: true})
if err != nil {
fmt.Println(err)
}
case <-ctx.Done():
return
}
}
}
Создадим функцию runCronJobs и настроим выполнение раз в 5 часов
func runCronJobs() {
s := gocron.NewScheduler(time.UTC)
s.Every(5).Hours().Do(func() {
clearDockerImages()
})
s.StartBlocking()
}
На что здесь нужно обратить внимание, так это на строчку s.StartBlocking(). Дело в том, что у GoCron есть 2 режима старта задач
- StartAsync - запуск в асинхронном режиме, не блокируя основной поток
- Start Blocking - запуск с блокировкой основного потока, что нам и нужно
Как запускать наш скрипт
export DOCKER_API_VERSION=1.41 && BETWEEN_REGISTRY_TIME_HOURS=72 MAX_REGISTRY_CLEAR_SECONDS=60 go run main.go
Что осталось? Если заметить, то нам крайне не удобно таскать за собой при запуске переменные окружения. Поэтому, в следующем материале я хочу разобрать как сделать конфиг генерируемым, это позволит нам сделать полноценную команду, которую можно будет установить в раннер и запускать при старте системы.