Предыстория

Как-то раз, в результате многих сверхурочных часов кропотливой работы, мне удалось разработать простейшее Hello world приложение на Go. И хотя оно всего-навсего производило вывод на экран, а затем заканчивало свою работу, мой начальник был настолько впечатлен, что попросил меня опубликовать мое детище, в написании которого я реализовал весь свой потенциал.

Схема проекта была следующей.

.
├── go.mod
└── hello.go

Код hello.go выглядел вот так:

package mainfunc main() {
    println("hello world!")
}

И еще мой начальник попросил использовать docker, чтобы мы выглядели посовременнее.

Первая попытка

Расспросив своих знакомых с целью получить полезные советы, я выяснил, что было бы неплохо скомпилировать весь процесс в docker, и немного поработав, я получил следующий Dockerfile:

FROM golang:alpine
WORKDIR /build
COPY hello.go .
RUN go build -o hello hello.go
CMD [". /hello"]

И чтобы собрать образ:

$ docker build -t hello:v1 .

Готово. Давайте посмотрим, что у нас получилось:

$ docker run -it --rm hello:v1 ls -l /build

total 1260

-rwxr-xr-x 1 root root 1281547 Mar 6 15:54 hello

-rw-r--r-- 1 root root 55 Mar 6 14:59 hello.go

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

Посмотрим на размер зеркала. Говорят, чем больше образ, тем медленнее pull.

$ docker docker images | grep hello

hello v1 2783ee221014 44 minutes ago 314MB

Ого, 314 МБ, неужели docker build превратила Go в программу на Java?

Давайте разберемся, почему он такой большой:

Смотрите, мы уже получаем 300+МБ еще до того, как мы запустим первую инструкцию (WORKDIR). Это какая-то жесть!

В любом случае, давайте запустим его и посмотрим, как он работает:

$ docker run --it --rm hello:v1

hello world!

Ну тут проблем нет, по крайней мере, он работает!

Вторая попытка

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

$ docker images | grep golang

golang alpine d026981a7165 2 days ago 313MB

Один мой знакомый сказал мне, что я могу сначала скомпилировать код, а затем скопировать его без огромного базового образа, но это легче сказать, чем сделать. Приложив к этому некоторые усилия, я получил следующий Dockerfile:

FROM alpine

WORKDIR /build

COPY hello .

CMD [". /hello"]

Попробуем его запустить:

$ docker build -t hello:v2 .
...
=> ERROR [3/3] COPY hello .                         0.0s
------
 > [3/3] COPY hello . :
------
failed to compute cache key: "/hello" not found: not found

Так, hello не найден — значит, я забыл сначала скомпилировать hello.go. Попробуем снова~

$ go build -o hello hello.go

Затем запустим docker build -t hello:v2 .. Тут уже все без проблем. Сделаем глубокий вдох и запустим:

$ docker run -it --rm hello:v2

standard_init_linux.go:228: exec user process caused: exec format error

Ошибка! Что ж, формат не тот поэтому, что наша машина для разработки не на linux. Еще разок~

$ GOOS=linux go build -o hello hello.go

Перезапустим docker build. Наконец все готово, и мы можем запустить:

$ docker run --it --rm hello:v2

hello world!

Никаких проблем. Давайте посмотрим содержимое и размер:

$ docker run --it --rm hello:v2 ls -l /build

total 1252

-rwxr-xr-x 1 root root 1281587 Mar 6 16:18 hello

Внутри только hello. Больше не нужно переживать о людях, насмехающихся над моим кодом ~

$ docker images | grep hello

hello v2 0dd53f016c93 53 seconds ago 6.61MB

hello v1 ac0e37173b85 25 minutes ago 314MB

Вау, 6.61МБ, прекрасно!

Смотрите, теперь у нас только 5.3МБ перед первой командой (WORKDIR), я уже счастлив!

Третья попытка

В ответ на мое невинное хвастовство кое-кто все-таки опустил меня на землю, сказав, что сейчас популярно создавать сборки в несколько этапов. Так что же не так со вторым способом? Поразмыслив над этим, я понял, что нам стоит реализовать возможность собирать образ docker прямо из кода Go, что потребует от нас три следующие шага:

  1. Скомпилировать код Go нативно (если он включает кроссплатформенную cgo компиляцию, это будет более проблематично).

2. Создать образ docker с скомпилированным исполняемым файлом.

3. Написать shell-скрипт или makefile, чтобы запускать все это с помощью одной команды.

Многоэтапная сборка помещает все это в Dockerfile — никаких утечек исходного кода, никаких скриптов для компиляции на разных платформах и образ минимального размера.

Наконец-то я написал следующий Dockerfile, ни одной строкой больше, ни одной строкой меньше:

FROM golang:alpine AS builder

WORKDIR /build

ADD go.mod .

COPY . .

RUN go build -o hello hello.go

FROM alpine

WORKDIR /build

COPY --from=builder /build/hello /build/hello

CMD [". /hello"]

Первый FROM начинается со сборки builder-образа для компиляции исполняемого файла hello, а второй From начинается с копирования исполняемого файла hello из первого образа и использования наименьшего возможного базового образа alpine, чтобы окончательный образ было как можно меньше. Причина, по которой мы не используем scratch меньшего размера, заключается в том, что scratch на самом деле ничего в себя не включает, поэтому у нас даже нет возможности просмотреть его в случае возникновения проблемы, а alpine весит всего 5МБ, так что это не окажет большого влияния на наш сервис. Я расскажу о создании образов на основе scratch чуть позже в этой статье.

Давайте запустим его и убедимся, что все работает:

$ docker run --it --rm hello:v3

hello world!

Никаких проблем, как и ожидалось! Посмотрим на размер:

$ docker images | grep hello

hello v3 f51e1116be11 8 hours ago 6.61MB

hello v2 0dd53f016c93 8 hours ago 6.61MB

hello v1 ac0e37173b85 8 hours ago 314MB

Размер образа точно такой же, как и во втором методе. И посмотрите на содержимое образа:

$ docker run -it --rm hello:v3 ls -l /build

total 1252

-rwxr-xr-x 1 root root 1281547 Mar 6 16:32 hello

Оно содержит только исполняемый файл hello. Замечательно!

Это по сути то же самое, что и второй окончательный образ, но мы упростили процесс до использования только Dockerfile и запуска всего одной команды, поэтому мне не нужно задействовать всю эту муть с shell и makefile.

Доводим до совершенства

Теперь мои товарищи по команде считают эту работу идеальной и хвалят меня! Однако каждый раз, когда я пишу такой Dockerfile, меня все равно это напрягает, поэтому я написал специальный инструмент. Позвольте мне вам его продемонстрировать~

# Сначала установите его

$ go install github.com/zeromicro/go-zero/tools/goctl@latest

# А теперь вы можете создать файл Docker с помощью всего одной команды

$ goctl docker -go hello.go

Готово! Посмотрим на сгенерированный Dockerfile.

FROM golang:alpine AS builder

LABEL stage=gobuilder

ENV CGO_ENABLED 0

ENV GOOS linux

RUN apk update --no-cache && apk add --no-cache tzdata

WORKDIR /build

ADD go.mod .

ADD go.sum .

RUN go mod download

COPY . .

RUN go build -ldflags="-s -w" -o /app/hello . /hello.go

FROM alpine

RUN apk update --no-cache && apk add --no-cache ca-certificates

COPY --from=builder /usr/share/zoneinfo/America/New_York /usr/share/zoneinfo/America/New_York

ENV TZ America/New_York

WORKDIR /app

COPY --from=builder /app/hello /app/hello

CMD [". /hello"]

Вот несколько моментов, которые нужно здесь не мешало бы объяснить:

  • cgo отключен по умолчанию.

  • Удалено сообщение отладки -ldflags="-s -w" для уменьшения размера образа.

  • Установлено ca-certificates, чтобы не было проблем с использованием TLS сертификатов.

  • tzdata устанавливается в builder-образ билдера, а в окончательный образ копируется только необходимый часовой пояс.

  • Автоматически устанавливает местный часовой пояс, чтобы в контейнере мы видели New York.

Давайте посмотрим на размер образа, созданного с помощью этого автоматически сгенерированного Dockerfile.

$ docker images | grep hello

hello v4 94ba3ece3071 4 hours ago 6.66MB

hello v3 f51e1116be11 8 hours ago 6.61MB

hello v2 0dd53f016c93 8 hours ago 6.61MB

hello v1 ac0e37173b85 9 hours ago 314MB

Он немного больше, потому что мы скопировали ca-certificates и tzdata. Проверим это:

Давайте посмотрим, что у нас в финальном образе:

$ docker run -it --rm hello:v4 ls -l /app

total 832

-rwxr-xr-x 1 root root 851968 Mar 7 08:36 hello

Все также только hello, а размер файла был уменьшен с 1281КБ до 851КБ. Запустим его и посмотрим исполняемый shell-файл:

$ docker run --it --rm hello:v4

hello world!

И вы можете указать базовый образ scratch при создании Dockerfile, чтобы образ был меньше, но вы не можете войти напрямую через sh.

$ goctl docker -base scratch -go hello.go

Размер стал в несколько раз меньше:

$ docker images | grep hello

hello v5 d084eed88d88 4 seconds ago 1.07MB

hello v4 94ba3ece3071 15 hours ago 6.66MB

hello v3 f51e1116be11 4 days ago 6.61MB

hello v2 0dd53f016c93 4 days ago 6.61MB

hello v1 ac0e37173b85 4 days ago 314MB

И посмотри, что в зеркале:

Команда docker кросс-билдинг образов. Если вы используете чипы Apple Silicon, вы можете передать --platform linux/amd64 для создания образов докеров под linux/amd64.

Вы также можете использовать upx для уменьшения размера бинарника. Установите upx на этапе сборки и запустите upx /app/hello после сборки.

$ docker images | grep hello

hello  v7  491d2cf669c7   16 minutes ago   548kB  # amd64 after upx

hello  v6  0a80622b3310   7 days ago       972kB  # amd64

hello  v5  d084eed88d88   13 days ago      1.07MB # arm64

Ладно, ладно, на сегодня хватит Dockerfile, пора дальше осваивать новые навыки~

Сам проект можно найти здесь: ссылка на Github.


Приглашаем всех желающих на открытый урок «Структуры». На занятии рассмотрим такие понятия, как: процесс определения структур, инкапсуляция полей структуры, определение методов структуры. Побеседуем про вложенные и анонимные структуры. Также постараемся успеть поговорить и про структурные тэги и их использование в контексте JSON, XML и СУБД. После занятия вы сможете создавать пользовательские типы данных и комбинировать их между собой. Регистрация на занятие по ссылке.

Комментарии (6)


  1. Exclipt
    11.04.2022 22:00
    +3

    Интересно, если загуглить go docker habr, сколько будет найдено таких же докерфайлов, больше 10и или меньше 10и?


  1. yellow79
    11.04.2022 22:13
    +1

    Для уменьшения размера(и не только) так же стоит указать флаг -trimpath при сборке бинарника


  1. xakep666
    11.04.2022 23:27
    +2

    tzdata можно не копировать, начиная вроде бы с 1.16 при

    import _ "time/tzdata" 

    или же добавке билд тега tzdata в бинарник будет встроена эта база целиком в сжатом виде. Документация обещает увеличение размера бинарника где-то на 450 КБ.


  1. AlexGluck
    12.04.2022 00:08
    +3

    В погоне за качеством, сложность выросла, читаемость просела и качество вышло таким себе. Куда проще на этапе сборки собрать статический бинарник в скретч его засунуть, а чтобы шелом в контейнер зайти можно либо в поде поднять другой контейнер с нужными неймспейсами, либо с хоста зайти через nsenter.


  1. fs94eskoe
    12.04.2022 10:34
    +1

    а какой тулзой слои вот так красиво смотреть?


    1. fs94eskoe
      12.04.2022 10:43

      похоже вот этой

      https://github.com/wagoodman/dive