В текущей системе программного обеспечения развертывание новой версии или изменение некоторой информации о конфигурации без отключения службы стало необходимым требованием. Здесь мы представляем различные методы корректного перезапуска приложений и используем несколько примеров, чтобы разобраться в деталях. Здесь мы начнем с представления Teleport. Teleport предназначен для контроля разрешений Kubernetes. Для тех, кто не знаком, вы можете проверить эту ссылку https://gravitational.com/teleport/.
SO_REUSERPORT vs Duplicating Sockets:
Чтобы сделать Teleport более доступным, мы недавно потратили некоторое время на то, как изящно перезапустить прослушиватели TLS и SSH Teleport.Наша цель — обновить пакеты Teleport без создания нового экземпляра.
В этой статье представлены два общих метода реализации, https://blog.cloudflare.com/the-sad-state-of-linux-socket-balancing, метод, вероятно, будет таким:
》Вы можете установить SO_REUSERPORT при использовании сокета, что позволяет нескольким процессам привязываться к одному и тому же порту.При использовании этого метода каждый процесс имеет соответствующую очередь приема и обработки.
》Вы также можете повторно использовать сокеты и использовать их, передавая их дочерним процессам.Таким образом, несколько процессов совместно используют очередь приема.
Есть некоторые негативные последствия для SO_REUSERPORT.Во-первых, наши инженеры уже использовали этот метод ранее.Этот метод множественных очередей приема иногда приводит к прерыванию tcp-соединений. Кроме того, в Go непросто установить параметр SO_REUSERPORT.
Второй метод более привлекателен, поскольку большинство разработчиков знакомы с его простой моделью unix fork/exec. Таким образом, все файловые дескрипторы могут быть переданы дочернему процессу, но пакет os/exec в go в настоящее время не позволяет этого.Возможно, из-за проблем с безопасностью дочернему процессу могут быть переданы только stdin stdou и stderr. Но в пакете os есть пакеты более низкого уровня, которые передают все файловые дескрипторы дочернему процессу, и это то, что мы собираемся сделать.
Сигналы управления процессом переключения:
Прежде чем говорить об официальном исходном коде, давайте поговорим о деталях того, как работает этот метод.
Слушатель сокета создается при запуске нового процесса Teleport, который будет получать весь трафик, отправленный на порт назначения. Мы добавляем обработчик сигнала для обработки SIGUSR2, этот сигнал может заставить Teleport скопировать сокет lisenter, а затем передать информацию о метаданных дескриптора файла и его переменных среды для запуска нового процесса. После запуска нового процесса используйте ранее переданный элемент описания файла, чтобы начать изменение сокета и запустить трафик.
Здесь следует отметить, что после мультиплексирования сокетов два сокета циклически балансируются для обработки трафика, подробнее см. рисунок ниже. Это означает, что процесс Teleport время от времени будет принимать новые соединения.
Рисунок 1: Teleport может мультиплексировать себя и совместно передавать данные с другими мультиплексированными процессами
Родительский процесс (PID2)) закрывается таким же образом, только в обратном порядке. Как только процесс Teleport получит сигнал SIGOUT, он начнет закрывать процесс.Его процесс: сначала прекратить получение новых соединений, а затем дождаться завершения всех соединений. Затем родительский процесс закроет свой собственный сокет слушателя и завершит работу. Теперь ядро отправляет трафик только новому процессу.
Пример:
Мы написали небольшое приложение, используя этот подход. Исходный код внизу. Сначала скомпилируем и запустим приложение:
$ go build restart.go
$ ./restart &
[1] 95147
$ Created listener file descriptor for :8080.
$ curl http://localhost:8080/hello
Hello from 95147!
Отправьте сигнал USR2 исходному процессу, теперь, когда вы нажмете отправить http-запрос, будут возвращены номера pid двух процессов:
$ kill -SIGUSR2 95147
user defined signal 2 signal received.
Forked child 95170.
$ Imported listener file descriptor for :8080.
$ curl http://localhost:8080/hello
Hello from 95170!
$ curl http://localhost:8080/hello
Hello from 95147!
убейте исходный процесс, и вы обнаружите, что он возвращает новый номер pid:
$ kill -SIGTERM 95147
signal: killed
[1]+ Exit 1 go run restart.go
$ curl http://localhost:8080/hello
Hello from 95170!
$ curl http://localhost:8080/hello
Hello from 95170!
Наконец, убить новый процесс и не убивай весь процесс.
$ kill -SIGTERM 95170
$ curl http://localhost:8080/hello
curl: (7) Failed to connect to localhost port 8080: Connection refused
Как видите, как только вы поймете, как это работает, вы легко сможете написать службу изящного перезапуска на ходу, и это может значительно повысить эффективность вашей службы.
Golang Graceful Restart Source Example
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
)
type listener struct {
Addr string `json:"addr"`
FD int `json:"fd"`
Filename string `json:"filename"`
}
func importListener(addr string) (net.Listener, error) {
// Extract the encoded listener metadata from the environment.
listenerEnv := os.Getenv("LISTENER")
if listenerEnv == "" {
return nil, fmt.Errorf("unable to find LISTENER environment variable")
}
// Unmarshal the listener metadata.
var l listener
err := json.Unmarshal([]byte(listenerEnv), &l)
if err != nil {
return nil, err
}
if l.Addr != addr {
return nil, fmt.Errorf("unable to find listener for %v", addr)
}
// The file has already been passed to this process, extract the file
// descriptor and name from the metadata to rebuild/find the *os.File for
// the listener.
listenerFile := os.NewFile(uintptr(l.FD), l.Filename)
if listenerFile == nil {
return nil, fmt.Errorf("unable to create listener file: %v", err)
}
defer listenerFile.Close()
// Create a net.Listener from the *os.File.
ln, err := net.FileListener(listenerFile)
if err != nil {
return nil, err
}
return ln, nil
}
func createListener(addr string) (net.Listener, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
return ln, nil
}
func createOrImportListener(addr string) (net.Listener, error) {
// Try and import a listener for addr. If it's found, use it.
ln, err := importListener(addr)
if err == nil {
fmt.Printf("Imported listener file descriptor for %v.\n", addr)
return ln, nil
}
// No listener was imported, that means this process has to create one.
ln, err = createListener(addr)
if err != nil {
return nil, err
}
fmt.Printf("Created listener file descriptor for %v.\n", addr)
return ln, nil
}
func getListenerFile(ln net.Listener) (*os.File, error) {
switch t := ln.(type) {
case *net.TCPListener:
return t.File()
case *net.UnixListener:
return t.File()
}
return nil, fmt.Errorf("unsupported listener: %T", ln)
}
func forkChild(addr string, ln net.Listener) (*os.Process, error) {
// Get the file descriptor for the listener and marshal the metadata to pass
// to the child in the environment.
lnFile, err := getListenerFile(ln)
if err != nil {
return nil, err
}
defer lnFile.Close()
l := listener{
Addr: addr,
FD: 3,
Filename: lnFile.Name(),
}
listenerEnv, err := json.Marshal(l)
if err != nil {
return nil, err
}
// Pass stdin, stdout, and stderr along with the listener to the child.
files := []*os.File{
os.Stdin,
os.Stdout,
os.Stderr,
lnFile,
}
// Get current environment and add in the listener to it.
environment := append(os.Environ(), "LISTENER="+string(listenerEnv))
// Get current process name and directory.
execName, err := os.Executable()
if err != nil {
return nil, err
}
execDir := filepath.Dir(execName)
// Spawn child process.
p, err := os.StartProcess(execName, []string{execName}, &os.ProcAttr{
Dir: execDir,
Env: environment,
Files: files,
Sys: &syscall.SysProcAttr{},
})
if err != nil {
return nil, err
}
return p, nil
}
func waitForSignals(addr string, ln net.Listener, server *http.Server) error {
signalCh := make(chan os.Signal, 1024)
signal.Notify(signalCh, syscall.SIGHUP, syscall.SIGUSR2, syscall.SIGINT, syscall.SIGQUIT)
for {
select {
case s := <-signalCh:
fmt.Printf("%v signal received.\n", s)
switch s {
case syscall.SIGHUP:
// Fork a child process.
p, err := forkChild(addr, ln)
if err != nil {
fmt.Printf("Unable to fork child: %v.\n", err)
continue
}
fmt.Printf("Forked child %v.\n", p.Pid)
// Create a context that will expire in 5 seconds and use this as a
// timeout to Shutdown.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Return any errors during shutdown.
return server.Shutdown(ctx)
case syscall.SIGUSR2:
// Fork a child process.
p, err := forkChild(addr, ln)
if err != nil {
fmt.Printf("Unable to fork child: %v.\n", err)
continue
}
// Print the PID of the forked process and keep waiting for more signals.
fmt.Printf("Forked child %v.\n", p.Pid)
case syscall.SIGINT, syscall.SIGQUIT:
// Create a context that will expire in 5 seconds and use this as a
// timeout to Shutdown.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Return any errors during shutdown.
return server.Shutdown(ctx)
}
}
}
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from %v!\n", os.Getpid())
}
func startServer(addr string, ln net.Listener) *http.Server {
http.HandleFunc("/hello", handler)
httpServer := &http.Server{
Addr: addr,
}
go httpServer.Serve(ln)
return httpServer
}
func main() {
// Parse command line flags for the address to listen on.
var addr string
flag.StringVar(&addr, "addr", ":8080", "Address to listen on.")
// Create (or import) a net.Listener and start a goroutine that runs
// a HTTP server on that net.Listener.
ln, err := createOrImportListener(addr)
if err != nil {
fmt.Printf("Unable to create or import a listener: %v.\n", err)
os.Exit(1)
}
server := startServer(addr, ln)
// Wait for signals to either fork or quit.
err = waitForSignals(addr, ln, server)
if err != nil {
fmt.Printf("Exiting: %v\n", err)
return
}
fmt.Printf("Exiting.\n")
}
Примечание: golang1.8 и выше, поскольку корректное завершение работы server.shutdown — это функция, добавленная в версии 1.8.
Английский оригинал: https://gravitational.com/blog/golang-ssh-bastion-graceful-restarts/