diff --git a/Dockerfile b/Dockerfile index d6d52826f..6e55dd2ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,10 @@ ARG AP_BUILDER_ALPINE=@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc423 ARG BURN_BUILDER_GOLANG=@sha256:f7d3519759ba6988a2b73b5874b17c5958ac7d0aa48a8b1d84d66ef25fa345f1 # gprofiler - ubuntu 20.04 ARG GPROFILER_BUILDER_UBUNTU=@sha256:cf31af331f38d1d7158470e095b132acd126a7180a54f263d386da88eb681d93 +# node-package-builder-musl alpine +ARG NODE_PACKAGE_BUILDER_MUSL=@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a +# node-package-builder-glibc - ubuntu:20.04 +ARG NODE_PACKAGE_BUILDER_GLIBC=@sha256:cf31af331f38d1d7158470e095b132acd126a7180a54f263d386da88eb681d93 # pyspy & rbspy builder base FROM rust${RUST_BUILDER_VERSION} AS pyspy-rbspy-builder-common @@ -148,6 +152,22 @@ COPY scripts/async_profiler_build_shared.sh . COPY scripts/async_profiler_build_musl.sh . RUN ./async_profiler_build_shared.sh /tmp/async_profiler_build_musl.sh +# node-package-builder-musl +FROM alpine${NODE_PACKAGE_BUILDER_MUSL} AS node-package-builder-musl +WORKDIR /tmp +COPY scripts/node_builder_musl_env.sh . +RUN ./node_builder_musl_env.sh +COPY scripts/build_node_package.sh . +RUN ./build_node_package.sh + +# node-package-builder-glibc +FROM ubuntu${NODE_PACKAGE_BUILDER_GLIBC} AS node-package-builder-glibc +WORKDIR /tmp +COPY scripts/node_builder_glibc_env.sh . +RUN ./node_builder_glibc_env.sh +COPY scripts/build_node_package.sh . +RUN ./build_node_package.sh + # burn FROM golang${BURN_BUILDER_GOLANG} AS burn-builder WORKDIR /tmp @@ -191,6 +211,8 @@ COPY --from=async-profiler-builder-glibc /tmp/async-profiler/build/async-profile COPY --from=async-profiler-builder-glibc /tmp/async-profiler/build/libasyncProfiler.so gprofiler/resources/java/glibc/libasyncProfiler.so COPY --from=async-profiler-builder-musl /tmp/async-profiler/build/libasyncProfiler.so gprofiler/resources/java/musl/libasyncProfiler.so COPY --from=async-profiler-builder-glibc /tmp/async-profiler/build/fdtransfer gprofiler/resources/java/fdtransfer +COPY --from=node-package-builder-musl /tmp/module_build gprofiler/resources/node/module/musl +COPY --from=node-package-builder-glibc /tmp/module_build gprofiler/resources/node/module/glibc COPY --from=rbspy-builder /tmp/rbspy/rbspy gprofiler/resources/ruby/rbspy diff --git a/README.md b/README.md index 1e66b4c9d..fc476e3bb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Profiling using eBPF incurs lower overhead & provides kernel & native stacks. * `--nodejs-mode`: Controls which profiler is used for NodeJS. * `none` - (default) no profiler is used. * `perf` - augment the system profiler (`perf`) results with jitdump files generated by NodeJS. This requires running your `node` processes with `--perf-prof` (and for Node >= 10, with `--interpreted-frames-native-stack`). See this [NodeJS page](https://nodejs.org/en/docs/guides/diagnostics-flamegraph/) for more information. + * `attach-maps` - Generates perf map using [node-linux-perf module](https://github.com/mmarchini-oss/node-linux-perf). This module is injected at runtime. Requires entrypoint of application to be CommonJS script. (Doesn't work for ES modules) ### System profiling options @@ -367,6 +368,7 @@ Alongside `perf`, gProfiler invokes runtime-specific profilers for processes bas * Uses [Granulate's fork](https://github.com/Granulate/rbspy) of the [rbspy](https://github.com/rbspy/rbspy) profiler. * NodeJS (version >= 10 for functioning `--perf-prof`): * Uses `perf inject --jit` and NodeJS's ability to generate jitdump files. See [NodeJS profiling options](#nodejs-profiling-options). + * Can also generate perf maps at runtime. * .NET runtime * Uses dotnet-trace. diff --git a/dev-requirements.txt b/dev-requirements.txt index d169bb28b..9a2cc206f 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,3 +10,4 @@ types-PyYAML==6.0.3 types-pkg-resources==0.1.3 types-protobuf==3.19.22 types-toml==0.10.8 +types-retry==0.9.9 diff --git a/gprofiler/main.py b/gprofiler/main.py index 1d2e40c16..01880e52d 100644 --- a/gprofiler/main.py +++ b/gprofiler/main.py @@ -433,9 +433,10 @@ def parse_cmd_args() -> configargparse.Namespace: "--nodejs-mode", dest="nodejs_mode", default="disabled", - choices=["perf", "disabled", "none"], - help="Select the NodeJS profiling mode: perf (run 'perf inject --jit' on perf results, to augment them" - " with jitdump files of NodeJS processes, if present) or disabled (no runtime-specific profilers for NodeJS)", + choices=["attach-maps", "perf", "disabled", "none"], + help="Select the NodeJS profiling mode: attach-maps (generates perf-maps at runtime)," + " perf (run 'perf inject --jit' on perf results, to augment them with jitdump files" + " of NodeJS processes, if present) or disabled (no runtime-specific profilers for NodeJS)", ) nodejs_options.add_argument( @@ -601,6 +602,7 @@ def parse_cmd_args() -> configargparse.Namespace: args = parser.parse_args() args.perf_inject = args.nodejs_mode == "perf" + args.perf_node_attach = args.nodejs_mode == "attach-maps" if args.upload_results: if not args.server_token: @@ -617,8 +619,8 @@ def parse_cmd_args() -> configargparse.Namespace: if args.perf_mode in ("dwarf", "smart") and args.frequency > 100: parser.error("--profiling-frequency|-f can't be larger than 100 when using --perf-mode 'smart' or 'dwarf'") - if args.nodejs_mode == "perf" and args.perf_mode not in ("fp", "smart"): - parser.error("--nodejs-mode perf requires --perf-mode 'fp' or 'smart'") + if args.nodejs_mode in ("perf", "attach-maps") and args.perf_mode not in ("fp", "smart"): + parser.error("--nodejs-mode perf or attach-maps requires --perf-mode 'fp' or 'smart'") return args diff --git a/gprofiler/metadata/application_metadata.py b/gprofiler/metadata/application_metadata.py index 5d0567224..0d23a58c9 100644 --- a/gprofiler/metadata/application_metadata.py +++ b/gprofiler/metadata/application_metadata.py @@ -4,16 +4,14 @@ # import functools -from subprocess import CompletedProcess from threading import Event, Lock from typing import Any, Dict, Optional -from granulate_utils.linux.ns import get_process_nspid, run_in_ns from granulate_utils.linux.process import is_process_running, read_process_execfn from psutil import NoSuchProcess, Process from gprofiler.log import get_logger_adapter -from gprofiler.utils import run_process +from gprofiler.metadata.versions import get_exe_version logger = get_logger_adapter(__name__) @@ -38,21 +36,7 @@ def _clear_cache(self) -> None: del self._cache[process] def get_exe_version(self, process: Process, version_arg: str = "--version", try_stderr: bool = False) -> str: - """ - Runs {process.exe()} --version in the appropriate namespace - """ - exe_path = f"/proc/{get_process_nspid(process.pid)}/exe" - - def _run_get_version() -> "CompletedProcess[bytes]": - return run_process([exe_path, version_arg], stop_event=self._stop_event, timeout=self._GET_VERSION_TIMEOUT) - - cp = run_in_ns(["pid", "mnt"], _run_get_version, process.pid) - stdout = cp.stdout.decode().strip() - # return stderr if stdout is empty, some apps print their version to stderr. - if try_stderr and not stdout: - return cp.stderr.decode().strip() - - return stdout + return get_exe_version(process, self._stop_event, self._GET_VERSION_TIMEOUT, version_arg, try_stderr) @functools.lru_cache(4096) def get_exe_version_cached(self, process: Process, version_arg: str = "--version", try_stderr: bool = False) -> str: diff --git a/gprofiler/metadata/versions.py b/gprofiler/metadata/versions.py new file mode 100644 index 000000000..907ea53e7 --- /dev/null +++ b/gprofiler/metadata/versions.py @@ -0,0 +1,35 @@ +# +# Copyright (c) Granulate. All rights reserved. +# Licensed under the AGPL3 License. See LICENSE.md in the project root for license information. +# +from subprocess import CompletedProcess +from threading import Event + +from granulate_utils.linux.ns import get_process_nspid, run_in_ns +from psutil import Process + +from gprofiler.utils import run_process + + +def get_exe_version( + process: Process, + stop_event: Event, + get_version_timeout: int, + version_arg: str = "--version", + try_stderr: bool = False, +) -> str: + """ + Runs {process.exe()} --version in the appropriate namespace + """ + exe_path = f"/proc/{get_process_nspid(process.pid)}/exe" + + def _run_get_version() -> "CompletedProcess[bytes]": + return run_process([exe_path, version_arg], stop_event=stop_event, timeout=get_version_timeout) + + cp = run_in_ns(["pid", "mnt"], _run_get_version, process.pid) + stdout = cp.stdout.decode().strip() + # return stderr if stdout is empty, some apps print their version to stderr. + if try_stderr and not stdout: + return cp.stderr.decode().strip() + + return stdout diff --git a/gprofiler/profilers/node.py b/gprofiler/profilers/node.py new file mode 100644 index 000000000..b36ab3a40 --- /dev/null +++ b/gprofiler/profilers/node.py @@ -0,0 +1,237 @@ +# +# Copyright (c) Granulate. All rights reserved. +# Licensed under the AGPL3 License. See LICENSE.md in the project root for license information. +# +import json +import os +import shutil +import signal +import stat +from functools import lru_cache +from pathlib import Path +from threading import Event +from typing import Any, Dict, List, cast + +import psutil +import requests +from granulate_utils.linux.ns import get_proc_root_path, get_process_nspid, resolve_proc_root_links, run_in_ns +from granulate_utils.linux.process import is_musl, is_process_running +from retry import retry +from websocket import create_connection +from websocket._core import WebSocket + +from gprofiler.log import get_logger_adapter +from gprofiler.metadata.versions import get_exe_version +from gprofiler.utils import TEMPORARY_STORAGE_PATH, add_permission_dir, pgrep_exe, resource_path + +logger = get_logger_adapter(__name__) + + +class NodeDebuggerUrlNotFound(Exception): + pass + + +class NodeDebuggerUnexpectedResponse(Exception): + pass + + +def _get_node_major_version(process: psutil.Process) -> str: + node_version = get_exe_version(process, Event(), 3) + # i. e. v16.3.2 -> 16 + return node_version[1:].split(".")[0] + + +@lru_cache(maxsize=1) +def _get_dso_git_rev() -> str: + libc_dso_version_file = resource_path(os.path.join("node", "module", "glibc", "version")) + musl_dso_version_file = resource_path(os.path.join("node", "module", "musl", "version")) + libc_dso_ver = Path(libc_dso_version_file).read_text() + musl_dso_ver = Path(musl_dso_version_file).read_text() + # with no build errors this should always be the same + assert libc_dso_ver == musl_dso_ver + return libc_dso_ver + + +@lru_cache() +def _get_dest_inside_container(musl: bool, node_version: str) -> str: + libc = "musl" if musl else "glibc" + return os.path.join(TEMPORARY_STORAGE_PATH, "node_module", _get_dso_git_rev(), libc, node_version) + + +def _start_debugger(pid: int) -> None: + # for windows: in shell node -e "process._debugProcess(PID)" + os.kill(pid, signal.SIGUSR1) + + +@retry(NodeDebuggerUrlNotFound, 5, 1) +def _get_debugger_url() -> str: + # when killing process with SIGUSR1 it will open new debugger session on port 9229, + # so it will always the same. When another debugger is opened in same NS it will not open new one. + # REF: Inspector agent initialization uses host_port + # https://github.com/nodejs/node/blob/5fad0b93667ffc6e4def52996b9529ac99b26319/src/inspector_agent.cc#L668 + # host_port defaults to 9229 + # ref: https://github.com/nodejs/node/blob/2849283c4cebbfbf523cc24303941dc36df9332f/src/node_options.h#L90 + # in our case it won't be changed + port = 9229 + debugger_url_response = requests.get(f"http://127.0.0.1:{port}/json/list", timeout=3) + if debugger_url_response.status_code != 200 or "application/json" not in debugger_url_response.headers.get( + "Content-Type", "" + ): + raise NodeDebuggerUrlNotFound( + {"status_code": debugger_url_response.status_code, "text": debugger_url_response.text} + ) + + response_json = debugger_url_response.json() + if ( + not isinstance(response_json, list) + or len(response_json) == 0 + or not isinstance(response_json[0], dict) + or "webSocketDebuggerUrl" not in response_json[0] + ): + raise NodeDebuggerUrlNotFound(response_json) + + return cast(str, response_json[0]["webSocketDebuggerUrl"]) + + +def _send_socket_request(sock: WebSocket, cdp_request: Dict) -> None: + sock.send(json.dumps(cdp_request)) + message = sock.recv() + try: + message = json.loads(message) + except json.JSONDecodeError: + raise NodeDebuggerUnexpectedResponse(message) + + if ( + "result" not in message.keys() + or "result" not in message["result"].keys() + or "type" not in message["result"]["result"].keys() + or message["result"]["result"]["type"] != "boolean" + ): + raise NodeDebuggerUnexpectedResponse(message) + + +def _execute_js_command(sock: WebSocket, command: str) -> Any: + cdp_request = { + "id": 1, + "method": "Runtime.evaluate", + "params": { + "expression": command, + "replMode": True, + }, + } + sock.send(json.dumps(cdp_request)) + message = sock.recv() + try: + message = json.loads(message) + except json.JSONDecodeError: + raise NodeDebuggerUnexpectedResponse(message) + try: + return message["result"]["result"]["value"] + except KeyError: + raise NodeDebuggerUnexpectedResponse(message) + + +def _change_dso_state(sock: WebSocket, module_path: str, action: str) -> None: + assert action in ("start", "stop"), "_change_dso_state supports only start and stop actions" + cdp_request = { + "id": 1, + "method": "Runtime.evaluate", + "params": { + "expression": f'process.mainModule.require("{os.path.join(module_path, "linux-perf.js")}").{action}()', + "replMode": True, + }, + } + _send_socket_request(sock, cdp_request) + + +def _validate_ns_node(sock: WebSocket, expected_ns_link_name: str) -> None: + command = 'const fs = process.mainModule.require("fs"); fs.readlinkSync("/proc/self/ns/pid")' + actual_ns_link_name = cast(str, _execute_js_command(sock, command)) + assert ( + actual_ns_link_name == expected_ns_link_name + ), f"Wrong namespace, expected {expected_ns_link_name}, got {actual_ns_link_name}" + + +def _validate_pid(expected_pid: int, sock: WebSocket) -> None: + actual_pid = cast(int, _execute_js_command(sock, "process.pid")) + assert expected_pid == actual_pid, f"Wrong pid, expected {expected_pid}, actual {actual_pid}" + + +def create_debugger_socket(nspid: int, ns_link_name: str) -> WebSocket: + debugger_url = _get_debugger_url() + sock = create_connection(debugger_url) + sock.settimeout(10) + _validate_ns_node(sock, ns_link_name) + _validate_pid(nspid, sock) + return sock + + +def _copy_module_into_process_ns(process: psutil.Process, musl: bool, version: str) -> str: + proc_root = get_proc_root_path(process) + libc = "musl" if musl else "glibc" + dest_inside_container = _get_dest_inside_container(musl, version) + dest = resolve_proc_root_links(proc_root, dest_inside_container) + if os.path.exists(dest): + return dest_inside_container + src = resource_path(os.path.join("node", "module", libc, _get_dso_git_rev(), version)) + shutil.copytree(src, dest) + add_permission_dir(dest, stat.S_IROTH, stat.S_IXOTH | stat.S_IROTH) + return dest_inside_container + + +def _generate_perf_map(module_path: str, nspid: int, ns_link_name: str) -> None: + sock = create_debugger_socket(nspid, ns_link_name) + _change_dso_state(sock, module_path, "start") + + +def _clean_up(module_path: str, nspid: int, ns_link_name: str) -> None: + sock = create_debugger_socket(nspid, ns_link_name) + try: + _change_dso_state(sock, module_path, "stop") + finally: + os.remove(os.path.join("/tmp", f"perf-{nspid}.map")) + + +def get_node_processes() -> List[psutil.Process]: + return pgrep_exe(r".*node[^/]*$") + + +def generate_map_for_node_processes(processes: List[psutil.Process]) -> None: + """Iterates over all NodeJS processes, starts debugger for it, finds debugger URL, + copies node-linux-perf module into process' namespace, loads module and starts it.""" + for process in processes: + try: + musl = is_musl(process) + node_major_version = _get_node_major_version(process) + dest = _copy_module_into_process_ns(process, musl, node_major_version) + nspid = get_process_nspid(process.pid) + ns_link_name = os.readlink(f"/proc/{process.pid}/ns/pid") + _start_debugger(process.pid) + run_in_ns( + ["pid", "mnt", "net"], + lambda: _generate_perf_map(dest, nspid, ns_link_name), + process.pid, + passthrough_exception=True, + ) + except Exception as e: + logger.warning(f"Could not create debug symbols for pid {process.pid}. Reason: {e}", exc_info=True) + + +def clean_up_node_maps(processes: List[psutil.Process]) -> None: + """Stops generating perf maps for each NodeJS process and cleans up generated maps""" + for process in processes: + try: + if not is_process_running(process): + continue + node_major_version = _get_node_major_version(process) + nspid = get_process_nspid(process.pid) + ns_link_name = os.readlink(f"/proc/{process.pid}/ns/pid") + dest = _get_dest_inside_container(is_musl(process), node_major_version) + run_in_ns( + ["pid", "mnt", "net"], + lambda: _clean_up(dest, nspid, ns_link_name), + process.pid, + passthrough_exception=True, + ) + except Exception as e: + logger.warning(f"Could not clean up debug symbols for pid {process.pid}. Reason: {e}", exc_info=True) diff --git a/gprofiler/profilers/perf.py b/gprofiler/profilers/perf.py index 667375e8f..66831f1fa 100644 --- a/gprofiler/profilers/perf.py +++ b/gprofiler/profilers/perf.py @@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional from granulate_utils.linux.elf import is_statically_linked, read_elf_symbol, read_elf_va -from granulate_utils.linux.process import is_musl +from granulate_utils.linux.process import is_musl, is_process_running from psutil import NoSuchProcess, Process from gprofiler import merge @@ -20,6 +20,7 @@ from gprofiler.gprofiler_types import AppMetadata, ProcessToProfileData, ProfileData from gprofiler.log import get_logger_adapter from gprofiler.metadata.application_metadata import ApplicationMetadata +from gprofiler.profilers.node import clean_up_node_maps, generate_map_for_node_processes, get_node_processes from gprofiler.profilers.profiler_base import ProfilerBase from gprofiler.profilers.registry import ProfilerArgument, register_profiler from gprofiler.utils import run_process, start_process, wait_event, wait_for_file_by_prefix @@ -173,11 +174,13 @@ def __init__( perf_mode: str, perf_dwarf_stack_size: int, perf_inject: bool, + perf_node_attach: bool, ): super().__init__(frequency, duration, stop_event, storage_dir) _ = profile_spawned_processes # Required for mypy unused argument warning self._perfs: List[PerfProcess] = [] self._metadata_collectors: List[PerfMetadata] = [GolangPerfMetadata(stop_event), NodePerfMetadata(stop_event)] + self._node_processes: List[Process] = [] if perf_mode in ("fp", "smart"): self._perf_fp: Optional[PerfProcess] = PerfProcess( @@ -205,13 +208,22 @@ def __init__( else: self._perf_dwarf = None + self.perf_node_attach = perf_node_attach assert self._perf_fp is not None or self._perf_dwarf is not None def start(self) -> None: + # we have to also generate maps here, + # it might be too late for first round to generate it in snapshot() + if self.perf_node_attach: + self._node_processes = get_node_processes() + generate_map_for_node_processes(self._node_processes) for perf in self._perfs: perf.start() def stop(self) -> None: + if self.perf_node_attach: + self._node_processes = [process for process in self._node_processes if is_process_running(process)] + clean_up_node_maps(self._node_processes) for perf in reversed(self._perfs): perf.stop() @@ -229,6 +241,12 @@ def _get_metadata(self, pid: int) -> Optional[AppMetadata]: return None def snapshot(self) -> ProcessToProfileData: + if self.perf_node_attach: + self._node_processes = [process for process in self._node_processes if is_process_running(process)] + new_processes = [process for process in get_node_processes() if process not in self._node_processes] + generate_map_for_node_processes(new_processes) + self._node_processes.extend(new_processes) + if self._stop_event.wait(self._duration): raise StopEventSetException diff --git a/gprofiler/utils/__init__.py b/gprofiler/utils/__init__.py index 14eac5560..c43bad0a5 100644 --- a/gprofiler/utils/__init__.py +++ b/gprofiler/utils/__init__.py @@ -271,7 +271,8 @@ def pgrep_exe(match: str) -> List[Process]: procs = [] for process in psutil.process_iter(): try: - if pattern.match(process_exe(process)): + # kernel threads should be child of process with pid 2 + if process.pid != 2 and process.ppid() != 2 and pattern.match(process_exe(process)): procs.append(process) except psutil.NoSuchProcess: # process might have died meanwhile continue @@ -457,3 +458,13 @@ def is_pyinstaller() -> bool: def get_staticx_dir() -> Optional[str]: return os.getenv("STATICX_BUNDLE_DIR") + + +def add_permission_dir(path: str, permission_for_file: int, permission_for_dir: int) -> None: + os.chmod(path, os.stat(path).st_mode | permission_for_dir) + for subpath in os.listdir(path): + absolute_subpath = os.path.join(path, subpath) + if os.path.isdir(absolute_subpath): + add_permission_dir(absolute_subpath, permission_for_file, permission_for_dir) + else: + os.chmod(absolute_subpath, os.stat(absolute_subpath).st_mode | permission_for_file) diff --git a/mypy.ini b/mypy.ini index b04acc747..8e983c177 100644 --- a/mypy.ini +++ b/mypy.ini @@ -19,3 +19,6 @@ ignore_missing_imports = True # no types in package / types- package :( [mypy-docker.*] ignore_missing_imports = True +# no types in package / types- package :( +[mypy-websocket.*] +ignore_missing_imports = True diff --git a/pyi.Dockerfile b/pyi.Dockerfile index 5f07c7f3b..f976e7fe2 100644 --- a/pyi.Dockerfile +++ b/pyi.Dockerfile @@ -12,6 +12,8 @@ ARG BURN_BUILDER_GOLANG ARG GPROFILER_BUILDER ARG PYPERF_BUILDER_UBUNTU ARG DOTNET_BUILDER +ARG NODE_PACKAGE_BUILDER_MUSL +ARG NODE_PACKAGE_BUILDER_GLIBC # pyspy & rbspy builder base FROM rust${RUST_BUILDER_VERSION} AS pyspy-rbspy-builder-common @@ -98,6 +100,22 @@ WORKDIR /tmp COPY scripts/burn_build.sh . RUN ./burn_build.sh +# node-package-builder-musl +FROM alpine${NODE_PACKAGE_BUILDER_MUSL} AS node-package-builder-musl +WORKDIR /tmp +COPY scripts/node_builder_musl_env.sh . +RUN ./node_builder_musl_env.sh +COPY scripts/build_node_package.sh . +RUN ./build_node_package.sh + +# node-package-builder-glibc +FROM ubuntu${NODE_PACKAGE_BUILDER_GLIBC} AS node-package-builder-glibc +WORKDIR /tmp +COPY scripts/node_builder_glibc_env.sh . +RUN ./node_builder_glibc_env.sh +COPY scripts/build_node_package.sh . +RUN ./build_node_package.sh + # bcc helpers # built on newer Ubuntu because they require new clang (newer than available in GPROFILER_BUILDER's CentOS 7) # these are only relevant for modern kernels, so there's no real reason to build them on CentOS 7 anyway. @@ -260,6 +278,8 @@ COPY --from=async-profiler-builder-glibc /tmp/async-profiler/build/async-profile COPY --from=async-profiler-centos-min-test-glibc /libasyncProfiler.so gprofiler/resources/java/glibc/libasyncProfiler.so COPY --from=async-profiler-builder-musl /tmp/async-profiler/build/libasyncProfiler.so gprofiler/resources/java/musl/libasyncProfiler.so COPY --from=async-profiler-builder-glibc /tmp/async-profiler/build/fdtransfer gprofiler/resources/java/fdtransfer +COPY --from=node-package-builder-musl /tmp/module_build gprofiler/resources/node/module/musl +COPY --from=node-package-builder-glibc /tmp/module_build gprofiler/resources/node/module/glibc COPY --from=burn-builder /tmp/burn/burn gprofiler/resources/burn @@ -295,12 +315,13 @@ COPY ./scripts/list_needed_libs.sh ./scripts/list_needed_libs.sh # we use list_needed_libs.sh to list the dynamic dependencies of *all* of our resources, # and make staticx pack them as well. # using scl here to get the proper LD_LIBRARY_PATH set -# hadolint ignore=SC2046 +# hadolint ignore=SC2046,SC2086 RUN set -e; \ if [ $(uname -m) != "aarch64" ]; then \ source scl_source enable devtoolset-8 llvm-toolset-7 ; \ fi && \ - staticx $(./scripts/list_needed_libs.sh) dist/gprofiler dist/gprofiler + LIBS=$(./scripts/list_needed_libs.sh) && \ + staticx $LIBS dist/gprofiler dist/gprofiler FROM scratch AS export-stage diff --git a/requirements.txt b/requirements.txt index 32bbe0bb6..31344e950 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,6 @@ dataclasses==0.8; python_version < '3.7' packaging==21.2 pyelftools==0.28 curlify==2.2.1 +retry==0.9.2 +websocket-client==1.3.1 ./granulate-utils/ diff --git a/scripts/build_aarch64_container.sh b/scripts/build_aarch64_container.sh index e5fb0173d..de73354fd 100755 --- a/scripts/build_aarch64_container.sh +++ b/scripts/build_aarch64_container.sh @@ -28,4 +28,6 @@ docker buildx build --platform=linux/arm64 \ --build-arg BURN_BUILDER_GOLANG=$GOLANG_VERSION \ --build-arg GPROFILER_BUILDER_UBUNTU=$UBUNTU_VERSION \ --build-arg DOTNET_BUILDER=$DOTNET_BUILDER \ + --build-arg NODE_PACKAGE_BUILDER_MUSL=$ALPINE_VERSION \ + --build-arg NODE_PACKAGE_BUILDER_GLIBC=$UBUNTU_VERSION \ . "$@" diff --git a/scripts/build_aarch64_executable.sh b/scripts/build_aarch64_executable.sh index 4ba43502c..b330c33ec 100755 --- a/scripts/build_aarch64_executable.sh +++ b/scripts/build_aarch64_executable.sh @@ -32,4 +32,6 @@ docker buildx build --platform=linux/arm64 \ --build-arg BURN_BUILDER_GOLANG=$GOLANG_VERSION \ --build-arg GPROFILER_BUILDER=$CENTOS8_VERSION \ --build-arg DOTNET_BUILDER=$DOTNET_BUILDER \ + --build-arg NODE_PACKAGE_BUILDER_MUSL=$ALPINE_VERSION \ + --build-arg NODE_PACKAGE_BUILDER_GLIBC=$UBUNTU_VERSION \ . -f pyi.Dockerfile --output type=local,dest=build/aarch64/ "$@" diff --git a/scripts/build_node_package.sh b/scripts/build_node_package.sh new file mode 100755 index 000000000..2dde04b81 --- /dev/null +++ b/scripts/build_node_package.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MODULE_PATH=/tmp/module +BUILD_TARGET_DIR=/tmp/module_build + +GIT_REV=20eb88a +git clone https://github.com/mmarchini-oss/node-linux-perf.git $MODULE_PATH +cd $MODULE_PATH +git reset --hard $GIT_REV + +npm install -g node-gyp +curl -L https://github.com/nodejs/nan/archive/refs/tags/v2.16.0.tar.gz -o nan.tar.gz +tar -vxzf nan.tar.gz -C /tmp +NAN_PATH=$(realpath /tmp/nan-*) +export NAN_PATH +# shellcheck disable=SC2016 # expression shouldn't be expanded +# providing nan module by path, rather than npm, because of using node-gyp instead of npm +sed -i 's/node \-e \\"require('\''nan'\'')\\"/echo $NAN_PATH/g' binding.gyp +rm -rf nan.tar.gz +mkdir $BUILD_TARGET_DIR +node_versions=( "10.10.0" "11.0.0" ) +for node_version in "${node_versions[@]}"; do + node-gyp configure --target="$node_version" --build_v8_with_gn=false + node-gyp build --target="$node_version" + # shellcheck disable=SC2206 # string is expected to be splitted here + t=(${node_version//./ }) + node_major_version=${t[0]} + mkdir -p "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version" + cp "$MODULE_PATH/linux-perf.js" "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version/." + mkdir -p "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version/build/Release" + # we need to preserve original path required by linux-perf.js + cp "$MODULE_PATH/build/Release/linux-perf.node" "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version/build/Release/linux-perf.node" + rm -rf "$MODULE_PATH/build" +done +for node_major_version in {12..16}; do + node-gyp configure --target="$node_major_version.0.0" + node-gyp build --target="$node_major_version.0.0" + mkdir -p "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version" + cp "$MODULE_PATH/linux-perf.js" "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version/." + mkdir -p "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version/build/Release" + cp "$MODULE_PATH/build/Release/linux-perf.node" "$BUILD_TARGET_DIR/$GIT_REV/$node_major_version/build/Release/linux-perf.node" + rm -rf "$MODULE_PATH/build" +done +rm -rf "$NAN_PATH" +echo -n "$GIT_REV" > $BUILD_TARGET_DIR/version \ No newline at end of file diff --git a/scripts/build_x86_64_executable.sh b/scripts/build_x86_64_executable.sh index c2d984e37..67ec6adcc 100755 --- a/scripts/build_x86_64_executable.sh +++ b/scripts/build_x86_64_executable.sh @@ -39,4 +39,6 @@ DOCKER_BUILDKIT=1 docker build -f pyi.Dockerfile --output type=local,dest=build/ --build-arg BURN_BUILDER_GOLANG=$BURN_BUILDER_GOLANG \ --build-arg GPROFILER_BUILDER=$GPROFILER_BUILDER \ --build-arg DOTNET_BUILDER=$DOTNET_BUILDER \ + --build-arg NODE_PACKAGE_BUILDER_MUSL=$AP_BUILDER_ALPINE \ + --build-arg NODE_PACKAGE_BUILDER_GLIBC=$UBUNTU_VERSION \ . "$@" diff --git a/scripts/list_needed_libs.sh b/scripts/list_needed_libs.sh index cfa82fc50..f6fca4013 100755 --- a/scripts/list_needed_libs.sh +++ b/scripts/list_needed_libs.sh @@ -10,7 +10,14 @@ set -euo pipefail # (staticx knows to pack the libraries used by the executable we're packing. it doesn't know # which executables are to be used by it) -BINS=$(find gprofiler/resources -executable -type f) +EXCLUDED_DIRECTORIES=('"gprofiler/resources/node/*"') +FIND_BINS_CMD="find gprofiler/resources -executable -type f" + +for DIR in "${EXCLUDED_DIRECTORIES[@]}" ; do + FIND_BINS_CMD+=" -not -path $DIR" +done + +BINS=$(eval "$FIND_BINS_CMD") libs= diff --git a/scripts/node_builder_glibc_env.sh b/scripts/node_builder_glibc_env.sh new file mode 100755 index 000000000..e35ab763e --- /dev/null +++ b/scripts/node_builder_glibc_env.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Copyright (c) Granulate. All rights reserved. +# Licensed under the AGPL3 License. See LICENSE.md in the project root for license information. +# +set -eu + +export DEBIAN_FRONTEND=noninteractive +apt update -y && apt install -y --no-install-recommends curl g++ python3 make gcc git ca-certificates npm +# apt has node v10 by default, so we need to add newer version to run node-gyp +curl -fsSL https://deb.nodesource.com/setup_16.x | bash - +apt install -y --no-install-recommends nodejs diff --git a/scripts/node_builder_musl_env.sh b/scripts/node_builder_musl_env.sh new file mode 100755 index 000000000..b23eb6832 --- /dev/null +++ b/scripts/node_builder_musl_env.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +# +# Copyright (c) Granulate. All rights reserved. +# Licensed under the AGPL3 License. See LICENSE.md in the project root for license information. +# +set -eu + +apk add --no-cache curl g++ python3 make gcc git bash nodejs npm \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index ca642647f..2376b9b5a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -216,7 +216,8 @@ def application_docker_image_configs() -> Mapping[str, Dict[str, Any]]: "thread_comm": dict(dockerfile="thread_comm.Dockerfile"), }, "nodejs": { - "": {}, + "": dict(buildargs={"NODE_RUNTIME_FLAGS": "--perf-prof --interpreted-frames-native-stack"}), + "without-flags": dict(buildargs={"NODE_RUNTIME_FLAGS": ""}), }, "php": { "": {}, @@ -384,7 +385,11 @@ def application_pid( # Application might be run using "sh -c ...", we detect the case and return the "real" application pid process = Process(pid) - if process.cmdline()[0] == "sh" and process.cmdline()[1] == "-c" and len(process.children(recursive=False)) == 1: + if ( + process.cmdline()[0].endswith("sh") + and process.cmdline()[1] == "-c" + and len(process.children(recursive=False)) == 1 + ): pid = process.children(recursive=False)[0].pid return pid diff --git a/tests/containers/nodejs/Dockerfile b/tests/containers/nodejs/Dockerfile index b5d9f72a6..ee8d322ab 100644 --- a/tests/containers/nodejs/Dockerfile +++ b/tests/containers/nodejs/Dockerfile @@ -1,10 +1,14 @@ # node:10.24.1 FROM node@sha256:59531d2835edd5161c8f9512f9e095b1836f7a1fcb0ab73e005ec46047384911 +ARG NODE_RUNTIME_FLAGS + # /tmp so node has permissions to write its jitdump file WORKDIR /tmp RUN mkdir /app ADD fibonacci.js /app -CMD ["node", "--perf-prof", "--interpreted-frames-native-stack", "/app/fibonacci.js"] +ENV NODE_RUNTIME_FLAGS ${NODE_RUNTIME_FLAGS} + +CMD node $NODE_RUNTIME_FLAGS /app/fibonacci.js diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 000000000..0ed952f5d --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,69 @@ +# +# Copyright (c) Granulate. All rights reserved. +# Licensed under the AGPL3 License. See LICENSE.md in the project root for license information. +from pathlib import Path +from threading import Event +from typing import List + +import pytest +from docker import DockerClient +from docker.models.images import Image + +from gprofiler.merge import parse_one_collapsed +from gprofiler.profilers.perf import SystemProfiler +from tests import CONTAINERS_DIRECTORY +from tests.conftest import AssertInCollapsed +from tests.utils import assert_function_in_collapsed, run_gprofiler_in_container_for_one_session, snapshot_pid_collapsed + + +@pytest.mark.parametrize("profiler_type", ["attach-maps"]) +@pytest.mark.parametrize("runtime", ["nodejs"]) +@pytest.mark.parametrize("application_image_tag", ["without-flags"]) +@pytest.mark.parametrize("command_line", [["node", f"{CONTAINERS_DIRECTORY}/nodejs/fibonacci.js"]]) +def test_nodejs_attach_maps( + tmp_path: Path, + application_pid: int, + assert_collapsed: AssertInCollapsed, + profiler_type: str, + command_line: List[str], + runtime_specific_args: List[str], +) -> None: + with SystemProfiler( + 1000, + 6, + Event(), + str(tmp_path), + False, + perf_mode="fp", + perf_inject=False, + perf_dwarf_stack_size=0, + perf_node_attach=True, + ) as profiler: + process_collapsed = snapshot_pid_collapsed(profiler, application_pid) + assert_collapsed(process_collapsed) + # check for node built-in functions + assert_function_in_collapsed("node::Start", process_collapsed) + # check for v8 built-in functions + assert_function_in_collapsed("v8::Function::Call", process_collapsed) + + +@pytest.mark.parametrize("profiler_type", ["attach-maps"]) +@pytest.mark.parametrize("runtime", ["nodejs"]) +@pytest.mark.parametrize("application_image_tag", ["without-flags"]) +@pytest.mark.parametrize("command_line", [["node", f"{CONTAINERS_DIRECTORY}/nodejs/fibonacci.js"]]) +def test_nodejs_attach_maps_from_container( + docker_client: DockerClient, + application_pid: int, + runtime_specific_args: List[str], + gprofiler_docker_image: Image, + output_directory: Path, + output_collapsed: Path, + assert_collapsed: AssertInCollapsed, + profiler_flags: List[str], +) -> None: + _ = application_pid # Fixture only used for running the application. + collapsed_text = run_gprofiler_in_container_for_one_session( + docker_client, gprofiler_docker_image, output_directory, output_collapsed, runtime_specific_args, profiler_flags + ) + collapsed = parse_one_collapsed(collapsed_text) + assert_collapsed(collapsed) diff --git a/tests/test_perf.py b/tests/test_perf.py index fe13a9dc0..6d0169e3e 100644 --- a/tests/test_perf.py +++ b/tests/test_perf.py @@ -25,6 +25,7 @@ def system_profiler(tmp_path: Path, perf_mode: str) -> SystemProfiler: perf_mode=perf_mode, perf_inject=False, perf_dwarf_stack_size=DEFAULT_PERF_DWARF_STACK_SIZE, + perf_node_attach=False, ) diff --git a/tests/test_sanity.py b/tests/test_sanity.py index 0a5abf98c..0a2b70753 100644 --- a/tests/test_sanity.py +++ b/tests/test_sanity.py @@ -116,7 +116,15 @@ def test_nodejs( gprofiler_docker_image: Image, ) -> None: with SystemProfiler( - 1000, 6, Event(), str(tmp_path), False, perf_mode="fp", perf_inject=True, perf_dwarf_stack_size=0 + 1000, + 6, + Event(), + str(tmp_path), + False, + perf_mode="fp", + perf_inject=True, + perf_dwarf_stack_size=0, + perf_node_attach=False, ) as profiler: process_collapsed = snapshot_pid_collapsed(profiler, application_pid) assert_collapsed(process_collapsed)