feat: Initial NFS PV usage exporter implementation

- Flask app with /usage/<pv_name> and /metrics endpoints
- 300s cache TTL with threading lock for du command
- ionice/nice for low I/O priority
- Dockerfile with gunicorn
- Helm chart with deployment, service, networkpolicy
- NetworkPolicy allows only gpulive and monitoring namespaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cloud User 2026-02-05 14:14:37 +09:00
commit 6db99ea0ef
9 changed files with 201 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.tgz
__pycache__/
*.pyc
.env

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
coreutils \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "app:app"]

61
app.py Normal file
View File

@ -0,0 +1,61 @@
from flask import Flask
from prometheus_client import Gauge, generate_latest, CONTENT_TYPE_LATEST
import subprocess
import time
import os
import threading
app = Flask(__name__)
pv_usage_bytes = Gauge('nfs_pv_usage_bytes', 'PV usage in bytes', ['pv_name'])
pv_last_check = Gauge('nfs_pv_last_check_timestamp', 'Last check timestamp', ['pv_name'])
cache = {}
CACHE_TTL = 300
du_lock = threading.Lock() # 동시 du 방지
def get_pv_usage(pv_name):
now = time.time()
# 캐시 확인 (락 없이)
if pv_name in cache and now - cache[pv_name]['time'] < CACHE_TTL:
return cache[pv_name]['bytes']
# du는 한 번에 하나만
with du_lock:
# 락 획득 후 다시 캐시 확인
if pv_name in cache and time.time() - cache[pv_name]['time'] < CACHE_TTL:
return cache[pv_name]['bytes']
path = f"/mnt/pvs/{pv_name}"
if not os.path.exists(path):
return None
result = subprocess.run(
['ionice', '-c', '3', 'nice', '-n', '19', 'du', '-sb', path],
capture_output=True, text=True, timeout=300
)
if result.returncode != 0:
return None
usage = int(result.stdout.split()[0])
cache[pv_name] = {'bytes': usage, 'time': time.time()}
pv_usage_bytes.labels(pv_name=pv_name).set(usage)
pv_last_check.labels(pv_name=pv_name).set(time.time())
return usage
@app.route('/usage/<pv_name>')
def usage(pv_name):
result = get_pv_usage(pv_name)
if result is None:
return {'error': 'PV not found or timeout'}, 404
return {'pv_name': pv_name, 'bytes': result}
@app.route('/metrics')
def metrics():
return generate_latest(), 200, {'Content-Type': CONTENT_TYPE_LATEST}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, threaded=True)

6
chart/Chart.yaml Normal file
View File

@ -0,0 +1,6 @@
apiVersion: v2
name: nfs-usage-exporter
description: NFS PV usage metrics exporter for Prometheus
type: application
version: 1.0.0
appVersion: "1.0.0"

View File

@ -0,0 +1,37 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
nodeSelector:
{{- toYaml .Values.nodeSelector | nindent 8 }}
containers:
- name: exporter
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 8080
name: metrics
volumeMounts:
- name: nfs-root
mountPath: /mnt/pvs
readOnly: true
resources:
{{- toYaml .Values.resources | nindent 10 }}
volumes:
- name: nfs-root
nfs:
server: {{ .Values.nfs.server }}
path: {{ .Values.nfs.path }}
readOnly: true

View File

@ -0,0 +1,24 @@
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
spec:
podSelector:
matchLabels:
app: {{ .Chart.Name }}
policyTypes:
- Ingress
ingress:
- from:
{{- range .Values.networkPolicy.allowedNamespaces }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . }}
{{- end }}
ports:
- protocol: TCP
port: 8080
{{- end }}

View File

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8080
protocol: TCP
name: metrics
selector:
app: {{ .Chart.Name }}

31
chart/values.yaml Normal file
View File

@ -0,0 +1,31 @@
replicaCount: 1
image:
repository: harbor.inje-private.com/infra/nfs-usage-exporter
tag: "v1.0.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
nodeSelector:
nodegroup: nd
nfs:
server: "192.168.0.54"
path: "/GJ_SHARE_FS6/39197c35-6247-4ece-bc74-d9460a757a04"
networkPolicy:
enabled: true
allowedNamespaces:
- gpulive
- monitoring

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask==3.0.0
prometheus_client==0.19.0
gunicorn==21.2.0