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,10 +15,35 @@ 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 {
cmd, err := cmd(logger)
if err != nil {
return err
}
// we try to start the process.
xexec.LogBeforeExecute(logger, cmd)
if err := cmd.Start(); err != nil {
return err
}
// if the process failed to start correctly,
// we have to restart it.
if !isViable(logger) {
return restart(logger, cmd.Process)
}
return nil
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
func cmd(logger xlog.Logger) (*exec.Cmd, error) {
const op string = "chrome.cmd"
binary := "google-chrome-stable" binary := "google-chrome-stable"
args := []string{ args := []string{
"--no-sandbox", "--no-sandbox",
@@ -36,43 +63,26 @@ func Start(ctx context.Context, logger xlog.Logger) (*os.Process, error) {
"--mute-audio", "--mute-audio",
"--no-first-run", "--no-first-run",
} }
cmd, err := xexec.CommandContext(ctx, logger, binary, args...) cmd, err := xexec.Command(logger, binary, args...)
if err != nil { if err != nil {
return nil, err
}
// we try to start the process.
xexec.LogBeforeExecute(logger, cmd)
if err := cmd.Start(); err != nil {
return cmd.Process, err
}
// we wait the process to be ready.
warmup(logger)
// if the process failed to start correctly,
// we have to restart it.
if !isViable(logger) {
return restart(ctx, logger, cmd)
}
return cmd.Process, 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 nil, xerror.New(op, err)
} }
return proc, nil cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd, nil
} }
func Kill(logger xlog.Logger, proc *os.Process) error { func kill(logger xlog.Logger, proc *os.Process) error {
const op string = "chrome.Kill" const op string = "chrome.kill"
logger.DebugOp(op, "killing Google Chrome headless process using port 9222...")
resolver := func() error { resolver := func() error {
logger.DebugOp(op, "removing Google Chrome process using port 9222...") err := syscall.Kill(-proc.Pid, syscall.SIGKILL)
if proc == nil { if err == nil {
logger.DebugOp(op, "no Google Chrome process using port 9222 found, skipping")
return nil return nil
} }
return proc.Kill() if strings.Contains(err.Error(), "no such process") {
return nil
}
return err
} }
if err := resolver(); err != nil { if err := resolver(); err != nil {
return xerror.New(op, err) return xerror.New(op, err)
@@ -80,32 +90,42 @@ 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 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 (
op string = "chrome.isViable"
maxViabilityTests int = 5
)
viable := func() bool {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
endpoint := "http://localhost:9222" endpoint := "http://localhost:9222"
@@ -130,10 +150,17 @@ func isViable(logger xlog.Logger) bool {
) )
return true return true
} }
result := false
for i := 0; i < maxViabilityTests && !result; i++ {
warmup(logger)
result = viable()
}
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)
} }
} }