refactoring process management: no more orphan processes and quicker chrome startup

This commit is contained in:
Julien Neuhart
2019-10-02 10:38:47 +02:00
parent c47dc5ecdb
commit 7ac104c3c6
8 changed files with 187 additions and 137 deletions

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -28,9 +27,8 @@ func main() {
systemLogger.InfofOp(op, "Gotenberg %s", version) systemLogger.InfofOp(op, "Gotenberg %s", version)
systemLogger.DebugfOp(op, "configuration: %+v", config) systemLogger.DebugfOp(op, "configuration: %+v", config)
if !config.DisableGoogleChrome() { if !config.DisableGoogleChrome() {
// start Google Chrome. // start Google Chrome headless.
_, err = chrome.Start(context.Background(), systemLogger) if err := chrome.Start(systemLogger); err != nil {
if err != nil {
systemLogger.FatalOp(op, err) systemLogger.FatalOp(op, err)
} }
} }

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"os" "os"
"os/exec" "os/exec"
"strings"
"syscall"
"time" "time"
"github.com/mafredri/cdp/devtool" "github.com/mafredri/cdp/devtool"
@@ -13,66 +15,26 @@ import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime" "github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
) )
func Start(ctx context.Context, logger xlog.Logger) (*os.Process, error) { // Start starts Google Chrome headless in background.
func Start(logger xlog.Logger) error {
const op string = "chrome.Start" const op string = "chrome.Start"
logger.DebugOp(op, "starting new Google Chrome process on port 9222...") logger.DebugOp(op, "starting new Google Chrome headless process on port 9222...")
resolver := func() (*os.Process, error) { resolver := func() error {
binary := "google-chrome-stable" cmd, err := cmd(logger)
args := []string{
"--no-sandbox",
"--headless",
// see https://github.com/GoogleChrome/puppeteer/issues/2410.
"--font-render-hinting=medium",
"--remote-debugging-port=9222",
"--disable-gpu",
"--disable-translate",
"--disable-extensions",
"--disable-background-networking",
"--safebrowsing-disable-auto-update",
"--disable-sync",
"--disable-default-apps",
"--hide-scrollbars",
"--metrics-recording-only",
"--mute-audio",
"--no-first-run",
}
cmd, err := xexec.CommandContext(ctx, logger, binary, args...)
if err != nil { if err != nil {
return nil, err return err
} }
// we try to start the process. // we try to start the process.
xexec.LogBeforeExecute(logger, cmd) xexec.LogBeforeExecute(logger, cmd)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return cmd.Process, err return err
} }
// we wait the process to be ready.
warmup(logger)
// if the process failed to start correctly, // if the process failed to start correctly,
// we have to restart it. // we have to restart it.
if !isViable(logger) { if !isViable(logger) {
return restart(ctx, logger, cmd) return restart(logger, cmd.Process)
} }
return cmd.Process, nil return nil
}
proc, err := resolver()
if err != nil {
if errKill := Kill(logger, proc); errKill != nil {
logger.ErrorOp(op, errKill)
}
return nil, xerror.New(op, err)
}
return proc, nil
}
func Kill(logger xlog.Logger, proc *os.Process) error {
const op string = "chrome.Kill"
resolver := func() error {
logger.DebugOp(op, "removing Google Chrome process using port 9222...")
if proc == nil {
logger.DebugOp(op, "no Google Chrome process using port 9222 found, skipping")
return nil
}
return proc.Kill()
} }
if err := resolver(); err != nil { if err := resolver(); err != nil {
return xerror.New(op, err) return xerror.New(op, err)
@@ -80,60 +42,125 @@ func Kill(logger xlog.Logger, proc *os.Process) error {
return nil return nil
} }
func restart(ctx context.Context, logger xlog.Logger, cmd *exec.Cmd) (*os.Process, error) { func cmd(logger xlog.Logger) (*exec.Cmd, error) {
const op string = "chrome.cmd"
binary := "google-chrome-stable"
args := []string{
"--no-sandbox",
"--headless",
// see https://github.com/GoogleChrome/puppeteer/issues/2410.
"--font-render-hinting=medium",
"--remote-debugging-port=9222",
"--disable-gpu",
"--disable-translate",
"--disable-extensions",
"--disable-background-networking",
"--safebrowsing-disable-auto-update",
"--disable-sync",
"--disable-default-apps",
"--hide-scrollbars",
"--metrics-recording-only",
"--mute-audio",
"--no-first-run",
}
cmd, err := xexec.Command(logger, binary, args...)
if err != nil {
return nil, xerror.New(op, err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd, nil
}
func kill(logger xlog.Logger, proc *os.Process) error {
const op string = "chrome.kill"
logger.DebugOp(op, "killing Google Chrome headless process using port 9222...")
resolver := func() error {
err := syscall.Kill(-proc.Pid, syscall.SIGKILL)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "no such process") {
return nil
}
return err
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
func restart(logger xlog.Logger, proc *os.Process) error {
const op string = "chrome.restart" const op string = "chrome.restart"
resolver := func() (*os.Process, error) { logger.DebugOp(op, "restarting Google Chrome headless process using port 9222...")
resolver := func() error {
// kill the existing process first.
if err := kill(logger, proc); err != nil {
return err
}
cmd, err := cmd(logger)
if err != nil {
return err
}
// we try to restart the process. // we try to restart the process.
xexec.LogBeforeExecute(logger, cmd) xexec.LogBeforeExecute(logger, cmd)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return cmd.Process, err return err
} }
// we wait the process to be ready.
warmup(logger)
// if the process failed to restart correctly, // if the process failed to restart correctly,
// we have to restart it again. // we have to restart it again.
if !isViable(logger) { if !isViable(logger) {
return restart(ctx, logger, cmd) return restart(logger, cmd.Process)
} }
return cmd.Process, nil return nil
} }
proc, err := resolver() if err := resolver(); err != nil {
if err != nil { return xerror.New(op, err)
return proc, xerror.New(op, err)
} }
return proc, err return nil
} }
func isViable(logger xlog.Logger) bool { func isViable(logger xlog.Logger) bool {
const op string = "chrome.isViable" const (
ctx, cancel := context.WithCancel(context.Background()) op string = "chrome.isViable"
defer cancel() maxViabilityTests int = 5
endpoint := "http://localhost:9222"
logger.DebugfOp(
op,
"checking Google Chrome process viability via endpoint '%s/json/version'",
endpoint,
) )
v, err := devtool.New(endpoint).Version(ctx) viable := func() bool {
if err != nil { ctx, cancel := context.WithCancel(context.Background())
logger.ErrorfOp( defer cancel()
endpoint := "http://localhost:9222"
logger.DebugfOp(
op, op,
"Google Chrome is not viable as endpoint returned '%v'", "checking Google Chrome process viability via endpoint '%s/json/version'",
err, endpoint,
) )
return false v, err := devtool.New(endpoint).Version(ctx)
if err != nil {
logger.ErrorfOp(
op,
"Google Chrome is not viable as endpoint returned '%v'",
err,
)
return false
}
logger.DebugfOp(
op,
"Google Chrome is viable as endpoint returned '%v'",
v,
)
return true
} }
logger.DebugfOp( result := false
op, for i := 0; i < maxViabilityTests && !result; i++ {
"Google Chrome is viable as endpoint returned '%v'", warmup(logger)
v, result = viable()
) }
return true return result
} }
func warmup(logger xlog.Logger) { func warmup(logger xlog.Logger) {
const op string = "chrome.warmup" const op string = "chrome.warmup"
warmupTime := xtime.Duration(10) warmupTime := xtime.Duration(0.5)
logger.DebugfOp( logger.DebugfOp(
op, op,
"waiting '%v' for allowing Google Chrome to warmup", "waiting '%v' for allowing Google Chrome to warmup",

View File

@@ -59,12 +59,7 @@ func (p mergePrinter) Print(destination string) error {
var args []string var args []string
args = append(args, p.fpaths...) args = append(args, p.fpaths...)
args = append(args, "cat", "output", destination) args = append(args, "cat", "output", destination)
cmd, err := xexec.CommandContext(p.ctx, p.logger, "pdftk", args...) return xexec.Run(p.ctx, p.logger, "pdftk", args...)
if err != nil {
return err
}
xexec.LogBeforeExecute(p.logger, cmd)
return cmd.Run()
} }
if err := resolver(); err != nil { if err := resolver(); err != nil {
return xcontext.MustHandleError( return xcontext.MustHandleError(

View File

@@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"syscall"
"github.com/phayes/freeport" "github.com/phayes/freeport"
"github.com/thecodingmachine/gotenberg/internal/pkg/conf" "github.com/thecodingmachine/gotenberg/internal/pkg/conf"
@@ -106,43 +104,7 @@ func unoconv(ctx context.Context, logger xlog.Logger, fpath, destination string,
args = append(args, "--printer", "PaperOrientation=landscape") args = append(args, "--printer", "PaperOrientation=landscape")
} }
args = append(args, "--output", destination, fpath) args = append(args, "--output", destination, fpath)
cmd, err := xexec.Command( return xexec.Run(ctx, logger, "unoconv", args...)
logger,
"unoconv",
args...,
)
if err != nil {
return err
}
xexec.LogBeforeExecute(logger, cmd)
// see https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
kill := func() {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if err == nil {
return
}
if !strings.Contains(err.Error(), "no such process") {
logger.ErrorOp(op, err)
}
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return err
}
result := make(chan error, 1)
go func() {
result <- cmd.Wait()
}()
select {
case err := <-result:
logger.DebugfOp(op, "command '%s' finished", strings.Join(cmd.Args, " "))
kill()
return err
case <-ctx.Done():
logger.DebugfOp(op, "command '%s' failed to finish before context.Context deadline", strings.Join(cmd.Args, " "))
kill()
return ctx.Err()
}
} }
if err := resolver(); err != nil { if err := resolver(); err != nil {
return xerror.New(op, err) return xerror.New(op, err)

View File

@@ -1,6 +1,7 @@
/* /*
Package xexec helps creating exec.Cmd Package xexec helps creating exec.Cmd
with logging. with logging and executing those commands
without leaking orphan processes.
All functions return our standard xerror.Error All functions return our standard xerror.Error
in case of error. in case of error.

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"os/exec" "os/exec"
"strings" "strings"
"syscall"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror" "github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog" "github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
@@ -43,6 +44,61 @@ func CommandContext(ctx context.Context, logger xlog.Logger, binary string, args
return cmd, nil return cmd, nil
} }
/*
Run runs a command.
If command finishes or fails to finish
before context.Context deadline, kill the
corresponding process in a way which does
not leak orphan processes.
*/
func Run(ctx context.Context, logger xlog.Logger, binary string, args ...string) error {
const op string = "xexec.Run"
resolver := func() error {
cmd, err := Command(
logger,
binary,
args...,
)
if err != nil {
return err
}
LogBeforeExecute(logger, cmd)
// see https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
kill := func() {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if err == nil {
return
}
if !strings.Contains(err.Error(), "no such process") {
logger.ErrorOp(op, err)
}
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return err
}
result := make(chan error, 1)
go func() {
result <- cmd.Wait()
}()
select {
case err := <-result:
logger.DebugfOp(op, "command '%s' finished", strings.Join(cmd.Args, " "))
kill()
return err
case <-ctx.Done():
logger.DebugfOp(op, "command '%s' failed to finish before context.Context deadline", strings.Join(cmd.Args, " "))
kill()
return ctx.Err()
}
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
// LogBeforeExecute logs a command before its execution. // LogBeforeExecute logs a command before its execution.
func LogBeforeExecute(logger xlog.Logger, cmd *exec.Cmd) { func LogBeforeExecute(logger xlog.Logger, cmd *exec.Cmd) {
const op string = "xexec.LogBeforeExecute" const op string = "xexec.LogBeforeExecute"
@@ -87,7 +143,7 @@ func logCommandOutput(logger xlog.Logger, reader io.ReadCloser, outputType strin
for { for {
line, _, err := r.ReadLine() line, _, err := r.ReadLine()
if err != nil { if err != nil {
if err != io.EOF { if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
logger.ErrorOp(op, err) logger.ErrorOp(op, err)
} }
break break

View File

@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/test" "github.com/thecodingmachine/gotenberg/test"
) )
@@ -37,3 +38,16 @@ func TestCommandContext(t *testing.T) {
LogBeforeExecute(logger, cmd) LogBeforeExecute(logger, cmd)
assert.Nil(t, err) assert.Nil(t, err)
} }
func TestRun(t *testing.T) {
logger := test.DebugLogger()
// should run without issue.
err := Run(context.Background(), logger, "echo", "Hello", "World")
assert.Nil(t, err)
// should not be OK as context.Context
// should timeout.
ctx, cancel := xcontext.WithTimeout(logger, 0)
defer cancel()
err = Run(ctx, logger, "echo", "Hello", "World")
assert.NotNil(t, err)
}

View File

@@ -1,8 +1,6 @@
package main package main
import ( import (
"context"
"github.com/thecodingmachine/gotenberg/internal/pkg/chrome" "github.com/thecodingmachine/gotenberg/internal/pkg/chrome"
"github.com/thecodingmachine/gotenberg/internal/pkg/conf" "github.com/thecodingmachine/gotenberg/internal/pkg/conf"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog" "github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
@@ -15,9 +13,8 @@ func main() {
if err != nil { if err != nil {
systemLogger.FatalOp(op, err) systemLogger.FatalOp(op, err)
} }
// start Google Chrome. // start Google Chrome headless.
_, err = chrome.Start(context.Background(), systemLogger) if err := chrome.Start(systemLogger); err != nil {
if err != nil {
systemLogger.FatalOp(op, err) systemLogger.FatalOp(op, err)
} }
} }