12 Fractured Apps и Docker
За эти годы я стал свидетелем того, как все больше и больше людей поддерживают манифест 12 Factor App и начинают реализовывать положения, описанные там. Это привело к появлению приложений, которые были значительно упрощены в развертывании и управлении. Однако примеры практического применения этих 12 факторов были довольно редким явлением на просторах сети Интернет.
За время работы с Docker, преимущества 12 Factor App (12FA) стали для меня более ощутимыми. Например, 12FA рекомендует, чтобы логирование было настроено для стандартного вывода и обрабатывалось в качестве общего потока событий. Вы когда-нибудь использовали команду docker logs
? Это и есть 12FA в действии!
12FA также рекомендует использовать переменные среды для конфигурирования приложения. Docker делает это тривиально, предоставляя возможность устанавливать переменные среды программно при создании контейнеров.
Docker и12 Factor App — убийственная комбинация, которая предоставляет беглый обзор проектирования и развертывания приложений будущего.
Docker также отчасти упрощает перемещение унаследованных приложений в контейнер. Я говорю «отчасти», потому что в конечном итоге приходится слегка править Docker контейнеры, в результате чего 2 Гб образы контейнеров создаются поверх полноценного дистрибутива Linux.
К сожалению, унаследованные приложения, с которыми Вы возможно работаете прямо сейчас, имеют много недостатков, особенно вокруг процесса запуска. Приложения, даже современные, насчитывают слишком много зависимостей и из-за этого не могут обеспечить чистый запуск. Приложения, которые требуют доступа к внешней базе данных, обычно инициируют соединение с базой данных во время запуска. Однако, если эта база данных была недоступна, или временно недоступна, то многие из приложений просто не запустятся. Если вам повезет, вы сможете получить сообщение о ошибке с подробностями, что поможет вам в устранении неполадок.
Многие приложения, которые упаковываются в Docker, имеют довольно мелкие недочеты. Это больше похоже на микротрещины — приложения продолжают работать, но могут причинять адские муки при работе с ними.
Такое поведение приложений заставляет прибегнуть к сложным процессам развертывания и способствует развитию таких инструментов как Puppet или Ansible. Инструменты управления конфигурациями помогают решить различные проблемы, к примеру недоступность БД. Они запускают базу данных, от которой зависит данное приложение перед стартом самого приложения. Скорее всего это похоже на наклеивание лейкопластыря на рваную рану. Приложение должно просто повторить соединение с базой данных, используя своего рода классификацию для возвращенных ошибок и конечно же логирования ошибок. В данном случае будет два варианта: или базу данных получится вернуть в онлайн, или Ваша компания просто обанкротится.
Другая проблема для приложений, перемещаемых в Docker, состоит в конфигурационных файлах. Много приложений, даже современных, все еще полагаются на конфигурационные файлы, размещенные локально на дисках. Наиболие часто применяемое решение — развернуть дополнительно новые контейнеры, которые связывают конфигурационные файлы в образ контейнера.
Не делайте этого.
Если вы выбрали такое решение, в конечном итоге у вас будет бесконечное число образов контейнеров, названных примерно так:
- application-v2–prod-01022015
- application-v2-dev-02272015
Вскоре Вам понадобится искать инструменты для управления таким количеством образов.
Перемещение в Docker дало людям ошибочное мнение о том, что они больше не нуждаются в управлении конфигурациями в какой либо форме. Я склонен согласится с этим, нет никакой потребности использовать Puppet, Chef или Ansible при создании образов, но все еще есть потребность управлять параметрами конфигураций во время работы.
Подобная логика используется, чтобы покончить с частым использованием систем управления конфигурацией во избежание init систем в пользу команды docker run
.
Чтобы компенсировать отсутствие инструментов управления конфигурацией и устойчивых init систем, пользователи Docker обращаются к shell-скриптам, дабы замаскировать недостатки применения вокруг начальной загрузки и процесса запуска.
Как только Вы перенесете все на Docker и откажетесь использовать инструменты, которые не имеют логотипа Docker, Вы поставите себя в безвыходное положение.
Приложение
Теперь перейдем к примеру приложения, чтобы продемонстрировать несколько общих задач при запуске типичного приложения. Пример выполняет следующие задачи во время запуска:
- Загружает параметры конфигурации из JSON закодированного конфиг. файла
- Получает доступ к рабочей директории
- Устанавливает соединение с внешней базой данных MySQL
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net"
"os"
_ "github.com/go-sql-driver/mysql"
)
var (
config Config
db *sql.DB
)
type Config struct {
DataDir string `json:"datadir"`
// Database settings.
Host string `json:"host"`
Port string `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
}
func main() {
log.Println("Starting application...")
// Load configuration settings.
data, err := ioutil.ReadFile("/etc/config.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &config); err != nil {
log.Fatal(err)
}
// Use working directory.
_, err = os.Stat(config.DataDir)
if err != nil {
log.Fatal(err)
}
// Connect to database.
hostPort := net.JoinHostPort(config.Host, config.Port)
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?timeout=30s",
config.Username, config.Password, hostPort, config.Database)
db, err = sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
if err := db.Ping(); err != nil {
log.Fatal(err)
}
}
Полный исходный код доступен на GitHub.
Как вы можете видеть, тут нет ничего особенного, но если Вы посмотрите внимательно, то сможете увидеть, что это приложение будет автоматически загружаться только при определенных условиях. Если конфигурационный файл или рабочий каталог будет отсутствовать, или база данных не будет доступна во время запуска, то вышеупомянутое приложение не запустится.
Давайте развернем пример приложения через Docker и исследуем его.
Создайте приложение, используя команду docker build
:
$ GOOS=linux go build -o app
Теперь, создайте контейнер из app: v1 образа Docker, используя команду docker run
:
FROM scratch
MAINTAINER Kelsey Hightower
COPY app /app
ENTRYPOINT ["/app"]
Все, что я тут делаю — копирую двоичный файл приложения в нужное место. Этот образ контейнера будет использовать сценарий базового образа, приводящего к минимальному образу Docker контейнера, подходящего для развертывания нашего приложения.
Создайте образ, используя команду docker build
:
$ docker build -t app:v1 .
Наконец, создайте контейнер из app: v1 образа, используя команду docker run
:
$ docker run --rm app:v1
2015/12/13 04:00:34 Starting application...
2015/12/13 04:00:34 open /etc/config.json: no such file or directory
Да начнется боль! Прямо, практически на старте, я столкнулся с первой проблемой запуска. Обратите внимание, что приложение не запускается из-за пропавшего /etc/config.json
конфигурационного файла. Я могу исправить это, монтируя конфигурационный файл во время выполнения:
$ docker run --rm \
-v /etc/config.json:/etc/config.json \
app:v1
2015/12/13 07:36:27 Starting application...
2015/12/13 07:36:27 stat /var/lib/data: no such file or directory
Другая ошибка! На сей раз приложению не удается запуститься, потому что не существует /var/lib/data
каталога. Я могу легко обойти пропавшую директорию, монтируя другой хост-каталог в контейнере:
$ docker run --rm \
-v /etc/config.json:/etc/config.json \
-v /var/lib/data:/var/lib/data \
app:v1
2015/12/13 07:44:18 Starting application...
2015/12/13 07:44:48 dial tcp 203.0.113.10:3306: i/o timeout
Мы делаем успехи, но я забыл настроить доступ к базе данных для данного экземпляра.
Это — точка, где некоторые люди начинают использовать инструменты управления конфигурацией, чтобы гарантировать, что все эти зависимости запускаются прежде чем запустится приложение. Хотя это и работает, оно все же в какой то степени является излишеством и зачастую неправильным подходом для решения проблем прикладного уровня.
Я слышу безмолвные крики от хипстеров «сис-админов», нетерпеливо ожидающих, чтобы предложить использовать пользовательскую точку входа Docker, чтобы решить наши проблемы начальной загрузки.
Пользовательская точка входа во спасение.
Один из способов решить наши проблемы запуска состоит в том, чтобы создать shell-сценарий и использовать его в качестве точки входа Docker, вместо фактического приложения. Вот короткий список вещей которые мы можем выполнить используя shell-сценарий как точку входа:
- Сгенерировать требуемый/etc/config.json конфигурационный файл
- создать требуемый/var/lib/data каталог
- протестировать соединение с базой данных пока она доступна
Следующий shell-сценарий занимается первыми двумя элементами, добавляя возможность использовать переменные окружения вместе с /etc/config.json
конфигурационного файла и создавая пропавший /var/lib/data
каталог во время процесса запуска. Сценарий выполняет пример приложения в качестве конечной стадии, сохраняя первоначальное поведение при запуске приложения по умолчанию.
#!/bin/sh
set -e
datadir=${APP_DATADIR:="/var/lib/data"}
host=${APP_HOST:="127.0.0.1"}
port=${APP_PORT:="3306"}
username=${APP_USERNAME:=""}
password=${APP_PASSWORD:=""}
database=${APP_DATABASE:=""}
cat < /etc/config.json
{
"datadir": "${datadir}",
"host": "${host}",
"port": "${port}",
"username": "${username}",
"password": "${password}",
"database": "${database}"
}
EOF
mkdir -p ${APP_DATADIR}
exec "/app"
Теперь образ может быть восстановлен, используя следующий Docker файл:
FROM alpine:3.1
MAINTAINER Kelsey Hightower
COPY app /app
COPY docker-entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
Заметьте, что пользовательский shell-сценарий копируется в образ Docker и используется в качестве точки входа вместо двоичного файла приложения.
Создайте app: v2 образ, используя команду docker build:
$ docker build -t app:v2 .
Теперь выполните следующий шаг:
$ docker run --rm \
-e "APP_DATADIR=/var/lib/data" \
-e "APP_HOST=203.0.113.10" \
-e "APP_PORT=3306" \
-e "APP_USERNAME=user" \
-e "APP_PASSWORD=password" \
-e "APP_DATABASE=test" \
app:v2
2015/12/13 04:44:29 Starting application...
Пользовательская точка входа работает. Используя только переменные окружения мы в состоянии сконфигурировать и запустить наше приложение.Но почему мы делаем это?
Почему мы должны использовать такой сложный сценарий обертки? Некоторые скажут, что намного проще записать эту функциональность в оболочке, чем реализовывать ее в приложении. Но дело не только в управлении shell-сценарями. Заметили другое различие между v1 и v2 файлами?
FROM alpine:3.1
v2 файл использует alpine — базовый образ, чтобы обеспечить среду сценариев, но он удваивает размер нашего образа Docker:
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
app v2 1b47f1fbc7dd 2 hours ago 10.99 MB
app v1 42273e8664d5 2 hours ago 5.952 MB
Другой недостаток этого подхода — неспособность использовать конфигурационный файл с образом. Мы можем продолжать писать сценарий и добавить поддержку конфигурационного файла и переменной среды, но все это просто потеряет работоспособность, когда сценарий обертки выйдет из синхронизации с приложением. Но существует другой способ решения данной проблемы.
Программирование всех спасет.
Да, старое доброе программирование. Каждая из задач shell-сценария точки входа Docker, может быть обработана непосредственно приложением.
Не поймите меня неправильно, использование сценария точки входа хорошо для приложений, которыми Вы не управляете. Но, когда Вы полагаетесь на сценарии точки входа для приложений, Вы добавляете другой уровень сложности к процессу развертывания приложения безосновательно.
Файлы конфигурации должны быть дополнительными
Думаю, что нет абсолютно никаких причин использовать конфигурационные файлы с конца 90-х годов. Я предлагаю загружать конфигурационный файл, если он существует и делает откат до параметров по умолчанию. Следующий фрагмент кода делает именно это.
// Load configuration settings.
data, err := ioutil.ReadFile("/etc/config.json")
// Fallback to default values.
switch {
case os.IsNotExist(err):
log.Println("Config file missing using defaults")
config = Config{
DataDir: "/var/lib/data",
Host: "127.0.0.1",
Port: "3306",
Database: "test",
}
case err == nil:
if err := json.Unmarshal(data, &config); err != nil {
log.Fatal(err)
}
default:
log.Println(err)
}
Использование переменной среды для конфигурации.
Это — одна из самых простых вещей, которую Вы можете сделать непосредственно в Вашем приложении. В следующем фрагменте кода переменные среды используются, чтобы переопределить параметры конфигурации.
log.Println("Overriding configuration from env vars.")
if os.Getenv("APP_DATADIR") != "" {
config.DataDir = os.Getenv("APP_DATADIR")
}
if os.Getenv("APP_HOST") != "" {
config.Host = os.Getenv("APP_HOST")
}
if os.Getenv("APP_PORT") != "" {
config.Port = os.Getenv("APP_PORT")
}
if os.Getenv("APP_USERNAME") != "" {
config.Username = os.Getenv("APP_USERNAME")
}
if os.Getenv("APP_PASSWORD") != "" {
config.Password = os.Getenv("APP_PASSWORD")
}
if os.Getenv("APP_DATABASE") != "" {
config.Database = os.Getenv("APP_DATABASE")
}
Управление рабочей директорией приложения.
Вместо того чтобы перекладывать ответственность за работу и связность с директориями на внешние инструменты или на скрипт точки входа, Ваше приложение должно управлять ими непосредственно. Если по какой то причине, что то не работает, не забудьте настроить логирование ошибки с деталями:
// Use working directory.
_, err = os.Stat(config.DataDir)
if os.IsNotExist(err) {
log.Println("Creating missing data directory", config.DataDir)
err = os.MkdirAll(config.DataDir, 0755)
}
if err != nil {
log.Fatal(err)
}
Избавьте от необходимости запускать службы в определенном порядке
Уберите требование развертывания для Вашего приложения в определенном порядке. Я видел, что во многих руководствах по развертыванию различных приложений есть указание запускать приложение после запуска базы данных, в противном же случае получится нулевой результат.
Избавиться от данного требования можно вот так:
$ docker run --rm \
-e "APP_DATADIR=/var/lib/data" \
-e "APP_HOST=203.0.113.10" \
-e "APP_PORT=3306" \
-e "APP_USERNAME=user" \
-e "APP_PASSWORD=password" \
-e "APP_DATABASE=test" \
app:v3
2015/12/13 05:36:10 Starting application...
2015/12/13 05:36:10 Config file missing using defaults
2015/12/13 05:36:10 Overriding configuration from env vars.
2015/12/13 05:36:10 Creating missing data directory /var/lib/data
2015/12/13 05:36:10 Connecting to database at 203.0.113.10:3306
2015/12/13 05:36:40 dial tcp 203.0.113.10:3306: i/o timeout
2015/12/13 05:37:11 dial tcp 203.0.113.10:3306: i/o timeout
Заметьте в вышеупомянутом выводе я не в состоянии подключиться к работающей целевой базе данных находящейся на 203.0.113.10…
Выполните следующую команду, чтобы предоставить доступ к базе данных «MySQL»:
$ gcloud sql instances patch mysql \
--authorized-networks "203.0.113.20/32"
Приложение в состоянии соединиться с базой данных и завершить процесс запуска.
2015/12/13 05:37:43 dial tcp 203.0.113.10:3306: i/o timeout
2015/12/13 05:37:46 Application started successfully.
Код для выполнения выглядит примерно так:
// Connect to database.
hostPort := net.JoinHostPort(config.Host, config.Port)
log.Println("Connecting to database at", hostPort)
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?timeout=30s",
config.Username, config.Password, hostPort, config.Database)
db, err = sql.Open("mysql", dsn)
if err != nil {
log.Println(err)
}
var dbError error
maxAttempts := 20
for attempts := 1; attempts <= maxAttempts; attempts++ {
dbError = db.Ping()
if dbError == nil {
break
}
log.Println(dbError)
time.Sleep(time.Duration(attempts) * time.Second)
}
if dbError != nil {
log.Fatal(dbError)
}
Ничего особенного здесь нет. Я просто повторяю соединение с базой данных и увеличиваю время между каждой попыткой.
Отлично, мы получили процесс запуска с дружественным сообщением в журнале, что приложение правильно запустилось.
log.Println("Application started successfully.")
Поверьте мне, Ваш сис-админ поблагодарит Вас.
Ссылку на первоначальный источник Вы можете найти тут.