Skip to content

Commit 9ae2991

Browse files
authored
feat: 添加流式查看进程日志的 API (#2255)
1 parent 01d5342 commit 9ae2991

5 files changed

Lines changed: 142 additions & 3 deletions

File tree

apiserver/paasng/paas_wl/bk_app/processes/processes.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ def get_instance_logs(
297297
process_type: str,
298298
instance_name: str,
299299
previous: bool,
300+
timestamps: bool = False,
300301
container_name: str | None = None,
301302
tail_lines: Optional[int] = None,
302303
):
@@ -305,6 +306,7 @@ def get_instance_logs(
305306
:param process_type: 进程类型
306307
:param instance_name: 进程实例名称
307308
:param previous: 是否获取上一次运行的日志
309+
:param timestamps: 是否在日志前添加时间戳
308310
:param container_name: 容器名称
309311
:param tail_lines: 获取日志末尾的行数
310312
:return: str
@@ -320,6 +322,7 @@ def get_instance_logs(
320322
name=instance_name,
321323
namespace=self.wl_app.namespace,
322324
previous=previous,
325+
timestamps=timestamps,
323326
container=container_name,
324327
tail_lines=tail_lines,
325328
)
@@ -333,6 +336,50 @@ def get_instance_logs(
333336

334337
return ensure_text(rsp.data)
335338

339+
def get_instance_logs_stream(
340+
self,
341+
process_type: str,
342+
instance_name: str,
343+
container_name: str | None = None,
344+
since_seconds: Optional[int] = None,
345+
timestamps: bool = True,
346+
):
347+
"""获取进程实例日志流
348+
349+
:param process_type: 进程类型
350+
:param instance_name: 进程实例名称
351+
:param container_name: 容器名称
352+
:param since_seconds: 获取日志的起始时间
353+
:param timestamps: 是否在日志前添加时间戳
354+
:return: Generator yielding log lines
355+
:raise: InstanceNotFound when instance not found
356+
"""
357+
if not container_name:
358+
container_name = process_kmodel.get_by_type(self.wl_app, type=process_type).main_container_name
359+
360+
params = {
361+
"name": instance_name,
362+
"namespace": self.wl_app.namespace,
363+
"container": container_name,
364+
"follow": True,
365+
"timestamps": timestamps,
366+
}
367+
if since_seconds:
368+
params["since_seconds"] = since_seconds
369+
370+
k8s_client = get_client_by_app(self.wl_app)
371+
372+
try:
373+
for line in KPod(k8s_client).get_log(**params):
374+
yield ensure_text(line)
375+
except ApiException as e:
376+
if e.status == 400 and "previous terminated container" in json.loads(e.body)["message"]:
377+
raise InstanceNotFound("Terminated container not found")
378+
elif e.status == 404:
379+
raise InstanceNotFound("Instance not found")
380+
else:
381+
raise
382+
336383
def _list_default_specs(self, target_status: Optional[str] = None) -> list[dict]:
337384
"""查询普通应用的进程 specs"""
338385
results = []

apiserver/paasng/paas_wl/bk_app/processes/serializers.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
# to the current version of the project delivered to anyone in the future.
1717

1818
import copy
19+
import re
1920
from dataclasses import dataclass, field
2021
from typing import Any, Dict, Generic, List, Optional, TypeVar
2122
from uuid import UUID
2223

2324
import arrow
2425
import cattr
26+
from dateutil import parser
27+
from django.utils import timezone
2528
from django.utils.translation import gettext_lazy as _
2629
from kubernetes.utils.quantity import parse_quantity
2730
from rest_framework import serializers
@@ -429,3 +432,27 @@ class InstanceLogQueryInputSLZ(serializers.Serializer):
429432
tail_lines = serializers.IntegerField(
430433
required=False, default=400, min_value=1, max_value=10000, help_text="获取日志的行数"
431434
)
435+
436+
437+
class InstanceLogStreamInputSLZ(serializers.Serializer):
438+
"""Serializer for instance log stream API"""
439+
440+
since_time = serializers.CharField(required=False, help_text="查询日志的起始时间 (UTC格式)")
441+
442+
def validate(self, attrs):
443+
since_time = attrs.get("since_time")
444+
# 如果没有传递 since_time, 则使用当前时间, 并转化为 RFC3339Nano 字符串
445+
if not since_time:
446+
attrs["since_time"] = timezone.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
447+
448+
# 验证是否是合法的时间格式
449+
try:
450+
parser.parse(since_time)
451+
except Exception:
452+
raise ValidationError({"since_time": "format must be RFC3339Nano"})
453+
# 验证 since_time 是否是 RFC3339Nano 格式
454+
rfc3339nano_pattern = re.compile(r"^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})\.(\d{9})Z$")
455+
if not re.match(rfc3339nano_pattern, since_time):
456+
raise ValidationError({"since_time": "format must be RFC3339Nano"})
457+
458+
return attrs

apiserver/paasng/paas_wl/bk_app/processes/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@
6868
views.InstanceManageViewSet.as_view({"get": "download_logs"}),
6969
name="api.instances.logs_download",
7070
),
71+
re_path(
72+
make_app_pattern(
73+
r"/processes/(?P<process_type>[\w-]+)/instances/(?P<process_instance_name>[.\w-]+)/logs/stream/$"
74+
),
75+
views.InstanceManageViewSet.as_view({"get": "logs_stream"}),
76+
name="api.instances.logs_stream",
77+
),
7178
re_path(
7279
make_app_pattern(r"/instances/(?P<process_instance_name>[.\w-]+)/restart/$"),
7380
views.InstanceManageViewSet.as_view({"put": "restart"}),

apiserver/paasng/paas_wl/bk_app/processes/views.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@
2121
from operator import attrgetter
2222
from typing import Dict, Optional
2323

24+
from dateutil import parser
2425
from django.http import HttpResponse, StreamingHttpResponse
2526
from django.shortcuts import get_object_or_404
27+
from django.utils import timezone
2628
from django.utils.translation import gettext as _
2729
from drf_yasg.utils import swagger_auto_schema
2830
from rest_framework import status
2931
from rest_framework.permissions import IsAuthenticated
3032
from rest_framework.response import Response
3133
from rest_framework.viewsets import GenericViewSet
34+
from urllib3.exceptions import ReadTimeoutError
3235

3336
from paas_wl.bk_app.applications.constants import WlAppType
3437
from paas_wl.bk_app.applications.models import WlApp
@@ -46,6 +49,7 @@
4649
EventSerializer,
4750
InstanceLogDownloadInputSLZ,
4851
InstanceLogQueryInputSLZ,
52+
InstanceLogStreamInputSLZ,
4953
ListProcessesQuerySLZ,
5054
ListWatcherRespSLZ,
5155
ModuleScopedData,
@@ -447,6 +451,9 @@ class InstanceManageViewSet(GenericViewSet, ApplicationCodeInPathMixin):
447451

448452
permission_classes = [IsAuthenticated, application_perm_class(AppAction.BASIC_DEVELOP)]
449453

454+
# Use special negotiation class to accept "text/event-stream" content type
455+
content_negotiation_class = IgnoreClientContentNegotiation
456+
450457
# 下载日志时,默认最大下载的行数一万
451458
download_log_lines_limit = 10000
452459

@@ -462,14 +469,21 @@ def retrieve_logs(self, request, code, module_name, environment, process_type, p
462469
try:
463470
logs = manager.get_instance_logs(
464471
process_type=process_type,
472+
timestamps=True,
465473
instance_name=process_instance_name,
466474
previous=data["previous"],
467475
tail_lines=data["tail_lines"],
468476
)
469477
except InstanceNotFound:
470478
raise error_codes.PROCESS_INSTANCE_NOT_FOUND
471479

472-
return Response(status=status.HTTP_200_OK, data=logs.splitlines())
480+
# 拆分时间戳和具体日志, 用于开启流式日志时流畅过渡
481+
result_data = []
482+
for log in logs.splitlines():
483+
rfc_timestamp, log_message = log.split(" ", 1)
484+
result_data.append({"timestamp": rfc_timestamp, "message": log_message})
485+
486+
return Response(status=status.HTTP_200_OK, data=result_data)
473487

474488
def download_logs(self, request, code, module_name, environment, process_type, process_instance_name):
475489
"""下载进程实例的日志"""
@@ -495,6 +509,50 @@ def download_logs(self, request, code, module_name, environment, process_type, p
495509
response["Content-Disposition"] = f'attachment; filename="{code}-{process_instance_name}-{log_type}-logs.txt"'
496510
return response
497511

512+
def logs_stream(self, request, code, module_name, environment, process_type, process_instance_name):
513+
"""实时获取进程实例的日志"""
514+
env = self.get_env_via_path()
515+
manager = ProcessManager(env)
516+
517+
slz = InstanceLogStreamInputSLZ(data=request.query_params)
518+
slz.is_valid(raise_exception=True)
519+
data = slz.validated_data
520+
521+
# 计算时间差(秒),并加1确保不会因为精度问题漏掉日志
522+
start_rfc_timestamp = data["since_time"]
523+
seconds_elapsed = int((timezone.now() - parser.parse(start_rfc_timestamp)).total_seconds()) + 1
524+
525+
def resp():
526+
yield "event: ping\n"
527+
yield "data: \n\n"
528+
529+
try:
530+
for log_line in manager.get_instance_logs_stream(
531+
process_type=process_type,
532+
instance_name=process_instance_name,
533+
since_seconds=seconds_elapsed,
534+
):
535+
# log format: "2023-01-01T01:01:01.123456789Z ..."
536+
rfc_timestamp, log_message = log_line.split(" ", 1)
537+
if rfc_timestamp <= start_rfc_timestamp:
538+
# 跳过早于请求时间的日志
539+
continue
540+
541+
data = json.dumps(
542+
{
543+
"timestamp": rfc_timestamp,
544+
"message": log_message.rstrip("\n"),
545+
}
546+
)
547+
yield "event: message\ndata: {}\n\n".format(data)
548+
except ReadTimeoutError as e:
549+
logger.warning("Log stream read timeout: %s", e)
550+
finally:
551+
# 发送结束事件
552+
yield "event: EOF\ndata: \n\n"
553+
554+
return StreamingHttpResponse(resp(), content_type="text/event-stream")
555+
498556
def restart(self, request, code, module_name, environment, process_instance_name):
499557
"""重启进程实例"""
500558
env = self.get_env_via_path()

apiserver/paasng/tests/paas_wl/bk_app/processes/test_processes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,9 @@ def test_get_instance_logs(
196196

197197
if expected_exception:
198198
with pytest.raises(expected_exception):
199-
ProcessManager(bk_stag_env).get_instance_logs("", instance_name, previous, "main")
199+
ProcessManager(bk_stag_env).get_instance_logs("", instance_name, previous, True, "main")
200200
else:
201-
logs = ProcessManager(bk_stag_env).get_instance_logs("", instance_name, previous, "main")
201+
logs = ProcessManager(bk_stag_env).get_instance_logs("", instance_name, previous, True, "main")
202202
assert logs is not None
203203
assert isinstance(logs, str)
204204

0 commit comments

Comments
 (0)