서비스를 운영하다 보면 서비스에 꼭 필요한 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 pods나 k 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을 이용하면 좀 더 간단히 구성할 수 있지만 처음에는 하나씩 확인하며 구성해 보는 것을 추천합니다. 🧑🏻💻
누군가에게 도움이 되길 바랍니다. 감사합니다. 🥕
Next.js와 Shadcn UI를 사용한 간단한 POS 시스템 구축 (0) | 2025.02.03 |
---|---|
Backup vs Snapshot (0) | 2024.10.11 |
Windows 방화벽 로그 확인 및 접속 위치 확인하는 방법 (0) | 2024.09.27 |
Windows 에서 IP 접속 통제 및 로그 확인하는 법(방화벽) (0) | 2024.09.27 |
스마트폰(안드로이드) USB 디버깅하기! (0) | 2024.08.31 |
댓글 영역