Вбудовування статичних файлів з go:embed. Вимірюємо швидкодію

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт! Коли тільки вийшов Go 1.16, то на просторах інтернету з’явилась купа статей про нову можливість go:embed. Це стало проривом, всі це обговорювали, відправляли у приватних чатах посилання на статтю, записували відео, питали на співбесідах, та навіть шукали, чи є таке в Rust.

Маю надію, такий початок дав вам зрозуміти, що статтю написано з розважальною метою, хоча, ймовірно, технічну цінність вона також буде мати.

У статті йтиметься про мікробенчмаркінг, порівняємо, наскільки швидше сервер віддає вбудовані файли за звичайні, та які веб-каркаси вже встигли підключити go:embed, а які — ще в процесі.

Ілюстрація Анастасії Сігетій

Статтю засновано на реальних подіях

Коли у травні я написав розширення StopDiiaCity, то для хостингу вибрав безкоштовний netlify.com, коли число викликів API перевищило 125 000, то безкоштовність завершилась, далі спробував vercel.com, та врешті зупинився на старому-доброму VPS від vultr.com.

Мати свій VPS — це найдешевший варіант, про порівняння цін писав раніше.

Логіка API розширення була завжди одна, але для кожного хостингу була своя обгортка, для Netlify serverless functions — одна обгортка, а вже для Vercel serverless functions — схожа, але інша обгортка.

Коли почав хостити на власному VPS, то обгортка перетворилась на стандартний HTTP сервер, який має один метод API та віддає файли, а далі вирішив вбудувати ці статичні файли через go:embed, от і стало цікаво дізнатись, наскільки швидкодія серверу збільшилась.

Стандартний веб-сервер

Ця частина стандартна для кожної статті — налаштування проєкту, запуск, запакування в контейнер, повторний запуск.

Все починається зі створення Go-проєкту, це звісно можна прибрати зі статті, але тоді при подальшому прочитанні будуть питання: «Звідки взявся цей пакет gitlab.com/go-yp/go-serve-embed?», принаймні, таке питання з’явиться у частини читачів.

go mod init gitlab.com/go-yp/go-serve-embed
go: creating new go.mod: module gitlab.com/go-yp/go-serve-embed

Я скопіював вже готовий проєкт StopDiiaCity, який складається з одного методу API та статичних файлів в теці public.

tree public
public
├── assets
│   └── js
│       └── test.js
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── index.html
└── test.html

Весь код доступний в репозиторії, наведу тільки файли, які мають значення main.go, Dockerfile та docker-compose.yml:

package main

import (
	"gitlab.com/go-yp/go-serve-embed/api"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/stopdiiacity/verify.json", api.VerifyHandler)

	http.Handle("/", http.FileServer(http.Dir("/var/www/public")))

	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal(err)
	}
}
FROM golang:1.16-alpine AS builder

WORKDIR $GOPATH/src/gitlab.com/go-yp/go-serve-embed

COPY . .

ENV CGO_ENABLED=0

RUN GOOS=linux go build -ldflags "-s -w" -o /bin/server ./go_http_standard_server_v1/main.go

FROM scratch

COPY --from=builder /bin/server /bin/server
COPY --from=builder /go/src/gitlab.com/go-yp/go-serve-embed/public /var/www/public

ENTRYPOINT ["/bin/server"]
version: "2"

services:
  go_http_standard_server_v1:
    build:
      context: .
      dockerfile: ./go_http_standard_server_v1/docker/Dockerfile
    ports:
      - "8080:8080"

Все готово до запуску:

time sudo docker-compose build --no-cache
Successfully tagged go-serve-embed_go_http_standard_server_v1:latest

real	0m3,791s
user	0m0,343s
sys	0m0,068s
sudo docker-compose up -d
docker image inspect go-serve-embed_go_http_standard_server_v1:latest --format='{{.Size}}'
4.73MB
Успішно запустився сайт localhost:8080, все працює.
sudo docker-compose down

Тестувати на швидкодію будемо після того, як буде готова версія веб-серверу з go:embed.

Версія веб-серверу з go:embed

Основні зміни в main.go, але їх настільки мало, що краще показати у форматі до та після:

Стандартний веб-серверВеб-сервер з go:embed
package main

import (
	"gitlab.com/go-yp/go-serve-embed/api"
	"log"
	"net/http"
)

func main() {
	// ...

	http.Handle("/", http.FileServer(http.Dir("/var/www/public")))

	// ...
}
package main

import (
	"embed"
	"gitlab.com/go-yp/go-serve-embed/api"
	"io/fs"
	"log"
	"net/http"
)

//go:embed public
var publicFS embed.FS

func main() {
	// ...

	http.Handle("/", http.FileServer(mustPublicFS()))

	// ...
}

func mustPublicFS() http.FileSystem {
	sub, err := fs.Sub(publicFS, "public")

	if err != nil {
		panic(err)
	}

	return http.FS(sub)
}
А в Dockerfile видно що копіюємо в scratch тільки сервер:
FROM golang:1.16-alpine AS builder

WORKDIR $GOPATH/src/gitlab.com/go-yp/go-serve-embed

COPY . .

ENV CGO_ENABLED=0

RUN cp -r /go/src/gitlab.com/go-yp/go-serve-embed/public ./go_http_embed_server_v2/public
RUN GOOS=linux go build -ldflags "-s -w" -o /bin/server ./go_http_embed_server_v2/main.go

FROM scratch

COPY --from=builder /bin/server /bin/server

ENTRYPOINT ["/bin/server"]
version: "2"

services:
  go_http_standard_server_v1:
    build:
      context: .
      dockerfile: ./go_http_standard_server_v1/docker/Dockerfile
    ports:
      - "8080:8080"

  go_http_embed_server_v2:
    build:
      context: .
      dockerfile: ./go_http_embed_server_v2/docker/Dockerfile
    ports:
      - "8081:8080"
sudo docker-compose up -d
docker images | grep go-serve-embed
go-serve-embed_go_http_standard_server_v1       4.73MB
go-serve-embed_go_http_embed_server_v2          4.75MB
Успішно запустився сайт localhost:8081, все працює.

Тестування з wrk

Інструмент wrk для навантажувального тестування використовував раніше Aliaksandr Valialkin, коли розповідав про fasthttp. Саме після тієї статті репозиторій github.com/valyala/fasthttp став настільки популярним.

Я теж буду використовувати wrk:

wrk -t 2 -c 1000 http://127.0.0.1:8080/
wrk -t 2 -c 1000 http://127.0.0.1:8081/
Running 10s test @ http://127.0.0.1:8080/
  2 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    12.73ms   11.05ms 137.02ms   85.48%
    Req/Sec    42.27k     4.72k   52.75k    71.21%
  839123 requests in 10.05s, 548.97MB read
Requests/sec:  83518.62
Transfer/sec:     54.64MB
Running 10s test @ http://127.0.0.1:8081/
  2 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     8.05ms    6.37ms 105.14ms   84.70%
    Req/Sec    59.19k     5.13k   68.86k    70.20%
  1175439 requests in 10.09s, 717.43MB read
Requests/sec: 116484.53
Transfer/sec:     71.10MB

Версія веб-серверу з go:embed працює швидше.

В попередньому прикладі з wrk тестували швидкодію тілько одного шляху /, але можна написати Lua-скрипт для обходу усіх файлів в теці public:

-- original https://github.com/wg/wrk/blob/master/scripts/counter.lua

-- tree -h public
-- public
-- ├── [4.0K]  assets
-- │   └── [4.0K]  js
-- │       └── [ 634]  test.js
-- ├── [ 542]  favicon-16x16.png
-- ├── [1.1K]  favicon-32x32.png
-- ├── [ 15K]  favicon.ico
-- ├── [ 501]  index.html
-- └── [ 869]  test.html

local paths = {
    "/assets/js/test.js",
    "/favicon-16x16.png",
    "/favicon-32x32.png",
    "/favicon.ico",
    "/index.html",
    "/test.html",
    "/"
}
local len = #paths
local counter = 0

request = function()
   counter = (counter + 1) % len

   return wrk.format(nil, paths[1+counter])
end
wrk -s ./wrk-scripts/paths.lua -t 2 -c 1000 http://127.0.0.1:8080/
Running 10s test @ http://127.0.0.1:8080/
  2 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    11.99ms   10.14ms 163.31ms   86.09%
    Req/Sec    42.63k     3.86k   49.74k    76.26%
  845799 requests in 10.07s, 2.28GB read
Requests/sec:  83969.68
Transfer/sec:    231.98MB
wrk -s ./wrk-scripts/paths.lua -t 2 -c 1000 http://127.0.0.1:8081/
Running 10s test @ http://127.0.0.1:8081/
  2 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    10.90ms    9.65ms 115.42ms   84.03%
    Req/Sec    46.62k     3.81k   55.66k    69.19%
  923111 requests in 10.08s, 2.46GB read
Requests/sec:  91613.93
Transfer/sec:    249.65MB

Більше прикладів wrk-скриптів у теці github.com/wg/wrk/tree/master/scripts.

Стара школа вбудовування файлів

Ви несете відповідальність за прочитане далі, ви робите це на свій страх та ризик!

В Go є корисна можливість встановити змінні під час компіляції за допомогою -ldflags:

go build -ldflags "-X 'main.var_name1=value1' -X 'main.var_name2=value2'"

Для кожного файлу я оголошу відповідну змінну, яку потім встановлю через Dockerfile:

package main

import (
	"encoding/base64"
	"gitlab.com/go-yp/go-serve-embed/api"
	"log"
	"net/http"
)

var (
	GitHash      string
	indexHTML    string
	testHTML     string
	favicon16PNG string
	favicon32PNG string
	faviconICO   string
	testJS       string
)

func main() {
	http.HandleFunc("/stopdiiacity/verify.json", api.VerifyHandler)

	http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/", "/index.html":
			w.Write([]byte(indexHTML))
		case "/test.html":
			w.Write([]byte(testHTML))
		case "/favicon-16x16.png":
			base64Decode(w, favicon16PNG)
		case "/favicon-32x32.png":
			base64Decode(w, favicon32PNG)
		case "/favicon.ico":
			base64Decode(w, faviconICO)
		case "/assets/js/test.js":
			w.Write([]byte(testJS))
		case "/git-hash":
			w.Write([]byte(GitHash))
		default:
			w.WriteHeader(http.StatusNotFound)
			w.Write([]byte(http.StatusText(http.StatusNotFound)))
		}
	}))

	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal(err)
	}
}

func base64Decode(w http.ResponseWriter, data string) {
	var content, err = base64.StdEncoding.DecodeString(data)

	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte(http.StatusText(http.StatusInternalServerError)))

		return
	}

	w.Write(content)
}
FROM golang:1.16-alpine AS builder

RUN apk add --no-cache git

WORKDIR $GOPATH/src/gitlab.com/go-yp/go-serve-embed

COPY . .

ENV CGO_ENABLED=0

RUN GOOS=linux go build -ldflags "-s -w \
    -X 'main.GitHash=$(git rev-parse HEAD)' \
    -X 'main.indexHTML=$(cat /go/src/gitlab.com/go-yp/go-serve-embed/public/index.html)' \
    -X 'main.testHTML=$(cat /go/src/gitlab.com/go-yp/go-serve-embed/public/test.html)' \
    -X 'main.favicon16PNG=$(cat /go/src/gitlab.com/go-yp/go-serve-embed/public/favicon-16x16.png | base64)' \
    -X 'main.favicon32PNG=$(cat /go/src/gitlab.com/go-yp/go-serve-embed/public/favicon-32x32.png | base64)' \
    -X 'main.faviconICO=$(cat /go/src/gitlab.com/go-yp/go-serve-embed/public/favicon.ico | base64)' \
    -X 'main.testJS=$(cat /go/src/gitlab.com/go-yp/go-serve-embed/public/assets/js/test.js)' \
    " -o /bin/server ./go_http_ldflags_server_v3/main.go

FROM scratch

COPY --from=builder /bin/server /bin/server

ENTRYPOINT ["/bin/server"]

Успішно запустився сайт localhost:8082, все працює.

Можна придумати ще страшніше рішення: запакувувати теку public в архів public.zip, передати цей архів через -X ’main.PublicZIP=$(cat public.zip)’ та на стороні Go розпаковувати та віддавати файли через HTTP.

Ще один, давно відомий варіант, це вбудовувати файли через кодогенерацію, написати вручну або використовувати github.com/gobuffalo/packr.

Вбудовування в каркасах

В одному з проєктів, де працюю, використовується Gin, для цього каркасу вже є обговорення з використання go:embed.

Розглянемо використання go:embed для каркасу Gin у форматі до та після:

Чистий GinGin з go:embed
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

func main() {
	router := gin.Default()

	router.StaticFS("/", http.Dir("public"))

	// Listen and serve on 0.0.0.0:8080
	router.Run(":8080")
}
package main

import (
	"embed"
	"io/fs"
	"net/http"
)
import "github.com/gin-gonic/gin"

//go:embed public
var publicFS embed.FS

func main() {
	router := gin.Default()

	router.StaticFS("/", mustPublicFS())

	// Listen and serve on 0.0.0.0:8080
	router.Run(":8080")
}

func mustPublicFS() http.FileSystem {
	sub, err := fs.Sub(publicFS, "public")

	if err != nil {
		panic(err)
	}

	return http.FS(sub)
}

А ось для github.com/valyala/fasthttp є GitHub issue #974, тому якщо у ває є бажання, то можете додати підтримку go:embed.

Епілог

Репозиторій.

Що ж, тепер можу сміливо додавати собі досягнення «Написав статтю про go:embed» на сайті achievki.io.

👍НравитсяПонравилось8
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Норм
Непогано б було додати приклади з embed.FS (робота з файлом, з директоріями)
Та про фільтрацію контенту, типу

// Requests embeds graphql requests
//go:embed *.graphql
var Requests embed.FS

ps:

ENV CGO_ENABLED=0 <- зайве

RUN GOOS=linux CGO_ENABLED=0 .....

додати приклади з embed.FS (робота з файлом, з директоріями)

То є стаття How to Use //go:embed.

Хоча там відсутній приклад що можна так:

//go:embed public/assets/js/*.js
//go:embed public/*.png public/favicon.ico
//go:embed public/*.html
var publicFS embed.FS
package main

import (
	"embed"
	"gitlab.com/go-yp/go-serve-embed/api"
	"io/fs"
	"log"
	"net/http"
)

//go:embed public/assets/js/*.js
//go:embed public/*.png public/favicon.ico
//go:embed public/*.html
var publicFS embed.FS

func main() {
	http.HandleFunc("/stopdiiacity/verify.json", api.VerifyHandler)

	http.Handle("/", http.FileServer(mustPublicFS()))

	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal(err)
	}
}

func mustPublicFS() http.FileSystem {
	sub, err := fs.Sub(publicFS, "public")

	if err != nil {
		panic(err)
	}

	return http.FS(sub)
}

Дякую за ачівку! :))

ну зрозуміло, що затягнути файли в стуркутри даний процесса іноді потрібно і зручно. Але точно не для швидкості сервінгу. Вже років 15-20 є sendfile(2), який із buffer cache зробить dma одразу в мережеву картку, без userspace, напряму в kernel. Так що воно має бути швидше ніж го/раст/та хоч asm.

Для мене це нова інформація бо хоч і є розуміння навіщо системні виклики, але відсутній досвід роботи з ними.

Дякую, що так це сприймаєте. Декілька років тому я бачив на UA Pycon проект, який презентували під оплески, як найкращий доклад і т.п. Проект мотивований тим, що люди не хочуть розібратися, що ж їм пропонує операційна система, і пиляють «магічні» рішення щоб це обійти. Проект до-речі був не на пітоні, а саме на голанг.

Подписаться на комментарии