Найти тему

Простой скрипт очистки гитлаб раннера по расписанию от старых образов написанный на Go

Оглавление

Доброго времени суток, дорогой читатель! Сегодня хочу рассказать о достаточно простой задаче, однако сильно упрощающей мне жизнь.

Итак, проблема - есть Gitlab Runner, который собирает докер образы и пушит их в Docker Registry, все бы ни чего, да вот рано или поздно, начинает docker build на раннере падать, с ошибкой "No space left on device", как понятно из текста ошибки - кончилось место.

Что приходится делать в такой ситуации? Верно идти и удалять все образы и контейнеры с раннера. Но у этого подхода есть существенный недостаток, ведь мы чистим весь кеш Docker на машине и первые образы собираются ну очень долго, а если есть еще и родительские, от которых зависят образы проектов, то нужно пересобирать вообще все.. Это долго и больно...

Постановка задачи

Необходимо написать решение, которое будет в заданный интервал времени сканировать Docker Image и удалять те, которые старше заданного количества дней.

Решение

Для работы с апи Docker есть несколько вариантов:

  1. Работать напрямую с консольными командами и парсить ответы в структуры
  2. Работать с официальной библиотекой 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
}
}

Итак, давайте разберемся, что сейчас умеет делать наш скрипт. А именно, он умеет делать следующее

  1. Читать образы, которые были сбилдены в системе
  2. Отправлять эти образы в канал
  3. Удалять образы

Решает ли это поставленную ранее задачу? Конечно нет! Ведь мы хотели удалять не все под чистую, а именно образы, которые старше определенного количества дней. Приступим!

У 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 переменными

GitHub - vrischmann/envconfig: Small library to read your configuration from environment variables

Итак, что нам нужно сделать.

  1. Необходимо создать сруктуру нашего конфига
  2. Сделать подгрузку в нее из Env переменных
  3. Пробросить в зависимые функции

Создадим файл 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 с заранее определенными интервалами, определяя простой, удобный для пользователя синтаксис.

GitHub - go-co-op/gocron at airplane
  1. Нам необходимо вынести наш логику из функции Main в дочернюю функцию
  2. Настроить GoCron
  3. Предотвратить закрытие приложения

Создадим функцию 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 режима старта задач

  1. StartAsync - запуск в асинхронном режиме, не блокируя основной поток
  2. Start Blocking - запуск с блокировкой основного потока, что нам и нужно

Как запускать наш скрипт

export DOCKER_API_VERSION=1.41 && BETWEEN_REGISTRY_TIME_HOURS=72 MAX_REGISTRY_CLEAR_SECONDS=60 go run main.go

Что осталось? Если заметить, то нам крайне не удобно таскать за собой при запуске переменные окружения. Поэтому, в следующем материале я хочу разобрать как сделать конфиг генерируемым, это позволит нам сделать полноценную команду, которую можно будет установить в раннер и запускать при старте системы.