mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
refactoring process management: no more orphan processes and quicker chrome startup
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user