Cloud Data Ingestion: A Step-by-Step Guide
Hands-on example: Ingesting parquet data to Google Cloud
This is my tech journal tracking a data engineering course with DataTalksClub: https://github.com/rasogltra/de-zoomcamp.git.
Prerequisites
Install Docker (https://www.docker.com)
Install Python 3.9+
Install Terraform
Google Cloud Account
Install
gcloud CLIDataset: https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page
Docker Introduction
Docker is a platform that packages applications and their dependencies into portable containers, ensuring consistent performance across environments. Docker images are created from a Docker file, and when run, they generate a container.
RUN A BASIC DOCKER IMAGE
docker run -it --entrypoint=bash python:3.9
This opens an interactive Bash shell inside a new container on the python:3.9 image. From here, we can start installing packages or running commands sans the python interpreter.
docker run this tells docker to start a new container.
-it interactive terminal so you can interact with the container.
--entrypoint=bash runs Bash shell
python:3.9 official docker python image with version 3.9
However, installing a base image or additional resources in a container doesn't persist; any changes made during runtime are lost when the container shuts down.
Custom Pipeline with Docker
Alternatively, instead of modifying the container manually, we write a Dockerfile which is a text file with instructions to build the image, define the environment and dependencies needed for a container.
In this example, we created a docker file using a python base image. The data pipeline is copied to the container and executed.
DOCKERFILE
FROM python:3.9
RUN pip install pandas
WORKDIR /app
COPY pipeline_Source.py pipeline_Dest.py
ENTRYPOINT["python", "pipeline.py"]
FROM specifies the base image for the container, which in our case is Python 3.9.
RUN runs a command within the container. We installed panda library within the container.
WORKDIR sets the working directory.
COPY copies file from local machine into the working directory.
ENTRYPOINT defines the command that should be executed when the container starts.
As an example, we write a simple python script that takes arguments and prints them out.
# pipeline.py
import sys
import pandas as pd
print(sys.argv)
day = sys.argv[1]
# some fancy stuff w/ pandas
print(f'job finished successfully for day = {day}')
BUILD AND RUN THE CONTAINER
# Make sure you're in the same folder as the Dockerfile and pipeline.py,
# or specify the path using the -f flag.
# build the image
docker build -t test:pandas .
# run the container with args passsed
docker run -it test:pandas 2025-04-30
# output
['pipeline.py', '2025-04-30']
job finished successfully for day = 2025-04-30
Connect Postgres via PgAdmin
PostgreSQL is an open-source relational database management system, while pgAdmin is a GUI for managing PostgreSQL databases. pgAdmin can be downloaded on their official site (https://www.pgadmin.org/download/pgadmin-4-container/).
RUN POSTGRES WITH DOCKER
# creates postgres container
docker run -it \
-e POSTGRES_USER="user" \
-e POSTGRES_PASSWORD="passwrd" \
-e POSTGRES_DB="ny_taxi" \
-v $(pwd)/ny_taxi_postgres_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:13
# creates pgadmin container
docker run -it \
-e PGADMIN_DEFAULT_EMAIL="email@email.com" \
-e PGADMIN_DEFAULT_PASSWORD="root" \
-p 8080:80 \
dpage/pgadmin4
docker run -it starts interactive container.
-e POSTGRES_USER="user" sets the database username.
-e POSTGRES_PASSWORD="password" sets the database password.
-e POSTGRES_DB="ny_taxi" creates a database when the container starts.
-v $(pwd)/ny_taxi_postgres_data:/var/lib/postgresql/data mounts a volume from local machine to store database data. This persists the data even if you stop or delete the container.
-p 5432:5432 maps port from your machine to container
postgres:13 official PostgreSQL 13 image
After starting pgAdmin with the run command, we can visit http://localhost:8080 to open the pgAdmin interface. However, pgAdmin and Postgres run in seperate containers and don’t automatically know about each other. To let them communicate, we need to connect them using a Docker network.
DOCKER CREATE NETWORK
docker network create pg-network
docker run -it \
-e POSTGRES_USER="root" \
-e POSTGRES_PASSWORD="root" \
-e POSTGRES_DB="ny_taxi" \
-v $(pwd)/ny_taxi_postgres_data:/var/lib/postgresql/data \
-p 5432:5432 \
--network=pg-network \
--name pg-database \
postgres:13
docker run -it \
-e PGADMIN_DEFAULT_EMAIL="admin@admin.com" \
-e PGADMIN_DEFAULT_PASSWORD="root" \
-p 8080:80 \
--network=pg-network \
--name pgadmin \
dpage/pgadmin4
8080:80 host machine port where pgADmin is listening.
network network connecting the containers.
name defines the name for postgres and pgadmin.
You should be able to access pgAdmin interface via http://localhost:8080 using credentials.
ACCESS THE DATABASE
We registered a new server in pgAdmin “localhost“ that connects to “pg-database” by entering the database details: hostname, port, username, and password.
Connect Postgres via pgCLI
Using a CLI client, we can connect to the Postgres container and access the database.
pip install pgcli
pgcli -h localhost -p 5432 -u root -d ny_taxi
BASIC PGCLI COMMANDS
\l+ list all databases on that server
\d+ list all tables
\d <table_name> table details
Dockerizing Ingestion Script
LOAD DATA TO DATABASE WITH JUPYTER
Before we dockerize the script, we’re going to load the data via a python script using a similar structure to pipeline.py. In this example, we wrote a Jupyter notebook script that uploads yellow taxi data to the database. The course used .csv downloaded file, but in my case I downloaded the .parquet version of the dataset.
We used Jupyter notebook to convert ingest_ny_taxi_data.ipynb to a ingest_ny_taxi_data.py.
jupyter nbconvert --to=script {notebook.ipynb}
INGEST_NY_TAXI_DATA.PY
In the parser, we parse the command line arguments which is passed to the main method. There are several methods to do this and ingest data into the database.
# ingest_ny_taxi_data.py
import pandas as pd
import pyarrow
from sqlalchemy import create_engine
import argparse
import sys
from time import time
def main(params):
user = params.user
password = params.password
host = params.host
port = params.port
database = params.database
url = params.url
table_name = params.table_name
# Reading parquet and converting to csv
csv_name = 'output.csv'
df = pd.read_parquet(url, engine='pyarrow')
df.to_csv(csv_name, index= False)
# Establish connection
engine = create_engine(f'postgresql://{user}:{password}@{host}:{port}/{database}')
engine.connect()
print("Connected to pgdatabase sucessfully.")
# creates tbl schema for first chunk. imports the schema (was converted for postgres)
print(pd.io.sql.get_schema(df, name= table_name, con=engine))
# read csv in chunks
df_iter = pd.read_csv(csv_name, iterator=True, chunksize=100000)
# inserts first chunk, column names to the database
df = next(df_iter)
df.head(n=0).to_sql(name= table_name, con=engine, if_exists='replace')
while True:
try:
t_start = time()
# use iterated df, to process each chunk and insert data into database
df = next(df_iter)
df.to_sql(name= table_name, con=engine, if_exists='append')
t_end= time()
# note: %.3f means 3 decimal float
print('insert another chunk..., took %.3f second ' %(t_end - t_start))
except StopIteration:
print("All data chunks processed.")
break
# parse the cmd line args which are passed to main
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Ingest CSV data to Postgres')
parser.add_argument('--user', help='user name for postgres')
parser.add_argument('--password', help='password for postgres')
parser.add_argument('--host', help='host for postgres')
parser.add_argument('--port', help='port for postgres')
parser.add_argument('--database', help='database for postgres')
parser.add_argument('--url', help='url of the csv file')
parser.add_argument('--table_name', help='name of the table where we will write the results to')
args = parser.parse_args()
print("Arguments received:", sys.argv)
main(args)
METHOD 1: INGEST DATA VIA PYTHON COMMAND
The parser is ran from the command line. Notice we passed the dataset file to a URL variable. The database variables remain the same, as we defined earlier in the course.
URL="https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet"
python3 ingest_ny_taxi_data.py \
--user=root \
--password=root \
--host=localhost \
--port=5432 \
--database=ny_taxi \
--url=${URL} \
--table_name=yellow_taxi_data
Navigate to http://localhost:8080/ to confirm the success. However, this above method is not recommended as it involves passing credentials.
METHOD 2: CREATE A DOCKER CONTAINER TO INGEST DATA
Again, we ingest the data but in a docker container produced by a docker file. Notice, the same dependencies we installed within the script is installed within the docker file. This way makes the application reproducible and portable on any platform.
# method 2: Ingest data using a dockerized python script (Recommended).
FROM python:latest
RUN pip install pandas sqlalchemy psycopg2-binary pyarrow
WORKDIR /app
COPY ingest_ny_taxi_data.py ingest_ny_taxi_data.py
ENTRYPOINT [ "python", "ingest_ny_taxi_data.py" ]
BUILD AND RUN INGESTION SCRIPT
Before building and running the script, a few ducks need to be lined:
Ensure pgAdmin and Postgres containers are on a shared network:
pg-network.Create the taxi_ingest container on the same network as the pgAdmin and Postgres containers.
ingest_ny_taxi_data.pywill be executed in thetaxi_ingest:v001container and the data files will be downloaded there.
BUILD IMAGE AND CREATE THE CONTAINER
docker build -t taxi_ingest:v001 .
URL="https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet"
docker run -it \
--network=pg-network \
taxi_ingest:v001 \
--user=root \
--password=root \
--host=pg-database \
--port=5432 \
--db=ny_taxi \
--table_name=yellow_taxi_trips \
--url=${URL}
Navigate to http://localhost:8080/ to confirm the success.
Running Postgres and pgAdmin with Docker-Compose
Alternatively, if we want to run multiple containers at once—like we did with pgAdmin and Postgres—Docker Compose is a tool that makes it easier. Instead of starting each container one by one, we can use a simple YAML file to describe them, and then start everything with just one command.
DOCKER-COMPOSE.YAML
services:
pgdatabase:
image: postgres:13
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=root
- POSTGRES_DB=ny_taxi
volumes:
- "./ny_taxi_postgres_data:/var/lib/postgresql/data:rw"
ports:
- "5432:5432"
pgadmin:
image: dpage/pgadmin4
environment:
- PGADMIN_DEFAULT_EMAIL=admin@admin.com
- PGADMIN_DEFAULT_PASSWORD=root
ports:
- "8080:80"
Containers defined within the YML are automatically created within the same network so you don’t need to define the network. Containers are defined as a service in the file.
DOCKER COMPOSE COMMANDS
docker-compose up execute the yaml file and start the services.
docker-compose up -d execute in detached mode.
docker-compose down shutdown services and remove the containers.
Terraform Introduction
Terraform is a configuration management tool that allows you to define and manage infrastructure resources using code. It automates provisioning and managing infrastructure resources, such as VMs, networks and storage.
BASIC TERRAFORM COMMANDS
init initialize a working directory containing Terraform config files
plan show changes Terraform will make
apply apply the changes
destroy destroy all resources Terraform created
INSTALL TERRAFORM
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
GOOGLE CLOUD (GCP) SERVICE ACOUNT SETUP
Because we want to use Terraform with GCP, we created a service account within the GCP console. A service account is similar to a user account, except the account is managed by the project and represents a non-human identity instead of a real person.
We created an account terraform-runner, assigned it Storage Admin, BigQuery Admin and Compute Admin roles. We also created and added a JSON key file under Manage keys within the GCP console. The key file is automatically downloaded to machine.
Alternatively, I learned outside the course, that we can create a JSON key for a service account via gcloud CLI. Note, ensure gcloud is installed and authenticated.
gcloud auth login
gcloud config set project [my_google_project_id]
gcloud iam service-accounts keys create my-gcp-key.json \
--iam-account=terraform-runner@[my_google_project_id].iam.gserviceaccount.com
where [my_google_project_id] is your Google Cloud project ID. eg. my-project-id-123
AUTHENTICATE THE TERRAFORM APPLICATION
We set the environment variable GOOGLE_APPLICATION_CREDENTIALS so that Terraform can authenticate our service account. If ran on command line, the variable will not persist so I added the variable to my .bashrc profile. To apply the change without a terminal restart, I used source ~/.bashrc.
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/my-gcp-key.json"
Next, the command below, authenticates my account and sets up credentials for my local development. It stores my credentials locally so Terraform can use them automatically.
gcloud auth application-default login
TERRAFROM GOOGLE PROVIDER CONFIGURATION: MAIN.TF
On Terraform’s Providers page, we copied the google provider block for our main.tf file and configure our google cloud project. I learned that credentials can be omitted because we have already set the GOOGLE_APPLICATION_CREDENTIALS environment variable and buckets is a cloud storage that can store various types of data.
For our example, we’re configuring Terraform to manage our GCP bucket eg. my_taxi_bucket and BigQuery dataset.
MAIN.TF
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "5.11.0"
}
}
}
provider "google" {
project = "terraform-demo"
region = "us-central1"
credentials = file("/path/to/your/my-app-key.json") # Optional if using env variable
}
resource "google_storage_bucket" "my_taxi_bucket" {
name = "terraform-bucket-123"
location = "US"
force_destroy = true
lifecycle_rule {
condition {
age = 1
}
action {
type = "AbortIncompleteMultipartUpload"
}
}
}
resource "google_bigquery_dataset" "terraform_dataset" {
dataset_id = "taxi_dataset"
location= "US"
}
USING .GITIGNORE AND VARIABLES.TF
We should never commit JSON key or hardcode a Terraform configuration file so we added .gitignore to the Terraform folder. I copied a .gitignore file directly from this gitignore repo before uploading any of this to my GitHub. I also created a variables.tf file, defining the variables we used in the main.tf file.
As an example, I used a credentials variable within my variables.tf.
# creating credentials variable in variables.tf
variable "credentials" {
description = "My Credentials"
default = "/path/to/your/my-gcp-key.json"
}
# using the credentials variable in main.tf
provider "google" {
credentials = file(var.credentials)
project = var.project
region = var.region
}
INITIALIZE, PLAN, APPLY AND DESTROY
On the command line, we run the terraform basic commands (init, plan, apply, destroy) to retrieve the google provider and connect me to GCP. If initialized correctly, a .terraform folder containing subdirectories and initialization files are created, as well as, .terraform.lock.hcl folder which is a locked file of provider plugins as hash code.
Next, we terraform plan and then deploy the resources with terraform apply. It then creates a terraform.tfstate file, a file that tracks our created resources and finally our bucket in the GCP console. We should be able to navigate to GCP, view the bucket and BigQuery dataset.
Lastly, I ran terraform destroy to tear down and rid of the resources. Terraform will look at the state file and check what changed are needed to do so.