docker_cs.md 18 KB

Создание образа АПИ (C#). Оптимизация образа.

Для Python создание образа не сильно отличается от JavaScript, а вот для C# есть принциальное отличие. Дело в том, что Python и JavaScript интерпретируемые языки и им для работы нужен интерпретатор, а C# компилируемый и после сборки проекта .NET в образе уже не нужен. Поэтому для таких образов делают двухстадийную сборку, сначала в образе с установленным .NET компилируют исполняемый файл, а потом делают легковесный образ только с исполняемым файлом, полученным на первой стадии.

Я буду создавать проект на Macos, поэтому использую VSC и консольные команды, но все то же самое можно сделать и в VS

Создание минималистичного апи

Создание "рыбы" проекта:

dotnet new webapi -minimal -o api_cs

Параметр -o api_cs задает имя создаваемого проекта

Откройте файл Program.cs и поменяйте содержимое по умолчанию на следующее:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();

Перейдите в каталог с проектом и запустите команды

dotnet build
dotnet run

На данном этапе проект должен собираться и запускаться без проблем, проверить работу можно выполнив http запрос (порт генерируется автоматически, у вас будет другой):

Подключение к базе данных

Используем знакомые вам MySqlConnector и Dapper, установив их соответствующими командами:

dotnet add package MySqlConnector   
dotnet add package Dapper

Создаем модель (класс для таблицы Genre)

Создайте каталог model и в нем класс Genre (консольная команда: dotnet new class -n Genre)

public class Genre
{
    public int id { get; set; }
    public required string title { get; set; }
}

Создаем точку входа для получения списка жанров

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# существует много систем для миграции:

  • EntityFramework - включает в себя и возможности миграции, но мы от него отказались и возрождать не будем
  • Fluent Migrator - популярный, но требует создания дополнительных классов, а это лишние "проектные знания"
  • DBup - небольшая обертка над чистыми SQL-скриптами, идеальный вариант для нас - его и будем использовать

Делал по этому туториалу

  1. Созадем каталог migrations, где будем хранить скрипты для миграции
  2. В настройках проекта (файл *.csproj) укажем, чтобы все SQL-файлы из этого каталога включались в проект

    <ItemGroup>
        <EmbeddedResource Include="migrations/*.sql" />
    </ItemGroup>
    
  3. Создадим файл для миграции migrations/init.sql

    insert into Genre values
        (1, "Комедия"),
        (2, "Ужасы"),
        (3, "Триллер");   
    
  4. Устанавливаем библиотеку DBup

    dotnet add package dbup-mysql
    
  5. Дописываем в начало нашего Program.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, в которой хранится какие миграции и когда применялись (чтобы повторно не накатить уже выполненную миграцию)

Наш проект готов к упаковке в контейнер

Создание контейнера с многостадийной сборкой

Смысл многостадийной сборки в том, что для сборки (компиляции) проекта нужен .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 (независимые) приложения, для работы которых фреймворк уже не нужен

Попробуем собрать все три варианта и сравнить размеры полученных образов (взято отсюда)

Ссылки на официальные образы можно посмотреть на сайте Microsoft

Вариант без оптимизации, просто сборка и запуск в образе SDK

Создадим файл Dockerfile.sdk (обратите внимание, имя файла с расширением - мы напишем несколько вариантов):

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 - ARGument позволяет задать константы для часто используемых литералов
  • 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 и создает набор файлов (исполняемый файл, сборки, зависимости, файлы конфигурации) в выходной папке для его последующего развертывания на другом компьютере или в другой среде выполнения. Она готовит приложение к распространению и запуску вне среды разработки, собирая все необходимые для этого компоненты.

Перед сборкой я вспомнил, что у нас параметры подключения к базе данных прибиты гвоздями - сделаем получение из переменной среды окружения:

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;";

И соберем образ

docker build -f ./Dockerfile.sdk -t api_cs:sdk .
  • -f ./Dockerfile.sdk - опция -f явно указывает имя докерфайла

Запускаем собранный образ:

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
  2. Наступил на грабли, предупреждаю: мы с вами в команде запуска сервера mysql не указали сеть (присоединились к ней позже), но если разворачивать эти контейнеры на другом компьютере или удалить и пересоздать контейнеры (мой случай), то окажется, что контейнер с MySQL запущен в собственной сети и апи его не найдет - добавьте в команду запуска контейнера с MySQL опцию --network test-network

У меня все заработало, проверить можно запросив список жанров на 5000 порту:

GET http://localhost:5000/genre

Вариант с оптимизацией, добавление стадии запуска в образе Runtime

Сборка проекта здесь происходит как и в первом варианте с использованием образа SDK, поэтому скопируем предыдущий докерфайл и дадим название Dockerfile.runtime

Но в конец нового докерфайла допишем второй этап - копирование скомпилированных бинарников из первого образа в базовый образ aspnet:

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, который указывает, что исходные данные надо брать не в файловой системе, а в именованом образе

Собираем образ, присвоив ему соответствующий тег

docker build -f ./Dockerfile.runtime -t api_cs:runtime .

Запускаем собранный образ:

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

Все должно работать

GET http://localhost:5001/genre

Вариант без .NET в образе, публикация standalone приложения

У меня почему-то не скачивается образ чистого линукс (FROM alpine), поэтому собрал с образом aspnet (runtime)

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

    docker build -f ./Dockerfile.standalone -t api_cs:standalone .
    
    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
    
    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 проверить не смог, образ alpine не скачивается. Удалось скачать образ scratch, образ на его основеучше занимает 160Мб, но приложение в нем не запускается...