Docker — это круто. Docker — это круче, чем круто. Docker 1.0 был выпущен в июне 2014 года. С тех пор он распространяется с поразительной скоростью. Более 37 миллиардов образов было загружено из Docker Hub, сервиса репозитория образов Docker. Docker так популярен, потому что он позволяет очень легко упаковывать и отправлять приложения.
Как вы выполняете докеризацию приложения? И как вы управляете своим стеком докеризованных компонентов? Этот пост в блоге дает практические ответы на оба вопроса. Мы собираемся создать небольшое приложение Celery, которое периодически загружает газетные статьи. Затем мы разбиваем стек на части, настраивая приложение Celery. и его компоненты, наконец, мы собираем все это обратно в виде многоконтейнерного приложения.
Что такое Docker?
Docker позволяет разработчикам упаковывать и запускать приложения с помощью стандартизированных интерфейсов. Такой пакет называется образом Docker. Образ Docker — это переносимый, самодостаточный артефакт. Независимо от того, на каком языке программирования он написан. Это упрощает создание, развёртывание и запуск приложений. В некотором смысле образ Docker похож на образ виртуальной машины. Но образы контейнеров занимают меньше места, чем виртуальные машины.
Когда вы запускаете образ Docker для запуска экземпляра вашего приложения, вы получаете контейнер Docker. Контейнер Docker — это изолированный процесс, который работает в пользовательском пространстве и использует ядро ОС. На одном компьютере может работать несколько контейнеров, каждый из которых представляет собой изолированный процесс.
Пока всё идёт хорошо. Что это вам даёт? Контейнеры предоставляют механизм упаковки. С помощью этого механизма упаковки ваше приложение, его зависимости и библиотеки становятся одним артефактом. Если вашему приложению требуется Debian 8.11 с Git 2.19.1, Mono 5.16.0, Python 3.6.6, несколько пакетов pip и переменная среды PYTHONUNBUFFERED=1, вы определяете всё это в Dockerfile.
Файл Dockerfile содержит инструкции по сборке образа Docker. Он также является отличной документацией. Если вам или другим разработчикам нужно понять требования к вашему приложению, прочтите файл Dockerfile. Файл Dockerfile описывает ваше приложение и его зависимости.
Docker выполняет инструкции Dockerfile для создания образа Docker. Это обеспечивает воспроизводимые сборки независимо от языка программирования. И позволяет предсказуемым и последовательным образом развертывать приложения. Независимо от целевой среды. Частный центр обработки данных, общедоступное облако, виртуальные машины, «голый металл» или ваш ноутбук.
Это даёт вам возможность создавать предсказуемые среды. Ваша среда разработки точно такая же, как тестовая и рабочая среды. Вы как разработчик можете сосредоточиться на написании кода, не беспокоясь о системе, в которой он будет работать.
Для операционных систем Docker сокращает количество систем и пользовательских сценариев развёртывания. Основное внимание уделяется планированию и управлению контейнерами. Операционные системы могут сосредоточиться на надёжности и масштабируемости. И они могут перестать беспокоиться об отдельных приложениях и их специфических зависимостях от среды.
Создадим приложения на Celery
Мы собираемся создать приложение Celery, которое будет периодически сканировать URL-адреса газет на наличие новых статей. Мы будем сохранять новые статьи в хранилище, аналогичном Amazon S3. Это упростит задачу, и мы сможем сосредоточиться на приложении Celery и Docker. Отсутствие базы данных означает отсутствие миграций. А хранилище, аналогичное S3, означает, что мы получаем REST API (и веб-интерфейс) бесплатно. Нам нужны следующие компоненты:
- Наше приложение Celery (приложение newspaper3k)
- RabbitMQ как посредник сообщений
- Minio (сервис хранения данных, подобный Amazon S3)
И RabbitMQ, и Minio — это приложения с открытым исходным кодом. Оба двоичных файла легко доступны. Таким образом, нам остаётся только создать приложение newspaper3k Celery. Давайте начнём с необходимых нам пакетов pip (полный исходный код доступен на GitHub):КопироватьКопировать
# requirements.txt
celery==4.2.1
minio==4.0.6
newspaper3k==0.2.8
Далее идёт само приложение Celery. Я предпочитаю, чтобы всё было чётко. Поэтому мы создаём один файл для работника Celery и другой файл для задачи. Код приложения помещается в специальную папку app:КопироватьКопировать
├── requirements.txt
└── app/
├── worker.py
└── tasks.py
worker.py создаёт экземпляр приложения Celery и настраивает периодический планировщик:КопироватьКопировать
# worker.py
from celery import Celery
app = Celery(
broker='amqp://user:password@localhost:5672',
include=['tasks'])
app.conf.beat_schedule = {
'refresh': {
'task': 'refresh',
'schedule': 300.0,
'args': ([
'https://www.theguardian.com',
'https://www.nytimes.com'
],),
}
Последовательность задач приложения следующая. На основе URL-адреса газеты приложение newspaper3k создаёт список URL-адресов статей. Для каждого URL-адреса статьи нам нужно получить содержимое страницы и проанализировать его. Мы вычисляем хэш MD5 статьи. Если статьи нет в Minio, мы сохраняем её в Minio. Если статья есть в Minio, мы сохраняем её в Minio, если хэши MD5 отличаются.
Наша цель — обеспечить параллелизм и масштабируемость. Для этого наши задачи должны быть атомарными и идемпотентными.
Атомарная операция — это неделимая и неразделимая последовательность операций, при которой либо выполняются все операции, либо не выполняется ни одна. Задача является идемпотентной, если она не вызывает непредвиденных последствий при многократном вызове с одинаковыми аргументами. Задача refresh принимает список URL-адресов газет. Для каждого URL-адреса газеты задача асинхронно вызывает fetch_source, передавая URL-адрес.КопироватьКопировать
# tasks.py
@app.task(bind=True, name='refresh')
def refresh(self, urls):
for url in urls:
fetch_source.s(url).delay()
Задача fetch_source принимает в качестве аргумента URL-адрес газеты. Она генерирует список URL-адресов статей. Для каждого URL-адреса статьи она вызывает fetch_article.КопироватьКопировать
# tasks.py
@app.task(bind=True, name='fetch_source')
def fetch_source(self, url):
source = newspaper.build(url)
for article in source.articles:
fetch_article.s(article.url).delay()
Задача fetch_article ожидает в качестве аргумента URL-адрес статьи. Она загружает и анализирует статью. Она вызывает save_article, передавая доменное имя газеты, заголовок статьи и её содержание.КопироватьКопировать
# tasks.py
@app.task(bind=True, name='fetch_article')
def fetch_article(self, url):
article = newspaper.Article(url)
article.download()
article.parse()
url = urlparse(article.source_url)
save_article.s(url.netloc, article.title, article.text).delay()
Задача save_article требует трёх аргументов. Это доменное имя газеты, заголовок статьи и её содержание. Задача отвечает за сохранение статьи в minio. Имя корзины — это доменное имя газеты. Имя ключа — это заголовок статьи. Здесь мы используем аргумент queue в декораторе задачи. Это отправляет задачу save_task в выделенную очередь Celery с именем minio. Это даёт нам дополнительный контроль над скоростью, с которой мы можем писать новые статьи для Minio. Это помогает нам создавать масштабируемый дизайн.КопироватьКопировать
# tasks.py
@app.task(bind=True, name='save_article', queue='minio')
def save_article(self, bucket, key, text):
minio_client = Minio('localhost:9000',
access_key='AKIAIOSFODNN7EXAMPLE',
secret_key='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
secure=False)
try:
minio_client.make_bucket(bucket, location="us-east-1")
except BucketAlreadyExists:
pass
except BucketAlreadyOwnedByYou:
pass
hexdigest = hashlib.md5(text.encode()).hexdigest()
try:
st = minio_client.stat_object(bucket, key)
update = st.etag != hexdigest
except NoSuchKey as err:
update = True
if update:
stream = BytesIO(text.encode())
minio_client.put_object(bucket, key, stream, stream.getbuffer().nbytes)
Когда дело доходит до развёртывания и запуска нашего приложения, нам нужно позаботиться о нескольких вещах. Обычно это решается с помощью написания скриптов. В частности, нам нужно:
- убедитесь, что на хост-компьютере установлена правильная версия Python, и при необходимости установите или обновите её
- убедитесь, что существует виртуальная среда Python для нашего приложения Celery; при необходимости создайте и запустите pip install -r requirements.txt
- убедитесь, что нужная версия RabbitMQ запущена где-нибудь в нашей сети
- убедитесь, что желаемая версия Minio запущена где-нибудь в нашей сети
- разверните желаемую версию вашего приложения Celery
- убедитесь, что следующие процессы настроены в Supervisor или Upstart:
- Смесь из сельдерея
- очередь по умолчанию Celery worker
- мини-очередь Celery worker
- перезапустите Supervisor или Upstart, чтобы запустить работников Celery и Beat после каждого развёртывания
Настройте все, что нужно
Сначала простые вещи. Как RabbitMQ, так и Minio легко доступны вместе с образами Docker на Docker Hub. Docker Hub — крупнейшая общедоступная библиотека изображений. Это первое место для размещения изображений с открытым исходным кодом. В результате нам остается настроить наше приложение Celery. Первым шагом к настройке приложения является создание двух новых файлов: Dockerfile и .dockerignore.КопироватьКопировать
├── Dockerfile
├── .dockerignore
├── requirements.txt
└── app/
├── worker.py
└── tasks.py
.dockerignore служит той же цели, что и .gitignore. Когда мы копируем файлы в образ Docker в процессе сборки Docker, любой файл, соответствующий любому шаблону, указанному в .dockerignore, исключается.
Dockerfile содержит команды, необходимые для создания образа Docker. Docker выполняет эти команды последовательно. Каждая команда называется слоем. Слои повторно используются в нескольких образах. Это экономит место на диске и сокращает время создания образов.КопироватьКопировать
# Dockerfile
FROM python:3.6.6
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONUNBUFFERED=1
WORKDIR /
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN rm requirements.txt
COPY . /
WORKDIR /app
В качестве основы мы используем образ Docker python:3.6.6. Образ python:3.6.6 доступен на Dockerhub. Затем мы задаём некоторые переменные среды. LANG и LC_ALL настраивают локаль Python по умолчанию. Установка PYTHONUNBUFFERED=1 позволяет избежать некоторых аномалий в журналах stdout.
Затем COPY requirements.txt ./ копирует файл requirements.txt в корневую папку образа. Затем мы запускаем pip install. Затем мы удаляем файл requirements.txt из образа, так как он нам больше не нужен. Наконец, COPY . / копирует весь проект в корневую папку образа. Исключая файлы в соответствии с файлом .dockerignore. Поскольку приложение теперь находится в каталоге /app образа, мы делаем его нашим рабочим каталогом. Это означает, что любая команда по умолчанию выполняется в этом каталоге. Выполните рецепт сборки Dockerfile для создания образа Docker:КопироватьКопировать
docker build . -t worker:latest
Опция -t присваивает образу значимое имя (тег). Двоеточие в теге позволяет указать версию. Если вы не укажете версию (worker вместо worker:latest), Docker по умолчанию использует latest. Указывайте версию для всего, что не является локальной разработкой. В противном случае рано или поздно у вас возникнут проблемы.
Рефакторинг приложения Celery
Контейнеризация приложения влияет на его архитектуру. Если вы хотите углубиться в эту тему, я рекомендую ознакомиться с манифестом двенадцатифакторного приложения. Для обеспечения переносимости и масштабируемости двенадцатифакторный подход требует отделения конфигурации от кода. Конфигурация приложения — это всё, что может различаться в разных средах.
Приложение Twelve-Factor хранит конфигурации в переменных среды. Переменные среды легко менять в разных средах. Переменные среды не зависят от языка. Переменные среды глубоко интегрированы в Docker. Измените способ создания экземпляра приложения Celery.КопироватьКопировать
# worker.py
app = Celery(
broker=os.environ['CELERY_BROKER_URL'],
include=('tasks',))
app.conf.beat_schedule = {
'refresh': {
'task': 'refresh',
'schedule': float(os.environ['NEWSPAPER_SCHEDULE']),
'args': (os.environ['NEWSPAPER_URLS'].split(','),)
},
}
Мы можем упростить процесс ещё больше. Любой параметр Celery (полный список доступен здесь) можно задать с помощью переменной среды. Имя переменной среды формируется на основе имени параметра. Имя параметра пишется в верхнем регистре и префиксом CELERY*. Например, чтобы задать адрес брокера, используйте переменную среды CELERY_BROKER_URL.КопироватьКопировать
# worker.py
app = Celery(include=('tasks',))
app.conf.beat_schedule = {
'refresh': {
'task': 'refresh',
'schedule': float(os.environ['NEWSPAPER_SCHEDULE']),
'args': (os.environ['NEWSPAPER_URLS'].split(','),)
},
}
Нам также необходимо реорганизовать способ создания экземпляра Minio-клиента.КопироватьКопировать
# tasks.py
@app.task(bind=True, name='save_article')
def save_article(self, bucket, key, text):
minio_client = Minio(os.environ['MINIO_HOST'],
access_key=os.environ['MINIO_ACCESS_KEY'],
secret_key=os.environ['MINIO_SECRET_KEY'],
secure=int(os.getenv('MINIO_SECURE', '0')))
...
Перестройте изображение:КопироватьКопировать
docker build -t worker:latest
Конфигурация
Наше приложение Celery теперь настраивается с помощью переменных среды. Давайте обобщим переменные среды, необходимые для всего нашего стека:
Рабочее изображение:
- CELERY_BROKER_URL
- MINIO_HOST
- MINIO_ACCESS_KEY
- MINIO_SECRET_KEY
- NEWSPAPER_SCHEDULE
- NEWSPAPER_URLS
Мини-изображение:
- MINIO_ACCESS_KEY
- MINIO_SECRET_KEY
При запуске контейнеров с помощью docker run необходимо передать правильный набор переменных среды. На самом деле вы, скорее всего, никогда не будете использовать docker run. Вместо этого вы будете использовать инструмент оркестрации, такой как Docker Compose. Даже если вы запускаете только один контейнер. Я опущу подробности о docker run (документацию можно найти здесь) и сразу перейду к Docker Compose.
Организуйте работу стека с помощью docker-compose
Теперь, когда у нас есть все наши образы Docker, нам нужно настроить, запустить и заставить их работать вместе. Это похоже на аранжировку музыки для исполнения оркестром. У нас есть отдельные музыкальные партии. Но нам нужно заставить их гармонично работать вместе.
Оркестрация контейнеров — это автоматизация развёртывания, настройки, масштабирования, сетевого взаимодействия и доступности контейнеров. Docker Compose — это простой инструмент для определения и запуска многоконтейнерных приложений Docker. С помощью Docker Compose мы можем описать и настроить весь стек с помощью файла YAML. Файл docker-compose.yml. С помощью одной команды мы можем создать, запустить и остановить весь стек.
Docker Compose создаёт единую сеть для нашего стека. Каждый контейнер подключается к сети и становится доступным для других контейнеров. Docker Compose присваивает каждому контейнеру имя хоста, совпадающее с именем контейнера. Это позволяет обнаруживать каждый контейнер в сети.
В файле docker-compose.yml мы определяем пять сервисов (worker, minio worker, beat, rabbitmq и minio) и один том. Сервисы — это контейнеры в Docker Compose. Сервис запускает образ и определяет способ его запуска. Томы обеспечивают постоянное хранилище. Для получения полной информации обязательно ознакомьтесь с файлом docs Docker Compose.КопироватьКопировать
# docker-compose.yaml
version: '3.4'
services:
worker:
build: .
image: &img worker
command: [celery, worker, --app=worker.app, --pool=gevent, --concurrency=20, --loglevel=INFO]
environment: &env
- CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672
- MINIO_HOST=minio:9000
- MINIO_ACCESS_KEY=token
- MINIO_SECRET_KEY=secret
- NEWSPAPER_URLS=https://www.theguardian.com,https://www.nytimes.com
- NEWSPAPER_SCHEDULE=300
depends_on:
- beat
- rabbitmq
restart: 'no'
volumes:
- ./app:/app
worker-minio:
build: .
image: *img
command: [celery, worker, --app=worker.app, --pool=gevent, --concurrency=20, --queues=minio, --loglevel=INFO]
environment: *env
depends_on:
- beat
- rabbitmq
restart: 'no'
volumes:
- ./app:/app
beat:
build: .
image: *img
command: [celery, beat, --app=worker.app, --loglevel=INFO]
environment: *env
depends_on:
- rabbitmq
restart: 'no'
volumes:
- ./app:/app
rabbitmq:
image: rabbitmq:3.7.8
minio:
image: minio/minio:RELEASE.2018-11-06T01-01-02Z
command: [server, /data]
environment: *env
ports:
- 80:9000
volumes:
- minio:/data
volumes:
minio:
Давайте рассмотрим свойства сервиса по очереди.
- build: строка, содержащая путь к контексту сборки (каталогу, в котором находится Dockerfile). Или объект с указанным в context путём и, при необходимости, Dockerfile и args. Это полезно при использовании docker-compose build worker в качестве альтернативы docker build. Или если вы хотите, чтобы Docker Compose автоматически создавал образ, если он не существует.
- изображение: название изображения
- команда: команда для выполнения внутри контейнера
- окружение: переменные окружения
- порты: откройте порты контейнера на вашем хост-компьютере. Например, minio работает на порту 9000. Мы сопоставляем его с портом 80, то есть он становится доступным по адресу localhost:80.
- перезапуск: что делать, когда процесс контейнера завершается. В этом случае мы не хотим, чтобы Docker Compose перезапускал его.
- тома: сопоставьте постоянный том хранилища (или путь к хосту) с внутренним путём контейнера. Для локальной разработки сопоставление с путём к хосту позволяет разрабатывать внутри контейнера. Для всего, что требует постоянного хранения, используйте том Docker. Здесь мы заставляем minio использовать том Docker. В противном случае мы потеряем все данные при завершении работы контейнера. А контейнеры по своей природе очень недолговечны.
- зависитот: определяет порядок, в котором Docker Compose запускает контейнеры. Это определяет только порядок запуска. Это не гарантирует, что контейнер, от которого он зависит, запущен. RabbitMQ запускается перед контейнерами _beat и worker. К тому времени, когда контейнеры beat и worker будут запущены, RabbitMQ все еще запускается. Просмотрите журналы с помощью docker-compose logs worker или docker-compose logs beat.
Постоянное хранилище определяется в разделе «Тома». Здесь мы объявляем один том с именем minio. Этот том монтируется как /data внутри контейнера Minio. И мы запускаем Minio, чтобы он сохранял свои данные по пути /data. Это том minio. Тома — это предпочтительный механизм для сохранения данных, создаваемых и используемых контейнерами Docker. Подробнее о том, как работают тома Docker, можно узнать здесь. А здесь — подробнее о разделе томов в файле docker-compose.yml.
Если вам интересно, для чего нужны амперсанд — & — и звёздочки — *, то вот что: они помогают при повторении узлов. Амперсанд идентифицирует узел. После этого вы можете ссылаться на этот узел с помощью звёздочки. Это очень удобно для имён образов. Если вы используете один и тот же образ в разных сервисах, вам нужно определить образ только один раз. При обновлении до более новой версии образа вам нужно сделать это только в одном месте в файле yaml.
То же самое относится и к переменным среды. Вы определяете их для всего стека только один раз. А затем можете ссылаться на них во всех своих сервисах. Если вам нужно что-то изменить, вам нужно сделать это только один раз. Это также позволяет использовать одни и те же переменные среды во всём стеке. Например, контейнеру minio требуются MINIO_ACCESS_KEY и MINIO_SECRET_KEY для управления доступом. Мы используем одни и те же переменные на стороне клиента в нашем приложении Celery.
Запустите Docker stack
Теперь, когда у нас есть файл docker-compose.yml, мы готовы к запуску. Перейдите в папку, где находится файл docker-compose.yml. Запустите стек Docker с помощьюКопироватьКопировать
# start up
docker-compose up -d
Minio должен стать доступным по адресу http://localhost. Для входа используйте ключ и секрет, указанные в разделе переменных среды. Следите за журналами с помощью docker-compose logs -f. Или docker-compose logs —f worker, чтобы следить только за журналами рабочих процессов.
Допустим, вам нужно добавить ещё одного работника Celery (увеличив общее количество потоков с 20 до 40).КопироватьКопировать
# scale up number of workers
docker-compose up -d --scale worker=2
И снова вернуться к работе.КопироватьКопировать
# scale down number of workers
docker-compose up -d --scale worker=1
Заключение
Это было довольно напряжённо. Но мы прошли долгий путь. Мы начали обсуждать преимущества запуска приложения в Docker. Затем мы подробно рассмотрели два важных компонента при переходе на Docker:
- создайте контейнер для приложения Celery
- организуйте стек контейнеров с помощью Docker Compose
Я составил небольшой список ресурсов, посвящённых важным аспектам докеризации. В нём рассказывается о важных аспектах проектирования при создании контейнерного приложения:
- https://12factor.net/
- Начните работу с контейнерами Docker
- Двенадцатифакторный контейнер
- Создание минимальных контейнеров Docker для приложений Python
А вот список ресурсов по оркестрации с помощью Docker Compose:
Docker Compose — отличная отправная точка. Это отличный инструмент для локальной разработки и непрерывной интеграции. Он может быть полезен в небольших производственных средах. В то же время Docker Compose привязан к одному хосту и ограничен в более крупных и динамичных средах.
Именно здесь Kubernetes проявляет себя наилучшим образом. Kubernetes_ — это де-факто стандарт для оркестрации контейнеров, который отлично масштабируется. В следующей статье мы перенесём наш небольшой стек Celery-newspaper3k-RabbitMQ-Minio из Docker Compose в Kubernetes.
Александр, добрый день. Статья интересная и полезная, но т.к. я не программист, то в практическом плане не смогу воспользоваться приведенной в ней информацией, для меня слишком сложно. Но у меня есть большое желание попробовать запустить проект для инженеров (специалистов строителей, по инженерным системам — инженеры, инженеры ПТО, проектировщики; эксплуатационщиков — энергетиков и др.).
Проект по сути — система сопровождения проектирования/строительства/монтажа и дальнейшей эксплуатации инженерных систем, либо как система управления жизненным циклом объектовой инженерии (если максимально, от концепции до вывода из эксплуатации). Базовый принцип — «дорожная карта» + технический документооборот, привязанный к этапам.
Для создания инфо-структуры для группы сервисов напрашивается Kubernetes, но возможно Docker Compose будет уже достаточно.
Основной вопрос — могу я надеяться на совместную реализацию данной структуры и размещении сервисов на dockerhosting.ru (первично, далее обсуждаемо с учетом локации)? Проект сложный и реализуемый только совместно (с бизнес-разработчиком из специалистов предметной области), + интеграции с сервисами (сметный, документы, BIM, оргштатные структуры, диаграммы Гантта, возможно ещё и другие).
Вопросы о приложениях в контейнерах, их связях и др. у меня предварительно описаны, но детальная проработка уже позже, по ходу дела. Реализация может быть поэтапной, по отдельным сервисам, но общая структура в т.ч. связей (шина данных) нужна сразу.
Есть ли у вас есть возможности для участия в таком проекта? А если будет ещё и желание участвовать в его реализации, обозначьте, пожалуйста ваши условия.
С уважением, Харитонов Дмитрий.