RE:NODE

Go web სერვისის deploy: net/http, chi და gin production-ში

ავტორი RE:NODE-ის გუნდი
18 წუთის საკითხავი


Go web სერვისი ამ კატეგორიაში გასაშვებად ყველაზე მარტივია და როცა ცუდად მუშაობს, თავის გასამართლებელიც ყველაზე ცოტა აქვს. სტანდარტული ბიბლიოთეკა უკვე შეიცავს production-ის დონის HTTP სერვერს, შედეგი ერთი სტატიკურად დაკავშირებული binary-ია, უქმე სერვისი კი რამდენიმე ათეულ მეგაბაიტს იკავებს. ცუდად მუშაობის მიზეზი თითქმის ყოველთვის ხუთი პატარა რამეა: ის 127.0.0.1-ზე ეკვრის, პორტი კოდშია ჩაწერილი, SIGTERM-ს უგულებელყოფს, timeout-ები არ აქვს, ან garbage collector-ს უშვებ, რომ container-ის მეხსიერების ლიმიტს გადააჭარბოს.

ეს პოსტი ამ ხუთივეს მოიცავს, ასევე იმას, რაც კონკრეტულად app სერვერზე deploy-ს ეხება, სადაც შენს კოდს თავად სერვერი აკომპილირებს: რას ამბობს go.mod, რა ხდება ყოველ გაშვებაზე, როგორ ჯდება GitHub deploy, რას უგზავნის proxy შენს handler-ებს, რომელ ბაზებს მიუთითო და რა წაიკითხო, როცა კონსოლში შეცდომა ჩანს.

რა სჭირდება Go სერვისს გაშვებისას#

თითქმის არაფერი, და ეს არის მისი მთავარი მომხიბვლელობა. go build შენს კოდს, დამოკიდებულებებსა და Go runtime-ს ერთ გამშვებ ფაილად აერთიანებს. სერვერზე არ არის არც interpreter, არც virtual environment, არც node_modules. runtime-ს საკუთარი scheduler და garbage collector მოაქვს, ამიტომ Go პროცესი ერთი binary-ა და გარემოს ცვლადების ნაკრები.

ორი დეტალი ღირს, რომ წინასწარ იცოდე, სანამ გაგაკვირვებს:

  • Cgo სურათს ცვლის. CGO_ENABLED=1-ის დროს (ნაგულისხმევია, როცა C compiler არსებობს) შენი binary შეიძლება glibc-თან დინამიკურად დაკავშირდეს, ხოლო ისეთ პაკეტებს, როგორიც mattn/go-sqlite3-ია, build-ის დროს C toolchain სჭირდება. CGO_ENABLED=0-ის დროს binary სრულად სტატიკურია და ასეთი დამოკიდებულება არ აქვს. თუ შენს ბიბლიოთეკას სუფთა Go-ზე დაწერილი ალტერნატივა აქვს - modernc.org/sqlite go-sqlite3-ის ნაცვლად, pgx ნებისმიერი libpq-ზე დაფუძნებულის ნაცვლად - სერვერზე, რომელსაც არ აკონტროლებ, ის ამჯობინე.
  • დროის ზონები და სერთიფიკატები. მინიმალურ გარემოში შეიძლება ზონების მონაცემები და CA bundle არ იყოს. თუ შენი პროგრამა time.LoadLocation("Europe/Berlin")-ს იძახებს ან გამავალ HTTPS მოთხოვნებს აკეთებს, დაამატე time/tzdata import, რომ ზონების ბაზა binary-ში ჩაიდოს. გამავალ TLS-ს სისტემის root სერთიფიკატები სჭირდება, ამიტომ პირველივე deploy-ზე შეამოწმე, მუშაობს თუ არა გამავალი ზარი, და ეს ისედაც ნაგულისხმევად ნუ მიიჩნევ.

მეხსიერება ის რიცხვია, რომლის გარშემოც უნდა დაგეგმო. პატარა net/http სერვისი ბაზის pool-ით უქმე რეჟიმში ჩვეულებრივ 10-დან 30 MB-მდე რეზიდენტულ მეხსიერებას იკავებს და იზრდება პარალელური მოთხოვნებისა და buffer-ების მიხედვით, არა კოდის ზომის. ამიტომაა ყველაზე პატარა app ტარიფი უმეტესი Go სერვისისთვის გონივრული სახლი, და ამიტომ სჭირდება დროდადრო მარაგი build-ს და არა გაშვებულ პროცესს.

net/http, chi თუ gin#

დაიწყე სტანდარტული ბიბლიოთეკით. Go 1.22-დან ნაგულისხმევი ServeMux მეთოდებსა და path wildcard-ებს იგებს, რამაც router-ის აღების მთავარი მიზეზი მოხსნა:

main.go
mux := http.NewServeMux()mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {    w.Write([]byte("ok"))})mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {    id := r.PathValue("id")    fmt.Fprintf(w, "user %s\n", id)})

chi აიღე, როცა middleware-ების კომპოზიცია და route-ების ჯგუფები გინდა, ისე რომ სტანდარტული http.Handler ინტერფეისი შენარჩუნდეს. net/http-ისთვის დაწერილი ყველაფერი chi-ში უცვლელად მუშაობს, ამიტომ მოგვიანებით გადასვლით არაფერს კარგავ:

go
r := chi.NewRouter()r.Use(middleware.RequestID, middleware.Recoverer)r.Route("/api", func(r chi.Router) {    r.Get("/health", health)    r.Get("/users/{id}", getUser)})

gin აიღე, როცა მისი binding და validation დამხმარეები და მზა middleware-ების ეკოსისტემა გინდა. მას საკუთარი context ტიპი აქვს, ამიტომ handler-ები gin-ზე ებმება. production-ში გარემოში დააყენე GIN_MODE=release; ნაგულისხმევი debug რეჟიმი გაშვებისას ყველა route-ს ბეჭდავს და იმაზე მეტს ლოგავს, ვიდრე გჭირდება:

env
GIN_MODE=release

სამიდან არცერთი არ არის ისე უფრო სწრაფი, რომ პატარა ტარიფზე მნიშვნელობა ჰქონდეს. მათ შორის სხვაობა ბაზასთან ერთ round trip-ზე ნაკლებია. აირჩიე იმის მიხედვით, როგორ გინდა handler-ებისა და middleware-ის წერა, ხოლო დაზოგილი ენერგია timeout-ებსა და shutdown-ს მოახმარე - ამას არც ერთი router შენ მაგივრად არ გააკეთებს.

go.mod, go.sum და build გაშვებისას#

RE:NODE-ის Go ხაზზე სერვერი ყოველ გაშვებაზე go build-ით აიგება. ამ ერთ წინადადებას პროექტის სტრუქტურაზე გავლენა აქვს, ამიტომ ეს შეგნებულად უნდა გააკეთო.

პირველი, go.mod დეკორაცია არ არის. ის მოდულს ასახელებს, მინიმალურ Go ვერსიას განსაზღვრავს და ყველა დამოკიდებულებას ვერსიით ჩამოთვლის:

go.mod
module github.com/yourname/apigo 1.23require (    github.com/go-chi/chi/v5 v5.1.0    github.com/jackc/pgx/v5 v5.7.1)

დააკომიტე როგორც go.mod, ისე go.sum. მეორე ყველა მოდულის ვერსიის checksum-ებს ინახავს და მის გარეშე build-ს ყველაფრის თავიდან გარკვევა და გადამოწმება უწევს. ყოველ commit-მდე გაუშვი go mod tidy, რომ გამოუყენებელი მოთხოვნები ამოვარდეს და ნაკლული დაემატოს; go.mod, რომელიც შენს import-ებს არ ემთხვევა, ყველაზე გავრცელებული მიზეზია იმისა, რომ build შენს ლეპტოპზე მუშაობს, სერვერზე კი ვარდება.

მეორე, go ხაზს მნიშვნელობა აქვს. თუ ის სერვერზე არსებულ toolchain-ზე ახალ ვერსიას ასახელებს, Go ხელსაწყომ შეიძლება ახალი toolchain-ის ჩამოტვირთვა სცადოს ან build-ზე უარი თქვას, toolchain დირექტივისა და GOTOOLCHAIN პარამეტრის მიხედვით. go 1.25 ნუ დაწერ მხოლოდ იმიტომ, რომ შენთან ეს დგას; მიუთითე ყველაზე დაბალი ვერსია, რომელიც შენს კოდს ნამდვილად სჭირდება. სანამ ახალ ენობრივ შესაძლებლობაზე დაეყრდნობი, შეამოწმე, რომელ Go ვერსიას გთავაზობს ეს ხაზი.

მესამე, ყოველი გაშვება კომპილაციას ნიშნავს. ეს უპირატესობაცაა - binary ყოველთვის სერვერზე არსებულ წყაროს ემთხვევა - და ფასიც, რადგან restart მყისიერი არ არის. პატარა სერვისი თბილი build cache-ით წამებში აიგება, ცივით კი წუთში ან მეტში. restart deploy-დ მიიჩნიე: ის build-ის დროს, პლუს იმ დროს მოითხოვს, რაც შენს სერვისს კავშირების გასახსნელად სჭირდება.

მეოთხე, კომპილაციის შეცდომები გაშვების შეცდომებად იქცევა. თუ კოდი არ აიგება, სერვერი არ ეშვება და compiler-ის შეტყობინება კონსოლშია. ეს ნახევრად მომუშავე სერვისზე უკეთესი ჩავარდნაა, მაგრამ ნიშნავს, რომ ცუდი commit იმ branch-ზე, რომელსაც სერვერი მიჰყვება, სერვისს გააჩერებს. push-მდე ლოკალურად გაუშვი:

bash
$ go mod tidy$ go vet ./...$ go test ./...$ go build -o /tmp/api .

main პაკეტი იქ დატოვე, სადაც ხაზი მას ელოდება. უმარტივესი სტრუქტურაა main.go repository-ის root-ში; თუ cmd/api/main.go გირჩევნია, Startup ჩანართზე ნახე, რას აგებს Go ხაზი, და პირველ deploy-მდე დარწმუნდი, რომ ის შენს main პაკეტზე მიუთითებს.

პორტები, binding და გარემოს ცვლადები#

აქ ორი პარამეტრია და ორივეს ნაგულისხმევი მნიშვნელობა container-ში არასწორია.

პირველი bind მისამართია. localhost:8080 და 127.0.0.1:8080 მხოლოდ loopback ინტერფეისზე უსმენს, რომელსაც container-ის გარედან ვერაფერი წვდება. გამოიყენე 0.0.0.0 ან host-ი ცარიელი დატოვე (":8080"), რაც ყველა ინტერფეისზე უსმენს.

მეორე პორტია. ნუ ჩაწერ მას კოდში. გამოყოფილი პორტი გარემოს ცვლადად მოდის, ხოლო პანელის Startup ჩანართი გვიჩვენებს ცვლადებს, რომლებიც შენს სერვერს აქვს. წაიკითხე სახელი, რომელიც იქ ჩანს; ქვემოთ მოცემული მაგალითი ჯერ PORT-ს ცდის, შემდეგ SERVER_PORT-ს, ბოლოს კი development-ის ნაგულისხმევს, ასე რომ იგივე binary მუშაობს როგორც შენს ლეპტოპზე, ისე სერვერზე:

main.go
func port() string {    for _, name := range []string{"PORT", "SERVER_PORT"} {        if v := os.Getenv(name); v != "" {            return v        }    }    return "8080"}func requireEnv(name string) string {    v := os.Getenv(name)    if v == "" {        log.Fatalf("missing required environment variable %s", name)    }    return v}

ყოველი ცვლადი ერთხელ, გაშვებისას წაიკითხე და თუ აუცილებელი აკლია, მაშინვე გაჩერდი. სერვისი, რომელიც DATABASE_URL-ის არარსებობას დილის სამ საათზე იშვიათ კოდის გზაზე აღმოაჩენს, გამართვისთვის უფრო რთულია, ვიდრე ის, რომელიც ეშვებაზე უარს ამბობს. Startup ჩანართზევე ამატებ საკუთარ ცვლადებს - ბაზის URL, session secret, API გასაღებები - რომ repository-ს მიღმა დარჩეს. გარემოს ცვლადები და secret-ები განმარტავს, რა ეკუთვნის იქ და რა არა.

პატარა კონფიგურაციის struct ამას ასუფთავებს და ერთ ადგილს გაძლევს, სადაც გაშვებისას ეფექტურ პარამეტრებს (secret-ების გარეშე) დალოგავ:

go
type Config struct {    Addr        string    DatabaseURL string    Env         string}func load() Config {    return Config{        Addr:        ":" + port(),        DatabaseURL: requireEnv("DATABASE_URL"),        Env:         os.Getenv("APP_ENV"),    }}

Timeout-ები, ლოგირება და graceful shutdown#

ნულოვანი მნიშვნელობის http.Server-ს საერთოდ არ აქვს timeout-ები. ლეპტოპზე ეს არაუშავს, production-ში კი პრობლემაა, რადგან კლიენტი, რომელიც კავშირს ხსნის და არაფერს აგზავნის, goroutine-სა და file descriptor-ს უსასრულოდ იკავებს. გამოიყენე აშკარად აწყობილი სერვერი:

main.go
srv := &http.Server{    Addr:              cfg.Addr,    Handler:           mux,    ReadHeaderTimeout: 5 * time.Second,    ReadTimeout:       15 * time.Second,    WriteTimeout:      30 * time.Second,    IdleTimeout:       60 * time.Second,    MaxHeaderBytes:    1 << 20,}
ველირას ზღუდავსტიპური მნიშვნელობა
ReadHeaderTimeoutმოთხოვნის header-ების წაკითხვის დრო5 s
ReadTimeoutმთელი მოთხოვნა body-ს ჩათვლით15 s
WriteTimeoutპასუხის ჩაწერის დრო30 s
IdleTimeoutkeep-alive კავშირები მოთხოვნებს შორის60 s

WriteTimeout ის არის, რომელიც კბენს. ის მთელ პასუხს მოიცავს, ამიტომ streaming endpoint-ი, დიდი ფაილის ჩამოტვირთვა ან server-sent events ნაკადი გაწყდება, როცა ის ამოქმედდება. ასეთი route-ებისთვის deadline მოთხოვნაზე გაასუფთავე http.NewResponseController(w).SetWriteDeadline(time.Time{})-ით, ან გაუშვი ისინი ცალკე სერვერზე სხვა ლიმიტებით.

ლოგები standard output-ზე გამოიტანე, რომ პანელის კონსოლმა დაიჭიროს. Go 1.21-დან სტანდარტულ ბიბლიოთეკას სტრუქტურირებული ლოგირება აქვს log/slog-ში:

go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))slog.SetDefault(logger)slog.Info("listening", "addr", cfg.Addr)

JSON ხაზები ადვილი საძებნია და ცოცხლად წასაკითხად მოუხერხებელი, ამიტომ როცა APP_ENV production არ არის, გადართე slog.NewTextHandler-ზე.

ახლა shutdown. სერვერის გაჩერება ან restart SIGTERM-ს აგზავნის. თუ შენი პროგრამა მას არ ამუშავებს, პროცესი საპატიებელი პერიოდის შემდეგ მოკვდება და ყველაფერი, რაც მიმდინარეობდა, იკარგება - ნახევრად ჩაწერილი პასუხები, buffer-ში დარჩენილი ჩანაწერები, შუა გზაზე შეწყვეტილი სამუშაოები. Go-ში მისი დამუშავება მოკლეა:

main.go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()go func() {    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {        log.Fatal(err)    }}()<-ctx.Done()slog.Info("shutting down")shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)defer cancel()if err := srv.Shutdown(shutdownCtx); err != nil {    slog.Error("shutdown", "err", err)}db.Close()

Shutdown ახალ კავშირებს აღარ იღებს, აქტიური მოთხოვნების დასრულებას ელოდება და ბრუნდება, როცა ისინი დასრულდება ან context-ის დრო ამოიწურება. ორი ხაფანგია: ListenAndServe http.ErrServerClosed-ს დაუყოვნებლივ აბრუნებს, როცა Shutdown-ს იძახებ, ეს შეცდომა არ არის და როგორც შეცდომა არ უნდა დალოგო; და Shutdown hijack-ნულ კავშირებს, მაგალითად websocket-ებს, არ ელოდება, ამიტომ ისინი თავად დახურე. shutdown timeout იმაზე მოკლე გააკეთე, რაც პლატფორმა გაძლევს, ათი წამი გონივრული ზედა ზღვარია. Graceful shutdown და health check-ები health endpoint-ის მხარეს განიხილავს, რაც Go-სთვის მუდმივ მნიშვნელობას დამბრუნებელი handler-ია.

Deploy GitHub-იდან#

app ხაზებზე deploy-ები მხოლოდ GitHub-იდან მოდის, GitHub App-ით, რომელიც მოკლევადიან token-ებს იყენებს, ამიტომ კერძო repository-ები მუშაობს და personal access token-ის სადმე ჩასმა არ გჭირდება. ორი გადამრთველია და ისინი ერთმანეთისგან დამოუკიდებელია:

  • Pull ყოველ გაშვებაზე. სერვერი ყოველი გაშვებისას იღებს იმ branch-ის უახლეს commit-ს, რომელსაც მიჰყვება, და შემდეგ აგებს მას.
  • Deploy push-ზე. branch-ზე push გაშვებულ სერვერს გადატვირთავს, გაჩერებულს კი გაუშვებს. ყოველი deploy ერთ ჩანაწერად ინახება.

build-გაშვებისას ქცევასთან ერთად deploy pipeline მოკლეა:

pushHTTPSHTTPGitHub repogo.mod, go.sum, sourceკლიენტებიPull გაშვებისასუახლესი commitProxy slotTLS, X-Forwarded-Forgo buildყოველ გაშვებაზეGo პროცესიპორტზე უსმენს
Push-იდან გაშვებულ Go სერვისამდე

ორი ჩვევა ამას უსაფრთხოს ხდის. deploy-ი გააკეთე branch-იდან, რომელზეც მხოლოდ ისეთი კოდი ხვდება, რომელიც უკვე ტესტებს გადის, რადგან deploy push-ზე production-ს გადატვირთავს იმაზე, რაც იქ მოხვდება. და secret-ები repository-ში არ შეინახო, რადგან build იმავე ხეს კითხულობს, რომელიც ატვირთე. თუ push-ი ვერ აიგო, compiler-ის გამოტანა კონსოლშია, ხოლო გამოსწორება კიდევ ერთი push-ია.

როცა პირველად აგზავნი push-ს ცარიელ სერვერზე, გახსოვდეს, რომ Node.js-ის გარდა ყველა runtime ხაზი თავის მისამართზე პატარა მომუშავე გვერდით იწყებს, სანამ კოდის deploy-ს არ გააკეთებ, და ეს გვერდი შენს ფაილებს არასოდეს გადააწერს. თუ deploy-ის შემდეგ მას ხედავ, deploy არ ამოქმედებულა: proxy-ს დამნაშავედ გამოცხადებამდე deploy-ის ჩანაწერი და კონსოლი ნახე.

Reverse proxy, დომენი და X-Forwarded-For#

TLS-ის Go პროგრამის შიგნით დასრულება შესაძლებელია ListenAndServeTLS-ით ან autocert-ით, და hosting ტარიფზე ეს, როგორც წესი, არასწორი არჩევანია: შენ იღებ სერთიფიკატის განახლებას, HTTP-დან HTTPS-ზე გადამისამართებას და სერთიფიკატის შეცვლისას გადატვირთვას. გაუშვი უბრალო HTTP შენს გამოყოფილ პორტზე და დანარჩენი proxy-ს მიანდე.

app ტარიფები reverse-proxy slot-ს შეიცავს. მიუთითე A ჩანაწერი slot-ისთვის ნაჩვენებ მისამართზე და სერთიფიკატი ავტომატურად გაიცემა და განახლდება 21-დღიან ფანჯარაში. ტესტირებამდე ჩანაწერს გავრცელების დრო მიეცი; სერთიფიკატი ვერ გაიცემა სახელზე, რომელიც მისამართზე ჯერ არ წყდება. დომენის მიერთება თამაშის სერვერთან თავად ჩანაწერს ეხება და იგივე ნაბიჯები app-ზეც მუშაობს. რას აკეთებს reverse proxy header-ების ჯაჭვს უფრო სიღრმისეულად განმარტავს.

რას ნიშნავს ეს შენი handler-ებისთვის:

  • `r.RemoteAddr` proxy-ა და არა სტუმარი. კლიენტის რეალური მისამართი X-Forwarded-For header-ში მოდის. თუ r.RemoteAddr-ს ლოგავ ან მასზე rate limit-ს აყენებ, proxy-ს ლოგავ და ზღუდავ, და ყველა სტუმარი ერთ bucket-ს იზიარებს.
  • აიღე სწორი ელემენტი. X-Forwarded-For მძიმეებით გამოყოფილი სიაა. რასაც კლიენტი თავად გამოგზავნის, მარცხნივ ჩანს; მისამართი, რომელიც შენმა proxy-მ დაამატა, მარჯვნივაა. უკიდურესი მარცხენა მნიშვნელობის ნდობა ნებისმიერ კლიენტს საშუალებას აძლევს, ვინმედ წარმოაჩინოს თავი. რადგან აქ proxy ერთადერთი შესასვლელია, header-ის ნდობა საერთოდ გონივრულია, მაგრამ აიღე ბოლო ჩანაწერი, რომელიც proxy-მ დაამატა, და არა პირველი.
  • Middleware ერთხელ და ადრე გამოიყენე. chi-ს მოყვება middleware.RealIP, რომელიც r.RemoteAddr-ს გადამისამართების header-ებიდან გადააწერს. ჩართე ის მხოლოდ მაშინ, როცა გარანტირებულია, რომ proxy წინ დგას, რადგან ის header-ებში დაწერილს დაიჯერებს.
  • აბსოლუტური URL-ები `X-Forwarded-Proto`-დან ააგე. გადამისამართებებს, ელფოსტის ბმულებსა და OAuth callback-ებს საჯარო სქემა სჭირდება. r.TLS == nil-ის წაკითხვა proxy-ს უკან ყოველთვის უბრალო HTTP-ს გვეუბნება, შედეგი კი http:// გადამისამართების ციკლია.
go
func clientIP(r *http.Request) string {    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {        parts := strings.Split(xff, ",")        return strings.TrimSpace(parts[len(parts)-1])    }    host, _, _ := net.SplitHostPort(r.RemoteAddr)    return host}

Websocket-ები proxy-ს გავლით მუშაობს, მაგრამ heartbeat-ები ნებისმიერ idle timeout-ზე მოკლე უნდა იყოს; websocket-ები reverse proxy-ს უკან დეტალებს განიხილავს და gorilla/websocket-სა და nhooyr.io/websocket-ზე უცვლელად ვრცელდება.

ბაზები: Postgres, MySQL და Valkey#

Go სერვისი თავისთავად stateless-ია, და ეს არის ის, რაც გინდა. state ბაზაში მიდის და მისი მიღების ორი გზა არსებობს.

ყოველ app ტარიფს ბაზის slot-ები მოჰყვება, რომლებიც პანელში იქმნება გენერირებული host-ით, მომხმარებლითა და პაროლით. უფრო დიდისთვის ან ცალკეულისთვის არსებობს ბაზების hosting ტარიფები PostgreSQL-ის, MySQL 8.4 LTS-ის, MongoDB-ის, SQL Server Express-ისა და Valkey-სთვის. მათთან დაკავშირება ტარიფის საკუთარ host-ზე და პორტზე ხდება, სერვერზე გენერირებული მონაცემებით. მიღებული connection string გარემოს ცვლადში Startup ჩანართზე ჩასვი, არასოდეს კოდში.

PostgreSQL-ისთვის pgx არის driver, რომელიც პირდაპირ ან database/sql-ით უნდა გამოიყენო. მისი pool-ია pgxpool:

go
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))if err != nil {    log.Fatal(err)}defer pool.Close()

MySQL-ისთვის სტანდარტული არჩევანია go-sql-driver/mysql database/sql-ის ქვეშ. DSN-ში ჩაწერე parseTime=true, თორემ time.Time მნიშვნელობების ნაცვლად ბაიტების slice-ებს მიიღებ, და charset აშკარად მიუთითე:

code
user:pass@tcp(host:3306)/appdb?parseTime=true&charset=utf8mb4&loc=UTC

database/sql ნაგულისხმევად pool-ს ინახავს და ლიმიტების გარეშე, და სწორედ ეს არის გასასწორებელი. თუ ხელს არ ახლებ, ის იმდენ კავშირს ხსნის, რამდენსაც პარალელური მოთხოვნები მოითხოვს, და ტრაფიკის მომატება ბაზის მხარეს კავშირების ლიმიტის შეცდომად იქცევა:

go
db.SetMaxOpenConns(10)db.SetMaxIdleConns(5)db.SetConnMaxLifetime(30 * time.Minute)db.SetConnMaxIdleTime(5 * time.Minute)

Go პროცესს ერთი pool აქვს და არა worker-ზე ერთი, ამიტომ ათი ბევრია ერთი ან ორი ბირთვის სერვისისთვის. კავშირის pool-ები და ლიმიტები ბაზის მხარის არითმეტიკას შეიცავს.

cache-ის, session-ებისა და queue-ებისთვის გამოიყენე Valkey. ის Redis-ის ღია კოდის fork-ია და Redis პროტოკოლზე მუშაობს, ამიტომ go-redis ან rueidis მას უცვლელად უკავშირდება. instance პაროლით არის დაცული და დისკზე ინახავს, როგორც AOF-ით, ისე snapshot-ებით. Valkey-ს დაწყება დაკავშირებას განმარტავს, job queue-ები Valkey-ით კი ბუნებრივი შემდეგი ნაბიჯია.

migration-ები გააკეთე გააზრებულად. მათი ყოველ გაშვებაზე main-იდან გამოყენება მოსახერხებელიცაა და საშიშიც, რადგან restart-ის ციკლი ნახევრად გამოყენებულ migration-ს თავიდან სცდის. ცალკე ბრძანება, ან ისეთი ხელსაწყო, როგორიც golang-migrate ან goose-ია და ხელით გაშვებული, უფრო უსაფრთხოა ყველაფრისთვის, რაც სათამაშოზე დიდია.

მეხსიერება: GOMEMLIMIT, GOGC და build-ის მეხსიერება#

მეხსიერების ლიმიტზე kernel container-ს აჩერებს და ის სუფთად ხელახლა ეშვება, swap-ის ნაცვლად. Go სერვისისთვის ეს მთავარი ოპერაციული რისკია, რადგან Go-ს garbage collector ნაგულისხმევად შენი ლიმიტის შესახებ არაფერი იცის. GOGC=100-ის დროს ის heap-ს ცოცხალი მონაცემების დაახლოებით ორჯერ ზომამდე გაზრდის, სანამ აგროვებს, ამიტომ 300 MB ცოცხალი მონაცემის მქონე სერვისს შეუძლია 600 MB-ს მიაღწიოს და არაფერი იყოს არასწორად.

ამას ორი პარამეტრი ასწორებს და ისინი Startup ჩანართზე გარემოს ცვლადებია:

ცვლადინაგულისხმევირას აკეთებს
GOGC100heap-ის ზრდა აგროვებამდე, პროცენტებში
GOMEMLIMITnoneGo runtime-ის მთლიანი მეხსიერების რბილი ჭერი
GOMAXPROCSlogical CPUsნაკადები, რომლებიც Go კოდს ერთდროულად ასრულებს

GOMEMLIMIT ზომას ერთეულის სუფიქსით იღებს, მაგალითად GOMEMLIMIT=800MiB. ეს რბილი ლიმიტია: როცა runtime მას უახლოვდება, collector უფრო მეტს მუშაობს, რომ მის ქვემოთ დარჩეს, და მეხსიერებას CPU-ში ცვლის. დააყენე container-ის ლიმიტზე დაბლა და არა მასზე, რადგან Go-ს heap მეხსიერების ერთადერთი მომხმარებელი არ არის - stack-ები, თავად runtime და cgo-ს გამოყოფებიც ითვლება. ტარიფის მეხსიერებაზე დაახლოებით 10-დან 20 პროცენტით დაბლა გონივრული საწყისია. 1 GB ტარიფზე ეს არის GOMEMLIMIT=800MiB.

რბილი ლიმიტი leak-ის წამალი არ არის. თუ ცოცხალი მონაცემები ლიმიტს ნამდვილად აღემატება, collector თითქმის განუწყვეტლივ მუშაობს, CPU იზრდება, latency მატულობს და სუფთა ჩავარდნის ნაცვლად death spiral გიჩნდება. თუ კონსოლის გრაფიკი ტრაფიკის დასტაბილურების შემდეგაც მეხსიერების მუდმივ ზრდას აჩვენებს, ლიმიტის ზრდის ნაცვლად იპოვე leak (შეუზღუდავი map, თითო მოთხოვნაზე goroutine, რომელიც არასოდეს სრულდება, response body, რომელიც არ დახურულა).

GOGC მასთან წყვილშია. მისი დაწევა, 50-მდე, უფრო ხშირად აგროვებს და ნაკლებ მეხსიერებას იყენებს CPU-ს ხარჯზე. ზრდა, 200-მდე, საპირისპირო შედეგს იძლევა. GOMEMLIMIT-ის არსებობისას შეგიძლია GOGC=off-იც დააყენო და აგროვება მარტო ლიმიტმა განსაზღვროს, მაგრამ ეს მხოლოდ იმ სერვისებისთვისაა, რომელთა ცოცხალი heap პატარა და კარგად ცნობილია. GOGC ნაგულისხმევზე დატოვე, სანამ გრაფიკი მიზეზს არ მოგცემს.

CPU მკაცრად შეზღუდულია იმ წილამდე, რაც იყიდე: 0.5 vCPU-იანი ტარიფი შენს ნაკადებს ამ სიჩქარით ამუშავებს, რასაც არ უნდა ფიქრობდეს runtime. Go 1.25-მდე GOMAXPROCS ნაგულისხმევად მანქანის logical CPU-ების რაოდენობას იღებდა და არა კვოტას, რაც ნიშნავს, რომ scheduler-ის ნაკადები შეიძლება container-ს მეტი ჰქონდეს, ვიდრე მას გამოყენება შეუძლია, და throttling-ის დროს შეფერხებები გაჩნდეს; უფრო ახალი ვერსიები container-ის კვოტას ითვალისწინებს. თუ უფრო ძველი Go-თი აგებ, GOMAXPROCS დააყენე შენი ტარიფის მიერ მოცემული ბირთვების მთელ რაოდენობაზე ან დაამატე go.uber.org/automaxprocs.

build-ის მეხსიერება ცალკე საკითხია. Go compiler Rust-ის release build-ზე ბევრად მსუბუქია, მაგრამ უფასო არც ის არის, და build იმავე container-ში ყოველ გაშვებაზე ხდება. ეს გამოცდილებიდან მოყვანილი რიცხვებია და არა დაპირებები:

პროექტიცივი buildbuild-ის პიკური მეხსიერება
პატარა API, ათიოდე დამოკიდებულება20-60 წამი300-500 MB
ტიპური სერვისი, რამდენიმე ათეული მოდული1-3 წუთი500-900 MB
დიდი ხე, გენერირებული კოდი, დიდი protobuf-ები3-8 წუთი1 GB ან მეტი

ყველაზე პატარა ტარიფზე ტიპური სერვისი აიგება, მაგრამ დიდი დამოკიდებულებათა ხე შეიძლება ლიმიტს მიაღწიოს და compile-ის შუაში გაჩერდეს, რაც ნელი build-ის სახით კი არა, restart-ის სახით ჩანს. თუ ასე მოხდა, go build -p 1 (ან GOFLAGS=-p=1) პაკეტებს სათითაოდ აკომპილირებს და დროის ხარჯზე პიკურ მეხსიერებას ამცირებს. ამის გარდა, შეამცირე, რასაც import-ობ: დამოკიდებულება, რომელიც ერთი ფუნქციისთვის მთელ cloud SDK-ს ითრევს, ჩვეულებრივი დამნაშავეა. release build-ებისთვის -ldflags="-s -w" სიმბოლოებისა და debug ინფორმაციის მოხსნით binary-ს მეოთხედით ან მეტით ამცირებს, გაშვებისას მეხსიერების შეცვლის გარეშე.

რა შეიძლება არასწორად წავიდეს#

გვერდი შენს ლეპტოპზე იტვირთება და სერვერზე timeout-ს იძლევა. localhost-ზეა მიბმული. bind გააკეთე :PORT-ზე ან 0.0.0.0:PORT-ზე.

`listen tcp :8080: bind: address already in use`. ან შენი პროგრამის მეორე ასლი მუშაობს, ან პორტი კოდშია ჩაწერილი და გამოყოფილი არ არის. წაიკითხე გარემოს ცვლადი.

სერვერი პირველ გაშვებას ვერ სცდება და კონსოლში compiler-ის შეცდომა ჩანს. build ჩავარდა. ლოკალურად გაიმეორე სუფთა checkout-ზე go build ./...-ით; ჩვეულებრივი მიზეზებია go.sum-ის ნაკლული ჩანაწერი, გამოუყენებელი import ან დაუკომიტებელი ფაილი.

`go: updates to go.mod needed; to update it: go mod tidy`. შენი import-ები და go.mod ერთმანეთს არ ემთხვევა. ლოკალურად გაუშვი go mod tidy და ორივე ფაილი დააკომიტე.

`go.mod requires go >= 1.24 (running go 1.23)`. go ხაზი toolchain-ზე ახალია. დაწიე ის ვერსიამდე, რომელიც ნამდვილად გჭირდება, ან ხაზი გადართე Go ვერსიაზე, რომელიც მას აკმაყოფილებს.

build Go-ს შეცდომის გარეშე კვდება. compiler-ს მეხსიერება ამოეწურა. სცადე go build -p 1, მოხსენი მძიმე დამოკიდებულებები ან გადადი უფრო მაღალ ტარიფზე იმისთვის, რასაც აგებ.

რამდენიმე წუთში ერთხელ restart, მეხსიერების გრაფიკი მაღლა მიდის. ლიმიტს აღწევს. დააყენე GOMEMLIMIT, შემდეგ leak-ი ეძებე. გახსოვდეს, რომ საათში სამი restart სერვერის გვერდზე გაფრთხილებას იწვევს, ექვსი კი შეჩერებას.

`dial tcp: lookup db: no such host`. ბაზის host ამ გარემოსთვის არასწორია. გამოიყენე host და პორტი, რომელიც პანელმა ან ბაზის ტარიფმა მოგცა, და არა სახელი შენი ლოკალური compose ფაილიდან.

ყველა სტუმარს ლოგებში ერთი და იგივე IP აქვს. r.RemoteAddr-ს კითხულობ. X-Forwarded-For წაიკითხე ზემოთ აღწერილი გზით.

`x509: certificate signed by unknown authority` გამავალ ზარებზე. გარემოს გამოსადეგი CA bundle არ აქვს, ან სერვერი, რომელსაც უკავშირდები, კერძო CA-ს იყენებს. გამავალი მოთხოვნა პირველივე deploy-ზე შეამოწმე, რომ ეს ადრე გადაეყარო.

მოთხოვნები ჩერდება და დიდი ლოდინის შემდეგ ვარდება. handler-ები გარე ზარებს deadline-ის გარეშე აკეთებს. გამოიყენე http.Client{Timeout: ...} და r.Context() გადაეცი ბაზისა და HTTP ზარებს, რომ გაუქმებულმა მოთხოვნამ თავისი სამუშაო შეწყვიტოს.

FAQ#

რომელ Go ვერსიას უნდა მივმართო?

ყველაზე დაბალს, რომელიც შენს კოდს ნამდვილად სჭირდება, go.mod-ის go ხაზში ჩაწერილს. რასაც 1.22-ით ან უფრო ახლით წერ, მეთოდების მცოდნე ServeMux-ს იღებს, 1.21-მა კი log/slog დაამატა. შეამოწმე ვერსია, რომელსაც Go ხაზი გვთავაზობს, და შენი go დირექტივა მასზე ან უფრო დაბლა გააჩერე.

მჭირდება chi ან gin?

არა. სტანდარტული ბიბლიოთეკა უმეტესი API-სთვის საკმარისია, მისთვის დაწერილი handler-ები კი chi-ზე ცვლილების გარეშე გადადის. router დაამატე, როცა route-ების ჯგუფები და middleware-ების ჯაჭვი გინდა, gin კი მაშინ, როცა მისი request binding და validation გჭირდება.

როგორ მივიღო კლიენტის რეალური IP?

წაიკითხე X-Forwarded-For, რომელსაც proxy ადგენს, და გამოიყენე ბოლო ჩანაწერი, რომელიც მან დაამატა, და არა პირველი. r.RemoteAddr proxy-ს მისამართია და ყველა სტუმრისთვის ერთი და იგივე იქნება.

რატომ გრძელდება restart უფრო დიდხანს, ვიდრე Node ან Python აპისთვის?

სერვერი ყოველ გაშვებაზე go build-ს უშვებს, ამიტომ restart კომპილაციას შეიცავს. ის სწრაფია, როცა ცოტა რამ შეცვლილა, და ნელი, როცა ცივია. დამოკიდებულებების მცირედ შენახვა მას მოკლეს ინარჩუნებს.

შემიძლია migration-ები გაშვებისას გავუშვა?

შეგიძლია და სათამაშოსთვის ეს კარგია. ყველაფრისთვის, რაც რეალურია, migration-ები ცალკე ნაბიჯად გაუშვი, რადგან restart-ის ციკლში გაჭედილი სერვისი ერთსა და იმავე ნახევრად გამოყენებულ ცვლილებას თავიდან სცდის.

უნდა დავაყენო GOMEMLIMIT?

დიახ, თუ შენი სერვისი heap-ის შესამჩნევ რაოდენობას ინახავს. დააყენე ტარიფის მეხსიერებაზე 10-დან 20 პროცენტით დაბლა, რომ collector container-ის გაჩერებამდე შეიკრას. ის garbage collector-ს ხელს უშლის, მთელი მანქანა საკუთარად ჩათვალოს.