.NET 기술을 활용한 Worker 서비스 개발기 -3-
.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();
}
}
}
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);
}
}
실제의 구조는
- 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 운영의 문제점
- 추가 모듈에 대해서 모두 worker-module 에 모이게 된다
새로운 nuget dependency 가 추가 될 때는 worker-module 프로젝트에 추가가 되었다.
덕분에 해당 dependency 가 필요하지 않은 worker 들도 강제로 해당 dll 을 가지고 있게 되더라. (아니 이것은 어차피 main worker 에 이미 속해있기 때문에...) - 설정 파일에 대한 추가 설정이 필요했다
appsettings.json
파일을 설정하는 과정에서 보통 .net core 의 경우 env 환경에 맞게 구축을 한다.appsettings.Production.json
appsettings.Staging.json
하지만 해당 설정이 특정 worker 에는 맞지 않을 수도 있었다.
추가로 worker appsettings.json 을 따로 만들게 되더라 - logging 설정에 대한 추가 설정
공용으로 사용할 수 없이 위와 같은 형태로 분리가 되었다. - publish를 하게 되면 불필요 dll을 갖고 있기 때문에 쓸모 없는 낭비가 시작된다.
배포 게시된 폴더에 가보면 각종 dll + 각종 worker 의 설정 파일들의 난잡함에 눈을 찌푸리게 된다
몇몇 worker 를 변경해서 사용을 하던 참에 NET5 가 나왔길래 NET5 익히는 겸사겸사 또 마이그레이션을 빙자한 new worker service 를 만들기로 한다.
실제 돌아가는 코드 Repository 는 아래 링크를 확인 바란다