오픈소스 간단 리뷰 – nomad : 가벼운 스케쥴러, 강력한 성능

Introduction

서비스 스케줄러는 클러스터 내의 컴퓨팅 리소스를 관리하고 사용자 애플리케이션을 어느 호스트에서 서비스할지 결정하는 시스템 소프트웨어입니다. 컨테이너 스케줄러로는 kubernetes나 docker swarm등이 있으며, 하둡 스케줄러로는 YARN이 많이 사용됩니다.

이 글에서는 이러한 스케줄러 중 하나인 nomad를 소개합니다. nomad는 vagrantconsulpacker등의 시스템 소프트웨어로 유명한 hashicorp사의 오픈소스 소프트웨어로, 자사의 consul과 vault를 각각 service discovery와 secure storage에 사용합니다. hashicorp사는 자사의 웹페이지에서 nomad를 통해 5000개 호스트에 100만 개 컨테이너를 5분 만에 배포한 사례를 소개하고 있습니다.(Nomad Million Container Challenge)

Why?

Kubernetes는 CNCF(Cloud Native Computing Foundation)에서 개발을 주도하고 있으며, 현재 가장 인기있는 container orchestrator로서 안정성과 성능이 뛰어납니다. (최근 오픈소스화된 cite도 Kubernetes를 사용합니다.) 하지만 설계의 특성상 몇가지 단점들이 있습니다.

Kubernetes의 특징 중 하나는 전체 시스템을 “직접” 관리하는 것입니다. 이러한 설계로 인해 Kubernetes에서 pluggable하게 설계하지 않은 기능은 재컴파일하지 않는 이상 구현하기 어려운데요, 예를 들면 cloud provider 추가, volume type 추가 등이 있습니다. 최근에는 FlexVolume(https://kubernetes.io/docs/user-guide/volumes/#flexvolume)등의 재컴파일 없이 확장 가능한 모델도 추가되고 있습니다.

또한, Kubernetes는 기반 인프라가 갖춰져 있어야만 사용할 수 있습니다. Kubernetes 설치에서 가장 난해한 부분 중 하나는 네트워킹으로, Kubernetes는 모든 Pod이 서로 중복되지 않고 routable한 IP를 가져야만 서비스할 수 있습니다. 이러한 요구사항을 따르기 위한 여러 플러그인들이 개발되어 있지만 대부분 각 호스트에 설치된 docker daemon의 구동 옵션을 조절(flannel)하거나 컨테이너 구동 후 nsenter를 통해 직접 network stack을 수정(calico)하는 등의 저수준 해킹에 가까운 방식을 사용합니다.

반면 nomad는 가벼운 리소스 관리자와 스케줄러로만 이루어져 있기 때문에 구조적으로 단순하고 성능이 빠르며 이해하기 쉽습니다. 또한, docker나 rkt 등의 컨테이너 드라이버 외에 fork/exec, qemu, java, LXC 등의 드라이버도 활용할 수 있습니다.

java로 만든 서비스는 JAR나 WAR형태로 패키징 되어 JVM위에서 구동되며, 맞는 버전의 JVM만 있으면 어디에서나 구동된다는 점에서 컨테이너와 유사합니다.(JNI가 필요한 경우는 논외로 합니다.) 이러한 JVM기반 서비스는 nomad를 통해 컨테이너화 하지 않고도 orchestration을 할 수 있습니다.

이 글에서는 Java 드라이버의 활용에 대해 다루며, nomad와 다른 클러스터 관리자/스케줄러와의 차이점은 Nomad vs. Other Software 에서 자세하게 살펴보실 수 있습니다.

Job Spec

이 절에서는 nomad에서 JVM기반 프로세스를 구동하는 방법에 대해 간단한 웹서비스 예제를 통해 살펴보겠습니다.

nomad의 Java Driver는 주어진 Job Spec에 따라 JAVA_HOME을 선택하고 cgroups, namespaces, chroot 등으로 job을 isolation시켜서 구동합니다.

nomad의 Java Driver 스펙에는 jar_path, args, jvm_options등을 지정하며, 부가적으로 구동될 JVM의 버전을 선택할 수 있습니다.

아래 Job Spec은 가상의 fatJAR 배포 서버 https://internal.file.server에서 다운로드할 수 있는 java-sample-all.jar에 대해 명시하고 있습니다. 실제 운영 시에는 jenkins의 artifact를 사용하여 손쉽게 CI서버/배포서버를 구축할 수 있습니다.

  • Job Spec 생성 : docker기반 redis서비스를 명시한 example.nomad파일 생성
$ nomad init
  • Job Spec 수정 : java driver로 변경, artifact source지정
job "hello" {
  datacenters = ["dc1"]
  type = "service"
  
  group "helloGroup" {
    count = 1

    task "helloTask" {
      driver = "java"

      config {
        jar_path = "local/java-sample-all.jar"
        jvm_options = ["-Xmx2048m","-Xms256m"]
      }
      resources {
        network {
          mbits = 10
          port "http" {}
        }
      }
      service {
        name = "hello"
        tags = ["hello"]
        port = "http"
        check {
          name     = "alive"
          type     = "tcp"
          interval = "10s"
          timeout  = "2s"
        }
      }
      artifact {
        source = "https://internal.file.server/hello.jar"

        options {
        checksum = "md5:123445555555555"
        }
      }
    }
  }
}

Service

nomad는 서비스 실행 시 호스트에서 사용되지 않는 임의의 포트번호를 서비스에 할당한 후 런타임에 환경변수로 NOMAD_PORT_[포트이름]를 전달합니다. 각 서비스는 실행 시 전달받은 포트를 사용하여 구동됩니다.

아래는 sparkjava를 이용한 간단한 예제입니다. 코드에서 listening port의 기본값으로 4567을 지정한 후 NOMAD_PORT_http가 있는 경우 해당 환경변수의 값으로 변경하였습니다.

  • src/main/java/HelloWorld.java
import static spark.Spark.*;

public class HelloWorld {

    public static void main(String[] args) {
        int port = 4567;
        if (System.getenv("NOMAD_PORT_http") != null)
            port = Integer.valueOf(System.getenv("NOMAD_PORT_http"));
        port(port);
        get("/hello", (req, res) -> "Hello World");
    }
}

빌드 스크립트에서는 fatJAR로 만들기 위해 shadowjar plugin을 사용하였습니다.

  • build.gradle
plugins {
  id 'com.github.johnrengelman.shadow' version '1.2.4'
}

apply plugin: 'java'
apply plugin: 'application'

mainClassName = 'HelloWorld'

repositories {
    jcenter()
}

dependencies {
    compile 'org.slf4j:slf4j-api:1.7.22'
    compile 'org.slf4j:slf4j-simple:1.7.22'
    compile "com.sparkjava:spark-core:2.5.4"
    testCompile 'junit:junit:4.12'
}

Running a Job

작업을 구동하려면 nomad run명령을 사용합니다.

$ nomad run example.nomad

실행결과를 보려면 nomad status 명령을 사용합니다.

$ nomad status hello
ID          = hello
Name        = hello
Type        = service
Priority    = 50
Datacenters = dc1
Status      = running
Periodic    = false

Summary
Task Group  Queued  Starting  Running  Failed  Complete  Lost
helloGroup  0       0         1        0       7         0

Allocations
ID        Eval ID   Node ID   Task Group  Desired  Status   Created At
e7a4aa6e  f19cf697  d084d0ce  helloGroup  run      running  01/20/17 19:37:26 KST

서비스가 클러스터 내에 할당된 정보를 보려면 nomad alloc-status명령을 사용합니다.

$ nomad alloc-status e7a4aa6e
ID                  = e7a4aa6e
Eval ID             = f19cf697
Name                = hello.helloGroup[0]
Node ID             = d084d0ce
Job ID              = hello
Client Status       = running
Client Description  = <none>
Desired Status      = run
Desired Description = <none>
Created At          = 01/20/17 19:37:26 KST

Task "helloTask" is "running"
Task Resources
CPU        Memory          Disk  IOPS  Addresses
9/500 MHz  40 MiB/256 MiB  0 B   0     http: [호스트 IP]:[할당된 임의 포트 48367]

Recent Events:
Time                   Type                   Description
01/20/17 19:37:31 KST  Started                Task started by client
01/20/17 19:37:26 KST  Downloading Artifacts  Client is downloading artifacts
01/20/17 19:37:26 KST  Received               Task received by client

서비스 로그는 nomad logs명령으로 볼 수 있습니다. 아래에서는 slf4j-simple이 stderr로 로그를 출력하기 때문에 -stderr옵션을 추가하였습니다.

$ nomad logs -stderr e7a4aa6e
[Thread-0] INFO org.eclipse.jetty.util.log - Logging initialized @236ms
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - == Spark has ignited ...
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - >> Listening on 0.0.0.0:48367
[Thread-0] INFO org.eclipse.jetty.server.Server - jetty-9.3.z-SNAPSHOT
[Thread-0] INFO org.eclipse.jetty.server.ServerConnector - Started ServerConnector@245acdf6{HTTP/1.1,[http/1.1]}{0.0.0.0:48367}
[Thread-0] INFO org.eclipse.jetty.server.Server - Started @365ms
[qtp1878800355-18] INFO spark.http.matching.MatcherFilter - The requested route [/] has not been mapped in Spark for Accept: [*/*]

Modifying a Job

배포나 롤백 등의 서비스 수정은 plan과 run 두 단계를 거칩니다. plan은 변경된 내역이 클러스터에 어떻게 적용될지 보여주며, run은 실제로 변경을 수행합니다.

Job Spec에서 hello서비스의 인스턴스를 3개로 늘려 보겠습니다.

job "hello" {
  group "helloGroup" {
    count = 3

변경된 Job Spec을 plan명령으로 확인합니다.

$ nomad plan example.nomad 
+/- Job: "hello"
    Datacenters {
  Datacenters: "dc1"
}
+/- Task Group: "helloGroup" (2 create, 1 create/destroy update)
  +/- Count: "1" => "3" (forces create)
  +/- Task: "helloTask" (forces create/destroy update)
    - Artifact {
      - GetterSource: "https://internal.file.server/hello.jar"
      - RelativeDest: "local/"
    }
    + Service {
      + Name:      "hello"
      + PortLabel: "http"
      + Check {
          Command:       ""
          InitialStatus: ""
        + Interval:      "10000000000"
        + Name:          "alive"
          Path:          ""
          PortLabel:     ""
          Protocol:      ""
        + Timeout:       "2000000000"
        + Type:          "tcp"
      }
    }

Scheduler dry-run:
- All tasks successfully allocated.

Job Modify Index: 4617
To submit the job with version verification run:

nomad run -check-index 4617 example.nomad

When running the job with the check-index flag, the job will only be run if the
server side version matches the job modify index returned. If the index has
changed, another user has modified the job and the plan's results are
potentially invalid.

변경내역을 run명령으로 적용합니다.

$ nomad run -check-index 4617 example.nomad
==> Monitoring evaluation "a92b41cc"
    Evaluation triggered by job "hello"
    Allocation "100f9aac" created: node "d084d0ce", group "helloGroup"
    Evaluation status changed: "pending" -> "complete"
==> Evaluation "a92b41cc" finished with status "complete"
==> Monitoring evaluation "aba7825f"
    Evaluation triggered by job "hello"
    Allocation "1f08bfd7" created: node "2002f3dd", group "helloGroup"
    Evaluation status changed: "pending" -> "complete"
==> Evaluation "aba7825f" finished with status "complete"
==> Monitoring next evaluation "1418a2ab" in 10s
==> Monitoring evaluation "1418a2ab"
    Evaluation triggered by job "hello"
    Allocation "376e1895" created: node "0593bcf8", group "helloGroup"
    Evaluation status changed: "pending" -> "complete"
==> Evaluation "1418a2ab" finished with status "complete"

변경된 서비스를 status명령으로 확인합니다.

$ nomad status hello
ID          = hello
Name        = hello
Type        = service
Priority    = 50
Datacenters = dc1
Status      = running
Periodic    = false

Summary
Task Group  Queued  Starting  Running  Failed  Complete  Lost
helloGroup  0       0         3        0       11        0

Allocations
ID        Eval ID   Node ID   Task Group  Desired  Status    Created At
376e1895  1418a2ab  0593bcf8  helloGroup  run      running   01/25/17 16:11:35 KST
1f08bfd7  aba7825f  2002f3dd  helloGroup  run      running   01/25/17 16:11:25 KST
100f9aac  a92b41cc  d084d0ce  helloGroup  run      running   01/25/17 16:11:15 KST
e7a4aa6e  f19cf697  d084d0ce  helloGroup  stop     complete  01/20/17 19:37:26 KST

구동된 서비스는 consul에 등록됩니다.

Nomad Consul Service

Web UI : hashi-ui

nomad의 web frontend는 hashicorp에서 공식적으로 지원하는 atlas와 오픈소스인 hashi-ui가 있습니다. 여기서는 hashi-ui에 대해 알아보겠습니다.

hashi-ui는 consul과 nomad의 오픈소스 web frontend입니다. consul에 내장된 ui와 가장 큰 차이점은 websocket을 이용한 실시간 업데이트입니다.

Nomad Cluster Overview
Nomad Jobs
Nomad Allocations
Nomad Servers

Conclusion

nomad는 클러스터를 구성하는 각각의 역할을 분리하고 스케줄링에만 집중함으로써 간결하고 빠른 아키텍처를 만들었습니다. 단순하기 때문에 비록 구현된 기능의 수는 Kubernetes보다 적지만 성능 측면에서는 Kubernetes보다 더 뛰어나며, 필요한 기능을 추가하기도 더 쉽습니다.

이 글이 서비스 오케스트레이션 솔루션 선택에 도움이 되었으면 합니다. 감사합니다.

References

카카오톡 공유 보내기 버튼

Latest Posts

제5회 Kakao Tech Meet에 초대합니다!

Kakao Tech Meet #5 트렌드와 경험 및 노하우를 자주, 지속적으로 공유하며 개발자 여러분과 함께 성장을 도모하고 긴밀한 네트워크를 형성하고자 합니다.  다섯 번째

테크밋 다시 달릴 준비!

(TMI: 이 글의 썸네일 이미지는 ChatGPT와 DALL・E로 제작했습니다. 🙂) 안녕하세요, Kakao Tech Meet(이하 테크밋)을 함께 만들어가는 슈크림입니다. 작년 5월에 테크밋을 처음 시작하고,