
Привет, друзья! Продолжаю делиться с вами заметками о Docker.
Заметки состоят из 4 частей: 2 теоретических и 2 практических. Если быть более конкретным:
- первая часть посвящена
Docker,Docker CLIиDockerfile; - во второй части рассказывается о
Docker Compose.
В этой части мы разработаем простое приложение, состоящее из трех сервисов и базы данных, а в заключительной — "контейнеризуем" его.
Репозиторий с кодом приложения.
Если вам это интересно, прошу под кат.
Подготовка и настройка проекта
Предполагается, что вы хотя бы вкратце ознакомились с содержанием предыдущих частей или изучали другие материалы, посвященные работе с Docker. Впрочем, в этой части Docker будет совсем чуть-чуть.
Также предполагается, что на вашей машине установлен Docker и Node.js.
Хорошо, если на вашей машине установлен Yarn и вы имеете опыт работы с React, Vue, Node.js, PostgreSQL и sh или bash (все это опционально).
Как я сказал, наше приложение будет состоять из трех сервисов:
- клиента на
React.js; - админки на
Vue.js; - сервера (API) на
Node.js.
В качестве базы данных мы будем использовать PostgreSQL, а для взаимодействия с ней — Prisma.
Функционал нашего приложения будет следующим:
- в админке задаются настройки для приветствия, темы и базового размера шрифта;
- эти настройки записываются в БД и применяются на клиенте;
- на клиенте реализована "тудушка";
- задачи записываются в БД;
- все это обслуживается сервером.
Создаем директорию для проекта, переходим в нее и создаем еще парочку директорий:
mkdir docker-test cd !$ # docker-test mkdir services sh uploads
В директории services будут находиться наши сервисы, в директории sh — скрипты для терминала, директорию uploads мы использовать не будем, но обычно в ней хранятся различные файлы, загружаемые админом или пользователями.
Переходим в директорию services, создаем директорию для API, генерируем шаблон клиента с помощью Create React App и ша��лон админки с помощью Vue CLI:
cd services mkdir api yarn create react-app client # or npx create-react-app client yarn create vue-app admin # or npx vue create admin
Начнем с API.
API
Переходим в директорию api, инициализируем Node.js-проект и устанавливаем зависимости:
cd api yarn init -yp # or npm init -y # производственные зависимости yarn add express cors # зависимости для разработки yarn add -D nodemon prisma
- express —
Node.js-фреймворкдля разработки веб-серверов; - cors — утилита для работы с CORS;
- nodemon — утилита для запуска сервера для разработки;
- prisma — ядро (core) ORM, которое мы будем использовать для взаимодействия с
postgres.
Инициализируем prisma:
npx prisma init
Это приводит к генерации директории prisma, а также файлов prisma/schema.prisma и .env.
Определяем генератор, источник данных и модели в файле prisma/schema.prisma:
// https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" // это нужно для контейнера binaryTargets = ["native"] } datasource db { provider = "postgresql" // путь к БД извлекается из переменной среды окружения `DATABASE_URL` url = env("DATABASE_URL") } // модель для настроек model Settings { id Int @id @default(autoincrement()) created_at DateTime @default(now()) updated_at DateTime @updatedAt greetings String theme String base_font_size String } // модель для задачи model Todo { id Int @id @default(autoincrement()) created_at DateTime @default(now()) updated_at DateTime @updatedAt text String done Boolean }
Определяем путь к БД в файле .env:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb?schema=public
Здесь:
postgres— имя пользователя и пароль;localhost— хост, на котором запущен серверpostgres;5432— порт, на котором запущен серверpostgres;mydb— название БД.
Определяем команду для запуска контейнера postgres в файле sh/db (без расширения):
docker run --rm --name postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=mydb -dp 5432:5432 -v $HOME/docker/volumes/postgres:/var/lib/postgresql/data postgres
Обратите внимание: если вы работаете на Mac, вам потребуется предоставить самому себе разрешение на выполнение кода из файла sh/db. Это можно сделать так:
# мы находимся в директории `sh` chmod +x db # or sudo chmod +x db
Находясь в корневой директории проекта, открываем терминал и выполняем команду:
sh/db
Происходит загрузка образа postgres из Docker Hub и запуск контейнера под названием postgres.
Обратите внимание: иногда может возникнуть ошибка, связанная с тем, что порт 5432 занят другим процессом. В этом случае необходимо найти PID данного процесса и "убить" его. На Mac это делается так:
# получаем `PID` процесса, запущенного на порту `5432` sudo lsof -i :5432 # предположим, что `PID` имеет значение `103` # "убиваем" процесс sudo kill 103
Убедиться в запуске контейнера можно, выполнив команду docker ps:

Или запустив Docker Desktop:

Или в разделе Individual Containers расширения Docker для VSCode:

Выполняем миграцию:
# мы находимся в директории `api` # migrate dev - миграция для разработки # --name init - название миграции npx prisma migrate dev --name init
Это приводит к генерации файла prisma/migrations/[Date]-init/migration.sql, подключению к БД, созданию в ней таблиц, установке и настройке @prisma/client.
Создаем файл prisma/seed.js с кодом для заполнения БД начальными данными:
import Prisma from '@prisma/client' const { PrismaClient } = Prisma // инициализируем клиента const prisma = new PrismaClient() // начальные настройки const initialSettings = { greetings: 'Welcome to Docker Test App', theme: 'light', base_font_size: '16px' } // начальные задачи const initialTodos = [ { text: 'Eat', done: true }, { text: 'Code', done: true }, { text: 'Sleep', done: false }, { text: 'Repeat', done: false } ] async function main() { try { // если таблица настроек является пустой if (!(await prisma.settings.findFirst())) { await prisma.settings.create({ data: initialSettings }) } // если таблица задач является пустой if (!(await (await prisma.todo.findMany()).length)) { await prisma.todo.createMany({ data: initialTodos }) } console.log('Database has been successfully seeded ? ') } catch (e) { console.log(e) } finally { await prisma.$disconnect() } } main()
В package.json определяем тип кода сервера (модуль), команды для запуска сервера в режиме для разработки и производственном режиме, а также команду для заполнения БД начальными данными:
"type": "module", "scripts": { "dev": "nodemon", "start": "prisma generate && prisma migrate deploy && node index.js" }, "prisma": { "seed": "node prisma/seed.js" }
Заполняем БД начальными данными:
# мы находимся в директории `api` npx prisma db seed
Открываем нашу БД в интерактивном режиме:
npx prisma studio
Это приводит к открытию вкладки браузера по адресу http://localhost:5555:

Приступаем к разработке сервера.
Структура сервера будет следующей:
- routes - index.js - settings.routes.js - маршруты (роуты) для настроек - todo.routes.js - роуты для задач - index.js
Содержание файла index.js:
// импортируем библиотеки и утилиты import express from 'express' import cors from 'cors' import Prisma from '@prisma/client' import apiRoutes from './routes/index.js' import { join, dirname } from 'path' import { fileURLToPath } from 'url' const { PrismaClient } = Prisma // создаем и экспортируем экземпляр `prisma` export const prisma = new PrismaClient() // путь к текущей директории const __dirname = dirname(fileURLToPath(import.meta.url)) // создаем экземпляр приложения `express` const app = express() // отключаем `cors` app.use(cors()) // включаем парсинг `json` в объекты app.use(express.json()) // это пригодится нам при запуске приложения в производственном режиме if (process.env.ENV === 'prod') { // обратите внимание на пути // путь к текущей директории + `client/build` const clientBuildPath = join(__dirname, 'client', 'build') // путь к текущей директории + `admin/dist` const adminDistPath = join(__dirname, 'admin', 'dist') // обслуживание статических файлов // клиент будет доступен по пути сервера app.use(express.static(clientBuildPath)) app.use(express.static(adminDistPath)) // админка будет доступна по пути сервера + `/admin` app.use('/admin', (req, res) => { res.sendFile(join(adminDistPath, decodeURIComponent(req.url))) }) } // роутинг app.use('/api', apiRoutes) // обработчик ошибок app.use((err, req, res, next) => { console.log(err) const status = err.status || 500 const message = err.message || 'Something went wrong. Try again later' res.status(status).json({ message }) }) // запускаем сервер на порту 5000 app.listen(5000, () => { console.log(`Server ready ? `) })
Рассмотрим роуты.
Начнем с роутера приложения (routes/index.js):
import { Router } from 'express' import todoRoutes from './todo.routes.js' import settingsRoutes from './settings.routes.js' const router = Router() router.use('/todo', todoRoutes) router.use('/settings', settingsRoutes) export default router
Роутер для настроек (routes/settings.routes.js):
import { Router } from 'express' import { prisma } from '../index.js' const router = Router() // получение настроек router.get('/', async (req, res, next) => { try { const settings = await prisma.settings.findFirst() res.status(200).json(settings) } catch (e) { next(e) } }) // обновление настроек router.put('/:id', async (req, res, next) => { const id = Number(req.params.id) try { const settings = await prisma.settings.update({ data: req.body, where: { id } }) res.status(201).json(settings) } catch (e) { next(e) } }) export default router
Роутер для задач (routes/todo.routes.js):
import { Router } from 'express' import { prisma } from '../index.js' const router = Router() // получение задач router.get('/', async (req, res, next) => { try { const todos = (await prisma.todo.findMany()).sort( (a, b) => a.created_at - b.created_at ) res.status(200).json(todos) } catch (e) { next(e) } }) // создание задачи router.post('/', async (req, res, next) => { try { const newTodo = await prisma.todo.create({ data: req.body }) res.status(201).json(newTodo) } catch (e) { next(e) } }) // обновление задачи router.put('/:id', async (req, res, next) => { const id = Number(req.params.id) try { const updatedTodo = await prisma.todo.update({ data: req.body, where: { id } }) res.status(201).json(updatedTodo) } catch (e) { next(e) } }) // удаление задачи router.delete('/:id', async (req, res, next) => { const id = Number(req.params.id) try { await prisma.todo.delete({ where: { id } }) res.sendStatus(201) } catch (e) { next(e) } }) export default router
Это все, что требуется от нашего сервера.
Админка
Структура админки будет следующей (admin/src):
- components - App.vue - основной компонент приложения - Settings.vue - компонент для обновления настроек - index.js
Начнем с основного компонента (components/App.vue).
Разметка:
<template> <div id="app"> <h1>Admin</h1> <!-- Загрузка --> <h2 v-if="loading">Loading...</h2> <!-- Ошибка --> <h3 v-else-if="error" class="error"> {{ error.message || 'Something went wrong. Try again later' }} </h3> <!-- Компонент для обновления настроек --> <div v-else> <h2>Settings</h2> <!-- Пропы: настройки, полученные от сервера (из БД), метод для их получения и адрес API --> <Settings :settings="settings" :getSettings="getSettings" :apiUri="apiUri" /> </div> </div> </template>
Стили:
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap'); :root { --primary: #0275d8; --success: #5cb85c; --warning: #f0ad4e; --danger: #d9534f; --light: #f7f7f7; --dark: #292b2c; } * { font-family: 'Montserrat', sans-serif; font-size: 1rem; } body.light { background-color: var(--light); color: var(--dark); } body.dark { background-color: var(--dark); color: var(--light); } #app { display: flex; flex-direction: column; text-align: center; } h2 { font-size: 1.4rem; } form div { display: flex; flex-direction: column; align-items: center; } label { margin: 0.5rem 0; } input { padding: 0.5rem; max-width: 220px; width: max-content; outline: none; border: 1px solid var(--dark); border-radius: 4px; text-align: center; } input:focus { border-color: var(--primary); } button { margin: 1rem 0; padding: 0.5rem 1rem; background: none; border: none; border-radius: 4px; outline: none; background-color: var(--success); color: var(--light); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); cursor: pointer; user-select: none; transition: 0.2s; } button:active { box-shadow: none; } .error { color: var(--danger); }
Скрипт:
// импортируем компонент для обновления настроек import Settings from './Settings' export default { // название компонента name: 'App', // дочерние компоненты components: { Settings }, // начальное состояние data() { return { loading: true, error: null, settings: {}, apiUri: 'http://localhost:5000/api/settings' } }, // монтирование компонента created() { // получаем настройки this.getSettings() }, // методы methods: { // для получения настроек async getSettings() { this.loading = true try { const response = await fetch(API_URI) if (!response.ok) throw response this.settings = await response.json() } catch (e) { this.error = e } finally { this.loading = false } } } }
Теперь рассмотрим компонент для обновления настроек (components/Settings.vue).
Разметка:
<template> <!-- Загрузка --> <div v-if="loading">Loading...</div> <!-- Ошибка --> <div v-else-if="error"> {{ error.message || JSON.stringify(error, null, 2) }} </div> <!-- Настройки --> <form v-else @submit.prevent="saveSettings"> <!-- Приветствие --> <div> <label for="greetings">Greetings</label> <input type="text" id="greetings" name="greetings" :value="settings.greetings" required /> </div> <!-- Тема --> <div> <label for="theme">Theme</label> <input type="text" id="theme" name="theme" :value="settings.theme" required /> </div> <!-- Базовый размер шрифта --> <div> <label for="base_font_size">Base font size</label> <input type="text" id="base_font_size" name="base_font_size" :value="settings.base_font_size" required /> </div> <button>Save</button> </form> </template>
Скрипт:
export default { // название компонента name: 'Settings', // пропы props: { settings: { type: Object, required: true }, getSettings: { type: Function, required: true }, apiUri: { type: String, required: true } }, // начальное состояние data() { return { loading: false, error: null } }, // методы methods: { // для обновления настроек в БД async saveSettings(e) { this.loading = true const formDataObj = [...new FormData(e.target)].reduce( (obj, [key, val]) => ({ ...obj, [key]: val }), {} ) try { const response = await fetch(`${this.apiUri}/${this.settings.id}`, { method: 'PUT', body: JSON.stringify(formDataObj), headers: { 'Content-Type': 'application/json' } }) if (!response.ok) throw response // получаем обновленные настройки await this.getSettings() } catch (e) { this.error = e } finally { this.loading = false } } } }
На этом с админкой мы закончили.
Клиент
Структура клиента будет следующей (client/src):
- api - settings.api.js - API для настроек - todo.api.js - API для задач - components - TodoForm.js - компонент для создания задачи - TodoList.js - компонент для формирования списка задач - hooks - useStore.js - хранилище состояние в виде пользовательского хука - App.js - основной компонент приложения - App.css - index.js
Для управления состоянием приложения мы будем использовать Zustand.
Устанавливаем его:
yarn add zustand
Начнем с API для настроек (api/settings.api.js):
// конечная точка const API_URI = 'http://localhost:5000/api/settings' // метод для получения настроек const fetchSettings = async () => { try { const response = await fetch(API_URI) if (!response.ok) throw response return await response.json() } catch (e) { throw e } } const settingsApi = { fetchSettings } export default settingsApi
API для задач (api/todo.api.js):
// конечная точка const API_URI = 'http://localhost:5000/api/todo' // метод для получения задач const fetchTodos = async () => { try { const response = await fetch(API_URI) if (!response.ok) throw response return await response.json() } catch (e) { throw e } } // метод для создания новой задачи const addTodo = async (newTodo) => { try { const response = await fetch(API_URI, { method: 'POST', body: JSON.stringify(newTodo), headers: { 'Content-Type': 'application/json' } }) if (!response.ok) throw response return await response.json() } catch (e) { throw e } } // метод для обновления задачи const updateTodo = async (id, changes) => { try { const response = await fetch(`${API_URI}/${id}`, { method: 'PUT', body: JSON.stringify(changes), headers: { 'Content-Type': 'application/json' } }) if (!response.ok) throw response return await response.json() } catch (e) { throw e } } // метод для удаления задачи const removeTodo = async (id) => { try { const response = await fetch(`${API_URI}/${id}`, { method: 'DELETE' }) if (!response.ok) throw response } catch (e) { throw e } } const todoApi = { fetchTodos, addTodo, updateTodo, removeTodo } export default todoApi
Хранилище состояния в виде пользовательского хука (hooks/useStore.js):
import create from 'zustand' // API для настроек import settingsApi from '../api/settings.api' // API для задач import todoApi from '../api/todo.api' const useStore = create((set, get) => ({ // начальное состояние settings: {}, todos: [], loading: false, error: null, // методы для // получения настроек fetchSettings() { set({ loading: true }) settingsApi .fetchSettings() .then((settings) => { set({ settings }) }) .catch((error) => { set({ error }) }) .finally(() => { set({ loading: false }) }) }, // получения задач fetchTodos() { set({ loading: true }) todoApi .fetchTodos() .then((todos) => { set({ todos }) }) .catch((error) => { set({ error }) }) .finally(() => { set({ loading: false }) }) }, // создания задачи addTodo(newTodo) { set({ loading: true }) todoApi .addTodo(newTodo) .then((newTodo) => { const todos = [...get().todos, newTodo] set({ todos }) }) .catch((error) => { set({ error }) }) .finally(() => { set({ loading: false }) }) }, // обновления задачи updateTodo(id, changes) { set({ loading: true }) todoApi .updateTodo(id, changes) .then((updatedTodo) => { const todos = get().todos.map((todo) => todo.id === updatedTodo.id ? updatedTodo : todo ) set({ todos }) }) .catch((error) => { set({ error }) }) .finally(() => { set({ loading: false }) }) }, // удаления задачи removeTodo(id) { set({ loading: true }) todoApi .removeTodo(id) .then(() => { const todos = get().todos.filter((todo) => todo.id !== id) set({ todos }) }) .catch((error) => { set({ error }) }) .finally(() => { set({ loading: false }) }) } })) export default useStore
Компонент для создания новой задачи (components/TodoForm.js):
import { useState, useEffect } from 'react' import useStore from '../hooks/useStore' export default function TodoForm() { // метод для создания задачи из хранилища const addTodo = useStore(({ addTodo }) => addTodo) // состояние для текста новой задачи const [text, setText] = useState('') const [disable, setDisable] = useState(true) useEffect(() => { setDisable(!text.trim()) }, [text]) // метод для обновления текста задачи const onChange = ({ target: { value } }) => { setText(value) } // метод для отправки формы const onSubmit = (e) => { e.preventDefault() if (disable) return const newTodo = { text, done: false } addTodo(newTodo) } return ( <form onSubmit={onSubmit}> <label htmlFor='text'>New todo text</label> <input type='text' id='text' value={text} onChange={onChange} /> <button className='add'>Add</button> </form> ) }
Компонент для формирования списка задач (components/TodoList.js):
import useStore from '../hooks/useStore' export default function TodoList() { // задачи и методы для обновления и удаления задачи из хранилища const { todos, updateTodo, removeTodo } = useStore( ({ todos, updateTodo, removeTodo }) => ({ todos, updateTodo, removeTodo }) ) return ( <ul> {todos.map(({ id, text, done }) => ( <li key={id}> <input type='checkbox' checked={done} onChange={() => { updateTodo(id, { done: !done }) }} /> <span>{text}</span> <button onClick={() => { removeTodo(id) }} > Remove </button> </li> ))} </ul> ) }
Основной компонент приложения (App.js):
import { useEffect } from 'react' import './App.css' import useStore from './hooks/useStore' import TodoForm from './components/TodoForm' import TodoList from './components/TodoList' // получаем настройки useStore.getState().fetchSettings() // получаем задачи useStore.getState().fetchTodos() function App() { // настройки, индикатор загрузки и ошибка из хранилища const { settings, loading, error } = useStore( ({ settings, loading, error }) => ({ settings, loading, error }) ) useEffect(() => { if (Object.keys(settings).length) { // применяем базовый размер шрифта к элементу `html` document.documentElement.style.fontSize = settings.base_font_size // применяем тему document.body.className = settings.theme } }, [settings]) // загрузка if (loading) return <h2>Loading...</h2> // ошибка if (error) return ( <h3 className='error'> {error.message || 'Something went wrong. Try again later'} </h3> ) return ( <div className='App'> <h1>Client</h1> <h2>{settings.greetings}</h2> <TodoForm /> <TodoList /> </div> ) } export default App
Стили (App.css):
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap'); :root { --primary: #0275d8; --success: #5cb85c; --warning: #f0ad4e; --danger: #d9534f; --light: #f7f7f7; --dark: #292b2c; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Montserrat', sans-serif; font-size: 1rem; } /* Тема */ body.light { background-color: var(--light); color: var(--dark); } body.dark { background-color: var(--dark); color: var(--light); } /* --- */ #root { padding: 1rem; display: flex; justify-content: center; } .App { display: flex; flex-direction: column; align-items: center; } h1, h2 { margin: 1rem 0; } h1 { font-size: 1.6rem; } h2 { font-size: 1.4rem; } h3 { font-size: 1.2rem; } label { margin-bottom: 0.5rem; display: block; } form { margin: 1rem 0; } form input { padding: 0.5rem; max-width: 220px; width: max-content; outline: none; border: 1px solid var(--dark); border-radius: 4px; text-align: center; } form input:focus { border-color: var(--primary); } ul { list-style: none; } li { margin: 0.75rem 0; display: flex; align-items: center; justify-content: space-between; } li input { width: 18px; height: 18px; } li span { display: block; width: 120px; word-break: break-all; } button { padding: 0.5rem 1rem; background: none; border: none; border-radius: 4px; outline: none; background-color: var(--danger); color: var(--light); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); cursor: pointer; user-select: none; transition: 0.2s; } button:active { box-shadow: none; } button.add { background-color: var(--success); } .error { color: var(--danger); } .App { text-align: center; }
Поскольку отступы и размеры заданы с помощью rem, мы легко можем манипулировать этими значениями, меняя размер шрифта элемента html.
На этом с клиентом мы также закончили.
Проверка работоспособности приложения
Поднимаемся в корневую директорию (docker-test), инициализируем Node.js-проект и устанавливаем concurrently — утилиту для одновременного выполнения команд, определенных в файле package.json:
# мы находимся в директории `docker-test` yarn init -yp yarn add concurrently
Определяем команды для запуска серверов для разработки в package.json:
"scripts": { "dev:client": "yarn --cwd services/client start", "dev:admin": "yarn --cwd services/admin dev", "dev:api": "yarn --cwd services/api dev", "dev": "concurrently \"yarn dev:client\" \"yarn dev:admin\" \"yarn dev:api\"" }
Выполняем команду yarn dev или npm run dev.
Это приводит к запуску 3 серверов для разработки:
- для клиента по адресу
http://localhost:3000:

- для админки по адресу
http://localhost:4000":

- для сервера по адресу
http://localhost:5000.
Меняем настройки в админке:

Перезагружаем клиента:

Видим, что настройки успешно применились.
Работаем с задачами:

Задачи успешно создаются/обновляются/удаляются и сохраняются в БД.
Отлично. Приложение работает, как ожидается.
Пожалуй, это все, о чем я хотел вам рассказать в этой части.
Благодарю за внимание и happy coding!

