메뉴

문서정보

목차

traefik

물리서버를 사용 할 때, 그리고 VM을 사용 할 적에도 다이나믹한 proxy server는 그다지 필요하지 않았다. 왜냐하면 그들 물리서버와 VM이 다이나믹하지 않았기 때문이다.

proxy server(특히 reverse proxy server)는 인터넷 서비스에서 가장 중요한 인프라 중 하나다. 클라이언트와 백앤드 중간에서 보안 감시자의 역할을 해주며, 트래픽을 분산해준다. 또한 복잡한 내부를 단순화 하는 추상화 계층의 역할 도 수행한다. 단순 트래픽 분산 뿐만아니라 도메인 이름, Path등을 기준으로 지능적으로 트래픽을 라우팅한다.

프락시 서버(proxy server)로는 NginX, Apache, Haproxy 등을 십 수년동안 사용해 왔다. 대부분의 경우 이들로 충분했다. 프락시 서버는 도메인 이름과 포트번호를 이용해서 프락시를 하는데, 고정된 정보였기 때문이다.

그러나 컨테이너 기반의 클라우드 환경에서는 이야기가 달라진다. (도커)컨테이너는 근본적으로 프로세스다. 컨테이너를 실행 한 다음 ps로 확인해 보자. 다른 프로세스와 어떤 차이점도 보이지 않는다는 사실을 알게 될 것이다.

컨테이너 기반의 인터넷 서비스라는 것은 결국 프로세스를 인터넷에 노출한다는 건데, 프로세스는 포트를 이용해서 서비스를 한다. 포트는 변동 자산이다. 게다가 컨테이너의 경우 클러스터를 하기 마련이라서 IP도 계속 바뀐다. VM도 물리서버 사이를 넘어다니기는 하지만 VM은 무뉘만 가상이지 물리서버와 차이가 없이, 고정된 IP를 가지므로 이러한 문제에서 자유롭다.

따라서 컨테이너 기반환경에서 프락시 서버를 만들려고 하면 동적인 네트워크와 관련된 문제를 해결해야 한다. 예전에는 직접 개발(의외로 직접 개발하는 것도 그리 나쁘지는 않다. 워낙에 생산성 좋은 라이브러리들이 많아서)했다. 이런 류의 툴이 그리 많지 않기 때문이기도 하고, 있다고 하더라도 컨테이너 기반의 클라우드라는 생소한 환경에 그대로 가져다 쓸 수 있는 소프트웨어를 찾기 힘들기 때문이다.

그러다 찾아낸게 trafic이다. 개의 star를 가지고 있는 아주 핫한 소프트웨어로 Docker, Swarm, Kubernetes, Marathon, Mesos, Consul, Etcd, Zookeeper, BoltDB 등 다양한 백앤드 시스템과 연동해서 사용 할 수 있는 유연한 프락시 서버다.

도커를 위한 리버스 프락시 클러스 구성

traefic에 대한 모든 기능을 다루지는 않는다. 도커를 위한 리버스 프락시 클러스터 구성만을 다루려 한다. 구성도는 다음과 같다.

 Traefik 기반 reverse proxy 시스템 구성

Traefik은 docker server에 연결해서 컨테이너의 start, stop을 모니터링한다. 유저는 컨테이너를 실행 할 때, Traefik이 식별 할 수 있는 몇 가지 정보들을 label로 넘긴다. 대략 아래와 같은 느낌이다.
$ docker run --name mywebapp -l traefik.backend=webapp \
-l traefik.port=80 -l traefik.frontend.rule=Host:test.joinc.co.kr \
/opt/app/django
이 정보들은 Traefik 서버로 전달되고, Traefik은 이 정보로 프락시 룰을 만든다. Traefik는 label 정보뿐만 아니라 컨테이너의 ip 정보도 알고 있기 때문에 관리자의 개입없이 자동으로 프락시 룰을 설정 할 수 있다. 관리자는 그냥 label 잘 붙여서 컨테이너를 실행하기만 하면 된다.

traefik 설치

2017.4.5일 현재 최신 버전은 1.2.1이다. traefik releases download page에서 다운로드 했다.
$ wget https://github.com/containous/traefik/releases/download/v1.2.1/traefik_linux-amd64
$ sudo mv traefik_linux-amd64 /usr/local/bin/traefik

시나리오

몇 개 시나리오 별로 traefik 응용을 살펴본다.

간단한 리버스 프락시

로컬 호스트에 2개의 컨테이너를 만들고 RR 방식으로 로드밸런싱 하는 시나리오다. traefik 설정파일을 만들었다.
################################################################
# 전역 설정 
# traefik를 0.0.0.0:80에 bind 한다.
################################################################
port = ":80"
debug = true

################################################################
# Traefik 관리자 페이지 접근 포트 
################################################################
[web]
address = ":8080"

################################################################
# 도커 backend 설정 
################################################################
[docker]
endpoint = "unix:///var/run/docker.sock"
domain = "docker.localhost"
watch = true
swarmmode=false
traefik 를 실행한다.
$ traefik -c traefik.toml

nginx 이미지를 pull 했다.
$ docker pull nginx
컨테이너 두개를 만들었다.
$ docker run --rm -l traefik.backend=webapp \
-l traefik.port=80 \
-l traefik.frontend.rule=Host:web.joinc.priv nginx
-l, --label 을 이용해서 라벨값을 설정한다. traefik는 지금 docker.sock에서 컨테이너를 watch하고 있다. 컨테이너가 실행되면, 컨테이너의 inspect 정보와 라벨정보를 이용해서 리버스 프락시 룰을 만든다.

curl로 테스트를 해보자.
$ curl -H "Host: web.joinc.priv" http://localhost -I
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 612
Content-Type: text/html
Date: Wed, 05 Apr 2017 03:00:57 GMT
Etag: "58d535a3-264"
Last-Modified: Fri, 24 Mar 2017 15:05:07 GMT
Server: nginx/1.11.12
각 컨테이너로 요청이 분산되는 걸 확인 할 수 있을 거다. 8080 포트로 접근하면 프락시 정보를 확인 할 수 있다.

 traefik dashboard

 traefik dashboard

Host: web.joinc.priv인 경우에 route-frontend-Host-web-joinc-priv 라우트 룰을 실행하라는 정보를 확인 할 수 있다. 그리고 webapp 백앤드에 등록된 컨테이너 목록들과 프락시 규칙이 wrr가 적용됐다는 것을 알 수 있다. Weight는 분산 가중치의 설정을 위해서 사용한다. 지금은 가중치가 0이니, 두 개의 컨테이너에 동일하게 요청이 분산된다. 그리고 전체 서비스의 상태도 대략 확인 할 수 있다.

 traefik dashboard 2

 traefik dashboard 2

docker-compose 연동

docker compose과 traefik를 함께 사용해보자. 아래와 같은 compose 파일을 만들었다.
$ mkdir composetest
$ cd composetest
$ cat docker-compose.yml 
version: '2'
services:
    web:
        build: .
        volumes:
            - .:/code
        links:
            - redis
        labels:
            - traefik.backend=test_web
            - traefik.port=5000
            - traefik.frontend.rule=Host:localhost
    redis:
        image: "redis:alpine"
web은 flask 웹 애플리케이션이다. composetest 디렉토리에 아래와 같은 Dockerfile 파일을 만들었다.
FROM python:3.4-alpine
ADD . /code
WORKDIR /code
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
app.py 코드는 아래와 같다.
from flask import Flask
from redis import Redis

app = Flask(__name__)
redis = Redis(host='redis', port=6379)

@app.route('/')
def hello():
    count = redis.incr('hits')
    return 'Hello World! I have been seen {} times.\n'.format(count)

if __name__ == "__main__":
    app.run(host="0.0.0.0", debug=True)
3개의 web 서비스와 1개의 redis 서비스를 실행했다.
$ docker-compose scale web=3 redis=1

REST API Server

traefik 은 호스트 이름 외에도 URL Path를 프락시룰로 사용 할 수 있다. 예컨데, 아래와 같은 구성이 가능하다.

 REST API Server

각 서버에 API 서버들을 배치하고 URL Path로 프락시룰을 만든다. 즉 LB를 통해서 들어온 유저 요청은 각 Server로 분산되고, trafic는 Path를 확인해서 해당 컨테이너로 분배한다.

실제 구성은 이 보다는 복잡할 것이다. 서비스 컨테이너들은 전체 서버의 일부에 배치되므로, 로드밸런서는 요청을 처리할 컨테이너가 있는 서버로 유저 요청을 분산해야 하기 때문이다. 몇 가지 방법이 있을 것이다.
  1. 로드밸런서도 traefik로 구성해서 동적으로 프락시 하게 한다.
  2. 도메인 기반으로 분산한다. 이 경우 NginX나 Apache, Haproxy 기반으로 로드밸런서를 만들 수 있을 것이다.
DNS만 관리해 주면 되므로 2번이 쉬운 방법이다. 이건 나중에 살펴보고, 테스트를 진행해보자. /music 과 /photo 2개의 REST API 서버군을 준비했다. /music을 위해서 2개의 컨테이너 /photo를 위해서 1개의 컨테이너를 준비한다.
$ docker run --rm -l traefik.backend=photoapp \
-l traefik.port=80 \
-l traefik.frontend.rule=PathPrefixStrip:/photo nginx
$ docker run --rm -l traefik.backend=musicapp \
-l traefik.port=80 \
-l traefik.frontend.rule=PathPrefixStrip:/music nginx
traefik.frontend.rule를 PathPrefixStrip로 설정했다. /music 로 시작하는 모든 Path에 대해서 이 룰을 적용하겠다는 의미다. curl로 테스트해보면 /music와 /photo를 각각의 컨테이너로 라우팅 하는 걸 확인 할 수 있을 것이다.
$ curl http://localhost/music/1 -I
HTTP/1.1 404 Not Found
Content-Length: 170
Content-Type: text/html
Date: Wed, 05 Apr 2017 05:25:33 GMT
Server: nginx/1.11.12

$ curl http://localhost/music -I
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 612
Content-Type: text/html
Date: Wed, 05 Apr 2017 05:25:37 GMT
Etag: "58d535a3-264"
Last-Modified: Fri, 24 Mar 2017 15:05:07 GMT
Server: nginx/1.11.12

$ curl http://localhost/photo -I
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 612
Content-Type: text/html
Date: Wed, 05 Apr 2017 05:25:41 GMT
Etag: "58d535a3-264"
Last-Modified: Fri, 24 Mar 2017 15:05:07 GMT
Server: nginx/1.11.12
요청들이 잘 전달되는 걸 확인 할 수 있다. 대시보드를 확인해 보자.

 Dashboard

 Dashboard

람다 서비스

서버 없이 함수를 실행하는 서버리스(serverless)가 유행하고 있다. 이미지의 섬네일을 만들어야 한다고 가정해보자. 보통은 이미지 편집 기능을 가진 애플리케이션은 탑재한 서버를 준비하기 마련이다. 그게 물리든 가상이든 서버를 준비하는게 일반적인 방식이었다.

대표적인 서버리스 서비스인 AWS 람다(Lambda)의 경우 REST URL을 명시하고, 이 URL을 호출할 때 실행할 함수를 업로드 하는 것만으로 이미지 편집기능을 서비스 할 수 있다. 서버를 만들경우 운영체에 대한 지식이 있어야 하고, 2대 이상으로 구성해서 가용성을 확보하고 모니터링을 하고 스케일링 계획을 세워야 한다. 람다를 이용하면 오로지 서비스 개발에만 집중할 수 있다.

람다 서비스는 어떤 구성일까. 아래 구성을 보자.

 람다 서비스

람다 서비스를 하려면, 함수를 동적으로 적재하는 기능을 만들어야 한다. 그것도 언어별로 만들어야 할 건데, Python 같은 언어는 비교적 쉽게 만들 수 있을 거다. 내가 사용하는 Go언어의 경우에는 Shared Object(.so)를 이용해서 동적으로 함수를 호출하는 식으로 만들 수 있다. 물론 그리쉽지 만은 않을 것이다. 제대로 하려면, 온라인에서 웹브라우저를 이용해서 코딩, 테스트, 빌드를 할 수 있는 시스템을 만들어야 하기 때문이다. 소위 말하는 클라우드 IDE 이다. IDE를 위한 완전한 스펙을 가질 필요는 없긴 하지만 쉽지는 않은 일이다. 클라우드 IDE(혹은 에디터)를 만들기가 그렇다면, 적어도 로컬 개발 환경을 만들 수 있는 툴들은 제공해줘야 할 것이다.

요즘에는 컨테이너와 ace와 같은 클라우드 에디터가 있기 때문에, 완전한 기능이 필요하지 않은 람다 펑션 개발과 같은 제한된 기능을 가진 IDE를 만드는 건 그리 어려운 일이 아니다. 하지만 IDE를 만드는게 이 글의 목적은 아니니 접어두자.

실제 어떻게 람다를 구성 할 수 있을지 프로토타이핑 해보자.

디렉토리 구성은 아래와 같다.
.
├── lambda
├── main.go
└── plugin
    └── sum
        ├── sum.go
        └── sum.so
아래는 main.go 코드다.
package main

import (
	"errors"
	"fmt"
	"github.com/gorilla/mux"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"plugin"
)

type Fmap map[string]plugin.Symbol
type PlugIns struct {
	pluginList Fmap
}

// plugin 디렉토리 밑에 있는 .so 파일을 로딩한다.
func Load() (*PlugIns, error) {
	plugins := &PlugIns{}
	plugins.pluginList = make(Fmap)

	files, err := ioutil.ReadDir("./plugin")
	if err != nil {
		return nil, err
	}

	for _, file := range files {
		if !file.IsDir() {
			continue
		}
		sofile := fmt.Sprintf("./plugin/%s/%s.so", file.Name(), file.Name())
		fmt.Println("Plugin loading...", sofile)
		if _, err := os.Stat(sofile); os.IsNotExist(err) {
			fmt.Println(err.Error())
			continue
		}
		p, err := plugin.Open(sofile)
		if err != nil {
			fmt.Println("Loading Error ", err.Error())
			return nil, err
		}
		sym, err := p.Lookup("Function_" + file.Name())
		if err != nil {
			return nil, err
		}
		plugins.pluginList["Function_"+file.Name()] = sym
	}
	return plugins, nil

}

// plugin을 찾아서 실행한다.
func (p PlugIns) Exec(fname string, w http.ResponseWriter, r *http.Request) (string, error) {
	if sym, ok := p.pluginList["Function_"+fname]; ok {
		r := sym.(func(http.ResponseWriter, *http.Request) string)(w, r)
		return r, nil
	} else {
	}
	return "", errors.New("ERROR")
}

type Handler struct {
	Router *mux.Router
	P      *PlugIns
}

func HandlerNew() *Handler {
	p, err := Load()
	if err != nil {
		panic(err)
	}
	h := &Handler{P: p, Router: mux.NewRouter()}
	h.Router.HandleFunc("/api/{name}/{a}/{b}", h.Lambda).Methods("GET")
	http.Handle("/", h.Router)
	return h
}

func (h *Handler) Run() {
	fmt.Println("Web Server start...")
	log.Fatal(http.ListenAndServe(":7000", nil))
}

func main() {
	HandlerNew().Run()
}

func (h *Handler) Lambda(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	fname := vars["name"]
	rtv, err := h.P.Exec(fname, w, r)
	if err != nil {
		fmt.Println(err.Error())
	}
	w.Write([]byte(rtv))
}
이 코드는 plugin/{pluginName} 디렉토리 밑에 있는 .so 파일을 찾아서 로딩 한후, map 에 저장한다. 그리고 유저가 펑션을 호출하면 map에서 함수를 찾아서 실행하는 일을 한다.

아래는 플러그인 코드인 sum.go 파일이다.
package main

import (
    "encoding/json"
    "github.com/gorilla/mux"
    "net/http"
    "strconv"
)

func Function_sum(w http.ResponseWriter, r *http.Request) string {
    vars := mux.Vars(r)
    a := vars["a"]
    b := vars["b"]

    ai, _ := strconv.Atoi(a)
    bi, _ := strconv.Atoi(b)
    sum := struct {
        Sum int `json:"result"`
    }{ai + bi}
    response, _ := json.Marshal(sum)
    return string(response)
}
아래와 같이 빌드하면 .so 파일이 만들어진다.
$ go build -buildmode=plugin
$ ls
sum.go sum.so
웹 서버를 실행하고 curl로 테스트해보자.
$ curl -XGET localhost:7000/api/sum/1/3
{"result":4}
이건 어디까지나 프로토타이핑이고, 실제 구현에서는 매개변수를 URL Path를 파싱해서 읽어오는 식으로 구성해야 할 거다. 또한 새로운 람다펑션을 올리면, 이 펑션을 찾아서 로딩하는 기능도 추가해야 할 거다. 대략 아래와 같이 구성할 수 있을 거다.
  1. 개발한 람다평선을 빌드해서 .so 파일을 만들고 이 파일을 GridFS 나 REDIS 혹은 NFS 등에 올린다. 권한과 인증 정보등을 넣을 수 있는 GridFS가 가장 좋은 방법일 것 같다.
  2. 람다펑션을 로딩해야 하는 람다서버에 펑션을 로딩하라는 명령을 내란다.
  3. 람다서버는 펑션을 로컬에 복사하고 로딩한다.
  4. 혹은 유저가 람다펑션을 요청 할 때, map에 없다면, GridFS 등에서 검색해서 로딩하는 방법도 있다.
이제 이 람다 서버를 traefik로 리버스프락시 하면 대강의 모습을 만들 수 있다.

API Gateway

실제 구현은 다른 문제이긴 하지만 위의 아이디어를 이용해도 API Gateway의 얼개는 만들 수 있다. 하지만 인증 문제가 걸린다. 인증 시스템을 포함한 API Gateway의 구성은 아래와 같을 것이다.

 API-Gateway

인증 처리는 서비스 타입에 따라서 꽤나 머리가 아플 수 있다.
  1. 디바이스에서의 통신이라면 HMAC 기반으로 디바이스를 인증 할 수 있을 것이다. HMAC을 만들기 위한 secret key가 누출되면 어떡하냐라는 문제가 있긴 하지만 어느 기본적인 해결은 된다.
  2. 다음 유저 인증인데, API Gateway에서 oAuth2를 이용해서 access token을 validation 한 후 JWT 등을 발급하는 방법이 있다. 이 것도 나름의 방법일 것이다.
2의 방법은 유저를 인증하기 위해서 1의 방법은 인증된 디바이스나 서버에서만 호출 할 수 있도록 함으로써 DOS류의 공격을 막을 수단으로 사용 할 수 있다.

traefik를 기반으로 API Gateway를 만들어 보자.

traefik가 굉장히 유연하긴 하지만 어디까지나 HTTP 기반의 reverser proxy server일 뿐이다. HTTP Basic authentication 정도를 지원하는데, 이 정도로는 인터넷 서비스를 위한 인증을 수행하기에는 턱없이 부족하다.

traefik 앞단에 인증을 처리하기 위한 별도의 인증 게이트웨이를 두면 어떨까? 아래와 같은 구성이 될테다.

 인증 및 권한

  1. HMAC : API를 호출하려는 서비스를 인증하기 위한 가장 단순한 수단이다. Secret Key 가 도난 당하면 무효화된다. 하지만 도난 당한 상태라 하더라도 어떤 서비스에서 문제가 발생했는지 추적할 수 있기 때문에, 간단하지만 효율적인 인증수단으로 사용 할 수 있다.
  2. Session-ID : HMAC은 서비스 레벨에서의 인증 수단이다. server to server 모델에서는 사용 할 수 있지만, client to server 모델에서는 사용 할 수 없다. 유저를 인증 할 수 없기 때문이다. oAuth2 access token으로 validation 체크를 하고, 성공하면 session-id를 발급한다. JWT를 발급 할 수도 있을 것이다.
... 계속...