Привет! Я решил выяснить, на каком языке программирования можно написать веб-приложение, чтобы при его контейнеризации Docker-образ получился легковесным, а сборка образа была быстрой.
Правила таковы:
- Для веб-приложения выбирается наиболее популярный (или один из наиболее ходовых) веб-фреймворк.
- Приложение, которое создается, должно выполнять следующее действие: присылать сообщение «Hello, world!» при обращении по единственному маршруту — "/".
- Там, где это имеет смысл, используется многоэтапная сборка для оптимизации образа.
- В качестве базового образа всегда используется Alpine, либо образы на его основе.
- Время сборки измеряется Linux-командой time. Используется вот так:
time docker build -t my-image .
После исполнения команды выводит время, затраченное на ее выполнение.
- Для запуска контейнера всегда используется команда
docker run --rm -it -p port1:port2 my-image
Ну что же, приступим!
Node.JS
В качестве базового образа используем node:alpine, в качестве сервера — Express. Многоэтапная сборка в данном случае сокращает образ всего на пару мегабайтов.
Код приложения:
const express = require('express');
const app = express();
const port = 8000;
app.get('/', (req, res) => {
res.send('Hello, world!');
});
app.listen(port, () => {
console.log('The server listens on ' + port);
});
Dockerfile:
FROM node:alpine AS builder
COPY index.js /app/index.js
WORKDIR /app
RUN npm install express --save
ENTRYPOINT [ "node", "/app/index.js" ]
FROM node:alpine
COPY --from=builder /app /app
ENTRYPOINT [ "node", "/app/index.js" ]
Время сборки — 14.791 секунд.
Размер образа — 81 MB.
C#
В случае с C# использование многоэтапной сборки и Alpine в качестве production-образа сокращают размер финального образа примерно в 7 раз. В качестве фреймворка испоьзуется ASP.NET Core.
Код контроллера:
[Route("/")]
[ApiController]
public class ValuesController : ControllerBase
{
[HttpGet]
public ActionResult<string> Get()
{
return "Hello, world!";
}
}
Dockerfile:
FROM mcr.microsoft.com/dotnet/core/sdk:3.0 AS builder
COPY . /app
WORKDIR /app
RUN dotnet publish -c Release
ENTRYPOINT [ "dotnet", "/app/bin/Release/netcoreapp3.0/publish/cs-app.dll" ]
FROM mcr.microsoft.com/dotnet/core/aspnet:3.0-alpine3.9
COPY --from=builder /app/bin/Release/netcoreapp3.0/publish/ /app/
ENTRYPOINT [ "dotnet", "/app/cs-app.dll" ]
Время сборки — 14.818 секунд.
Размер образа — 94.4 MB.
Java
Использование многоэтапной сборки и Alpine сокращают размер образа примерно в 6 раз. В качестве веб-фреймворка используется Spring Boot с пакетным менеджером Gradle.
Код контроллера:
@RestController
public class HelloController {
@RequestMapping("/")
public String hello() {
return "Hello, world!";
}
}
Dockerfile:
FROM gradle:jdk8 AS builder
COPY . /app
WORKDIR /app
RUN ./gradlew build
ENTRYPOINT [ "java", "-jar", "build/libs/app-0.0.1-SNAPSHOT.jar" ]
FROM openjdk:8-jdk-alpine
COPY --from=builder /app/build/libs/app-0.0.1-SNAPSHOT.jar /app/application.jar
ENTRYPOINT [ "java", "-jar", "/app/application.jar" ]
Время сборки — 1 минута 52.479 секунд.
Размер образа — 122 MB.
Время сборки является очень высоким из-за запуска демона Gradle и выполнения всех его тасков.
PHP
В качестве фреймворка был выбран Laravel. Конкретно в этом случае не было никаких дополнительных библиотек, только код, сгенерированный самим фреймворком, так что использование многоэтапной сборки не имело смысла. Нам достаточно изменить код файла routes/web.php:
Route::get('/', function () {
return "Hello, world!";
});
Короткий Dockerfile:
FROM php:7.2.19-alpine3.9
COPY . /usr/src/app
WORKDIR /usr/src/app
ENTRYPOINT [ "php", "artisan", "serve", "--host", "0.0.0.0" ]
Время сборки — 15.046 секунд.
Размер образа — 116 MB.
Python
Многоэтапная сборка экономит всего пару мегабайтов. В качестве веб-фреймворка был выбран Flask. Код весьма прост:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello World!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int("5000"), debug=True)
Dockerfile:
FROM python:alpine3.7 AS builder
COPY . /app
WORKDIR /app
RUN pip install --user -r requirements.txt
FROM python:alpine3.7
COPY --from=builder /root/.local/lib/python3.7/site-packages /usr/local/lib/python3.7/site-packages
COPY --from=builder /app/index.py /app/index.py
ENTRYPOINT [ "python", "./app/index.py" ]
В файле requirements.txt прописана всего одна зависимость — flask.
Время сборки — 15.332 секунд.
Размер образа — 85.1 MB.
Go
Go имеет преимущество перед другими языками в плане построения веб-приложений. Ему не нужен какой-нибудь тяжеловесный фреймворк, все необходимое уже находится в стандартной библиотеке. При этом он компилируется напрямую в код той архитектуры, на которой будет запущена программа, так что нет необходимости в виртуальной машине, исполняющей байт-код. Мы можем собрать исполнимый файл и запустить его на чистом Alpine.
Код сервера:
package main
import (
"log"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, world!"))
}
func main() {
http.HandleFunc("/", hello)
err := http.ListenAndServe(":80", nil)
if err != nil {
log.Fatalln("Couldn't start the server:", err)
}
}
Dockerfile:
FROM golang:1.12 AS builder
COPY . /go/src/app
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /go/bin/app /go/src/app/
ENTRYPOINT [ "go/bin/app" ]
FROM alpine:latest
COPY --from=builder /go/bin/app /bin/app
ENTRYPOINT [ "/bin/app" ]
Строка «CGO_ENABLED=0 GOOS=linux GOARCH=amd64» необходима, т.к. Alpine не имеет стандартного libc.
Время сборки — 12.568 секунд.
Размер образа — 12.9 MB.
Это просто фантастический результат. При использовании Alpine и многоэтапной сборки размер образа уменьшается в 60 раз. Бесспорно, Go — лучший язык для приложений, подлежащих упаковке в контейнер.
Что действительно может увеличить время сборки, так это скачивание библиотек при помощи go get. Пожалуй, лучше использовать для этого dep.
Ruby
Контейнеризация Rails-приложения — сущий кошмар. Пришлось столкнуться со следующими проблемами:
- Несовместимость версий. По умолчанию Ubuntu ставит Ruby 2.5.1 и Bundler 2.0.2. Но в контейнере для Ruby 2.5.1 был Bundler 1-ой версии. Если прописать в Dockerfile инструкцию по установке нового Bundler, то то среда Ruby все равно продолжит использовать старый. Решение нашлось на сайте, на который я смог попасть только через Tor.
- Сборка некоторых гемов требует исходники на Си. Хуже того, при сборке некоторых из них (конкретно — nokogiri) необходимо прописывать конфиги, валяющиеся где-то в этих исходниках. Решение этой проблемы мне повезло найти в одном японском блоге. Мало того, эти исходники необходимы даже на production'e.
Код контроллера:
class HomeController < ApplicationController
def index
render plain: 'Hello, world!'
end
end
Маршрут:
Rails.application.routes.draw do
root to: 'home#index'
end
Кроме того, в Gemfile надо прописать следующее:
gem 'tzinfo-data'
gem 'execjs'
Поучившийся Dockerfile:
FROM ruby:2.5.1-alpine3.7 AS base
ENV BUNDLER_VERSION 2.0.2
RUN apk add --no-cache --update
build-base
libxml2-dev
libxslt-dev
nodejs
nodejs-npm
sqlite-dev
&& gem install bundler
FROM base AS builder
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN gem install nokogiri
-- --use-system-libraries
--with-xml2-config=/usr/bin/xml2-config
--with-xslt-config=/usr/bin/xslt-config
&& bundle install
CMD rails server -b 0.0.0.0
FROM base
COPY --from=builder /usr/src/app /usr/src/app
COPY --from=builder /usr/local/bundle/ /usr/local/bundle/
WORKDIR /usr/src/app
CMD rails server -b 0.0.0.0
Есть, конечно, официальный образ Rails, но его поддержка была прекращена в 2016-ом.
Время сборки — 2 минуты 20.374 секунд.
Размер образа — 322 MB.
Это очень много. Объективно наихудший результат среди всех представленных здесь языков.
Вообще, большинство языков из присутствующих здесь появились именно в те времена, когда о контейнеризации никто и не подозревал, а кроссплатформенность достигалась с помошью виртуальных машин, исполняющих байт-код. Go пошел кардинально другим путем, но и повезо ему больше всех.
Если у вас есть какие-либо советы или замечания по оптимизации образов, пожалуйста, пишите в комментариях, все учтется.
Автор: zergon321