Евгений Колесников 5 months ago
parent
commit
66edaaf096

+ 339 - 0
articles/docker_cs.md

@@ -0,0 +1,339 @@
+# Создание образа АПИ (C#). Оптимизация образа.
+
+Для `Python` создание образа не сильно отличается от `JavaScript`, а вот для `C#` есть принциальное отличие. Дело в том, что `Python` и `JavaScript` интерпретируемые языки и им для работы нужен интерпретатор, а `C#` компилируемый и после сборки проекта `.NET` в образе уже не нужен. Поэтому для таких образов делают двухстадийную сборку, сначала в образе с установленным `.NET` компилируют исполняемый файл, а потом делают легковесный образ только с исполняемым файлом, полученным на первой стадии.
+
+>Я буду создавать проект на **Macos**, поэтому использую VSC и консольные команды, но все то же самое можно сделать и в VS
+
+## Создание минималистичного апи
+
+Создание "рыбы" проекта:
+
+```
+dotnet new webapi -minimal -o api_cs
+```
+
+Параметр `-o api_cs` задает имя создаваемого проекта
+
+
+Откройте файл `Program.cs` и поменяйте содержимое по умолчанию на следующее:
+
+```cs
+var builder = WebApplication.CreateBuilder(args);
+var app = builder.Build();
+app.MapGet("/", () => "Hello World!");
+app.Run();
+```
+
+Перейдите в каталог с проектом и запустите команды 
+
+```
+docker build
+docker run
+```
+
+На данном этапе проект должен собираться и запускаться без проблем, проверить работу можно выполнив http запрос (порт генерируется автоматически, у вас будет другой):
+
+![](../img/docker08.png)
+
+## Подключение к базе данных
+
+Используем знакомые вам `MySqlConnector` и `Dapper`, установив их соответствующими командами:
+
+```
+dotnet add package MySqlConnector   
+dotnet add package Dapper
+```
+
+### Создаем модель (класс для таблицы Genre)
+
+Создайте каталог `model` и в нем класс **Genre** (консольная команда: `dotnet new class -n Genre`)
+
+```cs
+public class Genre
+{
+    public int id { get; set; }
+    public required string title { get; set; }
+}
+```
+
+### Создаем точку входа для получения списка жанров
+
+```cs
+string connectionString = "Server=127.0.0.1; Port=3308; User ID=root; Password=toor; Database=mydb";
+
+app.MapGet("/genre", () =>
+{
+    using (MySqlConnection db = new MySqlConnection(connectionString))
+    {
+        return db.Query<Genre>(
+            "SELECT * FROM Genre");
+    }
+});
+```
+
+На этом этапе тоже проблем быть не должно - у меня все запустилось
+
+## Добавление миграций
+
+Для **C#** существует много [систем для миграции](https://habr.com/ru/companies/crosstech/articles/453930/):
+
+* **EntityFramework** - включает в себя и возможности миграции, но мы от него отказались и возрождать не будем
+* **Fluent Migrator** - популярный, но требует создания дополнительных классов, а это лишние "проектные знания"
+* **DBup** - небольшая обертка над чистыми SQL-скриптами, идеальный вариант для нас - его и будем использовать
+
+[Делал по этому туториалу](https://medium.com/@niteshsinghal85/dbup-postgresql-dapper-in-asp-net-core-c3be6c580c54)
+
+1. Созадем каталог `migrations`, где будем хранить скрипты для миграции
+1. В настройках проекта (файл `*.csproj`) укажем, чтобы все SQL-файлы из этого каталога включались в проект
+
+    ```xml
+    <ItemGroup>
+        <EmbeddedResource Include="migrations/*.sql" />
+    </ItemGroup>
+    ```
+1. Создадим файл для миграции `migrations/init.sql`
+
+    ```sql
+    insert into Genre values
+        (1, "Комедия"),
+        (2, "Ужасы"),
+        (3, "Триллер");   
+    ```
+
+1. Устанавливаем библиотеку `DBup`
+
+    ```
+    dotnet add package dbup-mysql
+    ```
+
+1. Дописываем в начало нашего `Program.cs` код для накатывания миграций
+
+    ```cs
+    // проверяет наличие возможности подключения к БД
+    EnsureDatabase.For.MySqlDatabase(connectionString);
+
+    // подгатавливает миграцию
+    // куда и что будем накатывать
+    var upgrader = DeployChanges.To
+        .MySqlDatabase(connectionString)
+        .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
+        .LogToConsole()
+        .Build();
+
+    // запуск миграции
+    var result = upgrader.PerformUpgrade();
+
+    // проверка результата миграции
+    if (!result.Successful)
+    {
+        throw new Exception("Не смог сделать миграцию");
+    }
+    ```
+
+    Если все сделали правильно, то в консоли увидите примерно такие логи
+
+    ```
+    2025-09-18 10:31:43 +03:00 [INF] Beginning database upgrade
+    2025-09-18 10:31:43 +03:00 [INF] Checking whether journal table exists
+    2025-09-18 10:31:43 +03:00 [INF] Journal table does not exist
+    2025-09-18 10:31:43 +03:00 [INF] Executing Database Server script 'api_cs.migrations.init.sql'
+    2025-09-18 10:31:43 +03:00 [INF] Checking whether journal table exists
+    2025-09-18 10:31:43 +03:00 [INF] Creating the `schemaversions` table
+    2025-09-18 10:31:44 +03:00 [INF] The `schemaversions` table has been created
+    2025-09-18 10:31:44 +03:00 [INF] Upgrade successful
+    ```
+
+    А в базе добавится таблица `shemaversions`, в которой хранится какие миграции и когда применялись (чтобы повторно не накатить уже выполненную миграцию)
+
+    ![](../img/docker09.png)
+
+Наш проект готов к упаковке в контейнер
+
+## Создание контейнера с многостадийной сборкой
+
+Смысл многостадийной сборки в том, что для сборки (компиляции) проекта нужен **.NET SDK**, а для выполнения уже скомпилированного приложения достаточно так называемого **Runtime** (но можно пойти еще дальше и сделать Standalone приложение)
+
+### SDK vs Runtime
+
+* **SDK** - Используется для разработки приложений.
+
+    Включает в себя все необходимые инструменты для написания компиляции и сборки приложений:
+
+    * Компиляторы для C#, F#, VB.NET.
+    * Инструменты командной строки - `dotnet build`, `dotnet run`.
+    * Библиотеки и шаблоны проектов.
+
+* **.NET Runtime** - Используется для выполнения уже готовых (скомпилированных) приложений.
+
+    Включает только библиотеки и компоненты необходимые для работы приложения.
+
+    * Среда выполнения Common Language Runtime CLR.
+    * Базовые библиотеки .NET.
+
+
+**.NET Runtime** выгоден, когда на компьютере запускается несколько программ на **.NET**, но принцип контейнеризации в том, что в каждом контейнере запущено только одно приложение. Компилятор позволяет создавать так называемые **Standalone** (независимые) приложения, для работы которых фреймворк уже не нужен 
+
+Попробуем собрать все три варианта и сравнить размеры полученных образов (взято [отсюда](https://habr.com/ru/companies/lamoda/articles/807179/))
+
+>Ссылки на официальные образы можно посмотреть на [сайте Microsoft](https://learn.microsoft.com/ru-ru/dotnet/architecture/microservices/net-core-net-framework-containers/official-net-docker-images)
+
+### Вариант без оптимизации, просто сборка и запуск в образе SDK
+
+Создадим файл `Dockerfile.sdk` (обратите внимание, имя файла с расширением - мы напишем несколько вариантов):
+
+```Dockerfile
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+                                      ^^^^^^^^
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY api_cs.csproj .
+RUN dotnet restore api_cs.csproj
+COPY . .
+RUN dotnet build api_cs.csproj -c $BUILD_CONFIGURATION -o ./build
+RUN dotnet publish api_cs.csproj -c $BUILD_CONFIGURATION -o ./publish /p:UseAppHost=false
+CMD ["dotnet", "/src/publish/api_cs.dll"]
+```
+
+что нового:
+
+* `FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build` - при указании базового образа .NET мы указали для него алиас **build** - по этому алиасу мы потом сможем обращаться к содержимому этой стадии сборки
+* `ARG BUILD_CONFIGURATION=Release` - **ARG**ument позволяет задать константы для часто используемых литералов
+* `RUN dotnet restore api_cs.csproj` - команда `dotnet restore` скачивает зависимости проекта
+* `RUN dotnet publish api_cs.csproj -c $BUILD_CONFIGURATION -o ./publish /p:UseAppHost=false` - Команда `dotnet publish` компилирует ваше приложение .NET и создает набор файлов (исполняемый файл, сборки, зависимости, файлы конфигурации) в выходной папке для его последующего развертывания на другом компьютере или в другой среде выполнения. Она готовит приложение к распространению и запуску вне среды разработки, собирая все необходимые для этого компоненты. 
+
+Перед сборкой я вспомнил, что у нас параметры подключения к базе данных прибиты гвоздями - сделаем получение из переменной среды окружения:
+
+```cs
+string? connectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING");
+
+Console.WriteLine("connectionString (from ENV): {0}", connectionString);
+
+if (connectionString == null)
+    connectionString = "Server=127.0.0.1; Port=3308; User ID=root; Password=toor; Database=mydb;";
+```
+
+И соберем образ
+
+```sh
+docker build -f ./Dockerfile.sdk -t api_cs:sdk .
+```
+
+* `-f ./Dockerfile.sdk` - опция `-f` явно указывает имя докерфайла
+
+
+Запускаем собранный образ:
+
+```sh
+docker run --name test_cs_sdk --network test-network -d -p 5000:8080 -e CONNECTION_STRING="Server=test_mysql; User ID=root; Password=toor; Database=mydb;" api_cs:sdk
+```
+
+Замечания:
+
+1. В качестве внутреннего порта контейнера появился `8080` хотя в конфигах этого нигде нет - этот порт используется по-умолчанияю для режима `Release`
+1. Наступил на грабли, предупреждаю: мы с вами в команде запуска сервера mysql не указали сеть (присоединились к ней позже), но если разворачивать эти контейнеры на другом компьютере или удалить и пересоздать контейнеры (мой случай), то окажется, что контейнер с MySQL запущен в собственной сети и апи его не найдет - добавьте в команду запуска контейнера с MySQL опцию `--network test-network`
+
+
+У меня все заработало, проверить можно запросив список жанров на `5000` порту:
+
+```rest
+GET http://localhost:5000/genre
+```
+
+### Вариант с оптимизацией, добавление стадии запуска в образе Runtime
+
+Сборка проекта здесь происходит как и в первом варианте с использованием образа SDK, поэтому скопируем предыдущий докерфайл и дадим название `Dockerfile.runtime`
+
+Но в конец нового докерфайла допишем второй этап - копирование скомпилированных бинарников из первого образа в базовый образ `aspnet`:
+
+```Dockerfile
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY api_cs.csproj .
+RUN dotnet restore api_cs.csproj
+COPY . .
+RUN dotnet build api_cs.csproj -c $BUILD_CONFIGURATION -o ./build
+RUN dotnet publish api_cs.csproj -c $BUILD_CONFIGURATION -o ./publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
+WORKDIR /app
+COPY --from=build /src/publish .
+
+CMD ["dotnet", "/app/api_cs.dll"]
+```
+
+В принципе тут все очевидно, в команде COPY добавился параметр `--from`, который указывает, что исходные данные надо брать не в файловой системе, а в именованом образе
+
+Собираем образ, присвоив ему соответствующий тег
+
+```sh
+docker build -f ./Dockerfile.runtime -t api_cs:runtime .
+```
+
+Запускаем собранный образ:
+
+```sh
+docker run --name test_cs_runtime --network test-network -d -p 5001:8080 -e CONNECTION_STRING="Server=test_mysql; User ID=root; Password=toor; Database=mydb;" api_cs:runtime
+```
+
+Все должно работать
+
+```rest
+GET http://localhost:5001/genre
+```
+
+### Вариант без .NET в образе, публикация standalone приложения
+
+>У меня почему-то не скачивается образ чистого линукс (`FROM alpine`), поэтому собрал с образом aspnet (runtime)
+
+```Dockerfile
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY api_cs.csproj .
+RUN dotnet restore api_cs.csproj
+COPY . .
+RUN dotnet build api_cs.csproj -c $BUILD_CONFIGURATION -o ./build
+RUN dotnet publish api_cs.csproj -c $BUILD_CONFIGURATION -o ./publish -r linux-arm64 --self-contained true
+                                                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
+WORKDIR /app
+COPY --from=build /src/publish .
+
+RUN chmod 777 ./api_cs
+CMD ./api_cs
+```
+
+В команде `dotnet publish` добавились параметры
+
+* `-r linux-arm64` - целевая операционная система и архитектура исполняемого файла (OS в контейнере всегда linux, а вот архитектура зависит от процессора - для **Intel** нужно указывать `linux-x64`, в примере вариант для **Macos**)
+* `--self-contained true` - собрать **standalone** приложение (включить все зависимости в приложение)
+* `RUN chmod 777 ./api_cs` - исполняемому файлу даем права на выполнение и при запуске (`CMD ./api_cs`) уже не надо писать `dotnet`
+
+```sh
+docker build -f ./Dockerfile.standalone -t api_cs:standalone .
+```
+
+```sh
+docker run --name test_cs_standalone --network test-network -d -p 5002:8080 -e CONNECTION_STRING="Server=test_mysql; User ID=root; Password=toor; Database=mydb;" api_cs:standalone
+```
+
+```rest
+GET http://localhost:5002/genre
+```
+
+Посмотрим, как отличаются размеры разных вариантов сборки (команда `docker images`):
+
+```
+REPOSITORY             TAG          IMAGE ID       CREATED             SIZE
+api_cs                 runtime      bddcceb05493   43 minutes ago      360MB
+                       ^^^^^^^                                         ^^^^^
+api_cs                 sdk          1a233bdb3e4e   About an hour ago   1.52GB
+                       ^^^                                             ^^^^^^
+api_cs                 standalone   4e44052e2b01   53 seconds ago      521MB
+                       ^^^^^^^^^^                                      ^^^^^
+test-api               latest       3b986ccd7bbf   2 weeks ago         254MB
+test-mysql             latest       72f91044024b   2 weeks ago         800MB
+```
+
+Видно, что **runtime** занимает в четыре раза меньше места чем **sdk**, а вот использование **standalone** неочевидно: `521 - 360 = 160` - примерно столько занимают библиотеки, которые придется таскать вместо .NET, но из `360MB` **runtime** образа около `200МБ` занимает сам линукс, поэтому экономии не видно. Если использовать минималистичесую версию линукс **alpine**, образ которой "весит" всего `20МБ`, то теоретически можно ужать наш образ до `180MB`.     

+ 1 - 1
articles/docker_mysql.md

@@ -61,7 +61,7 @@ docker build -t test-mysql .
 ## Запуск образа с указанием тома (volume)
 
 ```sh
-docker run --name test_mysql -d -p 3308:3306 -e MYSQL_ROOT_PASSWORD=toor -v ./db_volume:/var/lib/mysql test-mysql
+docker run --name test_mysql --network test-network -d -p 3308:3306 -e MYSQL_ROOT_PASSWORD=toor -v ./db_volume:/var/lib/mysql test-mysql
 ```
 
 * `docker run`: Консольная команда для запуска нового контейнера. 

+ 3 - 1
docker/api_cs/.gitignore

@@ -1,2 +1,4 @@
 bin
-obj
+obj
+build
+publish

+ 14 - 0
docker/api_cs/Dockerfile.runtime

@@ -0,0 +1,14 @@
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY api_cs.csproj .
+RUN dotnet restore api_cs.csproj
+COPY . .
+RUN dotnet build api_cs.csproj -c $BUILD_CONFIGURATION -o ./build
+RUN dotnet publish api_cs.csproj -c $BUILD_CONFIGURATION -o ./publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
+WORKDIR /app
+COPY --from=build /src/publish .
+
+CMD ["dotnet", "/app/api_cs.dll"]

+ 9 - 0
docker/api_cs/Dockerfile.sdk

@@ -0,0 +1,9 @@
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY api_cs.csproj .
+RUN dotnet restore api_cs.csproj
+COPY . .
+RUN dotnet build api_cs.csproj -c $BUILD_CONFIGURATION -o /app/build
+RUN dotnet publish api_cs.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+CMD ["dotnet", "/app/publish/api_cs.dll"]

+ 16 - 0
docker/api_cs/Dockerfile.standalone

@@ -0,0 +1,16 @@
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY api_cs.csproj .
+RUN dotnet restore api_cs.csproj
+COPY . .
+RUN dotnet build api_cs.csproj -c $BUILD_CONFIGURATION -o ./build
+RUN dotnet publish api_cs.csproj -c $BUILD_CONFIGURATION -o ./publish -r linux-arm64 --self-contained true
+
+# FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
+FROM ubuntu AS runtime
+WORKDIR /app
+COPY --from=build /src/publish .
+
+RUN chmod 777 ./api_cs
+CMD ./api_cs

+ 41 - 0
docker/api_cs/Program.cs

@@ -0,0 +1,41 @@
+using System.Reflection;
+using api_cs;
+using Dapper;
+using DbUp;
+using MySqlConnector;
+
+string? connectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING");
+
+Console.WriteLine("connectionString (from ENV): {0}", connectionString);
+
+if (connectionString == null)
+    connectionString = "Server=127.0.0.1; Port=3308; User ID=root; Password=toor; Database=mydb;";
+
+EnsureDatabase.For.MySqlDatabase(connectionString);
+
+var upgrader = DeployChanges.To
+    .MySqlDatabase(connectionString)
+    .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
+    .LogToConsole()
+    .Build();
+
+var result = upgrader.PerformUpgrade();
+
+if (!result.Successful)
+{
+    throw new Exception("Не смог сделать миграцию");
+}
+
+var builder = WebApplication.CreateBuilder(args);
+var app = builder.Build();
+
+
+app.MapGet("/genre", () =>
+{
+    using (MySqlConnection db = new MySqlConnection(connectionString))
+    {
+        return db.Query<Genre>(
+            "SELECT * FROM Genre");
+    }
+});
+app.Run();

+ 23 - 0
docker/api_cs/Properties/launchSettings.json

@@ -0,0 +1,23 @@
+{
+  "$schema": "https://json.schemastore.org/launchsettings.json",
+  "profiles": {
+    "http": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": false,
+      "applicationUrl": "http://localhost:5091",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "https": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": false,
+      "applicationUrl": "https://localhost:7224;http://localhost:5091",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}

+ 19 - 0
docker/api_cs/api_cs.csproj

@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+  <PropertyGroup>
+    <TargetFramework>net9.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+  </PropertyGroup>
+
+  <ItemGroup>
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Dapper" Version="2.1.66" />
+    <PackageReference Include="dbup-mysql" Version="6.0.4" />
+    <PackageReference Include="MySqlConnector" Version="2.4.0" />
+    <EmbeddedResource Include="migrations/*.sql" />
+  </ItemGroup>
+
+</Project>

+ 5 - 0
docker/api_cs/api_cs.http

@@ -0,0 +1,5 @@
+@api_cs_HostAddress = http://localhost:5091
+
+GET {{api_cs_HostAddress}}/genre
+
+###

+ 8 - 0
docker/api_cs/appsettings.Development.json

@@ -0,0 +1,8 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  }
+}

+ 9 - 0
docker/api_cs/appsettings.json

@@ -0,0 +1,9 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*"
+}

+ 4 - 0
docker/api_cs/migrations/init.sql

@@ -0,0 +1,4 @@
+insert into Genre values
+    (1, "Комедия"),
+    (2, "Ужасы"),
+    (3, "Триллер");   

+ 7 - 0
docker/api_cs/model/Genre.cs

@@ -0,0 +1,7 @@
+namespace api_cs;
+
+public class Genre
+{
+    public int id { get; set; }
+    public required string title { get; set; }
+}

BIN
img/docker08.png


BIN
img/docker09.png


+ 1 - 0
readme.md

@@ -5,6 +5,7 @@
 1. [Основы](./articles/docker01.md)
 1. [Создание первого образа (база данных)](./articles/docker_mysql.md)
 1. [Создание образа АПИ (express.js). Настройка сети](./articles/docker_express_js.md)
+1. [Создание образа АПИ написанного на C#. Мультистадийная сборка образа](./articles/docker_cs.md)
 
 <!--