Kubernetes 운영 17개월의 성과와 교훈

데일리호텔은 아마도 국내에서 Kubernetes를 가장 잘 활용하는 서비스 중 하나일 것이다. 2016년 8월 11일에 kube-aws v0.8.1 / kubernetes v1.3.4을 도입하여 현 시점에 kube-aws v0.9.9-rc.4 / kubernetes v1.8.4까지 운영 중이다. 비교적 이른 시기부터 도입하여 Kubernetes와 함께 우리의 시스템도 발전해왔다. 개인적으로는 3월에 입사하여 4월에 Docker 기반의 빌드 환경을 도입하고 이어 성수기를 무사히 마친 후 바로 Container orchestration을 적용했으니 정신 없기도 하고 즐겁기도 한 시기였다. 오늘은 연말 특집으로 그 간의 운영 경험을 간단하게 정리하고 공유하려고 한다. 아마도 한국에서는 이러한 규모로 Kubneretes를 운영하는 회사가 많지 않고 공유된 자료는 더더욱 없으므로 Docker orchestration 도입을 준비하는 이들에게는 도움이 되리라 기대한다.

성과

양적으로 성과를 간단히 평가하자면 최초에는 EC2 인스턴스 m3.medium 서너 대로 시작하여 지금은 t2.xlarge급을 중심으로 백여 대를 운영 중이다. Kubernetes 안에서 작동하는 Pod는 십여 개에서 550여 개로 늘었고 Service는 2개에서 200여 개로 증가했다. 이러한 양적 증가는

  • 리눅스에 수동 배포하던 레가시 환경을 모두 자동화하고 컨테이너에 적재하는 마이그레이션을 꾸준히 진행하고
  • 배포환경을 단계별로 나누고
  • 서비스를 고도화하고 안정화하는

일을 꾸준히 진행했던 덕분이다. 서비스를 개선하고자 하는 사람들의 의지와 이를 뒷받침하는 Kubernetes 커뮤니티의 힘이 함께 시너지를 냈다.

질적으로 보자면 많은 면이 달라졌다.

High Availability를 제공하는 서비스가 ECS로 띄운 클러스터 하나일 뿐이던 시절이 있었으나 지금은 사실상 모든 서비스가 클러스터로 구성된다. Kubernetes는 Deployment, Statefulset 등 다양한 배포 패턴을 제공하고 이를 다루기가 매우 쉽다. 조직에 노하우가 어느 정도 쌓이면 누구나 쉽게 서비스를 띄울 수 있다. 덕분에 지금은 API 서비스군, 백오피스 서비스군, 배치 및 스트림 서비스군, 데이터베이스 프록시, Zookeeper, Kafka, Hadoop, Solr, Elasticsearch, 데이터 시각화 및 Business Intelligence 서비스군, 각종 테스팅 및 모니터링 서비스 등을 매우 안정적으로, 그리고 쉽게 운영한다.

무엇이든 쉽게 접근한다. 예를 들어 Kafka를 생전 처음 도입해볼 계획이 있다고 하자. 제일 먼저 할 일은 잘 만든 레시피를 구글링해서 찾는 것이다. 쓸만해 보이는 예제를 찾았으면 일단 돌려본다. 실패하면 로그를 확인하고 다시 띄운다. 다 합해봐야 30분에서 1시간이면 그럭저럭 돌아가는 Kafka 클러스터가 손에 들어온다. 빠르게 실험하고 빨리 피드백 받고 개선한다.

운영 노하우를 자연스럽게 흡수한다. Rolling Update 등을 직접 구현하면 정교하게 만들기 쉽지 않다. Kafka에 맞는 배포 정책이 Maxscale에는 적합하지 않을 수 있다. Kubernetes가 제공하는 배포 정책은 추상화와 일반화가 잘 되어 있고 커스터마이징도 어렵지 않다. 개발자가 인지하든 못하든 Kubernetes와 함께 잘 정리된 배포 정책이 함께 도입된다. 운영 중에 더 개선할 점을 발견하면 몇 가지 설정을 살짝 바꾸는 것으로 대부분 충분하다.

교훈

네트워크, 네트워크, 그리고 네트워크

Kubernetes 자체도 분산 시스템이고 그 안에서 작동하는 우리 서비스도 거대한 분산 시스템이다. 분산 시스템은 기본적으로 네트워크가 언제든 단절될 수 있다는 전제로 설계해야 한다. 예를 들어 컨트롤러와 노드(EC2 instance) 간의 통신이 단절되고 일정 시간이 지나면 컨트롤러는 노드가 Not Ready 상태에 들어갔다고 취급한다. 이로써 해당 노드와 그 노드에서 작동 중인 서비스는 나머지 시스템과 단절되고 경우에 따라선 일부 서비스에 장애가 발생할 수 있다. 통신이 단절된 노드를 격리하는 것 자체가 잘못된 행동은 아니지만 단절의 기준이 매우 엄격할 경우, 이를테면 15초 이상 시간이 지나면 단절되었다고 분류할 경우에는 장애가 자주 발생할 수 있다. 그러므로 이와 관련한 설정값 nodeMonitorGracePeriod이 현실적일 필요가 있다.

추상화는 적당히

네트워크가 언제든 단절될 수 있다는 전제와 일맥상통하는 부분일 수 있다. 예를 들어 레거시 시스템에서는 운영상의 여러 장점이 있어 AWS 서비스 주소 일부를 ALIAS 또는 CNAME 처리해서 사용하였다. mydb.abcdefg.ap-northeast-1.rds.amazonaws.com 대신 mydb.dailyhotel.io를 사용하면 도메인 주소만 변경함으로써 백엔드 데이터베이스를 교체하기 쉽다. 이제 mydb.dailyhotel.io를 Kubernetes에서 사용하기 쉽게 External Name Service(일종의 CNAME)로 다시 묶는다.

apiVersion: v1
kind: Service
metadata:
  name: mydb
spec:
  ports:
  - port: 3306
  type: ExternalName
  externalName: mydb.dailyhotel.io

이러한 설정은 여러 이점에도 불구하고 장애로 이어지기 쉽다. RDS의 실제 IP 주소가 바뀌면 TTL 설정 때문에 실제 엔드포인트가 그 사실을 알게 될 때까지 상당한 시간이 걸릴 수도 있다. 조금은 성가시나 애플리케이션에서 실제 도메인 주소를 쓰거나 딱 한번 정도만 Alias처리해서 쓰는 편이 안전하다.

apiVersion: v1
kind: Service
metadata:
  name: mydb
spec:
  ports:
  - port: 3306
  type: ExternalName
  externalName: mydb.abcdefg.ap-northeast-1.rds.amazonaws.com

작은 운영체제

Docker orchestration을 시스템의 기반으로 쓰기로 했으면 CoreOS 같은 컨테이너 전용 운영체제를 고려하면 좋다. 운영체제에 도커가 아닌 패키지 관리자 등으로 뭔가 설치해서 운영할 생각이라면 몰라도 Kubernetes만 운영할 생각이면 Memory footage가 작은 운영체제를 쓰면 관리가 쉽고 비용이 적게 든다.

Container OS comparison – via @codeship

For anyone who’s been following the container community’s rise the past two years (after Solomon Hykes’s famous five-minute presentation at PyCon), you have surely seen many companies and projects spring up, offering really innovative ways of managing your applications. There are several projects around management, network, storage, logging, monitoring, and more (check out this awesome mind map of the ecosystem).

OS 버전마다 다르므로 일괄적으로 비교하기 힘들지만 예를 들어 CoreOS가 Ubuntu보다 기본설치시 메모리를 200메가 적게 쓴다고 가정하고 전체 노드가 20개인 Kubernetes 환경이라면 이론적으로 메모리를 200메가 사용하는 서비스를 20개 더 운용할 수 있다는 게 가장 큰 장점이다. 이와 더불어 컨테이너 운용에 필요한 서비스만 있는 편이 추가적인 보안 취약점에서 쉽게 벗어나는 가장 손쉬운 길이기도 하다.

적당히 사양 좋은 머신

로그 수집, 모니터링 등의 서비스는 보통 DaemonSet으로 설정한다. 말인즉, 전체 노드에 똑같은 Pod(보통은 컨테이너 하나를 의미하지만 실제로는 조금 더 확장된 개념)를 하나씩 띄운다. 예를 들어 DataDog Agent는 EC2 인스턴스마다 설치해야 각 인스턴스의 CPU/메모리 현황, 컨테이너 상태 등을 모니터링할 수 있을 것이다.

이렇게 DaemonSet을 하나 둘 늘려가다 보면 각 노드에 띄울 수 있는 컨테이너 숫자가 급격히 줄어든다. 이론적으로는 vCPU가 2개이고 메모리가 8기가인 t2.large 두 대보다 vCPU가 4개이고 메모리가 16기가인 t2.xlarge 한 대를 운영하는 편이 효율적이다.

다만 그렇다고 해서 무조건 사양 좋은 머신을 쓰면 그 나름의 문제가 있다. 한 노드에 여러 컨테이너가 들어가고 전체 노드 수가 줄면 그 노드에 문제가 생겼을 때 총체적인 서비스 장애로 이어질 수 있다. 그러므로 균형점을 찾아야 한다.

지속적인 개선

처음에는 단순히 서비스를 띄우는 것으로 시작한다.

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: myapi
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: myapi
    spec:
      containers:
      - name: myapi
        image: dailyhotel/myapi:latest
        imagePullPolicy: Always
       ports:
        - name: myapi-port
          containerPort: 8080

운영하다 보니 롤링 업데이트를 적용했음에도 배포시 일부 결제건이 제대로 처리되지 않았다는 것을 알게 된다. 원인을 분석하니 새 버전의 애플리케이션이 배포되긴 했지만 아직 초기화가 덜 끝나서 트래픽을 받으면 안 되는 시점에 롤링 업데이트가 진행되었던 게 문제이다. 이는 Kubernetes의 readiness 설정을 이용해 애플리케이션에 트래픽을 넘겨도 되는 시점을 지정해주면 간단히 해결되는 문제이다.

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: myapi
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: myapi
    spec:
      containers:
      - name: myapi
        image: dailyhotel/myapi:latest
        imagePullPolicy: Always
       ports:
        - name: myapi-port
          containerPort: 8080
       readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 3

그러고 나서 일주일간 조용했으나 어느 날 알람이 온다. 특정 노드의 CPU 사용률이 장시간 100%에 달했다. 무슨 문제인가 싶어 해당 노드에 배포된 컨테이너 현황을 살펴본다.

Namespace  Name         CPU Requests  CPU Limits  Memory Requests  Memory Limits
---------  ----         ------------  ----------  ---------------  -------------
default    zk-2         0    (0%)        0  (0%)  0   (0%)         0 (0%)
default    myapi        0    (0%)        0  (0%)  0   (0%)         0 (0%)
default    another-app  0    (0%)        0  (0%)  0   (0%)         0 (0%)

세 개의 Pod가 실행 중인데 특별히 리소스를 제한하지 않고 그때그때 필요한만큼 리소스를 가져다 쓴다. 추가로 분석한 결과 문제가 발생한 시점에 myapi가 CPU를 거의 독점하였고 그 때문에 알람이 발생하고 더불어 another-app의 응답속도가 저하된 흔적을 찾았다. 이와 같은 문제는 limits를 설정하여 해소할 수 있다.

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: myapi
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: myapi
    spec:
      containers:
      - name: myapi
        image: dailyhotel/myapi:latest
        imagePullPolicy: Always
       ports:
        - name: myapi-port
          containerPort: 8080
       readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 3
       resources:
          requests:
            cpu: 600m
            memory: 1024Mi
          limits:
            cpu: 1500m
            memory: 2048Mi
Namespace  Name         CPU Requests  CPU Limits  Memory Requests  Memory Limits
---------  ----         ------------  ----------  ---------------  -------------
default    zk-2         300m (7%)     1500m (0%)  1Gi (6%)         0 (0%)
default    myapi        600m (14%)    1500m (0%)  1Gi (6%)         2Gi  (12%)
default    another-app  300m (7%)     300m  (0%)  1Gi (6%)         0 (0%)

이와 같은 조치 후 특정 애플리케이션 때문에 나머지 애플리케이션까지 문제가 발생하는 일은 없어졌다. 그러고 나서 몇 달 뒤 사건이 벌어지는데…

myapi 세 대가 동시에 내려가면서 일시적인 장애가 발생했다. 원인인 즉 myapi 세 대가 한 노드에 몰려서 배포됐고 그 노드가 커널 패닉에 빠지고 Kubernetes 클러스터에서 빠지면서 myapi 서비스가 일시 중지되었다. Pod가 여러 노드에 골고루 배포되어야 제대로 된 가용성을 확보할 수 있다. 그래서 podAntiAffinity를 이용해 서로 다른 노드에 배포되게 유도하기로 한다.

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: myapi
spec:
  affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - myapi 
            topologyKey: kubernetes.io/hostname
  replicas: 2
  template:
    metadata:
      labels:
        app: myapi
    spec:
      containers:
      - name: myapi
        image: dailyhotel/myapi:latest
        imagePullPolicy: Always
       ports:
        - name: myapi-port
          containerPort: 8080
       resources:
          requests:
            cpu: 600m
            memory: 1024Mi
          limits:
            cpu: 1500m
            memory: 2048Mi

이런 식으로 꾸준히 모니터링하고 차분하게 문제를 해결해나가면 어느새 서비스는 안정화되고 사람이 개입하지 않아도 시스템이 알아서 운영해나가는 부분이 많아진다. 그러면 우리는 더 가치있는 일에 우리의 시간을 쏟을 여력이 생긴다.

사건 일지


2016-04-07 INFRA-7, CodeShip의 Docker 인프라스트럭처를 활용해서 빌드와 배포용 Docker 빌드까지 2016-08-11 [kube-aws v0.8.1 / kubernetes v1.3.4](https://github.com/coreos/coreos-kubernetes/releases/tag/v0.8.1)https://github.com/coreos/coreos-kubernetes/releases/tag/v0.8.1) 도입 시작. KommandoCenter 2016-08-25 route53-kubernetes 2016-08-25 DataDog 2016-08-27 Fluentd 2016-09-01 MobileAPI, MaxScale 2016-09-06 Kafka, Intranet, Elasticsearch. 초기 Kafka는 Deployment로 구현 2016-09-07 Extranet 2016-09-10 kube-aws v0.8.1 / kubernetes v1.3.6 2016-09-11 Private subnets, Labelgun 2016-09-20 최초의 Kafka 토픽 생성 2016-09-27 CoreOS 자동 업데이트 2016-09-27 kube-aws v0.8.2 / kubernetes v1.3.6 2016-09-27 운영환경에 도입 2016-10-06 kube-aws v0.8.2 / kubernetes v1.4.0 2016-10-12 grinder 2016-10-13 Java 측 서버는 모두 Prod 환경에 올린다. Worker 6대 2016-10-25 kube-aws v0.8.3 / kubernetes v1.4.3, MaxScale 운영 난항 2016-10-27 locust 2016-11-03 Slack Relay 2016-11-09 log rotate 2016-11-24 MaxScale 운영 또다시 난항. AWS RDS에 걸어놓은 CNAME이 DNS resolution 실패가 나는 오류를 피하기 위해 CNAME을 쓰지 않는다 2016-12-01 kube-aws v0.8.3 / kubernetes v1.4.6 2016-12-22 kube-aws v0.9.3-rc.2 / kubernetes v1.5.1, Statefulset 2017-01-16 kube-aws v0.9.3-rc.5 / kubernetes v1.5.1, Solr 인덱싱을 위한 크론 추가, 2017-02-15 Slow query logs 통계 수집 2017-03-03 Elastalert, MaxScale 재도입 2017-03-06 kube-aws v0.9.4 / kubernetes v1.5.3, Labelgun 은퇴 2017-03-20 kube-aws v0.9.5-rc.3 / kubernetes v1.5.3 2017-03-22 Prod 환경 통합 2017-03-23 Superset 도입 2017-03-24 Spotfleet 적용 2017-04-01 kube-aws v0.9.5 / kubernetes v1.5.5 2017-04-07 kube-aws v0.9.6 / kubernetes v1.6.1 2017-04-12 Kafka manager 2017-04-14 kube-aws v0.9.6-rc.2 / kubernetes v1.6.1, Debezium 2017-04-18 Druid 2017-04-20 Hadoop 2017-04-25 Maxwell 2017-05-10 kube-aws v0.9.6 / kubernetes v1.6.2 2017-05-31 Dex 2017-06-05 kube-aws v0.9.7 / kubernetes v1.6.3, Kubernetes 백업 기능 추가 2017-06-07 인생 꼬임 방지 rename 2017-06-13 Cafe24 이전 2017-06-20 [kube-dns가 종종 죽는 문제를 패치한다](https://github.com/kubernetes/dns/issues/93) 2017-06-22 [INFRA-671 ELB의 아이피가 바뀌었을 때 클러스터가 내려가지 않게 한다](https://github.com/kubernetes-incubator/kube-aws/issues/598), `nodeMonitorGracePeriod: "90s”` 2017-06-22 ism-agent 2017-06-26 ALB 2017-06-27 external-dns 2017-06-28 RBAC 2017-07-03 `nodeMonitorGracePeriod: “240s”` 2017-07-12 `nodeMonitorGracePeriod: “600s”` 2017-09-25 kube-aws v0.9.8 / kubernetes v1.7.4

Also published on Medium.

최 재훈

블로그, 페이스북, 트위터 고성능 서버 엔진, 데이터베이스, 지속적인 통합 등 다양한 주제에 관심이 많다.
Close Menu