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, ...
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 :)
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
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
version:'3.5'services:s3-storage:image:localstack/localstack:0.12.5environment:# 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=testports:# 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:
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:
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):
publicS3ResourceRepository(S3Clients3Client,S3ResourceRepositoryOptionsrepositoryOptions,DestinationFactorydestinationFactory){this.s3Client=s3Client;this.repositoryOptions=repositoryOptions;this.destinationFactory=destinationFactory;}@OverridepublicDestinationsave(StreamableResourcestreamableResource,ResourceOptionsresourceOptions){PutObjectRequest.BuilderrequestBuilder=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 readrequestBuilder.contentType(URLConnection.guessContentTypeFromName(resourceOptions.getPath()));// it's highly recommendable to specify the cache-control for presupposed repetitive reads, specially if it's combined with CloudFrontrequestBuilder.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 CloudFrontrequestBuilder.acl(ObjectCannedACL.PUBLIC_READ);}PutObjectRequestobjectRequest=requestBuilder.build();s3Client.putObject(objectRequest,RequestBody.fromInputStream(streamableResource.stream(),streamableResource.contentLength()));returndestinationFactory.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.
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:
Sin embargo, si tuviéramos ese bucket mapeado como origen en una distribución de CloudFront, p.ej.:
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.ymls3-storage:bucket:a.dcdn.esoriginFor:a.dcdn.es# add the accessKey and secretKey config...
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:
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 :)