Usando AWS S3 en local con LocalStack
Tags: backend, aws, microservices, testing
Published: 2021-03-11 11:57:08

> _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**](https://github.com/localstack/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...](https://github.com/localstack/localstack#overview)_

Por poner un ejemplo (y código :nerd_face:), 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](https://aws.amazon.com/s3/) como sistema de almacenamiento y [CloudFront](https://aws.amazon.com/cloudfront/) como sistema de recuperación.

Así que... vamos a probar LocalStack :computer::rocket:

> _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

{% github alextremp/s3-storage-service-demo %}

`Stack:`
```
java, spring-boot, aws-sdk, testcontainers
```

#Levantando S3 con docker-compose

Configuremos el servicio de localstack:

`docker-compose.yml`
```yaml
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'
```

Podemos generar un bucket al iniciar el docker-compose:

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

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ire48awxbjyrurfdeod3.png)


#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):

```java
  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());
  }

```

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.

```java
@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);
  }
}
```

##S3 Endpoint Override

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

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`:

```java
@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();
  }
}
```

Esto nos permitirá guardar correctamente ficheros en localhost:
![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2b81ere8ks6vgmlv0bw3.png)


Así como recuperarlos vía HTTP:
![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pxoz0ysxca0f3az8g679.png)

##Habilitando salida en CloudFront para PROD

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

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

  # add the accessKey and secretKey config...
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6xiu8jikyiet6uox57jz.png)

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

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/44rwpsqfjb6o1hnfy87x.png)

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

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

  # add the accessKey and secretKey config...
```

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ar4oy3uhd57ezs80flai.png)

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:

```java
@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);
  }
}
```

:rocket::rocket:


#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 :grimacing:

Pero como necesitamos los dedos para teclear, [LocalStack](https://github.com/localstack/localstack), junto con [Docker Compose](https://docs.docker.com/compose/) y [Test Containers](https://www.testcontainers.org/) 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 :)

{% github alextremp/s3-storage-service-demo no-readme %}