.NET 기술을 활용한 Worker 서비스 개발기 -3-

.NET 기술을 활용한 Worker 서비스 개발기 -3-
Photo by Joseph Barrientos / Unsplash

.NET Framework Service 에서 .NET Core 3.1 로 마이그레이션을 하기로 했다

처음에는 다음과 같은 이유 때문에 변환 하기로 마음 먹고 진행 했다

  • Container 서비스로 Worker 를 돌리고 싶었다.
  • .NET Framework 를 Dockerize (windows) = 평균 4.5GB 이상 이미지 사이즈
  • .NET Core 를 Dockerize  (linux) = 평균 350MB 이상 이미지 사이즈
  • Windows OS 의존 하지 않도록, Linux OS Deamon 으로 실행해도 상관없겠다 라는 생각이 들었다 (물론 이 생각은 여러 난관에 봉착하게 된다)

기본 전제 조건

  • 대부분 Worker는 Message Broker 의 Consumer 역할을 기본으로 하고 있다
  • 우리는 현재 RabbitMQ 를 사용한다

처음 생각

처음 worker service 를 설계 및 구현하는 과정이라서, 안일하게 생각했던게 컸다.

  • 메인 worker가 돌면서
  • 참조하고 있는 dll 들을 dynamic 하게 참조 하면서
  • 해당하는 worker type command 에 따라 각기 실행을 달리 하도록 만들자!

라고 해서 만든게 다음과 같은 구조가 되었다. (첫 설계의 아픔)

처음의 생각은 CommandLine 에서 option 넣어서 workerType 받아서 써야지~ 하는 생각이었는데 나중에는 이게 다 의미 없다는 것을 나중에 알게 되었다;;

Console Program 을 만들어서 진행 했고, 우선 다음과 같이 만들었다.

class Program
{
    static void Main(string[] args)
    {
    	var logger = LogManager.GetCurrentClassLogger();
    	try
    	{
        	var config = new ConfigurationBuilder()
			... 중략 ...

            var servicesProvider = BuildDi(config);
            using (servicesProvider as IDisposable)
            {
                var runner = servicesProvider.GetRequiredService<Runner>();
                runner.DoAction(QueueName);
            }
       	}
        catch (Exception ex)
        {
            logger.Error(ex);
            throw;
        }
        finally
        {
            LogManager.Shutdown();
        }
	}
}
Runner의 DoAction을 실행
public class Runner
{
	private readonly IRabbitManager _queueAdapter;
    private readonly IWorker _worker; 

    public Runner(IRabbitManager queueAdapter, IWorker worker) 
    {
        _queueAdapter = queueAdapter;
        _worker = worker;
    }

    public void DoAction(string queue_name)
    {
        _queueAdapter.Consume(queue_name, body => DoConsumeProcess(body));
    }

    private async Task DoConsumeProcess(byte[] body)
    {
        var message = Encoding.UTF8.GetString(body);

        if (string.IsNullOrEmpty(message))
            return;

        Console.WriteLine($"[{DateTime.UtcNow.AddHours(9).ToString("yyyy-MM-dd HH:mm:ss")}] - {message}");

        await _worker.RunAsync(message);
    }
}
IWorker 인터페이스의 RunAsync 실행
기본 구조

실제의 구조는

  • worker [console program] : Program 시작점 및 Runner.cs 파일로 실제 Run
  • worker-modules [class library] : worker 에 필요한 각종 모듈 등록
  • 각종 worker 프로젝트는 [class library] 로 만들어서 worker 프로젝트에 참조시켰다

사실 각 worker 프로젝트는 해당 worker 의 bizLogic 만을 수행하도록 만들어졌다. 아래와 같은 형태로 말이다

public class WorkerA : IWorker
{
	public async Task RunAsync(string message)
	{
		// 실행할 biz logic
        Console.WriteLine("worker is A");
    }
}
public class WorkerB : IWorker
{
	public async Task RunAsync(string message)
	{
		Console.WriteLine("worker is B");
    }
}

이렇게 우후죽순 worker 를 늘려나가면 되겠지 라고 생각한게 큰 오산
처음 (에는 잘 몰랐지) 생각한 의도와는 한참이나 다르다는 것을 만들다 보면 알게 된다 -_-
(이렇게 글로 써야 정리 해서 쓸 수 있지 이전에는 이런 생각도 제대로 못했다)

새로 만드는 worker 프로젝트 들은 각 dependency 를 추가 하기 위해서 worker-module 프로젝트를 포함해야만 했다.

또 모든 worker 들을 worker project 안에 참조하고, 그 참조된 값들을 추가 때마다 새롭게 Injection 해줘야만 했다. 그리고 worker project를 publish 하게 되면 문제가 생겼다.

불필요한 다른 worker package dll 도 같이 참조로 따라오게 된다. 그렇다고 넣지 않으면 Assembly 목록에서 빠지게 되어서 Dynamic Injection 도 되지 않는다.

public static class ServiceCollectionExtensions
{
	/// workerType 에 따른 dynamic worker DI
    public static void AddScopedWorkerDynamic<TInterface>(this IServiceCollection services, string workerType)
    {
        var list = new List<Assembly>
        {
            Assembly.GetAssembly(typeof(WorkerA)),
            Assembly.GetAssembly(typeof(WorkerB)),
            ...
			Assembly.GetAssembly(typeof(WorkerConverter))
            Assembly.GetAssembly(typeof(WorkerHook))
        };

        services.Scan(scan => scan
            .FromAssemblies(list.AsEnumerable())
            .AddClasses(classes => classes.AssignableTo<TInterface>())
            .AsSelf() 
            .WithScopedLifetime()
        );

        // workerType 에 맞게 ServiceType 구현
        services.AddScoped(typeof(TInterface), serviceProvider =>
        {
            var type = services.Where(x => x.ServiceType.Name.Contains(workerType, StringComparison.OrdinalIgnoreCase)).FirstOrDefault()?.ServiceType;
                
            if (null == type)
                throw new KeyNotFoundException("No instance found for the given tenant.");

            return (TInterface)serviceProvider.GetService(type);
        });
    }
}

그럼에도 불구하고, 장점으로는 하나의 Repository 에서 모든 worker를  관리 할 수 있고, 워커 배포가 조금 맘에는 안들었지만 아래와 같은 형태로 workerType command 를 통해서 각각의 워커를 달리 실행 할 수가 있었다.

worker.exe --workerType workerA --env Production

worker.exe --workerType workerB --env Production

worker.exe --workerType workerconverter --env Production

하지만 운영을 하다보니 하나 둘 문제가 발생하게 되었다.

Worker 운영의 문제점

  1. 추가 모듈에 대해서 모두 worker-module 에 모이게 된다
    새로운 nuget dependency 가 추가 될 때는 worker-module 프로젝트에 추가가 되었다.
    덕분에 해당 dependency 가 필요하지 않은 worker 들도 강제로 해당 dll 을 가지고 있게 되더라. (아니 이것은 어차피 main worker 에 이미 속해있기 때문에...)
  2. 설정 파일에 대한 추가 설정이 필요했다
    appsettings.json 파일을 설정하는 과정에서 보통 .net core 의 경우 env 환경에 맞게 구축을 한다. appsettings.Production.json appsettings.Staging.json
    하지만 해당 설정이 특정 worker 에는 맞지 않을 수도 있었다.
    추가로 worker appsettings.json 을 따로 만들게 되더라
  3. logging 설정에 대한 추가 설정
    공용으로 사용할 수 없이 위와 같은 형태로 분리가 되었다.
  4. publish를 하게 되면 불필요 dll을 갖고 있기 때문에 쓸모 없는 낭비가 시작된다.  
    배포 게시된 폴더에 가보면 각종 dll + 각종 worker 의 설정 파일들의 난잡함에 눈을 찌푸리게 된다

몇몇 worker 를 변경해서 사용을 하던 참에 NET5 가 나왔길래 NET5 익히는 겸사겸사 또 마이그레이션을 빙자한 new worker service 를 만들기로 한다.

실제 돌아가는 코드 Repository 는 아래 링크를 확인 바란다

References

Worker Services in .NET
Learn how to implement a custom IHostedService and use existing implementations with .NET.
ssemi/develop-worker-service-with-dotnet
.NET 기술을 활용한 Worker 서비스 개발기. Contribute to ssemi/develop-worker-service-with-dotnet development by creating an account on GitHub.