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
Eighteen Files And One Nix Module: How My Brain Works
The Obsidian vault I use as a system of record for every project, why the checkouts inside it are gitignored, and why half of it is generated from my NixOS flake.
Aug 2026 · 10 min read
AIMCP Goes Stateless: What Changed and Why It Matters
The Streamable HTTP transport replaced HTTP+SSE and quietly made MCP servers stateless. Here is what that means, how the new transport works, and why it unlocks serverless and edge deployments.
Aug 2026 · 12 min read
DevOpsOne Host, Twenty-One Files: A NixOS Flake That Stays Out Of Your Way
How auto-imported flake-parts modules, wrapped desktop packages, and a single base16 palette file shape one NixOS config - and where each one bites.
Aug 2026 · 13 min read