Для 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
Создайте каталог 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# существует много систем для миграции:
migrations, где будем хранить скрипты для миграцииВ настройках проекта (файл *.csproj) укажем, чтобы все SQL-файлы из этого каталога включались в проект
<ItemGroup>
<EmbeddedResource Include="migrations/*.sql" />
</ItemGroup>
Создадим файл для миграции migrations/init.sql
insert into Genre values
(1, "Комедия"),
(2, "Ужасы"),
(3, "Триллер");
Устанавливаем библиотеку DBup
dotnet add package dbup-mysql
Дописываем в начало нашего 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 - Используется для разработки приложений.
Включает в себя все необходимые инструменты для написания компиляции и сборки приложений:
dotnet build, dotnet run..NET Runtime - Используется для выполнения уже готовых (скомпилированных) приложений.
Включает только библиотеки и компоненты необходимые для работы приложения.
.NET Runtime выгоден, когда на компьютере запускается несколько программ на .NET, но принцип контейнеризации в том, что в каждом контейнере запущено только одно приложение. Компилятор позволяет создавать так называемые Standalone (независимые) приложения, для работы которых фреймворк уже не нужен
Попробуем собрать все три варианта и сравнить размеры полученных образов (взято отсюда)
Ссылки на официальные образы можно посмотреть на сайте Microsoft
Создадим файл 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
Замечания:
8080 хотя в конфигах этого нигде нет - этот порт используется по-умолчанияю для режима Release--network test-networkУ меня все заработало, проверить можно запросив список жанров на 5000 порту:
GET http://localhost:5000/genre
Сборка проекта здесь происходит как и в первом варианте с использованием образа 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
У меня почему-то не скачивается образ чистого линукс (
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 неочевидно: 521 - 360 = 160 - примерно столько занимают библиотеки, которые придется таскать вместо .NET, но из 360MB runtime образа около 200МБ занимает сам линукс, поэтому экономии не видно. Если использовать минималистичесую версию линукс alpine, образ которой "весит" всего 20МБ, то теоретически можно ужать наш образ до 180MB.