Skip to main content
ArticlesProjects

Building a Go microservice using gRPC for image metadata extraction

Learn how to build a production-grade Go microservice that extracts EXIF and image metadata using gRPC, complete with S3/R2 integration.

Steve McDougallFeb 20265 min readUpdated Aug 2026

Every media pipeline I have worked on eventually grows the same component, whether anyone planned for it or not: something that can answer “what exactly is this image?” Camera model, dimensions, geolocation, colour space. They sound like technical trivia until you realise they are what powers search, filtering, ranking and display logic everywhere downstream.

It usually starts as a function buried in the upload handler. Then it needs to run on a backfill. Then something else wants it too. At that point you may as well admit it is a service and build it properly.

So that is what I want to walk through here: a Go microservice exposing a gRPC API for image metadata extraction. Not a toy example - a realistic shape. Clients send object storage references such as S3 keys or Cloudflare R2 paths, the service fetches the file, extracts EXIF and general image metadata, and returns a structured response.

By the end you will have a service you could drop into a real ingestion pipeline.

The realistic problem

Assume we’re building a media ingestion system for a stock-photo platform. Creators upload high-resolution images into a storage bucket. A separate ingestion orchestrator sends a request to the metadata service, saying: “Extract metadata for the file at images/uploads/2025/11/beach-sunrise.jpg.”

We don’t want this micro-service to handle uploads or storage writes. It only performs fetch, extract, and respond. This separation ensures ingestion pipelines stay flexible, the storage layer remains the source of truth, the service is stateless and horizontally scalable, and you can plug in different storage backends later.

The service needs to extract EXIF metadata (camera model, exposure, aperture, geolocation), dimensions, colour profile, orientation, file type, and optionally file size.

We’ll build this using Go 1.22+, gRPC with Protocol Buffers, AWS S3 or Cloudflare R2 via the S3 API, and go-exif / imaging libraries.

Project structure

Before writing any code, it helps to establish the directory structure.

metadata-service/
├── cmd/
│ └── metadata/
│ └── main.go
├── internal/
│ ├── extractor/
│ │ ├── exif.go
│ │ └── image.go
│ ├── service/
│ │ └── service.go
│ └── storage/
│ └── s3.go
├── proto/
│ └── metadata/
│ └── v1/
│ └── metadata.proto
├── gen/
│ └── metadata/
│ └── v1/
│ ├── metadata.pb.go
│ └── metadata_grpc.pb.go
├── docker-compose.yml
├── Dockerfile
├── Makefile
├── go.mod
└── go.sum

Designing the protocol buffer schema

The .proto file defines our API contract. It must be explicit, strongly typed, and stable.

File: proto/metadata/v1/metadata.proto

syntax = "proto3";
package metadata.v1;
option go_package = "github.com/juststeveking/metadata-service/gen/metadata/v1;metadatav1";
message ExtractRequest {
string bucket = 1;
string object_key = 2;
}
message Exif {
string camera_make = 1;
string camera_model = 2;
string lens_model = 3;
string exposure_time = 4;
string f_number = 5;
string iso = 6;
double latitude = 7;
double longitude = 8;
}
message ImageProperties {
uint32 width = 1;
uint32 height = 2;
string format = 3;
string color_space = 4;
uint64 file_size_bytes = 5;
}
message ExtractResponse {
Exif exif = 1;
ImageProperties properties = 2;
}
service MetadataService {
rpc ExtractMetadata(ExtractRequest) returns (ExtractResponse);
}

The schema focuses on structured data, not blobs. Metadata should be queryable, not opaque.

Building the extractors

I like to separate extraction logic into focused packages. EXIF parsing and image property extraction are distinct concerns.

File: internal/extractor/exif.go

package extractor
import (
"bytes"
"github.com/rwcarlsen/goexif/exif"
pb "github.com/juststeveking/metadata-service/gen/metadata/v1"
)
func ParseExif(data []byte) *pb.Exif {
x, err := exif.Decode(bytes.NewReader(data))
if err != nil {
return nil
}
out := &pb.Exif{}
if cam, err := x.Get(exif.Make); err == nil && cam != nil {
out.CameraMake, _ = cam.StringVal()
}
// ... rest of extraction logic
return out
}

Building the gRPC service

Now we wire everything together into the actual service implementation.

File: internal/service/service.go

package service
import (
"context"
"log/slog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/juststeveking/metadata-service/gen/metadata/v1"
)
type MetadataService struct {
pb.UnimplementedMetadataServiceServer
// ... storage and logger
}
func (s *MetadataService) ExtractMetadata(
ctx context.Context,
req *pb.ExtractRequest,
) (*pb.ExtractResponse, error) {
// 1. Fetch from storage
// 2. Parse EXIF
// 3. Parse Image Properties
// 4. Return response
}

The decision that matters most

If you take one thing from this, make it the boundary rather than the code. This service speaks in storage keys, not bytes. It does not handle uploads and it does not write to storage.

That single choice is what makes everything else easy. It keeps the service stateless, so you can scale it horizontally without thinking hard. It keeps the storage layer as the source of truth. It means you can swap S3 for R2 later without touching extraction logic. And it makes the thing genuinely testable, because the input is a reference you can fake rather than a multipart upload you have to simulate.

Get the boundary right and the rest of it is just plumbing.

Share

XLinkedIn

Related

Keep Reading

All posts →