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 :)
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
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
…Stack:
java, spring-boot, aws-sdk, testcontainers
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'
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
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):
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.
@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
# 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
:
@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:
Así como recuperarlos vía HTTP:
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...
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
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.yml
s3-storage:
bucket: a.dcdn.es
originFor: 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:
@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);
}
}
🚀🚀
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 :)
Top comments (1)
Thanks for your article, it matched my work.