Yet another tutorial: запускаем dotnet core приложение в docker на Linux

Habrahabr

В один пасмурный летний день, после посещения секции от авито на РИТ2017, до меня вдруг дошло, что хайп по поводу докера не смолкает уже пару лет и пора, наконец, уже его освоить. В качестве подопытного для упаковки был выбран dotnet core+C#, т. к. давно интересно было посмотреть, каково это — разрабатывать на C# под Linux. Предупреждение читателю: статья ориентирована на совсем новичков в docker/dotnet core и писалась большей частью, как напоминалка для себя. Вдохновлялся я первыми 3 частями Docker Get Started Guide и неким блог-постом на english. У кого хорошо с английским, можно читать сразу их и в общем-то будет сильно похоже. Если же после всего вышенаписанного вы еще не передумали продолжить чтение, то добро пожаловать под кат. Пререквизиты Итак нам понадобится собственно Linux (в моем случае это была Ubuntu 16.04 под VirtualBox на Windows 10), dotnet core, docker, а также docker compose, чтобы было удобнее поднимать сразу несколько контейнеров за раз. Никаких особых проблем с установкой возникнуть не должно. По крайней мере, у меня таковых не возникло.

Выбор среды разработки
Формально можно писать хоть в блокноте и отлаживаться через логи или консольные сообщения, но т.к. я несколько избалован, то все-таки хотелось получить нормальную отладку и, желательно, еще и нормальный рефакторинг.
Из того, что может под Linux, для себя я попробовал Visual Studio Code и JetBrains Rider.

Visual Studio Code
Что могу сказать — оно работает. Отлаживаться можно, синтаксис подсвечивается, но уж очень все незатейлево — осталось впечатление, что это блокнот с возможностью отладки.

Rider
По сути Idea, скрещенная с решарпером — все просто и понятно, если работал до этого с любой IDE от JetBrains. До недавнего времени не работал debug под linux, но в последней EAP сборке его вернули. Вообщем для меня выбор в пользу Rider был однозначен. Спасибо JetBrains за их классные продукты.

Создаем проект Презреем в учебных целях кнопки Create Project различных IDE и cделаем все ручками через консоль. 1. Переходим в директорию нашего будущего проекта 2. посмотрим ради интереса, какие шаблоны мы можем использовать

dotnet new -all
3. создадим WebApi проект
dotnet new webapi
4. подтянем зависимости
dotnet restore
5. запустим наше приложение
dotnet run
6. Открываем http://localhost:5000/api/values и наслаждаемся работой C# кода на Linux

Готовим приложение к докеризации Заходим в Program.cs и в настройке хоста добавляем

.UseUrls("http://*:5000") // listen on port 5000 on all network interfaces

В итоге должно получится что-то типа
public static void Main(string[] args)
{
    var host = new WebHostBuilder()
        .UseKestrel()
        .UseContentRoot(Directory.GetCurrentDirectory())
        .UseUrls("http://*:5000") // listen on port 5000 on all network interfaces
        .UseStartup<Startup>()
        .Build();

    host.Run();
}

Это нужно для того, чтобы мы смогли обратится к приложению, находящемуся внутри контейнера. По дефолту Kestrel, на котором работает наше приложение, слушает http://localhost:5000. Проблема в том, что localhost является loopback-интерфейсом и при запуске приложения в контейнере доступен только внутри контейнера. Соответственно, докеризовав dotnet core приложение с дефолтной настройкой прослушиваемых url можно потом довольно долго удивляться, почему проброс портов не работает, и перечитывать свой docker-файл в поисках ошибок. А еще можно добавить приложению немного функциональности
Передача параметров в приложение
При запуске контейнера хотелось бы иметь возможность передавать приложению параметры.
Беглое гугление показало, что если обойтись без экзотики типа обращения изнутри контейнера к сервису конфигурации, то можно использовать передачу параметров через переменные окружения или подмену файла конфига.
Отлично, будем передавать через переменные окружения.
Идем в Startup.cs publicpublic Startup(IHostingEnvironment env) и смотрим, что у нашего ConfigurationBuilder вызван метод AddEnvironmentVariables().
Собственно все — теперь можно инжектить параметры из переменных окружения куда угодно через DI.

Идентификатор инстанса
При старте инстанса приложения будем генерировать новый Guid и засунем его в IoC-контейнер, чтобы раздавать страждущим. Нужно это, например, для анализа логов от нескольких параллельно работающих инстансов сервиса.
Все тоже достаточно тривильно — у ConfigurationBuilder вызываем

 .AddInMemoryCollection(new Dictionary<string, string>
                {
                    {"InstanseId", Guid.NewGuid().ToString()}
                })

После этих двух шагов public Startup(IHostingEnvironment env) будет выглядеть примерно так:

public Startup(IHostingEnvironment env)
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(env.ContentRootPath)
        .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
        .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
        .AddInMemoryCollection(new Dictionary<string, string>
        {
            {"InstanseId", Guid.NewGuid().ToString()}
        })
        .AddEnvironmentVariables();
    Configuration = builder.Build();
}


Немного про DI
Он не показался мне хоть сколько-нибудь интуитивным. Глубоко я не копал, но тем не менее приведу ниже небольшой пример того, как прокинуть Id инстанса, который мы задаем при старте, и что-нибудь из переменных окружения(например переменную MyTestParam) в контроллер.
Первым делом надо создать класс настроек — имена полей должны совпадать с именами параметров конфигурации, которые мы хотим инжектить
  public class ValuesControllerSettings
  {
        public string MyTestParam { get; set; }
        public string InstanseId { get; set; }
  }


Далее идем в Startup.cs и вносим изменения в ConfigureServices(IServiceCollection services)
  // This method gets called by the runtime. Use this method to add services to the container.
  public void ConfigureServices(IServiceCollection services)
  {
        //регистрируем экземпляр IСonfiguration
        //откуда будет заполняться наш ValuesControllerSettings
        services.Configure<ValuesControllerSettings>(Configuration); 
             
        // Add framework services.
        services.AddMvc();
  }


И последним шагом идем в наш подопытный и единственный созданный автоматом ValuesController и пишем инжекцию через конструктор
  private readonly ValuesControllerSettings _settings;

  public ValuesController(IOptions<ValuesControllerSettings> settings)
  {
        _settings = settings.Value;
  }


не забывая добавить using Microsoft.Extensions.Options;.
Для теста переопределяем ответ любого понравившегося Get метода, чтобы он возвращал обретенные контроллером параметры, запускаем, проверяем → профит.

Собираем и запускаем docker-образ 1. Первым делом получим бинарники нашего приложения для публикации. Для этого открываем терминал, переходим в директорию проекта и вызываем

dotnet publish
Более подробно про команду можно почитать тут. Запуск этой команды без доп. аргументов из директории проекта сложит dll-ки для публикации в ./bin/Debug/[framework]/publish

2. Собственно эту папочку мы и положим в наш docker-образ. Для этого создадим в директории проекта Dockerfile и напишем туда примерно следующее


# базовый образ для нашего приложения
FROM microsoft/dotnet:runtime

# рабочая директория внутри контейнера для запуска команды CMD
WORKDIR /testapp

# копируем бинарники для публикации нашего приложения(напомню,что dockerfile лежит в корневой папке проекта) в рабочую директорию
COPY /bin/Debug/netcoreapp1.1/publish /testapp

# пробрасываем из контейнера порт 5000, который слушает Kestrel
EXPOSE 5000

# при старте контейнера поднимаем наше приложение
CMD ["dotnet","ИмяВашегоСервиса.dll"]

3. После того, как Dockerfile написан, запускаем
docker build -t my-cool-service:1.0 . 
Где my-cool-service — имя образа, а 1.0 — тэг с указанием версии нашего приложения

4. Теперь проверим, что образ нашего сервиса попал в репозиторий

docker images
5. И, наконец, запустим наш образ
docker run -p 5000:5000 my-cool-service:1.0
6. Открываем http://localhost:5000/api/values и наслаждаемся работой C# кода на Linux в docker
Полезные команды для работы с docker
Посмотреть образы в локальном репозитории
docker images
Посмотреть запущенные контейнеры
docker ps
Запустить контейнер в detached mode
docker run с флагом -d
Получить информацию о контейнере
docker inspect имя_или_ид_контейнера
Остановить контейнер
docker stop имя_или_ид_контейнера
Удалить все контейнеры и все образы
# Delete all containers
docker rm $(docker ps -a -q)
# Delete all images
docker rmi $(docker images -q)

Немного docker-compose напоследок docker-compose удобен для запуска групп связанных контейнеров. Как пример, приведу разработку нового микросервиса: пусть мы пишем сервис3, который хочет общаться с уже написанными и докеризованными сервисом1 и сервисом2. Сервис1 и сервис2 для целей разработки удобно и быстро поднять из репозитория через docker-compose. Напишем простенький docker-compose.yml, который будет поднимать контейнер нашего приложения и контейнер с nginx (не знаю, зачем он мог понадобиться нам локально при разработке, но для примера сойдет) и настраивать последний, как reverse proxy для нашего приложения.


# версия синтаксиса docker-compose файла
version: '3.3'
services:
    # сервис нашего приложения
    service1:
        container_name: service1_container
        # имя образа приложения
        image: my-cool-service:1.0
        # переменные окружения, которые хотим передать внутрь контейнера
        environment:
         - MyTestParam=DBForService1

    # nginx
    reverse-proxy:
        container_name: reverse-proxy
        image: nginx
        # маппинг портов для контейнера с nginx 
        ports:
         - "777:80"
        # подкладываем nginx файл конфига 
        volumes:
         - ./test_nginx.conf:/etc/nginx/conf.d/default.conf

docker-compose поднимает при старте между сервисами, описанными в docker-compose файле, локальную сеть и раздает hostname в соотвествии с названиями сервисов. Это позволяет таким сервисам удобно между собой общаться. Воспользуемся этим свойством и напишем простенький файл конфигурации для nginx
upstream myapp1 {
     server service1:5000; /* мы можем обратится к нашему приложению по имени сервиса из docker-compose файла*/
}
server {
    listen 80;
    location / {
        proxy_pass http://myapp1;
    }
}

Вызываем
docker-compose up 

из директории с docker-compose.yml и получаем nginx, как reverse proxy для нашего приложения. При этом рекомендуется представить, что тут вместо nginx что-то действительно вам полезное и нужное. Например база данных при запуске тестов.

Заключение Мы создали dotnet core приложение на Linux, научились собирать и запускать для него docker-образ, а также немного познакомились с docker-compose. Надеюсь, что кому-нибудь эта статья поможет сэкономить немного времени на пути освоения docker и/или dotnet core.

Просьба к читателям: если вдруг среди вас у кого-нибудь есть опыт с dotnet core в продакшене на Linux (не обязательно в docker, хотя в docker особенно интересно) — поделитесь, пожалуйста, впечатлениями от использования в комментариях. Особенно будет интересно услышать про реальные проблемы и то, как их решали.