Создание минимального Docker-контейнера для Go-приложений

в 12:34, , рубрики: docker, Go, контейнеры, перевод

Привет! Предлагаю вашему вниманию перевод статьи основателя сервиса Meetspaceapp Nick Gauthier «Building Minimal Docker Containers for Go Applications».

Время чтения: 6 минут

Существует множество, как официальных, так и поддерживаемых сообществом контейнеров для различных языков программирования (включая Go). Но эти контейнеры могут быть довольно большими. Давайте сперва сравним стандартные методы создания контейнеров для Go-приложений, а затем я покажу способ создания крайне маленьких статических контейнерезированных Go-приложений

Часть 1: Наше «приложение»

Для тестирования нам потребуется какое-нибудь маленькое приложение. Давайте будем фетчить google.com и выводить размер HTML.

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
)

func main() {
    resp, err := http.Get("https://google.com")
    check(err)
    body, err := ioutil.ReadAll(resp.Body)
    check(err)
    fmt.Println(len(body))
}

func check(err error) {
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

Если мы запустимся, то получим только какое-то число. У меня вышло около 17К. Я целенаправленно решил использовать SSL, но причину объясню позднее.

Часть 2: Докеризация

Используя официальный образ Go мы напишем “onbuild” Dockerfile:

FROM golang:onbuild

“Onbuild” образ предполагает, что у вашего проекта стандартная структура и создаст стандартное Go-приложение. Если же вам нужна большая гибкость, можно использовать стандартный образ Go и самостоятельно его скомпилировать:

FROM golang:latest 
RUN mkdir /app 
ADD . /app/ 
WORKDIR /app 
RUN go build -o main . 
CMD ["/app/main"]

Хорошо бы здесь еще создать Makefile или что-то еще подобное, что вы используете для билда приложений. Мы могли бы загрузить какие-нибудь ресурсы с CDN или импортировать их из другого проекта, или, может, мы хотим запускать тесты в контейнере…
Как вы видите, докеризация Go довольно несложная, особенно если учесть, что у нас не используется сервисы и порты, к которым надо подключаться. Но есть один серьезный недостаток у официальных образов – они реально большие. Давайте посмотрим:

REPOSITORY SIZE     TAG         IMAGE ID            CREATED              VIRTUAL SIZE
example-onbuild     latest      9dfb1bbac2b8        19 minutes ago       520.7MB
example-golang      latest      02e19291523e        19 minutes ago       520.7MB
golang              onbuild     3be7ee2ec1ae        9 days ago           514.9MB
golang              1.4.2       121a93c90463        9 days ago           514.9MB
golang              latest      121a93c90463        9 days ago           514.9MB

Базовый образ занимает 514,9МБ, а наше приложение добавляет еще 5,8МБ. Как так выходит, что для нашего скомпилированного приложения требуется 515МБ зависимостей?
Дело в том, что наше приложение было скомпилировано внутри контейнера. Это означает, что контейнеру требуется установить Go. Следовательно, ему нужны зависимости Go, а так же менеджер пакетов и реально целая ОС. Фактически, если вы посмотрите Dockerfile для golang:1.4, — он ставится с Debian Jessie, устанавливает компилятор GCC и инструменты сборки, скачивает Go и устанавливает его. Таким образом, мы получаем целый сервер Debian и набор инструментов Go для запуска нашего крошечного приложения. Что можно с этим сделать?

Часть 3: Компилируй!

Улучшить положение можно, немного отступив от привычного всем подхода. Для этого мы собираемся скомпилировать Go в нашем рабочем каталоге, а затем добавить двоичный файл в контейнер. Это означает, что простая сборка докера не будет работать. Нам нужна многошаговая сборка контейнера:

go build -o main .
docker build -t example-scratch -f Dockerfile.scratch .

И простой Dockerfile.scratch:

FROM scratch
ADD main /
CMD ["/main"]

Что такое scratch? Scratch — это специальный пустой образ в докере. Его размер 0B:

REPOSITORY          TAG         IMAGE ID            CREATED              VIRTUAL SIZE
example-scratch     latest      ca1ad50c9256        About a minute ago   5.60MB
scratch             latest      511136ea3c5a        22 months ago        0B

В итоге наш контейнер занимает всего лишь 5,6 МБ. Отлично! Но есть одна проблема:

$ docker run -it example-scratch
no such file or directory

Что это значит? Мне потребовалось некоторое время, чтобы понять, что наш бинарный файл Go ищет библиотеки в той операционной системе, в которой запущен. Мы скомпилировали наше приложение, но оно по-прежнему динамически связано с библиотеками, которые необходимо запустить (т. е. со всеми библиотеками C). К сожалению, scratch пуст, поэтому нет ни библиотек, ни путей загрузки. Нам нужно изменить скрипт сборки, чтобы статически компилировать наше приложение со всеми встроенными библиотеками:

CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

Мы отключаем cgo, который отдает нам статический бинарник. Также мы указываем Linux в качестве ОС (на случай, если кто-то билдит его на Mac или Windows). Флаг -a означает перестройку всех пакетов, которые мы используем, что перестроит весь импорт с отключенным cgo. Теперь у нас есть статический бинарник. Давайте запустим:

$ docker run -it example-scratch
Get https://google.com: x509: failed to load system roots and no roots provided

А это еще что? Вот почему я решил использовать SSL в нашем примере. Это действительно распространенный «косяк» для подобных сценариев: для выполнения запросов SSL нам нужны рутовые сертификаты SSL. Так как же мы добавим их в наш контейнер?
В зависимости от операционной системы сертификаты могут лежать в разных местах. Для многих дистрибутивов Linux это /etc/ssl/certs/ca-certificates.crt. Итак, во-первых, мы скопируем ca-certificates.crt с нашего компьютера (или виртуальной машины Linux, или поставщика онлайн-сертификатов) в наш репозиторий. Затем мы добавим ADD в наш Dockerfile, чтобы переместить этот файл туда, где Go его ожидает:

FROM scratch
ADD ca-certificates.crt /etc/ssl/certs/
ADD main /
CMD ["/main"]

Теперь просто пересоздадим наш образ и запустим его. Работает! Давайте посмотрим размер нашего приложения теперь:

REPOSITORY          TAG         IMAGE ID            CREATED              VIRTUAL SIZE
example-scratch     latest      ca1ad50c9256        About a minute ago   6.12MB
example-onbuild     latest      9dfb1bbac2b8        19 minutes ago       520.7MB
example-golang      latest      02e19291523e        19 minutes ago       520.7MB
golang              onbuild     3be7ee2ec1ae        9 days ago           514.9MB
golang              1.4.2       121a93c90463        9 days ago           514.9MB
golang              latest      121a93c90463        9 days ago           514.9MB
scratch             latest      511136ea3c5a        22 months ago        0B

Мы добавили чуть больше пол мегабайта (и большая часть которого – от статического файла, а не от корневых сертификатов). У нас получился реально маленький контейнер — его будет очень удобно перемещать между реестрами.

Заключение

Наша цель состояла в том, чтобы уменьшить размер контейнера для приложения Go. Особенность Go заключается в том, что он может создавать статически связанный двоичный файл, полностью содержащий приложение. Другие языки тоже могут так, но далеко не все. Применение подобной техники уменьшения размера контейнера в других языках будет зависеть от их минимальных требований. Например, приложение Java или JVM может быть скомпилировано вне контейнера и затем внедрено в контейнер, который содержит только JVM (и ее зависимости). Но даже так будет меньше, чем контейнер с JDK.

Автор: ldanmer

Источник


* - обязательные к заполнению поля