Skip to content

Commit

Permalink
feat: add shared package nginxprocess
Browse files Browse the repository at this point in the history
This package is intended for use by any software that wants to
identify NGINX processes.

It's mostly a refactor of the existing `process.ProcessOperator` with
a few differences and some features wanted by NGINXaaS.

- minimizes `gopsutil` calls, many of which end up shelling out to
`ps`. `nginxprocess.List` filters out non-NGINX processes
early. `process.ProcessOperator` gathered details for all processes,
and then non-NGINX processes were filtered out later by
`instance.NginxProcessParser`. `nginxprocess.List` also fetches less
process information, keeping only what the agent codebase actually
used.

A quick benchmark on my laptop (~750 processes, 12 of which are NGINX)
showed a drastic speed improvement:

| Benchmark   | Iterations | Time (ns/op) | Memory (B/op) | Allocations (allocs/op) |
| ----------- | ---------- | ------------ | ------------- | ----------------------- |
| old         | 1          | 26832273195  | 18282768      | 133775                  |
| new         | 9          | 114558263    | 3439672       | 12836                   |

- uses a functional options pattern to optionally fetch process
status, which is only needed in a few places and saves some more
shelling out to `ps`. This could be expanded in the future to support
other options while retaining backwards compatibility.

- includes some error testing funcs to let callers handle different
errors without needing to know how those errors are implemented. This
helps trap `gopsutil` implementation details in `package nginxprocess`
and makes future `gopsutil` upgrades easier.

- respects context cancellation a little faster
  • Loading branch information
ryepup committed Jan 17, 2025
1 parent 406a927 commit 766e251
Show file tree
Hide file tree
Showing 4 changed files with 398 additions and 1 deletion.
3 changes: 2 additions & 1 deletion .testcoverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ exclude:
- ^test/.*$
- app.go # app.go and main.go should be tested by integration tests.
- main.go
# Intentionally ignoring as GetProcesses & KillProcess arent being tested as they are wrappers
# ignore wrappers around gopsutil
- internal/datasource/host
- internal/watcher/process
- pkg/nginxprocess

# NOTES:
# - symbol `/` in all path regexps will be replaced by
Expand Down
20 changes: 20 additions & 0 deletions pkg/nginxprocess/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) F5, Inc.
//
// This source code is licensed under the Apache License, Version 2.0 license found in the
// LICENSE file in the root directory of this source tree.
package nginxprocess

import (
"errors"

"github.com/shirou/gopsutil/v4/process"
)

// errNotAnNginxProcess is returned when querying a process that is not an NGINX process.
var errNotAnNginxProcess = errors.New("not a NGINX process")

// IsNotNginxErr returns true if this error is due to the process not being an NGINX process.
func IsNotNginxErr(err error) bool { return errors.Is(err, errNotAnNginxProcess) }

// IsNotRunningErr returns true if this error is due to the OS process no longer running.
func IsNotRunningErr(err error) bool { return errors.Is(err, process.ErrorProcessNotRunning) }
145 changes: 145 additions & 0 deletions pkg/nginxprocess/process.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright (c) F5, Inc.
//
// This source code is licensed under the Apache License, Version 2.0 license found in the
// LICENSE file in the root directory of this source tree.

// Package nginxprocess contains utilities for working with OS-level NGINX processes.
package nginxprocess

import (
"context"
"strings"
"time"

"github.com/shirou/gopsutil/v4/process"
)

// Process contains a snapshot of read-only data about an OS-level NGINX process. Create using [List] or [Find].
type Process struct {
// Created is when this process was created, precision varies by platform and is at best to the millisecond. On
// linux there can be significant skew compared to [time.Now], ± 1s.
Created time.Time
Name string
Cmd string
Exe string // path to the executable
Status string // process status, only present if this process was created using [WithStatus]
PID int32
PPID int32 // parent PID
}

// IsWorker returns true if the process is a NGINX worker process.
func (p *Process) IsWorker() bool { return strings.HasPrefix(p.Cmd, "nginx: worker") }

// IsMaster returns true if the process is a NGINX master process.
func (p *Process) IsMaster() bool { return strings.HasPrefix(p.Cmd, "nginx: master") }

// IsShuttingDown returns true if the process is shutting down. This can identify workers that are in the process of a
// graceful shutdown. See [changing NGINX configuration] for more details.
//
// [changing NGINX configuration]: https://nginx.org/en/docs/control.html#reconfiguration
func (p *Process) IsShuttingDown() bool { return strings.Contains(p.Cmd, "is shutting down") }

// IsHealthy uses Status flags to judge process health. Only works on processes created using [WithStatus].
func (p *Process) IsHealthy() bool {
return p.Status != "" && !strings.Contains(p.Status, process.Zombie)
}

type options struct {
loadStatus bool
}

// Option customizes how processes are gathered from the OS.
type Option interface{ apply(opts *options) }

type optionFunc func(*options)

func (f optionFunc) apply(o *options) { f(o) }

// WithStatus runs an additional lookup to load the process status.
func WithStatus(v bool) Option { //nolint:ireturn // functional options can be opaque
return optionFunc(func(o *options) { o.loadStatus = v })
}

func convert(ctx context.Context, p *process.Process, o options) (*Process, error) {
if err := ctx.Err(); err != nil { // fail fast if we've canceled
return nil, err
}

name, _ := p.NameWithContext(ctx) // slow: shells out to ps
if name != "nginx" {
return nil, errNotAnNginxProcess
}

cmdLine, _ := p.CmdlineWithContext(ctx) // slow: shells out to ps
// ignore nginx processes in the middle of an upgrade
if !strings.HasPrefix(cmdLine, "nginx:") || strings.Contains(cmdLine, "upgrade") {
return nil, errNotAnNginxProcess
}

var status string
if o.loadStatus {
flags, _ := p.StatusWithContext(ctx) // slow: shells out to ps
status = strings.Join(flags, " ")
}

// unconditionally run fast lookups
var created time.Time
if millisSinceEpoch, err := p.CreateTimeWithContext(ctx); err == nil {
created = time.UnixMilli(millisSinceEpoch)
}
ppid, _ := p.PpidWithContext(ctx)
exe, _ := p.ExeWithContext(ctx)

return &Process{
PID: p.Pid,
PPID: ppid,
Name: name,
Cmd: cmdLine,
Created: created,
Status: status,
Exe: exe,
}, ctx.Err()
}

// List returns a slice of all NGINX processes. Returns a zero-length slice if no NGINX processes are found.
func List(ctx context.Context, opts ...Option) (ret []*Process, err error) {
o := options{}
for _, opt := range opts {
opt.apply(&o)
}
processes, err := process.ProcessesWithContext(ctx)
if err != nil {
return nil, err
}
for _, p := range processes {
pr, cerr := convert(ctx, p, o)
if IsNotNginxErr(cerr) {
continue
}
if cerr != nil {
return nil, cerr
}
ret = append(ret, pr)
}

return ret, nil
}

// Find returns a single NGINX process by PID. Returns an error if the PID is no longer running or if it is not an NGINX
// process. Use with [IsProcessNotRunningErr] and [IsNotNginxErr].
func Find(ctx context.Context, pid int32, opts ...Option) (*Process, error) {
o := options{}
for _, opt := range opts {
opt.apply(&o)
}
p, err := process.NewProcessWithContext(ctx, pid)
if err != nil {
return nil, err
}
pr, err := convert(ctx, p, o)
if err != nil {
return nil, err
}

return pr, nil
}
Loading

0 comments on commit 766e251

Please sign in to comment.