文系プログラマによるTIPSブログ

文系プログラマ脳の私が開発現場で学んだ事やプログラミングのTIPSをまとめています。

spring-cloud-starter-awsがローカル環境でエラーになる場合の最低限の対応

全然解らない。私は雰囲気でspring-cloud-starter-awsを使っている・・・・

f:id:treeapps:20171029033317p:plain

javaやkotlinでorg.springframework.cloud:spring-cloud-starter-awsを使う事がまあまああったりします。これを使うとawsのリージョンを自動取得してくれたり、awsフレンドリーな状態でawsのサービスを扱う事ができるようになります。

環境

  • java or kotlin
  • Spring boot(v1系、v2系のどらでも起きます)
  • build.gradleやpom.xmlにorg.springframework.cloud:spring-cloud-starter-awsを設定している
  • application.ymlにはspring-cloud-starter-awsの設定を何も記述していない

エラーを解消した最終的なローカル環境向けのapplication.yml設定

いきなり答えを記述すると、EC2以外の環境(ローカル環境等)の場合は、以下になります。

cloud:
  aws:
    stack:
      # CloudFormationのstack名を自動収集しない
      auto: false
    region:
      # EC2のmetadataを自動収集しない
      auto: false
      static: ap-northeast-1


では、どんなエラーが起きて、どう解決していくかを見ていきます。

EC2からStack Nameを自動取得できないよエラー

spring-cloud-starter-awsをapplication.ymlの設定無しにそのまま使うと、ローカル環境で以下のエラーが発生します。

2019-01-25 23:45:29,397 ERROR [main] [org.springframework.boot.SpringApplication:858] Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.springframework.cloud.aws.core.env.ResourceIdResolver.BEAN_NAME': Invocation of init method failed; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'stackResourceRegistryFactoryBean' defined in class path resource [org/springframework/cloud/aws/autoconfigure/context/ContextStackAutoConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.cloud.aws.core.env.stack.config.StackResourceRegistryFactoryBean]: Factory method 'stackResourceRegistryFactoryBean' threw exception; nested exception is java.lang.IllegalArgumentException: No valid instance id defined
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1745)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:576)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:498)
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:846)
	at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:863)
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:546)
	at org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext.refresh(ReactiveWebServerApplicationContext.java:67)
	at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:775)
	at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:397)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:316)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1260)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1248)
	at com.example.admin.AdminApplicationKt.main(AdminApplication.kt:21)
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'stackResourceRegistryFactoryBean' defined in class path resource [org/springframework/cloud/aws/autoconfigure/context/ContextStackAutoConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.cloud.aws.core.env.stack.config.StackResourceRegistryFactoryBean]: Factory method 'stackResourceRegistryFactoryBean' threw exception; nested exception is java.lang.IllegalArgumentException: No valid instance id defined
	at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:627)
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:607)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1288)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1127)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:538)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:498)
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeansOfType(DefaultListableBeanFactory.java:602)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeansOfType(DefaultListableBeanFactory.java:590)
	at org.springframework.cloud.aws.core.env.StackResourceRegistryDetectingResourceIdResolver.findSingleOptionalStackResourceRegistry(StackResourceRegistryDetectingResourceIdResolver.java:81)
	at org.springframework.cloud.aws.core.env.StackResourceRegistryDetectingResourceIdResolver.afterPropertiesSet(StackResourceRegistryDetectingResourceIdResolver.java:77)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1804)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1741)
	... 16 common frames omitted
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.cloud.aws.core.env.stack.config.StackResourceRegistryFactoryBean]: Factory method 'stackResourceRegistryFactoryBean' threw exception; nested exception is java.lang.IllegalArgumentException: No valid instance id defined
	at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:185)
	at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:622)
	... 31 common frames omitted
Caused by: java.lang.IllegalArgumentException: No valid instance id defined
	at org.springframework.util.Assert.notNull(Assert.java:198)
	at org.springframework.cloud.aws.core.env.stack.config.AutoDetectingStackNameProvider.autoDetectStackName(AutoDetectingStackNameProvider.java:75)
	at org.springframework.cloud.aws.core.env.stack.config.AutoDetectingStackNameProvider.afterPropertiesSet(AutoDetectingStackNameProvider.java:62)
	at org.springframework.cloud.aws.core.env.stack.config.AutoDetectingStackNameProvider.<init>(AutoDetectingStackNameProvider.java:52)
	at org.springframework.cloud.aws.core.env.stack.config.AutoDetectingStackNameProvider.<init>(AutoDetectingStackNameProvider.java:56)
	at org.springframework.cloud.aws.autoconfigure.context.ContextStackAutoConfiguration.stackResourceRegistryFactoryBean(ContextStackAutoConfiguration.java:71)
	at org.springframework.cloud.aws.autoconfigure.context.ContextStackAutoConfiguration$$EnhancerBySpringCGLIB$$be0ef810.CGLIB$stackResourceRegistryFactoryBean$0(<generated>)
	at org.springframework.cloud.aws.autoconfigure.context.ContextStackAutoConfiguration$$EnhancerBySpringCGLIB$$be0ef810$$FastClassBySpringCGLIB$$d3106a9b.invoke(<generated>)
	at org.springframework.cglib.proxy.MethodProxy.invokeSuper(MethodProxy.java:244)
	at org.springframework.context.annotation.ConfigurationClassEnhancer$BeanMethodInterceptor.intercept(ConfigurationClassEnhancer.java:363)
	at org.springframework.cloud.aws.autoconfigure.context.ContextStackAutoConfiguration$$EnhancerBySpringCGLIB$$be0ef810.stackResourceRegistryFactoryBean(<generated>)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:154)
	... 32 common frames omitted

StackTraceの中の「No valid instance id defined」「stack.config」辺りを見ると何が起きているか解りますね。

No valid instance id defined

StackTraceからコードを追うと、以下のAssertに引っかかった事が解ります。

/**
 * Represents a stack name provider that automatically detects the current stack name based on the amazon elastic cloud
 * environment.
 */
public class AutoDetectingStackNameProvider implements StackNameProvider, InitializingBean {

    private final AmazonCloudFormation amazonCloudFormationClient;
    private final AmazonEC2 amazonEc2Client;
    private final InstanceIdProvider instanceIdProvider;
    ・・・略・・・

    private String autoDetectStackName(String instanceId) {

        Assert.notNull(instanceId, "No valid instance id defined");
        ・・・略・・・
        return null;
    }

順に上から見ていくと、InitializingBeanを継承していて、これはSpring起動時の初期化処理をするもので、更にクラス名が「Auto」と自動で取得をしにいこうとするものであると解ります。
続いてフィールドに「amazonCloudFormationClient」「amazonEc2Client」「instanceIdProvider」があります。この変数名から連想される事は、CloudFormationのスタックで使うEC2のインスタンスIDを自動で取得しようとするクラスである、と解ります。

cloud.spring.io

If the application runs inside a stack (because the underlying EC2 instance has been bootstrapped within the stack), then Spring Cloud AWS will automatically detect the stack and resolve all resources from the stack. Application developers can use all the logical names from the stack template to interact with the services. In the example below, the database resource is configured using a CloudFormation template, defining a logical name for the database instance.

いろいろ書いてますが、要は環境がどこであれ、Spring起動時にEC2の情報を自動で収集するからな〜、と言っています。ではここで「ローカル環境はEC2じゃないからEC2のインスタンスIDなんて無いんだが」という疑問にぶち当たり、案の定インスタンスIDを取得しようとして100%エラーが発生するわけです。

ここで重要なのは、Stack、つまり「CloudFormationが」という部分です。

という事は、Spring boot(spring-cloud-starter-aws)のCloudFormationの設定で、EC2情報を自動収集しないようにすれば解決しそうです。

EC2の情報を自動収集させないようにする

https://cloud.spring.io/spring-cloud-aws/spring-cloud-aws.html#_cloudformation_configuration_in_spring_boot

property example description
cloud.aws.stack.auto true Enables the automatic stack name detection for the application.

Spring boot向けの設定に「cloud.aws.stack.auto」が有って初期値は「true」で、自動的にスタック名を収集する設定との事です。つまりこれをfalseにすれば自動収集が止まるわけです。

application.ymlの設定

f:id:treeapps:20190126201739p:plain
EC2でない環境でCloudFormationのStack名を収集しないapplication.ymlの設定の仕方

EC2でない環境のapplication.ymlが↑この設定にすれば自動収集は止まり、エラーにならなくなります。↑の画像では黄色く警告が出ており、コード補完もできないですが、ちゃんと設定自体は存在して有効になるのでご安心下さい。


しかし、これで終わりではありません・・・・

EC2からリージョン名が自動収集できないよエラー

cloud:
  aws:
    stack:
      # CloudFormationのstack名を自動収集しない
      auto: false

この設定でSpring bootを起動すると、今度は以下のエラーが起きます。

2019-01-26 20:20:11,455 ERROR [main] [org.springframework.boot.SpringApplication:858] Application run failed
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'sendMailService' defined in file [/Users/tree/github/kotlin-spring-boot-jooq-liquibase-thymeleaf-example/base/out/production/classes/com/example/base/service/SendMailService.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'javaMailSender' defined in class path resource [org/springframework/cloud/aws/autoconfigure/mail/MailSenderAutoConfiguration.class]: Unsatisfied dependency expressed through method 'javaMailSender' parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'amazonSimpleEmailService' defined in class path resource [org/springframework/cloud/aws/autoconfigure/mail/MailSenderAutoConfiguration.class]: Invocation of init method failed; nested exception is java.lang.IllegalStateException: There is no EC2 meta data available, because the application is not running in the EC2 environment. Region detection is only possible if the application is running on a EC2 instance
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:769)
    at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:218)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1308)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1154)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:538)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:498)
    at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:846)
    at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:863)
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:546)
    at org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext.refresh(ReactiveWebServerApplicationContext.java:67)
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:775)
    at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:397)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:316)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1260)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1248)
    at com.example.admin.AdminApplicationKt.main(AdminApplication.kt:21)
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'javaMailSender' defined in class path resource [org/springframework/cloud/aws/autoconfigure/mail/MailSenderAutoConfiguration.class]: Unsatisfied dependency expressed through method 'javaMailSender' parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'amazonSimpleEmailService' defined in class path resource [org/springframework/cloud/aws/autoconfigure/mail/MailSenderAutoConfiguration.class]: Invocation of init method failed; nested exception is java.lang.IllegalStateException: There is no EC2 meta data available, because the application is not running in the EC2 environment. Region detection is only possible if the application is running on a EC2 instance
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:769)
    at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:509)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1288)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1127)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:538)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:498)
    at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
    at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:277)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1244)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1164)
    at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:857)
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:760)
    ... 19 common frames omitted
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'amazonSimpleEmailService' defined in class path resource [org/springframework/cloud/aws/autoconfigure/mail/MailSenderAutoConfiguration.class]: Invocation of init method failed; nested exception is java.lang.IllegalStateException: There is no EC2 meta data available, because the application is not running in the EC2 environment. Region detection is only possible if the application is running on a EC2 instance
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1745)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:576)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:498)
    at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
    at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:277)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1244)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1164)
    at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:857)
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:760)
    ... 33 common frames omitted
Caused by: java.lang.IllegalStateException: There is no EC2 meta data available, because the application is not running in the EC2 environment. Region detection is only possible if the application is running on a EC2 instance
    at org.springframework.util.Assert.state(Assert.java:73)
    at org.springframework.cloud.aws.core.region.Ec2MetadataRegionProvider.getRegion(Ec2MetadataRegionProvider.java:39)
    at org.springframework.cloud.aws.core.config.AmazonWebserviceClientFactoryBean.createInstance(AmazonWebserviceClientFactoryBean.java:92)
    at org.springframework.cloud.aws.core.config.AmazonWebserviceClientFactoryBean.createInstance(AmazonWebserviceClientFactoryBean.java:44)
    at org.springframework.beans.factory.config.AbstractFactoryBean.afterPropertiesSet(AbstractFactoryBean.java:142)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1804)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1741)
    ... 44 common frames omitted

There is no EC2 meta data available

前述同様、対象コードを見てみます。

/**
 * {@link org.springframework.cloud.aws.core.region.RegionProvider} implementation that dynamically retrieves the
 * region with the EC2 meta-data. This implementation allows application to run against their region without any
 * further configuration.
 */
public class Ec2MetadataRegionProvider implements RegionProvider {

    @Override
    public Region getRegion() {
        Region currentRegion = getCurrentRegion();
        Assert.state(currentRegion != null, "There is no EC2 meta data available, because the application is not running " +
                "in the EC2 environment. Region detection is only possible if the application is running on a EC2 instance");
        return currentRegion;
    }

    protected Region getCurrentRegion() {
        try {
            InstanceInfo instanceInfo = EC2MetadataUtils.getInstanceInfo();
            return instanceInfo != null && instanceInfo.getRegion() != null ? RegionUtils.getRegion(instanceInfo.getRegion()) : null;
        } catch (AmazonClientException e) {
            return null;
        }

    }
}

EC2のメタデータからインスタンス情報(Region等)を取得しようとするものですね。当然ローカル環境はEC2でないので、EC2インスタンス情報を取得しようとして100%エラーになるわけです。

という事は、Spring boot(spring-cloud-starter-aws)のリージョン設定で、EC2情報を自動収集しないようにすれば解決しそうです。

EC2メタデータを自動収集させないようにする

https://cloud.spring.io/spring-cloud-aws/spring-cloud-aws.html#_configuring_region

property example description
cloud.aws.region.auto true Enables automatic region detection based on the EC2 meta data service
cloud.aws.region.static eu-west-1 Configures a static region for the application. Possible regions are (currently) us-east-1, us-west-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-1, ap-southeast-1, ap-northeast-1, sa-east-1, cn-north-1 and any custom region configured with own region meta data

これです。どうやら初期値は自動収集になっているようです。ではapplication.ymlを以下のように設定して起動します。

application.ymlの設定
cloud:
  aws:
    stack:
      # CloudFormationのstack名を自動収集しない
      auto: false
    region:
      # EC2のmetadataを自動収集しない
      auto: false

この設定に変更して再起動すると、今度は以下のエラーが出ます。

2019-01-26 20:36:46,619 ERROR [main] [org.springframework.boot.SpringApplication:858] Application run failed
java.lang.IllegalArgumentException: Region must be manually configured or autoDetect enabled
	at org.springframework.cloud.aws.context.config.support.ContextConfigurationUtils.registerRegionProvider(ContextConfigurationUtils.java:65)
	at org.springframework.cloud.aws.autoconfigure.context.ContextRegionProviderAutoConfiguration$Registrar.registerBeanDefinitions(ContextRegionProviderAutoConfiguration.java:72)
	at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.lambda$loadBeanDefinitionsFromRegistrars$1(ConfigurationClassBeanDefinitionReader.java:364)
	at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:684)
	at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsFromRegistrars(ConfigurationClassBeanDefinitionReader.java:363)
	at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForConfigurationClass(ConfigurationClassBeanDefinitionReader.java:145)
	at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitions(ConfigurationClassBeanDefinitionReader.java:117)
	at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:327)
	at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:232)
	at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:275)
	at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:95)
	at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:691)
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:528)
	at org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext.refresh(ReactiveWebServerApplicationContext.java:67)
	at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:775)
	at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:397)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:316)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1260)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1248)
	at com.example.admin.AdminApplicationKt.main(AdminApplication.kt:21)

Region must be manually configured or autoDetect enabled(cloud.aws.region.staticで設定するか、cloud.aws.region.autoで自動設定しろよな)というエラーです。

両パラメータには相関があって、自動収集を停止するならばcloud.aws.region.staticを設定しないといけないのです。「static」は「自動収集しないで静的(決め打ち)で設定する」という意味合いですね。

ローカル環境なので、以下のようにして、自動収集しないで決め打ちにします。

cloud:
  aws:
    stack:
      # CloudFormationのstack名を自動収集しない
      auto: false
    region:
      # EC2のmetadataを自動収集しない
      auto: false
      static: ap-northeast-1

これで無事起動します!

EC2以外の環境では上記のように自動収集をやめる設定とし、EC2環境の場合は自動収集設定でもよさそうですね。

AmazonSESは東京リージョンに対応してないんだが

cloud:
  aws:
    stack:
      # CloudFormationのstack名を自動収集しない
      auto: false
    region:
      # EC2のmetadataを自動収集しない
      auto: false
      static: ap-northeast-1

これだと、東京リージョンに対応していないAmazonSESの場合、エラーになりそうですよね。

Amazon SES is not available in all regions of the Amazon Web Services cloud. Therefore an application hosted and operated in a region that does not support the mail service will produce an error while using the mail service. Therefore the region must be overridden for the mail sender configuration. The example below shows a typical combination of a region (EU-CENTRAL-1) that does not provide an SES service where the client is overridden to use a valid region (EU-WEST-1).

[>https://cloud.spring.io/spring-cloud-aws/spring-cloud-aws.html#_configuring_regions>]

「SESは全リージョンに対応してるわけではないから、cloud.aws.region.staticの設定をしても未対応リージョンだとエラーが出るから、別の方法でリージョンを上書きしてくれよな。」と言っています。サンプルとしてaws-config.xmlを使う例が載っています。↓こういうやつです。
https://github.com/eugenp/tutorials/blob/master/spring-cloud/spring-cloud-aws/src/main/resources/aws-config.xml
ここにSESだけ異なるリージョンを書いてもいいぞー、という事だそうです。

しかし、実際の開発では、local〜staging環境はオレゴンリージョン、production環境はバージニアリージョン、等と利用環境を分けて、負荷分散やバウンスレートの分けをキッチリする事が多いです。するとaws-config.xmlを環境毎に上書きしないといけないビルド設定が必要になるので、これは嫌です。

折角application.ymlが環境毎に設定を柔軟に変更できる機構があるのですから、それで変更したいですね。

AmazonSESのリージョンをソースコード側で変更する

f:id:treeapps:20190126212524p:plain

いきなりDeprecatedの洗礼を浴びます。amazonSimpleEmailService.setRegionは非推奨なので「AwsClientBuilder.setRegion(String)」を使えとのことです。

AwsClientBuilderはabstractクラスであり、実際はAwsClientBuilderを継承しているAmazonSimpleEmailServiceClientBuilderを使えという事になります。
github.com

で、AmazonSimpleEmailServiceClientBuilderの使い方は公式サイトにズバリそのものがあるので、省略します。
docs.aws.amazon.com

おまけ:ローカル開発時にML宛にメールを送信したくないんだが!

ローカルで開発していて、例えばhtmlメールを実装中だとします。テンプレートエンジンでif文やらfor文やらをゴリゴリ実装していくわけです。他にもtoやccが正常に分岐できているかもテストしたいですよね。

するとローカル環境で何度も実際にメール送信してテストしたくなりますね。メール送信先には社内MLが設定される事が多いと思いますが、テストメールが何十通・何百通も送信されると、MLを受信してる人がウザい・全くメールを見なくなる、という事になる可能性があります。

特にバッチで大量のhtmlメールを送信するテストをしたい時等は困ってしまいます。実装をミスって無限ループでSESにメールを送信してしまい、莫大な金額が請求されたりとか。

それを回避するため、私はローカル環境の場合はMailCatcher(仮想SMTPサーバ)を利用します。
mailcatcher.me

MailCatcherは仮想メールサーバなのですが、メールホストをMailCatcherに向けて送信すると、MailCatcherの画面上ではメールの受信フォルダに受信されますが、実際のTO・CC・BCCにメールが送信される事はありません。つまり、自分のローカル環境に完全に閉じたメールサーバが構築できるという優れものです。勿論テキストメール・htmlメール・マルチパートメールにも対応しています。

しかしこれをローカル環境に直接インストールしたくありません。そこでdockerです。ではdockerでMailCatcherを起動できるようにし、更にSpring bootからMailCatcherに向けてメールが送信できるようにしてみましょう。

docker-compose.yml

version: '3'
services:
  localMailServer:
    image: schickling/mailcatcher:latest
    ports:
    - 1080:1080
    - 25:1025
    - 465:1025
    - 587:1025

port=1080はブラウザで表示する画面用のポート、
port=25は一般的なメールサーバのポート、
port=465,587はgmailのポート、
となります。

application.yml

  mail:
    host: "localhost"
    protocol: "smtp"
    port: 25
    default-encoding: UTF-8
    test-connection: true
    properties:
      mail:
        smtp:
          timeout: 10000
          connectiontimeout: 10000
          writetimeout: 10000

実際にMailCatcherにメールを送信するGIFアニメ

Spring bootでAPIサーバを起動し、http://localhost:8080/send-multipart-mail/ をGETリクエストするとマルチパートメール(テキストメールとhtmlメールが合体したメール)をMailCatcherに送信し、テキスト・htmlの両方が確認でき、メールのソースも確認できる事をGIFアニメにしてみました。

f:id:treeapps:20190126222601g:plain

勿論TO・CC・BCCに実際にはメールは送信されていません。ここで受信したメールの履歴は、dockerを停止すると全削除されます。(Volume Mountすれば永続化もできると思います)

ここでは日本語は使っていませんが、ちゃんと日本語も化けずに表示できます。これをローカル環境で用意しておくと、メール送信に関する実装で精神の安定を向上させる事ができます。

雑感

今回書いたやり方で困るのは、ローカル環境ではSMTPでDockerのMailCatcherに向けて送信したいが、ローカル以外の環境ではSESに送信したい場合、コードの共通化ができるのか?という点です。

docs.aws.amazon.com

↑このやり方はSMTPプロトコルではなく、SESのAPIで送信します。SMTPで送信する場合はJavaMailSender等を使いますよね。しかしAmazonSES APIだとAmazonSimpleEmailServiceClientBuilderを使い、両者で大分コードが変わります。これを共通化するいい方法があるのかがまだ解っていません。

一応AmazonSESもSMTPプロトコルでメール送信する事ができますが、物凄く遅いです。1回のメール送信で2秒くらいかかる程遅いです。よくあるユースケースとして、メール送信時にユーザーと管理者に異なるメールを同時に送信したい場合があります。その時同期処理で送信すると2秒×2通=4秒もかかります。2通をasync/awaitで非同期送信しても、2秒より速くはなりません。なので、できればSESでSMTPプロトコルは使わず、高速なAPI(AmazonSimpleEmailServiceClientBuilder)を使いたいです。

もしSMTP(ローカル環境専用)とSES APIを共存しつつソースコードの共通化をするいい方法をご存知の方がいれば是非教えて下さい!