Создаём микросервис обработки изображений на Go с gRPC
- воскресенье, 1 марта 2026 г. в 00:00:11
В этой статье мы рассмотрим создание микросервиса обработки изображений на golang с использованием технологии gRPC. Цель статьи - показать как может выглядеть такой сервис и что он может в себя включать. В результате мы получим полностью рабочий сервис по обработке изображений, который принимает данные, сохраняет исходную картинку, сжимает её, накладывает на неё ватермарку, изменяет размер изображения, и конвертирует его в нужный формат.
Разберём возможные варианты взаимодействия клиента с сервером для обработки больших объектов, в нашем случае это картинки:
HTTP/1.1 (REST)
Передача изображений в виде текстовых чанков (например, base64) приводит к значительным накладным расходам: бинарные данные увеличиваются на ~33% при кодировании в Base64, а текстовый формат неэффективен для больших объёмов.
WebSocket
Подходит для долгоживущих сессий и двустороннего обмена, но избыточен, если нам нужно просто «принять изображение → обработать → вернуть результат». Удержание тысяч соединений ради однократных операций — неоптимально.
gRPC использует:
Protocol Buffers — строго типизированный, компактный бинарный формат,
HTTP/2 — мультиплексирование, потоки, сжатие заголовков,
Client-Streaming — идеально подходит для передачи одного большого файла (например, изображения) в одном вызове.
Принимать изображение и параметры (format, compress, watermark, width[], height[]),
Сохранять оригинал с уникальным путём (./download/YYYY/MM/DD/UUID/img/...),
Накладывать водяной знак,
Генерировать версии заданных размеров,
Сохранять полученные изображения,
Возвращать список путей.
Запрос с width = [1920, 1280], height = [1080, 720], format = "webp", watermark = "logo.png"
→ Сервис вернёт два пути:
./download/2026/02/08/abc123/img/abc123_1920x1080.webp
./download/2026/02/08/abc124/img/abc124_1280x720.webp

1.Приём → 2. Сохранение исходного файла→ 3. Сжатие → 4. Watermark → 5. Resize → 6. Конвертация → 7. Ответ.
./download/2026/02/08/a1b2c3d4-.../img/
├── a1b2c3d4-....jpg ← оригинал
├── a1b2c3d4-..._800x600.png
└── a1b2c3d4-..._1024x768.png
Для работы с WebP мы используем библиотеку golang.org/x/image/webp, а исходные утилиты можно скачать на официальной странице Google.
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
Позволяет генерировать определения сервисов Go для буфера протокола, заданного нашим .proto файлом protoc-gen-go-grpc
Также для работы webp нам потребуется работа с CGO, которую мы рассмотрим отдельно.
Для начала работы опишем наш proto файл. Это будет сервис с единственным rpc который будет обрабатывать картинки пользователей.
./proto/image.proto
message DownloadImagesRequest { ImageInfo info = 1; //параметры обработки изображения bytes image = 2; // непосредственно данные изображения } message ImageInfo { string compress = 1; // сжатие string watermark = 2; // вотермарк string format = 3; // перевод в формат файла repeated int32 width = 4; // ширина обработанной картинки repeated int32 height = 5; // высота обработанной картинки } message DownloadImagesResponse { repeated string storage_path = 1; // ссылка куда сохранить string error=2; } /* При передаче файлов больших размеров клиент передает нам данные потоком, сервер дожидается окончания передачи, обрабатывет запрос и отдает пользователю ответ. */ service ImageService { rpc DownloadImages(stream DownloadImagesRequest) returns (DownloadImagesResponse); }
Мы используем Client-Streaming RPC — клиент может отправить несколько сообщений, сервер — один ответ. Этот способ поможет нам в случае необходимости гибкого расширения и избежания ограничений на размер одного сообщения.
Сгенерируем файлы для реализации сервера с помощью protoc:
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative -I . image.proto
У нас должно получиться 2 файла image.pb.go и image_grpc.pb.go в директории proto
./internal/app/server.go
package app import ( pb "image-converter/proto" "log/slog" "net" "os" "os/signal" "sync" "syscall" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) type GrpcServer struct { Server *grpc.Server } func recoveryFn(p any) (err error) { return status.Errorf(codes.Unknown, "panic triggered: %v", p) } func NewGrpcServer() *GrpcServer { return &GrpcServer{ Server: grpc.NewServer( grpc.ChainStreamInterceptor( recovery.StreamServerInterceptor(recovery.WithRecoveryHandler(recoveryFn)), )), } }
Теперь реализуем функцию запуска нашего grpc сервера:
./internal/app/server.go
... func (s *GrpcServer) GrpcServeServer(a ImageServer, adress string) error { lis, err := net.Listen("tcp", adress) if err != nil { slog.Error("address for grpc server not found, attempting graceful shutdown") s.Server.GracefulStop() return err } pb.RegisterImageServiceServer(s.Server, a) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) wg := sync.WaitGroup{} wg.Add(1) go func() { <-sigCh slog.Error("got signal 1, attempting graceful shutdown") s.Server.GracefulStop() wg.Done() }() slog.Info("starting grpc server", "address", adress) if err := s.Server.Serve(lis); err != nil { slog.Error("grpc server error", "error", err.Error()) s.Server.GracefulStop() return err } wg.Wait() return nil }
Мы создали наш grpc сервер, но пока нет никакой реализации ImageServiceServer который сгенерировал нам protoc, это просто интерфейс который нам и нужно реализовать.
Нам необходимо создать структуру ImageServer которая реализует интерфейс ImageServiceServer с его методом DownloadImage, также создадим конструктор для него :
./internal/app/image.go
package app import ( "errors" pb "image-converter/proto" "io" "strconv" ) type ImageServer struct { pb.ImageServiceServer } func NewImageServer() ImageServer { i := ImageServer{} return i } // создадим структуру OriginalImage которая будет хранить метаданные о наших картинках type OriginalImage struct { Path string Lenght []int32 Width []int32 Format string Folder string Watermark string UUID string } const defaultWatermark = "watermark.png" func (img ImageServer) DownloadImages(stream pb.ImageService_DownloadImagesServer) error { var images []*pb.DownloadImagesRequest //принимаем поток от клиента и проверяем, что он дошёл до нас без сбоев for { image, err := stream.Recv() if err == io.EOF { break } if err != nil { // возвращаем ошибку в случае неудачного приема return stream.SendAndClose(&pb.DownloadImagesResponse{ Error: err.Error(), }) } images = append(images, image) } /*Проверяем , чтобы клиент при передаче длины и ширины картинок которые необходимо создать передал одинаковое их количество, иначе будет непонятно по каким размерам её нужно будет обрабатывать, и сохраняем сами картинки, также проверяем чтобы в ватермарке присутствовало хотя бы значение по умолчанию, а не пустое */ var paths []OriginalImage for i := range images { if len(images[i].Info.Height) != len(images[i].Info.Width) { return stream.SendAndClose(&pb.DownloadImagesResponse{ Error: "different len of lenght and width for picture " + strconv.Itoa(i), }) } if images[i].Info.Watermark == "" { // если нам не передали делаем по умолчанию images[i].Info.Watermark = defaultWatermark } } /*наше сохранение исходных файлов, полученных от клиента, а также помещение всех метаданных в слайс наших структур для удобства работы с ними */ paths = saveSourceFiles(images) if len(images) == 0 { return stream.SendAndClose(&pb.DownloadImagesResponse{ Error: "no images in request", }) } //наложение watermark на наши изображения err := watermark(paths) if err != nil { return stream.SendAndClose(&pb.DownloadImagesResponse{ Error: errors.New("path for watermark is invalid").Error(), }) } //изменение размера картинки и сохранение уже обработанной версии uploadPath := resizeAndSave(paths) res := &pb.DownloadImagesResponse{ StoragePath: uploadPath, } //возврат полученных путей хранения картинок для возможности просмотра err = stream.SendAndClose(res) if err != nil { return stream.SendAndClose(&pb.DownloadImagesResponse{ Error: "error when receive an responce", }) } return nil }
Нам осталось реализовать ключевые функции для нашего сервиса, а именно: saveSourceFiles: отвечает за сохранение исходных файлов и их сжатие, watermark: наложение ватермарки , resizeAndSave: изменения размера картинки и его конвертация в нужный формат изображения.
Мы создаем путь для сохранения картинки, сохраняем её с необходимым для клиента уровнем сжатия и потом для удобства всю метаинформацию об картинке помещаем уже в нашу структуру и работаем в дальнейшем только с ней:
./internal/app/save.go
package app import ( "bytes" "fmt" "image" pb "image-converter/proto" "image/jpeg" "image/png" "log/slog" "os" sync "sync" "time" "github.com/chai2010/webp" "github.com/google/uuid" ) // вспомогательная функция для получения года, месяца и дня func getTimeData() (string, string, string) { year := time.Now().Year() month := time.Now().Month() day := time.Now().Day() y := fmt.Sprintf("%v", year) m2 := int(month) m := fmt.Sprintf("%v", m2) d := fmt.Sprintf("%v", day) return y, m, d } // вспомогательные функции для определения уровня сжатия картинок func setLevelCompressionPNG(level string) png.CompressionLevel { var compessInt png.CompressionLevel switch level { case "low": compessInt = -3 case "medium": compessInt = -2 case "max": compessInt = -1 } return compessInt } func setLevelCompressionJPG(level string) int { var compessInt int switch level { case "low": compessInt = 10 case "medium": compessInt = 50 case "max": compessInt = 100 } return compessInt } func setLevelCompressionWEBP(level string) float32 { var compessInt float32 switch level { case "low": compessInt = 10 case "medium": compessInt = 50 case "max": compessInt = 100 } return compessInt } func saveSourceFiles(images []*pb.DownloadImagesRequest) []OriginalImage { var wg sync.WaitGroup var mu sync.Mutex y, m, d := getTimeData() imagesNew := make([]OriginalImage, 0) for i := range images { //применяем WaitGroup чтобы дождаться сохранения всех картинок переданных пользователем wg.Add(1) //используем горутины чтобы конкурентно обработать наши картинки go func(i int) { defer wg.Done() extension := images[i].Info.Format //генерируем уникальное имя и путь сохранения uuid := uuid.New().String() newFileName := uuid + "." + extension newFilePath := "./download/" + y + "/" + m + "/" + d + "/" + uuid + "/" + "img/" //создаём папку для сохранения файлов err := os.MkdirAll(newFilePath, 0755) if err != nil { slog.Error("failed to create directory", "error", err.Error()) } // декодируем изображение img, _, err := image.Decode(bytes.NewReader(images[i].Image)) if err != nil { slog.Error("failed to decode image", "error", err.Error()) return } //собираем полный путь до файла с его названием и расширением path := newFilePath + newFileName //создаём файл для последующего сохранения туда картинки out, _ := os.Create(path) defer out.Close() //теперь обрабатываем картинку в зависимости от полученного формата изображения switch extension { case "png": var enc png.Encoder //сжимаем в соответствии с полученными параметрами от клиента level := setLevelCompressionPNG(images[i].Info.Compress) enc.CompressionLevel = level //сохраняем изображение err = enc.Encode(out, img) if err != nil { slog.Error("failed to encode PNG", "error", err.Error()) } case "jpeg", "jpg": var opts jpeg.Options level := setLevelCompressionJPG(images[i].Info.Compress) opts.Quality = level err = jpeg.Encode(out, img, &opts) if err != nil { slog.Error("failed to encode JPEG", "error", err.Error()) } case "webp": var data []byte level := setLevelCompressionWEBP(images[i].Info.Compress) data, err = webp.EncodeRGB(img, level) if err != nil { slog.Error("failed to encode WEBP", "error", err.Error()) } if err = os.WriteFile(path, data, 0666); err != nil { slog.Error("failed to write WEBP file", "error", err.Error()) } default: slog.Error("unknown file format, please provide a file with extension png,jpg,webp") } //создание структуры с метаданными для последующей работы imageNew := OriginalImage{ Path: path, Lenght: images[i].Info.Height, Width: images[i].Info.Width, Format: images[i].Info.Format, Folder: newFilePath, Watermark: images[i].Info.Watermark, UUID: uuid, } // берём мьютекс для корректного добавления в слайс нашего изображения mu.Lock() imagesNew = append(imagesNew, imageNew) mu.Unlock() }(i) wg.Wait() } return imagesNew }
Для ускорения нашего процесса обработки все этапы мы будем выполнять конкурентно с помощью горутин.
Следующим этапом идёт наложение водяного знака на нашу картинку. Процесс наложения: мы передаем каждой горутине по изображению, они его обрабатывают, а для того чтобы убедиться что они все обработались мы применяем sync.WaitGroup. Сам процесс наложения водяного знака это задание параметров для установки его на исходную картинку, в нашем случае мы делаем её полупрозрачную с небольшим углом поворота и в случайном месте и сохраняем её:
./internal/app/watermark.go
package app import ( "os" sync "sync" "github.com/disintegration/imaging" "github.com/filipenevs/go-imagewatermark" ) func watermark(paths []OriginalImage) error { var wg sync.WaitGroup var mu sync.Mutex var err error //получение текущего каталога для последующего открытия //и работы с watermark currDir, err := os.Getwd() if err != nil { return err } for i := range paths { //применяем WaitGroup чтобы дождаться наложения watermark на все картинки wg.Add(1) //используем горутины чтобы конкурентно обработать наши картинки go func(i int) { defer wg.Done() watermarkS := "" //проверяем если все таки нет конкретного указания на необходимую watermark // используем default switch paths[i].Watermark { case defaultWatermark: watermarkS = defaultWatermark case "": watermarkS = defaultWatermark default: watermarkS = paths[i].Watermark } watermarkPath := currDir + "\\" + watermarkS //сама функция наложения watermark funcErr := addWaterMark(paths[i].Path, watermarkPath) //так как обработка конкурентна возьмем мьютекс для того чтобы если //произошла хоть 1 ошибка то она точно вернется нам if funcErr != nil { mu.Lock() if err == nil { err = funcErr } mu.Unlock() } }(i) } wg.Wait() return err } func addWaterMark(bgImg, watermark string) error { /*наложение картинки на картину, так же здесь можно указывать её прозрачность, угол поворота, пропорцию, положение по горизонтали/вертикали */ result, err := imagewatermark.ProcessImageWithWatermark(imagewatermark.WatermarkConfig{ InputPath: bgImg, WatermarkPath: watermark, OpacityAlpha: 0.5, WatermarkWidthPercent: 40, VerticalAlign: imagewatermark.VerticalRandom, HorizontalAlign: imagewatermark.HorizontalRandom, Spacing: 10, RotationDegrees: 20, }) if err != nil { return err } //сохранение на диск err = imaging.Save(result, bgImg) return nil }
Остаётся только изменить размер и сохранить в нужном формате наши обработанные изображения.
Теперь создадим файл resize.go и реализуем функцию изменения размера изображении и сохранения в нужном нам формате:
./internal/app/resize.go
package app import ( "bytes" "image/jpeg" "image/png" "io" "log/slog" "os" "strconv" sync "sync" "github.com/chai2010/webp" "github.com/nfnt/resize" ) func resizeAndSave(paths []OriginalImage) []string { var wg sync.WaitGroup var mu sync.Mutex //создаём результирующий слайс путей наших файлов чтобы потом отдать клиенту uploadPaths := make([]string, 0) for i := range paths { // используем wg чтобы дождаться обработки все�� картинок wg.Add(1) // используем горутины для распараллеливания процесса go func(i int) { defer wg.Done() //основная функция изменения размера uploadPath := resizeImage(paths[i]) if len(uploadPath) != 0 { mu.Lock() uploadPaths = append(uploadPaths, uploadPath...) mu.Unlock() } else { mu.Lock() uploadPaths = append(uploadPaths, paths[i].Path) mu.Unlock() } }(i) } wg.Wait() return uploadPaths } // наша основная функция на этом этапе обработки func resizeImage(path OriginalImage) []string { var mu sync.Mutex // проверка что длина и ширина изображения имеются for i := range path.Lenght { if path.Lenght[i] == 0 || path.Width[i] == 0 { return []string{} } } //проверяем что у нас одинаковое количество // параметров длины и ширины картинки для ее создания if len(path.Lenght) != len(path.Width) { return []string{} } //результирующий слайс путей наших файлов var uploadPaths []string switch path.Format { case "png": for i := range path.Lenght { //открытие файла уже сжатого и с ватермаркой imgIn, err := os.Open(path.Path) if err != nil { slog.Error("failed to open PNG file", "error", err.Error()) return []string{} } //его считывание imgPng, err := png.Decode(imgIn) if err != nil { slog.Error("failed to decode PNG", "error", err.Error()) return []string{} } // и закрытие err = imgIn.Close() if err != nil { slog.Error("failed to close PNG file", "error", err.Error()) return []string{} } // генерация нового изображения с измененным размером imgPng = resize.Resize(uint(path.Lenght[i]), uint(path.Width[i]), imgPng, resize.Bilinear) upPath := path.Folder + path.UUID + "_" + strconv.FormatUint(uint64(path.Lenght[i]), 10) + "x" + strconv.FormatUint(uint64(path.Width[i]), 10) + "." + path.Format buf := new(bytes.Buffer) // его запись в нужный нам формат err = png.Encode(buf, imgPng) if err != nil { slog.Error("failed to encode PNG", "error", err.Error()) return []string{} } imgSave := buf.Bytes() //сохранение нового изображения по сгенерированному пути err = os.WriteFile(upPath, imgSave, 0666) if err != nil { slog.Error("failed to save PNG file", "error", err.Error()) return []string{} } // добавление полученного пути к общему слайсу mu.Lock() uploadPaths = append(uploadPaths, upPath) mu.Unlock() } // по аналогии с кейсом png case "jpg", "jpeg": for i := range path.Lenght { imgIn, err := os.Open(path.Path) if err != nil { slog.Error("failed to open JPEG file", "error", err.Error()) return []string{} } imgJpeg, err := jpeg.Decode(imgIn) if err != nil { slog.Error("failed to decode JPEG", "error", err.Error()) return []string{} } err = imgIn.Close() if err != nil { slog.Error("failed to close JPEG file", "error", err.Error()) return []string{} } imgJpeg = resize.Resize(uint(path.Lenght[i]), uint(path.Width[i]), imgJpeg, resize.Bilinear) upPath := path.Folder + path.UUID + "_" + strconv.FormatUint(uint64(path.Lenght[i]), 10) + "x" + strconv.FormatUint(uint64(path.Width[i]), 10) + "." + path.Format buf := new(bytes.Buffer) err = jpeg.Encode(buf, imgJpeg, &jpeg.Options{Quality: 100}) if err != nil { slog.Error("failed to encode JPEG", "error", err.Error()) return []string{} } imgSave := buf.Bytes() err = os.WriteFile(upPath, imgSave, 0666) if err != nil { slog.Error("failed to save JPEG file", "error", err.Error()) return []string{} } mu.Lock() uploadPaths = append(uploadPaths, upPath) mu.Unlock() } // по аналогии с кейсом png case "webp": for i := range path.Lenght { imgIn, err := os.Open(path.Path) if err != nil { slog.Error("failed to open WEBP file", "error", err.Error()) return []string{} } // Decode webp mg, err := io.ReadAll(imgIn) if err != nil { slog.Error("failed to read WEBP file", "error", err.Error()) return []string{} } m, err := webp.DecodeRGB(mg) if err != nil { slog.Error("failed to decode WEBP", "error", err.Error()) return []string{} } err = imgIn.Close() if err != nil { slog.Error("failed to close WEBP file", "error", err.Error()) return []string{} } imgWebp := resize.Resize(uint(path.Lenght[i]), uint(path.Width[i]), m, resize.Bilinear) upPath := path.Folder + path.UUID + "_" + strconv.FormatUint(uint64(path.Lenght[i]), 10) + "x" + strconv.FormatUint(uint64(path.Width[i]), 10) + "." + path.Format imgSave, err := webp.EncodeRGB(imgWebp, 100) if err != nil { slog.Error("failed to encode WEBP", "error", err.Error()) return []string{} } err = os.WriteFile(upPath, imgSave, 0666) if err != nil { slog.Error("failed to save WEBP file", "error", err.Error()) return []string{} } mu.Lock() uploadPaths = append(uploadPaths, upPath) mu.Unlock() } default: slog.Error("unknown file format, please provide a file with extension png,jpg,webp") } return uploadPaths }
Теперь реализуем main.go в котором создадим и вызовем наш сервис обработки изображений.
./internal/cmd/main.go
package main import ( "image-converter/internal/app" "log/slog" ) func main() { imageServer := app.NewImageServer() grpcServer := app.NewGrpcServer() err := grpcServer.GrpcServeServer(imageServer, ":8086") if err != nil { slog.Warn("Server shutdown with error", "error", err.Error()) } }
Наше приложение полностью готово, но теперь нужно удостовериться, что у нас работает поддержка CGO. Для начала нужно убедиться что мы скачали и установили webp по ссылке официальная страница Google. Далее нам необходимо установить GCC, без него go не может распознать импортированные C файлы, скачиваем и устанавливаем официальные зеркала GNU. Теперь нужно проверить, что переменная CGO_ENABLED=1, для этого используем команду go env и проверяем, если она равна 0, то используем go set CGO_ENABLED=1 и проверяем ,что она применилась, если нет то перезагружаем нашу систему и проверяем. Теперь мы готовы собрать наше приложение и приступить к тестированию.
Здесь вы можете тестировать удобными для вас средствами такими как Postman, Bruno, Yaak, grpccurl и т.д., главное чтобы они поддерживали тестирование grpc методов. Рассмотрим пример с Bruno:
1.Конвертируйте изображение в Base64: Image to Base64 Converter

2.Откройте Bruno создайте новый grpc запрос

Выбираем метод grpc:

4.Уберите ползунок с reflection и укажите .proto файл

5.Выберите метод DownloadImage

6.В поле message заполните поле в соответствии с параметрами, например:

В поле image нужно скопировать текстовую строку ,что идёт после запятой, сгенерированную на шаге 1
7.Далее нужно запустить наше приложение и подключиться к нему с помощью кнопки →

В случае успеха должен появиться статус streaming
8.Отправляем наше сообщение/сообщения нажав на кнопку "Send message" один или несколько раз и завершаем нашу передачу сообщений с помощью кнопки → (если не нажать то приложение будет ожидать приёма сообщений). Результатом будет возврат путей наших обработанных сообщений

Подведем итоги, мы создали сервис который:
Использует gRPC для эффективной передачи данных,
Поддерживает WebP, JPEG, PNG,
Безопасен в конкурентной среде,
Можно легко масштабировать.
В результате получился сервис обработки изображений. Этот подход применим не только к изображениям, но и к любым бинарным данным.
Исходный код доступен по ссылке
Спасибо за внимание!