gRPC es un framework de llamadas a procedimientos remotos que usa HTTP/2 como transporte y Protocol Buffers para definir los servicios y serializar los mensajes, lo que lo hace más eficiente y estricto que una API REST con JSON. En este tutorial definirás un servicio gRPC, generarás el código en Go, implementarás un servidor con health checks estándar y apagado ordenado, ejecutarás dos instancias con systemd y las publicarás detrás de Nginx, que termina el TLS con un certificado de Let's Encrypt y reparte las llamadas entre ellas. Todo sobre Ubuntu 24.04.

Requisitos previos

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 1 GB de RAM.
  • Un usuario no root con privilegios sudo.
  • Go instalado desde go.dev/dl en /usr/local/go, con /usr/local/go/bin en el PATH. El paquete golang-go de Ubuntu 24.04 es demasiado antiguo para las versiones actuales de grpc-go.
  • Un dominio o subdominio con un registro A apuntando al servidor. En esta guía se usa your_domain.
  • Los puertos 80 y 443 accesibles desde Internet.

Paso 1: Instalar protoc y los plugins de Go

protoc es el compilador de Protocol Buffers; los plugins protoc-gen-go y protoc-gen-go-grpc generan, respectivamente, los tipos de los mensajes y el código cliente y servidor de gRPC. Instala el compilador desde los repositorios de Ubuntu:

sudo apt update
sudo apt install protobuf-compiler
protoc --version
libprotoc 3.21.12

Instala los plugins y grpcurl, una herramienta de línea de comandos para llamar a servicios gRPC, con go install. Los binarios quedan en ~/go/bin, que debes añadir al PATH:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.profile
source ~/.profile
protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.6.2

Paso 2: Definir el servicio en Protocol Buffers

Crea el proyecto y el directorio del contrato. Versionar el paquete (greeter.v1) te permite publicar más adelante una v2 incompatible sin romper a los clientes actuales:

mkdir -p ~/greeter/proto/greeter/v1 && cd ~/greeter
go mod init example.com/greeter
nano proto/greeter/v1/greeter.proto
syntax = "proto3";

package greeter.v1;

option go_package = "example.com/greeter/proto/greeter/v1;greeterv1";

service Greeter {
  // Llamada unaria: una petición, una respuesta
  rpc SayHello(HelloRequest) returns (HelloReply);
  // Streaming de servidor: una petición, varias respuestas
  rpc SayHelloStream(HelloRequest) returns (stream HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

Genera el código Go. paths=source_relative deja los archivos generados junto al .proto:

protoc --go_out=. --go_opt=paths=source_relative \
  --go-grpc_out=. --go-grpc_opt=paths=source_relative \
  proto/greeter/v1/greeter.proto
ls proto/greeter/v1
greeter.pb.go  greeter.proto  greeter_grpc.pb.go

No edites los archivos .pb.go a mano: se regeneran cada vez que cambias el .proto.

Paso 3: Implementar el servidor

Crea el programa del servidor:

mkdir -p cmd/server
nano cmd/server/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/health"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/keepalive"
	"google.golang.org/grpc/reflection"
	"google.golang.org/grpc/status"

	greeterv1 "example.com/greeter/proto/greeter/v1"
)

type greeterServer struct {
	greeterv1.UnimplementedGreeterServer
	instance string
}

func (s *greeterServer) SayHello(ctx context.Context, req *greeterv1.HelloRequest) (*greeterv1.HelloReply, error) {
	if req.GetName() == "" {
		return nil, status.Error(codes.InvalidArgument, "name es obligatorio")
	}
	return &greeterv1.HelloReply{Message: fmt.Sprintf("Hola, %s (instancia %s)", req.GetName(), s.instance)}, nil
}

func (s *greeterServer) SayHelloStream(req *greeterv1.HelloRequest, stream grpc.ServerStreamingServer[greeterv1.HelloReply]) error {
	for i := 1; i <= 3; i++ {
		msg := fmt.Sprintf("Hola %d/3, %s", i, req.GetName())
		if err := stream.Send(&greeterv1.HelloReply{Message: msg}); err != nil {
			return err
		}
		select {
		case <-stream.Context().Done():
			return stream.Context().Err()
		case <-time.After(time.Second):
		}
	}
	return nil
}

func main() {
	addr := os.Getenv("LISTEN_ADDR")
	if addr == "" {
		addr = "127.0.0.1:50051"
	}
	lis, err := net.Listen("tcp", addr)
	if err != nil {
		log.Fatalf("no se puede escuchar en %s: %v", addr, err)
	}

	srv := grpc.NewServer(
		grpc.KeepaliveParams(keepalive.ServerParameters{
			MaxConnectionIdle: 5 * time.Minute,
			Time:              1 * time.Minute,
			Timeout:           20 * time.Second,
		}),
		grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
			MinTime:             20 * time.Second,
			PermitWithoutStream: true,
		}),
	)

	greeterv1.RegisterGreeterServer(srv, &greeterServer{instance: addr})

	// Servicio estándar de salud (grpc.health.v1.Health)
	healthSrv := health.NewServer()
	healthpb.RegisterHealthServer(srv, healthSrv)
	healthSrv.SetServingStatus("greeter.v1.Greeter", healthpb.HealthCheckResponse_SERVING)

	// Reflexión: permite a grpcurl descubrir los servicios sin el .proto
	reflection.Register(srv)

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		log.Printf("gRPC escuchando en %s", addr)
		if err := srv.Serve(lis); err != nil {
			log.Fatalf("error del servidor: %v", err)
		}
	}()

	<-ctx.Done()
	log.Println("señal recibida, apagado ordenado")
	healthSrv.Shutdown() // pasa todos los servicios a NOT_SERVING

	done := make(chan struct{})
	go func() {
		srv.GracefulStop() // espera a que terminen las llamadas en curso
		close(done)
	}()
	select {
	case <-done:
	case <-time.After(20 * time.Second):
		log.Println("tiempo agotado, cerrando conexiones")
		srv.Stop()
	}
	log.Println("servidor detenido")
}

Puntos clave del servidor:

  • Incrustar UnimplementedGreeterServer mantiene la compatibilidad si añades métodos al .proto: los que no implementes devuelven Unimplemented en lugar de romper la compilación.
  • Los errores se devuelven con status.Error y un código gRPC (InvalidArgument, NotFound...), que el cliente recibe de forma estructurada.
  • El servicio de salud implementa el protocolo estándar grpc.health.v1.Health, que entienden balanceadores, Kubernetes y grpcurl.
  • Los parámetros de keepalive cierran conexiones inactivas y detectan clientes caídos; la política de aplicación rechaza clientes que envíen pings con demasiada frecuencia.
  • GracefulStop deja de aceptar llamadas nuevas y espera a las que están en curso, con un límite de 20 segundos antes de forzar el cierre.

Descarga las dependencias y compila un binario estático:

go mod tidy
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o greeter-server ./cmd/server

Pruébalo en primer plano:

./greeter-server
2026/09/25 10:12:03 gRPC escuchando en 127.0.0.1:50051

Desde otra sesión SSH, lista los servicios con grpcurl. -plaintext indica que esta conexión local no usa TLS:

grpcurl -plaintext 127.0.0.1:50051 list
greeter.v1.Greeter
grpc.health.v1.Health
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection

Llama al método unario:

grpcurl -plaintext -d '{"name": "CubePath"}' 127.0.0.1:50051 greeter.v1.Greeter/SayHello
{
  "message": "Hola, CubePath (instancia 127.0.0.1:50051)"
}

Detén el servidor con Ctrl+C.

Paso 4: Ejecutar dos instancias con systemd

Instala el binario y crea un usuario de sistema para el servicio:

sudo install -m 0755 -o root -g root greeter-server /usr/local/bin/greeter-server
sudo useradd --system --no-create-home --shell /usr/sbin/nologin greeter

Una unidad plantilla de systemd (con @ en el nombre) permite arrancar varias instancias del mismo servicio. El texto después de @ llega a la unidad como %i, y aquí se usa como puerto:

sudo nano /etc/systemd/system/[email protected]
[Unit]
Description=Servidor gRPC greeter (puerto %i)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=greeter
Group=greeter
Environment=LISTEN_ADDR=127.0.0.1:%i
ExecStart=/usr/local/bin/greeter-server
Restart=on-failure
RestartSec=2
KillSignal=SIGTERM
TimeoutStopSec=30

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

Arranca las instancias en los puertos 50051 y 50052:

sudo systemctl daemon-reload
sudo systemctl enable --now greeter@50051 greeter@50052
systemctl list-units 'greeter@*' --no-pager
  UNIT                  LOAD   ACTIVE SUB     DESCRIPTION
  [email protected] loaded active running Servidor gRPC greeter (puerto 50051)
  [email protected] loaded active running Servidor gRPC greeter (puerto 50052)

Comprueba el estado de salud de cada instancia con el protocolo estándar:

grpcurl -plaintext -d '{"service": "greeter.v1.Greeter"}' 127.0.0.1:50052 grpc.health.v1.Health/Check
{
  "status": "SERVING"
}

Este mismo comando, con código de salida distinto de cero si el servicio no responde SERVING, es el que puedes usar en tu sistema de monitorización.

Paso 5: Obtener el certificado TLS

Los clientes gRPC en Internet deben conectarse siempre por TLS. Nginx terminará el TLS con un certificado de Let's Encrypt. Instala Nginx y Certbot y abre el firewall:

sudo apt install nginx certbot python3-certbot-nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Crea un bloque para el puerto 80, que Certbot usa para validar el dominio y que redirige el resto del tráfico a HTTPS:

sudo nano /etc/nginx/sites-available/greeter
server {
    listen 80;
    listen [::]:80;
    server_name your_domain;

    location / {
        return 301 https://$host$request_uri;
    }
}

Actívalo y solicita el certificado. Con certonly, Certbot obtiene el certificado sin modificar la configuración de Nginx:

sudo ln -s /etc/nginx/sites-available/greeter /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --nginx -d your_domain
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/your_domain/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/your_domain/privkey.pem

Para que Nginx cargue el certificado renovado sin intervención, añade un hook de despliegue:

sudo sh -c 'printf "#!/bin/sh\nsystemctl reload nginx\n" > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Paso 6: Configurar Nginx como proxy gRPC

Nginx habla gRPC con el módulo ngx_http_grpc_module, incluido en el paquete de Ubuntu. La directiva grpc_pass reenvía cada llamada a un backend del upstream, repartiéndolas por turnos entre las dos instancias. Añade el bloque HTTPS al mismo archivo del sitio:

sudo nano /etc/nginx/sites-available/greeter
upstream greeter_grpc {
    server 127.0.0.1:50051 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:50052 max_fails=3 fail_timeout=10s;
    keepalive 16;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name your_domain;

    ssl_certificate     /etc/letsencrypt/live/your_domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    access_log /var/log/nginx/greeter-access.log;
    error_log  /var/log/nginx/greeter-error.log;

    location / {
        grpc_pass grpc://greeter_grpc;
        grpc_set_header X-Real-IP $remote_addr;

        # Streams largos: evita que Nginx corte la llamada
        grpc_read_timeout 1h;
        grpc_send_timeout 1h;

        # Si no puede conectar con un backend, prueba el otro
        grpc_next_upstream error timeout;
    }
}
  • http2 en el listen es obligatorio: gRPC no funciona sobre HTTP/1.1.
  • grpc_read_timeout por defecto es de 60 segundos, demasiado poco para llamadas de streaming que duran más.
  • max_fails y fail_timeout retiran temporalmente un backend que no responde, y grpc_next_upstream pasa la llamada al otro backend cuando la conexión falla o expira. Nginx no reintenta una llamada que ya llegó a enviar al backend, porque las peticiones gRPC son POST y no se consideran idempotentes.

Valida y recarga Nginx:

sudo nginx -t
sudo systemctl reload nginx

Paso 7: Probar el servicio a través de TLS

Desde tu equipo (con grpcurl instalado) o desde el propio servidor, llama al servicio por el puerto 443. Sin -plaintext, grpcurl usa TLS y valida el certificado contra las CA del sistema:

grpcurl -d '{"name": "CubePath"}' your_domain:443 greeter.v1.Greeter/SayHello
grpcurl -d '{"name": "CubePath"}' your_domain:443 greeter.v1.Greeter/SayHello
{
  "message": "Hola, CubePath (instancia 127.0.0.1:50051)"
}
{
  "message": "Hola, CubePath (instancia 127.0.0.1:50052)"
}

Las respuestas alternan entre instancias, lo que confirma el reparto de carga. Prueba también el streaming de servidor, que devuelve un mensaje por segundo:

grpcurl -d '{"name": "CubePath"}' your_domain:443 greeter.v1.Greeter/SayHelloStream
{
  "message": "Hola 1/3, CubePath"
}
{
  "message": "Hola 2/3, CubePath"
}
{
  "message": "Hola 3/3, CubePath"
}

Y la validación de errores:

grpcurl -d '{}' your_domain:443 greeter.v1.Greeter/SayHello
ERROR:
  Code: InvalidArgument
  Message: name es obligatorio

Paso 8: Actualizar sin interrumpir el servicio

Con dos instancias puedes desplegar una nueva versión reiniciándolas de una en una. Mientras una se reinicia, Nginx envía las llamadas a la otra:

sudo install -m 0755 -o root -g root greeter-server /usr/local/bin/greeter-server.new
sudo mv /usr/local/bin/greeter-server.new /usr/local/bin/greeter-server
sudo systemctl restart greeter@50051
grpcurl -plaintext -d '{"service": "greeter.v1.Greeter"}' 127.0.0.1:50051 grpc.health.v1.Health/Check
sudo systemctl restart greeter@50052

Reinicia la segunda instancia solo después de que la primera responda SERVING. En el journal verás el apagado ordenado:

journalctl -u greeter@50051 -n 4 --no-pager
... greeter-server[7102]: señal recibida, apagado ordenado
... greeter-server[7102]: servidor detenido
... systemd[1]: Started [email protected] - Servidor gRPC greeter (puerto 50051).
... greeter-server[7240]: gRPC escuchando en 127.0.0.1:50051

Solución de problemas

grpcurl devuelve Failed to dial target host. Nginx no escucha en 443 o el firewall lo bloquea. Comprueba sudo ss -ltnp 'sport = :443' y sudo ufw status.

El cliente recibe Unavailable con un 502 de Nginx. Ninguna instancia responde. Revisa systemctl status 'greeter@*' y /var/log/nginx/greeter-error.log.

Error upstream sent too large http2 frame en el log de Nginx. El backend no habla HTTP/2 en texto plano: el upstream apunta a un puerto HTTP/1.1 o a un servidor gRPC con TLS. En el segundo caso usa grpc_pass grpcs:// en lugar de grpc://. Recuerda también que para gRPC se usa grpc_pass, no proxy_pass.

Los streams se cortan justo al minuto. Falta grpc_read_timeout en la location; el valor por defecto es de 60 segundos.

ENHANCE_YOUR_CALM y too_many_pings en los logs del cliente. El cliente envía pings de keepalive más a menudo que el MinTime de 20 segundos de la política del servidor. Sube el intervalo en el cliente o baja MinTime.

Conclusión

Tienes un servicio gRPC en Go definido con Protocol Buffers, con health checks estándar y apagado ordenado, ejecutándose en dos instancias gestionadas por systemd y publicado con TLS a través de Nginx, que reparte las llamadas y retira los backends caídos. Como siguientes pasos, puedes generar clientes en otros lenguajes a partir del mismo .proto, añadir autenticación con un interceptor que valide un token en los metadatos de cada llamada, o validar tus archivos .proto y detectar cambios incompatibles con la herramienta buf.