[FaaS] Create multiple function with fn #fnproject

こむろ@札幌です。まだ札幌は雪が降っていません。異常気象だ。

はじめに

fnを使い単体のFunctionについて確認しました。大量のFunctionを構成する場合、すべてがフラットに構成されてしまうと非常に見づらく(管理しづらく)なってしまいます。そこで、今回はグルーピングして複数のFunctionが管理できるかを検証してみます。

こんな感じになっているのを。

理想はこうできると良いな、という検証です。

fnは発展途上のOSSです。今回紹介する内容は未来(次の日かも知れない)で正しくなくなる可能性があります。その点を踏まえた上で必要ならばご参照ください。

前提

今回のバージョンは以下(2018/11/12現在)になります。

  • fn version 0.5.26
  • fn-server v0.3.600

Grouping function

Functionを入れ子にすることでGroupingさせることができます。今回はこんな構成を目指します。

nest-function
 ├── func.yaml
 ├── goodbye
 │   ├── func.java
 │   └── func.yaml
 └── hello
     ├── func.ruby
     └── func.yaml

Rootであるnest-function, 子要素のFunctionに2つのFunction、hello, goodbye を作成します。

Create Nested Function

では作っていきます。せっかくなのでいくつかのruntimeを混ぜたFunctionを構成してみましょう。

Create Parent Directory

nest-functionという名前のディレクトリを作成し、 app.yaml を作成しておきます。Deployするアプリケーション名が定義されているファイルです。

name: nest-sample-function

これを定義しておくとdeploy時にアプリケーション名を指定せずとも良いようです。この時点での構造は以下の通り。

nest-function/
└── app.yaml

Create Child Functions

続いて子Functionを作成します。先程作成したFunctionの nest-function ディレクトリへ移動します。

Java Function

hello FunctionをJavaで作成します。

$ fn init --runtime java --trigger http hello
Creating function at: /hello
Function boilerplate generated.
func.yaml created.

こちらも一部コードを修正します。

package com.example.fn;

public class HelloFunction {

    public String handleRequest(String input) {
        String name = (input == null || input.isEmpty()) ? "world"  : input;
        return "Hello, Java " + name + "!";
    }
}

こちらも大した修正は入れていません。Java を追記したのみ。Javaはテストコードも記述されています。こちらも修正します。

public class HelloFunctionTest {

    @Rule
    public final FnTestingRule testing = FnTestingRule.createDefault();

    @Test
    public void shouldReturnGreeting() {
        testing.givenEvent().enqueue();
        testing.thenRun(HelloFunction.class, "handleRequest");

        FnResult result = testing.getOnlyResult();
        assertEquals("Hello, Java world!", result.getBodyAsString());}
}

Python Function

最後に goodbye FunctionはPythonで作成します。

$ fn init --runtime python --trigger http goodbye
Creating function at: /goodbye
Function boilerplate generated.
func.yaml created.

こちらも一部コードを修正します。

def handler(ctx, data=None, loop=None):
    name = "World"
    if data and len(data) > 0:
        body = json.loads(data)
        name = body.get("name")
    return {"message": "Hello Python {0}".format(name)}

準備ができました。

Deploy to Local

現在の構成を確認してみます。

$ tree nest-function/
nest-function/
├── app.yaml
├── goodbye
│   ├── func.py
│   ├── func.yaml
│   └── requirements.txt
└── hello
    ├── func.yaml
    ├── pom.xml
    └── src
        ├── main
        │   └── java
        │       └── com
        │           └── example
        │               └── fn
        │                   └── HelloFunction.java
        └── test
            └── java
                └── com
                    └── example
                        └── fn
                            └── HelloFunctionTest.java

Javaのパッケージ構造がある関係上ディレクトリの深度が増している感じですが、`nest-function以下にPython, JavaのFunctionコードが存在することが分かります。これをDeployしていきます。

Deploy to Local Environment

まずはRegistryへImageをPushせずにローカル環境へのDeployを実行します。 nest-function ディレクトリ以下でコマンドを実行します。

$ fn deploy --all --local
Deploying goodbye to app: nest-sample-function
Bumped to version 0.0.4
Building image hogeuser/goodbye:0.0.4 .....................
Updating function goodbye using image hogeuser/goodbye:0.0.4...
Successfully created function: goodbye with hogeuser/goodbye:0.0.4
Successfully created trigger: goodbye
Trigger Endpoint: http://localhost:8999/t/nest-sample-function/goodbye-trigger
Deploying hello to app: nest-sample-function
Bumped to version 0.0.3
Building image hogeuser/hello:0.0.3
Updating function hello using image hogeuser/hello:0.0.3...
Successfully created function: hello with hogeuser/hello:0.0.3
Successfully created trigger: hello
Trigger Endpoint: http://localhost:8999/t/nest-sample-function/hello-trigger

デプロイが完了しました。ディレクトリ配下に作成した2つのFunctionがDeployされていることがわかります。

Check Triggers

TriggerがFunctionの数だけ生成されています。念の為、定義されているTriggerを確認してみます。

$ fn list triggers nest-sample-function
FUNCTION    NAME    ID              TYPE    SOURCE              ENDPOINT
goodbye     goodbye 01CWB8CPD5NG8G00GZJ0000003  http    /goodbye-trigger    http://localhost:8999/t/nest-sample-function/goodbye-trigger
hello       hello   01CWB8CQ8TNG8G00GZJ0000005  http    /hello-trigger  http://localhost:8999/t/nest-sample-function/hello-trigger

意図通り2つのFunction Triggerが定義されていました。

Invoke Functions

それぞれのFunctionを実行してみましょう。

$ fn invoke nest-sample-function hello
Hello, Java world!

$ fn invoke nest-sample-function goodbye
{"message":"Hello Python World"}

異なるruntimeで構成されたFunctionをそれぞれちゃんと実行できました。ディレクトリの中にネストされたFunctionであっても --all の指定ですべてDeployし、実行することができることが確認できました。

Nested Function?

ところで、確かにFunctionのコードは入れ子で構成できましたが、実際に実行するFunctionのTriggerはフラットな構造になっており、本来の意味での入れ子構造になっていません。

元の想定とはちょっと異なります。理想としてFunction Triggerの構造はディレクトリの構造を反映して以下のようになってほしいところです。

http://localhost:8999/t/nest-sample-function/nest-function/goodbye-trigger ## Child Function 1
http://localhost:8999/t/nest-sample-function/nest-function/hello-trigger    ## Child Function 2

nest-sample-function はアプリケーション名なので固定です。この形を目指してまずはTriggerの構造を変更していきます。

FunctionのTrigger定義を修正

Deploy時にTrigger定義を生成する際には func.yaml の定義を利用します。そこでこのTrigger定義をそれぞれ書き換える必要があります。

まずは hello Functionの func.yaml の中にある triggers/source を書き換えます。

schema_version: 20180708
name: hello
version: 0.0.3
runtime: java
build_image: fnproject/fn-java-fdk-build:jdk9-1.0.75
run_image: fnproject/fn-java-fdk:jdk9-1.0.75
cmd: com.example.fn.HelloFunction::handleRequest
format: http-stream
triggers:
- name: hello
  type: http
  source: /nest-function/hello-trigger

goodbye Functionの triggers/source も書き換えます。

schema_version: 20180708
name: goodbye
version: 0.0.4
runtime: python
entrypoint: python3 func.py
format: http-stream
triggers:
- name: goodbye
  type: http
  source: /nest-function/goodbye-trigger

定義の変更が完了したので、再度Deployを行えばTriggerの定義更新を行ってくれるはずです。

再度デプロイを実行

定義を変更した内容をDeployしてみます。

$ fn deploy --all --local
Deploying hello to app: nest-sample-function
Bumped to version 0.0.4
Building image hogeuser/hello:0.0.4 ......
Updating function nest-function using image hogeuser/hello:0.0.4...

Fn: deploy error on /Users/xxxxxxxx/fn/nest-function/hello/func.yaml: Trigger with the same type and source exists on this app

See 'fn <command> --help' for more information. Client version: 0.5.26

おや。エラーで失敗しました。どうやら現時点ではTriggerの定義はDeployタスクによって更新できないようです。これは厳しい。

Trigger定義を削除する

現在はTriggerを更新する方法がなさそうなので、仕方なく手動ですべてのTriggerを削除します。

$ fn delete trigger nest-sample-function hello hello-trigger
nest-sample-function hello hello-trigger deleted

$ fn delete trigger nest-sample-function goodbye goodbye-trigger
nest-sample-function goodbye goodbye-trigger deleted

delete trigger コマンドではfn-serverに登録されているTriggerの定義を削除できます。引数はそれぞれ アプリケーション名, Function名, Trigger名 の順番で指定します。いずれもrequiredな引数なので省略はできません。

上記コマンド後にTriggerが削除されたかを確認してみます。

$ fn list triggers nest-sample-function
schema_version: 20180708
No triggers found for app: nest-sample-function

すべて削除されました。

再再度デプロイを実行

もう一度Deployを実行してみましょう *1

$ fn deploy --all --local
Deploying goodbye to app: nest-sample-function
Bumped to version 0.0.4
Building image hogeuser/goodbye:0.0.4 .....................
Updating function goodbye using image hogeuser/goodbye:0.0.4...
Successfully created function: goodbye with hogeuser/goodbye:0.0.4
Successfully created trigger: goodbye
Trigger Endpoint: http://localhost:8999/t/nest-sample-function/nest-function/goodbye-trigger
Deploying hello to app: nest-sample-function
Bumped to version 0.0.3
Building image hogeuser/hello:0.0.3
Updating function hello using image hogeuser/hello:0.0.3...
Successfully created function: hello with hogeuser/hello:0.0.3
Successfully created trigger: hello
Trigger Endpoint: http://localhost:8999/t/nest-sample-function/nest-function/hello-trigger

今度は正常にDeployが完了するはずです。Triggerの定義を確認します。

$ fn list triggers nest-sample-function
FUNCTION    NAME    ID              TYPE    SOURCE              ENDPOINT
goodbye     goodbye 01CWB8CPD5NG8G00GZJ0000003  http    /nest-function/goodbye-trigger  http://localhost:8999/t/nest-sample-function/nest-function/goodbye-trigger
hello       hello   01CWB8CQ8TNG8G00GZJ0000005  http    /nest-function/hello-trigger    http://localhost:8999/t/nest-sample-function/nest-function/hello-trigger

想定通りのTriggerが作成できました。実行してみます。まずは hello Functionから

$ curl -H "Content-Type: text/plain" -d 'Classmethod' http://localhost:8999/t/nest-sample-function/nest-function/hello-trigger
Hello, Java Classmethod!

続いて goodbye Functionを実行。

$ curl -H "Content-Type: application/json" -d '{"name": "Classmethod"}' http://localhost:8999/t/nest-sample-function/nest-function/goodbye-trigger
{"message":"Hello Python Classmethod"}

これで見た目上ではありますが、グルーピングされたFunctionをパス上は表現できました。些か不満ではありますがひとまずの目的は達成しました。

Trouble Shooting

ここにたどり着くまでにいくつか失敗しているのでそちらを記載します。

その1. テストコードの修正忘れ

Javaのボイラープレートコードにはテストコードが記述されています。これの修正を忘れるとdeploy時にエラーが発生し処理が正常に完了しません。Functionのビルドに失敗した場合、以下のエラーが出力されます。

Building image hogeuser/hello:0.0.2 .........
Error during build. Run with `--verbose` flag to see what went wrong. eg: `fn --verbose CMD`

Fn: deploy error on /Users/xxxxxxxx/fn/nest-function/hello/func.yaml: error running docker build: exit status 1

この出力からエラーの原因を確認するのはなかなか厳しそうです。

そこで原因を確認するためにhello/ ディレクトリ以下に入って fn build--verbose オプション付きで実行し、単体でのBuildを試行してみました。 *2

$ fn --verbose build
Building image hogeuser/hello:0.0.2
FN_REGISTRY:  hogeuser
Current Context:  default
Sending build context to Docker daemon  14.34kB
Step 1/11 : FROM fnproject/fn-java-fdk-build:jdk9-1.0.75 as build-stage
 ---> 10c10a1cd2ae
Step 2/11 : WORKDIR /function
 ---> Using cache
 ---> 14635a08065b
Step 3/11 : ENV MAVEN_OPTS -Dhttp.proxyHost= -Dhttp.proxyPort= -Dhttps.proxyHost= -Dhttps.proxyPort= -Dhttp.nonProxyHosts= -Dmaven.repo.local=/usr/share/maven/ref/repository
 ---> Using cache
 ---> 8ecb8920ffaa
Step 4/11 : ADD pom.xml /function/pom.xml
 ---> Using cache
 ---> eb08f93b98c9
Step 5/11 : RUN ["mvn", "package", "dependency:copy-dependencies", "-DincludeScope=runtime", "-DskipTests=true", "-Dmdep.prependGroupId=true", "-DoutputDirectory=target", "--fail-never"]
 ---> Using cache
 ---> 4ac15f343db0
Step 6/11 : ADD src /function/src
 ---> Using cache
 ---> 2b667837a3ab
Step 7/11 : RUN ["mvn", "package"]
 ---> Running in fb38bc33b4e9
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------< com.example.fn:hello >------------------------
[INFO] Building hello 1.0.0
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ hello ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory /function/src/main/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.3:compile (default-compile) @ hello ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source file to /function/target/classes
[INFO]
[INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ hello ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory /function/src/test/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.3:testCompile (default-testCompile) @ hello ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source file to /function/target/test-classes
[INFO]
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ hello ---
[INFO] Surefire report directory: /function/target/surefire-reports

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running com.example.fn.HelloFunctionTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.632 sec <<< FAILURE!
shouldReturnGreeting(com.example.fn.HelloFunctionTest)  Time elapsed: 0.199 sec  <<< FAILURE!
org.junit.ComparisonFailure: expected:<Hello, []world!> but was:<Hello, [Java ]world!>
    at org.junit.Assert.assertEquals(Assert.java:115)
    at org.junit.Assert.assertEquals(Assert.java:144)
    at com.example.fn.HelloFunctionTest.shouldReturnGreeting(HelloFunctionTest.java:19)
    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:564)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
package com.example.fn;
    at org.junit.rules.RunRules.evaluate(RunRules.java:20)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
    at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:252)
    at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:141)
    at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:112)
    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:564)
    at org.apache.maven.surefire.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:189)
    at org.apache.maven.surefire.booter.ProviderFactory$ProviderProxy.invoke(ProviderFactory.java:165)
    at org.apache.maven.surefire.booter.ProviderFactory.invokeProvider(ProviderFactory.java:85)
    at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:115)
    at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:75)


Results :

Failed tests:   shouldReturnGreeting(com.example.fn.HelloFunctionTest): expected:<Hello, []world!> but was:<Hello, [Java ]world!>

Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.384 s
[INFO] Finished at: 2018-11-12T09:00:07Z
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.12.4:test (default-test) on project hello: There are test failures.
[ERROR]
[ERROR] Please refer to /function/target/surefire-reports for the individual test results.
[ERROR] -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
The command 'mvn package' returned a non-zero code: 1


Fn: error running docker build: exit status 1

テスト失敗の詳細が表示されているのが分かります。具体的なエラーも出力されているため、この指摘どおりテストコードを修正してあげればこの問題は解消です。

その2. ネストしたFunctionでもDocker ImageはRegistryへフラットにPushされる

Functionは1つにつき1つのDocker Imageとして構成されます。ディレクトリの構造がネストしたFunctionの場合、パスを見れば別の関数と認識できるものの、実際のImage名は関数名のみで構成されています。そのため同じ関数名のImageがある場合、当然ながらPushすることができません。

以下具体例で示します。

$ tree nest-function2
nest-function2
├── app.yaml
├── goodbye
│   ├── Gopkg.toml
│   ├── func.go
│   └── func.yaml
└── hello
    ├── Gopkg.toml
    ├── func.go
    └── func.yaml

nest-function2 以下に同じ名前のFunction hello, goodby を配置しました。いずれもTriggerは nest-function2 以下になるようにしています。

$ fn deploy --all
Deploying goodbye to app: nest-sample-function2
Bumped to version 0.0.3
Building image hogeuser/goodbye:0.0.3 ........
Parts:  [hogeuser goodbye:0.0.3]
Pushing hogeuser/goodbye:0.0.3 to docker registry...The push refers to repository [docker.io/hogeuser/goodbye]
078fa38e1cee: Preparing
d15d26d63fb7: Preparing
97dedccb7128: Preparing
c9e8b5c053a2: Preparing
denied: requested access to the resource is denied

Fn: deploy error on /Users/xxxxxxxxx/fn/nest-function2/goodbye/func.yaml: error running docker push, are you logged into docker?: exit status 1

See 'fn <command> --help' for more information. Client version: 0.5.26

同じ名前でDocker ImageをPushしようとするのでダメ。Docker imageの名前が別途一意になるように修正が必要なようです。例えば nest-function2-goodbye 等。

まとめ

複数のFunctionをディレクトリ配下においてグルーピングし、Deployできることが分かりました。

もう少しきちんとグルーピングができるならば、Functionの管理が楽になりそうだと思ったのですが、少々手を加える必要があります。ネストしたFunctionを利用するにはもう少し工夫が必要なようです。

参照

脚注

  1. ちなみにDeployに失敗した場合でもVersionはインクリメントされます。失敗し続けるといつの間にかすごいバージョン数が上がっていることがあるので、その場合は必要に応じて --no-bump オプションを付与して実行してみましょう。
  2. `--verbose` オプションはGlobal Option扱いのためどのコマンドでも付与が可能です。deployタスクにも付与すると詳しい情報が出力されるかと思います