Вбудовування статичних файлів з go:embed. Вимірюємо швидкодію
Привіт! Коли тільки вийшов 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
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) } |
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
Тестування з 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 у форматі до та після:
Чистий Gin | Gin з 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.
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів