Skip to main content

· 8 min read

최근에 특정 node 한두개에 심한 부하가 걸리면서 kubelet을 포함한 대부분의 process들이 제대로 동작하지 못하고 그로 인해 cluster에서 해당 node 들이 빠지는 현상이 발생했다. Load average 값을 관찰했을 때 core 수 대비 10배 이상의 부하가 걸리면 node 들이 빠지고 있었는데 의심할만한 disk와 network i/o, 관련된 여러 지표를 모니터링 해보니 수치들이 같이 치솟기는 했으나 cpu 부하에 의한 결과로 벌어지는 문제였지 다른 요소들이 cpu 부하를 야기하는 것은 아니었다.

그래서 왜 cpu 부하가 높아지는지에 대해 꽤 오랜 시간을 들여서 살펴보니 보통 신규 pod가 배포될 때 많이 발생한다는 것을 알 수 있었다. 내 환경에서 문제가 발생하는 node들은 무거운 pod 들이 이미 배포되어 있는 상태였는데 (cassandra cluster가 node selector로 특정 node 2개가 선택되어 배포된 상태) 신규 pod 들이 안그래도 무거운 node 들에만 배포되면서 부하를 가중시키는 것으로 보였다.

Pod가 배포되면서 특정 node에 배포되는게 어느 정도로 치우치게 되는지 알아보기 위해 임시로 nginx를 20개 배포해보니 아래 그림처럼 한두개를 제외한 대부분이 부하가 높은 node에 배포된다.

이런 문제가 왜 발생하는 것일까?

Kubernetes에서 pod를 어디에 배치할 것인지를 담당하는 component는 scheduler이다. Scheduler는 pod에 지정된 속성들과 node의 상태를 확인하는데 1차적으로는 filtering을 해서 가능한 node list를 뽑고 2차로 node 마다 점수를 매겨서(scoring) 가장 높은 점수를 가진 node에 pod를 배포하라는 명령을 내리게 되어있다. Scoring 부분을 좀 더 자세히 살펴보면 기본으로는 공식문서에 기술된 방법으로 점수를 매기게 된다. (SelectorSpreadPriority, InterPodAffinityPriority, LeastRequestedPriority 등. Scheduler의 소스코드를 살펴봐도 마찬가지)

Scoring 방식 각각을 살펴보고 종합해보면 대략적으로 이런 내용이 될 것 같다.

  1. Node 전반에 리소스를 분산시킨다.
  2. 같은 서비스를 참조하거나 replicaset 등으로 묶인 pod 들도 왠만하면 같은 node에 두지 않는다.
  3. 리소스를 가능한 덜 쓰는 곳으로 배치한다.

Node에 문제가 생기더라도 배포된 서비스에 영향을 가능한 덜 주려고 하거나 특정 node에 부하를 가하지 않고 가능한 골고루 분산하려는 의도는 알겠는데 나와 같은 경우는 의도와 다른 방향으로 흘러가고 있었기 때문에 이런 scoring 방식이 제대로 동작하고 있는지 알아볼 필요가 있었다.

Node 상태

Scoring priority에 영향을 줄만한 지표들을 뽑아본 상태는 아래 그림과 같다.

pod capacity

requested cpu

requested memory

podscpu (cores)mem (GB)
node1381.054.81
node2120.7750.968
node3250.7755.42
node4350.7553.2
node5261.6253.47

위에서 테이블로 정리한 것처럼 node2, node3 (cassandra cluster가 배포되어 있는 node로 실제 부하가 많이 발생해서 문제가 생기는 node임)의 pod 갯수가 다른 node에 비해 상대적으로 낮고 requested cpu의 값도 낮다.

Scoring이 priority에 따라 복합적으로 작동하겠지만 위의 값들만 봤을 때는 부하가 상대적으로 작거나 덜 사용하고 있는 node가 2, 3번으로 보인다. 하지만 실제 cpu / memory usage는 위의 수치들과 다르다. 모니터링을 해보면 cassandra가 실제로 많은 자원을 사용하고 있다. (memory는 보통 10GB 정도, cpu usage의 대부분)

Scoring priority를 보면 scheduler가 scoring을 할 때 resource와 관계된 수치는 node의 capacity, allocatable, requested인데 capacity와 allocatable은 거의 변하지 않는 수치로 (allocatable이 단어 자체의 의미와 달라서 오해의 여지가 있다) 사용자 입장에서 조정이 가능한 부분은 requested 값이 유일하며 이 값은 실제 사용량과는 다르다.

이게 scheduler가 특정 node에 신규 pod들을 배포하는 이유라는 생각이 들어서 왜 requested cpu, memory 값이 낮은지를 봤다. 당연하겠지만 실제로 가장 많은 resource를 차지하는 cassandra에 request resource 지정이 되어있지 않은 상태였고 새로 지정해보기로 했다. (2core, 8Gi)

Resource request 지정 후 node의 상태는 아래와 같다.

설정 후 requested cpu

설정 후 requested memory

podscpu (cores)mem (GB)
node1381.054.81
node2120.775 => 2.7750.968 => 9.56
node3250.775 => 2.7755.42 => 14.01
node4350.7553.2
node5261.6253.47

설정 후 처음 실험했던 것처럼 nginx를 20개 배포해보기로 했고 아래 그림처럼 실제 부하가 높은 node2, 3 이외에 다른 node 들에도 배치되는 것을 확인할 수 있었다.

결론

이 문제를 겪으면서 새로 알게 된 내용이나 필요하다고 생각되는 내용은 이렇다.

  1. Kubernetes scheduler가 실제 node 부하를 반영해서 scheduling 하지는 않는다. (이런 기능이 필요할 것 같다. 또는 좋은 방법이 있을까?)
  2. 배포될 pod 마다 resource request 값 지정하는게 사실 번거롭다. 어떻게 편하게 할 수는 없을까?
  3. 모든 pod 마다 request 지정이 번거롭더라도 최소한 무거운 것들은 지정해주자!

· 9 min read

Docker container + AWS API gateway로 개발환경이 구성되어 있는 상태에서 Kubernetes 도입을 검토하게 되었다. Image build나 배포를 위해 Jenkins와 Ansible을 이용하고 있기는 했지만 원래의 구성이라면 배포나 배포 후의 작업들이 번거롭기도 하고 배포의 편의성이나 유연함과는 거리가 멀다고 느껴졌기 때문이다. 예를 들어 같은 기능을 갖는 backend service가 가용성을 위해 여러개 떠있다고 가정했을 때 update를 진행한다 생각하면 기존의 방식으로는 머리가 아플 것이 분명하다.

AWS API gateway의 특징

Kubernetes를 도입하기 전에 AWS API gateway을 살펴보지 않을 수 없는게 API gateway와 service의 연결 측면에서 managed service 로서의 특징이 존재하기 때문이다. 어쩌면 기존에 사용하던 AWS API gateway를 계속 유지해야 할 것 같다는 생각이 강하게 들어서 이 모든게 복잡해진 것일지도 모르겠다. AWS API gateway는 하위 서비스들과의 연결 방식으로 Lambda, AWS service, HTTP, VPC link를 제공하고 있다. (Mock도 있지만 제외) 일반적인 API gateway처럼 HTTP 연결은 기본으로 되어야 하는 것인데 AWS API gateway는 최근에 나온 private endpoint를 통한 외부 인터넷으로부터의 연결 제한을 제외하고는 public으로 오픈되어 있기 때문에 HTTP를 통해 하위 서비스와 연결하려면 서비스 역시 public이어야 한다는 제약이 발생한다. 그렇다면 private subnet에 있는 서비스와는 연결이 불가능한 것일까? 이런 경우를 위해 VPC link 라는 것을 제공하고 있다. VPC link는 아래 그림처럼 특정 VPC내에 NLB를 두고 NLB가 VPC 내의 target group을 설정함으로써(public이든 private이든 관계없음) API gateway와의 연결을 만들 수 있도록 되어있다.

External access to Kubernetes

기존에는 위의 그림처럼 container들이 올라가있는 instance 들을 target group으로 지정해서 연결했었는데 Kubernetes를 도입한다면 연결 대상이 Kubernetes 내의 service 들이 될 것이다. 그러므로 역시 Kubernetes에서 제공하는 외부 접근방식에 대해 따져보아야만 한다. Kubernetes에서 제공하는 외부 접근방식은 proxy를 제외하고 크게 세가지로 볼 수 있을 것이다.

1. NodePort

가장 기본으로 서비스를 외부에 노출시킬 수 있는 방법으로 서비스가 올라간 host의 port를 통해 접근하는 방식이다. Cluster를 구성하는 모든 worker node에는 kube-proxy라는 컴포넌트가 존재하고 kube-proxy는 cluster 내의 서비스가 매핑된 port 정보 등을 공유하고 있기 때문에 어떤 node를 통하더라도 동일한 port로 동일한 서비스에 접근이 가능하다. Cluster를 구성하는 컴포넌트들을 제외하고 서비스의 연결 측면에서만 바라본다면 아래 그림처럼 묘사할 수 있을 것 같다.

2. LoadBalancer

Cluster에 service를 배포할 때 LoadBalancer type을 지정하게 되면 LB까지 자동으로 구성된다. (kops 기준) 자동 구성되는 내용들을 살펴보면 이 방식도 서비스를 host의 특정 port와 매핑하고 해당 port 정보를 LB로 묶게 된다. LB는 기본으로 Classic ELB가 올라가게 되지만 AWS 환경이라면 option으로 NLB를 선택할 수도 있다. 다만 Classic ELB든 NLB든 배포할 경우 service당 하나의 LB가 할당된다.

이 부분에서 고민을 좀 해봐야 하는데, 아무리 물리적인 LB를 들이는 것보다 저렴하고 전반적으로 가격이 많이 내려갔다고 하더라도 서비스 당 LB를 하나씩 넣어야 하는 것은 문제가 될 수 있다고 본다. LB를 통한 외부와의 연결을 해야한다면 노출할 서비스의 갯수를 줄이던가 아예 다른 방식을 찾아야만 한다.

3. Ingress

Ingress는 LoadBalancer type과는 다르게 여러 서비스를 하나의 컴포넌트로 외부에 노출시킬 수 있다. 설정을 통해 특정 도메인과 서비스를 연결하거나 path를 통해 서비스 분기가 가능하다. 그렇기 때문에 여러개의 서비스를 하나의 Ingress만으로 접근 가능하게 할 수 있고 부가적으로 cluster 외부에 ALB를 두고 외부로부터의 접근을 가능하게 할 수 있다.

Ingress에 NLB를 붙일 수 없게 되어있는데, NLB는 TCP로 특정 port를 가진 target을 대상으로 로드밸런싱을 하도록 되어있고, Ingress를 위해 사용되는 ALB는 HTTP를 통해 밸런싱하고 path 지정 등이 가능하기 때문에 사실 Ingress에는 ALB가 가장 적합하다고 볼 수 있겠다.

AWS API gateway와 연결하려면 어떻게 해야하나?

AWS API gateway에서 제공되는 연결 방식 중 하나를 사용해야만 하므로 Lambda, HTTP, VPC link를 가지고 생각해야만 한다. 이 중 Lambda를 사용하게 되면 전체 서비스 구성상 하나의 컴포넌트를 더 통해야 하므로 지연의 문제가 추가로 발생할 수 있고 호출에 따른 비용문제도 고려해야 한다. HTTP로 묶게 될 경우 Kubernetes cluster 전체가 public으로 올라오던가 ALB만 외부에 노출시키는 방법을 생각해 볼 수 있을 것 같다. 하지만 이 경우 외부에서 접근 가능한 경로에 ALB가 추가되기 때문에 ACL 같은 걸 적용해서 API gateway를 통해서만 접근할 수 있도록 하면 좋은데 방법이 없어 보인다.

VPC link를 통한다면 전체 cluster를 private subnet에 넣어서 보다 안전하게 관리할 수 있을 것 같은데 위에서 기술한 것처럼 VPC link는 NLB만 지원하고, 서비스를 자동으로 배포할 경우 서비스 당 NLB가 하나씩 붙게 되기때문에 비용 측면에서 좋은 방법은 아닌 것 같다. 개인적으로는 API gateway를 꼭 사용해야만 한다면 AWS API gateway 대신 다른 API gateway를 사용해서 Kubernetes cluster 내부에 넣어버리는게 가장 좋은 방법이 될 거라고 생각하고 있다. 아래 그림처럼 되면 되지 않을까?

하지만 AWS API gateway를 꼭 사용해야만 한다면 (그래서 이런 고민들을 했던 거지만), 서비스 별로 NodePort를 지정하고 (지정하지 않으면 random으로 할당되니) 배포하되 미리 NLB를 생성해서 지정된 port 들을 가리키고 API gateway는 VPC link로 해당 NLB를 연결하는게 어떨까 싶다.

· 10 min read

종종 집을 나서기 전에 날씨앱을 열어서 그 날의 날씨를 살펴보곤 한다. 그런데 그나마도 귀찮게 느껴질 때가 많다. 그냥 눈에 띄는 곳에 일기예보가 디스플레이 되고 있으면 훨씬(?) 편할 것 같기도 하고 집을 나서기 전에 Alexa든 뭐든 나에게 알려주면 좋을 것 같다는 생각을 가끔 하게 된다. 귀찮음을 해결할 방법이 몇가지 떠오르곤 했지만 그 것들을 실행하는 것도 귀찮아서 그냥 생각만 하고 있던 터였다.

지난번에 실내 온도와 습도를 차트로 그려보면서 생각해봤던 것들을 실행해볼까 했는데 최종 목표는 뭐뭐뭐가 있지만 일단은 외부의 온도와 습도, 강수량, 예보 정보에 대해서 다시 고민해봐야 했다. 당시에 실외 날씨와 예보 정보를 darksky라는 해외 서비스를 통해 받아오게 해두었었는데 정보를 얻어오는 방법은 간단했지만 해외 서비스이다보니 정확하지 않은 듯 보였다. 예보 정보도 좀 다르다는 느낌이었고 현재 날씨에 대한 것도 국내 기상청 정보와는 차이가 있었다. 역시 내부 상황은 내부자들이 잘 아는 것인가 싶어서 국내 서비스나 기상청 정보를 이용해야겠다는 생각에 이르게 되었다.

날씨 정보 서비스들

몇년 전 기상청 동네예보 서비스를 가지고 놀았을 때 뭔가 복잡하다는 인상을 받았기 때문에 기상청은 제일 뒤로 미뤄놨고 다른 서비스들이 있나 찾아봤더니 케이웨더, 웨더아이 뭐 이런데가 있는 것으로 보였다. 두 서비스들은 각각 일반 업체가 서비스하는 것 같았는데 케이웨더는 뭔가 내부 사정에 의해 날씨 정보 제공서비스를 접었다! (업체 게시판에는 탈퇴할거니 개인 정보를 삭제해달라는 글들이 가득하다. 이미 개인 정보들은 공공재 아니던가?!) 그리고 웨더아이는 유료. 내가 뭐 대단한 걸 할 게 아닌데 유료로 이용할 수는 없는 노릇. (날씨 정보 말고도 돈 나갈 곳은 많다) 그래서 어쩔 수 없이 다시 기상청을 기웃거리게 되었다.

<그림 출처 : darksky.net (이렇게 예쁜 지도와 API를 제공할 수는 없는 것일까?)>

기상청 동네예보 서비스

기상청 동네예보 서비스(API)를 다시 천천히 살펴보니 철저히 '정보 제공자' 입장에서의 서비스라는 느낌이 강하게 들었다. 문서에서 사용되는 용어 자체도 언뜻 구분이 안된다. 예를 들어, '초단기실황조회'와 '초단기예보조회' 라는 이름을 보면 실황과 예보라는건 알겠는데 실황에 '초단기' 라는게 왜 붙어있는 건지(실황이면 그냥 현재인 것 아닌가?) 초단기라면 얼마나 초단기라는 건지 모르겠다. API를 호출하는 입장에서 보면 날씨정보를 원하는 시점에 대한 정보를 보내줘야 하는데 그 시점에 대한 정보가 사용자 입장에서는 애매하다. 오전 11시의 날씨 정보를 얻고 싶다면 11시에 해당 정보를 요청하면 안된다. 발표되는 시각이 있기 때문에 문서상에 기술된 시간 이후에 요청해야 한다. '초단기실황'의 경우 매시간 30분에 정보가 생성된다고 되어있기 때문에 11시의 날씨 실황을 얻고 싶다면 11시 30분 이후에 요청해야 한다는 얘기. 동네예보의 경우는 더 복잡하다. 3시간 단위로만 예보정보가 생성되는 것 같은데 요청은 해당 시간보다 10분 뒤에 요청해야 한다. (오전 2시 시점의 예보 정보는 2시 10분 이후에 요청 가능) 그리고 요청했을 때 제공되는 정보 항목 중 '아침 최저기온'과 '낮 최고기온' 이런 것들은 요청 시점에 따라 데이터 항목이 비어있을 수도 있다. 해보니 오전 일정 시간대에는 아침 최저기온 정보가 제공되고 낮 최고기온은 제공되지 않으면 일정 시간대 이후에는 반대로 낮 최고기온만 제공되는 것 같다. 문서에도 표로 기술되어 있기는 하지만 잘 이해가 안된다. 내 이해력의 문제인지도 모르겠다.

내가 원하는 것들

내가 원하는 것, 어쩌면 사용자들이 원하는 형태는 어느 시점이든 정보를 요청하면 그게 언제 측정된 것이든 가장 최근 정보를 보내주는게 아닐까 싶다. 기상청 내부에서는 몇시간 단위로 측정하든 분 단위로 측정해서 저장해놓든 그것은 내부에서 정책이나 상황에 맞게 처리할 일이다. 어쨌든 내가 원하는 방향으로 만들어진 라이브러리 같은게 혹시 있나 찾아봤더니 없는 것 같아서 만들어보기로 했고 일단 기본적으로 원하는 기능은 구현했다. 나도 복잡한 걸 다시 찾아보느라 시간을 또 쓰기는 귀찮은 일이니 여러가지 면에서 도움이 되리라 생각했다.

Sources

https://github.com/blurblah/kma

구현하면서 문제가 된 내용들과 해결책

'초단기 실황'과 '동네예보' 공통으로 위에서 언급한 특정 시간에 대한 계산이 필요했다. 요청하려는 시점에서 가장 최근 공개된 요청 가능한 시간을 찾는 내용. 더 깔끔하게 하고 싶은 욕구도 있지만 그 욕구는 미래의 나에게 던지는 떡밥으로 남겨둠. 그 다음 동네예보는 명칭에서 풍기는 냄새처럼 원하는 동네에 대한 위치 정보를 요구한다. 그런데 위치 정보는 흔히 사용되는 위경도 정보가 아니다. 위경도를 펼쳐서 일정한 크기의 구역으로 쪼개놓고 그 구역의 x, y 좌표를 넘겨줘야 한다. 그리고 그 동네별 x, y 좌표값은 엑셀 문서로 제공하고 있다. (그냥 사용자가 위경도 정보를 넘기면 알아서 정보를 주면 안되나?) 아무튼 기상청 어느 곳에 위경도를 x, y 좌표로 변환하는 jsp 페이지가 있길래 페이지 소스를 보니 친절하게도 javascript로 달려있어서 python으로 옮겨버렸다.

좀 더 편하게

pypi에 패키지로 올려버렸다. 그러므로 pip로 패키지 설치가 가능하다. 이제 날씨 정보가 필요할 때 요청 가능한 시간, 좌표 확인 이런 걸 할 필요가 없다. 그냥 발급받은 서비스키만 넣어주고 호출만 하면 끝.

from kma import Weather

w = Weather('SERVICE_KEY')
curr = w.get_current()
forecast = w.get_forecast()

· 11 min read

제목은 저렇지만 정확히는 Xiaomi 온습도 센서로 측정한 값을 Smart Things가 받고 그걸 InfluxDB에 저장해서 Grafana로 그린다가 되겠다. 해놓고 나니 이게 큰 의미가 있나 싶지만 준비해 놓은지는 꽤 되었으니 정리하는 차원에서. 성공한 기업가들이 내가 하고 싶은걸 하지 말고 고객이 원하는걸 하라고 했다는데 그 말이 맞다는 생각이 문득 든다.

원래 집안에 이런 저런 장비들을 엮어볼 생각으로 Smart Things를 구매해 놨었는데 zigbee나 zwave를 지원하는 센서 종류는 다 붙일 수 있는 걸로 알려져있다. 물론 공식적으로 지원하는 종류들이 있고 그 외에는 무언가 수를 써줘야만 가능하다. Smart Things는 내부적으로 Device Type Handler 라는걸 만들어줘야 하는데 주변 장치들과 주고받는 데이터의 형태를 정의한 내용 정도로 이해하고 있다. 공식적으로 지원하는 장치들은 그 DTH 라는게 제공되고 그렇지 않은 장치들은 만들어주던가 해야하는데 그 DTH가 정의되어 있지 않아도 Smart Things에서 장치 찾기를 하고 있는 상태에서 해당 장치를 페어링 하려고 시도하면 날라오는 zigbee 기반의 데이터는 볼 수 있다.

Pairing

그런데 Smart Things에서 공식 지원하는 센서들이 꽤 비싸다. 상대적으로 Xiaomi에서 나오는 센서들은 개당 만원정도로 저렴. Xiaomi 센서들도 zigbee 기반이고 기본은 Xiaomi의 home gateway 뭐 이런 장비들과 붙여 쓰라고 나온거지만 능력자님들께서 Smart Things에 붙일 준비를 다 해놓으셨다. Xiaomi의 gateway는 그네들의 제품들을 붙여 쓰기엔 좋겠지만 다른 제품군은 지원하지 않고 뭔가 내가 제어를 해보려고 하면 전용앱으로만 가능한 듯 해서 일찌감치 gateway는 포기했고 Smart Things로 시도. Github에 올라가 있는 DTH만 가져다쓰면 페어링은 어렵지 않다.

Smart Things의 구조에 따른 데이터 전달

Smart Things는 언뜻보면 요상한데 Box 형태라서 연결된 장치들에 대한 제어나 장치로부터의 데이터 수집은 box에서 이루어질 것 같지만 box 자체에 접근할 방법이나 api 같은게 없다. 그러니까 쉽게 raspberry pi 처럼 생각해서 내부에서 웹서버 같은게 하나 돌고 rest api를 제공하는게 쉬울 것 같은데 전혀 다르게 Smart Things에서 서비스하는 developer console을 통해서만 제어든 뭐든 가능하다. 그것도 application이라는 이름으로 만들어줘야 한다. 쓰다가 생각해보니 Amazon의 echo와 비슷하다. Box는 거의 깡통이고 받은 데이터는 위로 올리고 (심지로 로그까지), 스마트폰 전용 app에 개인이나 다른 사람이 만들어서 공개한 app (app내 app)을 설치하고 나면 그 app에서의 조작에 따라 Smart Things를 통해 box로 내려오는 걸로 보인다. 단순히 보면 이런 모양.

데이터를 가져와야 하나 보내줘야 하나

문제는 DTH를 포함해 app을 만드는 것도 groovy 기반의 DSL을 사용해야만 한다는 사실이다. 이미 정의된 형태의 UI (tile)들이 있고 groovy로 개발할 때 다른 library 들을 불러오는데 제약이 있다. 또 file io 등 시스템 자원에 대한 접근이 불가능하다. 대부분 필요할 것 같은 기능들은 이미 준비되어 있으니 준비해둔 메소드나 클래스를 가져다 쓰라고는 되어있는데 완전 새로운 언어를 익히는 느낌이고 디버깅이 힘든데다가 로그까지 브라우저에서 별도의 메뉴로 진입해 확인해야 하니 불편하다. (이런 방식이 편한 사람이 있을지도 모르겠다) 센서로부터 수집된 데이터들은 (사실 이런 용도로 만들어진 제품이 아니라는 생각도 들지만) box나 Smart Things 내부에 쌓이는게 아니기 때문에 Smart Things 서비스에 접근해서 받아오거나 반대쪽으로 보내줘야만 한다. 구조상 두번째 방법이 가능성이 있어보여서 groovy로 app을 하나 만들었다. Smart Things 모바일 앱 내부에서 보면 아래 그림처럼 이벤트가 발생하는 센서 종류를 선택하고 데이터를 보낼 곳을 지정하게 했다. 그림에는 보이지는 않지만 InfluxDB에 저장할거라 db 이름과 username, password까지.

데이터를 저장했는데 주기가 문제

InfluxDB에 데이터가 저장되기 시작하면서 Grafana를 붙여 차트를 그려봤는데 문제가 생겼다. Xiaomi 센서가 주기적으로 측정하지 않고 주기를 조정할 방법이 없어서 차트가 볼품이 없다. 온도나 습도가 어느 정도 수준 이상의 변화가 있을 때만 측정한다고 알려져있는데 빠르면 몇 분만에 측정하는 경우도 있지만 3시간 정도 측정하지 않는 경우도 생기기 시작했다. 센서 사이즈가 작고 전지를 쓰는 형태라 전력소모 문제로 그렇게 되어있는 듯 한데 도저히 조절할 방법이 안보여서 비싸지만 Smart Things의 센서로 교체해볼까도 생각해봤으나 그것도 동일하다고 해서 패스. 그래서 측정 문제로 해결할게 아니라 원본 데이터를 크게 왜곡하지 않는 선에서 볼품 없는 차트를 그럴듯하게 만들어보기로 했다. 어느 정도로 볼품이 없는지는 아래 그림이면 설명이 될 것 같다.

녹색 라인이 측정된 값으로 그려진 차트인데 좌측과 우측이 비어있다. 이전 3시간을 그린 건데 4번 정도 측정한 것으로 보이고 없는 구간은 찍힌 점이 없어서 그렇다. 이런 상태를 개선하기 위해서 5분 단위로 데이터를 grouping 하고, 그럼 데이터가 없으니 평균값을 사용해봤는데 그래도 개선은 안되고 (5분 단위로 쪼개려고 한 것은 위 그림의 노란색 라인이 다른 곳에서 받아오는 외부 온습도 데이터인데 5분 단위로 받아와 저장하기 때문에 맞춰주려고 시도한 것) InfluxDB의 fill 기능을 사용해서 빈 값을 이전 값으로 채워줬다. 이것도 문제는 있는게 이전 값으로 채우기 때문에 보여지는 time range에서 이전 값이 없는 경우 (시간 구간 중 초기 값)에는 여전히 비어있게 된다. 다음 값으로 채우는 옵션도 있으면 좋겠다 싶다. (관련된 내용은 InfluxDB 문서에도 이슈로 달려있음) 추가로 차트 종류도 변경했더니 처음보다는 보기가 훨씬 나아졌음.

Raspberry pi + sensor가 낫지 않나?

이런 생각을 잠깐 해봤는데 이렇게 하면 측정 주기 같은건 내 맘대로 할 수 있다. 복잡하게 Smart Things에서 app 같은거 안만들어도 되고. 그런데 이렇게 할 경우 여러개의 센서를 쓰려면, 예를 들어 방마다 센서를 두겠다 하면 raspberry pi도 센서 수만큼 필요해질 것 같다. 작게라도 보드를 만들어야 할 수도 있고, 전원 문제도 신경을 써야 한다. 역시 만들어진 걸 사서 쓰는게 그나마 편하다.

· 7 min read

잊을만하면 쏟아지는 T-mobile용 AC1900 짜리 ASUS 공유기. 이쯤이면 'T-mobile 그거'나 'ASUS 그거'라고 불려도 되지 않을까? 대부분 제조사 리퍼제품이거나 open box이거나 그렇지만 성능에 비해 매우 저렴하게 나와서 많이들 주문하는 것 같다. 쳐다보고 있자니 그것들이 쏟아지기 전에 원모델 중 하나(AC68U)를 요즘보다는 비싼 가격으로 구매했던터라 아쉬운 마음 뿐이다.

아무튼 얼마전에 아는 형님이 '그런 공유기' 모니터링 방법을 공유해주셨는데 멀린 펌업 => metric 수집 script 실행 (cron) => influxdb에 저장 => grafana에 표시하는 내용으로 요약할 수 있을 것 같다. 나의 경우 Asus 공식 펌웨어를 쓰고 있기 때문에 멀린 펌웨어로 올리는 과정이 필요한터라 SNMP로 어떻게 해보기로 했다.

SNMP를 지원하나?

많은 OS, 라우터 등의 장비들이 snmp를 지원하는 것으로 알려져있다. Asus 공유기라고 안될 이유는 없는데 관리 메뉴에는(있을법도 하지만) snmp on/off 라던가 설정에 대한 내용이 없다. 그럼 net-snmp 같은 패키지를 설치해서 돌려야 하는데 공유기가 많이 알려진 배포판 리눅스가 올라간게 아니라서 어떻게 설치할 수 있는지 패키지 이름은 뭔지 알 수가 없다.

Download master와 ipkg, 그리고 net-snmp의 설치

찾아보니 asus 공유기에서는 debian의 dpkg와 비슷한 ipkg를 사용할 수 있다고 한다. 그런데 이것 역시 기본적으론 설치되어 있지 않고 꼼수를 써야 가능하다. 먼저 공유기 뒷면에 있는 usb 포트에 스틱이든 하드든 꽂아준다. (단, 리눅스이므로 filesystem이 ext4나 ntfs 같은 형태여야 인식 가능) 공유기에서 인식을하면 download master를 설치할 수 있게 되는데 설치를 끝내면 연결한 외부 장치에 ipkg가 설치된다. (mount된 경로에 설치가 되기 때문에 빼면 안된다) ipkg로 net-snmp 설치 후 활성화하면 끝.

ipkg update
ipkg install net-snmp
app_set_enabled.sh net-snmp yes

수집은 어떻게?

SNMP를 통해 정상적으로 정보를 얻을 수 있는지는 간단하게 snmpwalk 등을 이용하면 확인할 수 있겠다.

지속적으로 정보를 수집해서 db에 쌓아야 하는데 별도로 스크립트를 만들어서 돌리기는 싫고 (이럼 처음부터 스크립트를 만들어 돌리지 굳이 snmp로 할 이유가 없다) 어차피 influxdb를 쓸 생각이었으니 같은 곳에서 만든 telegraf를 써보기로 했다. Telegraf는 docker container로 돌리고 output 설정은 이미 설치되어 있는 influxdb에 맞게, snmp input에 대해서는 아래처럼 해줬다.

[[inputs.snmp]]
agents = [ "xxx.xxx.xx.1:161" ]
version = 2
community = "public"

[[inputs.snmp.field]]
name = "hostname"
oid = "RFC1213-MIB::sysName.0"
is_tag = true

[[inputs.snmp.field]]
name = "uptime"
oid = "DISMAN-EXPRESSION-MIB::sysUpTimeInstance"

[[inputs.snmp.table]]
name = "interface"
inherit_tags = [ "hostname" ]
oid = "IF-MIB::ifTable"
[[inputs.snmp.table.field]]
name = "ifDescr"
oid = "IF-MIB::ifDescr"
is_tag = true

[[inputs.snmp.table]]
name = "interface"
inherit_tags = [ "hostname" ]
oid = "IF-MIB::ifXTable"
[[inputs.snmp.table.field]]
name = "ifDescr"
oid = "IF-MIB::ifDescr"
is_tag = true

[[inputs.snmp.table]]
name = "interface"
inherit_tags = [ "hostname" ]
oid = "EtherLike-MIB::dot3StatsTable"
[[inputs.snmp.table.field]]
name = "ifDescr"
oid = "IF-MIB::ifDescr"
is_tag = true

이렇게 하고 나니 telegraf의 문제인 것 같은데 IF-MIB라는 OID를 인식못하는 문제가 발생. Community에서 찾은 글을 참고해 따로 snmp-mibs-downloader를 설치해줬다. (Docker image를 새로 만드는게 나을 것 같긴 한데 제대로 되는지만 보려고 container 내부에 들어가서 설치 후에 container를 restart 해줌. 설정된 persistent volume에 변경된 내용이 저장되는 것인지 restart 해줘도 문제없었다)

InfluxDB에서의 확인

위와 같이 telegraf 설정을 한 경우 InfluxDB에서 확인을 해보면 (telegraf 기본 설정 중 inputs.cpu, inputs.disk 등을 제거하지 않은 경우) cpu, disk 등의 measurement 이외에 interface, snmp라는 measurement가 있는 것을 확인할 수 있다. 설정대로라면 snmp에는 host와 uptime 정보 정도, interface에는 network interface 별 정보들이 쌓이게 된다.

Grafana 연결과 dashboard 생성

Grafana에서 data source로 influxdb를 설정하고 나서 쓸만한 dashboard를 찾아보니 마땅한게 없다. SNMP에 대한 것들은 있어도 보통은 cpu, storage 정보 정도라 공유기에서 큰 의미는 없을 것 같아서 직접 추가하기로 했다. SNMP의 OID들을 좀 더 확인해봐야 더 재미있는 것들을 볼 수 있을 것 같은데 일단은 throughput과 uptime만. (지금은 수집해서 쌓는 것도 uptime이랑 interface 별 in/out packet 수 정도뿐)

참고

1. How to configure SNMP on Asus RT-AC68U

2. Using Telegraf, InfluxDB and Grafana to Monitor Network Statistics

· 8 min read

ELK stack을 사용하는 것. 괜찮다고 생각하지만 여전히 쉽지는 않다. 난이도를 떠나서 번거로운 요소들이 있다고 생각되는데 개인적으로 가능한 logstash를 활용하려고 하는게 더 번거롭게 만드는게 아닌가 싶다. 사족이지만.

어찌되었든 Docker container의 로그를 ELK에서 수집하기 위해서는 몇가지 방법을 생각해 볼 수 있을 것 같다.

1. Autodiscovery 기능을 활용

비교적 최근에 만들어진 기능으로 보이는데 내용을 훑어봤을 때 감은 오지만 로그수집 보다는 메트릭 수집의 목적이 강한 것 아닌가 싶다. 결론적으로 잘 되지 않아서 다른 방법을 알아보기로 했다.

https://www.elastic.co/kr/webinars/elasticsearch-log-collection-with-kubernetes-docker-and-containers

2. Syslog log driver를 사용하는 방법

Docker에서 제공하는 log driver 중 syslog가 있는데 syslog를 log driver로 설정한 호스트에서는 container 로그들이 모두 syslog에 쌓이기 때문에 filebeat로 syslog만 수집하면 된다. 하지만 syslog의 다른 로그들과 혼재되면 그걸 골라내는 것도 일일 것 같다.

3. Fluentd를 통해 elasticsearch로 로그를 전송하는 방법

역시 docker의 log driver로 fluentd를 지정해서 모든 container의 로그를 fluentd로 모은 후에 plugin을 이용해 elasticsearch에서 인덱싱하는 방법이 있을 수 있다. Container만 본다면 괜찮은 방법이라고 생각하는데 나의 경우엔 filebeat도 사용해야하는 상황이었기에 기존의 로그수집 방식을 fluentd로 변경해서 통합하거나 두가지를 혼용해야 할 것 같다.

4. Volume mount를 이용한 방법

Docker container 내부에서 로그를 파일로 쌓는 경우 해당 경로를 호스트의 특정 경로와 마운트하고 filebeat가 마운트 된 경로의 로그파일들을 수집하는 방식인데 application을 개발할 때 로그를 파일로 쌓도록 강제해야 하고 로그 경로가 공유되어야 한다는 문제가 있다. 개발 측면에서도 번거롭고 배포 과정에서도 volume 지정을 빼먹으면 안되니 비효율적이라고 생각한다.

5. Filebeat에서 add_docker_metadata processor를 사용하는 방법

마지막으로 검토해서 적용한 내용인데 filebeat에서 간단한 설정만으로 기존의 container나 시스템에 변경을 가하지 않고 로그를 수집할 수 있다. 문서(Enriching logs with Docker metadata using Filebeat)에 의하면 6.0.0 beta1 부터 새로 추가된 기능인 것 같다. Docker container의 stdout 로그들도 실제로는 호스트에 파일로 쌓이고 있기 때문에 해당 파일들을 전송하는 것 같은데 재미있는건 로그외의 container name, image 정보 등도 함께 날라가서 단순히 로그의 내용만 확인하는 데에는 큰 문제가 없다.

add_docker_metadata processor를 사용해보니

위에 링크된 문서를 참고해서 기존에 동작중인 filebeat의 prospector에 몇 줄 추가하고 restart를 하니 언급한대로 로그수집은 간단하게 된다.

# Sample prospectors
filebeat.prospectors:

- input_type: log
paths:
- /var/log/syslog
fields:
document_type: syslog

- type: log
paths:
- '/var/lib/docker/containers/*/*.log'
json.message_key: log
json.keys_under_root: true
processors:
- add_docker_metadata: ~
fields:
document_type: docker

로그 구분을 위해 사용하던 document_type이 deprecated 된 field라 custom field에 넣어준 것을 제외하고는 위의 문서의 내용과 동일하다. 로그는 아래와 유사한 형태로 kibana에서 확인할 수 있다.

Container의 이름도 표시되니 container 이름이나 image로 조회해서 살펴볼 수 있을 것이다. log field에 넘어오는 전체 로그 메세지는 경우에 따라 다시 별도의 field 들로 분류할 필요가 생기겠지만 간단하다.

time field에서 발생한 conflict

위의 이미지는 이미 문제를 해결하고 난 이후라 문제가 없어보인다. 원래 기본적으로는 time field가 넘어오게 되는데 나의 경우에는 다른 곳에서 filebeat가 보내고 있는 로그가 동일한 인덱스로 생성되고 있는 상황이었고 다른 로그의 time field와 type이 달라서 conflict이 발생하는 걸 볼 수 있었다.

(기존의 인덱스에 생성되어 있는 time field는 text, docker container 로부터 넘어오는 time field는 date) 이미 만들어진 index의 field type을 변경하는 것은 index 복제, field 변경, re-index 작업 등을 해야하니 무리라고 생각해서 container 로부터 넘어오는 field에만 조작을 가하기로 했다. (이런 경우에는 logstash가 있는게 불편하지만 편하다!?)

filter {
if [fields][document_type] == "docker" {
mutate {
convert => { "time" => "string" }
add_field => [ "received_at", "%{@timestamp}" ]
}
date {
match => [ "time", "ISO8601" ]
remove_field => "time"
}
}
}

위와 같이 logstash에 filter를 하나 추가해줬는데 이미 custom field로 document_type이 날라오고 있으니 구분해서 처리하고 time field를 string으로 변환해봤다. 처음엔 변환만 했는데도 동일한 문제가 생겨서 string으로 변환 후 timestamp로 찍히도록 date filter를 추가하고 time field는 날려버렸다. 차라리 time field를 다른 이름으로 rename 하는게 낫지 않을까도 생각해봤는데 timestamp가 있으니 의미는 없을 것 같다. Convert를 했는데 왜 동일한 문제가 생기는지는 좀 의문.

· 9 min read

서비스를 구성하면서 살펴보다 보면 어떤 서비스, 어떤 페이지에 request가 많은지 그 요청들은 어느 곳에서 많이 발생하는지 궁금할 때가 있다. 글로벌을 타겟으로 하는 서비스인 경우 특히 그럴 것이고 혹은 의도치 않은 곳에서 이상한 형태의 요청이 빈번할 경우 차단할지 판단하기 위해 필요할 수도 있다. Google Analytics 같은 서비스의 경우 이러한 요구사항을 간편한 방법으로 어느정도 해소할 수 있지만 어차피 여러가지 분석 용도로 ELK를 사용하고 있으니 packetbeat를 이용해보기로 했다.

Packetbeat?

ELK stack에서 로그 수집용으로 filebeat를 많이 사용하는데 filebeat는 특정 로그파일을 수집해서 logstash나 elasticsearch 등으로 전송하는 역할을 수행한다. Packetbeat는 로그파일 대신 network packet 데이터를 수집해서 전송할 수 있으며, 각종 프로토콜 별로 데이터를 분류해서 수집할 수 있게 되어있다.

테스트용 ELK 구성

우선 packetbeat를 사용해서 어느 지역에서 요청이 들어오는지를 보고 싶은 게 1차 목표였는데 실제 운영중인 ELK를 건드리고 싶지는 않아서 새로운 VM에 Ansible로 다시 구성했다. 미리 만들어둔 5개의 role을 clone해서 아래와 비슷한 형태로 playbook을 만들고 실행하면 된다. ELK가 동시에 여러 종류의 beat에서 전달되는 데이터를 받아야하니 다른 문제가 있는지를 보기 위해 filebeat도 같이 구성했다.

1. Roles

https://github.com/blurblah/ansible-role-elasticsearch

https://github.com/blurblah/ansible-role-logstash

https://github.com/blurblah/ansible-role-kibana

https://github.com/blurblah/ansible-role-docker (Dependency)

https://github.com/blurblah/ansible-role-filebeat

2. Playbook

- hosts: elk_test
become: yes
roles:
- role: elasticsearch
cluster:
name: test-es
- role: logstash
logstash_loglevel: debug
- role: kibana
server_name: test-kibana

- hosts: elk_test
become: yes
roles:
- role: filebeat
logstash_host: '{{ ansible_default_ipv4.address }}'
logstash_port: 5044
enable_kibana_dashboard: true
kibana_host: '{{ ansible_default_ipv4.address }}'
kibana_port: 5601
prospectors:
- { type: syslog, files: ['/var/log/auth.log'] }

Packetbeat 구성

Packetbeat도 역시 Ansible role로 만들어서 ELK 구성과 비슷한 형태로 간단히 완료. Role은 github에서 가져오면 되고 요청을 분석하고 싶은 서버를 대상으로 아래와 같은 형태로 playbook을 만들어서 실행했다.

- hosts: test_proxy
become: yes
roles:
- role: packetbeat
packetbeat_name: test_packetbeat
packetbeat_protocols:
- { type: tls, ports: [443] }
packetbeat_loglevel: debug
out_logstash:
host: '{{ hostvars["elk_test"].ansible_host }}'
port: 5044

내가 분석하고자 했던 서버는 nginx로 되어있는 reverse proxy 였는데 인증서가 적용되어 있었기 때문에 type을 tls로 port는 443 하나만 넣어서 지정했다.

Geoip filter

Packetbeat 까지 구성해서 데이터가 수집되는 것을 확인해보면 (Kibana에서 index pattern 생성까지 한 경우) 요청 ip, url, protocol 등은 표시되지만 위치 정보에 대한 내용은 없다. Logstash의 geoip filter는 수집된 정보 중 ip와 내부 DB에 매핑된 정보를 가지고 geoip라는 field를 생성해주는 역할을 수행한다고 해서 아래처럼 logstash pipeline 경로에 filter를 설정해주었다.

filter {
if [@metadata][beat] == "packetbeat" and [client_ip] and [client_ip] !~ /^10\./ {
geoip {
source => "client_ip"
}
}
}

Filebeat 등 다른 beat들은 geoip filter를 적용하지 않을 것이기 때문에 metadata를 이용해 거르게 했다. Private ip의 경우 위치 정보에 대해서 매핑된 정보가 없기 (알 수도 없고) 때문에 내 환경에서 요청이 발생하는 10.0.0.0/16 대역에 대해서도 적용하지 않기로 했다. Private ip에 대해서 처리를 하게 되면 아래와 같은 오류가 발생한다.

[2018-04-18T04:52:51,124][DEBUG][logstash.filters.geoip   ] IP 10.x.x.x was not found in the database {:event=>#<LogStash::Event:0x65726e3c>}

Filter 설정은 좀 더 살펴보고 더 효율적으로 수정할 필요는 있을 것 같다.

Geoip field 인식 문제

Filter 설정 후 logstash를 restart 하고 나서 kibana 쪽에서 살펴보니 없던 geoip field 들이 새로 만들어지는건 확인이 되는데 아래 그림처럼 warning이 발생하는 걸 볼 수 있었다.

이전에 데이터 들어오는걸 보겠다고 index pattern을 생성했는데 그 시점의 index에는 없던 field 들이기 때문에 warning이 발생하는 것이라서 index pattern을 제거하고 다시 생성해주는 걸로 문제를 해결

geo_point type

Warning이 없어졌으니 kibana의 visualize 메뉴에 가서 map을 추가해 그려볼까 하고 설정을 해보니 아래와 같은 오류가 뜨면서 그려지질 않는다.

무엇이 문제인가 싶어서 index pattern에서 field들의 type을 살펴보니 geo_point type으로 된 field가 보이질 않는다. 이런 부분은 좀 개선이 필요해 보이는데 field 들이 filter에 의해 자동으로 생성되는 것이라 알아서 처리되면 편할 것 같다.

이런 경우 elasticsearch에서 생성하는 index에 대한 변경이 필요한데 이미 존재하는 index를 일괄 변경하는 방법도 있을 것 같고 새로 생성되는 index에 대해서 적용할 template을 추가하는 방법도 있을 수 있다. 나의 경우엔 이미 만들어진 index와 데이터가 거의 없는 상태이기 때문에 존재하는 index는 모두 제거하고 template을 추가해서 이후 생성되는 index에 대해서만 field type이 변경되도록 했다. Template을 적용하기 위해서는 아래와 같은 json 파일을 생성해서 elasticsearch에 밀어넣어야 한다. (이전에 index, index pattern 제거하고 진행함)

{
"template": "packetbeat-*",
"mappings": {
"_default_": {
"properties": {
"geoip": {
"properties": {
"location": {"type": "geo_point"}
}
}
}
}
}
}

Json 생성 후 elasticsearch에게 보내주자.

curl -XPUT -H "Content-Type: application/json" localhost:9200/_template/packetbeat_geoip -d@packetbeat_geoip.json

다시 packetbeat 로부터 데이터가 들어오기 시작하면 template이 적용된 index가 생성되고 kibana에서 index pattern을 다시 생성해 확인해보면 아래처럼 geoip.location field가 geo_point type으로 되어있는 것을 확인할 수 있다.

이제 지도를 그리자

오류가 발생하던 부분을 해결했으니 다시 visualize 메뉴에서 지도를 추가해 설정하면 아래처럼 요청 횟수에 따라 색깔별로 표시되는 걸 볼 수 있다.

VPN에 연결해서 다른 나라로부터의 요청도 제대로 표시되는지 확인해보니 별 문제없이 된다.

 

Packetbeat로부터 들어오는 데이터들을 간단히 살펴보니 사실 지역에 대한 정보 이외에도 쓸만한 내용들이 많다. 요청 url 별로 분석할 때도 유용할 것 같고 protocol type (service) 별로 구분해서 확인하는 것도 쉽게 가능할 것 같다. 그러나 이런 도구들로 분석할만한 서비스를 만들어내는 건 어려운 문제 :)

· 19 min read

Legacy system을 MSA로 변경하는 작업을 하다보니 흩어져있는 각 application server 들의 인증이나 세션 공유, 그리고 배포와 관련해서 application 추가 혹은 변경시 어떻게 유연하게 대응할 수 있을지 고민을 하게 되었다. 필연적으로 API gateway를 살펴볼 수 밖에 없었는데 그 중 Netflix OSS인 Zuul에 상당히 재미를 느끼게 되었다. 내가 필요한 기능들을 filter로 넣으면 되겠다 싶기도 했고 Netflix tech blog에 기술된 Zuul에 대한 문서(Announcing Zuul: Edge Service in the Cloud) 가 그런 느낌을 갖게 만들어 준 듯 하다. 문서에 소개된, Netflix에서 Zuul을 활용하는 사례들만 보면 뭔가 대단히 흥미로운 작업들을 나중에 추가로 해볼 수도 있을 것 같다.

Zuul은 무엇인가?

고스트 버스터즈 1편을 보면 시고니 위버가 왠 개같이(발음 주의) 생긴 악마한테 빙의되고 나중엔 그걸로 변하는데 그게 Zuul이다. (영화에서는 Zuul로 변하는게 둘인데 하나는 gatekeeper, 하나는 keymaster)

Zuul은 문지기 정도의 역할이라 보면 되겠다. 이것과 비슷하지만 또 다르게 Netflix의 Zuul은 dynamic routing 등을 수행할 수 있는 gateway 서비스. Routing 기능뿐만 아니라 http 요청과 응답 사이에서 동작하는 filter를 지원함으로써 filter 구현내용에 따라 더 다양한 기능을 추가할 수 있고 전체적으로 유연한 서비스 구성이 가능하게 될 것 같다. 추가로 Spring boot 에서 몇가지 Netflix OSS (Zuul, Ribbon, Eureka, Feign, ... (Feign은 Netflix OSS project에서 분리된 듯))들을 지원하고 있기 때문에 Spring boot 프로젝트에서 원하는 spring cloud package에 대한 dependency 추가해서 코드 몇 줄 추가하는 것만으로도 간단하게 시작해 볼 수 있다.

Zuul에서의 filter

Zuul에서 filter의 기능과 역할을 잘 이해하기 위해 Zuul core의 구조를 먼저 확인해보는게 좋을 것 같다.

<출처 : https://medium.com/netflix-techblog/announcing-zuul-edge-service-in-the-cloud-ab3af5be08ee\>

참고로 위 그림은 Zuul 1.x의 core 그림으로 servlet을 확장해 사용하는 것처럼 보이지만 Zuul 2.x에서는 Netty 기반으로 수정되었기 때문에 실제 현재의 구조와는 다르겠지만 컨셉을 이해하는데 문제는 없을 것 같다. 그림을 보면 http 요청과 응답에 대한 처리는 servlet이 담당하고 요청과 응답 사이에서 filter를 실행할 수 있게 되어있다. Filter는 servlet과 request context를 공유하기 때문에 request context를 통해 요청과 응답의 내용을 파악할 수 있고 그에 따른 적절한 조치를 취할 수 있다. 또는 여러 filter 간 상태 공유를 위해서도 request context를 사용할 수 있다. Filter는 ZuulFilter를 extends 해서 java나 groovy로 구현하면 되는데, 아래의 4가지는 필수적으로 구현을 해주어야 한다.

1. Type (동작 시점과 기능에 따라 pre, route, post, error 네가지 type이 있음)

2. Execution order (같은 종류의 filter 들 사이에서 동작할 순서로 낮을수록 순위가 높음)

3. Criteria (Filter가 동작할 조건. 예를 들어, 특정 request url에 대해서만 동작하도록 할 수 있음)

4. Action (Filter가 수행할 작업)

그림에도 묘사되어 있지만 재미있는게 특정 디렉토리를 지정해서 주기적으로 해당 디렉토리에 있는 filter 들을 읽어들일 수 있다. 이렇게 되면 runtime에서 filter의 추가, 변경이 가능할 것이다.

Authentication, dynamic routing

Zuul을 통해서 바로 실험해보고자 했던 기능은 authentication과 dynamic routing이다. API gateway 하위에 있는 backend application으로 요청을 전달하기 전에 인증을 통해 접근 권한에 따라 적합한 처리를 할 수 있는 pre filter를 추가하면 service 전체에 대한 authentication은 편하게 해결할 수 있을 것 같았는데 dynamic routing의 경우 조금 고민을 하게 되었다. Application의 endpoint를 추가, 삭제, 변경할 수 있어야 하는데 복잡하진 않지만 groovy로 처리해야 한다는게 번거롭다 생각되었고 spring-cloud-netflix 에서는 문제가 있는 것 같은 이슈도 발견. (이슈를 보면 dynamic script reloading은 지원하지 않는다는 내용이 있지만 2015년도. 그 때도 되는 기능이었는지, 안되다가 지원이 된건지 모르겠으나 현재는 zuul의 filter loader를 사용하면 된다. Spring boot 2.0.0 / Spring cloud Finchley.M9)

Dynamic routing을 좀 더 편하게 할 수는 없을까?

Zuul에서 routing 자체는 Netflix OSS인 Ribbon을 통해 properties 추가만으로도 쉽게 가능하다. (@EnableZuulProxy annotation을 쓰면 설정만 하면 된다) 다만 properties에 routing 경로가 고정되어 있다 보니 추가, 삭제 또는 변경할 경우 restart 혹은 deploy 과정이 필요하고, 어차피 API gateway를 구성한다면 해야하겠지만 이중화 구성이 필수적일 수 밖에 없다. 그러니까 load balancer 아래에 여러개의 gateway를 두고 routing 설정 변경시마다 rolling update 같은 방식을 적용해야만 하는데 spring cloud config를 함께 사용하면 이런 번거로운 과정의 반복을 피할 수 있다. (물론 배포과정을 자동화해버리면 그렇게 번거로울 것도 없겠지만 더 간단한 방법이 있으므로. 그리고 설정 변경만으로 배포를 다시 해야한다는 것도 좀 불편한 일이다)

코드로 살펴보자

샘플이기도 하고 비즈니스 로직 같은게 들어있지도 않아서 코드랄 것도 없지만, 또 그만큼 간편해진건 Spring boot과 Netflix 덕분이다. Repository 참조.

1. Config server

Config server는 설정 정보를 중앙에서 관리하기 위한 것으로 아래와 같은 spring-cloud-config-server에 대한 dependency 추가가 필요하다. 반대로 설정 정보를 config server에 위임하고자 하는 서비스는 client가 되어야 하므로 spring-cloud-config-client 또는 spring-cloud-starter-config package가 필요.

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>

그 다음으로는 entrypoint가 되는 Application class에 @EnableConfigServer만 추가하자.

@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}

지금은 다른 동작을 수행할 필요가 없기 때문에 properties만 조금 추가해주면 완료.

server.port=8888
spring.cloud.config.server.git.uri=https://github.com/blurblah/zuul-demo.git
spring.cloud.config.server.git.search-paths=configurations

설정에서 중요한 것은 spring.cloud.config.server.git.uri 항목이다. Spring cloud config는 설정 내용을 관리할 수 있는 backend로 git, file system, vault, jdbc 등을 지원할 수 있게 해두었는데 사실 간단한게 시작할 수 있는건 local file이지만 어차피 데모 프로젝트를 저장소에 등록해 두어서 git으로 진행했다. Vault 까지는 필요가 없을수도 있을 것 같은데 DB로 관리하는 건 시도해볼 생각. 저장소나 파일로 설정파일을 관리할 경우 config client가 server에 연결해서 가져가게 될 설정파일은 보통 이렇다.

application.properties (yml 가능)

${spring.application.name}.properties (역시 yml 가능하고 client의 name에 따라 파일명이 달라짐)

여기서 application.properties는 모든 client가 참조하게 되므로 공통 속성들을 넣어두면 좋다. 위 두 가지 이외에도 active profile 이름에 따라서도 달라질 수 있으니 상세 내용은 문서를 참고하자. 여기서는 Zuul proxy로 사용할 application만 config client로 동작시킬거라 저장소에는 간단하게 gateway.properties 파일만 추가해두었다.

2. Gateway

Zuul를 사용한 간단한 API gateway도 config server처럼 간단하게 시작해볼 수 있다. 우선 dependency에 아래 두가지를 추가해주고, (위에 기술했듯이 설정을 config server에서 받아올 것이기 때문에 config server 입장에서는 client가 되어야 한다)

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>

Zuul 사용을 위해 @EnableZuulProxy annotation을 추가한다.

@EnableZuulProxy
@SpringBootApplication
public class GatewayApplication {

public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

다음으로는 역시 properties만 추가하면 되는데 보통 기본으로 존재하는 application.properties 대신 bootstrap.properties를 추가해서 사용해야 한다. application.properties는 spring boot application이 로드된 이후에 설정을 읽어오게 되고 application의 bootstrap 과정에서는 bootstrap.properties를 읽어들이게 되기 때문. bootstrap.properties 파일에서 application name을 지정(config server에서 읽어들일 properties 파일을 특정하기 위해 필요)하고 config server의 uri를 추가한다.

spring.application.name=gateway
spring.cloud.config.uri=http://localhost:8888

management.endpoints.web.exposure.include=*

Config server 역시 같은 host에서 돌릴 것이고 port는 8888로 띄우게 해두었기 때문에 localhost:8888로 config server 위치를 잡아주었다. 마지막에 있는 endpoint exposure 설정은 일반적으로 필요한 것들만 열어두는게 맞지만 테스트 용도이니 모두 열어둔다. Zuul proxy 혹은 spring-cloud-starter-config에 dependency가 걸려있는 것인지 Actuator가 기본으로 포함되어 있는 듯 한데 (없으면 추가하려고 했었음. 나중에 Spring cloud server 저장소를 찾아보니 dependency로 Actuator가 잡혀있다), 나중에 /actuator/refresh 라는 endpoint를 사용할거라 모두 열어두었다. (기본으로는 /actuator/refresh가 매핑되지 않음)

3. 기타

Zuul의 routing 설정이 dynamic 하게 변경되는 걸 실험할거라 routing 할 backend application을 만들어준다. 나의 경우엔 단순하게 Service A라는 문자열을 리턴하는 REST API 하나와 Service B를 리턴하는 API를 제공하는 서비스까지 두 개의 spring boot application을 만들어두었다. 저장소의 a-service 모듈은 8081 port를, b-service 모듈은 8082 port를 listen 하도록 되어있다. 그리고 config server가 관리하는 설정파일 (저장소의 configurations/gateway.properties)에는 우선 a-service만 라우팅하도록 설정.

## gateway.properties ##
zuul.routes.aservice.url=http://localhost:8081
#zuul.routes.bservice.url=http://localhost:8082

Run!

Config server를 먼저 실행시키고, gateway와 다른 backend application 2개를 모두 동작시켜본다. 시험삼아 설정파일이 제대로 로드되었는지 확인해보자. 아래 그림처럼 config server에 요청을 보내면 설정에 대한 응답이 날라오게 된다.

Request url은 여러가지 format이 있지만 /${spring.application.name}/${spring.profiles.active} 이런 형태로만 간단히 써도 된다. Application name이 gateway이고 active profile은 따로 지정한게 없으니 default로 넣으면 config server가 관리하는 설정파일 중 gateway.properties의 내용을 보여준다. Properties 파일에는 현재 a-service에 대한 routing 설정만 되어있으므로 Zuul이 제대로 동작하는지 확인하기 위해 zuul에 요청을 보낸다. 현재 routing의 이름이 aservice라고 되어있으니 localhost:8080/aservice를 요청하면 a-service에 정의된 API가 호출되어야 하는게 정상. 아래 그림처럼 Zuul을 통해 a-service routing은 정의된대로 동작하지만 b-service에 대한 요청은 routing 설정이 되어있지 않기 때문에 404 응답이 날라온다.

위에서 기술한대로 Zuul에 route filter를 추가해서 동적으로 로드되게 하거나, route 설정을 추가해서 처리한다면 gateway를 다시 packaging 해서 배포하는 방법을 써야 한다. (또는 새로운 API를 정의해 사용하는 등의 다른 방법도 있을 수 있다) 하지만 config server를 사용하고 있으므로 간단하게 처리할 수 있다. 우선 config server에서 관리하는 gateway.properties (저장소에 있는) 파일에 b-service에 대한 routing url 정보를 추가해서 commit / push 한다. (Github에 있으므로 browser에서 직접 수정해서 처리해도 된다.) 이후에 config server가 변경된 gateway.properties 파일을 제대로 바라보고 있는지 확인해보자. 아까처럼 config server에 요청을 보내보면 아래 그림처럼 추가된 b-service에 대한 routing url 정보까지 볼 수 있다.

Config server에서는 변경된 설정을 갖고 있기 때문에 Zuul 쪽도 바뀐 설정대로 바로 동작하기를 기대할 수도 있지만 실제 동작은 그렇지 않다. 하나의 안전장치로 생각할수도 있는데 actuator를 통한 refresh 요청을 Zuul에 날려줘야 변경된 설정값을 반영하게 된다.

위 그림처럼 refresh 명령을 POST로 날려주면 변경된 설정에 대한 간략한 정보가 표시되고 그 이후에 Zuul에 b-service에 대한 요청을 보내보면 결과를 확인할 수 있다. Refresh는 Zuul에 따로 정의한 적이 없지만 actuator를 통해 동작이 된다. 일반적으로는 bean이나 원하는 component에 @RefreshScope을 지정해서 원하는 bean만 원하는 시점에 reload 해서 사용한다.

정리하면...

Config server를 통해 Zuul의 routing 설정 변경을 package 내의 코드 한 줄 바꾸지 않고 적용할 수 있다. 코드가 바뀌지 않기 때문에 다시 build 할 필요도, 배포할 필요도 없어서 간편하게 사용하기 좋다고 생각한다. 물론 /actuator/refresh를 호출하기 위해 endpoint를 다 열어둔 것도 문제이고 properties 내의 설정에 민감한 내용이 포함될 가능성이 많기 때문에 공개 저장소를 이용하는 방식에도 문제가 있다. Endpoint 노출과 관련해서는 간단한 형태부터 조금 고급진 형태의 인증까지 보완책들이 spring 문서에 기술되어 있고, properties 파일에 대한 관리는 저장소 형태 보다는 다른 방법을 사용하는게 낫겠다는 생각을 하고 있다.

Discovery 서비스에 사용할 수 있는 Eureka (역시 Netflix OSS)를 함께 사용하면 좀 더 재미있고 유용한 기능들을 확인할 수 있는데, config server를 Eureka에 등록해서 사용하는 방법도 있다. Eureka에 config server를 등록하게 되면 config client인 application들이 config server의 uri를 고정할 필요가 사라지는데 중간에 Eureka를 한 번 더 거치게 되는 것에 대한 손해는 있지만 config server의 재배치나 추가, 변경에 따른 client 쪽 추가 작업이 사라지게 되므로 더 유연한 구성이 가능할 수 있다고 생각한다.

Eureka를 Zuul과 함께 사용하는 것에 대해서도 추가로 정리해볼 생각이다.

 

참고 #1 : https://cloud.spring.io/spring-cloud-static/spring-cloud-config/2.0.0.M9/single/spring-cloud-config.html

참고 #2 : https://medium.com/netflix-techblog/zuul-2-the-netflix-journey-to-asynchronous-non-blocking-systems-45947377fb5c

참고 #3 : http://kerberosj.tistory.com/228

· 10 min read

예전에 개발된 모듈을 개선하는 작업을 진행하고 있는데 확장성 및 여러가지 측면에서 고민을 하다가 queue-worker (work queue) 구조를 생각하게 되었다. 코드만으로 어떻게 해볼수도 있겠지만 아래와 같은 이유이었던 것 같다.

1. 단순 반복작업을 수행하기만 하면 된다.

2. 빨리 처리될수록 좋지만 그렇다고 실시간 처리가 필요한 것은 아니다.

3. 특정 시점에 처리할 작업이 늘어날 수 있지만 일반적으로는 적은 수의 service instance만 필요하다.

4. 서비스할 곳이 많아지면 처리할 작업의 수도 늘어난다.

일단은 요청에 대한 처리를 동기로 맞출 필요는 없기 때문에 작업을 queue에 쌓아두고 여건이 되는대로 worker가 처리하면 될 듯 싶다. 상황에 따라 동일한 로직을 갖는 worker를 늘리거나 줄이면 확장도 편리해질 것 같고.

AWS SQS vs RabbitMQ

MQ나 message broker로 많이 언급되는건 RabbitMQ와 Kafka인데, 매우 빠른 처리속도가 필요한 것은 아니고 이미 어느정도 알고 있는 RabbitMQ를 써볼까 싶었다. 다시 구축하는게 귀찮아서 찾아보니 SQS가 눈에 들어왔는데 가격도 저렴하고 구축이나 관리 측면에서 편의성은 좋다는 생각이 들었지만 말그대로 너무 심플해서 polling 방식으로만 가능한데다가, worker가 여러개인 경우 message 분배의 문제, message를 가져가서 작업하다 잘못된 경우 message 처리 등을 직접 해야할 것 같아서 고려하지 않기로 했다.

보내고 받는다

Queue의 개념이 그러하듯이 RabbitMQ도 단순하게 보면 비슷한 기능을 수행한다.

'Queue로 message를 보내고 (쌓고) queue에서 message 받기 (꺼내기)'

Queue는 하나가 아닐 수 있으므로 '어디로' 보내고 '어디서' 받아오느냐를 지정해야 하는데 RabbitMQ에서는 routing key로 구분하게 된다.

Acknowledgement

Consumer가 message를 받아가면 잘 받았다고 RabbitMQ에게 알려주는게 ack인데 automatic과 manual 방식을 제공한다. Automatic인 경우 단어의 의미처럼 queue에서 message를 꺼내 consumer에게 전달하면 consumer 쪽에서의 처리와 관계없이 ack를 받은 것처럼 동작하고 message는 삭제할 것으로 분류된다. Manual 방식은 message를 제대로 받았으니 삭제해도 된다고 consumer가 RabbitMQ에게직접 ack를 전달하는 방법이다.

Worker가 죽는다면?

Worker (consumer)가 message를 받아서 정해진 작업을 수행하는 중에 무슨 문제가 생겨서 죽거나 연결이 끊어지면 어떻게 될까? Automatic ack를 사용하도록 되어있다면 message가 이미 전달된 상태이므로 queue에는 없고, worker에 다른 장치가 되어있지 않다면 message는 유실될 것이다. 이런 경우 worker가 전달받은 message에 대한 작업을 완료하고 ack를 전달하는 방식으로 worker를 구현하면 message 유실을 막을 수 있다. RabbitMQ는 ack를 받기 이전에 message를 전달했던 consumer와의 연결이 끊어지면 re-queue 하도록 되어있기 때문이다. 매우 좋은 기능이라고 생각한다.

<그림 : message가 전달되고 나서 ack 이전에 consumer가 끊어졌다가 다시 연결된 경우>

Message dispatch

이제 확장에 대해서 생각을 해봐야 한다. 처음에 기술했듯이 특정 시점에 동일한 형상의 worker instance를 추가할 필요가 있는데 이 경우 message는 여러개의 worker에 어떤 방식으로 전달되는 걸까? RabbitMQ는 기본적으로 round robin 방식으로 message를 worker에 순차적으로 돌리도록 되어있다. 일반적으로는 round robin 방식으로도 충분할 수 있는데 worker 형태이기 때문에 특정 작업은 시간이 좀 더 걸릴수도 있고, 다른 worker는 놀고 있는데도 불구하고 작업중인 worker에 message가 전달되는 불상사가 생길수도 있다. 이래서 줄을 잘 서야 하는것인가... 이런 비효율을 막기 위해 RabbitMQ에는 QOS 설정이 가능하도록 되어있다. Message가 특정 consumer에만 몰리지 않게 잘 분배해서 전체적으로는 성능을 올리겠다는 의미로 이해하면 되겠다. QOS 설정시 prefetch count를 알려줘야 하는데 (여기서 prefetch count란 ack가 RabbitMQ에 전달되기 전에 - worker 입장에서 보면 message가 다 처리되기 전 - consumer가 받을 수 있는 message의 갯수를 의미), prefetch count를 1로 설정하게 되면 ack 이전에 받을 수 있는 message는 1이므로 현재 작업중인 worker는 건너뛰고 다른 worker에 message 전달을 시도하게 될 것이다. 합리적이다.

조건에 따라 여러 개의 queue에 message를 보내고 싶다면?

나의 경우에는 한 번에 queue를 여러개 사용하지 않아도 될 것 같지만 혹시 그럴 필요가 생길지도 모른다. (여러 개의 queue를 사용한다는 것은 consumer를 여러 종류로 구분해서 다른 처리를 하겠다라는 의미로 생각하고 있다. 물론 동일한 consumer 여러개라도 queue를 여럿으로 분리해서 쓰지 못하는 것은 아닐뿐더러 성능 측면에서는 혹시 어떤 이점이 있을지도 모르겠다.) Routing key로 queue를 구분해서 message를 전달하는 방식을 사용하게 되면, 조건에 따라 queue 여기 저기로 보낼 필요가 있을 때 producer의 구현 코드가 좀 지저분해질 수 있을 것 같다. (물론 구조적으로 좀 유연하게 만들수는 있겠지만) 이런 경우 고민없이 exchange를 활용하면 될 것 같다.

Exchange

RabbitMQ는 기본적으로 no named exchange를 사용하도록 되어있다. 사실 routing key로 queue를 직접 지정해서 message를 전달하고 받는 방식도 그렇다. (direct exchange) RabbitMQ에서 제공하는 exchange는 종류가 direct, topic, headers, fanout 네가지.

Direct exchange는 그림처럼 routing key (명칭은 동일하지만 위에서처럼 queue 이름이 아니라 binding key)에 따라 바인딩 되어있는 queue에 message가 전달된다.

<출처 : https://www.rabbitmq.com/tutorials/tutorial-four-python.html\>

Topic은 direct 보다는 더 다양한 조건(패턴)으로 queue를 바인딩해서 message를 전달하는 방식 (아래 그림의 패턴에서 *는 하나의 단어, #은 없거나 하나 이상의 단어와 매핑)

<출처 : https://www.rabbitmq.com/tutorials/tutorial-five-python.html\>

Headers는 message에 포함될 headers 속성값에 따라 message를 어디로 보낼지 결정하는 방식이다. 마지막으로 fanout exchange를 사용하게 되면, exchange에 binding 되어있는 모든 queue로 message가 전달된다.

RabbitMQ를 열면 마주치게 되는 부분들이기도 해서 몇가지 기본적인 내용에 대해서 살펴보고 그 의미들을 생각해봤다. 사용하면서 기술적인 부분에서 문제가 생기거나 운영상의 이슈가 생긴다면 (아마도 그렇겠지만) 또 정리할 예정.

· 2 min read

Nodebb 기본 구성 후 상위에 reverse proxy를 두려고 할 때 설정하는 방법
기본적인 nginx 설정 방법은 공식 문서를 참고하면 됨
Root context 이외의 경로를 root로 잡고자 할 때에는 몇가지 추가 과정이 필요함
NginX는 기본 설정에서 context path 추가하는 형태로 하면 되고 (아래 참조)

location /community {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;

proxy_pass http://127.0.0.1:4567/community;
proxy_redirect off;

# Socket.IO Support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}

proxy_pass 되는 경로를 root가 아니라 다른 경로로 잡아주는게 편한데 nodebb의 config.json 변경 필요
Nodebb의 설정 메뉴에서 변경 안되고 nodebb 설치 경로의 config.json 파일을 수정하고 restart 해야함
config.json 파일에 url 부분을 nginx에서 보내는 경로로 맞춰주면 완료
이 상태에서 nodebb에 접속해보면 socket.io 문제가 발생했음
이유는 외부에서 직접 nodebb url로 접속하지 않고 nginx를 통해서만 접속 가능하도록 하기 위해 url에 host ip 대신 127.0.0.1을 지정했는데 접속은 nginx의 ip나 nginx에 설정된 도메인으로 진행했기 때문
이와 같은 경우 nodebb의 config.json에 아래와 유사하게 socket.io에 대한 설정 추가하면 됨

"socket.io": {
"origins": "*:*"
}