Привет! Предлагаю вашему вниманию перевод статьи основателя сервиса 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