Создаём микросервис обработки изображений на Go с gRPC

Обложка: Создаём микросервис обработки изображений на Go с gRPC

Вступление

В этой статье мы рассмотрим создание микросервиса обработки изображений на golang с использованием технологии **gRPC**. Цель статьи - показать как может выглядеть такой сервис и что он может в себя включать. В результате мы получим полностью рабочий сервис по обработке изображений, который принимает данные, сохраняет исходную картинку,сжимает её, накладывает на неё ватермарку, изменяет размер изображения, и конвертирует его в нужный формат.

Разберём возможные варианты взаимодействия клиента с сервером для обработки больших объектов, в нашем случае это картинки:

1. HTTP/1.1 (REST)

Передача изображений в виде текстовых чанков (например, base64) приводит к значительным накладным расходам: бинарные данные увеличиваются на ~33% при кодировании в Base64, а текстовый формат неэффективен для больших объёмов.

2. WebSocket

Подходит для долгоживущих сессий и двустороннего обмена, но избыточен, если нам нужно просто «принять изображение → обработать → вернуть результат». Удержание тысяч соединений ради однократных операций — неоптимально.

3. gRPC использует:

Protocol Buffers — строго типизированный, компактный бинарный формат,

HTTP/2 — мультиплексирование, потоки, сжатие заголовков,

Client-Streaming — идеально подходит для передачи одного большого файла (например, изображения) в одном вызове.

I. Постановка задачи

Сервис должен:

Принимать изображение и параметры (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

II. Архитектура приложения

Обработка происходит в строгом порядке:

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.

Для генерации protobuff нам нужен protoc-gen-go

			go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
		

protoc-gen-go-grpc

Позволяет генерировать определения сервисов Go для буфера протокола, заданного нашим .proto файлом protoc-gen-go-grpc

Также для работы webp нам потребуется работа с CGO, которую мы рассмотрим отдельно.

III. Реализация proto файла

Для начала работы опишем наш 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 — клиент может отправить несколько сообщений, сервер — один ответ. Этот способ поможет нам в случае необходимости гибкого расширения и избежания ограничений на размер одного сообщения.

V. Реализация основных функций

1. Создание сервера

1.1 Генерация go файлов из .proto: Сгенерируем файлы для реализации сервера с помощью 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

1.2 Реализуем конструктор сервера и interceptor (аналог middleware в grpc) восстановления после паники:

./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 сервера:

			...
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, это просто интерфейс который нам и нужно реализовать.

2. Реализация ImageServiceServer

Нам необходимо создать структуру 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",
		})
	}
	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: изменения размера картинки и его конвертация в нужный формат изображения.

3. Сохранение оригинала

Мы создаем путь для сохранения картинки, сохраняем её с необходимым для клиента уровнем сжатия и потом для удобства всю метаинформацию об картинке помещаем уже в нашу структуру и работаем в дальнейшем только с ней:

./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 {
		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
}
		

Для ускорения нашего процесса обработки все этапы мы будем выполнять конкурентно с помощью горутин.

4. Водяной знак

Следующим этапом идёт наложение водяного знака на нашу картинку. Процесс наложения: мы передаем каждой горутине по изображению, они его обрабатывают, а для того чтобы убедиться что они все обработались мы применяем 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
	currDir, err := os.Getwd()
	if err != nil { return err }
	for i := range paths {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			watermarkS := paths[i].Watermark
			if watermarkS == "" || watermarkS == defaultWatermark { watermarkS = defaultWatermark }
			watermarkPath := currDir + "\\" + watermarkS
			funcErr := addWaterMark(paths[i].Path, watermarkPath)
			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
}
		

Остаётся только изменить размер и сохранить в нужном формате наши обработанные изображения.

5. Resize и конвертация.

Теперь создадим файл 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.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{} }
			err = os.WriteFile(upPath, buf.Bytes(), 0666)
			if err != nil { slog.Error("failed to save PNG file", "error", err.Error()); return []string{} }
			mu.Lock(); uploadPaths = append(uploadPaths, upPath); mu.Unlock()
		}
	case "jpg", "jpeg":
		for i := range path.Lenght {
			imgIn, _ := os.Open(path.Path)
			imgJpeg, _ := jpeg.Decode(imgIn)
			imgIn.Close()
			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)
			jpeg.Encode(buf, imgJpeg, &jpeg.Options{Quality: 100})
			os.WriteFile(upPath, buf.Bytes(), 0666)
			mu.Lock(); uploadPaths = append(uploadPaths, upPath); mu.Unlock()
		}
	case "webp":
		for i := range path.Lenght {
			imgIn, _ := os.Open(path.Path)
			mg, _ := io.ReadAll(imgIn)
			m, _ := webp.DecodeRGB(mg)
			imgIn.Close()
			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, _ := webp.EncodeRGB(imgWebp, 100)
			os.WriteFile(upPath, imgSave, 0666)
			mu.Lock(); uploadPaths = append(uploadPaths, upPath); mu.Unlock()
		}
	default:
		slog.Error("unknown file format")
	}
	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())
    }
}
		

VI. CGO

Наше приложение полностью готово, но теперь нужно удостовериться, что у нас работает поддержка CGO. Для начала нужно убедиться что мы скачали и установили webp по ссылке [официальная страница Google](https://developers.google.com/speed/webp/download?hl=ru). Далее нам необходимо установить GCC, без него go не может распознать импортированные C файлы, скачиваем и устанавливаем [официальные зеркала GNU](https://gcc.gnu.org/mirrors.html). Теперь нужно проверить, что переменная CGO_ENABLED=1, для этого используем команду go env и проверяем, если она равна 0, то используем go set CGO_ENABLED=1 и проверяем ,что она применилась, если нет то перезагружаем нашу систему и проверяем. Теперь мы готовы собрать наше приложение и приступить к тестированию.

VII. Тестирование и оптимизация

Тестирование с Bruno

Здесь вы можете тестировать удобными для вас средствами такими как Postman, Bruno, Yaak, grpccurl и т.д., главное чтобы они поддерживали тестирование grpc методов. Рассмотрим пример с Bruno:

1.Конвертируйте изображение в Base64: Image to Base64 Converter

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

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

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

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

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

если появляются проблемы при подключении к сервису, то не выносите текстовое представление картинки в переменную

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

7.Далее нужно запустить наше приложение и подключиться к нему с помощью кнопки →

В случае успеха должен появиться статус streaming

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

VIII. Заключение

Подведем итоги, мы создали сервис который:

Использует gRPC для эффективной передачи данных,

Поддерживает WebP, JPEG, PNG,

Безопасен в конкурентной среде,

Можно легко масштабировать.

В результате получился сервис обработки изображений. Этот подход применим не только к изображениям, но и к любым бинарным данным.

Исходный код доступен по ссылке

Спасибо за внимание!

Рекомендуем