|
| 1 | +# Java - Gradle Lambda Builder |
| 2 | + |
| 3 | +## Scope |
| 4 | + |
| 5 | +This package enables the creation of a Lambda deployment package for Java |
| 6 | +projects managed using the Gradle build tool. |
| 7 | + |
| 8 | +For Java projects, the most popular way to create a distribution package for |
| 9 | +Java based Lambdas is to create an "uber" or "fat" JAR. This is a single JAR |
| 10 | +file that contains both the customers' classes and resources, as well as all the |
| 11 | +classes and resources extracted from their dependency JAR's. However, this can |
| 12 | +cause files that have the same path in two different JAR's to collide within the |
| 13 | +uber JAR. |
| 14 | + |
| 15 | +Another solution is to create a distribution ZIP containing the customer's |
| 16 | +classes and resources and include their dependency JARs under a `lib` directory. |
| 17 | +This keeps the customers' classes and resources separate from their |
| 18 | +dependencies' to avoid any file collisions. However, this incurs some overhead |
| 19 | +as the ZIP must be unpacked before the code can run. |
| 20 | + |
| 21 | +To avoid the problem of colliding files, we will choose the second option and |
| 22 | +create distribution ZIP. |
| 23 | + |
| 24 | +## Challenges |
| 25 | + |
| 26 | +Java bytecode can only run on the same or newer version of the JVM for which |
| 27 | +it was compiled for. For example Java 8 bytecode can run a JVM that is at |
| 28 | +least version 8, but bytecode targetting Java 9 cannot run on a Java 8 VM. |
| 29 | +This is further complicated by the fact that a newer JDK can generate code to |
| 30 | +be run on an older VM if configured using the `targetCompatibility` and |
| 31 | +`sourceCompatibility` properties of the Java plugin. Therefore, it is not |
| 32 | +sufficient to check the version of the local JDK, nor is it possible to check |
| 33 | +the value set for `targetCompatibility` or `sourceCompatibility` since it can |
| 34 | +be local to the compile/build task. At best, we can check if the local |
| 35 | +version of the JDK is newer than Java 8 and emit a warning that the built |
| 36 | +artifact may not run in Lambda. |
| 37 | + |
| 38 | +Gradle projects are configured using `build.gradle` build scripts. These are |
| 39 | +executable files authored in either Groovy or since 5.0, Kotlin, and using the |
| 40 | +Gradle DSL. This presents a similar problem to `setup.py` in the Python world in |
| 41 | +that arbitrary logic can be executed during build time that could affect both |
| 42 | +how the customer's artifact is built, and which dependencies are chosen. |
| 43 | + |
| 44 | +An interesting challenge is dealing with single build and multi build projects. |
| 45 | +Consider the following different projects structures: |
| 46 | + |
| 47 | +**Project A** |
| 48 | +``` |
| 49 | +ProjectA |
| 50 | +├── build.gradle |
| 51 | +├── gradlew |
| 52 | +├── src |
| 53 | +└── template.yaml |
| 54 | +``` |
| 55 | + |
| 56 | +**Project B** |
| 57 | +``` |
| 58 | +ProjectB |
| 59 | +├── common |
| 60 | +│ └── build.gradle |
| 61 | +├── lambda1 |
| 62 | +│ └── build.gradle |
| 63 | +├── lambda2 |
| 64 | +│ └── build.gradle |
| 65 | +├── build.gradle |
| 66 | +├── gradlew |
| 67 | +├── settings.gradle |
| 68 | +└── template.yaml |
| 69 | +``` |
| 70 | + |
| 71 | +Here `ProjectA` is a a single lambda function, and `ProjectB` is a multi-build |
| 72 | +project where sub directories `lambda1` and `lambda2` are each a lambda |
| 73 | +function. In addition, suppose that `ProjectB/lambda1` has a dependency on its |
| 74 | +sibling project `ProjectB/common`. |
| 75 | + |
| 76 | +Building Project A is relatively simple since we just need to issue `gradlew |
| 77 | +build` and place the built ZIP within the artifact directory. |
| 78 | + |
| 79 | +Building `ProjectB/lambda1` is very similar from the point of view of the |
| 80 | +workflow since it still issues the same command (`gradlew build`), but it |
| 81 | +requires that Gradle is able to find its way back up to the parent `ProjectB` so |
| 82 | +that it can also build `ProjectB/common` which can be a challenge when mounting |
| 83 | +within a container. |
| 84 | + |
| 85 | +## Implementation |
| 86 | + |
| 87 | +### Build Workflow |
| 88 | + |
| 89 | +We leverage Gradle to do all the heavy lifting for executing the |
| 90 | +`build.gradle` script which will resolve and download the dependencies and |
| 91 | +build the project. To create the distribution ZIP, we use the help of a |
| 92 | +Gradle init script to insert a post-build action to do this. |
| 93 | + |
| 94 | +#### Step 1: Copy custom init file to temporary location |
| 95 | + |
| 96 | +There is no standard task in Gradle to create a distribution ZIP (or uber JAR). |
| 97 | +We add this functionality through the use of a Gradle init script. The script |
| 98 | +will be responsible for adding a post-build action that creates the distribution |
| 99 | +ZIP. |
| 100 | + |
| 101 | +It will do something similar to: |
| 102 | + |
| 103 | +```sh |
| 104 | +cp /path/to/lambda-build-init.gradle /$SCRATCH_DIR/ |
| 105 | +``` |
| 106 | + |
| 107 | +where the contents of `lambda-build-init.gradle` contains the code for defining |
| 108 | +the post-build action: |
| 109 | + |
| 110 | +```gradle |
| 111 | +gradle.project.afterProject { p -> |
| 112 | + // Set the give project's buildDir to one under SCRATCH_DIR |
| 113 | +} |
| 114 | +
|
| 115 | +// Include the project classes and resources in the root, and the dependencies |
| 116 | +// under lib |
| 117 | +gradle.taskGraph.afterTask { t -> |
| 118 | + if (t.name != 'build') { |
| 119 | + return |
| 120 | + } |
| 121 | +
|
| 122 | + // Step 1: Find the directory under scratch_dir where the artifact for |
| 123 | + // t.project is located |
| 124 | + // Step 2: Open ZIP file in $buildDir/distributions/lambda_build |
| 125 | + // Step 3: Copy project class files and resources to ZIP root |
| 126 | + // Step 3: Copy libs in configurations.runtimeClasspath into 'lib' |
| 127 | + // subdirectory in ZIP |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +#### Step 2: Resolve Gradle executable to use |
| 132 | + |
| 133 | +[The recommended |
| 134 | +way](https://docs.gradle.org/current/userguide/gradle_wrapper.html) way to |
| 135 | +author and distribute a Gradle project is to include a `gradlew` or Gradle |
| 136 | +Wrapper file within the root of the project. This essentially locks in the |
| 137 | +version of Gradle for the project and uses an executable that is independent of |
| 138 | +any local installations. This helps ensure that builds are always consistent |
| 139 | +over different environments. |
| 140 | + |
| 141 | +The `gradlew` script, if it is included, will be located at the root of the |
| 142 | +project. We will rely on the invoker of the workflow to supply the path to the |
| 143 | +`gradlew` script. |
| 144 | + |
| 145 | +We give precedence to this `gradlew` file, and if isn't found, we use the |
| 146 | +`gradle` executable found on the `PATH` using the [path resolver][path resolver]. |
| 147 | + |
| 148 | +#### Step 3: Check Java version and emit warning |
| 149 | + |
| 150 | +Check whether the local JDK version is <= Java 8, and if it is not, emit a |
| 151 | +warning that the built artifact may not run in Lambda unless a) the project is |
| 152 | +properly configured (i.e. using `targetCompatibility`) or b) the project is |
| 153 | +built within a Lambda-compatibile environment like `lambci`. |
| 154 | + |
| 155 | +We use the Gradle executable from Step 2 for this to ensure that we check the |
| 156 | +actual JVM version Gradle is using in case it has been configured to use a |
| 157 | +different one than can be found on the PATH. |
| 158 | + |
| 159 | +#### Step 4: Build and package |
| 160 | + |
| 161 | +```sh |
| 162 | +$GRADLE_EXECUTABLE --project-cache-dir $SCRATCH_DIR/gradle-cache \ |
| 163 | + -Dsoftware.amazon.aws.lambdabuilders.scratch-dir=$SCRATCH_DIR \ |
| 164 | + --init-script $SCRATCH_DIR/lambda-build-init.gradle build |
| 165 | +``` |
| 166 | + |
| 167 | +Since by default, Gradle stores its build-related metadata in a `.gradle` |
| 168 | +directory under the source directory, we specify an alternative directory under |
| 169 | +`scratch_dir` to avoid writing anything under `source_dir`. This is simply a |
| 170 | +`gradle-cache` directory under `scratch_dir`. |
| 171 | + |
| 172 | +Next, we also pass the location of the `scratch_dir` as a Java system |
| 173 | +property so that it's availabe to our init script. This allows it to correctly |
| 174 | +map the build directory for each sub-project within `scratch_dir`. Again, this |
| 175 | +ensures that we are not writing anything under the source directory. |
| 176 | + |
| 177 | +One important detail here is that the init script may create *multiple* |
| 178 | +subdirectories under `scratch_dir`, one for each project involved in building |
| 179 | +the lambda located at `source_dir`. Going back to the `ProjectB` example, if |
| 180 | +we're building `lambda1`, this also has the effect of building `common` because |
| 181 | +it's a declared dependency in its `build.gradle`. So, within `scratch_dir` will |
| 182 | +be a sub directory for each project that gets built as a result of building |
| 183 | +`source_dir`; in this case there will be one for each of `lambda1` and `common`. |
| 184 | +The init file uses some way of mapping the source root of each project involved |
| 185 | +to a unique directory under `scratch_dir`, like a hashing function. |
| 186 | + |
| 187 | +#### Step 5: Copy to artifact directory |
| 188 | + |
| 189 | +The workflow implementation is aware of the mapping scheme used to map a |
| 190 | +`source_dir` to the correct directory under `scratch_dir` (described in step 4), |
| 191 | +so it knows where to find the built Lambda artifact when copying it to |
| 192 | +`artifacts_dir`. They will be located in |
| 193 | +`$SCRATCH_DIR/<mapping for source_dir>/build/distributions/lambda-build`. |
| 194 | + |
| 195 | +[path resolver]: https://github.com/awslabs/aws-lambda-builders/pull/55 |
0 commit comments