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.
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.sumDesigning 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.
Keep Reading
Building Research
A desktop research workspace in Laravel and NativePHP. Streaming SSE into a queued job, distilling reports with a local model, and why cosine similarity cannot tell a paraphrase from a contradiction.
Aug 2026 · 22 min read
LaravelEvery Feature Touches Ten Files
Ten files open for a one line change is either layering working correctly or one idea smeared across a codebase. The count does not tell you which, and git history does.
Aug 2026 · 4 min read
LaravelIt Was Fine Until We Added A Second One
Every trigger in this series has been a second something. That is not a coincidence, and it is the only signal in here reliable enough to act on.
Aug 2026 · 8 min read