상세 컨텐츠

본문 제목

로컬 랩탑에서 k8s로 ELK 구성

Dev Type

by ai developer 2024. 10. 14. 16:39

본문

서비스를 운영하다 보면 서비스에 꼭 필요한 DB와 로그들을 분리해서 저장하고 관리하게 되는데, 로그가 방대해 질수록 더 효율적으로 로그를 수집, 접근, 시각화할 수 있는 툴들이 필요합니다.

이런 시스템을 구성하기 위해서는 여러 기술 스택들의 조합이 필요한데, ELK 스택도 로그 시스템을 구성하기 위한 기술 스택들의 조합 중 하나입니다.

ELK 스택은 이미 많은 백엔드 개발자들이 구현하고 실제 서비스에 적용한 안정적인 시스템입니다.

요새 k8s(kubernetes)를 공부하고 있고 filebeats를 fluentbit로 변경해서 사용하고자 간단히 연습을 진행해 봤습니다!

filebeats와 fluentbit는 둘 다 로그 파일을 logstash로 옮기는 효율적인 작업을 진행하지만, filebeats는 elasticsearch를 위해 구현됐고, fluentbit는 보다 가볍고 빠르게 범용적으로 사용할 수 있게 구현됐기 때문에 추후 influxdb와 grafana 연결 구성도 생각하면 fluentbit가 낫다고 생각했습니다.

 

우선 ELK는 무엇이냐?

ELK의 E는 Elasticsearch 로그를 저장하고 검색할 수 있는 DB라고 보면 됩니다. L은 Logstash로서 로그를 수집하는 엔진으로 보면 되고 K는 Kibana로 Elasticsearch에 저장된 로그를 가져와 시각화할 때 사용하는 기술이라고 보면 됩니다.

순서는 아래와 같이 로그 수집 -> DB -> 시각화 순입니다.

 

여기서 fluentbit는 가장 왼쪽의 log 그림과 logstash의 중간에 위치한다고 보면 됩니다.

 

본격적으로 구성을 시작해 보자!

우선 간단히 mongodb에 연결하는 간단한 nestjs 앱을 만들어 보기 위해 아래 명령을 실행합니다.

npx @nestjs/cli new nestjs-app

 

nestjs-app 폴더로 들어가고 infra 폴더를 하나 만듭니다.(infra 폴더에 k8s 구성을 저장할 예정)

우선 k8s로 mongodb 환경을 구성해야 하니 cluster를 만들어야 합니다.(minikube, kind, k3d 등 여러가지가 있지만 저는 kind를 사용합니다) 

cluster 구성을 위해 간단히 config 파일을 생성합니다.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: elk-nestjs

nodes:
  - role: control-plane
  - role: worker
  - role: worker

kind.config.yaml

 

그런 뒤 아래 명령을 실행합니다.

kind create cluster --config=kind.config.yaml

 

이렇게 되면 kubectx를 실행하면 자동으로 새로 생성한 kind-elk-nestjs cluster로 접속되어 있는 것을 확인할 수 있습니다.

실수로 다른 context에 구성하게 되면 귀찮아지니 꼭 확인하고 시작하는게 좋습니다!

 

확인이 됐다면 이제 새로 만든 nestjs-app이 mongodb에 접속할 수 있도록 infra 폴더에 구성 파일을 만듭니다. (테스트기 때문에 auth 없이 접속할 수 있도록 만듭니다.)

# MongoDB Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mongodb
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mongodb
  template:
    metadata:
      labels:
        app: mongodb
    spec:
      containers:
        - name: mongodb
          image: mongo:4.4
          ports:
            - containerPort: 27017
          volumeMounts:
            - name: mongodb-data
              mountPath: /data/db
      volumes:
        - name: mongodb-data
          emptyDir: {}
---
# MongoDB Service
apiVersion: v1
kind: Service
metadata:
  name: mongodb-service
spec:
  selector:
    app: mongodb
  ports:
    - protocol: TCP
      port: 27017
      targetPort: 27017
---

mongodb.yaml

 

위와 같이 구성파일을 만든 뒤 infra 폴더에서 k apply -f mongodb.yaml 을 실행합니다.(kubectl을 k로 줄여서 사용하도록 설정함)

그럼 cluster에 mongodb가 아주 빠르게 구성됩니다. 

그럼 구성한 mongodb는 27017 포트를 사용하는데 여기 접속하기 위해 port-forwarding을 진행합니다. 

k port-forward svc/mongodb-service 27017:27017 을 실행하면 k8s 환경에서 실행되는 mongodb를 localhost:27017 에서 접속할 수 있게 됩니다.

그럼 mongodb 클라이언트를 통해 접속해 simple-app db를 만듭니다. 그런 뒤 아래와 같이 nestjs-app 을 수정합니다.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MongooseModule } from '@nestjs/mongoose';
import { ProductSchema } from './product/product.schema';
import { ProductController } from './product/product.controller';
import { ProductService } from './product/product.service';
import { LoggerModule } from './logger/logger.module';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://mongodb-service:27017/simple-app'),
    MongooseModule.forFeature([{ name: 'Product', schema: ProductSchema }]),
  ],
  controllers: [AppController, ProductController],
  providers: [AppService, ProductService],
})
export class AppModule {}

app.module.ts

 

그렇게 설정한 뒤 아래와 같이 dockerfile을 만들어 줍니다. 

# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["npm", "run", "start:prod"]

Dockerfile

 

그런 뒤 docker build -t <repo name>/nestjs-app . && docker push <repo name>/nestjs-app를 실행합니다.

그럼 docker login을 해뒀다면 그 repo로 이미지가 저장됩니다.

그럼 이제 k8s 설정을 통해 nestjs-app을 구성해 k8s cluster로 nestjs-app을 넣어줄 수 있도록 구성 파일을 만듭니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nestjs-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nestjs-app
  template:
    metadata:
      labels:
        app: nestjs-app
    spec:
      containers:
        - name: nestjs-app
          image: alexhkhan/nestjs-app:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 3000
          env:
            - name: NODE_ENV
              value: production
          volumeMounts:
            - name: log
              mountPath: /var/log
      volumes:
        - name: log
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: nestjs-app
spec:
  selector:
    app: nestjs-app
  ports:
    - port: 3000
      targetPort: 3000
  type: LoadBalancer

nestjs-app.yaml

 

이렇게 되면 k apply -f nestjs-app.yaml을 통해 k8s에 nestjs 가 생성됩니다.

k get podsk get svc를 실행하면 생성된 것을 확인할 수 있습니다.

k logs nestjs-app-688dd9b494-xk4mw -f 를 통해 pod name을 활용해 로그도 확인하며 정상적으로 mongodb와 연결됐는지도 체크하고 잘 연결됐다고 생각하고 다음을 진행합니다.

 

데이터베이스와 연결된 일반적인 앱을 아주 간단히 만들었고 이제 로그 시스템을 구성해야 합니다. 

src/logger 폴더를 만들고 그 안에 logger.module.ts를 생성합니다. 

import { Module } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';

@Module({
  imports: [
    WinstonModule.forRoot({
      transports: [
        new winston.transports.Console({
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.ms(),
            winston.format.colorize(),
            winston.format.printf(
              ({ timestamp, level, message, context, ms }) =>
                `${timestamp} [${context}] ${level}: ${message} ${ms}`,
            ),
          ),
        }),
        new winston.transports.File({
          filename: '/var/log/nestjs-app.log',
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.json(),
          ),
        }),
      ],
    }),
  ],
})
export class LoggerModule {}

logger.module.ts

 

logger module에서의 설정과 같이 log를 nestjs-app.log 파일에 저장하도록 경로를 설정했습니다.

이제 logger module을 app module에 import 해줍니다.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MongooseModule } from '@nestjs/mongoose';
import { ProductSchema } from './product/product.schema';
import { ProductController } from './product/product.controller';
import { ProductService } from './product/product.service';
import { LoggerModule } from './logger/logger.module';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://mongodb-service:27017/simple-app'),
    MongooseModule.forFeature([{ name: 'Product', schema: ProductSchema }]),
    LoggerModule,
  ],
  controllers: [AppController, ProductController],
  providers: [AppService, ProductService],
})
export class AppModule {}

app.module.ts

 

그리고 controller에 api 동작 시 로그를 작성하도록 수정해 줍니다.

import { Controller, Get, Logger } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
  private readonly logger = new Logger(AppController.name);

  @Get()
  getHello(): string {
    this.logger.log('This is a log message!');
    this.logger.error('This is an error message!!');
    return this.appService.getHello();
  }

  @Get('/json')
  getLog(): string {
    this.logger.log(JSON.stringify({ test: 'super', fast: 'nova' }));
    return this.appService.getHello();
  }
}

app.controller.ts

 

이제 위에서 했던 것처럼 다시 docker build -t <repo name>/nestjs-app . && docker push<repo name>/nestjs-app를 실행합니다. 

이제 로그 파일에 접속할 fluentbit 설정을 아래와 같이 진행합니다.

우선 아래의 rbac 파일을 이용해 cluster role을 만들어 service account와 바인딩해 주는 작업을 해주어 권한을 얻게 합니다. 

apiVersion: v1
kind: ServiceAccount
metadata:
  name: fluent-bit
  namespace: default

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: fluent-bit-read
rules:
  - apiGroups: [""]
    resources:
      - namespaces
      - pods
    verbs: ["get", "list", "watch"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: fluent-bit-read
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: fluent-bit-read
subjects:
  - kind: ServiceAccount
    name: fluent-bit
    namespace: default

fluentbit-rbac.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
  namespace: default
  labels:
    k8s-app: fluent-bit
data:
  fluent-bit.conf: |
    [SERVICE]
        Flush         1
        Log_Level     info
        Daemon        off
        Parsers_File  parsers.conf

    [INPUT]
        Name              tail
        Path              /var/log/containers/nestjs-app*.log
        Parser            docker
        Tag               kube.*
        Refresh_Interval  5
        Mem_Buf_Limit     5MB
        Skip_Long_Lines   On

    [FILTER]
        Name                kubernetes
        Match               kube.*
        Kube_URL            https://kubernetes.default.svc:443
        Kube_CA_File        /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
        Kube_Token_File     /var/run/secrets/kubernetes.io/serviceaccount/token
        Kube_Tag_Prefix     kube.var.log.containers.
        Merge_Log           On
        Merge_Log_Key       log_processed
        K8S-Logging.Parser  On
        K8S-Logging.Exclude Off

    [OUTPUT]
        Name          forward
        Match         *
        Host          logstash
        Port          5000

  parsers.conf: |
    [PARSER]
        Name   docker
        Format json
        Time_Key time
        Time_Format %Y-%m-%dT%H:%M:%S.%L
        Time_Keep On

---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluent-bit
  namespace: default
  labels:
    k8s-app: fluent-bit-logging
    version: v1
    kubernetes.io/cluster-service: "true"
spec:
  selector:
    matchLabels:
      k8s-app: fluent-bit-logging
  template:
    metadata:
      labels:
        k8s-app: fluent-bit-logging
        version: v1
        kubernetes.io/cluster-service: "true"
    spec:
      containers:
        - name: fluent-bit
          image: fluent/fluent-bit:1.9
          imagePullPolicy: Always
          volumeMounts:
            - name: varlog
              mountPath: /var/log
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
            - name: fluent-bit-config
              mountPath: /fluent-bit/etc/
      terminationGracePeriodSeconds: 10
      volumes:
        - name: varlog
          hostPath:
            path: /var/log
        - name: varlibdockercontainers
          hostPath:
            path: /var/lib/docker/containers
        - name: fluent-bit-config
          configMap:
            name: fluent-bit-config
      serviceAccountName: fluent-bit
      tolerations:
        - key: node-role.kubernetes.io/master
          operator: Exists
          effect: NoSchedule
        - operator: "Exists"
          effect: "NoExecute"
        - operator: "Exists"
          effect: "NoSchedule"

fluentbit-config.yaml

 

그리고 위와 같이 어떤 레벨의 로그를 어디서 INPUT 받아 어떻게 필터링하고 logstash로 OUTPUT 할지를 configmap으로 만들어주고 fluentbit의 배포 파일을 만들어 줍니다.

 

이제 logstash를 구성합니다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: logstash-config
data:
  logstash.conf: |
    input {
      tcp {
        port => 5000
        codec => fluent
      }
    }
    filter {
      if [kubernetes][container_name] == "nestjs-app" {
        json {
          source => "log"
        }
      }
    }
    output {
      elasticsearch {
        hosts => ["elasticsearch:9200"]
        index => "nestjs-logs-%{+YYYY.MM.dd}"
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: logstash
spec:
  replicas: 1
  selector:
    matchLabels:
      app: logstash
  template:
    metadata:
      labels:
        app: logstash
    spec:
      containers:
        - name: logstash
          image: docker.elastic.co/logstash/logstash:7.15.0
          ports:
            - containerPort: 5000
          volumeMounts:
            - name: config-volume
              mountPath: /usr/share/logstash/pipeline
      volumes:
        - name: config-volume
          configMap:
            name: logstash-config
            items:
              - key: logstash.conf
                path: logstash.conf
---
apiVersion: v1
kind: Service
metadata:
  name: logstash
spec:
  selector:
    app: logstash
  ports:
    - port: 5000
      targetPort: 5000

logstash.yaml

 

logstash에서도 configmap에서 input으로 fluent로부터 받아서 nestjs-app 로그를 필터링하고 output으로 elasticsearch로 접속해 index nestjs-logs-{timstamp}로 저장하도록 설정합니다. 

 

그럼 이제 elasticsearch와 kibana를 구성합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: elasticsearch
spec:
  replicas: 1
  selector:
    matchLabels:
      app: elasticsearch
  template:
    metadata:
      labels:
        app: elasticsearch
    spec:
      containers:
        - name: elasticsearch
          image: docker.elastic.co/elasticsearch/elasticsearch:7.15.0
          ports:
            - containerPort: 9200
          env:
            - name: discovery.type
              value: single-node
            - name: ES_JAVA_OPTS
              value: "-Xms512m -Xmx512m"
---
apiVersion: v1
kind: Service
metadata:
  name: elasticsearch
spec:
  selector:
    app: elasticsearch
  ports:
    - port: 9200
      targetPort: 9200

elasticsearch.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: kibana
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kibana
  template:
    metadata:
      labels:
        app: kibana
    spec:
      containers:
        - name: kibana
          image: docker.elastic.co/kibana/kibana:7.15.0
          ports:
            - containerPort: 5601
          env:
            - name: ELASTICSEARCH_HOSTS
              value: http://elasticsearch:9200
---
apiVersion: v1
kind: Service
metadata:
  name: kibana
spec:
  selector:
    app: kibana
  ports:
    - port: 5601
      targetPort: 5601
  type: LoadBalancer

kibana.yaml

 

이제 infra 폴더로 가서 k apply -f .을 실행해 위에서 정의한 fluentbit, logstash, elasticsearch, kibana를 모두 k8s에 적용합니다.

그럼 아래와 같이 pod과 svc를 볼 수 있습니다.

-----------------------------------------------------------------------

NAME                                 READY   STATUS    RESTARTS   AGE
pod/elasticsearch-7c9f8577c8-vb6gx   1/1     Running   0          115m
pod/fluent-bit-7ckdv                 1/1     Running   0          68m
pod/fluent-bit-hp2xt                 1/1     Running   0          68m
pod/fluent-bit-z5kcg                 1/1     Running   0          68m
pod/kibana-5b4d69d65c-xhjhd          1/1     Running   0          115m
pod/logstash-5f968c75ff-vv8zs        1/1     Running   0          115m
pod/mongodb-f8b76cc9-pw9q6           1/1     Running   0          115m
pod/nestjs-app-688dd9b494-xk4mw      1/1     Running   0          66m

-----------------------------------------------------------------------

-----------------------------------------------------------------------

NAME                      TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
service/elasticsearch     ClusterIP      10.96.19.131    <none>        9200/TCP         115m
service/kibana            LoadBalancer   10.96.85.145    <pending>     5601:32502/TCP   115m
service/kubernetes        ClusterIP      10.96.0.1       <none>        443/TCP          120m
service/logstash          ClusterIP      10.96.91.134    <none>        5000/TCP         115m
service/mongodb-service   ClusterIP      10.96.186.103   <none>        27017/TCP        115m
service/nestjs-app        LoadBalancer   10.96.22.72     <pending>     3000:31178/TCP   115m

-----------------------------------------------------------------------

 

만약 잘 구성되지 않았다면 pod과 svc를 확인하고 로그도 확인하면서 틀린 부분을 수정해 나가야 합니다. 

그럼 이제 터미널 2개를 열고 각각 k port-forward svc/nestjs-app 3000:3000, k port-forward svc/kibana 5601:5601를 실행해 nestjs-app의 controller에서 설정한 대로 json값과 string값을 로그로 남겼을 때 kibana에서 확인합니다.

브라우저에서 간단히 localhost:3000, localhost:3000/json을 접속해 보면 k logs nestjs-app-688dd9b494-xk4mw 에서 log가 찍힌 걸 볼 수 있습니다. 그럼 localhost:5601에 접속해 키바나에서 접속 로그를 확인합니다.

 

우선 키바나UI가 보이게 되면 상단 중앙부에 있는 돋보기 입력창을 눌러 Index Patterns를 검색해 도달합니다. 그런 뒤 crete index pattern을 클릭하고 name에 nestjs-logs-* 를 입력합니다. 그런 뒤 timestamp field는 @timestamp를 선택합니다. 그리고 create index pattern을 클릭합니다. 

 

이제 로그가 쌓인 것을 직접 확인할텐데요.

검색창에 discover를 검색해 이동합니다. 

그럼 아래와 같이 nestjs-app에서부터 쌓은 로그가 fluentbit을 거쳐 logstash를 통해 elasticsearch로 저장된 뒤 kibana에서 접속해 해당 로그를 검색하고 시각화에 사용할 수 있는 상태가 된 것을 확인할 수 있습니다. 

 

k8s를 이용해 ELK 스택을 구현해 봤습니다. 

helm을 이용하면 좀 더 간단히 구성할 수 있지만 처음에는 하나씩 확인하며 구성해 보는 것을 추천합니다. 🧑🏻‍💻

누군가에게 도움이 되길 바랍니다. 감사합니다. 🥕

300x250

관련글 더보기

댓글 영역