Skip to content

Commit ad80bfb

Browse files
committed
Merge branch 'topic/Add-support-for-arm64-lambda-functions-and-docker-buildkit' into 'master'
Improve support for Docker based Lambda functions See merge request it/e3-aws!105
2 parents 2c51751 + b8b1cd6 commit ad80bfb

File tree

4 files changed

+223
-72
lines changed

4 files changed

+223
-72
lines changed

pyproject.toml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ description = "E3 Cloud Formation Extensions"
1010
dependencies = [
1111
"boto3",
1212
"botocore",
13-
# Only require docker for linux as there is a known dependency issue
14-
# with pywin32 on windows
15-
"docker; platform_system=='Linux'",
1613
"e3-core",
1714
"pyyaml",
15+
"python-on-whales",
1816
"troposphere"
1917
]
2018

@@ -91,7 +89,6 @@ module = [
9189
"botocore.*",
9290
"boto3.*",
9391
"requests.*",
94-
"docker.*",
9592
"troposphere.*",
9693
]
9794
ignore_missing_imports = true

src/e3/aws/troposphere/awslambda/__init__.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3-
from datetime import datetime
3+
from datetime import UTC, datetime
4+
from enum import Enum
45
import logging
56
import os
67
import sys
@@ -34,10 +35,23 @@
3435
from troposphere import AWSObject
3536
import botocore.client
3637
from e3.aws.troposphere import Stack
38+
from python_on_whales import DockerClient
3739

3840
logger = logging.getLogger("e3.aws.troposphere.awslambda")
3941

4042

43+
class Architecture(Enum):
44+
X86_64 = "x86_64"
45+
ARM64 = "arm64"
46+
47+
48+
class UnknownPlatform(Exception):
49+
"""Unknown platform exception."""
50+
51+
def __init__(self, architecture: Architecture):
52+
super().__init__(f"Unknown platform for architecture {architecture}.")
53+
54+
4155
def package_pyfunction_code(
4256
filename: str,
4357
/,
@@ -263,6 +277,7 @@ def __init__(
263277
code_version: int | None = None,
264278
timeout: int = 3,
265279
runtime: str | None = None,
280+
architecture: Architecture | None = None,
266281
memory_size: int | None = None,
267282
ephemeral_storage_size: int | None = None,
268283
logs_retention_in_days: int | None = 731,
@@ -288,6 +303,7 @@ def __init__(
288303
:param code_version: code version
289304
:param timeout: maximum execution time (default: 3s)
290305
:param runtime: runtime to use
306+
:param architecture: x86_64 or arm64. (default: x86_64)
291307
:param memory_size: the amount of memory available to the function at
292308
runtime. The value can be any multiple of 1 MB.
293309
:param ephemeral_storage_size: The size of the function’s /tmp directory
@@ -317,6 +333,7 @@ def __init__(
317333
self.runtime = runtime
318334
self.role = role
319335
self.handler = handler
336+
self.architecture = architecture
320337
self.memory_size = memory_size
321338
self.ephemeral_storage_size = ephemeral_storage_size
322339
self.logs_retention_in_days = logs_retention_in_days
@@ -450,6 +467,9 @@ def lambda_resources(
450467
if self.handler is not None:
451468
params["Handler"] = self.handler
452469

470+
if self.architecture is not None:
471+
params["Architectures"] = [self.architecture.value]
472+
453473
if self.memory_size is not None:
454474
params["MemorySize"] = self.memory_size
455475

@@ -592,9 +612,14 @@ def __init__(
592612
repository_name: str,
593613
image_tag: str,
594614
timeout: int = 3,
615+
architecture: Architecture | None = None,
595616
memory_size: int | None = None,
617+
logs_retention_in_days: int | None = 731,
618+
environment: dict[str, str] | None = None,
596619
logging_config: awslambda.LoggingConfig | None = None,
597620
dl_config: awslambda.DeadLetterConfig | None = None,
621+
docker_client: DockerClient | None = None,
622+
**build_args: Any,
598623
):
599624
"""Initialize an AWS lambda function using a Docker image.
600625
@@ -605,26 +630,48 @@ def __init__(
605630
:param repository_name: ECR repository name
606631
:param image_tag: docker image version
607632
:param timeout: maximum execution time (default: 3s)
633+
:param architecture: x86_64 or arm64. (default: x86_64)
608634
:param memory_size: the amount of memory available to the function at
609635
runtime. The value can be any multiple of 1 MB.
636+
:param logs_retention_in_days: The number of days to retain the log
637+
events in the lambda log group
638+
:param environment: Environment variables that are accessible from
639+
function code during execution
610640
:param logging_config: The function's Amazon CloudWatch Logs settings
611-
:param dl_config: The dead letter config that specifies the topic or queue where
612-
lambda sends asynchronous events when they fail processing
641+
:param dl_config: The dead letter config that specifies the topic or
642+
queue where lambda sends asynchronous events when they fail processing
643+
:param docker_client: Docker client to use for building and pushing.
644+
This is here in case the user wants to customize the Docker client,
645+
for example to use podman.
646+
:param build_args: args to pass to docker build
613647
"""
614648
super().__init__(
615649
name=name,
616650
description=description,
617651
role=role,
618652
timeout=timeout,
653+
architecture=architecture,
619654
memory_size=memory_size,
655+
logs_retention_in_days=logs_retention_in_days,
656+
environment=environment,
620657
logging_config=logging_config,
621658
dl_config=dl_config,
622659
)
623660
self.source_dir: str = source_dir
624661
self.repository_name: str = repository_name
625-
timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H-%M-%S-%f")
662+
timestamp = datetime.now(UTC).strftime("%Y-%m-%d-%H-%M-%S-%f")
626663
self.image_tag: str = f"{image_tag}-{timestamp}"
627664
self.image_uri: str | None = None
665+
self.docker_client = docker_client
666+
self.build_args = build_args
667+
if "platforms" not in self.build_args:
668+
match self.architecture:
669+
case Architecture.ARM64:
670+
self.build_args["platforms"] = ["linux/arm64"]
671+
case Architecture.X86_64 | None:
672+
self.build_args["platforms"] = ["linux/amd64"]
673+
case _:
674+
raise UnknownPlatform(self.architecture)
628675

629676
def resources(self, stack: Stack) -> list[AWSObject]:
630677
"""Compute AWS resources for the construct.
@@ -641,6 +688,9 @@ def resources(self, stack: Stack) -> list[AWSObject]:
641688
self.repository_name,
642689
self.image_tag,
643690
stack.deploy_session,
691+
push=True,
692+
docker_client=self.docker_client,
693+
**self.build_args,
644694
)
645695

646696
return self.lambda_resources(image_uri=self.image_uri)

src/e3/aws/util/ecr.py

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,64 +5,89 @@
55
import base64
66
import logging
77
import tempfile
8-
from typing import TYPE_CHECKING
8+
from collections.abc import Iterator
9+
from typing import TYPE_CHECKING, Any
910

10-
import docker
11-
from e3.fs import sync_tree, rm
11+
from e3.fs import sync_tree
12+
from python_on_whales import DockerClient
1213

1314
if TYPE_CHECKING:
1415
from e3.aws import Session
1516

1617
logger = logging.getLogger("e3.aws.utils.ecr")
1718

1819

20+
def get_ecr_credentials(session: Session) -> tuple[str, str, str]:
21+
"""Get ECR credentials (username, password, registry URL).
22+
23+
:param session: AWS session to get ECR credentials
24+
:return: tuple of (username, password, registry URL)
25+
"""
26+
ecr_client = session.client("ecr")
27+
ecr_creds = ecr_client.get_authorization_token()["authorizationData"][0]
28+
ecr_username, ecr_password = (
29+
base64.b64decode(ecr_creds["authorizationToken"]).decode().split(":", 1)
30+
)
31+
ecr_url = ecr_creds["proxyEndpoint"]
32+
return ecr_username, ecr_password, ecr_url
33+
34+
1935
def build_and_push_image(
20-
source_dir: str, repository_name: str, image_tag: str, session: Session
36+
source_dir: str,
37+
repository_name: str,
38+
image_tag: str,
39+
session: Session,
40+
docker_client: DockerClient | None = None,
41+
**build_args: Any,
2142
) -> str:
2243
"""Build and push image to an ECR repository and return image URI.
2344
2445
:param source_dir: directory where to find Dockerfile
2546
:param repository_name: ECR repository name
2647
:param image_tag: Docker image tag
2748
:param session: AWS session to push docker image to ECR
49+
:param build_args: Keyword arguments to pass to ``docker_client.build``.
50+
See the ``python-on-whales`` documentation for a complete list of
51+
possible arguments. Note that this function sets default values for
52+
some arguments if they are not provided: ``push=True``,
53+
``progress="plain"``, ``stream_logs=True``, and ``provenance=False``.
54+
:param docker_client: Docker client to use for building and pushing.
55+
This is here in case the user wants to customize the Docker client,
56+
for example to use podman.
57+
:raises DockerException: if there is an error building or pushing the image
58+
:return: image URI
2859
"""
29-
temp_dir = tempfile.mkdtemp()
30-
try:
31-
sync_tree(source_dir, temp_dir)
32-
33-
docker_client = docker.from_env()
34-
# Build image
35-
image, build_log = docker_client.images.build(
36-
path=temp_dir, tag=f"{repository_name}:{image_tag}", rm=True
37-
)
38-
logging.info(f"Building Docker image from {source_dir}")
39-
for chunk in build_log:
40-
if "stream" in chunk:
41-
for line in chunk["stream"].splitlines():
42-
logging.debug(line)
60+
# Create a Docker client and login to ECR
61+
if docker_client is None:
62+
docker_client = DockerClient()
63+
ecr_username, ecr_password, ecr_url = get_ecr_credentials(session)
64+
docker_client.login(username=ecr_username, password=ecr_password, server=ecr_url)
65+
ecr_repo_name = f"{ecr_url.replace('https://', '')}/{repository_name}"
66+
image_uri = f"{ecr_repo_name}:{image_tag}"
4367

44-
# Push image to registry
45-
ecr_client = session.client("ecr")
46-
ecr_creds = ecr_client.get_authorization_token()["authorizationData"][0]
47-
ecr_username, ecr_password = (
48-
base64.b64decode(ecr_creds["authorizationToken"])
49-
.decode("utf-8")
50-
.split(":", 1)
51-
)
52-
ecr_url = ecr_creds["proxyEndpoint"]
53-
docker_client.login(
54-
username=ecr_username, password=ecr_password, registry=ecr_url
55-
)
56-
ecr_repo_name = f"{ecr_url.replace('https://', '')}/{repository_name}"
57-
image.tag(ecr_repo_name, tag=image_tag)
58-
59-
push_log = docker_client.images.push(ecr_repo_name, tag=image_tag)
60-
logging.info(f"Pushing Docker image to {ecr_repo_name} with tag {image_tag}")
61-
logging.debug(push_log)
68+
with tempfile.TemporaryDirectory() as temp_dir:
69+
sync_tree(source_dir, temp_dir)
6270

63-
image_uri = f"{ecr_repo_name}:{image_tag}"
71+
# Set various build args, unless the user has already specified a value
72+
# Disable provenance to avoid manifest list issues with AWS Lambda
73+
# See: https://github.com/docker/buildx/issues/1533
74+
defaults: dict[str, Any] = {
75+
"push": True,
76+
"context_path": temp_dir,
77+
"tags": [image_uri],
78+
"progress": "plain",
79+
"stream_logs": True,
80+
"provenance": False,
81+
}
82+
build_args = defaults | build_args
6483

65-
finally:
66-
rm(temp_dir, recursive=True)
84+
# Build and push image
85+
logger.info(f"Building Docker image from {source_dir}")
86+
logger.debug(f"Build args {build_args}")
87+
build_result = docker_client.build(**build_args)
88+
if isinstance(build_result, Iterator):
89+
# stream_logs=True, causes docker_client.build to return an iterator
90+
for log_text in build_result:
91+
logger.debug(log_text)
6792

6893
return image_uri

0 commit comments

Comments
 (0)