Linux контейнер для .NET Framework приложения (когда сложно уйти на .Net Core)

Здравствуй, Хабр.

Хочу поделиться с миром достаточно нетипичной, по крайней мере для меня, задачкой и её решением, которое мне кажется вполне приемлемым. Описанное ниже, возможно, не является идеальным выходом из ситуации, но это работает, и работает так, как задумывалось.

Завязка и предпосылки


Появилась по работе задача: нужно на сайт вынести 3D-превьюшки BIM-моделей разного оборудования, материалов, объектов. Нужно что-то легковесное, несложное.

На сайте модели этих объектов хранятся и доступны для скачивания в проприетарных форматах различных САПР и в виде открытых форматов 3D-моделей. Среди них есть и формат IFC. Его-то я и буду использовать, как исходник для решения этого задания.

Один из вариантов исполнения и его особенности


Формально можно было бы ограничиться написанием какого-нибудь конвертера *.ifc во что-то для отображения на web-странице. С этого я и начал.

Для подобного преобразования был избран замечательный тулкит — xBIM Toolkit.

В примерах использования этого инструмента просто и доходчиво описано, как работать с IFC и специализированным для web-форматом *.wexBIM.

Сначала конвертируем *.ifc в *.wexBIM:
using System.IO;
using Xbim.Ifc;
using Xbim.ModelGeometry.Scene;

namespace CreateWexBIM
{
    class Program
    {
        public static void Main()
        {
            const string fileName = "SampleHouse.ifc";
            using (var model = IfcStore.Open(fileName))
            {
                var context = new Xbim3DModelContext(model);
                context.CreateContext();

                var wexBimFilename = Path.ChangeExtension(fileName, "wexBIM");
                using (var wexBiMfile = File.Create(wexBimFilename))
                {
                    using (var wexBimBinaryWriter = new BinaryWriter(wexBiMfile))
                    {
                        model.SaveAsWexBim(wexBimBinaryWriter);
                        wexBimBinaryWriter.Close();
                    }
                    wexBiMfile.Close();
                }
            }
        }
    }
}


Далее полученный файл используется в «плеере» xBIM WeXplorer.

Пример встраивания *.wexBIM в страницу:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Hello building!</title>
    <script src="js/xbim-viewer.debug.bundle.js"></script>
</head>
<body>
    <div id="content">
        <canvas id="viewer" width="500" height="300"></canvas>
        <script type="text/javascript">
            var viewer = new xViewer('viewer');
            viewer.load('data/SampleHouse.wexbim');
            viewer.start();
        </script>
    </div>    
</body>
</html>


Что ж, поехали. Беру nuget'ы от xBIM. Пишу консольное приложение, которое на вход принимает пачку путей к *.ifc-файлам, рядом с ними складывает пачку *.wexBIM-файлов. Всё, можно выкладывать на сайт.

Но как-то это простенько… Хочется, чтобы эта программа стала неким сервисом, который по событию загрузки *.ifc на портал, сразу создаёт необходимый *.wexBIM, и он сразу отображается в подготовленном контейнере.

Ок, формирую новые требования:

  1. пусть задания на конвертацию приходят от нашего RabbitMQ;
  2. сами задания хочу видеть в виде бинарного сообщения, которое на самом деле будет готовым для десериализации классом, описанным в protobuf-файле;
  3. задание будет содержать ссылку для скачивания исходного *.ifc-файла с нашего Minio;
  4. задание также будет сообщать мне, в какой bucket в Minio складывать результат;
  5. пусть само приложение будет собрано под .net core 3.1 и работет внутри Linux docker-контейнера на нашей «docker-ферме»;

Первые сложности и условности


Описывать подробно первые 4 пункта реализации не стану. Возможно позже.

Заставил приложение слушать очередь заданий и отсылать сообщение с результатом в очередь из CorrelationId сообщения-задания. Прикрутил генерированные классы запрос/ответ из protobuf. Научил скачивать/загружать файлы в minio.

Всё это делаю в проекте консольного приложения. В настройках проекта:

<TargetFramework>netcoreapp3.1</TargetFramework>

И на моей машине с Windows 10 всё вполне отлаживается и работает. Но при попытке запустить приложение в WSL ловлю ошибку System.IO.FileLoadException:

Полная информация по ошибке:
{
  "Type": "System.IO.FileLoadException",
  "Message": "Failed to load Xbim.Geometry.Engine64.dll",
  "TargetSite": "Void .ctor(Microsoft.Extensions.Logging.ILogger`1[Xbim.Geometry.Engine.Interop.XbimGeometryEngine])",
  "StackTrace": " at Xbim.Geometry.Engine.Interop.XbimGeometryEngine..ctor(ILogger`1 logger)\r\n at Xbim.Geometry.Engine.Interop.XbimGeometryEngine..ctor()\r\n at Xbim.ModelGeometry.Scene.Xbim3DModelContext.get_Engine()\r\n at Xbim.ModelGeometry.Scene.Xbim3DModelContext.CreateContext(ReportProgressDelegate progDelegate, Boolean adjustWcs)\r\n at My.Converter.ConvertIfc.CreateWebIfc(String ifcFileFullPath, String wexBIMFolder)",
  "Data": {},
  "InnerException": {
    "Type": "System.IO.FileNotFoundException",
    "Message": "Could not load file or assembly 'Xbim.Geometry.Engine.dll, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified.",
    "FileName": "Xbim.Geometry.Engine.dll, Culture=neutral, PublicKeyToken=null",
    "FusionLog": "",
    "TargetSite": "System.Reflection.RuntimeAssembly nLoad(System.Reflection.AssemblyName, System.String, System.Reflection.RuntimeAssembly, System.Threading.StackCrawlMark ByRef, Boolean, System.Runtime.Loader.AssemblyLoadContext)",
    "StackTrace": " at System.Reflection.RuntimeAssembly.nLoad(AssemblyName fileName, String codeBase, RuntimeAssembly assemblyContext, StackCrawlMark& stackMark, Boolean throwOnFileNotFound, AssemblyLoadContext assemblyLoadContext)\r\n at System.Reflection.RuntimeAssembly.InternalLoadAssemblyName(AssemblyName assemblyRef, StackCrawlMark& stackMark, AssemblyLoadContext assemblyLoadContext)\r\n at System.Reflection.Assembly.Load(String assemblyString)\r\n at Xbim.Geometry.Engine.Interop.XbimGeometryEngine..ctor(ILogger`1 logger)",
    "Data": {},
    "Source": "System.Private.CoreLib",
    "HResult": -2147024894
  },
  "Source": "Xbim.Geometry.Engine.Interop",
  "HResult": -2146232799
}

Сеанс активного гугления и вдумчивого чтения показал мне, что я крайне невнимателен:
Recently at work, we were evaluating a few options to render building models in the browser. Building Information Modeling (BIM) in interoperability scenarios is done via Industry Foundation Classes, mostly in the STEP Physical File format. The schema is quite huge and complex with all the things you have to consider, so we were glad to find the xBim open source project on GitHub. They've got both projects to visualize building models in the browser with WebGL as well as conversion tools to create the binary-formatted geometry mesh. To achieve that, native C++ libraries are dynamically loaded (so no .Net Core compatibility) which must be present in the bin folder. The C++ libraries are expected either in the same folder as the application binaries or in a x64 (or x86, respectively) sub folder (See here for more details). In regular projects, the xBim.Geometry NuGet package adds a build task to copy the dlls into the build output folder, but this doesn't work with the new tooling. You can, however, get it to work in Visual Studio 2015 by taking care of supplying the interop dlls yourself.

И подобные трудности не у одного меня. Многим хочется xBIM под .Net Core.
Не критично, но многое меняет… Всё упирается в невозможность нормально загрузить Xbim.Geometry.Engine64.dll. Нужно иметь на машине vc_redist.x64.exe. Какие у меня варианты?
Первое, что подумалось: «А может виндовый контейнер с полным .Net Framework использовать?
Доставить Microsoft Visual C++ Redistributable for Visual Studio 2015, 2017 and 2019 в этот контейнер, и всё будет ок?» Я это попробовал:

Испытательный Windows-образ для docker:
Сменил .Net Core на:

<TargetFramework>net47</TargetFramework>

Dockerfile:

FROM microsoft/dotnet-framework:4.7
WORKDIR /bimlibconverter
COPY lib/VC_redist.x64.exe /VC_redist.x64.exe
RUN C:\VC_redist.x64.exe /quiet /install
COPY bin/Release .
ENTRYPOINT ["MyConverter.exe"]

Что ж, это сработало… It's alive! Но. А как же наша хостовая Linux-машина с docker? Не получится на неё загнать контейнер с образом на Windows Server Core. Надо выкручиваться…

Компромисс и развязка


Очередной поиск в Сети вывел меня на статью. В ней автор требует от реализации похожего:
To make things worse:
All binaries are 32-bits (x86).
Some require visual C++ redistributable runtime components.
Some require the .NET runtime.
Some need a windowing system, even though we only use the command-line interface (CLI).
В посте описывается потенциальная возможность запуска Windows-приложений в wine в Linux-контейнере. Любопытно, решил я.

После некоторых проб, багов и дополнений был получен Dockerfile:

Docker образ на основе Ubuntu с Wine, .Net Framework и vcredist на борту:

FROM ubuntu:latest
#Добавляем поддержку x86
RUN dpkg --add-architecture i386 \
    && apt-get update \
    #Ставим некоторые необходимые пакеты
    && apt-get install -qfy --install-recommends \
        software-properties-common \
        gnupg2 \
        wget \
        xvfb \
        cabextract \
    #Добавляем репозитарий Wine
    && wget -nv https://dl.winehq.org/wine-builds/winehq.key \
    && apt-key add winehq.key \
    && apt-add-repository 'deb https://dl.winehq.org/wine-builds/ubuntu/ bionic main' \
    #Дополнительный репозитарий для корректной установки Wine
    && add-apt-repository ppa:cybermax-dexter/sdl2-backport \
    #Ставим сам Wine
    && apt-get install -qfy --install-recommends \
        winehq-staging \
        winbind \
    #Подчищаем лишнее
    && apt-get -y clean \
    && rm -rf \
      /var/lib/apt/lists/* \
      /usr/share/doc \
      /usr/share/doc-base \
      /usr/share/man \
      /usr/share/locale \
      /usr/share/zoneinfo
#Переменные окружения для старта Wine
ENV WINEDEBUG=fixme-all
ENV WINEPREFIX=/root/.net
ENV WINEARCH=win64
#Пуск конфигурирования Wine
RUN winecfg \
    #Скачиваем winetricks, без них .Net Framework не заведётся
    && wget https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks \
    -O /usr/local/bin/winetricks \
    && chmod +x /usr/local/bin/winetricks \
#Подчищаем лишнее
    && apt-get -y clean \
    && rm -rf \
      /var/lib/apt/lists/* \
      /usr/share/doc \
      /usr/share/doc-base \
      /usr/share/man \
      /usr/share/locale \
      /usr/share/zoneinfo \
    #Запуск Wine с необходимыми дополнениями
    && wineboot -u && winetricks -q dotnet472 && xvfb-run winetricks -q vcrun2015

WORKDIR /root/.net/drive_c/myconverter/

#Копируем наше приложение
COPY /bin/Release/ /root/.net/drive_c/myconverter/

ENTRYPOINT ["wine", "MyConverter.exe"]

UPD: немного изменил файл для сборки более компактного образа. Спасибо комментарию rueler

Build идёт небыстро, но заканчивается удачно. Пробую, проверяю. Работает!

Итоги, выводы, размышления


Это сработало. На выходе получаем Linux-образ для docker-контейнера. Он «пухловат» (~5.2Гб), но вполне быстро стартует и внутри работает консольное Windows-приложени на .Net Framework 4.7, которое слушает RabbitMQ, пишет логи в Graylog, скачивает и загружает файлы на/в Minio. Обновлять само приложение буду по remote docker API.

Решение утилитарной выделенной задачи реализовано. Возможно, и скорее всего, не универсальное. Но меня в принципе устроило. Может быть кому-то тоже пригодится.

Спасибо, что прочли. На Хабр пишу впервые. Увидимся в комментариях.

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 20

  • НЛО прилетело и опубликовало эту надпись здесь
      +2

      Почему?

      0
      Обновлять само приложение буду по remote docker API.

      немного не понял, как именно будет обновляться приложение, будете подменять образ или само приложение внутри контейнера?

        0
        Образ собран, контейнер с ним запущен и работает. Всё это на удалённой Linux-машине. Поэтому новые сборки засылаю туда *.bat'ом примерно таким:

        set CONT=containername
        set FLD=appfolder
        set REMOTE=-H tcp://ip.ad.res.ss:port
        docker %REMOTE% cp .\_pub\. %CONT%:/%FLD%
        docker %REMOTE% restart %CONT%
          0

          Прикольно. На кубике, наверно, такое не прокатит) upd kubernetes

            0
            Пардон… На кубике?… что-то не понял…
              0

              kubernetes

                0
                А… :) Лично не юзаю, вот и слэнгом не владею. Теперь буду.
        0

        Боюсь спросить зачем закрывать файловый поток и бинариврайтер в юзингах

          0
          Смею предположить, что в методе Close() для этого объекта есть и Dispose() (внутри...). Это распространённое поведение похожих steam объектов. Вроде как попользовались и избавились, когда больше не нужен.
            0

            Это я знаю, но особенность using в том, что он как раз автоматически вызывает Dispose при выходе из него

              0
              Да. Как раз ответил на это ниже. :)
            0

            внимательно посмотри в теги =)

              0
              Хм… Действительно, особо не за чем его закрывать.
              Будем воспринимать этот кусок кода, как цитату от авторов фреймворка xBIM :).
              0

              Мне расссказывал знакомый про подобное, только это было на нетфреймворке и связано было с sqlconnection. Тип смысл был в том, что конец блока юзинг не означал, что коннекшен сразу закроется, и из-за этого то ли connection pool переполнялся, то ли были проблемы с коннектами. И прикол был в том, что существовала статья/блог девелопера из мс который подтверждал этот фичебаг.


              звучит как байка, конечно. Возможно подобное проявляется только под нагрузками

                0
                Подтверждаю. Сам натыкался на такие описания…
                И действительно много где рекоментуют убивать connections, когда они особо не нужны.
              +1
              Для уменьшения размера образа необходимо уменьшить количество слоев. Можно объединить операторы инструкции RUN, например так:

              RUN apt-get update \
              && apt-get upgrade -y \
              && dpkg --add-architecture i386 \
                +1
                Согласен. И даже немного допилил уже, только статью не стал править. Считаете нужно?
                  +1
                  Скрипт я бы поправил. Уважаемый читатель не всегда находит время изучить все комментарии. Тем более это весьма положительная практика улучшать статьи на основе обратной связи.
                    +1
                    Хорошо. А есть смысл где-то указывать, что статья изменена?
                    … новенький я… не привык ещё…

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое