Usando AWS S3 en local con LocalStack

alextremp

Alex Castells

Posted on March 11, 2021

Usando AWS S3 en local con LocalStack

En ocasiones nuestros servicios deben integrarse con infraestructuras difíciles de reproducir en local para el desarrollo o para ejecutar los tests, y AWS es un ejemplo de ello.

Para los tests, a veces con mocks puede bastar para sortear el problema.

Pero, para probar nuestro servicio en local, deberíamos usar nuestras credenciales de AWS y conectarnos al entorno real? Y... los demás miembros del equipo?

En este post veremos qué es LocalStack y lo usaremos para hacer funcionar en local un servicio para almacenar ficheros en S3.

LocalStack bajo el lema "a fully functional local AWS cloud stack" puede ser la herramienta que necesitemos para algunos de nuestros desarrollos con los SDKs de AWS.

Dispone de una versión gratuita que nos permitirá desarrollar contra servicios como S3, SQS, DynamoDb, ...
Y otra versión de pago con servicios como CloudFront, Neptune, ...

ver detalle de servicios en cada versión...

Por poner un ejemplo (y código 🤓), supongamos que necesitamos una API contra la que postear imágenes que luego vamos a usar para servirlas en un entorno web.

Este es un caso de uso ideal para usar un bucket de S3 como sistema de almacenamiento y CloudFront como sistema de recuperación.

Así que... vamos a probar LocalStack 💻🚀

Nota: CloudFront forma parte de los servicios de la distribución de pago de LocalStack, así que no lo podremos probar en local sin pagar... aunque... si sabemos cómo acaba linkado un bucket de S3 como origin de una distribución de CloudFront y cómo afecta a las URL para recuperar los ficheros almacenados en S3, algo se nos va a ocurrir :)

Ejemplo

AWS S3 storage en local con LocalStack

Este proyecto es una demo para un artículo en dev.to/alextremp

Servicio de almacenamiento de ficheros a bucket de S3

  • Un endpoint POST para enviar un fichero a un path específico.
  • El servicio almacenará el fichero a un bucket de S3.
  • Devolverá la URL de acceso del fichero, ya sea local, de S3 o de CloudFront si se dispone.

Uso

El endpoint de ejemplo estará expuesto para POST en http://localhost:8080/storage Acepta form-data con:

  • file: El fichero que queremos guardar en S3
  • path: La ruta donde queremos guardar el fichero, relativa respecto el bucket

Ejecución en local contra LocalStack

docker-compose up -d
./gradlew bootRun
Enter fullscreen mode Exit fullscreen mode

Ejecución en local contra bucket real

# active profile set to production
PROFILE="--spring.profiles.active=pro"
# your AWS access key
ACCESS="--s3-storage.accessKey="
# your AWS secret key
SECRET="--s3-storage.secretKey="
# your AWS S3 bucket
Enter fullscreen mode Exit fullscreen mode

Stack:



java, spring-boot, aws-sdk, testcontainers


Enter fullscreen mode Exit fullscreen mode

Levantando S3 con docker-compose

Configuremos el servicio de localstack:

docker-compose.yml



version: '3.5'

services:
  s3-storage:
    image: localstack/localstack:0.12.5
    environment:
      # permite más servicios separados por comas
      - SERVICES=s3 
      - DEBUG=1
      - DEFAULT_REGION=eu-west-1
      - AWS_ACCESS_KEY_ID=test
      - AWS_SECRET_ACCESS_KEY=test
    ports:
      # localstack usa rango de puertos, para el ejemplo,
      # usaremos solo el de S3, mapeado en local a 14566 en un 
      # fichero docker-compose.override.yml para permitir 
      # tests con puerto dinámico
      - '4566'
    volumes:
      # inicializaremos un bucket aquí
      - './volumes/s3-storage/.init:/docker-entrypoint-initaws.d'
      # no versionado, localstack nos generará aquí el .pem 
      # para nuestras claves de acceso fake
      - './volumes/s3-storage/.localstack:/tmp/localstack'


Enter fullscreen mode Exit fullscreen mode

Podemos generar un bucket al iniciar el docker-compose:

./volumes/s3-storage/.init/buckets.sh



#!/bin/bash
aws --endpoint-url=http://localhost:4566 s3 mb s3://com.github.alextremp.storage


Enter fullscreen mode Exit fullscreen mode

Al ejecutar docker-compose up deberíamos ver que el cliente de AWS de la imagen de localstack ha generado el bucket que le hemos indicado en el script de inicialización:

image

Interactuando con el bucket

Para el artículo me interesa explicaros sólo unos puntos concretos del código de ejemplo, para que veamos cómo podemos hacerlo funcionar en local con LocalStack y en entorno productivo con AWS.

Tomando como referencia el repositorio que usará el SDK de AWS para S3 de cara a guardar un fichero (en realidad el InputStream de un recurso recibido en un POST multipart):



  public S3ResourceRepository(S3Client s3Client,
                              S3ResourceRepositoryOptions repositoryOptions,
                              DestinationFactory destinationFactory) {
    this.s3Client = s3Client;
    this.repositoryOptions = repositoryOptions;
    this.destinationFactory = destinationFactory;
  }

  @Override
  public Destination save(StreamableResource streamableResource, ResourceOptions resourceOptions) {
    PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
          // the target bucket
          .bucket(repositoryOptions.getBucket())
          // the target path in the bucket
          .key(resourceOptions.getPath());

    // setting the content-type makes it web-friendly when being read
    requestBuilder.contentType(URLConnection.guessContentTypeFromName(resourceOptions.getPath()));
    // it's highly recommendable to specify the cache-control for presupposed repetitive reads, specially if it's combined with CloudFront
    requestBuilder.cacheControl(String.format("public, max-age=%s", resourceOptions.getMaxAge()));

    if (!repositoryOptions.hasOriginReference()) {
      // when needed to be read directly from S3, no need if it's a bucket linked to CloudFront
      requestBuilder.acl(ObjectCannedACL.PUBLIC_READ);
    }

    PutObjectRequest objectRequest = requestBuilder.build();
    s3Client.putObject(objectRequest, RequestBody.fromInputStream(streamableResource.stream(), streamableResource.contentLength()));

    return destinationFactory.create(resourceOptions.getPath());
  }



Enter fullscreen mode Exit fullscreen mode

En realidad, para que el servicio sea funcional en local igual que en producción (e incluso si tuviéramos un entorno intermedio de desarrollo o pre-producción en la nube), lo más interesante son las opciones que podemos necesitar modificar para el funcionamiento del repositorio en cada uno de los entornos.



@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
@Accessors(chain = true)
@NoArgsConstructor
public class S3ResourceRepositoryOptions {
  String region;
  String accessKey;
  String secretKey;
  String bucket;
  String endpoint;
  String originFor;

  public Boolean hasCustomEndpoint() {
    return StringUtils.isNotBlank(endpoint);
  }

  public Boolean hasOriginReference() {
    return StringUtils.isNotBlank(originFor);
  }
}


Enter fullscreen mode Exit fullscreen mode

S3 Endpoint Override



# application-dev.yml
s3-storage:
  endpoint: http://localhost:14566


Enter fullscreen mode Exit fullscreen mode

Con respecto a la opción hasCustomEndpoint, en local, tanto para podernos comunicar con S3 al guardar un fichero (por defecto, tipo s3://BUCKET/PATH), como para luego poderlo recuperar vía HTTP (por defecto, tipo https://s3-REGION.amazonaws.com/BUCKET/PATH), necesitamos modificar el endpoint que expone S3 en LocalStack y usarlo en el servicio para comunicarnos con él.

  • En LocalStack ya lo hemos hecho en el script de inicialización en docker-compose mediante aws --endpoint-url=http://localhost:4566 ...

  • En la creación del cliente de S3, debemos ligarlo mediante la opción endpointOverride:



@Configuration
@ComponentScan("com.github.alextremp.storage.infrastructure.aws")
public class AwsConfiguration {

  @Bean
  @ConfigurationProperties(prefix = "s3-storage")
  public S3ResourceRepositoryOptions s3ResourceRepositoryOptions() {
    return new S3ResourceRepositoryOptions();
  }

  @Bean
  public S3Client s3Client(S3ResourceRepositoryOptions s3ResourceRepositoryOptions) {
    S3ClientBuilder s3ClientBuilder = S3Client.builder()
          .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
                s3ResourceRepositoryOptions.getAccessKey(),
                s3ResourceRepositoryOptions.getSecretKey()
          )))
          .region(Region.of(s3ResourceRepositoryOptions.getRegion()));

    if (s3ResourceRepositoryOptions.hasCustomEndpoint()) {
      s3ClientBuilder.endpointOverride(URI.create(s3ResourceRepositoryOptions.getEndpoint()));
    }
    return s3ClientBuilder.build();
  }
}


Enter fullscreen mode Exit fullscreen mode

Esto nos permitirá guardar correctamente ficheros en localhost:
image

Así como recuperarlos vía HTTP:
image

Habilitando salida en CloudFront para PROD

Si usáramos este servicio para interactuar con un bucket real, sin CloudFront, p.ej.:



# application-pro.yml
s3-storage:
  bucket: a.dcdn.es

  # add the accessKey and secretKey config...


Enter fullscreen mode Exit fullscreen mode

La URL que necesitaríamos generar para poder acceder vía HTTPS al mismo recurso en S3, sería algo así:
https://s3-eu-west-1.amazonaws.com/a.dcdn.es/demo/demo-image.png

image

Sin embargo, si tuviéramos ese bucket mapeado como origen en una distribución de CloudFront, p.ej.:

image

Aunque no podamos probar CloudFront en LocalStack, podríamos configurar el servicio para que generara las URL necesarias para la salida via CloudFront:



# application-pro.yml
s3-storage:
  bucket: a.dcdn.es
  originFor: a.dcdn.es

  # add the accessKey and secretKey config...


Enter fullscreen mode Exit fullscreen mode

image

Para que esto sea así, y para terminar con el ejemplo de código, sólo necesitaremos que nuestro servicio sea capaz de generar las URL según las propiedades del storage:



@Component
public class AwsDestinationFactory implements DestinationFactory {

  private static final String HTTPS_PROTOCOL = "https://";
  private static final String AWS_S3_DOMAIN_TEMPLATE = "s3-%s.amazonaws.com";

  private final S3ResourceRepositoryOptions options;

  public AwsDestinationFactory(S3ResourceRepositoryOptions options) {
    this.options = options;
  }

  @Override
  @SneakyThrows
  public Destination create(String path) {
    StringBuilder rootBuilder = new StringBuilder();
    if (options.hasOriginReference()) {
      rootBuilder.append(HTTPS_PROTOCOL)
            .append(options.getOriginFor());
    } else if (options.hasCustomEndpoint()) {
      rootBuilder.append(options.getEndpoint())
            .append(Destination.SEPARATOR)
            .append(options.getBucket());
    } else {
      rootBuilder.append(HTTPS_PROTOCOL)
            .append(String.format(AWS_S3_DOMAIN_TEMPLATE, options.getRegion()))
            .append(Destination.SEPARATOR)
            .append(options.getBucket());
    }
    return new Destination(rootBuilder.toString(), path);
  }
}


Enter fullscreen mode Exit fullscreen mode

🚀🚀

Conclusiones

Desde mi punto de vista, cuando hay código en producción cuya ejecución no es reproducible en local / automatizable en tests, tenemos dos opciones: hacerlo reproducible en local, o cruzar los dedos 😬

Pero como necesitamos los dedos para teclear, LocalStack, junto con Docker Compose y Test Containers pueden ayudarnos a solventar los problemas de infraestructura para la ejecución local cuando trabajamos integrados con servicios de AWS, de modo que nosotros podamos focalizarnos en el código más que en el entorno de ejecución.

Si algún día hacéis algo con S3, sois libres de copy-pastear lo que necesitéis :)

💖 💪 🙅 🚩
alextremp
Alex Castells

Posted on March 11, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related